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
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ git_pruner version # also --version, -v
| `tab` | Switch between local branches and [remote-only branches](#remote-only-branches) |
| `c` | Checkout the branch under the cursor (`git switch`); on a remote-only row, create a local branch that tracks it |
| `/` | Filter branches by name; `enter` keeps it, `esc` clears it |
| `r` | Toggle "also delete remote" for the row (needs an upstream) |
| `r` | Toggle "also delete remote" for the row (needs an upstream on a remote that is not [protected](#protected-branches)) |
| `v` | View the branch's diff (through [delta](https://github.com/dandavison/delta) when installed) |
| `x` | Select gone branches that hold no unique work (no fetch) |
| `p` | Fetch `--all --prune`, then select gone branches that hold no unique work |
Expand Down Expand Up @@ -102,7 +102,8 @@ precedence, so `y`, `R` and `n` still work while a list is scrolled.

- `>` cursor, `[x]` selected, `R` remote deletion armed
- `*` current branch and `P` protected are locked; `+` checked out in another worktree (see
[Branches checked out in a worktree](#branches-checked-out-in-a-worktree))
[Branches checked out in a worktree](#branches-checked-out-in-a-worktree)); `~` held by a
rebase or bisect in progress (see [Branches held by a rebase or bisect](#branches-held-by-a-rebase-or-bisect))
- ahead/behind shown as `↑N↓M` (`=` when in sync, `gone` in red when the upstream was deleted)
- a green `✓` after the track column means the upstream is merged into the remote default
branch — i.e. the remote is safe to delete
Expand Down Expand Up @@ -166,8 +167,11 @@ claiming the work is unrecoverable.
the confirmation screen flags any commits that would be discarded (see above).
When a `-d` delete is refused for being unmerged, a follow-up prompt lets you retry those
branches with `-D` without leaving the results — no need to back out and re-select.
- Remote: when armed with `r`, runs `git push <remote> --delete <branch>`, where the remote is
derived from the branch's upstream. Because this affects shared history, remote deletion
- Remote: when armed with `r`, runs `git push <remote> --delete <branch>` on the branch's
upstream, as git records it. That upstream need not share the branch's name: a branch made
with `git switch -c feat origin/main` tracks `main`. So `r` refuses when the upstream is the
default branch or protected, and when the upstream is a local branch (`--track main`), which
has no remote branch to delete. Because this affects shared history, remote deletion
requires the explicit `R` key on the confirmation screen — plain `y` deletes locals only.
The confirmation screen also shows, per branch, whether the upstream is merged into the remote
default (`✓ merged` / `⚠ not merged`) to help you judge whether the remote is safe to delete.
Expand All @@ -191,6 +195,13 @@ branch. The confirmation screen names each worktree it will remove.
This happens when you run git_pruner from a linked worktree.
- Script mode removes worktrees the same way.

## Branches held by a rebase or bisect

git also refuses to delete a branch that a rebase or a bisect started from, in any worktree,
even with `-D`. A `git rebase --update-refs` holds every branch in its stack. git_pruner marks
such a branch with `~`, says `rebase in progress` or `bisect in progress` on its row, and locks
it. Finish or abort the operation (`git rebase --abort`, `git bisect reset`) to free it.

## Selecting merged, old branches

Press `m`, type an age in days (it starts at 90, or at `pruner.staleDays`), and press `enter`.
Expand All @@ -209,6 +220,11 @@ confirmation screen counts the commits of any branch that is not.
Deleting one is a push, so only `R` does it; `y` never touches a remote. The exit summary
prints the `git push` command that puts a deleted remote branch back.

Each row is mapped back to its branch on the remote through the remote's fetch refspecs
(`remote.<name>.fetch`), so a refspec that renames branches deletes the right one. A row that
maps to something other than one remote branch is not listed: pull request refs fetched with
`+refs/pull/*/head:refs/remotes/origin/pr/*` are not branches a push can delete.

## Protected branches

The default branch is always protected. Add your own with name globs:
Expand All @@ -219,7 +235,9 @@ git config --add pruner.protect develop
```

A protected branch shows `P` and cannot be selected by any key. Remote rows are matched by the
branch part of their name, so `release/*` covers `origin/release/1.0` too.
branch part of their name, so `release/*` covers `origin/release/1.0` too. The same check
applies to the remote branch a local branch tracks: `r` cannot arm the delete of
`origin/release/1.0` from a local `hotfix` made from it.

## Script mode

Expand All @@ -242,6 +260,11 @@ stop after 60 seconds. A remote that asks for a password or does not answer fail
instead of freezing the screen. Use a credential helper or an ssh agent for remotes that need a
login.

In a partial clone (`git clone --filter=blob:none`), git fetches file contents when it needs
them, so `git cherry` (the commit count) and `git diff` (`v`) can also reach the remote. They get
the same limits there. A commit count that fails is shown as unknown, never as zero, so `x` and
`p` leave that branch unselected.

## Settings

The sort field and order are saved in `~/Library/Application Support/git_pruner/settings`
Expand Down
6 changes: 6 additions & 0 deletions bugs.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
### October 5
- [x] Git worktrees checked out to other locations are not able to be cleaned up

### October 9
- [x] Branches held by a rebase, bisect or `rebase --update-refs` showed as free, and their delete failed
- [x] `r` could delete the wrong remote branch: a protected or default upstream, or `origin`'s branch for a local upstream
- [x] Remote-only rows ignored fetch refspecs and could delete a teammate's branch of the same short name
- [x] Partial clones: `git cherry` failing offline counted as zero risky commits, so `x` selected unmerged gone branches
60 changes: 60 additions & 0 deletions docs/test-coverage-gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,66 @@ Combined with a multibyte branch name, pinned end to end through parse, render,

---

## Tier 4 — git state outside the branch list — **DONE 2026-10-09**

The linked-worktree bug (`bugs.md`, October 5) had one cause: git keeps state outside
`refs/heads` that changes what a delete does, and the tool did not read it. This sweep looked
for more of that shape. Every claim was reproduced in a throwaway repo first.

### 15. Rebase, bisect and `rebase --update-refs` hold branches *(confirmed — fixed)*

git refuses `branch -d` and `-D` on a branch a rebase or bisect started from, in any worktree,
and on every branch a `rebase --update-refs` will move. The worktree's HEAD is detached, so
`%(HEAD)` and `%(worktreepath)` are both empty and the tool showed the branch as free. Fixed by
`loadHeldBranches`, which reads each worktree's `rebase-merge/head-name`,
`rebase-apply/head-name`, `rebase-merge/update-refs` and `BISECT_START`, following git's
`prepare_checked_out_branches`. Held branches show `~` and are locked.
*(`TestHeldBranchesAreLocked`)*

### 16. `r` armed the delete of the wrong remote branch *(confirmed — fixed)*

`r` targets the upstream, which need not share the branch's name. Reproduced:

- `hotfix` made from `origin/release/1.0` with `pruner.protect 'release/*'`: the push deleted
`release/1.0`. The protect check only looked at the local name.
- `git switch -c feat origin/main`: the push tried to delete `main`. Only the remote's own
`receive.denyDeleteCurrent` stopped it.
- `--track main` (remote `.`): `remoteName` fell back to `origin`, so the push tried to delete
`origin`'s `main`, a remote the branch never used.

Fixed by `remoteProtected` (trunk and protect checks on `remoteBranch()`) and
`remoteDeletable()`, which also refuses a local upstream. *(`TestRemoteDeleteRefusesWrongTargets`)*

### 17. Remote rows ignored fetch refspecs *(confirmed — fixed)*

With `+refs/heads/jb/*:refs/remotes/origin/*`, the row `origin/old` is `jb/old` on the remote,
but the delete pushed `refs/heads/old` and removed a teammate's branch. `mapRemoteRows` now maps
each row back through `remote.<name>.fetch`. Rows that map to no branch, or to more than one
source (the usual pull request refspec next to the default one), are dropped. Every remote's
trunk is now protected in the remote view, not only the default remote's.
*(`TestRemoteRowsFollowFetchRefspecs`)*

### 18. Partial clones reach the network from `cherry` and `diff` *(confirmed — fixed)*

In a `--filter=blob:none` clone, `git cherry` fetches blobs when two commits touch the same
files, and `git diff` fetches them for the `v` view. `runGit` treated both as local: no
timeout and no ssh prompt guard. Worse, with the remote unreachable `cherry` exits 128, and
`riskCommitCountRef` read that as **zero** commits at risk, so `x` selected a gone branch whose
unique commit `-D` then discarded. Fixed by `lazyFetch` (set from `remote.*.promisor` or
`extensions.partialClone`), which makes both commands network-bound, and by `riskUnknown`,
which `x`, `p` and the confirm screens treat as risky. *(`TestPartialCloneRiskIsNotGuessed`)*

`git switch` (`c`) also fetches in a partial clone, but stays local: the 60 s timeout would
kill a checkout partway and leave `index.lock` behind.

### Checked, no change needed

- Worktree folder deleted by hand: `git worktree remove --force` still succeeds.
- Bare repo with worktrees: its HEAD branch shows `*` and is locked, though git would delete
it. The safe side.
- `git am` in progress: HEAD stays on the branch, so it already shows as current.
- Locked worktree: the remove is refused as designed.

## Ref handling — the rule this work established

**Never use git's `%(refname:short)`, `%(upstream:short)`, or `symbolic-ref --short`.** They
Expand Down
Loading
Loading