6380270ac6
- 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
433 lines
13 KiB
Go
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
|
|
}
|