Files
agent-tools/internal/agent/git_test.go
T
unkin-agent 6a82b88947
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
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.
2026-08-15 12:21:08 +10:00

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
}