Find every stale worktree, not just the managed ones #14

Merged
benvin merged 3 commits from benvin/worktree-gc into main 2026-09-12 00:46:40 +10:00
7 changed files with 1182 additions and 68 deletions
+9
View File
@@ -197,5 +197,14 @@ wrapped per stage (login / read denied / write denied) via `ErrVaultDenied`.
the worktree goes and the branch stays. `origin/<branch>` is only evidence when
this run's pruning fetch succeeded; a failed fetch leaves stale tracking refs,
so those verdicts fall back to keeping the branch.
- `agentws prune` discovers worktrees from the worktree root *and* from
`git worktree list` on each source checkout, merging the two so git's own
`locked`/`prunable` flags reach entries the directory scan already found.
Removing a worktree is only safe because the local branch keeps its commits, so
the cases with no branch to fall back on are kept: a detached HEAD carrying
commits on no remote, a locked checkout, or one with a sequencer operation
half-finished (`rebase-merge`, `MERGE_HEAD`, `CHERRY_PICK_HEAD`, …). A directory
whose backing repo is gone is deleted outright, but only ever inside the
worktree root.
- CI "combined status" comes from `/commits/{sha}/status`; an empty head SHA
yields an empty state without an API call.
+41 -4
View File
@@ -104,8 +104,10 @@ agentws list
agentws rm benvin/my-change
agentws rm ~/.cache/agentws/argocd-apps__benvin-my-change --delete-branch
# Classify every managed worktree; dry run unless --yes is given
# Classify every worktree found; dry run unless --yes is given
agentws prune
agentws prune --json
agentws prune --no-fetch
agentws prune --yes
agentws prune --yes --keep-branches
@@ -118,16 +120,26 @@ agentws token
### prune
`agentws prune` decides, per worktree, whether its work is safely upstream:
`agentws prune` finds worktrees two ways and merges the results: the managed
directories under the worktree root, and `git worktree list` on every source
checkout — so hand-made worktrees, stale registrations whose directory is gone,
and leftover directories whose source repo was deleted all show up too.
It then decides, per worktree, whether its work is safely upstream:
| Signal (first match wins) | Verdict |
|---|---|
| uncommitted or untracked changes | keep |
| working tree gone (registration only) | prune the registration |
| backing repo gone | delete the leftover directory |
| locked by `git worktree lock` | keep |
| rebase, merge, cherry-pick, revert or bisect in progress | keep |
| uncommitted, staged or untracked changes | keep |
| branch has an open PR | keep |
| tip contained in `origin/<default>` | remove worktree + local branch |
| every commit patch-equivalent to one in `origin/<default>`'s history | remove worktree + local branch |
| PR merged **and** HEAD contained in the PR's head commit (or in a verified `origin/<branch>`) | remove worktree + local branch |
| PR closed **and** HEAD contained in a verified `origin/<branch>` | remove worktree + local branch |
| detached HEAD carrying commits on no remote | keep |
| anything else | remove worktree, keep the branch |
A branch is deleted only where git proves its commits survive elsewhere. PR
@@ -155,7 +167,32 @@ hits the pagination cap is reported rather than read as "no PR". Matching a
branch to its PR uses `head.label`, since Gitea rewrites `head.ref` to
`refs/pull/<n>/head` once the branch is deleted on merge.
`--keep-branches` removes worktrees only, and its verdicts print as `remove`.
The last row is safe only because the local branch keeps the commits, so the
reason names the branch it is relying on. A detached HEAD has no such branch, so
unique commits there are kept instead.
Output is a table (`REPO BRANCH PATH VERDICT REASON`) with every verdict's reason
spelled out, or `--json` for scripting. Neither form needs a terminal.
| Flag | Effect |
|---|---|
| `--yes` | apply the plan; without it nothing is touched |
| `--keep-branches` | remove worktrees only; verdicts print as `remove` |
| `--no-fetch` | judge against the refs already on disk, for offline use |
| `--json` | emit the report as JSON on stdout, notes on stderr |
| `--include-unmanaged` | also remove worktrees outside the worktree root |
| `--include-keep` | dangerous: also remove worktrees classified `keep`, destroying uncommitted and in-progress work |
Without `--include-unmanaged` a hand-made worktree is reported and then skipped,
naming the flag that would remove it. `--include-keep` is the only way past a
`keep`. It leaves the branch, so committed work outlives the worktree, but
`git worktree remove --force` discards a dirty working tree and a paused
rebase's sequencer state without a word, and no branch was carrying those.
A directory under the worktree root is deleted outright only when its git dir
and the repo's shared `.git` are both proven absent by `stat`. When git merely
fails to answer for a checkout, the verdict is `keep` with the error as its
reason: an unread state is never a dead one.
### Auth / credential-helper design
+286 -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,14 @@ 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
switch {
case w.orphan:
branch = "(orphan)"
case w.inspectErr != nil:
branch = "(unreadable)"
}
_, _ = fmt.Fprintf(out, "%s\t%s\t%s\n", w.repo, branch, w.path)
}
return nil
},
@@ -241,10 +250,31 @@ 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 git dir is
// proven gone, so no git state can be read from it ever again.
orphan bool
// inspectErr is set when git refused to answer for a checkout and the reason
// was not a proven-absent backing repo. The state is unknown, never removable.
inspectErr error
}
// 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 is proven gone are returned as orphans rather
// than dropped, so callers can see (and clean up) the leftovers; a directory git
// merely failed to answer for is returned with its error instead, because an
// unread state must never be mistaken for a dead one.
func managedWorktrees() ([]managedWt, error) {
wr, err := worktreeRoot()
if err != nil {
@@ -263,24 +293,238 @@ 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 {
entry := managedWt{repo: repoFromDirName(e.Name()), path: path, managed: true}
if gone, err := backingRepoGone(path); err == nil && gone {
entry.orphan = true
} else if branchErr != nil {
entry.inspectErr = branchErr
} else {
entry.inspectErr = srcErr
}
out = append(out, entry)
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
}
// backingRepoGone proves, by stat alone, that a worktree directory's backing
// repo no longer exists: its .git file names a git dir that is absent, and the
// repo's shared .git the git dir lived in is absent too. Only that pair licenses
// deleting the directory. Every other outcome — an unreadable .git file, a git
// dir still on disk, a stat that failed for any reason other than "not there",
// or a mere lost registration in a repo that is still present — reports false,
// so a transient or unexplained failure can never be read as "safe to delete".
func backingRepoGone(path string) (bool, error) {
dot := filepath.Join(path, ".git")
info, err := os.Lstat(dot)
if err != nil {
return false, err
}
if info.IsDir() {
return false, nil // a standalone checkout, not a linked worktree
}
data, err := os.ReadFile(dot)
if err != nil {
return false, err
}
rest, ok := strings.CutPrefix(strings.TrimSpace(string(data)), "gitdir:")
if !ok {
return false, fmt.Errorf("%s: not a worktree gitdir pointer", dot)
}
gitDir := strings.TrimSpace(rest)
if gitDir == "" {
return false, fmt.Errorf("%s: empty gitdir", dot)
}
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
}
// The git dir is "<repo>/.git/worktrees/<name>"; both it and the shared .git
// it sits in must be absent before the repo counts as gone.
for _, dir := range []string{gitDir, filepath.Dir(filepath.Dir(gitDir))} {
if _, err := os.Stat(dir); err == nil {
return false, nil
} else if !os.IsNotExist(err) {
return false, err
}
}
return true, 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 +556,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 +567,18 @@ 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.inspectErr != nil:
// No srcDir to act through and no idea what is in there; --include-keep
// must not turn that into a delete.
return fmt.Errorf("refusing to remove %s: git state unreadable: %w", wt.path, wt.inspectErr)
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 +596,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 {
+226 -41
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). Destroys uncommitted changes and paused rebase/merge state, which no branch is carrying; only the branch itself survives")
f.BoolVar(&opts.includeUnmanaged, "include-unmanaged", false, "Also remove worktrees that live outside the worktree root")
return cmd
}
@@ -72,58 +103,153 @@ 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
}
byRepo := map[string][]managedWt{}
for _, w := range managed {
byRepo[w.srcDir] = append(byRepo[w.srcDir], w)
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{}
var results []pruneResult
for _, w := range worktrees {
switch {
case w.inspectErr != nil:
// Unknown is not gone: a checkout git refused to answer for keeps.
results = append(results, pruneResult{wt: w, verdict: verdictKeep, reason: "inspection failed: " + oneLine(w.inspectErr.Error())})
case w.orphan:
results = append(results, pruneResult{wt: w, verdict: verdictRemove, reason: "backing repo gone, no git state to read"})
default:
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()})
results = append(results, pruneResult{wt: w, verdict: verdictKeep, reason: "repo state unknown: " + oneLine(err.Error())})
}
continue
}
for _, w := range byRepo[srcDir] {
res, err := classify(w, ctx)
if err != nil {
res = pruneResult{wt: w, verdict: verdictKeep, reason: "inspection failed: " + err.Error()}
res = pruneResult{wt: w, verdict: verdictKeep, reason: "inspection failed: " + oneLine(err.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()
}
// oneLine flattens a git error onto a single line so one row stays one row.
func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}
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 +257,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 +372,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 +433,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 +450,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 +458,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 +466,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)
}
+445 -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,433 @@ 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")
}
}
// --include-unmanaged only lifts the location gate. A keep verdict is a separate
// gate, so a dirty hand-made worktree still needs --include-keep to be touched.
func TestPruneIncludeUnmanagedStillObeysKeep(t *testing.T) {
f := newFixture(t)
manual := f.addManualWorktree(t, "benvin/by-hand")
if err := os.WriteFile(filepath.Join(manual, "scratch.txt"), []byte("wip\n"), 0o644); err != nil {
t.Fatal(err)
}
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true, includeUnmanaged: true})
assertVerdict(t, out, "benvin/by-hand", verdictKeep, "dirty")
if !exists(filepath.Join(manual, "scratch.txt")) {
t.Error("--include-unmanaged must not remove a worktree classified keep")
}
}
// 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")
}
}
// addForeignWorktree clones a second repo under the source root and gives it a
// worktree under the worktree root, so it can be broken without touching the
// fixture's own repo. It returns the clone and the worktree path.
func (f *fixture) addForeignWorktree(t *testing.T, repo, branch string) (string, string) {
t.Helper()
src := filepath.Join(f.root, "src", repo)
git(t, filepath.Join(f.root, "src"), "clone", f.bare, src)
identity(t, src)
wt := filepath.Join(f.wtRoot, agent.WorktreeDirName(repo, branch))
git(t, src, "worktree", "add", wt, "-b", branch, "origin/main")
return src, wt
}
// The regression this guards: git failing for a reason that is not "the backing
// repo is gone" used to be read as orphan, and orphan deletes the directory
// outright. Here the git dir is still on disk — only its commondir pointer is
// broken, as a half-written or momentarily unreachable repo would be — so the
// verdict must be keep and the directory must survive a --yes run.
func TestPruneKeepsWorktreeWhenGitFailsButRepoExists(t *testing.T) {
f := newFixture(t)
src, wt := f.addForeignWorktree(t, "other", "benvin/unreadable")
writeCommit(t, wt, "u.txt", "u\n", "work")
gitDir, err := agent.GitDir(wt)
if err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(gitDir, "commondir")); err != nil {
t.Fatal(err)
}
if _, err := agent.GitCurrentBranch(wt); err == nil {
t.Fatal("fixture did not break git in the worktree")
}
if !exists(gitDir) || !exists(filepath.Join(src, ".git")) {
t.Fatal("fixture removed the backing repo; it must still be present")
}
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true})
assertVerdict(t, out, "-", verdictKeep, "inspection failed")
if !exists(wt) {
t.Error("a worktree git could not be read must not be deleted")
}
}
// The same rule one step further in: the registration is gone but the repo is
// not, so the directory is still recoverable and must not be deleted.
func TestPruneKeepsWorktreeWhenOnlyRegistrationIsGone(t *testing.T) {
f := newFixture(t)
src, wt := f.addForeignWorktree(t, "other", "benvin/deregistered")
gitDir, err := agent.GitDir(wt)
if err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(gitDir); err != nil {
t.Fatal(err)
}
if !exists(filepath.Join(src, ".git")) {
t.Fatal("fixture removed the backing repo; it must still be present")
}
srv := fakeGitea(t)
out := runOpts(t, client(srv), pruneOpts{apply: true})
assertVerdict(t, out, "-", verdictKeep, "inspection failed")
if !exists(wt) {
t.Error("a worktree whose repo still exists must not be deleted")
}
}
// --include-keep overrides a keep, but it must not become a second route to the
// deletion finding #1 closed: with no readable git state there is nothing to
// remove through, so the removal fails loudly and the directory stays.
func TestPruneIncludeKeepDoesNotDeleteUnreadableWorktree(t *testing.T) {
f := newFixture(t)
_, wt := f.addForeignWorktree(t, "other", "benvin/forced")
gitDir, err := agent.GitDir(wt)
if err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(gitDir, "commondir")); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
err = runPrune(&out, &errOut, client(fakeGitea(t)), pruneOpts{apply: true, includeKeep: true})
if err == nil {
t.Error("forcing removal of an unreadable worktree should fail, not succeed silently")
}
if !exists(wt) {
t.Error("--include-keep must not delete a worktree whose git state is unknown")
}
}
// --- 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 ""
}
+65
View File
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
@@ -25,6 +26,10 @@ type Worktree struct {
Branch string // short branch name ("" when detached or bare)
Bare bool
Detached bool
Locked bool
// Prunable is git's own reason a registration is stale (e.g. "gitdir file
// points to non-existent location"); empty when the worktree is intact.
Prunable string
}
// runGit runs git with args, using dir as the working directory (empty = the
@@ -114,6 +119,54 @@ func GitIsDirty(dir string) (bool, error) {
return strings.TrimSpace(out) != "", nil
}
// GitDir returns the absolute path to the git directory backing the checkout at
// dir (per-worktree, unlike GitCommonDir).
func GitDir(dir string) (string, error) {
return runGit(dir, "rev-parse", "--path-format=absolute", "--git-dir")
}
// inProgressMarkers maps a sentinel inside the git dir to the operation it means
// is half-finished. Such a checkout holds state that lives nowhere else.
var inProgressMarkers = []struct{ path, op string }{
{"rebase-merge", "rebase"},
{"rebase-apply", "rebase"},
{"MERGE_HEAD", "merge"},
{"CHERRY_PICK_HEAD", "cherry-pick"},
{"REVERT_HEAD", "revert"},
{"BISECT_LOG", "bisect"},
}
// GitInProgressOp names the sequencer operation underway in the checkout at dir,
// or "" when none is.
func GitInProgressOp(dir string) (string, error) {
gitDir, err := GitDir(dir)
if err != nil {
return "", err
}
for _, m := range inProgressMarkers {
if _, err := os.Stat(filepath.Join(gitDir, m.path)); err == nil {
return m.op, nil
} else if !os.IsNotExist(err) {
return "", err
}
}
return "", nil
}
// GitCommitsNotOnRemotes counts commits reachable from HEAD that no
// remote-tracking ref holds, i.e. work that exists only in this checkout.
func GitCommitsNotOnRemotes(dir string) (int, error) {
out, err := runGit(dir, "rev-list", "--count", "HEAD", "--not", "--remotes")
if err != nil {
return 0, err
}
n, err := strconv.Atoi(strings.TrimSpace(out))
if err != nil {
return 0, fmt.Errorf("parse rev-list count %q: %w", out, err)
}
return n, nil
}
// GitIsAncestor reports whether ancestor is reachable from descendant.
func GitIsAncestor(repoDir, ancestor, descendant string) (bool, error) {
cmd := exec.Command("git", "merge-base", "--is-ancestor", ancestor, descendant)
@@ -277,6 +330,18 @@ func ParseWorktreeList(out string) []Worktree {
if cur != nil {
cur.Detached = true
}
case "locked":
if cur != nil {
cur.Locked = true
}
case "prunable":
if cur != nil {
// git omits the reason when it has none, so record the flag itself.
cur.Prunable = val
if cur.Prunable == "" {
cur.Prunable = "prunable"
}
}
}
}
flush()
+110 -2
View File
@@ -42,10 +42,20 @@ 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) != 3 {
t.Fatalf("got %d worktrees, want 3: %+v", len(wts), wts)
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])
@@ -56,6 +66,104 @@ detached
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.