From 6380270ac6f8c2f010a937320be938e0f8f39977 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 12 Sep 2026 00:16:23 +1000 Subject: [PATCH] Find every stale worktree, not just the managed ones - 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 --- AGENTS.md | 9 + README.md | 38 +++- cmd/agentws/main.go | 235 +++++++++++++++++++++++-- cmd/agentws/prune.go | 250 +++++++++++++++++++++++---- cmd/agentws/prune_test.go | 345 ++++++++++++++++++++++++++++++++++++- internal/agent/git.go | 65 +++++++ internal/agent/git_test.go | 112 +++++++++++- 7 files changed, 990 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf90434..46669a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,5 +197,14 @@ wrapped per stage (login / read denied / write denied) via `ErrVaultDenied`. the worktree goes and the branch stays. `origin/` 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. diff --git a/README.md b/README.md index 2ddeef2..f71803f 100644 --- a/README.md +++ b/README.md @@ -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/` | remove worktree + local branch | | every commit patch-equivalent to one in `origin/`'s history | remove worktree + local branch | | PR merged **and** HEAD contained in the PR's head commit (or in a verified `origin/`) | remove worktree + local branch | | PR closed **and** HEAD contained in a verified `origin/` | 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,25 @@ 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//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`, never their branch | + +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`, and it still leaves the branch, so the commits outlive the worktree. ### Auth / credential-helper design diff --git a/cmd/agentws/main.go b/cmd/agentws/main.go index 27b3258..8cb6dbd 100644 --- a/cmd/agentws/main.go +++ b/cmd/agentws/main.go @@ -13,7 +13,8 @@ // agentws new [--branch benvin/] [--from ] // agentws list // agentws rm [--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 "__" 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 { diff --git a/cmd/agentws/prune.go b/cmd/agentws/prune.go index 659182d..ad92f83 100644 --- a/cmd/agentws/prune.go +++ b/cmd/agentws/prune.go @@ -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/ 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) +} diff --git a/cmd/agentws/prune_test.go b/cmd/agentws/prune_test.go index a8f7254..a499cff 100644 --- a/cmd/agentws/prune_test.go +++ b/cmd/agentws/prune_test.go @@ -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/ 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 "" +} diff --git a/internal/agent/git.go b/internal/agent/git.go index e500fbb..54a1de6 100644 --- a/internal/agent/git.go +++ b/internal/agent/git.go @@ -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() diff --git a/internal/agent/git_test.go b/internal/agent/git_test.go index e63802b..ac8df65 100644 --- a/internal/agent/git_test.go +++ b/internal/agent/git_test.go @@ -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.