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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ precedence, so `y`, `R` and `n` still work while a list is scrolled.
```

- `>` cursor, `[x]` selected, `R` remote deletion armed
- `*` current branch, `+` checked out in another worktree, `P` protected — all three are locked
- `*` 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))
- 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 @@ -174,6 +175,22 @@ claiming the work is unrecoverable.
Deletions then run concurrently in the background on a live progress screen, and a results
screen reports per-branch success or failure.

## Branches checked out in a worktree

git refuses to delete a branch that any worktree has checked out, even with `-D`. git_pruner
marks such a branch with `+` and runs `git worktree remove --force` before it deletes the
branch. The confirmation screen names each worktree it will remove.

- A worktree is scratch space for its branch. The remove deletes the worktree folder and
everything in it, uncommitted changes included, in safe and force mode alike.
- A worktree whose folder is already gone is removed too.
- If the safe delete (`-d`) would refuse the branch as not fully merged, the worktree stays
until you accept the force delete prompt. The branch's commits are the work at risk there.
- git refuses to remove a locked worktree (`git worktree lock`), and the branch stays.
- A branch checked out in the main worktree is locked: git cannot remove the main worktree.
This happens when you run git_pruner from a linked worktree.
- Script mode removes worktrees the same way.

## Selecting merged, old branches

Press `m`, type an age in days (it starts at 90, or at `pruner.staleDays`), and press `enter`.
Expand Down
2 changes: 1 addition & 1 deletion bugs.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
### October 5
- [ ] Git worktrees checked out to other locations are not able to be cleaned up
- [x] Git worktrees checked out to other locations are not able to be cleaned up
8 changes: 7 additions & 1 deletion cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ func runCLI(args []string, stdout, stderr io.Writer) int {
if *dryRun || !*yes {
for _, b := range sel {
line := "would delete " + b.name + " (" + b.deleteFlag(false) + ")"
if w := m.worktreeWarning(b); w != "" {
line += " " + w
}
if w := m.riskWarning(b); w != "" {
line += " " + w
}
Expand All @@ -76,12 +79,15 @@ func runCLI(args []string, stdout, stderr io.Writer) int {
results := make([]deleteResult, len(sel))
var wg sync.WaitGroup
for i, b := range sel {
wg.Go(func() { results[i] = deleteBranch(b, b.deleteFlag(false), false) })
wg.Go(func() { results[i] = deleteBranch(b, false, false) })
}
wg.Wait()

code := 0
for _, r := range results {
if r.worktreeRemoved {
fmt.Fprintf(stdout, "removed worktree %s\n", r.br.worktree)
}
if r.localOK {
fmt.Fprintf(stdout, "deleted %s (was %s)\n", r.br.name, r.br.hash)
} else {
Expand Down
9 changes: 5 additions & 4 deletions docs/improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,11 @@ matters for a local branch literally named like a remote one, e.g. `origin/x`).
`proc_*.go` by an AST script — no hand edits in the move. **13.** `BINDIR` was already
overridable; the Makefile now rebuilds on any `*.go` change.

**New: locked branches.** `branch.locked()` = current, checked out in another worktree
(`%(worktreepath)`, shown `+` like `git branch`), or protected (the trunk plus `pruner.protect`
globs, shown `P`). Locked rows cannot be marked by any key, and `selectedBranches` drops them as a
second guard.
**New: locked branches.** `branch.locked()` = current, checked out in the main worktree, or
protected (the trunk plus `pruner.protect` globs, shown `P`). Locked rows cannot be marked by any
key, and `selectedBranches` drops them as a second guard. A branch in a linked worktree
(`%(worktreepath)`, shown `+` like `git branch`) is not locked: its delete runs `git worktree
remove` first.

**New: remote-only view (`tab`).** Remote branches no local branch tracks, as rows in the same
slice with `remoteOnly` set; `viewIdx` filters by mode, so marks survive a view switch like they
Expand Down
16 changes: 8 additions & 8 deletions features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,9 @@ func TestGoneMergedBranchIsNotFlaggedUnmerged(t *testing.T) {
}
}

// A branch checked out in another worktree is marked + and cannot be selected;
// git would refuse to delete it anyway.
func TestWorktreeBranchIsLocked(t *testing.T) {
// A branch checked out in a linked worktree is marked + and can be selected:
// the delete removes the worktree first (see TestWorktreeBranchDeletes).
func TestWorktreeBranchIsMarked(t *testing.T) {
repo := setupRepo(t)
chdir(t, repo)
git(t, repo, "worktree", "add", "-q", filepath.Join(t.TempDir(), "wt"), "feature/unmerged")
Expand All @@ -250,13 +250,13 @@ func TestWorktreeBranchIsLocked(t *testing.T) {
t.Fatal(err)
}
b := find(m.branches, "feature/unmerged")
if !b.worktree || b.isCurrent {
t.Fatalf("want a worktree branch: %+v", b)
if b.worktree == "" || b.isCurrent || b.locked() {
t.Fatalf("want an unlocked worktree branch: %+v", b)
}
cursorTo(t, &m, "feature/unmerged")
m = press(t, m, key(" "), key("a"))
if find(m.branches, "feature/unmerged").selected {
t.Fatal("a worktree branch must not be selectable")
m = press(t, m, key(" "))
if !find(m.branches, "feature/unmerged").selected {
t.Fatal("a linked worktree branch must be selectable")
}
if row := stripANSI(m.renderRow(*b, 20, false)); !strings.Contains(row, " + feature/unmerged") {
t.Fatalf("the row must carry the + marker: %q", row)
Expand Down
90 changes: 73 additions & 17 deletions git.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ type branch struct {
riskCommits int // commits whose patch is not in the base branch; -D discards them
riskMeasured bool // riskCommits has been computed (0 is a meaningful value)
isCurrent bool
worktree bool // checked out in another worktree; git refuses to delete it
protected bool // matches the default branch or a pruner.protect pattern
remoteOnly bool // a remote branch that no local branch tracks
worktree string // path of another worktree that has the branch checked out; "" when none does
mainWorktree bool // that worktree is the main one, which git cannot remove
protected bool // matches the default branch or a pruner.protect pattern
remoteOnly bool // a remote branch that no local branch tracks
selected bool
deleteRemote bool
}
Expand All @@ -48,8 +49,10 @@ type branch struct {
// a remoteOnly row arms it: deleting the remote branch is all it offers.
func (b branch) remoteArmed() bool { return (b.deleteRemote || b.remoteOnly) && b.upstream != "" }

// locked reports whether b can never be marked for deletion.
func (b branch) locked() bool { return b.isCurrent || b.worktree || b.protected }
// locked reports whether b can never be marked for deletion. A branch in a
// linked worktree is not: the delete removes the worktree first. The main
// worktree cannot be removed, so its branch stays locked.
func (b branch) locked() bool { return b.isCurrent || b.mainWorktree || b.protected }

// ref is b's fully qualified ref (see branchRef).
func (b branch) ref() string {
Expand Down Expand Up @@ -101,6 +104,12 @@ func (b branch) safeDeletable() bool {
// default but not in the local checkout — the headline prune case.
func (b branch) forcedDelete(force bool) bool { return force || b.gone }

// safeDeleteRefuses reports whether the delete of b under the given force mode
// will run -d and be refused as not fully merged.
func (b branch) safeDeleteRefuses(force bool) bool {
return !b.forcedDelete(force) && !b.safeDeletable()
}

// Concurrency caps. Local git work is subprocess-bound — roughly 6ms of spawn
// cost each — so running it a few at a time is what makes a repo full of gone
// branches load quickly. Remote work is not: every push or fetch opens its own
Expand Down Expand Up @@ -298,7 +307,9 @@ func loadBranches() ([]branch, error) {
b.upstream = shortRef(f[0])
b.isCurrent = f[2] == "*"
// %(worktreepath) is set for the current worktree's branch too.
b.worktree = f[3] != "" && !b.isCurrent
if !b.isCurrent {
b.worktree = f[3]
}
b.upRemote, b.upMerge = f[4], f[5]
track := f[1]
if strings.Contains(track, "gone") {
Expand Down Expand Up @@ -539,17 +550,36 @@ func loadRepo(withRemote bool) ([]branch, repoReads, error) {
branches []branch
err error
reads repoReads
mainWT string
)
var wg sync.WaitGroup
wg.Add(4)
wg.Add(5)
go func() { defer wg.Done(); branches, err = loadBranches() }()
go func() { defer wg.Done(); reads.headMerged = localMergedSet() }()
go func() { defer wg.Done(); reads.remotes = loadRemoteRefs(withRemote) }()
go func() { defer wg.Done(); reads.config = loadConfig() }()
go func() { defer wg.Done(); mainWT = mainWorktreePath() }()
wg.Wait()
for i := range branches {
branches[i].mainWorktree = mainWT != "" && branches[i].worktree == mainWT
}
return branches, reads, err
}

// mainWorktreePath returns the path of the repository's main worktree, which git
// always lists first. "" when the list cannot be read.
func mainWorktreePath() string {
out, err := runGit("worktree", "list", "--porcelain", "-z")
if err != nil {
return ""
}
first, _, _ := strings.Cut(out, "\x00")
if path, ok := strings.CutPrefix(first, "worktree "); ok {
return path
}
return ""
}

// loadDiff returns the patch introduced on name relative to its merge-base with
// the repo's default branch — i.e. what the branch contains — and the base it was
// compared against, shortened for display.
Expand Down Expand Up @@ -601,8 +631,9 @@ func (b branch) deleteFlag(force bool) string {

// deleteBranch runs one branch's local delete and, when wantRemote is set, its
// remote-branch push --delete. It is the worker deleteBranchCmd runs off the
// update loop, one cmd per branch.
func deleteBranch(b branch, flag string, wantRemote bool) deleteResult {
// update loop, one cmd per branch. force is the user's force mode, which picks
// the delete flag.
func deleteBranch(b branch, force, wantRemote bool) deleteResult {
res := deleteResult{br: b, done: true}
if b.remoteOnly {
// There is no local branch; the push is the whole job.
Expand All @@ -611,14 +642,25 @@ func deleteBranch(b branch, flag string, wantRemote bool) deleteResult {
}
return res
}
if _, err := runGit("branch", flag, b.name); err != nil {
res.localErr = err.Error()
// Only an unmerged refusal is worth escalating to -D. Other failures — a
// branch held by another worktree, most commonly — fail identically under
// -D, so offering the retry would just mislabel them as lost commits.
res.forceable = flag == "-d" && strings.Contains(res.localErr, "not fully merged")
} else {
res.localOK = true
flag := b.deleteFlag(force)
switch {
case b.worktree != "" && b.safeDeleteRefuses(force):
// git checks the worktree before the merge, so -d would report the
// worktree, not the unmerged commits that the force retry can clear.
// The worktree stays until the user agrees to that retry.
res.localErr = "not fully merged (worktree kept)"
res.forceable = true
case b.worktree != "" && !removeWorktree(&res):
default:
if _, err := runGit("branch", flag, b.name); err != nil {
res.localErr = err.Error()
// Only an unmerged refusal is worth escalating to -D. Other
// failures fail identically under -D, so offering the retry would
// just mislabel them as lost commits.
res.forceable = flag == "-d" && strings.Contains(res.localErr, "not fully merged")
} else {
res.localOK = true
}
}
// Never delete the remote copy while the local branch survives a refused
// delete: that would strand its commits with nowhere else to exist. The push
Expand All @@ -633,6 +675,20 @@ func deleteBranch(b branch, flag string, wantRemote bool) deleteResult {
return res
}

// removeWorktree removes the linked worktree holding res's branch: git refuses to
// delete a branch any worktree has checked out, under -d and -D alike. A
// worktree is scratch space for its branch, so it goes with everything in it,
// uncommitted changes included. A locked worktree is refused: someone locked it
// on purpose. It reports whether the branch is now free to delete.
func removeWorktree(res *deleteResult) bool {
if _, err := runGit("worktree", "remove", "--force", res.br.worktree); err != nil {
res.localErr = "worktree " + res.br.worktree + ": " + cleanText(err.Error())
return false
}
res.worktreeRemoved = true
return true
}

// pushRemoteDelete deletes res's remote branch, clearing any deferral: the
// results screen tests remoteSkipped first, so a stale flag would report the
// remote as kept right after a successful push.
Expand Down
Loading
Loading