diff --git a/README.md b/README.md index 60fa68a..1f4f043 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`. diff --git a/bugs.md b/bugs.md index 73d1132..604ac8d 100644 --- a/bugs.md +++ b/bugs.md @@ -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 diff --git a/cli.go b/cli.go index bb5a3e7..e5e1155 100644 --- a/cli.go +++ b/cli.go @@ -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 } @@ -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 { diff --git a/docs/improvements.md b/docs/improvements.md index 5eafbe0..bb12c4e 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -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 diff --git a/features_test.go b/features_test.go index aac0b49..4a81865 100644 --- a/features_test.go +++ b/features_test.go @@ -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") @@ -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) diff --git a/git.go b/git.go index d127fb0..218ac4f 100644 --- a/git.go +++ b/git.go @@ -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 } @@ -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 { @@ -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 @@ -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") { @@ -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. @@ -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. @@ -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 @@ -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. diff --git a/main_test.go b/main_test.go index 6057469..cbf1712 100644 --- a/main_test.go +++ b/main_test.go @@ -509,7 +509,7 @@ func TestDeleteBranchCmd(t *testing.T) { chdir(t, repo) // Local-only safe delete of the merged branch. - msg := deleteBranchCmd(2, branch{name: "feature/merged"}, "-d", false)() + msg := deleteBranchCmd(2, branch{name: "feature/merged"}, false, false)() dm, ok := msg.(branchDeletedMsg) if !ok { t.Fatalf("want branchDeletedMsg, got %T", msg) @@ -523,7 +523,7 @@ func TestDeleteBranchCmd(t *testing.T) { // Force delete + remote push of the tracked branch. tracked := branch{name: "feature/tracked", upstream: "origin/feature/tracked", ahead: 1} - dm2 := deleteBranchCmd(0, tracked, "-D", true)().(branchDeletedMsg) + dm2 := deleteBranchCmd(0, tracked, true, true)().(branchDeletedMsg) if !dm2.res.localOK { t.Fatalf("force local delete failed: %s", dm2.res.localErr) } @@ -1387,33 +1387,29 @@ func TestNonUnmergedFailureIsNotForceable(t *testing.T) { repo := setupRepo(t) chdir(t, repo) - // A second worktree holds feature/unmerged; git refuses to delete it under - // -d and -D alike. + // A locked worktree holds the merged branch: git refuses to remove it, so + // the branch cannot be freed under -d or -D. wt := t.TempDir() + "/wt" - git(t, repo, "worktree", "add", "-q", wt, "feature/unmerged") + git(t, repo, "worktree", "add", "-q", wt, "feature/merged") + git(t, repo, "worktree", "lock", wt) - if _, err := runGit("branch", "-D", "feature/unmerged"); err == nil { - t.Fatal("precondition: -D should also fail for a branch held by a worktree") + m, err := initialModel() + if err != nil { + t.Fatal(err) } - - b := branch{name: "feature/unmerged"} - res := deleteBranch(b, "-d", false) + b := *find(m.branches, "feature/merged") + res := deleteBranch(b, false, false) if res.localOK { t.Fatalf("delete should have failed: %+v", res) } if res.forceable { - t.Fatalf("a worktree conflict must not be offered as force-retryable: %q", res.localErr) + t.Fatalf("a locked worktree must not be offered as force-retryable: %q", res.localErr) } // End to end: the async path lands on the results screen, not the prompt. - m, err := initialModel() - if err != nil { - t.Fatal(err) - } - m.results = []deleteResult{{br: branch{name: "feature/unmerged"}}} + m.results = []deleteResult{{br: b}} m.state = stateDeleting - msg := deleteBranchCmd(0, *find(m.branches, "feature/unmerged"), "-d", false)() - nm, _ := m.Update(msg) + nm, _ := m.Update(deleteBranchCmd(0, b, false, false)()) if got := nm.(model).state; got != stateResult { t.Fatalf("state should be stateResult, got %v", got) } @@ -1690,7 +1686,7 @@ func TestGoneCurrentBranchIsNotPruned(t *testing.T) { } // Were it to reach git, git refuses — a failure -D cannot rescue, so it // must not be offered a retry that fails identically. - if r := deleteBranch(*b, "-d", false); r.localOK || r.forceable { + if r := deleteBranch(*b, false, false); r.localOK || r.forceable { t.Fatalf("deleting the checked-out branch must fail, unforceable: %+v", r) } if find(m.branches, "feature/tracked") == nil { @@ -2797,3 +2793,196 @@ func TestDiffRerendersOnResize(t *testing.T) { t.Fatalf("the view must show delta's lines and name it:\n%s", o) } } + +// result returns m's deletion result for the named branch, failing if none ran. +func result(t *testing.T, m model, name string) deleteResult { + t.Helper() + for _, r := range m.results { + if r.br.name == name { + return r + } + } + t.Fatalf("no deletion result for %s: %+v", name, m.results) + return deleteResult{} +} + +// confirmBody returns the body lines of m's confirmation screen. +func (m model) confirmBody() []string { + _, body, _ := m.confirmParts() + return body +} + +// realTempDir is t.TempDir with symlinks resolved, so its paths match the +// ones git reports (macOS puts temp dirs under /var, a link to /private/var). +func realTempDir(t *testing.T) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return dir +} + +// dirExists reports whether path is present on disk. +func dirExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// git refuses to delete a branch any worktree has checked out, under -d and -D +// alike, so the delete has to remove the worktree first. That holds for a +// worktree whose directory was deleted by hand too: git still records it until +// it is pruned. +func TestWorktreeBranchDeletes(t *testing.T) { + repo := setupLocalRepo(t) + chdir(t, repo) + git(t, repo, "branch", "feature/stale") + wt := filepath.Join(realTempDir(t), "wt") + stale := filepath.Join(realTempDir(t), "stale") + git(t, repo, "worktree", "add", "-q", wt, "feature/merged") + git(t, repo, "worktree", "add", "-q", stale, "feature/stale") + if err := os.RemoveAll(stale); err != nil { + t.Fatal(err) + } + + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"feature/merged", "feature/stale"} { + b := find(m.branches, name) + if b.worktree == "" || b.mainWorktree { + t.Fatalf("precondition: %s should be held by a linked worktree: %+v", name, b) + } + b.selected = true + } + m.force = false + m = startAndDrain(t, m, false) + + wantState(t, m, stateResult, "both deletes should succeed") + for _, name := range []string{"feature/merged", "feature/stale"} { + if r := result(t, m, name); !r.worktreeRemoved || !r.localOK { + t.Fatalf("%s: want worktree removed and branch deleted: %+v", name, r) + } + if find(m.branches, name) != nil { + t.Fatalf("%s should be gone after the delete", name) + } + } + if dirExists(wt) { + t.Fatal("the worktree directory should be removed") + } + if out := git(t, repo, "worktree", "list", "--porcelain"); strings.Contains(out, "feature/") { + t.Fatalf("git should no longer record either worktree:\n%s", out) + } + if body := strings.Join(appendResultLines(nil, result(t, m, "feature/merged")), "\n"); !strings.Contains(body, "removed worktree "+wt) { + t.Fatalf("the results must name the removed worktree:\n%s", body) + } +} + +// A worktree is scratch space for its branch: even a safe delete removes it +// with everything in it, changed and untracked files included. +func TestDirtyWorktreeIsRemoved(t *testing.T) { + repo := setupLocalRepo(t) + chdir(t, repo) + wt := filepath.Join(realTempDir(t), "wt") + git(t, repo, "worktree", "add", "-q", wt, "feature/merged") + if err := os.WriteFile(filepath.Join(wt, "a"), []byte("changed"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wt, "scratch"), []byte("untracked"), 0o644); err != nil { + t.Fatal(err) + } + + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + find(m.branches, "feature/merged").selected = true + m.force = false + if body := strings.Join(m.confirmBody(), "\n"); !strings.Contains(body, "remove worktree "+wt+" and everything in it") { + t.Fatalf("the confirm must say the worktree goes with its contents:\n%s", body) + } + m = startAndDrain(t, m, false) + + wantState(t, m, stateResult, "a dirty worktree is removed, not refused") + if r := result(t, m, "feature/merged"); !r.localOK || !r.worktreeRemoved { + t.Fatalf("safe mode should remove the dirty worktree and the branch: %+v", r) + } + if dirExists(wt) || find(m.branches, "feature/merged") != nil { + t.Fatal("the worktree and the branch should both be gone") + } +} + +// When -d is going to refuse the branch as unmerged, removing its worktree +// first would leave the user with neither: the branch survives, the worktree +// does not. The worktree stays until the user accepts the force retry. +func TestUnmergedWorktreeKeptUntilForce(t *testing.T) { + repo := setupLocalRepo(t) + chdir(t, repo) + wt := filepath.Join(realTempDir(t), "wt") + git(t, repo, "worktree", "add", "-q", wt, "feature/unmerged") + + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + find(m.branches, "feature/unmerged").selected = true + m.force = false + m = startAndDrain(t, m, false) + + wantState(t, m, stateForcePrompt, "an unmerged branch must still raise the force prompt") + if r := result(t, m, "feature/unmerged"); r.worktreeRemoved || !r.forceable { + t.Fatalf("the worktree must be kept and the delete forceable: %+v", r) + } + if !dirExists(wt) { + t.Fatal("the worktree must survive the refused -d") + } + if _, body, _ := m.forcePromptParts(); !strings.Contains(strings.Join(body, "\n"), "worktree "+wt+" will be removed first") { + t.Fatalf("the force prompt must name the worktree it removes:\n%s", strings.Join(body, "\n")) + } + + m = press(t, m, key("y")) + if r := result(t, m, "feature/unmerged"); !r.localOK || !r.worktreeRemoved { + t.Fatalf("the force retry should remove the worktree and the branch: %+v", r) + } + if dirExists(wt) || find(m.branches, "feature/unmerged") != nil { + t.Fatal("the worktree and the branch should both be gone after the force retry") + } +} + +// Run from a linked worktree, the branch checked out in the main worktree is as +// undeletable as the current one: git cannot remove the main worktree to free it. +func TestMainWorktreeBranchCannotBeMarked(t *testing.T) { + repo := setupLocalRepo(t) + addOrigin(t, repo, "main") + git(t, repo, "push", "-q", "-u", "origin", "feature/merged") + git(t, repo, "branch", "-q", "-u", "origin/feature/merged", "main") + git(t, repo, "push", "-q", "origin", "--delete", "feature/merged") + git(t, repo, "fetch", "-q", "--prune") + wt := filepath.Join(realTempDir(t), "wt") + git(t, repo, "worktree", "add", "-q", wt, "feature/unmerged") + chdir(t, wt) + + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + b := find(m.branches, "main") + if b == nil || !b.mainWorktree || b.isCurrent { + t.Fatalf("precondition: main should be held by the main worktree: %+v", b) + } + for i := range m.branches { + if m.branches[i].name == "main" { + m.cursor = i + } + } + m = press(t, m, key(" "), key("a"), key("x")) + for _, b := range m.selectedBranches() { + if b.name == "main" || b.isCurrent { + t.Fatalf("%s must not be selectable from a linked worktree", b.name) + } + } + if row := m.renderRow(*find(m.branches, "main"), 12, false); !strings.Contains(stripANSI(row), " + main") { + t.Fatalf("a branch held by another worktree must show the + marker:\n%q", row) + } +} diff --git a/model.go b/model.go index 1dc92d7..ea83af7 100644 --- a/model.go +++ b/model.go @@ -49,14 +49,17 @@ const ( ) type deleteResult struct { - br branch // the branch this deletion was run for - done bool // the async deletion for this branch has completed - localOK bool - localErr string - forceable bool // a safe (-d) delete failed and could be retried with -D - remoteTried bool - remoteOK bool - remoteErr string + br branch // the branch this deletion was run for + done bool // the async deletion for this branch has completed + // worktreeRemoved records that the linked worktree holding br was removed + // to free the branch for deletion. + worktreeRemoved bool + localOK bool + localErr string + forceable bool // a safe (-d) delete failed and could be retried with -D + remoteTried bool + remoteOK bool + remoteErr string // remoteSkipped records that the armed push was deliberately deferred // because the local delete failed — the one piece of state not derivable // from br, since arming is the caller's decision. @@ -64,6 +67,10 @@ type deleteResult struct { restored bool // undo recreated the local branch } +// worktreePending reports whether a linked worktree still holds r's branch, so +// a force retry has to remove it first. +func (r deleteResult) worktreePending() bool { return r.br.worktree != "" && !r.worktreeRemoved } + // restorable reports whether undo can bring r's local branch back. func (r deleteResult) restorable() bool { return r.localOK && !r.restored && r.br.sha != "" @@ -254,9 +261,9 @@ var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", " // deleteBranchCmd wraps the deleteBranch worker as a tea.Cmd so deletions run off // the update loop. It captures only a branch value (never the model), so each runs // independently and concurrently under tea.Batch. -func deleteBranchCmd(idx int, b branch, flag string, wantRemote bool) tea.Cmd { +func deleteBranchCmd(idx int, b branch, force, wantRemote bool) tea.Cmd { return func() tea.Msg { - return branchDeletedMsg{idx: idx, res: deleteBranch(b, flag, wantRemote)} + return branchDeletedMsg{idx: idx, res: deleteBranch(b, force, wantRemote)} } } @@ -855,7 +862,7 @@ func (m *model) startDeletions(includeRemote bool) tea.Cmd { for i, b := range sel { m.results[i] = deleteResult{br: b} wantRemote := includeRemote && b.remoteArmed() - cmds = append(cmds, deleteBranchCmd(i, b, b.deleteFlag(m.force), wantRemote)) + cmds = append(cmds, deleteBranchCmd(i, b, m.force, wantRemote)) } return tea.Batch(cmds...) } @@ -1197,6 +1204,11 @@ func (m *model) forceDeleteUnmerged() { if r.localOK || !r.forceable { continue } + // deleteBranch kept the worktree while -d was going to refuse the + // branch; the user has now agreed to the delete, so free it. + if r.worktreePending() && !removeWorktree(r) { + continue + } if _, err := runGit("branch", "-D", r.br.name); err != nil { r.localErr = err.Error() continue diff --git a/view.go b/view.go index 8c57f90..b90fb77 100644 --- a/view.go +++ b/view.go @@ -329,7 +329,7 @@ func (m model) renderRow(br branch, nameW int, isCursor bool) string { switch { case br.isCurrent: cur = currentStyle.Render("*") - case br.worktree: + case br.worktree != "": cur = hunkStyle.Render("+") case br.protected: cur = dimStyle.Render("P") @@ -534,7 +534,8 @@ func (m model) helpView() string { b.WriteString(headerStyle.Render("Columns")) b.WriteString("\n") writeRows([][2]string{ - {"* / + / P", "current / in another worktree / protected (locked)"}, + {"* / P", "current / protected (locked)"}, + {"+", "in another worktree (removed on delete; locked if the main one)"}, {"[x]", "selected for deletion"}, {"R", "its remote branch will also be deleted"}, {"↑/↓", "commits ahead of / behind upstream"}, @@ -545,6 +546,8 @@ func (m model) helpView() string { b.WriteString("\n") b.WriteString(dimStyle.Render("Gone branches are deleted with -D. Any holding commits that are not in\n" + "the default branch are left unselected by x/p and flagged on the confirm screen.\n" + + "A branch in a linked worktree is deleted after its worktree is removed, with\n" + + "everything in it.\n" + "Protect more branches with: git config --add pruner.protect 'release/*'")) b.WriteString("\n\n") @@ -611,6 +614,9 @@ func (m model) confirmParts() (header, body, footer []string) { if br.deleteRemote && br.upstream != "" && !br.remoteOnly { body = append(body, " "+errStyle.Render(fmt.Sprintf("+ delete remote %s/%s", br.remoteName(), br.remoteBranch()))) } + if w := m.worktreeWarning(br); w != "" { + body = append(body, " "+errStyle.Render(w)) + } if w := m.riskWarning(br); w != "" { body = append(body, " "+errStyle.Render(w)) } @@ -631,6 +637,21 @@ func (m model) confirmParts() (header, body, footer []string) { func (m model) confirmView() string { return m.page(m.confirmParts()) } +// worktreeWarning states what deleting br does to the worktree that holds it, +// or "" when no worktree does. git refuses to delete a branch a worktree has +// checked out, so the worktree is removed first, with everything in it. +func (m model) worktreeWarning(br branch) string { + switch { + case br.worktree == "": + return "" + case br.safeDeleteRefuses(m.force): + // The worktree stays until the force retry. + return "+ remove worktree " + br.worktree + " if you then force delete" + default: + return "+ remove worktree " + br.worktree + " and everything in it" + } +} + // riskWarning states the cost of deleting br, or "" when the delete is clean. // It covers every branch git's safe delete would refuse plus gone branches, // which take the -D path regardless: under -D the unmerged commits are @@ -690,6 +711,9 @@ func (m model) forcePromptParts() (header, body, footer []string) { if r.remoteSkipped { body = append(body, " "+errStyle.Render(fmt.Sprintf("+ remote %s/%s will be deleted once the branch is gone", r.br.remoteName(), r.br.remoteBranch()))) } + if r.worktreePending() { + body = append(body, " "+errStyle.Render("+ worktree "+r.br.worktree+" will be removed first")) + } } footer = []string{ @@ -704,6 +728,9 @@ func (m model) forcePromptView() string { return m.page(m.forcePromptParts()) } // appendResultLines adds one completed deletion result (local, then remote if // tried) to dst. Shared by the results screen and the live deleting screen. func appendResultLines(dst []string, r deleteResult) []string { + if r.worktreeRemoved { + dst = append(dst, okStyle.Render(" ✓ ")+"removed worktree "+r.br.worktree) + } switch { case r.br.remoteOnly: // No local branch: the remote line below is the whole result.