Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,11 @@ Terms used throughout the `git-workon` codebase. Implementation details do not b

**Merged(target)** β€” the worktree's branch has been merged into `target`. Only a candidate when `--merged` is passed.

**PrMerged(number)** β€” `gh` reports a merged pull request whose head covers the branch's current tip. Always a candidate, gated only on a GitHub remote and `gh` being usable.

**Explicit** β€” the worktree was named directly as a positional argument to `prune`.

**Branch-only row** β€” a local branch with no worktree, evaluated by `prune` for the same
candidate reasons as a worktree row (default on; `--no-branches` / `workon.pruneBranches =
false` opts out). It never has a working tree, so status filters on `list`/`find` are
unaffected.
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ git workon list --dirty --ahead # filters combine with AND logic

`prune` always analyzes every worktree in scope for every signal (branch deleted, remote gone, merged into target, PR merged) β€” `--gone`/`--merged` don't hide anything, they just decide what counts as an *active* criterion for pre-checking and auto-pruning. A branch-deleted worktree and a worktree whose branch tip is the head of a merged PR are always active. By default, pruning deletes the local branch ref along with the worktree; use `--keep-branch` to preserve it.

Local branches with no worktree checked out are candidates too (default on; `--no-branches` / `workon.pruneBranches = false` opts out), evaluated for the same signals. This is what cleans up a merged stack's sibling branches once the worktree on the stack's other branch is pruned.

```sh
git workon prune # interactive: multi-select picker, pre-checked with the safe default
git workon prune --yes # skip the picker; prune exactly the pre-checked set (for scripting)
Expand All @@ -121,18 +123,21 @@ git workon prune --gone # treat gone-upstream worktrees as active (pre-
git workon prune --gone --fetch # fetch --prune from remotes first so gone status is fresh
git workon prune --merged # treat merged-into-default worktrees as active
git workon prune --merged=release/v2 # merge target other than the default branch
git workon prune --no-branches # only consider worktrees, skip branches with no worktree
git workon prune --keep-branch # prune worktrees but keep local branch refs
git workon prune --allow-dirty # prune even with uncommitted changes
git workon prune --allow-unmerged # prune even with unmerged commits
git workon prune --include-locked # include locked worktrees
git workon prune --force # override all safety checks (protection, dirty, unmerged, locked)
```

Naming a worktree strictly narrows the scope β€” it's never additive with `--gone`/`--merged`. An unmatched name is a hard error listing every miss, before anything is deleted. A named worktree with nothing wrong with it (no signal, not dirty, not unmerged) still shows up β€” annotated "not prunable" β€” but needs `--force` to actually be pruned; naming is how a healthy worktree gets pulled into view, not how it gets deleted. The default worktree never appears, even when named with `--force`.
Naming a worktree or a local branch with no worktree strictly narrows the scope β€” it's never additive with `--gone`/`--merged`. An unmatched name is a hard error listing every miss, before anything is deleted. A named row with nothing wrong with it (no signal, not dirty, not unmerged) still shows up β€” annotated "not prunable" β€” but needs `--force` to actually be pruned; naming is how a healthy row gets pulled into view, not how it gets deleted. The default branch never appears, even when named with `--force`, whether or not it has a worktree.

In an interactive terminal, `prune` opens a checkbox picker (pre-checked rows match the same "safe default" `--yes` would prune) followed by one summary confirm. Non-interactively (`--yes`, `--json`, or no TTY), it prunes the pre-checked set directly.

Safety checks (skipped with `--force`): protected branches (`workon.pruneProtectedBranches`), locked worktrees (`--include-locked`), uncommitted changes (tracked files only when the only signal is a gone upstream; `--allow-dirty`), unmerged commits (skipped when any signal is present; `--allow-unmerged`).
Safety checks (skipped with `--force`): protected branches (`workon.pruneProtectedBranches`), locked worktrees (`--include-locked`), uncommitted changes (tracked files only when the only signal is a gone upstream; `--allow-dirty`), unmerged commits (skipped when any signal is present; `--allow-unmerged`). A branch-only row has no working tree, so it's never dirty or locked.

`--json` reports `"kind": "worktree"` or `"kind": "branch"` on every `pruned`/`skipped` entry, with `"path": null` for a branch-only row.

### Rename a worktree

