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.
197 lines
5.5 KiB
Go
197 lines
5.5 KiB
Go
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
|
|
}
|