Add agentws worktree-management binary
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.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeBranch(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"benvin/agentws", "benvin-agentws"},
|
||||
{"main", "main"},
|
||||
{" feature/x ", "feature-x"},
|
||||
{"a/b/c", "a-b-c"},
|
||||
{"ns:thing", "ns-thing"},
|
||||
{"with space", "with-space"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := SanitizeBranch(tt.in); got != tt.want {
|
||||
t.Errorf("SanitizeBranch(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorktreeDirName(t *testing.T) {
|
||||
if got := WorktreeDirName("argocd-apps", "benvin/foo"); got != "argocd-apps__benvin-foo" {
|
||||
t.Errorf("WorktreeDirName = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWorktreeList(t *testing.T) {
|
||||
out := `worktree /home/ben/src/prodenv/repo
|
||||
HEAD 1111111111111111111111111111111111111111
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /home/ben/.cache/agentws/repo__benvin-foo
|
||||
HEAD 2222222222222222222222222222222222222222
|
||||
branch refs/heads/benvin/foo
|
||||
|
||||
worktree /home/ben/.cache/agentws/repo__detached
|
||||
HEAD 3333333333333333333333333333333333333333
|
||||
detached
|
||||
`
|
||||
wts := ParseWorktreeList(out)
|
||||
if len(wts) != 3 {
|
||||
t.Fatalf("got %d worktrees, want 3: %+v", len(wts), wts)
|
||||
}
|
||||
if wts[0].Branch != "main" || wts[0].Path != "/home/ben/src/prodenv/repo" {
|
||||
t.Errorf("wt[0] = %+v", wts[0])
|
||||
}
|
||||
if wts[1].Branch != "benvin/foo" {
|
||||
t.Errorf("wt[1].Branch = %q, want benvin/foo", wts[1].Branch)
|
||||
}
|
||||
if !wts[2].Detached || wts[2].Branch != "" {
|
||||
t.Errorf("wt[2] = %+v, want detached with empty branch", wts[2])
|
||||
}
|
||||
}
|
||||
|
||||
// gitSeed sets a repo-local identity so commits work without global config.
|
||||
func gitIdentity(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := GitConfigSet(dir, false, "user.email", "test@example.com"); err != nil {
|
||||
t.Fatalf("set user.email: %v", err)
|
||||
}
|
||||
if err := GitConfigSet(dir, false, "user.name", "Test"); err != nil {
|
||||
t.Fatalf("set user.name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newTempRepos builds a bare "origin" with one commit on main and clones it into
|
||||
// srcDir (so refs/remotes/origin/HEAD is set), returning the source checkout.
|
||||
func newTempRepos(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
bare := filepath.Join(root, "origin.git")
|
||||
if _, err := runGit(root, "init", "--bare", "-b", "main", bare); err != nil {
|
||||
t.Fatalf("init bare: %v", err)
|
||||
}
|
||||
|
||||
seed := filepath.Join(root, "seed")
|
||||
if _, err := runGit(root, "init", "-b", "main", seed); err != nil {
|
||||
t.Fatalf("init seed: %v", err)
|
||||
}
|
||||
gitIdentity(t, seed)
|
||||
if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("hi\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := runGit(seed, "add", "."); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if _, err := runGit(seed, "commit", "-m", "init"); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
if _, err := runGit(seed, "remote", "add", "origin", bare); err != nil {
|
||||
t.Fatalf("remote add: %v", err)
|
||||
}
|
||||
if _, err := runGit(seed, "push", "-u", "origin", "main"); err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
|
||||
srcDir := filepath.Join(root, "src")
|
||||
if err := GitClone(bare, srcDir); err != nil {
|
||||
t.Fatalf("clone: %v", err)
|
||||
}
|
||||
gitIdentity(t, srcDir)
|
||||
return srcDir
|
||||
}
|
||||
|
||||
func TestGitWorktreeLifecycle(t *testing.T) {
|
||||
srcDir := newTempRepos(t)
|
||||
|
||||
def, err := GitRemoteDefaultBranch(srcDir, "origin")
|
||||
if err != nil {
|
||||
t.Fatalf("GitRemoteDefaultBranch: %v", err)
|
||||
}
|
||||
if def != "main" {
|
||||
t.Errorf("default branch = %q, want main", def)
|
||||
}
|
||||
|
||||
if err := GitFetch(srcDir, "origin"); err != nil {
|
||||
t.Fatalf("GitFetch: %v", err)
|
||||
}
|
||||
|
||||
wtPath := filepath.Join(t.TempDir(), "repo__benvin-x")
|
||||
if GitBranchExists(srcDir, "benvin/x") {
|
||||
t.Fatal("branch benvin/x should not exist yet")
|
||||
}
|
||||
if err := GitWorktreeAdd(srcDir, wtPath, "benvin/x", "origin/main"); err != nil {
|
||||
t.Fatalf("GitWorktreeAdd: %v", err)
|
||||
}
|
||||
if !GitBranchExists(srcDir, "benvin/x") {
|
||||
t.Error("branch benvin/x should exist after worktree add")
|
||||
}
|
||||
|
||||
if br, err := GitCurrentBranch(wtPath); err != nil || br != "benvin/x" {
|
||||
t.Errorf("GitCurrentBranch = %q, %v; want benvin/x", br, err)
|
||||
}
|
||||
|
||||
src2, err := SourceRepoDir(wtPath)
|
||||
if err != nil {
|
||||
t.Fatalf("SourceRepoDir: %v", err)
|
||||
}
|
||||
if resolve(t, src2) != resolve(t, srcDir) {
|
||||
t.Errorf("SourceRepoDir = %q, want %q", src2, srcDir)
|
||||
}
|
||||
|
||||
wts, err := GitWorktreeList(srcDir)
|
||||
if err != nil {
|
||||
t.Fatalf("GitWorktreeList: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, w := range wts {
|
||||
if resolve(t, w.Path) == resolve(t, wtPath) && w.Branch == "benvin/x" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("worktree %s not found in list: %+v", wtPath, wts)
|
||||
}
|
||||
|
||||
// Per-worktree config must not leak into the shared checkout.
|
||||
if err := GitConfigSet(srcDir, false, "extensions.worktreeConfig", "true"); err != nil {
|
||||
t.Fatalf("enable worktreeConfig: %v", err)
|
||||
}
|
||||
if err := GitConfigSet(wtPath, true, "user.name", "unkin-agent"); err != nil {
|
||||
t.Fatalf("set worktree user.name: %v", err)
|
||||
}
|
||||
if name, _ := runGit(srcDir, "config", "user.name"); name == "unkin-agent" {
|
||||
t.Error("shared checkout user.name was polluted by worktree config")
|
||||
}
|
||||
|
||||
if err := GitWorktreeRemove(srcDir, wtPath, true); err != nil {
|
||||
t.Fatalf("GitWorktreeRemove: %v", err)
|
||||
}
|
||||
if err := GitDeleteBranch(srcDir, "benvin/x", true); err != nil {
|
||||
t.Fatalf("GitDeleteBranch: %v", err)
|
||||
}
|
||||
if GitBranchExists(srcDir, "benvin/x") {
|
||||
t.Error("branch benvin/x should be gone after delete")
|
||||
}
|
||||
if err := GitWorktreePrune(srcDir); err != nil {
|
||||
t.Fatalf("GitWorktreePrune: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolve canonicalizes a path (temp dirs may live behind symlinks like /var).
|
||||
func resolve(t *testing.T, p string) string {
|
||||
t.Helper()
|
||||
r, err := filepath.EvalSymlinks(p)
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user