package main import ( "bytes" "path/filepath" "strings" "testing" "git.unkin.net/unkin/agent-tools/internal/agent" ) // newFixtureOn builds the same origin/source/worktree-root layout as the prune // fixture but with a chosen default branch, so `new` can be tested against a // repo whose default is not "main". func newFixtureOn(t *testing.T, defBranch string) *fixture { t.Helper() root := t.TempDir() f := &fixture{ root: root, bare: filepath.Join(root, "origin.git"), srcDir: filepath.Join(root, "src", "repo"), wtRoot: filepath.Join(root, "worktrees"), } git(t, root, "init", "--bare", "-b", defBranch, f.bare) seed := filepath.Join(root, "seed") git(t, root, "init", "-b", defBranch, seed) identity(t, seed) writeCommit(t, seed, "README.md", "hi\n", "init") git(t, seed, "remote", "add", "origin", f.bare) git(t, seed, "push", "-u", "origin", defBranch) git(t, root, "clone", f.bare, f.srcDir) identity(t, f.srcDir) t.Setenv("AGENTWS_ROOT", f.wtRoot) t.Setenv("AGENTWS_SRC_ROOT", filepath.Join(root, "src")) t.Setenv("AGENTWS_OWNER", "unkin") return f } // runNew invokes `agentws new repo --branch ` and returns its output. func runNew(t *testing.T, branch string, extra ...string) string { t.Helper() var out bytes.Buffer cmd := newRootCmd() cmd.SetArgs(append([]string{"new", "repo", "--branch", branch}, extra...)) cmd.SetOut(&out) cmd.SetErr(&out) if err := cmd.Execute(); err != nil { t.Fatalf("new %s: %v (output %q)", branch, err, out.String()) } return out.String() } // pushBranch creates branch on origin carrying one commit and returns its SHA. func pushBranch(t *testing.T, f *fixture, branch, file, content string) string { t.Helper() seed := filepath.Join(f.root, "seed") git(t, seed, "checkout", "-b", branch) writeCommit(t, seed, file, content, "work on "+branch) git(t, seed, "push", "origin", branch) return git(t, seed, "rev-parse", "HEAD") } func wtPathFor(f *fixture, branch string) string { return filepath.Join(f.wtRoot, agent.WorktreeDirName("repo", branch)) } // The regression: a branch that already exists on origin must be checked out at // origin's tip, not forked from the default branch. func TestNewChecksOutExistingRemoteBranch(t *testing.T) { f := newFixtureOn(t, "main") want := pushBranch(t, f, "benvin/existing", "a.txt", "a\n") out := runNew(t, "benvin/existing") path := wtPathFor(f, "benvin/existing") if got := git(t, path, "rev-parse", "HEAD"); got != want { t.Errorf("worktree HEAD = %s, want origin/benvin/existing %s", got, want) } if upstream := git(t, path, "rev-parse", "--abbrev-ref", "HEAD@{upstream}"); upstream != "origin/benvin/existing" { t.Errorf("upstream = %q, want origin/benvin/existing", upstream) } if !contains(out, "tracking origin/benvin/existing") { t.Errorf("output %q does not say the remote branch was checked out", out) } } // A branch origin does not have is still forked from the default branch, and the // output must say so rather than leaving the caller to guess. func TestNewForksBranchMissingFromRemote(t *testing.T) { f := newFixtureOn(t, "main") want := git(t, f.srcDir, "rev-parse", "origin/main") out := runNew(t, "benvin/fresh") path := wtPathFor(f, "benvin/fresh") if got := git(t, path, "rev-parse", "HEAD"); got != want { t.Errorf("worktree HEAD = %s, want origin/main %s", got, want) } if !contains(out, "branch benvin/fresh (new, from origin/main)") { t.Errorf("output %q does not report a new branch", out) } } // The base is the remote's own default branch, so a repo defaulting to master // forks from master. func TestNewForksFromMasterDefaultBranch(t *testing.T) { f := newFixtureOn(t, "master") want := git(t, f.srcDir, "rev-parse", "origin/master") out := runNew(t, "benvin/on-master") path := wtPathFor(f, "benvin/on-master") if got := git(t, path, "rev-parse", "HEAD"); got != want { t.Errorf("worktree HEAD = %s, want origin/master %s", got, want) } if !contains(out, "from origin/master") { t.Errorf("output %q does not name origin/master as the base", out) } } // An existing remote branch beats --from: the flag is ignored and the caller is // told, rather than the branch being silently re-forked. func TestNewIgnoresFromWhenBranchIsOnRemote(t *testing.T) { f := newFixtureOn(t, "main") want := pushBranch(t, f, "benvin/with-from", "a.txt", "a\n") out := runNew(t, "benvin/with-from", "--from", "main") path := wtPathFor(f, "benvin/with-from") if got := git(t, path, "rev-parse", "HEAD"); got != want { t.Errorf("worktree HEAD = %s, want origin/benvin/with-from %s", got, want) } if !contains(out, "--from main ignored") { t.Errorf("output %q does not report the ignored --from", out) } } // A local branch left behind by an earlier run must not pin the worktree to a // commit origin has moved past. func TestNewFastForwardsStaleLocalBranch(t *testing.T) { f := newFixtureOn(t, "main") stale := pushBranch(t, f, "benvin/stale", "a.txt", "a\n") git(t, f.srcDir, "fetch", "origin") git(t, f.srcDir, "branch", "benvin/stale", "origin/benvin/stale") seed := filepath.Join(f.root, "seed") writeCommit(t, seed, "b.txt", "b\n", "more work") git(t, seed, "push", "origin", "benvin/stale") want := git(t, seed, "rev-parse", "HEAD") if want == stale { t.Fatal("fixture did not move origin/benvin/stale on") } out := runNew(t, "benvin/stale") path := wtPathFor(f, "benvin/stale") if got := git(t, path, "rev-parse", "HEAD"); got != want { t.Errorf("worktree HEAD = %s, want origin/benvin/stale %s", got, want) } if !contains(out, "fast-forwarded stale benvin/stale") { t.Errorf("output %q does not report the fast-forward", out) } } // A local branch carrying commits origin does not have keeps them: they exist // nowhere else, so the worktree stays put and the output says so. func TestNewKeepsLocalCommitsAheadOfRemote(t *testing.T) { f := newFixtureOn(t, "main") pushBranch(t, f, "benvin/ahead", "a.txt", "a\n") git(t, f.srcDir, "fetch", "origin") git(t, f.srcDir, "checkout", "-b", "benvin/ahead", "origin/benvin/ahead") writeCommit(t, f.srcDir, "local.txt", "local\n", "local only") want := git(t, f.srcDir, "rev-parse", "HEAD") git(t, f.srcDir, "checkout", "main") out := runNew(t, "benvin/ahead") path := wtPathFor(f, "benvin/ahead") if got := git(t, path, "rev-parse", "HEAD"); got != want { t.Errorf("worktree HEAD = %s, want the local tip %s", got, want) } if !contains(out, "1 commit not on origin/benvin/ahead") { t.Errorf("output %q does not report the unpushed commit", out) } } func contains(haystack, needle string) bool { return strings.Contains(haystack, needle) }