add agentws prune
Agents leave their managed worktrees behind, and `agentws rm` takes one path at a time with no idea whether a branch's work is safely upstream, so clearing an accumulation by hand risks destroying unmerged commits. - classify every managed worktree: dirty, PR open, upstream, or unproven - remove only what is safe; delete the local branch only when work is upstream - prove "upstream" with merge-base and git cherry, so squash merges count - match a PR by head.label, which survives the branch deletion a merge does - dry run by default; --yes applies, --keep-branches spares every branch - read the Gitea path from origin's URL rather than assuming the owner
This commit is contained in:
@@ -185,6 +185,134 @@ func TestGitWorktreeLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
Reference in New Issue
Block a user