Expand Down Expand Up @@ -248,6 +253,7 @@ man git-workon
pruneProtectedBranches = release/*
pruneGone = false # prune gone-upstream worktrees by default
pruneFetch = false # fetch from remotes before evaluating gone status
pruneBranches = true # also consider local branches with no worktree

# Stacked diffs (Graphite or gh-stack)
stackModel = auto # "auto", "graphite", "gh-stack", "git", or "none"
Expand Down
15 changes: 11 additions & 4 deletions docs/adr/014-prune-three-phase-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
> `--gone`/`--merged`, list+confirm, and a gone-upstream hint). The safety-check
> ordering and override flags are unchanged; what changed is *visibility* and the
> interaction model.
>
> Amended 2026-09-09: the candidate pool now also includes local branches with no
> worktree, evaluated for the same signals. See "Branch-only rows" below.

## Context

Expand All @@ -25,7 +28,9 @@ v2 collapses this into one analysis pipeline that always runs in full, with `--g
- **`--dry-run`**: prints the same annotated analysis β€” pre-checked / selectable / locked-out, each with its signals β€” and exits. No picker, no deletion.
- **Non-interactive** (`--yes`, `--json`, or no TTY): bare mode prunes exactly the pre-checked set (unchanged from the old `--yes` behavior). Named mode prunes named worktrees when safe, per the rules above.

`--json` extends the existing envelope (`pruned`/`skipped`/`dry_run`) with a `signals` array per entry; `--dry-run --json` populates `pruned` with the would-be-pruned set and leaves `dry_run: true` without deleting anything, same as before.
`--json` extends the existing envelope (`pruned`/`skipped`/`dry_run`) with a `signals` array per entry; `--dry-run --json` populates `pruned` with the would-be-pruned set and leaves `dry_run: true` without deleting anything, same as before. Each entry also carries `"kind": "worktree"` or `"kind": "branch"`, with `"path": null` for a branch-only row.

**Branch-only rows** (added 2026-09-09): the candidate pool also includes every local branch with no worktree (minus the default branch, protected globs, and any branch checked out in a worktree), on by default (`--no-branches` / `workon.pruneBranches = false` opts out). This exists because a merged stack's sibling branches are left behind once the worktree on the stack's other branch is pruned: gh-stack never sets `branch.<name>.remote`, so `RemoteGone` can't fire on those branches, and a squash merge defeats `Merged`. `PrMerged` is often the only signal that can reach them. Branch rows are evaluated for the same signals as worktree rows, checked against the repo directly (there's no worktree path to open) rather than through `WorktreeDescriptor`. `locked` and `dirty` are always false for a branch-only row (there's no working tree to lock or dirty), so only the protected and unmerged safety checks apply, and `--force` overrides protection the same as it would for a worktree row. `--keep-branch` drops branch-only rows entirely before they're shown: there's nothing to keep-branch about a row whose only action is deleting the branch.

## Consequences

Expand All @@ -35,11 +40,13 @@ v2 collapses this into one analysis pipeline that always runs in full, with `--g
- Breaking UX change: `prune <name>` combined with `--gone`/`--merged` used to also sweep in filter-matched worktrees; naming now strictly narrows, never adds.
- Fetch narrows to remotes tracked by named worktrees when names are given, reducing unnecessary network calls.
- The interactive experience moves from "read a static list, type y/n" to "toggle checkboxes, confirm once" β€” more control, at the cost of one more keystroke for the default case (still just Enter, Enter).
- Breaking UX change: `--json` entries now carry a `kind` field, and a branch-only entry's `path` is `null` instead of a string.

## References

- `docs/diagrams/prune-flow.md` β€” full flow diagram
- `git-workon/src/cmd/prune.rs` β€” `Signal`, `PruneRow`, `classify`, `run_interactive`
- `git-workon-lib/src/fetch.rs` β€” `remotes_tracked_by_worktrees`, `prune_fetch`
- `git-workon-lib/src/config.rs` β€” `WorkonConfig::prune_gone`, `WorkonConfig::prune_fetch`
- `git-workon/src/cmd/prune.rs` β€” `Signal`, `PruneRow`, `classify`, `run_interactive`, `build_branch_row`
- `git-workon-lib/src/branch.rs` β€” `branch_has_gone_upstream`, `branch_is_merged_into`, `branch_tip_at_or_behind` (branch-only-row signal checks)
- `git-workon-lib/src/fetch.rs` β€” `remotes_tracked_by_worktrees`, `remotes_tracked_by_branches`, `prune_fetch`
- `git-workon-lib/src/config.rs` β€” `WorkonConfig::prune_gone`, `WorkonConfig::prune_fetch`, `WorkonConfig::prune_branches`
- `git-workon-lib/src/error.rs` β€” `PruneError::NamesNotFound`
37 changes: 22 additions & 15 deletions docs/diagrams/prune-flow.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
# Prune Command (Always-On Analysis + Picker)

`prune` runs one analysis pass over every worktree in scope, then dispatches to one of three interaction modes. `--gone`/`--merged` only decide which signals are "active" (pre-checked / auto-pruned); they never hide a row from the analysis.
`prune` runs one analysis pass over every worktree in scope, plus (by default) every local branch with no worktree, then dispatches to one of three interaction modes. `--gone`/`--merged` only decide which signals are "active" (pre-checked / auto-pruned); they never hide a row from the analysis. `--no-branches` / `workon.pruneBranches = false` drops the branch-only rows before analysis even starts.

```mermaid
flowchart TD
START([git workon prune]) --> SETUP["get_repo()\nget_worktrees()\nload WorkonConfig + pruneProtectedBranches\nresolve effective_gone / effective_fetch"]

SETUP --> SCOPE{names given?}
SCOPE -->|no| POOL["scope = every worktree\nexcept the default one"]
SCOPE -->|yes| MATCH["match each name by\nworktree name or branch name\n(against the same pool, default excluded)"]
START([git workon prune]) --> SETUP["get_repo()\nget_worktrees()\nload WorkonConfig + pruneProtectedBranches\nresolve effective_gone / effective_fetch / effective_branches"]

SETUP --> BPOOL{effective_branches\n&& !keep_branch?}
BPOOL -->|yes| BRANCHPOOL["branch_pool = every local branch\nwith no worktree, minus checked_out\n(every worktree's branch, not just the pool)\nand the default branch"]
BPOOL -->|no| SCOPE
BRANCHPOOL --> SCOPE{names given?}
SCOPE -->|no| POOL["scope = every worktree\nexcept the default one\nbranch_scope = branch_pool"]
SCOPE -->|yes| MATCH["match each name against the worktree pool\nfirst, then branch_pool by branch name\n(default branch excluded from both)"]
MATCH --> MISS{any name\nunmatched?}
MISS -->|yes| ERR["hard error: PruneError::NamesNotFound\nlists ALL misses β€” nothing touched\nnonzero exit"]
MISS -->|no| NAMED["scope = exactly the matched worktrees"]
MISS -->|no| NAMED["scope = matched worktrees\nbranch_scope = matched branches"]

POOL --> FETCH
NAMED --> FETCH

subgraph FETCH0["Phase 0 (optional) β€” Prune-fetch"]
FETCH{effective_fetch?}
FETCH -->|yes| REMOTES["remotes tracked by scope\n(narrowed to named worktrees\nwhen names given)"]
FETCH -->|yes| REMOTES["remotes tracked by scope + branch_scope\n(narrowed to named worktrees/branches\nwhen names given)"]
REMOTES --> DOFETCH["git fetch --prune per remote\nfailure: warn + continue on cached refs"]
FETCH -->|no| ANALYZE
DOFETCH --> ANALYZE
end

subgraph ANALYSIS["Analysis β€” every row in scope, always"]
ANALYZE["build_row() per worktree:\nsignals: BranchDeleted | RemoteGone | Merged(target) | PrMerged(number)\n+ protected / locked / dirty / unmerged"]
ANALYZE["build_row() per worktree, then\nbuild_branch_row() per branch_scope entry\n(worktree rows always first β€” the gh pass\nbelow visits rows in order)\nsignals: BranchDeleted | RemoteGone | Merged(target) | PrMerged(number)\n+ protected / locked / dirty / unmerged\n(branch rows: locked/dirty always false)"]
ANALYZE --> VISIBLE{bare mode?}
VISIBLE -->|yes| FILTERSIG["keep only rows with >=1 signal"]
VISIBLE -->|no named| KEEPALL["keep every named row\n(signal or not)"]
Expand All @@ -44,7 +47,7 @@ flowchart TD
subgraph PICKER_BLOCK["Interactive picker"]
PICKER["locked-out rows (protected/locked,\nnot overridden) -> printed list, not selectable"]
PICKER --> MULTI["picker::multi_select over selectable rows\n(find/list row style + dim prune annotation;\nspace: toggle, a: all, enter: confirm)\ndefaults = pre-checked per active-criteria + safety"]
MULTI --> SUMMARY["one summary confirm:\n'N worktree(s) and their branches will be deleted'\n+ dirty/unmerged + orphaned-stash warnings"]
MULTI --> SUMMARY["one summary confirm:\n'N worktree(s) and their branches will be deleted'\n(branch rows annotated 'no worktree, ...')\n+ dirty/unmerged + orphaned-stash warnings"]
SUMMARY -->|confirmed| EXEC
SUMMARY -->|declined| CANCEL(["Cancelled"])
end
Expand All @@ -65,12 +68,12 @@ flowchart TD
end

subgraph EXEC_BLOCK["Execution"]
EXEC["for each: remove_dir_all\nworktree.prune()\ndelete local branch ref\n(unless --keep-branch or BranchDeleted signal)"]
EXEC --> ORPHAN["warn per orphaned stash\n(collect_orphaned_stashes)"]
EXEC["worktree row: remove_dir_all -> worktree.prune()\n-> delete local branch ref\n(unless --keep-branch or BranchDeleted signal)\nbranch-only row: prune_branch() deletes\nthe branch ref directly, no ordering guard needed"]
EXEC --> ORPHAN["warn per orphaned stash\n(collect_orphaned_stashes β€” worktree rows only)"]
end

TOPRUNE --> JSONQ{--json?}
JSONQ -->|yes| JSONOUT["emit {pruned, skipped, dry_run}\neach entry includes 'signals'\ndry-run: list without deleting"]
JSONQ -->|yes| JSONOUT["emit {pruned, skipped, dry_run}\neach entry includes 'signals' and 'kind'\n('worktree' or 'branch'; branch rows: 'path': null)\ndry-run: list without deleting"]
JSONQ -->|no| CONFIRM{--yes?}
CONFIRM -->|no| DIALOG["dialoguer::Confirm\n(only reachable non-TTY, no --yes)"]
DIALOG -->|confirmed| EXEC
Expand All @@ -96,11 +99,15 @@ flowchart TD

A row can carry more than one signal (e.g. a fresh worktree off the default branch is always trivially `Merged(default)`). `reason_display()`/`annotate()` join every signal present, not just the active ones.

Branch-only rows (a local branch with no worktree) carry the same three real signals (`RemoteGone`, `Merged(target)`, `PrMerged(number)`), checked against the branch directly via `git-workon-lib/src/branch.rs` instead of through `WorktreeDescriptor`. `BranchDeleted` is moot for a branch-only row: the branch enumeration only ever considers branches that still exist, so there's no "branch ref no longer exists" state to detect.

## Key files

- `git-workon/src/cmd/prune.rs` β€” `Signal`, `PruneRow`, `build_row`, `classify`, `is_prechecked`, `run_interactive`, `render_dry_run`, `emit_json`
- `git-workon/src/cmd/prune.rs` β€” `Signal`, `PruneRow`, `build_row`, `build_branch_row`, `classify`, `is_prechecked`, `run_interactive`, `render_dry_run`, `emit_json`, `prune_branch`
- `git-workon/src/picker.rs` β€” `multi_select` (checkbox pick loop shared with the `find` picker's terminal handling)
- `git-workon/src/display.rs` β€” `worktree_display_row`, `format_aligned_rows_annotated` (find/list row style + trailing prune annotation)
- `git-workon-lib/src/worktree.rs` β€” `is_dirty()`, `has_tracked_changes()`, `is_merged_into()`, `has_gone_upstream()`, `is_locked()`
- `git-workon-lib/src/config.rs` β€” `prune_protected_branches()`, `prune_gone()`, `prune_fetch()`
- `git-workon-lib/src/branch.rs` β€” `branch_has_gone_upstream()`, `branch_is_merged_into()`, `branch_tip_at_or_behind()` (the same three checks, against a branch with no worktree)
- `git-workon-lib/src/fetch.rs` β€” `remotes_tracked_by_worktrees()`, `remotes_tracked_by_branches()`
- `git-workon-lib/src/config.rs` β€” `prune_protected_branches()`, `prune_gone()`, `prune_fetch()`, `prune_branches()`
- `git-workon-lib/src/error.rs` β€” `PruneError::NamesNotFound`
Loading
Loading