Find every stale worktree, not just the managed ones
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

- 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
This commit is contained in:
2026-09-12 00:16:23 +10:00
parent 6d0e954cce
commit 6380270ac6
7 changed files with 990 additions and 64 deletions
+223 -12
View File
@@ -13,7 +13,8 @@
// agentws new <repo> [--branch benvin/<name>] [--from <base-branch>]
// agentws list
// agentws rm <path-or-branch> [--delete-branch]
// agentws prune [--yes] [--keep-branches]
// agentws prune [--yes] [--keep-branches] [--no-fetch] [--json]
// [--include-unmanaged] [--include-keep]
// agentws clean
// agentws token
// agentws credential get # git credential-helper protocol on stdin
@@ -25,6 +26,7 @@ import (
"io"
"os"
"path/filepath"
"sort"
"strings"
"git.unkin.net/unkin/agent-tools/internal/agent"
@@ -228,7 +230,11 @@ func newListCmd() *cobra.Command {
return nil
}
for _, w := range managed {
_, _ = fmt.Fprintf(out, "%s\t%s\t%s\n", w.repo, w.branch, w.path)
branch := w.branch
if w.orphan {
branch = "(orphan)"
}
_, _ = fmt.Fprintf(out, "%s\t%s\t%s\n", w.repo, branch, w.path)
}
return nil
},
@@ -241,10 +247,26 @@ type managedWt struct {
branch string
path string
srcDir string
// managed is false for worktrees found via `git worktree list` that live
// outside the worktree root, i.e. somebody made them by hand.
managed bool
// detached is true when the worktree has no branch to fall back on, so its
// commits die with the checkout.
detached bool
// locked records git's own "do not remove me" marker.
locked bool
// missing is a registration whose working tree is gone: nothing to inspect,
// nothing to lose.
missing bool
// orphan is a directory under the worktree root whose backing repo no longer
// resolves, so no git state can be read from it at all.
orphan bool
}
// managedWorktrees scans the worktree root and resolves each entry's repo and
// branch from git so branch names are accurate (not the sanitized dir name).
// Directories whose backing repo no longer resolves are returned as orphans
// rather than dropped, so callers can see (and clean up) the leftovers.
func managedWorktrees() ([]managedWt, error) {
wr, err := worktreeRoot()
if err != nil {
@@ -263,24 +285,187 @@ func managedWorktrees() ([]managedWt, error) {
continue
}
path := filepath.Join(wr, e.Name())
branch, err := agent.GitCurrentBranch(path)
if err != nil {
continue // not a git worktree; skip
if _, err := os.Stat(filepath.Join(path, ".git")); err != nil {
continue // not a worktree checkout at all
}
srcDir, err := agent.SourceRepoDir(path)
if err != nil {
branch, branchErr := agent.GitCurrentBranch(path)
srcDir, srcErr := agent.SourceRepoDir(path)
if branchErr != nil || srcErr != nil {
out = append(out, managedWt{repo: repoFromDirName(e.Name()), path: path, managed: true, orphan: true})
continue
}
out = append(out, managedWt{
repo: filepath.Base(srcDir),
branch: branch,
path: path,
srcDir: srcDir,
repo: filepath.Base(srcDir),
branch: branch,
path: path,
srcDir: srcDir,
managed: true,
detached: branch == "HEAD",
})
}
return out, nil
}
// repoFromDirName recovers the repo name from the "<repo>__<branch>" layout used
// under the worktree root, for entries git can no longer answer for.
func repoFromDirName(name string) string {
if repo, _, ok := strings.Cut(name, "__"); ok {
return repo
}
return name
}
// allWorktrees is every worktree prune should consider: the managed ones under
// the worktree root, plus whatever `git worktree list` reports for the repos
// they belong to and for every checkout in the source root. The second source
// finds hand-made worktrees and stale registrations whose directory is gone, and
// carries git's own locked/prunable flags onto the entries the first source
// already found.
func allWorktrees() ([]managedWt, error) {
managed, err := managedWorktrees()
if err != nil {
return nil, err
}
wr, err := worktreeRoot()
if err != nil {
return nil, err
}
out := make([]managedWt, 0, len(managed))
index := map[string]int{}
for _, w := range managed {
index[resolvePath(w.path)] = len(out)
out = append(out, w)
}
for _, srcDir := range sourceRepos(managed) {
wts, err := agent.GitWorktreeList(srcDir)
if err != nil {
continue // not a repo any more, or unreadable; managed entries still stand
}
for _, wt := range wts {
if wt.Bare || sameDir(wt.Path, srcDir) {
continue
}
_, statErr := os.Stat(wt.Path)
entry := managedWt{
repo: filepath.Base(srcDir),
branch: worktreeBranch(wt),
path: wt.Path,
srcDir: srcDir,
managed: underRoot(wt.Path, wr),
detached: wt.Detached,
locked: wt.Locked,
missing: wt.Prunable != "" || os.IsNotExist(statErr),
}
key := resolvePath(wt.Path)
if i, ok := index[key]; ok {
// Keep the managed scan's own view, but adopt the flags only git knows.
out[i].locked = entry.locked
out[i].missing = out[i].missing || entry.missing
out[i].detached = out[i].detached || entry.detached
continue
}
index[key] = len(out)
out = append(out, entry)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].repo != out[j].repo {
return out[i].repo < out[j].repo
}
return out[i].path < out[j].path
})
return out, nil
}
// resolvePath is a path key that matches however git spells the same directory.
func resolvePath(path string) string {
if p, err := filepath.EvalSymlinks(path); err == nil {
return p
}
return filepath.Clean(path)
}
// worktreeBranch names a worktree's branch, reporting a detached checkout as
// "HEAD" so it reads the same as GitCurrentBranch does.
func worktreeBranch(wt agent.Worktree) string {
if wt.Branch != "" {
return wt.Branch
}
return "HEAD"
}
// sourceRepos is every repo to enumerate worktrees from: the ones the managed
// worktrees point back at, plus every git checkout directly under the source
// root (so a repo with only hand-made worktrees is still covered). Each is
// normalised to its main checkout, because a directory in the source root may
// itself be a linked worktree — enumerating from there would report the repo's
// real checkout as a removable worktree of itself.
func sourceRepos(managed []managedWt) []string {
seen := map[string]bool{}
var dirs []string
add := func(dir string) {
if dir == "" {
return
}
if main, err := agent.SourceRepoDir(dir); err == nil {
dir = main
}
key := resolvePath(dir)
if seen[key] {
return
}
seen[key] = true
dirs = append(dirs, dir)
}
for _, w := range managed {
add(w.srcDir)
}
if sr, err := srcRoot(); err == nil {
if entries, err := os.ReadDir(sr); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(sr, e.Name())
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
add(dir)
}
}
}
}
sort.Strings(dirs)
return dirs
}
// sameDir compares two paths after resolving symlinks, because git reports
// worktree paths fully resolved while our own paths may not be.
func sameDir(a, b string) bool {
if a == b {
return true
}
ra, errA := filepath.EvalSymlinks(a)
rb, errB := filepath.EvalSymlinks(b)
return errA == nil && errB == nil && ra == rb
}
// underRoot reports whether path sits inside root, comparing resolved paths
// because git hands back worktree paths with symlinks already resolved.
func underRoot(path, root string) bool {
if r, err := filepath.EvalSymlinks(root); err == nil {
root = r
}
if p, err := filepath.EvalSymlinks(path); err == nil {
path = p
}
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// --- rm -------------------------------------------------------------------
func newRmCmd() *cobra.Command {
@@ -312,7 +497,7 @@ func resolveWorktree(target string) (managedWt, error) {
}
abs, _ := filepath.Abs(target)
for _, w := range managed {
if w.path == target || w.path == abs || w.branch == target {
if w.path == target || w.path == abs || (w.branch != "" && w.branch == target) {
return w, nil
}
}
@@ -323,6 +508,14 @@ func resolveWorktree(target string) (managedWt, error) {
// forceBranch overrides git's unmerged-branch guard, so only a caller that
// proved the commits survive elsewhere may set it.
func removeWorktree(out io.Writer, wt managedWt, deleteBranch, forceBranch bool) error {
switch {
case wt.orphan:
return removeOrphanDir(out, wt)
case wt.missing:
// The working tree is already gone; only the registration is left.
_, _ = fmt.Fprintf(out, "pruned stale registration %s\n", wt.path)
return agent.GitWorktreePrune(wt.srcDir)
}
if err := agent.GitWorktreeRemove(wt.srcDir, wt.path, true); err != nil {
return err
}
@@ -340,6 +533,24 @@ func removeWorktree(out io.Writer, wt managedWt, deleteBranch, forceBranch bool)
return agent.GitWorktreePrune(wt.srcDir)
}
// removeOrphanDir deletes a worktree directory whose backing repo is gone. git
// cannot act on it, so this is a plain delete — confined to the worktree root so
// a bad path can never reach a real checkout.
func removeOrphanDir(out io.Writer, wt managedWt) error {
wr, err := worktreeRoot()
if err != nil {
return err
}
if !underRoot(wt.path, wr) || sameDir(wt.path, wr) {
return fmt.Errorf("refusing to delete %s: not inside the worktree root %s", wt.path, wr)
}
if err := os.RemoveAll(wt.path); err != nil {
return err
}
_, _ = fmt.Fprintf(out, "deleted orphaned worktree directory %s\n", wt.path)
return nil
}
// deleteLocalBranch tries the guarded delete first so git refuses to drop
// unmerged commits on its own; force is a fallback, never the first attempt.
func deleteLocalBranch(wt managedWt, force bool) error {
+213 -37
View File
@@ -1,11 +1,15 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"text/tabwriter"
"git.unkin.net/unkin/agent-tools/internal/agent"
@@ -43,22 +47,49 @@ type repoCtx struct {
// fetched records that this run's pruning fetch succeeded; without it an
// origin/<branch> ref may be stale and due for deletion, so it proves nothing.
fetched bool
// unfetched explains why, so a verdict can say which it was.
unfetched string
}
// pruneOpts is the knob set runPrune is driven by.
type pruneOpts struct {
apply bool
keepBranches bool
noFetch bool
jsonOut bool
includeKeep bool
includeUnmanaged bool
}
// reportEntry is the --json shape: one object per worktree, mirroring the table.
type reportEntry struct {
Repo string `json:"repo"`
Branch string `json:"branch"`
Path string `json:"path"`
Verdict string `json:"verdict"`
Reason string `json:"reason"`
Managed bool `json:"managed"`
Applied bool `json:"applied"`
}
func newPruneCmd() *cobra.Command {
var apply, keepBranches bool
var opts pruneOpts
cmd := &cobra.Command{
Use: "prune",
Short: "Classify managed worktrees and remove the ones whose work is safely upstream",
Long: "prune inspects every managed worktree, classifies it against git and its Gitea\npull request, and removes the ones whose work is provably upstream. It is a dry\nrun unless --yes is given.",
Short: "Classify worktrees and remove the ones whose work is safely upstream",
Long: "prune inspects every worktree it can find — the managed ones under the worktree\nroot plus whatever `git worktree list` reports for the source checkouts — and\nclassifies each against git and its Gitea pull request. It reports and changes\nnothing unless --yes is given.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runPrune(cmd.OutOrStdout(), pruneClient(), apply, keepBranches)
return runPrune(cmd.OutOrStdout(), cmd.ErrOrStderr(), pruneClient(), opts)
},
}
f := cmd.Flags()
f.BoolVar(&apply, "yes", false, "Actually remove worktrees (default is a dry run)")
f.BoolVar(&keepBranches, "keep-branches", false, "Never delete a local branch, whatever the classification")
f.BoolVar(&opts.apply, "yes", false, "Actually remove worktrees (default is a dry run)")
f.BoolVar(&opts.keepBranches, "keep-branches", false, "Never delete a local branch, whatever the classification")
f.BoolVar(&opts.noFetch, "no-fetch", false, "Do not fetch; judge against the refs already on disk")
f.BoolVar(&opts.jsonOut, "json", false, "Emit JSON instead of a table")
f.BoolVar(&opts.includeKeep, "include-keep", false, "Dangerous: also remove worktrees classified keep (needs --yes; never deletes their branch)")
f.BoolVar(&opts.includeUnmanaged, "include-unmanaged", false, "Also remove worktrees that live outside the worktree root")
return cmd
}
@@ -72,29 +103,60 @@ func pruneClient() prLister {
return agent.NewGiteaClient(tok)
}
func runPrune(out io.Writer, prs prLister, apply, keepBranches bool) error {
managed, err := managedWorktrees()
func runPrune(out, errOut io.Writer, prs prLister, opts pruneOpts) error {
// In JSON mode stdout carries the document alone, so notes go to stderr.
notes := out
if opts.jsonOut {
notes = errOut
}
worktrees, err := allWorktrees()
if err != nil {
return err
}
if len(managed) == 0 {
if len(worktrees) == 0 {
if opts.jsonOut {
_, _ = fmt.Fprintln(out, "[]")
return nil
}
_, _ = fmt.Fprintln(out, "no managed worktrees")
return nil
}
results := classifyAll(notes, prs, worktrees, opts)
if err := report(out, results, opts); err != nil {
return err
}
if !opts.apply {
if !opts.jsonOut {
_, _ = fmt.Fprintln(out, "dry run: nothing removed (pass --yes to apply)")
}
return nil
}
return applyPrune(notes, results, opts)
}
// classifyAll groups worktrees by source repo so each repo is fetched and its
// PRs listed once, then classifies every worktree against that repo's state.
func classifyAll(notes io.Writer, prs prLister, worktrees []managedWt, opts pruneOpts) []pruneResult {
byRepo := map[string][]managedWt{}
for _, w := range managed {
var results []pruneResult
for _, w := range worktrees {
if w.orphan {
results = append(results, pruneResult{wt: w, verdict: verdictRemove, reason: "backing repo gone, no git state to read"})
continue
}
byRepo[w.srcDir] = append(byRepo[w.srcDir], w)
}
srcDirs := make([]string, 0, len(byRepo))
for dir := range byRepo {
srcDirs = append(srcDirs, dir)
}
sort.Strings(srcDirs)
var results []pruneResult
for _, srcDir := range srcDirs {
ctx, err := newRepoCtx(out, prs, srcDir)
ctx, err := newRepoCtx(notes, prs, srcDir, opts.noFetch)
if err != nil {
for _, w := range byRepo[srcDir] {
results = append(results, pruneResult{wt: w, verdict: verdictKeep, reason: "repo state unknown: " + err.Error()})
@@ -109,21 +171,76 @@ func runPrune(out io.Writer, prs prLister, apply, keepBranches bool) error {
results = append(results, res)
}
}
sort.SliceStable(results, func(i, j int) bool {
if results[i].wt.repo != results[j].wt.repo {
return results[i].wt.repo < results[j].wt.repo
}
return results[i].wt.path < results[j].wt.path
})
return results
}
// report writes the classification as a table or as JSON.
func report(out io.Writer, results []pruneResult, opts pruneOpts) error {
if opts.jsonOut {
entries := make([]reportEntry, 0, len(results))
for _, r := range results {
entries = append(entries, reportEntry{
Repo: r.wt.repo,
Branch: r.wt.branch,
Path: r.wt.path,
Verdict: plannedVerdict(r, opts),
Reason: r.reason,
Managed: r.wt.managed,
Applied: opts.apply && willRemove(r, opts),
})
}
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(entries)
}
// Padded with spaces only, so the table reads the same with or without a TTY.
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, "REPO\tBRANCH\tPATH\tVERDICT\tREASON")
for _, r := range results {
_, _ = fmt.Fprintf(out, "%-44s %-34s %-14s %s\n", filepath.Base(r.wt.path), r.wt.branch, plannedVerdict(r, keepBranches), r.reason)
}
if !apply {
_, _ = fmt.Fprintln(out, "dry run: nothing removed (pass --yes to apply)")
return nil
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
dash(r.wt.repo), dash(r.wt.branch), abbrevHome(r.wt.path), plannedVerdict(r, opts), r.reason)
}
return tw.Flush()
}
func dash(s string) string {
if s == "" {
return "-"
}
return s
}
// abbrevHome shortens $HOME to ~ so paths do not dominate the table.
func abbrevHome(path string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" || !strings.HasPrefix(path, home+string(filepath.Separator)) {
return path
}
return "~" + path[len(home):]
}
// applyPrune performs the removals the classification authorised, reporting each
// one and collecting failures so one bad worktree does not stop the rest.
func applyPrune(out io.Writer, results []pruneResult, opts pruneOpts) error {
var errs []error
for _, r := range results {
if r.verdict == verdictKeep {
if !willRemove(r, opts) {
if !r.wt.managed && r.verdict != verdictKeep {
_, _ = fmt.Fprintf(out, "skipped %s: outside the worktree root (pass --include-unmanaged)\n", r.wt.path)
}
continue
}
deleteBranch := r.verdict == verdictRemoveBranch && !keepBranches
if r.verdict == verdictKeep {
_, _ = fmt.Fprintf(out, "warn: removing %s despite %q (--include-keep)\n", r.wt.path, r.reason)
}
deleteBranch := plannedVerdict(r, opts) == verdictRemoveBranch
if err := removeWorktree(out, r.wt, deleteBranch, r.proven); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", r.wt.path, err))
}
@@ -131,23 +248,44 @@ func runPrune(out io.Writer, prs prLister, apply, keepBranches bool) error {
return errors.Join(errs...)
}
// plannedVerdict is what will actually happen, so --keep-branches does not
// print a branch deletion it will not perform.
func plannedVerdict(r pruneResult, keepBranches bool) string {
if keepBranches && r.verdict == verdictRemoveBranch {
// willRemove is the single gate on destruction: a keep verdict needs
// --include-keep, and a worktree outside the worktree root needs
// --include-unmanaged.
func willRemove(r pruneResult, opts pruneOpts) bool {
if !r.wt.managed && !opts.includeUnmanaged {
return false
}
if r.verdict == verdictKeep {
return opts.includeKeep
}
return true
}
// plannedVerdict is what will actually happen, so no flag prints an action it
// will not perform. A detached HEAD has no branch to delete, and a keep forced
// through with --include-keep never takes its branch with it.
func plannedVerdict(r pruneResult, opts pruneOpts) string {
if r.verdict != verdictRemoveBranch {
return r.verdict
}
if opts.keepBranches || r.wt.detached {
return verdictRemove
}
return r.verdict
return verdictRemoveBranch
}
// newRepoCtx refreshes a source repo and collects the signals prune classifies
// against. A failed fetch or an unreachable Gitea is reported and tolerated:
// the signals that hold offline still work, and the rest are recorded as
// unverified.
func newRepoCtx(out io.Writer, prs prLister, srcDir string) (repoCtx, error) {
// against. A failed or skipped fetch and an unreachable Gitea are reported and
// tolerated: the signals that hold offline still work, and the rest are recorded
// as unverified.
func newRepoCtx(out io.Writer, prs prLister, srcDir string, noFetch bool) (repoCtx, error) {
ctx := repoCtx{srcDir: srcDir, prs: map[string]agent.PullRequest{}}
repo := filepath.Base(srcDir)
if err := agent.GitFetchPrune(srcDir, "origin", credentialHelperArgs()...); err != nil {
if noFetch {
ctx.unfetched = "fetch skipped"
_, _ = fmt.Fprintf(out, "warn: fetch %s skipped (--no-fetch, remote state unverified)\n", repo)
} else if err := agent.GitFetchPrune(srcDir, "origin", credentialHelperArgs()...); err != nil {
ctx.unfetched = "fetch failed"
_, _ = fmt.Fprintf(out, "warn: fetch %s: %v (remote state unverified)\n", repo, err)
} else {
ctx.fetched = true
@@ -225,14 +363,33 @@ func headContainedIn(dir, ref string) bool {
return err == nil && ok
}
// classify applies the prune precedence: dirty and open-PR worktrees are kept,
// provably-upstream work loses its branch too, and anything unproven keeps its
// branch so no commits become unreachable. A PR's state alone never authorises
// deleting a branch — git must confirm HEAD is contained in what merged or in
// what origin still holds, and origin's refs only count when this run's pruning
// fetch refreshed them.
// classify applies the prune precedence. Removal must never destroy state that
// exists nowhere else: a vanished working tree is the one case with nothing to
// lose, a locked or mid-rebase checkout holds sequencer state git itself refuses
// to discard, and a dirty checkout or a detached HEAD with unique commits holds
// work no branch is carrying. Past those guards, provably-upstream work loses its
// branch too, and anything unproven keeps its branch so no commit becomes
// unreachable. A PR's state alone never authorises deleting a branch — git must
// confirm HEAD is contained in what merged or in what origin still holds, and
// origin's refs only count when this run's pruning fetch refreshed them.
func classify(wt managedWt, ctx repoCtx) (pruneResult, error) {
res := pruneResult{wt: wt}
if wt.missing {
res.verdict, res.reason = verdictRemove, "working tree gone, stale registration only"
return res, nil
}
if wt.locked {
res.verdict, res.reason = verdictKeep, "locked"
return res, nil
}
op, err := agent.GitInProgressOp(wt.path)
if err != nil {
return res, err
}
if op != "" {
res.verdict, res.reason = verdictKeep, op+" in progress"
return res, nil
}
dirty, err := agent.GitIsDirty(wt.path)
if err != nil {
return res, err
@@ -267,6 +424,15 @@ func classify(wt managedWt, ctx repoCtx) (pruneResult, error) {
return res, nil
}
local, err := agent.GitCommitsNotOnRemotes(wt.path)
if err != nil {
return res, err
}
if wt.detached && local > 0 {
res.verdict, res.reason = verdictKeep, fmt.Sprintf("detached HEAD carrying %s on no remote", commitCount(local))
return res, nil
}
remote := "origin/" + wt.branch
onOrigin := hasPR && ctx.fetched && agent.GitRemoteBranchExists(ctx.srcDir, "origin", wt.branch)
switch {
@@ -275,7 +441,7 @@ func classify(wt managedWt, ctx repoCtx) (pruneResult, error) {
case hasPR && pr.Merged && onOrigin && headContainedIn(wt.path, remote):
res.verdict, res.reason, res.proven = verdictRemoveBranch, fmt.Sprintf("PR merged #%d, HEAD contained in %s", pr.Number, remote), true
case hasPR && pr.Merged && !ctx.fetched:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR merged #%d, fetch failed so %s is unverified", pr.Number, remote)
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR merged #%d, %s so %s is unverified", pr.Number, ctx.unfetched, remote)
case hasPR && pr.Merged:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR merged #%d, local commits not in the merged head", pr.Number)
case hasPR && onOrigin && headContainedIn(wt.path, remote):
@@ -283,7 +449,7 @@ func classify(wt managedWt, ctx repoCtx) (pruneResult, error) {
case hasPR && onOrigin:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR closed #%d, local commits not on %s", pr.Number, remote)
case hasPR && !ctx.fetched:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR closed #%d, fetch failed so %s is unverified", pr.Number, remote)
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR closed #%d, %s so %s is unverified", pr.Number, ctx.unfetched, remote)
case hasPR:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR closed #%d, branch gone", pr.Number)
case ctx.prsKnown:
@@ -291,5 +457,15 @@ func classify(wt managedWt, ctx repoCtx) (pruneResult, error) {
default:
res.verdict, res.reason = verdictRemove, "PR state unknown"
}
if res.verdict == verdictRemove && local > 0 {
res.reason += fmt.Sprintf(", %s on no remote so branch %s is kept", commitCount(local), wt.branch)
}
return res, nil
}
func commitCount(n int) string {
if n == 1 {
return "1 commit"
}
return fmt.Sprintf("%d commits", n)
}
+336 -9
View File
@@ -166,17 +166,22 @@ func closedPR(number int, branch string) map[string]any {
func run(t *testing.T, prs prLister, apply, keepBranches bool) string {
t.Helper()
var out bytes.Buffer
if err := runPrune(&out, prs, apply, keepBranches); err != nil {
t.Fatalf("runPrune: %v\n%s", err, out.String())
return runOpts(t, prs, pruneOpts{apply: apply, keepBranches: keepBranches})
}
func runOpts(t *testing.T, prs prLister, opts pruneOpts) string {
t.Helper()
var out, errOut bytes.Buffer
if err := runPrune(&out, &errOut, prs, opts); err != nil {
t.Fatalf("runPrune: %v\n%s%s", err, out.String(), errOut.String())
}
return out.String()
return out.String() + errOut.String()
}
func lineFor(t *testing.T, out, branch string) string {
t.Helper()
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, " "+branch+" ") {
if fields := strings.Fields(line); len(fields) > 1 && fields[1] == branch {
return line
}
}
@@ -184,11 +189,12 @@ func lineFor(t *testing.T, out, branch string) string {
return ""
}
// assertVerdict reads the table row for a branch: REPO BRANCH PATH VERDICT REASON.
func assertVerdict(t *testing.T, out, branch, verdict, reason string) {
t.Helper()
line := lineFor(t, out, branch)
fields := strings.Fields(line)
if len(fields) < 3 || fields[2] != verdict {
if len(fields) < 4 || fields[3] != verdict {
t.Errorf("branch %s: verdict line %q, want verdict %q", branch, line, verdict)
}
if reason != "" && !strings.Contains(line, reason) {
@@ -559,12 +565,12 @@ func (f *fixture) breakRemote(t *testing.T) {
// classification and the removals it authorised happen regardless.
func applyUnreachable(t *testing.T, prs prLister) string {
t.Helper()
var out bytes.Buffer
err := runPrune(&out, prs, true, false)
var out, errOut bytes.Buffer
err := runPrune(&out, &errOut, prs, pruneOpts{apply: true})
if err == nil {
t.Fatalf("expected the refresh fetch to fail against a missing remote:\n%s", out.String())
}
return out.String()
return out.String() + errOut.String()
}
// staleFixture builds a repo where origin/<branch> covers HEAD for a closed and
@@ -685,3 +691,324 @@ func TestRepoPathFollowsOrigin(t *testing.T) {
t.Errorf("repoPath for a local remote = %q, want unkin/dotfiles", got)
}
}
// --- discovery beyond the worktree root -----------------------------------
// gitAllow runs git and returns its combined output without failing the test,
// for commands that are expected to stop mid-way (e.g. an interrupted rebase).
func gitAllow(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, _ := cmd.CombinedOutput()
return strings.TrimSpace(string(out))
}
// addManualWorktree creates a worktree outside the worktree root, the way a
// person would by hand, so only `git worktree list` can find it.
func (f *fixture) addManualWorktree(t *testing.T, branch string) string {
t.Helper()
path := filepath.Join(f.root, "manual", agent.SanitizeBranch(branch))
git(t, f.srcDir, "worktree", "add", path, "-b", branch, "origin/main")
identity(t, path)
return path
}
// A hand-made worktree outside the worktree root is still classified, but the
// default run refuses to remove it: that needs --include-unmanaged.
func TestPruneReportsUnmanagedWorktreeButKeepsIt(t *testing.T) {
f := newFixture(t)
manual := f.addManualWorktree(t, "benvin/by-hand")
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true})
assertVerdict(t, out, "benvin/by-hand", verdictRemoveBranch, "contained in origin/main")
if !strings.Contains(out, "--include-unmanaged") {
t.Errorf("output should name the flag that would remove it:\n%s", out)
}
if !exists(manual) {
t.Error("an unmanaged worktree must survive without --include-unmanaged")
}
if !agent.GitBranchExists(f.srcDir, "benvin/by-hand") {
t.Error("an unmanaged worktree's branch must survive too")
}
}
func TestPruneRemovesUnmanagedWorktreeWithFlag(t *testing.T) {
f := newFixture(t)
manual := f.addManualWorktree(t, "benvin/by-hand")
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true, includeUnmanaged: true})
assertVerdict(t, out, "benvin/by-hand", verdictRemoveBranch, "contained in origin/main")
if exists(manual) {
t.Error("--include-unmanaged should have removed the worktree")
}
}
// A worktree whose directory was deleted leaves only a registration behind:
// there is nothing to lose, so it is prunable outright.
func TestPruneStaleRegistrationWithMissingDirectory(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/vanished")
writeCommit(t, wt, "v.txt", "v\n", "work")
if err := os.RemoveAll(wt); err != nil {
t.Fatal(err)
}
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true})
assertVerdict(t, out, "benvin/vanished", verdictRemove, "working tree gone")
wts, err := agent.GitWorktreeList(f.srcDir)
if err != nil {
t.Fatal(err)
}
for _, w := range wts {
if w.Path == wt {
t.Errorf("registration for %s should have been pruned: %+v", wt, w)
}
}
if !agent.GitBranchExists(f.srcDir, "benvin/vanished") {
t.Error("pruning a registration must not delete the branch")
}
}
// A directory under the worktree root whose backing repo is gone cannot be
// inspected by git at all, so prune deletes the leftover directory.
func TestPruneOrphanedDirectoryWhenRepoIsGone(t *testing.T) {
f := newFixture(t)
other := filepath.Join(f.root, "src", "other")
git(t, filepath.Join(f.root, "src"), "clone", f.bare, other)
identity(t, other)
orphan := filepath.Join(f.wtRoot, agent.WorktreeDirName("other", "benvin/orphaned"))
git(t, other, "worktree", "add", orphan, "-b", "benvin/orphaned", "origin/main")
if err := os.RemoveAll(other); err != nil {
t.Fatal(err)
}
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true})
assertVerdict(t, out, "-", verdictRemove, "backing repo gone")
if exists(orphan) {
t.Error("an orphaned worktree directory should have been deleted")
}
}
// --- safety signals -------------------------------------------------------
// An interrupted rebase holds sequencer state that exists nowhere else, and it
// detaches HEAD while it runs, so it must outrank every other signal.
func TestPruneKeepsInterruptedRebase(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/rebasing")
writeCommit(t, wt, "r.txt", "r\n", "work")
gitAllow(t, wt, "rebase", "--exec", "false", "origin/main")
if op, err := agent.GitInProgressOp(wt); err != nil || op != "rebase" {
t.Fatalf("fixture did not leave a rebase in progress: op=%q err=%v", op, err)
}
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true})
assertVerdict(t, out, "HEAD", verdictKeep, "rebase in progress")
if !exists(wt) {
t.Error("a worktree mid-rebase must not be removed")
}
}
// A half-finished cherry-pick is the same case with a branch still checked out,
// so the sequencer check has to run before the dirty check can mask it.
func TestPruneKeepsInterruptedCherryPick(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/picking")
gitDir, err := agent.GitDir(wt)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gitDir, "CHERRY_PICK_HEAD"), []byte(git(t, wt, "rev-parse", "HEAD")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true})
assertVerdict(t, out, "benvin/picking", verdictKeep, "cherry-pick in progress")
if !exists(wt) {
t.Error("a worktree mid-cherry-pick must not be removed")
}
}
// A detached HEAD has no branch to carry its commits, so unique work there is
// unrecoverable once the worktree goes.
func TestPruneKeepsDetachedHeadWithUniqueCommits(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/detaching")
writeCommit(t, wt, "d.txt", "d\n", "work")
git(t, wt, "checkout", "--detach")
git(t, f.srcDir, "branch", "-D", "benvin/detaching")
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true})
assertVerdict(t, out, "HEAD", verdictKeep, "detached HEAD carrying 1 commit on no remote")
if !exists(wt) {
t.Error("a detached worktree with unique commits must not be removed")
}
}
// A detached HEAD whose commit is already upstream is safe to drop, but there is
// no branch to delete, so the verdict must not promise one.
func TestPruneDetachedHeadContainedUpstream(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/detached-clean")
git(t, wt, "checkout", "--detach")
git(t, f.srcDir, "branch", "-D", "benvin/detached-clean")
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true})
assertVerdict(t, out, "HEAD", verdictRemove, "contained in origin/main")
if strings.Contains(out, verdictRemoveBranch) {
t.Errorf("a detached worktree has no branch to delete:\n%s", out)
}
if exists(wt) {
t.Error("a detached worktree contained upstream should have been removed")
}
}
// git refuses to drop a locked worktree, and so does prune.
func TestPruneKeepsLockedWorktree(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/locked")
git(t, f.srcDir, "worktree", "lock", wt)
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true})
assertVerdict(t, out, "benvin/locked", verdictKeep, "locked")
if !exists(wt) {
t.Error("a locked worktree must not be removed")
}
}
// Unproven work is only safe to abandon because the branch keeps it, so the
// reason has to say so rather than leaving the operator to guess.
func TestPruneReasonNamesRetainedBranch(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/unpushed")
writeCommit(t, wt, "u.txt", "u\n", "work")
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{})
assertVerdict(t, out, "benvin/unpushed", verdictRemove, "1 commit on no remote so branch benvin/unpushed is kept")
if !exists(wt) {
t.Error("a dry run must not remove anything")
}
}
// --- flags ----------------------------------------------------------------
// --include-keep is the only way past a keep verdict, and even then the branch
// stays, so the commits survive the worktree.
func TestPruneIncludeKeepRemovesDirtyWorktreeButNotItsBranch(t *testing.T) {
f := newFixture(t)
wt := f.addWorktree(t, "benvin/dirty")
writeCommit(t, wt, "d.txt", "d\n", "work")
if err := os.WriteFile(filepath.Join(wt, "wip.txt"), []byte("wip\n"), 0o644); err != nil {
t.Fatal(err)
}
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true, includeKeep: true})
assertVerdict(t, out, "benvin/dirty", verdictKeep, "dirty")
if !strings.Contains(out, "--include-keep") {
t.Errorf("forcing a keep should warn it is doing so:\n%s", out)
}
if exists(wt) {
t.Error("--include-keep should have removed the dirty worktree")
}
if !agent.GitBranchExists(f.srcDir, "benvin/dirty") {
t.Error("--include-keep must never delete a branch")
}
}
// --no-fetch means origin's refs are whatever is already on disk, so a tracking
// ref cannot prove the work survives: the branch is kept.
func TestPruneNoFetchDistrustsTrackingRefs(t *testing.T) {
f, srv, closed, _ := staleFixture(t, true)
out := runOpts(t, client(srv), pruneOpts{apply: true, noFetch: true})
if !strings.Contains(out, "--no-fetch") {
t.Errorf("output should record that the fetch was skipped:\n%s", out)
}
assertVerdict(t, out, "benvin/stale-closed", verdictRemove, "is unverified")
if exists(closed) {
t.Error("worktree should have been removed")
}
if !agent.GitBranchExists(f.srcDir, "benvin/stale-closed") {
t.Error("an unverified tracking ref must not authorise deleting the branch")
}
}
// --json puts the machine-readable document on stdout alone, with the same
// verdicts the table shows.
func TestPruneJSONOutput(t *testing.T) {
f := newFixture(t)
contained := f.addWorktree(t, "benvin/contained")
dirty := f.addWorktree(t, "benvin/dirty")
if err := os.WriteFile(filepath.Join(dirty, "wip.txt"), []byte("wip\n"), 0o644); err != nil {
t.Fatal(err)
}
srv := fakeGitea(t)
var out, errOut bytes.Buffer
if err := runPrune(&out, &errOut, client(srv), pruneOpts{jsonOut: true}); err != nil {
t.Fatalf("runPrune: %v", err)
}
var entries []reportEntry
if err := json.Unmarshal(out.Bytes(), &entries); err != nil {
t.Fatalf("stdout is not JSON (%v): %s", err, out.String())
}
byBranch := map[string]reportEntry{}
for _, e := range entries {
byBranch[e.Branch] = e
}
if got := byBranch["benvin/contained"]; got.Verdict != verdictRemoveBranch || got.Path != contained || !got.Managed || got.Applied {
t.Errorf("contained entry = %+v", got)
}
if got := byBranch["benvin/dirty"]; got.Verdict != verdictKeep || got.Reason != "dirty" {
t.Errorf("dirty entry = %+v", got)
}
if !exists(contained) {
t.Error("a --json dry run must not remove anything")
}
_ = f
}
// A directory in the source root can itself be a linked worktree. Enumerating
// from there lists the repo's real checkout, which must never be offered up for
// removal — discovery normalises each candidate to its main checkout instead.
func TestPruneNeverOffersAMainCheckout(t *testing.T) {
f := newFixture(t)
sibling := filepath.Join(f.root, "src", "repo-sibling")
git(t, f.srcDir, "worktree", "add", sibling, "-b", "benvin/sibling", "origin/main")
identity(t, sibling)
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{})
for _, line := range strings.Split(out, "\n") {
if fields := strings.Fields(line); len(fields) > 2 && fields[2] == f.srcDir {
t.Errorf("main checkout %s must not be classified: %q", f.srcDir, line)
}
}
assertVerdict(t, out, "benvin/sibling", verdictRemoveBranch, "contained in origin/main")
if strings.Contains(out, "\tmain\t") || lineForBranch(out, "main") != "" {
t.Errorf("the default branch's own checkout must not appear:\n%s", out)
}
}
// lineForBranch is lineFor without the fatal, for asserting a row is absent.
func lineForBranch(out, branch string) string {
for _, line := range strings.Split(out, "\n") {
if fields := strings.Fields(line); len(fields) > 1 && fields[1] == branch {
return line
}
}
return ""
}