6a82b88947
agentws manages per-branch git worktrees for the unkin-agent user: it clones repos into the source root (~/src/prodenv/<repo>) so branches are visible in Ben's main checkout, and creates isolated worktrees under the worktree root (~/.cache/agentws/<repo>__<branch>). - New internal/agent/git.go: small, testable git helpers shelling out to the git binary (clone/fetch/worktree add/remove/list/prune, branch + config ops, porcelain parsing, path sanitizing). No go-git dependency. - New cmd/agentws: new / list / rm / clean / token / credential subcommands. Auth uses an ephemeral git credential helper (agentws credential get) so the ~1h Gitea token is never persisted in a remote URL or config; per-worktree config keeps the shared checkout's identity untouched. - Wire agentws into Makefile, scripts/build-rpm.sh, packaging/nfpm.yaml (binary + bash/zsh/fish completions), .woodpecker/release.yaml (cross-compile + assets) and .gitignore. - Tests: table tests for parsing/sanitizing/dir-naming, a real temp-git repo for the worktree lifecycle, and hermetic cmd tests (bad input + credential-helper host guard) that never touch the network. - Document agentws in README.md and AGENTS.md.
231 lines
6.6 KiB
Go
231 lines
6.6 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ensureDir creates dir (and parents) if it does not already exist.
|
|
func ensureDir(dir string) error {
|
|
if dir == "" {
|
|
return nil
|
|
}
|
|
return os.MkdirAll(dir, 0o755)
|
|
}
|
|
|
|
// Worktree is one entry from `git worktree list --porcelain`.
|
|
type Worktree struct {
|
|
Path string
|
|
Head string
|
|
Branch string // short branch name ("" when detached or bare)
|
|
Bare bool
|
|
Detached bool
|
|
}
|
|
|
|
// runGit runs git with args, using dir as the working directory (empty = the
|
|
// process cwd). It returns trimmed stdout, or an error that includes stderr so
|
|
// failures like "branch already checked out" surface verbatim.
|
|
func runGit(dir string, args ...string) (string, error) {
|
|
cmd := exec.Command("git", args...)
|
|
if dir != "" {
|
|
cmd.Dir = dir
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = strings.TrimSpace(stdout.String())
|
|
}
|
|
return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, msg)
|
|
}
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|
|
|
|
// GitClone clones url into dir. Any globalArgs (e.g. "-c",
|
|
// "credential.helper=...") are passed before the clone subcommand so auth can be
|
|
// supplied without persisting it in the resulting checkout's config.
|
|
func GitClone(url, dir string, globalArgs ...string) error {
|
|
if err := ensureDir(filepath.Dir(dir)); err != nil {
|
|
return err
|
|
}
|
|
args := append(append([]string{}, globalArgs...), "clone", url, dir)
|
|
_, err := runGit(filepath.Dir(dir), args...)
|
|
return err
|
|
}
|
|
|
|
// GitFetch runs `git fetch <remote>` in repoDir. globalArgs are passed before
|
|
// the subcommand (used to inject an ephemeral credential helper).
|
|
func GitFetch(repoDir, remote string, globalArgs ...string) error {
|
|
args := append(append([]string{}, globalArgs...), "fetch", remote)
|
|
_, err := runGit(repoDir, args...)
|
|
return err
|
|
}
|
|
|
|
// GitRemoteDefaultBranch returns the short name of remote's default branch
|
|
// (e.g. "main") by resolving refs/remotes/<remote>/HEAD.
|
|
func GitRemoteDefaultBranch(repoDir, remote string) (string, error) {
|
|
out, err := runGit(repoDir, "rev-parse", "--abbrev-ref", remote+"/HEAD")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimPrefix(out, remote+"/"), nil
|
|
}
|
|
|
|
// GitBranchExists reports whether a local branch exists.
|
|
func GitBranchExists(repoDir, branch string) bool {
|
|
_, err := runGit(repoDir, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
|
|
return err == nil
|
|
}
|
|
|
|
// GitWorktreeAdd adds a worktree at path checked out to branch. When the branch
|
|
// already exists it is reused; otherwise it is created from startPoint.
|
|
func GitWorktreeAdd(repoDir, path, branch, startPoint string) error {
|
|
if err := ensureDir(filepath.Dir(path)); err != nil {
|
|
return err
|
|
}
|
|
var args []string
|
|
if GitBranchExists(repoDir, branch) {
|
|
args = []string{"worktree", "add", path, branch}
|
|
} else {
|
|
args = []string{"worktree", "add", path, "-b", branch, startPoint}
|
|
}
|
|
_, err := runGit(repoDir, args...)
|
|
return err
|
|
}
|
|
|
|
// GitWorktreeRemove removes the worktree at path (force skips the dirty check).
|
|
func GitWorktreeRemove(repoDir, path string, force bool) error {
|
|
args := []string{"worktree", "remove", path}
|
|
if force {
|
|
args = append(args, "--force")
|
|
}
|
|
_, err := runGit(repoDir, args...)
|
|
return err
|
|
}
|
|
|
|
// GitWorktreePrune prunes stale worktree administrative entries.
|
|
func GitWorktreePrune(repoDir string) error {
|
|
_, err := runGit(repoDir, "worktree", "prune")
|
|
return err
|
|
}
|
|
|
|
// GitWorktreeList returns the worktrees registered for repoDir.
|
|
func GitWorktreeList(repoDir string) ([]Worktree, error) {
|
|
out, err := runGit(repoDir, "worktree", "list", "--porcelain")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseWorktreeList(out), nil
|
|
}
|
|
|
|
// GitDeleteBranch deletes a local branch (force uses -D).
|
|
func GitDeleteBranch(repoDir, branch string, force bool) error {
|
|
flag := "-d"
|
|
if force {
|
|
flag = "-D"
|
|
}
|
|
_, err := runGit(repoDir, "branch", flag, branch)
|
|
return err
|
|
}
|
|
|
|
// GitConfigSet sets a config key in repoDir. When worktree is true the value is
|
|
// written to the per-worktree config (extensions.worktreeConfig must be enabled)
|
|
// so it does not touch the shared checkout's config.
|
|
func GitConfigSet(repoDir string, worktree bool, key, value string) error {
|
|
args := []string{"config"}
|
|
if worktree {
|
|
args = append(args, "--worktree")
|
|
}
|
|
args = append(args, key, value)
|
|
_, err := runGit(repoDir, args...)
|
|
return err
|
|
}
|
|
|
|
// GitCommonDir returns the absolute path to the shared .git directory for the
|
|
// checkout at dir (a worktree's common dir points back at its source repo).
|
|
func GitCommonDir(dir string) (string, error) {
|
|
out, err := runGit(dir, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GitCurrentBranch returns the short branch name checked out at dir.
|
|
func GitCurrentBranch(dir string) (string, error) {
|
|
return runGit(dir, "rev-parse", "--abbrev-ref", "HEAD")
|
|
}
|
|
|
|
// SourceRepoDir maps a worktree checkout to its source repo directory by walking
|
|
// from the shared .git common dir up to the repo root.
|
|
func SourceRepoDir(worktreeDir string) (string, error) {
|
|
common, err := GitCommonDir(worktreeDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// common is ".../<repo>/.git"; the repo dir is its parent.
|
|
return filepath.Dir(common), nil
|
|
}
|
|
|
|
// ParseWorktreeList parses the output of `git worktree list --porcelain`.
|
|
func ParseWorktreeList(out string) []Worktree {
|
|
var wts []Worktree
|
|
var cur *Worktree
|
|
flush := func() {
|
|
if cur != nil {
|
|
wts = append(wts, *cur)
|
|
cur = nil
|
|
}
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimRight(line, "\r")
|
|
if line == "" {
|
|
flush()
|
|
continue
|
|
}
|
|
key, val, _ := strings.Cut(line, " ")
|
|
switch key {
|
|
case "worktree":
|
|
flush()
|
|
cur = &Worktree{Path: val}
|
|
case "HEAD":
|
|
if cur != nil {
|
|
cur.Head = val
|
|
}
|
|
case "branch":
|
|
if cur != nil {
|
|
cur.Branch = strings.TrimPrefix(val, "refs/heads/")
|
|
}
|
|
case "bare":
|
|
if cur != nil {
|
|
cur.Bare = true
|
|
}
|
|
case "detached":
|
|
if cur != nil {
|
|
cur.Detached = true
|
|
}
|
|
}
|
|
}
|
|
flush()
|
|
return wts
|
|
}
|
|
|
|
// SanitizeBranch turns a branch name into a filesystem-safe path segment by
|
|
// replacing separators that would otherwise create nested directories.
|
|
func SanitizeBranch(branch string) string {
|
|
r := strings.NewReplacer("/", "-", "\\", "-", ":", "-", " ", "-")
|
|
return r.Replace(strings.TrimSpace(branch))
|
|
}
|
|
|
|
// WorktreeDirName is the directory name (under the worktree root) for a repo's
|
|
// branch worktree: "<repo>__<sanitized-branch>".
|
|
func WorktreeDirName(repo, branch string) string {
|
|
return repo + "__" + SanitizeBranch(branch)
|
|
}
|