Files
agent-tools/internal/agent/git_test.go
T
unkin-agent 6380270ac6
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Find every stale worktree, not just the managed ones
- discover worktrees from `git worktree list` on each source checkout, not only
  the worktree root, so hand-made ones, stale registrations and orphaned
  directories are classified too
- normalise each candidate to its main checkout, so a linked worktree in the
  source root cannot offer up the repo's real checkout
- keep locked, mid-rebase and detached-with-unique-commits worktrees, whose
  removal would destroy state nothing else holds
- name the retained branch in every unproven verdict
- add --no-fetch, --json, --include-unmanaged and --include-keep
2026-09-12 00:16:32 +10:00

433 lines
13 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
worktree /home/ben/.cache/agentws/repo__gone
HEAD 4444444444444444444444444444444444444444
branch refs/heads/benvin/gone
prunable gitdir file points to non-existent location
worktree /home/ben/.cache/agentws/repo__held
HEAD 5555555555555555555555555555555555555555
branch refs/heads/benvin/held
locked
`
wts := ParseWorktreeList(out)
if len(wts) != 5 {
t.Fatalf("got %d worktrees, want 5: %+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])
}
if wts[3].Prunable != "gitdir file points to non-existent location" {
t.Errorf("wt[3].Prunable = %q", wts[3].Prunable)
}
if !wts[4].Locked || wts[4].Prunable != "" {
t.Errorf("wt[4] = %+v, want locked and not prunable", wts[4])
}
}
// A bare "prunable" with no reason still has to read as prunable.
func TestParseWorktreeListPrunableWithoutReason(t *testing.T) {
wts := ParseWorktreeList("worktree /tmp/wt\nHEAD 1111111111111111111111111111111111111111\ndetached\nprunable\n")
if len(wts) != 1 || wts[0].Prunable == "" {
t.Errorf("ParseWorktreeList = %+v, want one prunable worktree", wts)
}
}
func TestGitInProgressOp(t *testing.T) {
srcDir := newTempRepos(t)
gitDir, err := GitDir(srcDir)
if err != nil {
t.Fatalf("GitDir: %v", err)
}
if op, err := GitInProgressOp(srcDir); err != nil || op != "" {
t.Fatalf("clean checkout: op = %q, err = %v", op, err)
}
tests := []struct {
marker string
dir bool
want string
}{
{"MERGE_HEAD", false, "merge"},
{"CHERRY_PICK_HEAD", false, "cherry-pick"},
{"REVERT_HEAD", false, "revert"},
{"BISECT_LOG", false, "bisect"},
{"rebase-merge", true, "rebase"},
{"rebase-apply", true, "rebase"},
}
for _, tt := range tests {
path := filepath.Join(gitDir, tt.marker)
if tt.dir {
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatal(err)
}
} else if err := os.WriteFile(path, []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
op, err := GitInProgressOp(srcDir)
if err != nil || op != tt.want {
t.Errorf("%s: op = %q, err = %v; want %q", tt.marker, op, err, tt.want)
}
if err := os.RemoveAll(path); err != nil {
t.Fatal(err)
}
}
}
// Commits that reached a remote-tracking ref are not unique local work; commits
// made after the push are.
func TestGitCommitsNotOnRemotes(t *testing.T) {
srcDir := newTempRepos(t)
if n, err := GitCommitsNotOnRemotes(srcDir); err != nil || n != 0 {
t.Fatalf("freshly cloned main: n = %d, err = %v; want 0", n, err)
}
if _, err := runGit(srcDir, "checkout", "-b", "benvin/x"); err != nil {
t.Fatal(err)
}
commitFile(t, srcDir, "a.txt", "a\n", "local a")
commitFile(t, srcDir, "b.txt", "b\n", "local b")
if n, err := GitCommitsNotOnRemotes(srcDir); err != nil || n != 2 {
t.Fatalf("two unpushed commits: n = %d, err = %v; want 2", n, err)
}
if _, err := runGit(srcDir, "push", "origin", "benvin/x"); err != nil {
t.Fatal(err)
}
if n, err := GitCommitsNotOnRemotes(srcDir); err != nil || n != 0 {
t.Fatalf("after push: n = %d, err = %v; want 0", n, err)
}
commitFile(t, srcDir, "c.txt", "c\n", "local c")
if n, err := GitCommitsNotOnRemotes(srcDir); err != nil || n != 1 {
t.Fatalf("one commit past the push: n = %d, err = %v; want 1", n, err)
}
}
func commitFile(t *testing.T, dir, name, content, msg string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if _, err := runGit(dir, "add", "."); err != nil {
t.Fatal(err)
}
if _, err := runGit(dir, "commit", "-m", msg); err != nil {
t.Fatal(err)
}
}
// 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)
}
}
// commit writes a file and commits it, returning the new HEAD sha.
func commit(t *testing.T, dir, name, content, msg string) string {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if _, err := runGit(dir, "add", "."); err != nil {
t.Fatalf("add: %v", err)
}
if _, err := runGit(dir, "commit", "-m", msg); err != nil {
t.Fatalf("commit: %v", err)
}
sha, err := runGit(dir, "rev-parse", "HEAD")
if err != nil {
t.Fatalf("rev-parse: %v", err)
}
return sha
}
func TestGitIsAncestor(t *testing.T) {
srcDir := newTempRepos(t)
base, err := runGit(srcDir, "rev-parse", "HEAD")
if err != nil {
t.Fatal(err)
}
tip := commit(t, srcDir, "a.txt", "a\n", "add a")
if ok, err := GitIsAncestor(srcDir, base, tip); err != nil || !ok {
t.Errorf("GitIsAncestor(base, tip) = %v, %v; want true", ok, err)
}
if ok, err := GitIsAncestor(srcDir, tip, base); err != nil || ok {
t.Errorf("GitIsAncestor(tip, base) = %v, %v; want false with no error", ok, err)
}
if _, err := GitIsAncestor(srcDir, "no-such-ref", tip); err == nil {
t.Error("GitIsAncestor with a bogus ref should error, not report false")
}
}
// These repos squash-merge, so merged work keeps its local SHA while the
// upstream commit is a different one carrying the same patch. `git cherry` must
// see that as merged even though the SHAs differ.
func TestGitUnmergedCommitsIgnoresRewrittenSHAs(t *testing.T) {
srcDir := newTempRepos(t)
if _, err := runGit(srcDir, "checkout", "-b", "feature"); err != nil {
t.Fatalf("checkout: %v", err)
}
commit(t, srcDir, "f.txt", "hello\n", "add f")
n, err := GitUnmergedCommits(srcDir, "origin/main", "HEAD")
if err != nil {
t.Fatalf("GitUnmergedCommits: %v", err)
}
if n != 1 {
t.Fatalf("unmerged before upstream landing = %d, want 1", n)
}
// Land the same patch upstream under a different SHA.
if _, err := runGit(srcDir, "checkout", "main"); err != nil {
t.Fatalf("checkout main: %v", err)
}
commit(t, srcDir, "f.txt", "hello\n", "squashed f")
if _, err := runGit(srcDir, "push", "origin", "main"); err != nil {
t.Fatalf("push: %v", err)
}
if err := GitFetchPrune(srcDir, "origin"); err != nil {
t.Fatalf("GitFetchPrune: %v", err)
}
if ok, err := GitIsAncestor(srcDir, "feature", "origin/main"); err != nil || ok {
t.Fatalf("squash-merged branch must not be an ancestor: %v, %v", ok, err)
}
n, err = GitUnmergedCommits(srcDir, "origin/main", "feature")
if err != nil {
t.Fatalf("GitUnmergedCommits: %v", err)
}
if n != 0 {
t.Errorf("unmerged after upstream landing = %d, want 0", n)
}
}
func TestGitIsDirty(t *testing.T) {
srcDir := newTempRepos(t)
if dirty, err := GitIsDirty(srcDir); err != nil || dirty {
t.Fatalf("clean checkout reported dirty=%v, err=%v", dirty, err)
}
if err := os.WriteFile(filepath.Join(srcDir, "scratch.txt"), []byte("wip\n"), 0o644); err != nil {
t.Fatal(err)
}
if dirty, err := GitIsDirty(srcDir); err != nil || !dirty {
t.Errorf("untracked file must count as dirty: dirty=%v, err=%v", dirty, err)
}
}
func TestGitRemoteBranchExists(t *testing.T) {
srcDir := newTempRepos(t)
if !GitRemoteBranchExists(srcDir, "origin", "main") {
t.Error("origin/main should exist")
}
if GitRemoteBranchExists(srcDir, "origin", "benvin/nope") {
t.Error("origin/benvin/nope should not exist")
}
if _, err := runGit(srcDir, "checkout", "-b", "benvin/pushed"); err != nil {
t.Fatalf("checkout: %v", err)
}
commit(t, srcDir, "p.txt", "p\n", "add p")
if _, err := runGit(srcDir, "push", "origin", "benvin/pushed"); err != nil {
t.Fatalf("push: %v", err)
}
if err := GitFetchPrune(srcDir, "origin"); err != nil {
t.Fatalf("GitFetchPrune: %v", err)
}
if !GitRemoteBranchExists(srcDir, "origin", "benvin/pushed") {
t.Error("pushed branch should have a remote-tracking ref")
}
if _, err := runGit(srcDir, "push", "origin", "--delete", "benvin/pushed"); err != nil {
t.Fatalf("delete remote branch: %v", err)
}
if err := GitFetchPrune(srcDir, "origin"); err != nil {
t.Fatalf("GitFetchPrune: %v", err)
}
if GitRemoteBranchExists(srcDir, "origin", "benvin/pushed") {
t.Error("a pruning fetch must drop the tracking ref for a deleted remote branch")
}
}
// 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
}