diff --git a/Makefile b/Makefile index 64bc08d..a96f4e1 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ TARGET := $(BINDIR)/$(BINARY) build: $(TARGET) -$(TARGET): main.go go.mod +$(TARGET): $(wildcard *.go) go.mod @mkdir -p $(BINDIR) go build -o $(TARGET) . diff --git a/README.md b/README.md index 3739736..60fa68a 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,11 @@ git_pruner version # also --version, -v | -------------- | ------------------------------------------------------------- | | `↑`/`k`, `↓`/`j` | Move cursor | | `g` / `G` | Jump to top / bottom | -| `space` | Toggle selection (the current branch cannot be selected) | +| `space` | Toggle selection (locked branches cannot be selected — see [Protected branches](#protected-branches)) | | `a` / `n` | Select all listed branches / clear selection | -| `c` | Checkout the branch under the cursor (`git switch`) | +| `m` | Select listed branches that are merged and older than N days (prompts for N) | +| `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) | | `v` | View the branch's diff (through [delta](https://github.com/dandavison/delta) when installed) | @@ -56,6 +58,7 @@ git_pruner version # also --version, -v | `s` | Cycle sort field: committerdate -> name -> ahead/behind | | `o` | Reverse sort direction | | `f` | Toggle delete mode: safe `-d` <-> force `-D` | +| `u` | Undo: recreate the local branches the last delete removed | | `d` / `enter` | Go to the confirmation screen | | `?` | Help screen (build metadata, keybindings, column guide) | | `q` / `ctrl+c` | Quit | @@ -68,7 +71,19 @@ delete (`-d`) because it isn't fully merged, a follow-up prompt offers to force just those branches — `y` discards their unmerged commits, `n`/`esc` keeps them. Deletions run in the background with a live progress screen (a spinner plus a per-branch -checklist), so the UI stays responsive while remote pushes complete. +checklist), so the UI stays responsive while remote pushes complete. `ctrl+c` there asks for a +second press: quitting mid-run can leave a remote branch behind a deleted local one. + +On the results screen, `u` recreates the deleted local branches at their old commits, with +their upstream config. `enter` returns to the list, and `q` quits. When you quit, git_pruner +prints a restore command for every branch it deleted this session, so the way back stays in your +scrollback: + +``` +git_pruner: to restore a deleted branch, run: + git branch feature/foo 3e2210a… + git push origin 3e2210a…:refs/heads/feature/foo +``` In the diff view: `↑`/`↓` scroll, `space`/`ctrl+d` page down, `ctrl+u`/`pgup` page up, `g`/`G` jump to top/bottom, and `q`/`esc`/`v` return to the list. @@ -85,11 +100,13 @@ precedence, so `y`, `R` and `n` still work while a list is scrolled. > [x] R * feature/foo ↑2↓1 ✓ 3 days ago a1b2c3d Fix the thing ``` -- `>` cursor, `[x]` selected, `R` remote deletion armed, `*` current branch +- `>` cursor, `[x]` selected, `R` remote deletion armed +- `*` current branch, `+` checked out in another worktree, `P` protected — all three are locked - 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 -- relative commit date, short hash, and commit subject +- relative commit date, short hash, and commit subject. On a narrow terminal the relative date, + then the hash, then the date drop out, so a row never wraps ## Viewing a branch's changes @@ -157,10 +174,66 @@ 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. +## Selecting merged, old branches + +Press `m`, type an age in days (it starts at 90, or at `pruner.staleDays`), and press `enter`. +git_pruner selects every listed branch that is merged into the default branch and whose last +commit is older than that. A local branch counts as merged when its tip is in the default +branch, or when its upstream is merged and it has no commits of its own on top. Like `a`, it only +acts on the listed rows, so a `/` filter narrows it. + +## Remote-only branches + +Press `tab` to list the remote branches that no local branch tracks — work other people pushed, +or branches you deleted locally but not on the remote. They are read on first use, so startup +does not pay for them. A `✓` means the branch is merged into the remote default; the +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. + +## Protected branches + +The default branch is always protected. Add your own with name globs: + +```sh +git config --add pruner.protect 'release/*' +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. + +## Script mode + +The same selection rules work without the screen, for cron jobs or shell aliases: + +```sh +git_pruner --prune-gone # list what would be deleted +git_pruner --fetch --prune-gone --yes # fetch, then delete +git_pruner --merged-older-than 90 --yes # delete merged branches older than 90 days +``` + +Nothing is deleted without `--yes` (`--dry-run` forces a listing even with it). Script mode never +deletes remote branches, and never deletes a gone branch that holds commits missing from the +default branch. It prints restore commands for what it deleted, and exits 1 if any delete failed. + +## Network calls + +`git fetch` and `git push --delete` run with `GIT_TERMINAL_PROMPT=0` and without a terminal, and +stop after 60 seconds. A remote that asks for a password or does not answer fails with a message, +instead of freezing the screen. Use a credential helper or an ssh agent for remotes that need a +login. + +## Settings + +The sort field and order are saved in `~/Library/Application Support/git_pruner/settings` +(macOS) or `~/.config/git_pruner/settings` (Linux), and restored on the next start. + ## Development ```sh -make build # build straight to $BINDIR (default ~/shared/bin), skipping install.sh +make build # build straight to $BINDIR (default ~/shared/bin; override with BINDIR=...) make test # go test ./... make vet # go vet ./... make clean # remove the binary from $BINDIR @@ -181,6 +254,9 @@ would look mismatched if the width or theme drifted between them. [`docs/improvements.md`](docs/improvements.md) records the codebase analysis, the reasoning behind the current safety behavior, and the roadmap of remaining work. +The code is split by layer: `git.go` (every git call), `model.go` (state and key handling), +`view.go` (rendering), `cli.go` (script mode), `settings.go`, and `main.go`. + The test suite drives a real `git` binary against throwaway repositories created per test, so it needs `git` on `PATH` and a committer identity (`user.name` / `user.email`); the tests set one inside each temporary repo. diff --git a/assets/screenshot.tape b/assets/screenshot.tape index af9b52a..df33f50 100644 --- a/assets/screenshot.tape +++ b/assets/screenshot.tape @@ -14,7 +14,7 @@ Source "assets/common.tape" Type "git_pruner" Enter Sleep 3s -# `g` first: on launch the cursor sits on whatever branch git listed first. +# `g` is kept for older builds, where the cursor did not start on the first row. Type "g" Sleep 300ms Type "jjj" diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..bb5a3e7 --- /dev/null +++ b/cli.go @@ -0,0 +1,113 @@ +package main + +import ( + "flag" + "fmt" + "io" + "strings" + "sync" + "time" +) + +// runCLI is script mode: the TUI's selection rules, without the TUI. It only +// deletes with --yes, so a cron line or a typo cannot delete by accident. It +// never deletes remote branches: that stays a deliberate key in the TUI. +// Returns the exit code. +func runCLI(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("git_pruner", flag.ContinueOnError) + fs.SetOutput(stderr) + gone := fs.Bool("prune-gone", false, "select gone branches that hold no commits missing from the default branch") + olderThan := fs.Int("merged-older-than", -1, "select merged branches whose last commit is older than `DAYS`") + fetch := fs.Bool("fetch", false, "run git fetch --all --prune first") + yes := fs.Bool("yes", false, "delete the selected branches (without it, only list them)") + dryRun := fs.Bool("dry-run", false, "only list what would be deleted, even with --yes") + fs.Usage = func() { + fmt.Fprintln(stderr, "usage: git_pruner [--fetch] (--prune-gone | --merged-older-than DAYS)... [--yes | --dry-run]") + fmt.Fprintln(stderr, " git_pruner start the interactive screen") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() > 0 || (!*gone && *olderThan < 0) { + fs.Usage() + return 2 + } + + if *fetch { + if _, err := runGit("fetch", "--all", "--prune"); err != nil { + fmt.Fprintln(stderr, "git_pruner: fetch:", err) + return 1 + } + } + m, err := initialModel() + if err != nil { + fmt.Fprintln(stderr, "git_pruner:", err) + return 1 + } + if *gone { + fmt.Fprintln(stderr, m.selectGone()) + } + if *olderThan >= 0 { + fmt.Fprintln(stderr, m.selectMergedOlder(*olderThan, time.Now())) + } + m.measureSelectedRisk() + sel := m.selectedBranches() + if len(sel) == 0 { + fmt.Fprintln(stdout, "nothing to delete") + return 0 + } + + if *dryRun || !*yes { + for _, b := range sel { + line := "would delete " + b.name + " (" + b.deleteFlag(false) + ")" + if w := m.riskWarning(b); w != "" { + line += " " + w + } + fmt.Fprintln(stdout, line) + } + if !*dryRun { + fmt.Fprintln(stderr, "rerun with --yes to delete") + } + return 0 + } + + // runGit caps the concurrency, so one goroutine per branch is safe. + 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.Wait() + + code := 0 + for _, r := range results { + if r.localOK { + fmt.Fprintf(stdout, "deleted %s (was %s)\n", r.br.name, r.br.hash) + } else { + fmt.Fprintf(stdout, "failed %s: %s\n", r.br.name, r.localErr) + code = 1 + } + } + fmt.Fprint(stderr, restoreSummary(results)) + return code +} + +// restoreSummary lists a command that brings back each branch deleted this +// session. The TUI prints it on quit and script mode after a run, so the way +// back stays in the terminal's scrollback after the screen is gone. +func restoreSummary(results []deleteResult) string { + var lines []string + for _, r := range results { + if r.restorable() { + lines = append(lines, fmt.Sprintf(" git branch %s %s", r.br.name, r.br.sha)) + } + if r.remoteOK && r.br.sha != "" { + lines = append(lines, fmt.Sprintf(" git push %s %s:refs/heads/%s", r.br.remoteName(), r.br.sha, r.br.remoteBranch())) + } + } + if len(lines) == 0 { + return "" + } + return "git_pruner: to restore a deleted branch, run:\n" + strings.Join(lines, "\n") + "\n" +} diff --git a/docs/improvements.md b/docs/improvements.md index ae25feb..5eafbe0 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -204,71 +204,70 @@ The 501-branch case is `for-each-ref`'s own work (42ms of the 77ms), not chain d --- -## Remaining work - -### Tier 2 — robustness - -**5. Blocking git calls inside `Update`.** `loadDiff` (`v`), `refreshMergeInfo` and -`reloadBranches` still run synchronously in the update loop, so what remains of their cost is -still a freeze. Items 14, 16 and 18 under Completed took the residue to ~16ms on a -100-gone-branch repo, so this is no longer a visible freeze — what it would still buy is the -first paint, which currently waits on the whole two-round load. Moving `refreshMergeInfo` onto -the `tea.Cmd` pattern already used for fetch would put the branch list on screen after one -subprocess (~7ms) and fill the ✓ and risk columns in behind it. - -Do not reach for `git branch -r --merged` first: it was measured at 100ms with 2000 remote -refs, roughly a tenth of what the `git cherry` loop beside it cost. - -**6. `runGit` has no timeout and does not disable terminal prompts.** `fetch --all --prune` and -`push --delete` are network-bound; a credential or SSH prompt hangs the TUI with no recovery. -Set `GIT_TERMINAL_PROMPT=0` and attach a `context.WithTimeout` so it fails fast instead. - -**8. Smaller items.** -- Rows are wider than the terminal whenever `subjectWidth` hits its `max(10, …)` floor, and each - wrapped row eats two screen lines while `visibleRows` still counts it as one — so the list - overruns and the footer scrolls away. Measured threshold: rows wrap below `68 + nameW` columns, - which at a classic 80-column terminal means any branch name of 13 cells or more wraps *every* - row. `renderRow` needs to fit `m.width` rather than assume it. -- `listView` runs one line over terminal height when `status` and `err` are both set - (`visibleRows` is `height-5`; actual emission is `height+1`) — confirmed at 25 lines for a - height of 24. -- ANSI and control characters in commit subjects and branch names render raw into the terminal. - Confirmed: a `\x1b[31m` in a subject reaches the row intact (a bare `BEL` is stripped by - `ansi.Truncate`). The text comes from fetched branches, so it is not the author's to trust. -- The cursor starts on an arbitrary row. `sortBranches` preserves the cursor by name - unconditionally, but at startup `cursor` is 0 and `branches` is still in `for-each-ref` - (alphabetical) order, so it pins the cursor to wherever the alphabetically-first branch - lands after sorting — row 10 of 11 on the demo repo. Skip the preserve when there is no - prior cursor to restore. -- The confirmation screen warns `⚠ not merged into ` for every gone branch, because - `remoteMerged` tests the upstream ref and a gone branch no longer has one. Branches that were - merged and pushed before their upstream was deleted are flagged as if they held unique work; - `riskWarning` already reports the real cost correctly. -- `stateDeleting`'s ctrl+c quits while `git push --delete` children are still running. - -### Tier 3 — features for the tool's actual job - -**9. `/` incremental filter.** ✅ Done (2026-08-31). Case-insensitive substring filter over -branch names; `enter` keeps it, `esc` clears it, and `a` selects only the listed rows. The -cursor and scroll offsets are positions in the filtered view (`viewIdx`); marks stay on the -branch structs, so hidden rows keep theirs. - -**9b. `c` branch checkout (was `b`).** ✅ Done (2026-08-31). `git switch` on the cursor row — it resolves -branch names only, so the tag-shadowing rule holds without qualification. Part of a deliberate -shift toward a general interactive branch tool, not just pruning. - -**10. Bulk-select predicates** (merged, older than N days). "Select everything merged and older -than 90 days" is the canonical prune workflow and currently has to be done by hand. - -**11. Reflog recovery hint after a `-D`.** The force-prompt screen says "permanently discard their -unmerged commits" without telling the user that `git reflog` can still recover them. Pairs -naturally with finding 1. - -### Tier 4 — hygiene - -**12. Split `main.go`** (~1,300 lines) into `git.go` / `model.go` / `view.go`. - -**13. Make the Makefile's `BINDIR` overridable** — it hardcodes `$HOME/shared/bin`. +### 2026-09-23 pass (all of the remaining roadmap, plus new work) + +**5. First paint no longer waits on the merge queries.** `startupModel` paints after round one; +`Init` runs `queryMergeInfo` (round two plus the gone-branch `git cherry` calls) as a `tea.Cmd`. +The result carries `loadGen`, so a reply for a list that was reloaded since is dropped. Until it +lands, `x`, `m` and `d` wait with a status line: selecting on unknown merge state is the one +thing that must not happen. Every other reload path stays synchronous (it is ~16ms), which keeps +one async path instead of a pending-action queue behind `p`. `initialModel` keeps the old +synchronous behavior for script mode and the tests. `v` now also loads its diff (and re-runs +delta on resize) in a `tea.Cmd`, with `diffSeq` dropping stale answers. + +**6. Network calls are bounded.** `runGit` sets `GIT_TERMINAL_PROMPT=0`, starts `push`/`fetch` +in a new session (`Setsid`, so ssh has no `/dev/tty` to prompt on), and kills them after +`netTimeout` (60s) with `WaitDelay` for an ssh child still holding the pipes. Tested with a fake +`git` on `PATH` that sleeps, because the `ext::` transport needs a config override. + +**8. Smaller items — all fixed.** +- Rows fit the terminal: `rowLayout` drops the relative date, then the hash, then the date until + a 10-cell subject fits, then shrinks the name; a final `ansi.Truncate` guards the rest. The + header, help, status and error lines are cut to the width too, and `visibleRows` counts the + status and error lines (`TestListViewFitsHeight` checks the exact height). +- Subjects and git error text pass through `cleanText` (ANSI strip, control characters out); + raw diff lines too, with tabs widened first. Branch names cannot hold control characters + (`check-ref-format`), so they need nothing. +- The cursor starts on row 0: `installBranches` reads the focused name from the *old* list. +- A clean gone branch shows `✓ no commits missing from ` instead of `⚠ not merged`. +- ctrl+c while deleting needs a second press, with an on-screen warning. + +**10. `m`: merged and older than N days.** Prompt starts at `pruner.staleDays` (default 90). +Merged means tip in the base, or upstream merged with `ahead == 0` — a branch ahead of its merged +upstream holds work the upstream check cannot see. + +**11. Undo and recovery hints.** `u` (results screen or list) recreates the last run's local +branches at their full SHA and restores `branch..remote/.merge` from +`%(upstream:remotename)`/`%(upstream:remoteref)`, read in the existing `for-each-ref`. Remote +deletions are not undone (that would be a push); instead the exit summary prints +`git push :refs/heads/`. The results screen no longer quits on `enter`. Undo runs as a `tea.Cmd`, serially: parallel +`git config` writes would collide on `.git/config.lock`. + +A later `/simplify` pass also debounced delta on resize (100ms), so a window drag runs it once. +It left two findings on purpose: dropping the test-only `riskCommitCount`/`loadDiff` wrappers +(churn in a dozen tests for no behavior change), and keying the merge sets by full ref (it only +matters for a local branch literally named like a remote one, e.g. `origin/x`). + +**12. `main.go` split** into `git.go`, `model.go`, `view.go`, `cli.go`, `settings.go`, +`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: 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 +survive a filter. The rows are read lazily (`loadRemoteRefs(true)`) on first `tab`, then kept on +every reload. Deleting them is `R` only; `c` runs `git switch -c --track refs/remotes/…` +(the full ref, so a same-named tag cannot shadow it). + +**New: script mode** (`--prune-gone`, `--merged-older-than`, `--fetch`, `--yes`, `--dry-run`). +Lists unless `--yes`; never deletes remotes. + +**New: sort memory** in `os.UserConfigDir()/git_pruner/settings`; `settingsPath` stays empty +under test. ### Reviewed and deferred (2026-08-31 /simplify pass) diff --git a/features_test.go b/features_test.go new file mode 100644 index 0000000..aac0b49 --- /dev/null +++ b/features_test.go @@ -0,0 +1,518 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +// A remote that never answers must fail within netTimeout, not hang the TUI, +// and git must be told never to prompt. A fake git on PATH stands in for the +// hung remote: it records the prompt setting and then sleeps. +func TestNetworkCallTimesOut(t *testing.T) { + realGit := mustLookPath(t, "git") + bin := t.TempDir() + seen := filepath.Join(bin, "prompt") + script := "#!/bin/sh\nif [ \"$1\" = fetch ]; then echo \"$GIT_TERMINAL_PROMPT\" > " + seen + + "; exec sleep 30; fi\nexec " + realGit + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + old := netTimeout + netTimeout = 300 * time.Millisecond + t.Cleanup(func() { netTimeout = old }) + + start := time.Now() + _, err := runGit("fetch", "--all") + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("want a timeout error, got %v", err) + } + if d := time.Since(start); d > 5*time.Second { + t.Fatalf("the call took %s; the timeout did not stop it", d) + } + if got, _ := os.ReadFile(seen); strings.TrimSpace(string(got)) != "0" { + t.Fatalf("GIT_TERMINAL_PROMPT must be 0, got %q", got) + } + // Local calls have no time limit and are not affected. + if _, err := runGit("version"); err != nil { + t.Fatal(err) + } +} + +// Commit subjects come from other people. An escape code in one must not reach +// the terminal, where it could recolor or move the screen. +func TestSubjectEscapeCodesAreRemoved(t *testing.T) { + repo := initRepo(t, "main") + chdir(t, repo) + git(t, repo, "commit", "-q", "--allow-empty", "-m", "red \x1b[31mtext\x07 here") + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + b := find(m.branches, "main") + if b.subject != "red text here" { + t.Fatalf("subject must be cleaned, got %q", b.subject) + } + if row := m.renderRow(*b, 10, false); strings.Contains(row, "\x1b[31m") { + t.Fatalf("the escape code reached the row: %q", row) + } + if got := cleanText("a\tb\nc\x1b[2Jd"); got != "a b cd" { + t.Fatalf("cleanText: got %q", got) + } +} + +// u recreates what the last run deleted — at the same commit, with the upstream +// config `git branch -d` removed. +func TestUndoRestoresDeletedBranches(t *testing.T) { + repo := setupRepo(t) + chdir(t, repo) + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + sha := git(t, repo, "rev-parse", "feature/tracked") + find(m.branches, "feature/tracked").selected = true + m.force = true + m = startAndDrain(t, m, false) + wantState(t, m, stateResult, "a clean -D run lands on the results screen") + if find(m.branches, "feature/tracked") != nil { + t.Fatal("precondition: the branch must be deleted") + } + if o := stripANSI(m.resultView()); !strings.Contains(o, "u undo") { + t.Fatalf("the results screen must offer undo:\n%s", o) + } + summary := restoreSummary(m.results) + if !strings.Contains(summary, "git branch feature/tracked "+sha) { + t.Fatalf("the exit summary must give the restore command:\n%s", summary) + } + + nm, cmd := m.Update(key("u")) + m = nm.(model) + wantState(t, m, stateList, "undo returns to the list") + if cmd == nil { + t.Fatal("u must start the restore") + } + nm, _ = m.Update(cmd()) + m = nm.(model) + if got := git(t, repo, "rev-parse", "refs/heads/feature/tracked"); got != sha { + t.Fatalf("restored at %s, want %s", got, sha) + } + if up := git(t, repo, "rev-parse", "--abbrev-ref", "feature/tracked@{upstream}"); up != "origin/feature/tracked" { + t.Fatalf("upstream must be restored, got %q", up) + } + if !strings.Contains(m.status, "restored 1 branch") { + t.Fatalf("status: %q", m.status) + } + if restoreSummary(m.results) != "" { + t.Fatal("a restored branch needs no restore command") + } + // A second u has nothing left to do. + if m = press(t, m, key("u")); m.status != "nothing to undo" { + t.Fatalf("status: %q", m.status) + } +} + +// The force prompt says how to get the commits back. +func TestForcePromptGivesRecoveryHint(t *testing.T) { + m := model{height: 40, width: 120, riskBase: "main", results: []deleteResult{ + {br: branch{name: "wip", hash: "abc1234", riskCommits: 2}, done: true, forceable: true}, + }} + o := stripANSI(m.forcePromptView()) + for _, want := range []string{"press u", "git branch ", "wip at abc1234"} { + if !strings.Contains(o, want) { + t.Fatalf("missing %q:\n%s", want, o) + } + } +} + +// m selects the listed branches that are merged and older than N days — never +// a young one, an unmerged one, or a locked one. +func TestSelectMergedOlder(t *testing.T) { + now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + old, young := now.AddDate(0, 0, -100), now.AddDate(0, 0, -10) + m := model{width: 100, height: 24, + baseMerged: map[string]bool{"old-merged": true, "young-merged": true, "main": true}, + branches: []branch{ + {name: "main", committed: old, protected: true}, + {name: "old-merged", committed: old}, + {name: "young-merged", committed: young}, + {name: "old-unmerged", committed: old}, + {name: "old-upstream-merged", committed: old, upstream: "origin/x", remoteMerged: true}, + {name: "old-ahead", committed: old, upstream: "origin/y", remoteMerged: true, ahead: 1}, + }} + m = press(t, m, key("m")) + if m.state != stateAge || m.ageInput != "0" { + // config is zero-valued here; startup fills it from pruner.staleDays + t.Fatalf("m must open the age prompt, state %v input %q", m.state, m.ageInput) + } + m.status = m.selectMergedOlder(90, now) + want := map[string]bool{"old-merged": true, "old-upstream-merged": true} + for _, b := range m.branches { + if b.selected != want[b.name] { + t.Fatalf("%s selected=%v, want %v", b.name, b.selected, want[b.name]) + } + } + if m.status != "selected 2 merged branch(es) older than 90 days" { + t.Fatalf("status: %q", m.status) + } + + // The prompt takes digits only, and enter applies it. + m = press(t, model{state: stateAge, ageInput: "9"}, key("x"), key("0"), special(tea.KeyEnter)) + if m.state != stateList || !strings.Contains(m.status, "older than 90 days") { + t.Fatalf("prompt: state %v status %q", m.state, m.status) + } +} + +// No row may be wider than the terminal: a wrapped row takes two screen lines +// that visibleRows does not count. +func TestRowsFitTheTerminal(t *testing.T) { + br := branch{name: "feature/a-very-long-branch-name-here", upstream: "origin/x", ahead: 3, behind: 12, + remoteMerged: true, hash: "abc1234", committedRel: "3 weeks ago", subject: strings.Repeat("subject ", 20)} + for w := 30; w <= 200; w += 7 { + m := model{width: w} + for _, cursor := range []bool{false, true} { + if got := ansi.StringWidth(m.renderRow(br, 36, cursor)); got > w { + t.Fatalf("width %d: row is %d cells", w, got) + } + } + } + // At 80 columns the relative date goes first, and a subject still shows. + row := stripANSI(model{width: 80}.renderRow(br, 20, false)) + if strings.Contains(row, "3 weeks ago") || !strings.Contains(row, "abc1234") || !strings.Contains(row, "subject") { + t.Fatalf("80 columns: %q", row) + } +} + +// The list fills the terminal exactly, whatever status and error lines are set. +func TestListViewFitsHeight(t *testing.T) { + m := model{width: 100, height: 24} + for i := 0; i < 50; i++ { + m.branches = append(m.branches, branch{name: "b" + strings.Repeat("x", i%7)}) + } + for _, c := range []struct{ status, err string }{{"", ""}, {"s", ""}, {"s", "e"}} { + m.status, m.err = c.status, c.err + m.cursor = 49 + m.adjustScroll() + if rows := screenRows(m.listView()); rows != m.height { + t.Fatalf("status=%q err=%q: %d rows for a %d-row terminal", c.status, c.err, rows, m.height) + } + if !strings.Contains(stripANSI(m.listView()), "> ") { + t.Fatal("the cursor row must stay visible") + } + } +} + +// The cursor starts on the first row, not wherever the alphabetically first +// branch lands after the date sort. +func TestCursorStartsOnFirstRow(t *testing.T) { + repo := setupRepo(t) + chdir(t, repo) + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + if m.cursor != 0 { + t.Fatalf("cursor starts on row %d", m.cursor) + } +} + +// A gone branch that holds nothing new must not be flagged "not merged": its +// upstream is gone, so there is nothing for remoteMerged to test. +func TestGoneMergedBranchIsNotFlaggedUnmerged(t *testing.T) { + m := model{width: 100, height: 40, remoteDefault: "origin/main", riskBase: "origin/main", branches: []branch{ + {name: "done", upstream: "origin/done", gone: true, riskMeasured: true, selected: true}, + }} + o := stripANSI(m.confirmView()) + if strings.Contains(o, "not merged") { + t.Fatalf("a clean gone branch is flagged:\n%s", o) + } + if !strings.Contains(o, "✓ no commits missing from origin/main") { + t.Fatalf("the clean state must be stated:\n%s", o) + } +} + +// 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) { + repo := setupRepo(t) + chdir(t, repo) + git(t, repo, "worktree", "add", "-q", filepath.Join(t.TempDir(), "wt"), "feature/unmerged") + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + b := find(m.branches, "feature/unmerged") + if !b.worktree || b.isCurrent { + t.Fatalf("want a 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") + } + if row := stripANSI(m.renderRow(*b, 20, false)); !strings.Contains(row, " + feature/unmerged") { + t.Fatalf("the row must carry the + marker: %q", row) + } +} + +// The trunk and pruner.protect globs are locked in both views. +func TestProtectedBranches(t *testing.T) { + repo := setupRepo(t) + chdir(t, repo) + git(t, repo, "config", "--add", "pruner.protect", "feature/m*") + git(t, repo, "config", "pruner.staleDays", "30") + git(t, repo, "checkout", "-q", "feature/tracked") + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]bool{"main": true, "feature/merged": true, "feature/unmerged": false} { + if got := find(m.branches, name).protected; got != want { + t.Fatalf("%s protected=%v, want %v", name, got, want) + } + } + m = press(t, m, key("a")) + if got := branchNames(m.selectedBranches()); len(got) != 1 || got[0] != "feature/unmerged" { + t.Fatalf("a must skip protected branches, selected %v", got) + } + if m.config.staleDays != 30 { + t.Fatalf("pruner.staleDays: got %d", m.config.staleDays) + } +} + +// The sort choice survives a restart. +func TestSortSettingPersists(t *testing.T) { + settingsPath = filepath.Join(t.TempDir(), "d", "settings") + t.Cleanup(func() { settingsPath = "" }) + m := press(t, model{}, key("s"), key("o")) + var fresh model + loadSettings(&fresh) + if fresh.field != sortName || !fresh.ascending { + t.Fatalf("loaded %v asc=%v, want %v asc=true (saved as %v)", fresh.field, fresh.ascending, sortName, m.field) + } +} + +// The TUI paints before the merge queries: Init runs them, and the keys that +// select by their answers wait until they land. +func TestStartupMergeInfoArrivesLater(t *testing.T) { + repo := setupGoneMerged(t, 3) + chdir(t, repo) + m, err := startupModel() + if err != nil { + t.Fatal(err) + } + if !m.merging { + t.Fatal("startupModel must leave the merge queries to Init") + } + m = press(t, m, key("x")) + if len(m.selectedBranches()) != 0 || !strings.Contains(m.status, "still reading") { + t.Fatalf("x must wait for the merge info: status %q", m.status) + } + msg := m.Init()() + // A result for an older load is dropped. + stale := msg.(mergeInfoMsg) + stale.gen-- + if nm, _ := m.Update(stale); !nm.(model).merging { + t.Fatal("a stale merge result must be ignored") + } + nm, _ := m.Update(msg) + m = nm.(model) + if m.merging || !find(m.branches, "feature/0").riskMeasured { + t.Fatal("the merge info must be applied") + } + if m = press(t, m, key("x")); len(m.selectedBranches()) != 3 { + t.Fatalf("x after the load: %s", m.status) + } +} + +// v loads the diff in the background; an answer for an older request is dropped. +func TestDiffLoadsInBackground(t *testing.T) { + repo := setupRepo(t) + chdir(t, repo) + // A PATH with git alone, so a delta installed beside it is not used. + bin := t.TempDir() + if err := os.Symlink(mustLookPath(t, "git"), filepath.Join(bin, "git")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + cursorTo(t, &m, "feature/unmerged") + nm, cmd := m.Update(key("v")) + m = nm.(model) + if m.state != stateDiff || !m.diffLoading || cmd == nil { + t.Fatal("v must open the diff view and start the load") + } + if o := stripANSI(m.diffView()); !strings.Contains(o, "loading diff") { + t.Fatalf("the view must say it is loading:\n%s", o) + } + msg := cmd().(diffLoadedMsg) + old := msg + old.seq-- + old.lines = []string{"stale"} + nm, _ = m.Update(old) + if nm.(model).diffLines != nil { + t.Fatal("a stale diff must be dropped") + } + nm, _ = m.Update(msg) + m = nm.(model) + if m.diffLoading || !strings.Contains(strings.Join(m.diffLines, "\n"), "+b") || m.diffBase != "origin/main" { + t.Fatalf("diff not applied: base %q lines %q", m.diffBase, m.diffLines) + } +} + +func mustLookPath(t *testing.T, name string) string { + t.Helper() + p, err := exec.LookPath(name) + if err != nil { + t.Fatal(err) + } + return p +} + +// setupRemoteOnly adds two branches to origin that no local branch tracks: +// "done" (merged into main) and "orphan" (one commit main lacks). +func setupRemoteOnly(t *testing.T) string { + t.Helper() + repo := setupRepo(t) + git(t, repo, "push", "-q", "origin", "main:refs/heads/done", "feature/unmerged:refs/heads/orphan") + return repo +} + +// tab shows the remote branches no local branch tracks. Deleting them is a push, +// so only R does it; y has nothing to do. +func TestRemoteOnlyView(t *testing.T) { + repo := setupRemoteOnly(t) + chdir(t, repo) + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + nm, cmd := m.Update(special(tea.KeyTab)) + m = nm.(model) + if cmd == nil || !m.showRemote { + t.Fatal("tab must switch view and load the remote rows") + } + nm, _ = m.Update(cmd()) + m = nm.(model) + var names []string + for _, i := range m.viewIdx() { + names = append(names, m.branches[i].name) + } + // origin/main is protected, origin/feature/tracked is tracked locally. + if strings.Join(names, ",") != "origin/done,origin/orphan" && strings.Join(names, ",") != "origin/orphan,origin/done" { + t.Fatalf("remote-only rows: %v", names) + } + if !find(m.branches, "origin/done").remoteMerged || find(m.branches, "origin/orphan").remoteMerged { + t.Fatal("merge marks on remote rows are wrong") + } + + m = press(t, m, key("a"), key("d")) + wantState(t, m, stateConfirm, "d opens the confirm screen") + o := stripANSI(m.confirmView()) + if !strings.Contains(o, "R = delete 2 remote branch(es)") || !strings.Contains(o, "1 commit(s) not in origin/main") { + t.Fatalf("confirm screen:\n%s", o) + } + if nm, cmd := m.Update(key("y")); cmd != nil || nm.(model).state != stateConfirm { + t.Fatal("y must not delete remote-only rows") + } + nm, cmd = m.Update(key("R")) + m = drainDeletions(t, nm.(model), cmd) + for _, name := range []string{"done", "orphan"} { + if remoteHasBranch(t, repo, name) { + t.Fatalf("origin still has %s", name) + } + } + if !strings.Contains(restoreSummary(m.results), "git push origin ") { + t.Fatal("the exit summary must say how to push a deleted remote branch back") + } +} + +// c on a remote-only row creates a local branch that tracks it. +func TestCheckoutRemoteOnly(t *testing.T) { + repo := setupRemoteOnly(t) + chdir(t, repo) + m, err := initialModel() + if err != nil { + t.Fatal(err) + } + nm, cmd := m.Update(special(tea.KeyTab)) + nm, _ = nm.(model).Update(cmd()) + m = nm.(model) + cursorTo(t, &m, "origin/orphan") + m = doSwitch(t, m) + if m.err != "" || currentBranch(t, repo) != "orphan" { + t.Fatalf("switch failed: %q", m.err) + } + if m.showRemote { + t.Fatal("the list must move to the local view") + } + if b := m.cur(); b == nil || b.name != "orphan" || b.upstream != "origin/orphan" { + t.Fatalf("cursor must be on the new tracking branch: %+v", b) + } +} + +// Script mode lists without --yes, deletes with it, and keeps the same safety +// rules as the TUI. +func TestScriptMode(t *testing.T) { + repo := setupGoneMerged(t, 2) + chdir(t, repo) + git(t, repo, "checkout", "-q", "-b", "wip", "main") + commitFile(t, repo, "w", "w") + git(t, repo, "config", "branch.wip.remote", "origin") + git(t, repo, "config", "branch.wip.merge", "refs/heads/wip") + git(t, repo, "checkout", "-q", "main") + + var out, errOut bytes.Buffer + if code := runCLI([]string{"--prune-gone"}, &out, &errOut); code != 0 { + t.Fatalf("exit %d: %s", code, errOut.String()) + } + if !strings.Contains(out.String(), "would delete feature/0 (-D)") || strings.Contains(out.String(), "wip") { + t.Fatalf("dry run:\n%s", out.String()) + } + if !strings.Contains(errOut.String(), "1 hold commits not in main") { + t.Fatalf("the risky branch must be reported:\n%s", errOut.String()) + } + if find(mustLoad(t), "feature/0") == nil { + t.Fatal("a dry run must delete nothing") + } + + out.Reset() + errOut.Reset() + if code := runCLI([]string{"--prune-gone", "--yes"}, &out, &errOut); code != 0 { + t.Fatalf("exit %d: %s", code, errOut.String()) + } + bs := mustLoad(t) + if find(bs, "feature/0") != nil || find(bs, "feature/1") != nil || find(bs, "wip") == nil { + t.Fatalf("after --yes: %v", branchNames(bs)) + } + if !strings.Contains(errOut.String(), "git branch feature/0 ") { + t.Fatalf("restore commands must be printed:\n%s", errOut.String()) + } + + if code := runCLI([]string{"--bogus"}, &out, &errOut); code != 2 { + t.Fatalf("a bad flag must exit 2, got %d", code) + } + if code := runCLI(nil, &out, &errOut); code != 2 { + t.Fatalf("no rule must exit 2, got %d", code) + } +} + +func mustLoad(t *testing.T) []branch { + t.Helper() + bs, err := loadBranches() + if err != nil { + t.Fatal(err) + } + return bs +} diff --git a/git.go b/git.go new file mode 100644 index 0000000..d127fb0 --- /dev/null +++ b/git.go @@ -0,0 +1,667 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + "unicode" + + "github.com/charmbracelet/x/ansi" +) + +// branch holds the metadata git_pruner displays and acts on for one local +// branch, or for one remote branch with no local copy (remoteOnly). +type branch struct { + name string // "feature"; for a remoteOnly row, the tracking name "origin/feature" + hash string + sha string // full commit ID; what undo recreates the branch at + subject string + committed time.Time + committedRel string + upstream string // e.g. "origin/feature"; "" when no upstream is configured + upRemote string // branch..remote, kept so undo can restore tracking + upMerge string // branch..merge, e.g. "refs/heads/feature" + ahead int + behind int + gone bool // upstream was configured but no longer exists + remoteMerged bool // upstream is merged into the remote default branch (safe to delete) + headMerged bool // branch tip is merged into HEAD (git's -d criterion when there is no upstream) + 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 + selected bool + deleteRemote bool +} + +// remoteArmed reports whether deleting b includes its remote branch. Selecting +// 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 } + +// ref is b's fully qualified ref (see branchRef). +func (b branch) ref() string { + if b.remoteOnly { + return "refs/remotes/" + b.name + } + return branchRef(b.name) +} + +// remoteName prefers the configured remote: it is the truth, while the tracking +// name is only a convention that custom refspecs break. +func (b branch) remoteName() string { + if b.upRemote != "" && b.upRemote != "." { + return b.upRemote + } + if name, _, ok := strings.Cut(b.upstream, "/"); ok { + return name + } + return "origin" +} + +func (b branch) remoteBranch() string { + if s, ok := strings.CutPrefix(b.upMerge, "refs/heads/"); ok { + return s + } + if _, name, ok := strings.Cut(b.upstream, "/"); ok { + return name + } + return b.name +} + +// safeDeletable reports whether `git branch -d` will accept b, mirroring git's +// rule: a resolvable upstream is the sole criterion (ahead == 0 means the branch +// holds no commit the upstream lacks), and HEAD is consulted only when there is +// no upstream to ask. This is a precedence, not an either-or — git refuses a +// branch ahead of its upstream even when HEAD already contains it. Note that a +// branch with no upstream always has ahead == 0, git reporting no track info for +// it, so ahead alone cannot answer this. +func (b branch) safeDeletable() bool { + if b.upstream != "" && !b.gone { + return b.ahead == 0 + } + return b.headMerged +} + +// forcedDelete reports whether b will be deleted with -D rather than -d. Gone +// branches always are: git reports no track info for them, so a safe delete +// would turn on HEAD alone and refuse branches whose work is in the remote +// default but not in the local checkout — the headline prune case. +func (b branch) forcedDelete(force bool) bool { return force || b.gone } + +// 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 +// connection, and a prune of a hundred armed branches would open a hundred at +// once, which remotes throttle or refuse. The two are capped separately so a +// wide local fan-out never widens the network one. +const ( + maxLocalGit = 8 + maxRemotePush = 3 +) + +var ( + localSlots = make(chan struct{}, maxLocalGit) + remoteSlots = make(chan struct{}, maxRemotePush) +) + +// networkBound reports whether a git invocation opens a connection to a remote. +// It is what picks the cap, so a new network subcommand belongs here rather than +// at its call site. +func networkBound(args []string) bool { + return len(args) > 0 && (args[0] == "push" || args[0] == "fetch") +} + +// gauge records the highest number of concurrent holders it has seen. It sits on +// the subprocesses rather than on the slots, so a test can tell a cap that works +// from one that was removed — instrumenting the cap would stop reporting along +// with it. +// It also counts every holder, which is what tells a fan-out that was removed +// from one that merely runs fast on a small fixture. +type gauge struct{ inFlight, peak, total atomic.Int64 } + +func (g *gauge) enter() { + g.total.Add(1) + n := g.inFlight.Add(1) + for { + peak := g.peak.Load() + if n <= peak || g.peak.CompareAndSwap(peak, n) { + return + } + } +} + +func (g *gauge) leave() { g.inFlight.Add(-1) } + +// gitProcs counts every git subprocess; netProcs counts the ones that talk to a +// remote, which is what a remote host actually feels. +var gitProcs, netProcs gauge + +// runGit is the single door every git invocation passes through, which is what +// makes it the place to bound them: a cap at the call sites would only hold for +// the callers that remembered to ask. +func runGit(args ...string) (string, error) { + net := networkBound(args) + slots := localSlots + if net { + slots = remoteSlots + } + slots <- struct{}{} + defer func() { <-slots }() + + // Counted after the slot is held, so the gauges measure what is running + // rather than what is queued. + gitProcs.enter() + defer gitProcs.leave() + if net { + netProcs.enter() + defer netProcs.leave() + } + + ctx := context.Background() + if net { + // A remote that stops answering would otherwise hold the slot, and the + // screen waiting on it, forever. + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, netTimeout) + defer cancel() + } + cmd := exec.CommandContext(ctx, "git", args...) + // Pin the locale: deleteBranch classifies failures by matching git's own + // error text, which gettext would otherwise translate. Everything else we + // parse is --format-driven and unaffected. + // GIT_TERMINAL_PROMPT=0 makes git fail instead of asking for a password: the + // TUI owns the terminal, so a prompt there can never be answered. + cmd.Env = append(os.Environ(), "LC_ALL=C", "GIT_TERMINAL_PROMPT=0") + if net { + // ssh asks for passphrases on /dev/tty, past GIT_TERMINAL_PROMPT. With no + // controlling terminal it fails fast instead of fighting the TUI for input. + detachTTY(cmd) + } + // Once git is killed, an ssh child can still hold the pipes open; stop + // waiting on them shortly after. + cmd.WaitDelay = 2 * time.Second + var out, errBuf strings.Builder + cmd.Stdout = &out + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(errBuf.String()) + if ctx.Err() == context.DeadlineExceeded { + msg = fmt.Sprintf("git %s timed out after %s (the remote did not answer, or asked for a password)", args[0], netTimeout) + } else if msg == "" { + msg = err.Error() + } + return strings.TrimSpace(out.String()), fmt.Errorf("%s", cleanText(msg)) + } + return out.String(), nil +} + +// netTimeout bounds each network git call. A var so tests can shorten it. +var netTimeout = 60 * time.Second + +// cleanText makes text from git safe to print in the TUI. Commit subjects and +// remote error messages come from other people, so escape codes in them could +// recolor or move the screen. Line breaks and tabs become spaces: every place +// this text lands is one row. +func cleanText(s string) string { + return strings.Map(func(r rune) rune { + switch { + case r == '\n' || r == '\t': + return ' ' + case unicode.IsControl(r): + return -1 + } + return r + }, ansi.Strip(s)) +} + +// branchRef fully qualifies a local branch name. git's ref search order puts +// refs/tags/ ahead of refs/heads/, so a tag sharing a branch's name +// silently shadows the branch in any command handed the bare name — and tagging a +// release branch with its own name is ordinary practice. +func branchRef(name string) string { return "refs/heads/" + name } + +// shortRef strips the namespace from a full ref, yielding the plain branch name +// ("feature/x") or remote-tracking name ("origin/feature/x") the rest of the +// program keys on. git's own %(refname:short) cannot be used for this: it yields +// the shortest *unambiguous* name, which grows a "heads/" or "remotes/" prefix +// exactly when a tag shares the name — silently breaking every name-keyed lookup +// and every ref built back up from it. +func shortRef(ref string) string { + for _, prefix := range []string{"refs/heads/", "refs/remotes/"} { + if s, ok := strings.CutPrefix(ref, prefix); ok { + return s + } + } + return ref +} + +// refExists reports whether a ref resolves. Always pass a fully qualified ref: a +// bare name would also match a tag (see branchRef). +func refExists(ref string) bool { + _, err := runGit("rev-parse", "--verify", "--quiet", ref) + return err == nil +} + +var trackRe = regexp.MustCompile(`ahead (\d+)|behind (\d+)`) + +// commitFormat is the for-each-ref format shared by the local and remote +// loaders. The subject goes last: it is the one free-text field. +const commitFormat = "%(refname)%00%(objectname:short)%00%(objectname)%00" + + "%(committerdate:iso8601-strict)%00%(committerdate:relative)%00%(contents:subject)" + +// parseCommitFields reads commitFormat's fields into a branch. +func parseCommitFields(f []string) branch { + b := branch{ + name: shortRef(f[0]), + hash: f[1], + sha: f[2], + committedRel: f[4], + subject: cleanText(f[5]), + } + if t, err := time.Parse(time.RFC3339, f[3]); err == nil { + b.committed = t + } + return b +} + +func loadBranches() ([]branch, error) { + // Branch-only fields go first, so commitFormat's fields close the line. + const format = "%(upstream)%00%(upstream:track)%00%(HEAD)%00%(worktreepath)%00" + + "%(upstream:remotename)%00%(upstream:remoteref)%00" + commitFormat + out, err := runGit("for-each-ref", "--format="+format, "refs/heads") + if err != nil { + return nil, err + } + var branches []branch + for _, line := range strings.Split(out, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + f := strings.Split(line, "\x00") + if len(f) < 12 { + continue + } + b := parseCommitFields(f[6:]) + 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 + b.upRemote, b.upMerge = f[4], f[5] + track := f[1] + if strings.Contains(track, "gone") { + b.gone = true + } + for _, mm := range trackRe.FindAllStringSubmatch(track, -1) { + if mm[1] != "" { + b.ahead, _ = strconv.Atoi(mm[1]) + } + if mm[2] != "" { + b.behind, _ = strconv.Atoi(mm[2]) + } + } + branches = append(branches, b) + } + return branches, nil +} + +// remoteRefs is one read of refs/remotes: which remote-tracking refs exist, and +// what each symbolic one points at. Reading them together is what replaces `git +// remote`, a `symbolic-ref` per remote and a `rev-parse --verify` per candidate +// — five subprocess starts on an ordinary one-remote repo, against this one. +type remoteRefs struct { + names []string // remote names, "origin" first + exists map[string]bool // fully qualified ref -> present + symref map[string]string // fully qualified symbolic ref -> the ref it names + rows []branch // every non-symbolic remote branch; only when asked for +} + +// loadRemoteRefs reads every remote-tracking ref in one call. The remote names +// come out of the refs rather than out of `git remote`: a remote with no fetched +// refs cannot supply a default branch, so it is nothing the caller could use. +// withRows also reads each ref's commit for the remote view. That costs a commit +// read per ref, so it is only paid once the user has opened that view. +func loadRemoteRefs(withRows bool) remoteRefs { + rr := remoteRefs{exists: map[string]bool{}, symref: map[string]string{}} + format := "%(symref)%00%(refname)" + if withRows { + format = "%(symref)%00" + commitFormat + } + out, err := runGit("for-each-ref", "--format="+format, "refs/remotes") + if err != nil { + return rr + } + seen := map[string]bool{} + for _, line := range strings.Split(out, "\n") { + f := strings.Split(strings.TrimSpace(line), "\x00") + if len(f) < 2 || f[1] == "" { + continue + } + ref, target := f[1], f[0] + rr.exists[ref] = true + if target != "" { + rr.symref[ref] = target + } else if withRows && len(f) >= 7 { + b := parseCommitFields(f[1:]) + b.remoteOnly = true + // The row is its own upstream, which lets the merge query and the + // push --delete path treat it like a local branch's remote copy. + b.upstream = b.name + rr.rows = append(rr.rows, b) + } + // The first segment after the namespace is the remote's name. + if name, _, ok := strings.Cut(shortRef(ref), "/"); ok && !seen[name] { + seen[name] = true + rr.names = append(rr.names, name) + } + } + // origin first, so the conventional remote wins when several exist while a + // repo whose only remote is named something else (upstream, fork, …) still + // resolves a default branch. + sort.SliceStable(rr.names, func(i, j int) bool { return rr.names[i] == "origin" && rr.names[j] != "origin" }) + return rr +} + +// defaultBranchNames are the branch names treated as a repo's trunk, in order of +// preference. +var defaultBranchNames = []string{"main", "master"} + +// localDefaultBranch returns the ref of a local main/master, skipping exclude (a +// short branch name) so a branch is never compared against itself. Returns "" +// when neither exists. has answers whether a local branch of that name exists; +// a caller already holding the branch list passes a lookup into it rather than +// paying a subprocess per candidate. +// +// The resolvers below all return fully qualified refs, and the display layer +// shortens them with shortRef. Resolving is the only place the namespace is +// known for certain, so carrying it forward from here is what keeps a same-named +// tag from being measured in place of the branch further down. +func localDefaultBranch(exclude string, has func(string) bool) string { + for _, c := range defaultBranchNames { + if c != exclude && has(c) { + return branchRef(c) + } + } + return "" +} + +// gitHasBranch is the localDefaultBranch lookup for callers with no branch list +// to hand — it costs a subprocess per candidate. +func gitHasBranch(name string) bool { return refExists(branchRef(name)) } + +// remoteDefaultFrom resolves the remote's default branch as a remote-tracking +// ref (e.g. "refs/remotes/origin/main"): /HEAD if set, else +// /main, else /master, trying each remote in turn. Returns "" +// when none can be found. +func remoteDefaultFrom(rr remoteRefs) string { + for _, r := range rr.names { + // %(symref) yields the full ref, unlike `symbolic-ref --short`, which + // gives the shortest *unambiguous* name — "remotes/origin/main" as soon + // as a tag shares the name. + if t := rr.symref["refs/remotes/"+r+"/HEAD"]; t != "" { + return t + } + for _, c := range defaultBranchNames { + if ref := "refs/remotes/" + r + "/" + c; rr.exists[ref] { + return ref + } + } + } + return "" +} + +// remoteDefault reads the remote-tracking refs and resolves the default branch +// from them. +func remoteDefault() string { return remoteDefaultFrom(loadRemoteRefs(false)) } + +// baseBranch returns the ref to diff a branch against: the remote default branch, +// else a local main/master, excluding name itself. +func baseBranch(name string) string { + if def := remoteDefault(); def != "" { + return def + } + return localDefaultBranch(name, gitHasBranch) +} + +// riskCommitCount counts commits on name whose patch is not already present in +// base — the work a force delete (-D) would discard. Uses `git cherry` rather +// than `rev-list base..name` so commits that were cherry-picked, rebased, or +// squashed singly into base are correctly seen as already integrated. Commits +// squashed as a group still count, since no equivalent single patch exists; +// the warning is therefore worded as "not in ", not "will be lost". +// Returns 0 when there is nothing to compare against. +func riskCommitCount(name, base string) int { return riskCommitCountRef(branchRef(name), base) } + +// riskCommitCountRef is riskCommitCount for any fully qualified ref, which is +// what lets a remote branch be measured the same way. +func riskCommitCountRef(ref, base string) int { + if base == "" || base == ref { + return 0 + } + out, err := runGit("cherry", base, ref) + if err != nil { + return 0 + } + n := 0 + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "+") { // '+' = no equivalent patch in base + n++ + } + } + return n +} + +// mergedSet runs a `git branch --merged` query and collects the short ref names +// it reports into a set. +func mergedSet(args ...string) map[string]bool { + set := map[string]bool{} + out, err := runGit(append(args, "--format=%(refname)")...) + if err != nil { + return set + } + for _, line := range strings.Split(out, "\n") { + if s := strings.TrimSpace(line); s != "" { + set[shortRef(s)] = true + } + } + return set +} + +// remoteMergedSet returns the set of remote-tracking branches (short names, e.g. +// "origin/feature") whose tip is merged into def, a qualified ref. Operates on +// local remote-tracking refs, so it needs no network — it reflects the last fetch. +func remoteMergedSet(def string) map[string]bool { + if def == "" { + return map[string]bool{} + } + return mergedSet("branch", "-r", "--merged", def) +} + +// localMergedSet returns the local branches whose tip is merged into HEAD — +// git's criterion for accepting `branch -d` on a branch with no upstream. One +// git call covers the whole list, so this costs nothing per branch. +func localMergedSet() map[string]bool { return mergedSet("branch", "--merged", "HEAD") } + +// repoReads holds the reads that do not depend on the branch list, so they can +// run in the same round as it. +type repoReads struct { + headMerged map[string]bool // local branches merged into HEAD + remotes remoteRefs + config prunerConfig +} + +// prunerConfig is the tool's own git config, under the pruner.* section. +type prunerConfig struct { + protect []string // pruner.protect: branch name globs that can never be marked + staleDays int // pruner.staleDays: the default age for the merged-and-old rule +} + +// defaultStaleDays is the age the merged-and-old rule starts from. +const defaultStaleDays = 90 + +// loadConfig reads every pruner.* key in one call. Unset is the usual case, and +// git exits 1 for it, so an error only means "use the defaults". +func loadConfig() prunerConfig { + c := prunerConfig{staleDays: defaultStaleDays} + out, _ := runGit("config", "--get-regexp", `^pruner\.`) + for _, line := range strings.Split(out, "\n") { + key, val, _ := strings.Cut(strings.TrimSpace(line), " ") + switch key { + case "pruner.protect": + c.protect = append(c.protect, strings.Fields(val)...) + case "pruner.staledays": + if n, err := strconv.Atoi(val); err == nil && n >= 0 { + c.staleDays = n + } + } + } + return c +} + +// loadRepo reads the branch list and everything independent of it in one round. +// Starting a git subprocess costs about 6ms, and none of these reads waits on +// another, so the depth of the chain is what the user waits on — not the work +// inside it. +func loadRepo(withRemote bool) ([]branch, repoReads, error) { + var ( + branches []branch + err error + reads repoReads + ) + var wg sync.WaitGroup + wg.Add(4) + 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() }() + wg.Wait() + return branches, reads, err +} + +// 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. +func loadDiff(name string) (diff, base string, err error) { return loadDiffRef(branchRef(name), name) } + +// loadDiffRef is loadDiff for any fully qualified ref; name is the short name +// that must not be picked as its own base. +func loadDiffRef(ref, name string) (diff, base string, err error) { + baseRef := baseBranch(name) + if baseRef == "" || baseRef == ref { + baseRef = "HEAD" + } + diff, err = runGit("diff", baseRef+"..."+ref) + return diff, shortRef(baseRef), err +} + +// styleDiff turns a unified diff into display lines. With delta on the PATH +// its output is used as-is, so side-by-side, line numbers and theme all come +// from the user's own delta config; the width is passed so delta lays the +// columns out for this terminal. Without delta (or if it fails) the raw lines +// are returned for colorizeDiffLine. +func styleDiff(diff string, width int) (lines []string, styled bool) { + if strings.TrimSpace(diff) == "" { + return nil, false + } + if path, err := exec.LookPath("delta"); err == nil { + cmd := exec.Command(path, "--paging=never", "--width="+strconv.Itoa(width)) + cmd.Stdin = strings.NewReader(diff) + if out, err := cmd.Output(); err == nil { + return strings.Split(strings.TrimRight(string(out), "\n"), "\n"), true + } + } + // The file contents are someone else's text; clean each line like a subject. + // Tabs are widened first, or cleanText would flatten indentation to one space. + lines = strings.Split(strings.TrimRight(diff, "\n"), "\n") + for i, l := range lines { + lines[i] = cleanText(strings.ReplaceAll(l, "\t", " ")) + } + return lines, false +} + +// deleteFlag returns the git branch delete flag for b under the given force mode. +func (b branch) deleteFlag(force bool) string { + if b.forcedDelete(force) { + return "-D" + } + return "-d" +} + +// 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 { + res := deleteResult{br: b, done: true} + if b.remoteOnly { + // There is no local branch; the push is the whole job. + if wantRemote { + pushRemoteDelete(&res) + } + 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 + } + // 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 + // is deferred until the force retry clears the local branch. + switch { + case !wantRemote: + case res.localOK: + pushRemoteDelete(&res) + default: + res.remoteSkipped = true + } + return res +} + +// 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. +func pushRemoteDelete(res *deleteResult) { + res.remoteSkipped = false + res.remoteTried = true + // Qualify the remote branch: a remote carrying both a branch and a tag of that + // name rejects a bare refspec as matching more than one ref, deleting nothing. + if _, err := runGit("push", res.br.remoteName(), "--delete", branchRef(res.br.remoteBranch())); err != nil { + res.remoteErr = err.Error() + } else { + res.remoteOK = true + } +} + +// restoreBranch recreates a deleted local branch at its old commit and puts its +// upstream config back; `git branch -d` removes both. The commit is still in the +// object store (git only prunes unreachable objects after weeks), so this works +// long after the delete. +func restoreBranch(b branch) error { + if _, err := runGit("branch", "--no-track", b.name, b.sha); err != nil { + return err + } + if b.upRemote == "" || b.upMerge == "" { + return nil + } + if _, err := runGit("config", "branch."+b.name+".remote", b.upRemote); err != nil { + return err + } + _, err := runGit("config", "branch."+b.name+".merge", b.upMerge) + return err +} diff --git a/main.go b/main.go index 68f8346..98c0628 100644 --- a/main.go +++ b/main.go @@ -3,165 +3,11 @@ package main import ( "fmt" "os" - "os/exec" - "regexp" "runtime/debug" - "sort" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" ) -// branch holds the metadata git_pruner displays and acts on for one local branch. -type branch struct { - name string - hash string - subject string - committed time.Time - committedRel string - upstream string // e.g. "origin/feature"; "" when no upstream is configured - ahead int - behind int - gone bool // upstream was configured but no longer exists - remoteMerged bool // upstream is merged into the remote default branch (safe to delete) - headMerged bool // branch tip is merged into HEAD (git's -d criterion when there is no upstream) - 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 - selected bool - deleteRemote bool -} - -func (b branch) remoteName() string { - if name, _, ok := strings.Cut(b.upstream, "/"); ok { - return name - } - return "origin" -} - -func (b branch) remoteBranch() string { - if _, name, ok := strings.Cut(b.upstream, "/"); ok { - return name - } - return b.name -} - -// safeDeletable reports whether `git branch -d` will accept b, mirroring git's -// rule: a resolvable upstream is the sole criterion (ahead == 0 means the branch -// holds no commit the upstream lacks), and HEAD is consulted only when there is -// no upstream to ask. This is a precedence, not an either-or — git refuses a -// branch ahead of its upstream even when HEAD already contains it. Note that a -// branch with no upstream always has ahead == 0, git reporting no track info for -// it, so ahead alone cannot answer this. -func (b branch) safeDeletable() bool { - if b.upstream != "" && !b.gone { - return b.ahead == 0 - } - return b.headMerged -} - -// forcedDelete reports whether b will be deleted with -D rather than -d. Gone -// branches always are: git reports no track info for them, so a safe delete -// would turn on HEAD alone and refuse branches whose work is in the remote -// default but not in the local checkout — the headline prune case. -func (b branch) forcedDelete(force bool) bool { return force || b.gone } - -type sortField int - -const ( - sortDate sortField = iota - sortName - sortAheadBehind - sortFieldCount // number of sort fields; keep last -) - -func (s sortField) String() string { - switch s { - case sortDate: - return "committerdate" - case sortName: - return "name" - case sortAheadBehind: - return "ahead/behind" - } - return "?" -} - -type viewState int - -const ( - stateList viewState = iota - stateConfirm - stateForcePrompt - stateDeleting - stateResult - stateHelp - stateDiff - stateFilter // the list, with the filter line capturing keystrokes -) - -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 - // 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. - remoteSkipped bool -} - -type model struct { - branches []branch - cursor int - top int // index of first visible row (scroll window) - - field sortField - ascending bool - force bool - nameW int // cached branch-name column width (see recomputeNameWidth) - - filter string // active branch-name filter; "" shows every branch - - state viewState - results []deleteResult - - remoteDefault string // resolved remote default branch, e.g. "origin/main" - riskBase string // ref that branch.riskCommits is measured against ("" if unresolved) - riskBaseRef string // riskBase fully qualified, so a same-named tag cannot shadow it - // baseMerged holds the local branches whose tip is an ancestor of riskBaseRef. - // Their riskCommits is 0 by definition, so one query here removes a `git - // cherry` subprocess per branch (see measureRisk). - baseMerged map[string]bool - - spinnerFrame int // animation frame for the deleting spinner (deletion counts derive from results) - - diffBranch string // branch whose diff is shown in stateDiff - diffBase string // base ref the diff was computed against - diffRaw string // unified diff as git produced it; delta is re-run from it on resize - diffLines []string // display lines of the diff being viewed - diffStyled bool // lines came from delta and carry their own colors - diffTop int // scroll offset within diffLines - - bodyTop int // scroll offset within the confirm/force/result body (see page) - - width, height int - err string - status string // transient info message (e.g. fetch results) - fetching bool // a background fetch --all --prune is in flight - switching bool // a background git switch is in flight -} - // Build metadata. buildTime and gitCommit can be injected at link time via // -ldflags "-X main.buildTime=... -X main.gitCommit=..." (or -X main.buildDate=...); // when unset, buildInfo falls back to the VCS data the Go toolchain embeds. @@ -171,1795 +17,6 @@ var ( gitCommit string ) -// ---- styles ---- - -var ( - currentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) - cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) - rowBgStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")) // cursor row band - selStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) - goneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - headerStyle = lipgloss.NewStyle().Bold(true) - okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) - errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) - - nameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan - hashStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow - subjectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // light gray - aheadStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green - behindStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red - trackColStyle = lipgloss.NewStyle().Width(10) // ahead/behind + optional merged ✓ - - addStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green: additions - delStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red: removals - hunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("13")) // magenta: hunk headers -) - -// ---- git I/O ---- - -// 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 -// connection, and a prune of a hundred armed branches would open a hundred at -// once, which remotes throttle or refuse. The two are capped separately so a -// wide local fan-out never widens the network one. -const ( - maxLocalGit = 8 - maxRemotePush = 3 -) - -var ( - localSlots = make(chan struct{}, maxLocalGit) - remoteSlots = make(chan struct{}, maxRemotePush) -) - -// networkBound reports whether a git invocation opens a connection to a remote. -// It is what picks the cap, so a new network subcommand belongs here rather than -// at its call site. -func networkBound(args []string) bool { - return len(args) > 0 && (args[0] == "push" || args[0] == "fetch") -} - -// gauge records the highest number of concurrent holders it has seen. It sits on -// the subprocesses rather than on the slots, so a test can tell a cap that works -// from one that was removed — instrumenting the cap would stop reporting along -// with it. -// It also counts every holder, which is what tells a fan-out that was removed -// from one that merely runs fast on a small fixture. -type gauge struct{ inFlight, peak, total atomic.Int64 } - -func (g *gauge) enter() { - g.total.Add(1) - n := g.inFlight.Add(1) - for { - peak := g.peak.Load() - if n <= peak || g.peak.CompareAndSwap(peak, n) { - return - } - } -} - -func (g *gauge) leave() { g.inFlight.Add(-1) } - -// gitProcs counts every git subprocess; netProcs counts the ones that talk to a -// remote, which is what a remote host actually feels. -var gitProcs, netProcs gauge - -// runGit is the single door every git invocation passes through, which is what -// makes it the place to bound them: a cap at the call sites would only hold for -// the callers that remembered to ask. -func runGit(args ...string) (string, error) { - net := networkBound(args) - slots := localSlots - if net { - slots = remoteSlots - } - slots <- struct{}{} - defer func() { <-slots }() - - // Counted after the slot is held, so the gauges measure what is running - // rather than what is queued. - gitProcs.enter() - defer gitProcs.leave() - if net { - netProcs.enter() - defer netProcs.leave() - } - - cmd := exec.Command("git", args...) - // Pin the locale: deleteBranch classifies failures by matching git's own - // error text, which gettext would otherwise translate. Everything else we - // parse is --format-driven and unaffected. - cmd.Env = append(os.Environ(), "LC_ALL=C") - var out, errBuf strings.Builder - cmd.Stdout = &out - cmd.Stderr = &errBuf - if err := cmd.Run(); err != nil { - msg := strings.TrimSpace(errBuf.String()) - if msg == "" { - msg = err.Error() - } - return strings.TrimSpace(out.String()), fmt.Errorf("%s", msg) - } - return out.String(), nil -} - -// branchRef fully qualifies a local branch name. git's ref search order puts -// refs/tags/ ahead of refs/heads/, so a tag sharing a branch's name -// silently shadows the branch in any command handed the bare name — and tagging a -// release branch with its own name is ordinary practice. -func branchRef(name string) string { return "refs/heads/" + name } - -// shortRef strips the namespace from a full ref, yielding the plain branch name -// ("feature/x") or remote-tracking name ("origin/feature/x") the rest of the -// program keys on. git's own %(refname:short) cannot be used for this: it yields -// the shortest *unambiguous* name, which grows a "heads/" or "remotes/" prefix -// exactly when a tag shares the name — silently breaking every name-keyed lookup -// and every ref built back up from it. -func shortRef(ref string) string { - for _, prefix := range []string{"refs/heads/", "refs/remotes/"} { - if s, ok := strings.CutPrefix(ref, prefix); ok { - return s - } - } - return ref -} - -// refExists reports whether a ref resolves. Always pass a fully qualified ref: a -// bare name would also match a tag (see branchRef). -func refExists(ref string) bool { - _, err := runGit("rev-parse", "--verify", "--quiet", ref) - return err == nil -} - -var trackRe = regexp.MustCompile(`ahead (\d+)|behind (\d+)`) - -func loadBranches() ([]branch, error) { - const format = "%(refname)%00%(objectname:short)%00%(committerdate:iso8601-strict)%00" + - "%(committerdate:relative)%00%(upstream)%00%(upstream:track)%00%(HEAD)%00%(contents:subject)" - out, err := runGit("for-each-ref", "--format="+format, "refs/heads") - if err != nil { - return nil, err - } - var branches []branch - for _, line := range strings.Split(out, "\n") { - if strings.TrimSpace(line) == "" { - continue - } - f := strings.Split(line, "\x00") - if len(f) < 8 { - continue - } - b := branch{ - name: shortRef(f[0]), - hash: f[1], - committedRel: f[3], - upstream: shortRef(f[4]), - isCurrent: f[6] == "*", - subject: f[7], - } - if t, terr := time.Parse(time.RFC3339, f[2]); terr == nil { - b.committed = t - } - track := f[5] - if strings.Contains(track, "gone") { - b.gone = true - } - for _, mm := range trackRe.FindAllStringSubmatch(track, -1) { - if mm[1] != "" { - b.ahead, _ = strconv.Atoi(mm[1]) - } - if mm[2] != "" { - b.behind, _ = strconv.Atoi(mm[2]) - } - } - branches = append(branches, b) - } - return branches, nil -} - -// remoteRefs is one read of refs/remotes: which remote-tracking refs exist, and -// what each symbolic one points at. Reading them together is what replaces `git -// remote`, a `symbolic-ref` per remote and a `rev-parse --verify` per candidate -// — five subprocess starts on an ordinary one-remote repo, against this one. -type remoteRefs struct { - names []string // remote names, "origin" first - exists map[string]bool // fully qualified ref -> present - symref map[string]string // fully qualified symbolic ref -> the ref it names -} - -// loadRemoteRefs reads every remote-tracking ref in one call. The remote names -// come out of the refs rather than out of `git remote`: a remote with no fetched -// refs cannot supply a default branch, so it is nothing the caller could use. -func loadRemoteRefs() remoteRefs { - rr := remoteRefs{exists: map[string]bool{}, symref: map[string]string{}} - out, err := runGit("for-each-ref", "--format=%(refname)%00%(symref)", "refs/remotes") - if err != nil { - return rr - } - seen := map[string]bool{} - for _, line := range strings.Split(out, "\n") { - ref, target := "", "" - if s := strings.TrimSpace(line); s != "" { - ref, target, _ = strings.Cut(s, "\x00") - } - if ref == "" { - continue - } - rr.exists[ref] = true - if target != "" { - rr.symref[ref] = target - } - // The first segment after the namespace is the remote's name. - if name, _, ok := strings.Cut(shortRef(ref), "/"); ok && !seen[name] { - seen[name] = true - rr.names = append(rr.names, name) - } - } - // origin first, so the conventional remote wins when several exist while a - // repo whose only remote is named something else (upstream, fork, …) still - // resolves a default branch. - sort.SliceStable(rr.names, func(i, j int) bool { return rr.names[i] == "origin" && rr.names[j] != "origin" }) - return rr -} - -// defaultBranchNames are the branch names treated as a repo's trunk, in order of -// preference. -var defaultBranchNames = []string{"main", "master"} - -// localDefaultBranch returns the ref of a local main/master, skipping exclude (a -// short branch name) so a branch is never compared against itself. Returns "" -// when neither exists. has answers whether a local branch of that name exists; -// a caller already holding the branch list passes a lookup into it rather than -// paying a subprocess per candidate. -// -// The resolvers below all return fully qualified refs, and the display layer -// shortens them with shortRef. Resolving is the only place the namespace is -// known for certain, so carrying it forward from here is what keeps a same-named -// tag from being measured in place of the branch further down. -func localDefaultBranch(exclude string, has func(string) bool) string { - for _, c := range defaultBranchNames { - if c != exclude && has(c) { - return branchRef(c) - } - } - return "" -} - -// gitHasBranch is the localDefaultBranch lookup for callers with no branch list -// to hand — it costs a subprocess per candidate. -func gitHasBranch(name string) bool { return refExists(branchRef(name)) } - -// remoteDefaultFrom resolves the remote's default branch as a remote-tracking -// ref (e.g. "refs/remotes/origin/main"): /HEAD if set, else -// /main, else /master, trying each remote in turn. Returns "" -// when none can be found. -func remoteDefaultFrom(rr remoteRefs) string { - for _, r := range rr.names { - // %(symref) yields the full ref, unlike `symbolic-ref --short`, which - // gives the shortest *unambiguous* name — "remotes/origin/main" as soon - // as a tag shares the name. - if t := rr.symref["refs/remotes/"+r+"/HEAD"]; t != "" { - return t - } - for _, c := range defaultBranchNames { - if ref := "refs/remotes/" + r + "/" + c; rr.exists[ref] { - return ref - } - } - } - return "" -} - -// remoteDefault reads the remote-tracking refs and resolves the default branch -// from them. -func remoteDefault() string { return remoteDefaultFrom(loadRemoteRefs()) } - -// baseBranch returns the ref to diff a branch against: the remote default branch, -// else a local main/master, excluding name itself. -func baseBranch(name string) string { - if def := remoteDefault(); def != "" { - return def - } - return localDefaultBranch(name, gitHasBranch) -} - -// riskCommitCount counts commits on name whose patch is not already present in -// base — the work a force delete (-D) would discard. Uses `git cherry` rather -// than `rev-list base..name` so commits that were cherry-picked, rebased, or -// squashed singly into base are correctly seen as already integrated. Commits -// squashed as a group still count, since no equivalent single patch exists; -// the warning is therefore worded as "not in ", not "will be lost". -// Returns 0 when there is nothing to compare against. -func riskCommitCount(name, base string) int { - ref := branchRef(name) - if base == "" || base == ref { - return 0 - } - out, err := runGit("cherry", base, ref) - if err != nil { - return 0 - } - n := 0 - for _, line := range strings.Split(out, "\n") { - if strings.HasPrefix(line, "+") { // '+' = no equivalent patch in base - n++ - } - } - return n -} - -// mergedSet runs a `git branch --merged` query and collects the short ref names -// it reports into a set. -func mergedSet(args ...string) map[string]bool { - set := map[string]bool{} - out, err := runGit(append(args, "--format=%(refname)")...) - if err != nil { - return set - } - for _, line := range strings.Split(out, "\n") { - if s := strings.TrimSpace(line); s != "" { - set[shortRef(s)] = true - } - } - return set -} - -// remoteMergedSet returns the set of remote-tracking branches (short names, e.g. -// "origin/feature") whose tip is merged into def, a qualified ref. Operates on -// local remote-tracking refs, so it needs no network — it reflects the last fetch. -func remoteMergedSet(def string) map[string]bool { - if def == "" { - return map[string]bool{} - } - return mergedSet("branch", "-r", "--merged", def) -} - -// localMergedSet returns the local branches whose tip is merged into HEAD — -// git's criterion for accepting `branch -d` on a branch with no upstream. One -// git call covers the whole list, so this costs nothing per branch. -func localMergedSet() map[string]bool { return mergedSet("branch", "--merged", "HEAD") } - -// repoReads holds the reads that do not depend on the branch list, so they can -// run in the same round as it. -type repoReads struct { - headMerged map[string]bool // local branches merged into HEAD - remotes remoteRefs -} - -// loadRepo reads the branch list and everything independent of it in one round. -// Starting a git subprocess costs about 6ms, and none of these three waits on -// another, so the depth of the chain is what the user waits on — not the work -// inside it. -func loadRepo() ([]branch, repoReads, error) { - var ( - branches []branch - err error - reads repoReads - ) - var wg sync.WaitGroup - wg.Add(3) - go func() { defer wg.Done(); branches, err = loadBranches() }() - go func() { defer wg.Done(); reads.headMerged = localMergedSet() }() - go func() { defer wg.Done(); reads.remotes = loadRemoteRefs() }() - wg.Wait() - return branches, reads, err -} - -// fetchDoneMsg reports completion of an async `git fetch --all --prune`. -type fetchDoneMsg struct{ err error } - -// fetchPruneCmd fetches all remotes and prunes deleted remote-tracking refs so -// that branches whose upstream is gone are detected. Run as a tea.Cmd to keep -// the UI responsive while the (network-bound) fetch runs. -func fetchPruneCmd() tea.Cmd { - return func() tea.Msg { - _, err := runGit("fetch", "--all", "--prune") - return fetchDoneMsg{err: err} - } -} - -// switchDoneMsg reports completion of an async `git switch`, carrying the -// reloaded repo so the reads run off the update loop too. -type switchDoneMsg struct { - name string - err error // the switch itself failed; the repo is untouched - branches []branch - reads repoReads - loadErr error -} - -// switchCmd checks out the named branch and reloads the repo, off the update -// loop like every other mutation (see fetchPruneCmd). `git switch` resolves -// branch names only, so a same-named tag cannot shadow it (see branchRef). -func switchCmd(name string) tea.Cmd { - return func() tea.Msg { - msg := switchDoneMsg{name: name} - if _, msg.err = runGit("switch", name); msg.err != nil { - return msg - } - msg.branches, msg.reads, msg.loadErr = loadRepo() - return msg - } -} - -// branchDeletedMsg reports the outcome of one branch's async deletion; idx is -// its position in m.results. -type branchDeletedMsg struct { - idx int - res deleteResult -} - -// spinnerTickMsg advances the deleting-view spinner animation. -type spinnerTickMsg struct{} - -// spinnerFrames are the braille frames cycled while deletions are in flight. -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 { - return func() tea.Msg { - return branchDeletedMsg{idx: idx, res: deleteBranch(b, flag, wantRemote)} - } -} - -// spinnerTickCmd schedules the next spinner frame. -func spinnerTickCmd() tea.Cmd { - return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return spinnerTickMsg{} }) -} - -// 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. -func loadDiff(name string) (diff, base string, err error) { - ref := baseBranch(name) - if ref == "" { - ref = "HEAD" - } - diff, err = runGit("diff", ref+"..."+branchRef(name)) - return diff, shortRef(ref), err -} - -// styleDiff turns a unified diff into display lines. With delta on the PATH -// its output is used as-is, so side-by-side, line numbers and theme all come -// from the user's own delta config; the width is passed so delta lays the -// columns out for this terminal. Without delta (or if it fails) the raw lines -// are returned for colorizeDiffLine. -func styleDiff(diff string, width int) (lines []string, styled bool) { - if strings.TrimSpace(diff) == "" { - return nil, false - } - if path, err := exec.LookPath("delta"); err == nil { - cmd := exec.Command(path, "--paging=never", "--width="+strconv.Itoa(width)) - cmd.Stdin = strings.NewReader(diff) - if out, err := cmd.Output(); err == nil { - return strings.Split(strings.TrimRight(string(out), "\n"), "\n"), true - } - } - return strings.Split(strings.TrimRight(diff, "\n"), "\n"), false -} - -// ---- model ---- - -func initialModel() (model, error) { - // The repo check runs beside the reads rather than ahead of them. It is here - // to give a clearer message than git's own, not to gate the work, and a - // serial subprocess start is most of what startup costs. - var repoErr error - checked := make(chan struct{}) - go func() { - _, repoErr = runGit("rev-parse", "--is-inside-work-tree") - close(checked) - }() - branches, reads, err := loadRepo() - <-checked - - if repoErr != nil { - return model{}, fmt.Errorf("not a git repository (or git is unavailable)") - } - if err != nil { - return model{}, err - } - m := model{field: sortDate, ascending: false, height: 24, width: 100} - m.applyBranches(branches, reads) - return m, nil -} - -func (m *model) sortBranches() { - current := "" - if b := m.cur(); b != nil { - current = b.name - } - less := func(i, j int) bool { - a, b := m.branches[i], m.branches[j] - var r bool - switch m.field { - case sortName: - r = a.name < b.name - case sortAheadBehind: - r = (a.ahead - a.behind) < (b.ahead - b.behind) - default: - r = a.committed.Before(b.committed) - } - if !m.ascending { - return !r - } - return r - } - sort.SliceStable(m.branches, less) - m.focusBranch(current) - m.clampCursor() -} - -// focusBranch puts the cursor on the named branch's view row. A filtered-out -// or unknown name leaves the cursor where it is. The cursor is a view -// position, not a branch index — this is the one place that mapping is done. -func (m *model) focusBranch(name string) { - for p, i := range m.viewIdx() { - if m.branches[i].name == name { - m.cursor = p - m.adjustScroll() - return - } - } -} - -func (m *model) clampCursor() { - m.cursor = max(0, min(m.cursor, len(m.viewIdx())-1)) - m.adjustScroll() -} - -// scroll applies a mouse-wheel step (delta of -1 up / +1 down) to whichever -// scrollable view is active; other states ignore the wheel. -func (m *model) scroll(delta int) { - switch m.state { - case stateList, stateFilter: - m.cursor += delta - m.clampCursor() - case stateDiff: - m.diffTop += delta * 3 // 3 lines per wheel notch, like a pager - m.clampDiff() - case stateConfirm, stateForcePrompt, stateDeleting, stateResult: - m.bodyTop += delta * 3 - m.clampBody() - } -} - -func (m *model) visibleRows() int { - return max(1, m.height-5) // minus header (2) + footer (3) -} - -func (m *model) adjustScroll() { - vis := m.visibleRows() - if m.cursor < m.top { - m.top = m.cursor - } - if m.cursor >= m.top+vis { - m.top = m.cursor - vis + 1 - } - m.top = max(0, m.top) -} - -// viewIdx returns the indices of the branches the list shows, in display -// order. The cursor and scroll offsets are positions in this view, not in -// m.branches: marks live on the branches, so hiding a row must not move them. -func (m model) viewIdx() []int { - idx := make([]int, 0, len(m.branches)) - f := strings.ToLower(m.filter) - for i, b := range m.branches { - if f == "" || strings.Contains(strings.ToLower(b.name), f) { - idx = append(idx, i) - } - } - return idx -} - -func (m *model) cur() *branch { - if idx := m.viewIdx(); m.cursor >= 0 && m.cursor < len(idx) { - return &m.branches[idx[m.cursor]] - } - return nil -} - -func (m model) selectedBranches() []branch { - var out []branch - for _, b := range m.branches { - if b.selected { - out = append(out, b) - } - } - return out -} - -func (m model) Init() tea.Cmd { return nil } - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width, m.height = msg.Width, msg.Height - if m.state == stateDiff && m.diffStyled { - // delta laid its columns out for the old width - m.diffLines, m.diffStyled = styleDiff(m.diffRaw, m.width) - } - m.adjustScroll() - return m, nil - case fetchDoneMsg: - m.fetching = false - if msg.err != nil { - m.err = msg.err.Error() - m.status = "" - return m, nil - } - if branches, reads, err := loadRepo(); err == nil { - // A fetch is non-destructive, so both the cursor (by name, in - // sortBranches) and the user's pending marks survive it. - m.applyBranches(carryMarks(m.branches, branches), reads) - } - m.err = "" - m.status = "fetched & pruned — " + m.selectGone() - return m, nil - case switchDoneMsg: - m.switching = false - if msg.err != nil { - // A refused switch (dirty worktree, held by another worktree) - // leaves the repo untouched; surface git's reason and stay put. - m.err = msg.err.Error() - m.status = "" - return m, nil - } - m.err = "" - m.status = "switched to " + msg.name - if msg.loadErr == nil { - // A switch deletes nothing, so pending marks survive it; carryMarks - // drops the ones now on the current branch. - m.applyBranches(carryMarks(m.branches, msg.branches), msg.reads) - } - m.focusBranch(msg.name) - return m, nil - case branchDeletedMsg: - if msg.idx >= 0 && msg.idx < len(m.results) { - m.results[msg.idx] = msg.res - } - if m.deletesDone() >= len(m.results) { - m.reloadBranches() - m.bodyTop = 0 - if len(m.forceableFailures()) > 0 { - m.state = stateForcePrompt - } else { - m.state = stateResult - } - } - return m, nil - case spinnerTickMsg: - if m.state == stateDeleting { - m.spinnerFrame++ - return m, spinnerTickCmd() - } - return m, nil - case tea.MouseMsg: - // Handle the wheel ourselves so it scrolls the active view rather than - // the terminal translating it into arrow-key bursts that leak between views. - switch msg.Button { - case tea.MouseButtonWheelUp: - m.scroll(-1) - case tea.MouseButtonWheelDown: - m.scroll(1) - } - return m, nil - case tea.KeyMsg: - switch m.state { - case stateList: - return m.updateList(msg) - case stateFilter: - return m.updateFilter(msg) - case stateConfirm: - return m.updateConfirm(msg) - case stateForcePrompt: - return m.updateForcePrompt(msg) - case stateDiff: - return m.updateDiff(msg) - case stateDeleting: - // Deletions are in flight; ignore input except an abort. - if msg.String() == "ctrl+c" { - return m, tea.Quit - } - case stateResult: - switch msg.String() { - case "q", "ctrl+c", "enter", "esc": - return m, tea.Quit - default: - m.scrollKeys(msg.String()) - } - case stateHelp: - if msg.String() == "ctrl+c" { - return m, tea.Quit - } - m.state = stateList - } - } - return m, nil -} - -// updateFilter handles keys in stateFilter, where the filter line captures -// input: plain letters (q, j, a, …) type into the filter instead of firing -// their list actions. -func (m model) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - case "enter": - m.state = stateList - case "esc": - m.filter = "" - m.state = stateList - case "backspace": - if r := []rune(m.filter); len(r) > 0 { - m.filter = string(r[:len(r)-1]) - } - default: - if msg.Type == tea.KeyRunes { - m.filter += string(msg.Runes) - m.cursor = 0 - } - } - m.clampCursor() - return m, nil -} - -func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "up", "k": - m.cursor-- - m.clampCursor() - case "down", "j": - m.cursor++ - m.clampCursor() - case "g", "home": - m.cursor = 0 - m.clampCursor() - case "G", "end": - m.cursor = len(m.branches) // clampCursor lands it on the last visible row - m.clampCursor() - case " ": - if b := m.cur(); b != nil && !b.isCurrent { - b.selected = !b.selected - } - case "r": - if b := m.cur(); b != nil && b.upstream != "" && !b.isCurrent { - b.deleteRemote = !b.deleteRemote - } - case "/": - m.state = stateFilter - case "esc": - // n already disarms everything; esc only lifts the filter. - m.filter = "" - m.clampCursor() - case "a": - // Select what the list shows: with a filter active, a marks only the - // matching branches, which is what makes filter-then-select useful. - for _, i := range m.viewIdx() { - if !m.branches[i].isCurrent { - m.branches[i].selected = true - } - } - case "n": - for i := range m.branches { - m.branches[i].selected = false - m.branches[i].deleteRemote = false - } - case "s": - m.field = (m.field + 1) % sortFieldCount - m.sortBranches() - case "o": - m.ascending = !m.ascending - m.sortBranches() - case "f": - m.force = !m.force - case "x": - // The same selection `p` makes after its fetch, for a repo whose - // gone marks are already known; no network round trip needed. - m.err = "" - m.status = m.selectGone() - case "p": - if !m.fetching { - m.fetching = true - m.err = "" - m.status = "fetching --all --prune…" - return m, fetchPruneCmd() - } - case "v": - if b := m.cur(); b != nil { - diff, base, err := loadDiff(b.name) - if err != nil { - m.err = err.Error() - break - } - m.err = "" - m.diffBranch = b.name - m.diffBase = base - m.diffRaw = diff - m.diffLines, m.diffStyled = styleDiff(diff, m.width) - m.diffTop = 0 - m.state = stateDiff - } - case "c": - if b := m.cur(); b != nil && !b.isCurrent && !m.switching && !m.fetching { - m.switching = true - m.err = "" - m.status = "switching to " + b.name + "…" - return m, switchCmd(b.name) - } - case "?": - m.state = stateHelp - case "d", "enter": - if len(m.selectedBranches()) > 0 { - m.measureSelectedRisk() // the confirm screen states what each delete costs - m.state, m.bodyTop = stateConfirm, 0 - } - } - return m, nil -} - -func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "y", "Y": - // Local only: never delete remotes on the same key that deletes locals. - return m, m.startDeletions(false) - case "R": - // Local + remote — only meaningful when at least one remote is armed. - if m.armedRemoteCount() > 0 { - return m, m.startDeletions(true) - } - case "n", "N", "esc", "q": - m.state = stateList - case "ctrl+c": - return m, tea.Quit - default: - m.scrollKeys(msg.String()) - } - return m, nil -} - -// countArmedRemotes counts branches whose remote deletion is armed. -func countArmedRemotes(branches []branch) int { - n := 0 - for _, b := range branches { - if b.deleteRemote && b.upstream != "" { - n++ - } - } - return n -} - -// armedRemoteCount counts selected branches whose remote deletion is armed. -func (m model) armedRemoteCount() int { - return countArmedRemotes(m.selectedBranches()) -} - -// startDeletions kicks off the asynchronous deletion of the selected branches, -// pre-seeding results, entering stateDeleting, and returning a batch of one cmd -// per branch (which run concurrently) plus the spinner tick. Remote branches are -// pushed --delete only when includeRemote is set (see updateConfirm). -func (m *model) startDeletions(includeRemote bool) tea.Cmd { - m.measureSelectedRisk() // results carry the cost through to the force prompt - sel := m.selectedBranches() - m.results = make([]deleteResult, len(sel)) - m.spinnerFrame = 0 - m.state, m.bodyTop = stateDeleting, 0 - - cmds := []tea.Cmd{spinnerTickCmd()} - for i, b := range sel { - m.results[i] = deleteResult{br: b} - wantRemote := includeRemote && b.deleteRemote && b.upstream != "" - cmds = append(cmds, deleteBranchCmd(i, b, b.deleteFlag(m.force), wantRemote)) - } - return tea.Batch(cmds...) -} - -// deletesDone counts how many of the current run's deletions have completed. -func (m model) deletesDone() int { - n := 0 - for _, r := range m.results { - if r.done { - n++ - } - } - return n -} - -// forceableFailures returns the results whose safe (-d) local delete was -// refused — the ones a force (-D) delete could clear. -func (m model) forceableFailures() []deleteResult { - var out []deleteResult - for _, r := range m.results { - if !r.localOK && r.forceable { - out = append(out, r) - } - } - return out -} - -func (m model) updateForcePrompt(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "y", "Y": - m.forceDeleteUnmerged() - m.state, m.bodyTop = stateResult, 0 - case "n", "N", "esc", "q", "enter": - m.state, m.bodyTop = stateResult, 0 - case "ctrl+c": - return m, tea.Quit - default: - m.scrollKeys(msg.String()) - } - return m, nil -} - -func (m *model) clampDiff() { - m.diffTop = max(0, min(m.diffTop, len(m.diffLines)-m.visibleRows())) -} - -func (m model) updateDiff(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc", "v": - m.state = stateList - case "ctrl+c": - return m, tea.Quit - case "up", "k": - m.diffTop-- - m.clampDiff() - case "down", "j": - m.diffTop++ - m.clampDiff() - case "ctrl+u", "pgup": - m.diffTop -= m.visibleRows() / 2 - m.clampDiff() - case "ctrl+d", "pgdown", " ": - m.diffTop += m.visibleRows() / 2 - m.clampDiff() - case "g", "home": - m.diffTop = 0 - case "G", "end": - m.diffTop = len(m.diffLines) - m.clampDiff() - } - return m, nil -} - -// deleteFlag returns the git branch delete flag for b under the given force mode. -func (b branch) deleteFlag(force bool) string { - if b.forcedDelete(force) { - return "-D" - } - return "-d" -} - -// 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 { - res := deleteResult{br: b, done: true} - 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 - } - // 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 - // is deferred until the force retry clears the local branch. - switch { - case !wantRemote: - case res.localOK: - pushRemoteDelete(&res) - default: - res.remoteSkipped = true - } - return res -} - -// 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. -func pushRemoteDelete(res *deleteResult) { - res.remoteSkipped = false - res.remoteTried = true - // Qualify the remote branch: a remote carrying both a branch and a tag of that - // name rejects a bare refspec as matching more than one ref, deleting nothing. - if _, err := runGit("push", res.br.remoteName(), "--delete", branchRef(res.br.remoteBranch())); err != nil { - res.remoteErr = err.Error() - } else { - res.remoteOK = true - } -} - -// carryMarks copies the user's pending selections from old onto a freshly loaded -// branch set, matching by name. Used on the fetch path, which reloads every -// branch struct but changes nothing the marks were made about. An armed remote -// delete is dropped when the fetch reveals the upstream is already gone: the push -// it would run can only fail. -func carryMarks(old, fresh []branch) []branch { - prev := make(map[string]branch, len(old)) - for _, b := range old { - prev[b.name] = b - } - for i := range fresh { - if p, ok := prev[fresh[i].name]; ok { - // Marks never land on the current branch: it cannot be deleted, so - // a carried mark would arm an operation the list refuses to offer - // (relevant after a switch, or when HEAD moved outside the TUI). - fresh[i].selected = p.selected && !fresh[i].isCurrent - fresh[i].deleteRemote = p.deleteRemote && !fresh[i].gone && !fresh[i].isCurrent - } - } - return fresh -} - -// applyBranches installs a freshly-loaded branch set and recomputes everything -// derived from it (name-width, merge info, sort order). Callers set their own -// cursor policy around it. This is the single refresh core shared by -// reloadBranches and the fetch handler. -func (m *model) applyBranches(branches []branch, reads repoReads) { - m.branches = branches - m.recomputeNameWidth() - m.refreshMergeInfo(reads) - m.sortBranches() -} - -// reloadBranches refreshes the branch list from git and resets the view to the -// top — appropriate after a mutation that may have removed the cursor's branch. -func (m *model) reloadBranches() { - if branches, reads, err := loadRepo(); err == nil { - m.cursor = 0 - m.top = 0 - m.applyBranches(branches, reads) - } -} - -// hasBranch reports whether the loaded list holds a local branch of that name. -func (m model) hasBranch(name string) bool { - for _, b := range m.branches { - if b.name == name { - return true - } - } - return false -} - -// refreshMergeInfo caches the remote default branch, marks each branch whose -// upstream is merged into it or whose tip is merged into HEAD, and measures what -// deleting it would cost. Call after every branch (re)load, passing the reads -// that came back with it. -func (m *model) refreshMergeInfo(reads repoReads) { - defRef := remoteDefaultFrom(reads.remotes) - - m.riskBaseRef = defRef - if m.riskBaseRef == "" { - // The branch list is already loaded, so this candidate check is a lookup - // rather than a subprocess per name. - m.riskBaseRef = localDefaultBranch("", m.hasBranch) - } - // The refs drive git; the short forms are what the views print. - m.remoteDefault = shortRef(defRef) - m.riskBase = shortRef(m.riskBaseRef) - - // Round two. Neither query needs the other's answer, and each is a - // subprocess start, so they go together. - var merged map[string]bool - remote := make(chan struct{}) - go func() { - merged = remoteMergedSet(defRef) - close(remote) - }() - // One query answers "is this branch's tip already in the base?" for the whole - // list, which is the answer for most branches a prune touches. - m.baseMerged = nil - if m.riskBaseRef != "" { - m.baseMerged = mergedSet("branch", "--merged", m.riskBaseRef) - } - <-remote - - for i := range m.branches { - b := &m.branches[i] - b.remoteMerged = b.upstream != "" && merged[b.upstream] - b.headMerged = reads.headMerged[b.name] - b.riskMeasured = false // the branch was just reloaded; any old count is stale - } - // Only gone branches are measured up front, because `p` consults the count to - // decide what it may auto-select. The rest wait for measureSelectedRisk: - // riskCommitCount is a subprocess per branch, and measuring every unmergeable - // branch here would put a network-free repo's whole branch list on the clock. - m.measureRisk(func(b branch) bool { return b.gone }) -} - -// measureRisk fills in the cost-of-deletion count for every not-yet-measured -// branch that want accepts. The counts are independent `git cherry` subprocesses -// whose cost is dominated by process spawn, so they run concurrently: measured -// one at a time, a repo with a hundred gone branches spent 1.1s here on every -// load, fetch and prune. -func (m *model) measureRisk(want func(branch) bool) { - base := m.riskBaseRef // read once: the goroutines must not touch the model - var wg sync.WaitGroup - for i := range m.branches { - if m.branches[i].riskMeasured || !want(m.branches[i]) { - continue - } - // A branch already contained in the base has an empty base..branch range, - // so `git cherry` would report nothing. Answer from the set instead of - // spawning the subprocess. - if m.baseMerged[m.branches[i].name] { - m.branches[i].riskCommits = 0 - m.branches[i].riskMeasured = true - continue - } - wg.Add(1) - go func() { - defer wg.Done() - // Each goroutine owns one slice element, so no two write the same branch. - m.branches[i].riskCommits = riskCommitCount(m.branches[i].name, base) - m.branches[i].riskMeasured = true - }() - } - wg.Wait() -} - -// measureSelectedRisk measures what deleting each selected branch would cost. -// Call before any view that reports the cost: only branches a safe delete would -// refuse are measured, since those are the ones deleted with -D. -func (m *model) measureSelectedRisk() { - m.measureRisk(func(b branch) bool { return b.selected && (b.gone || !b.safeDeletable()) }) -} - -// forceDeleteUnmerged re-runs the deletions that a safe (-d) delete refused, -// this time with -D. It updates the matching result in place so the results -// screen reflects the retry outcome. -func (m *model) forceDeleteUnmerged() { - for i := range m.results { - r := &m.results[i] - if r.localOK || !r.forceable { - continue - } - if _, err := runGit("branch", "-D", r.br.name); err != nil { - r.localErr = err.Error() - continue - } - r.localOK = true - r.localErr = "" - // The armed remote delete was deferred while the local branch survived; - // now that it is gone, honour what the user confirmed. - if r.remoteSkipped { - pushRemoteDelete(r) - } - } - m.reloadBranches() -} - -// ---- views ---- - -func (m model) View() string { - switch m.state { - case stateConfirm: - return m.confirmView() - case stateForcePrompt: - return m.forcePromptView() - case stateDeleting: - return m.deletingView() - case stateResult: - return m.resultView() - case stateHelp: - return m.helpView() - case stateDiff: - return m.diffView() - default: - return m.listView() - } -} - -// page renders a screen as fixed header lines, a window into body, and fixed -// footer lines. The prompt on these screens is the whole point of them, and it -// lives in the footer: without a window, a wide selection pushes the question -// past the last row of the terminal, where the user cannot read what they are -// answering. Rendering only the visible rows is also what keeps a long list off -// the cost of every frame. -func (m model) page(header, body, footer []string) string { - rows := m.bodyRows(len(header), len(footer), len(body)) - top := max(0, min(m.bodyTop, len(body)-rows)) - end := min(top+rows, len(body)) - - var b strings.Builder - for _, l := range header { - b.WriteString(l + "\n") - } - for _, l := range body[top:end] { - b.WriteString(l + "\n") - } - if len(body) > rows { - b.WriteString(dimStyle.Render(fmt.Sprintf("[%d-%d / %d] ↑/↓ scroll · space/ctrl+d page · g/G top/bottom", - top+1, end, len(body))) + "\n") - } - for _, l := range footer { - b.WriteString(l + "\n") - } - return b.String() -} - -// bodyRows is how many body lines fit between header and footer. A body that -// does not fit gives up one more row to the position line, so that line never -// pushes the footer off in its turn. -func (m model) bodyRows(header, footer, body int) int { - rows := max(1, m.height-header-footer) - if body > rows { - rows = max(1, rows-1) - } - return rows -} - -// pageParts returns the current state's screen as header, body and footer. The -// scroll keys measure against it too, so the window and the clamp can never -// disagree about how far down the body goes. -func (m model) pageParts() (header, body, footer []string) { - switch m.state { - case stateConfirm: - return m.confirmParts() - case stateForcePrompt: - return m.forcePromptParts() - case stateDeleting: - return m.deletingParts() - case stateResult: - return m.resultParts() - } - return nil, nil, nil -} - -// bodyWindow reports the visible row count and the total body length for the -// current state. -func (m model) bodyWindow() (rows, total int) { - header, body, footer := m.pageParts() - return m.bodyRows(len(header), len(footer), len(body)), len(body) -} - -func (m *model) clampBody() { - rows, total := m.bodyWindow() - m.bodyTop = max(0, min(m.bodyTop, total-rows)) -} - -// scrollKeys applies the shared paging keys to a windowed screen. It reports -// whether the key was one of them, so each screen's own keys stay in charge: -// callers must offer their answers first. -func (m *model) scrollKeys(s string) bool { - rows, total := m.bodyWindow() - switch s { - case "up", "k": - m.bodyTop-- - case "down", "j": - m.bodyTop++ - case "ctrl+u", "pgup": - m.bodyTop -= max(1, rows/2) - case "ctrl+d", "pgdown", " ": - m.bodyTop += max(1, rows/2) - case "g", "home": - m.bodyTop = 0 - case "G", "end": - m.bodyTop = total - default: - return false - } - m.clampBody() - return true -} - -func colorizeDiffLine(line string) string { - switch { - case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"): - return headerStyle.Render(line) - case strings.HasPrefix(line, "diff "), strings.HasPrefix(line, "index "), - strings.HasPrefix(line, "new file"), strings.HasPrefix(line, "deleted file"), - strings.HasPrefix(line, "rename "), strings.HasPrefix(line, "similarity "): - return dimStyle.Render(line) - case strings.HasPrefix(line, "@@"): - return hunkStyle.Render(line) - case strings.HasPrefix(line, "+"): - return addStyle.Render(line) - case strings.HasPrefix(line, "-"): - return delStyle.Render(line) - default: - return line - } -} - -func (m model) diffView() string { - var b strings.Builder - - base := m.diffBase - title := fmt.Sprintf("diff — %s (vs %s)", m.diffBranch, base) - if m.diffStyled { - title += " · delta" - } - b.WriteString(headerStyle.Render(title)) - b.WriteString("\n\n") - - if len(m.diffLines) == 0 { - b.WriteString(dimStyle.Render("no changes — branch matches " + base)) - b.WriteString("\n\n") - b.WriteString(dimStyle.Render("q/esc back · v back")) - b.WriteString("\n") - return b.String() - } - - vis := m.visibleRows() - end := min(m.diffTop+vis, len(m.diffLines)) - for i := m.diffTop; i < end; i++ { - line := truncate(m.diffLines[i], m.width) - if !m.diffStyled { - line = colorizeDiffLine(line) - } - b.WriteString(line) - b.WriteString("\n") - } - - b.WriteString("\n") - pos := fmt.Sprintf("[%d-%d / %d]", m.diffTop+1, end, len(m.diffLines)) - help := "↑/↓ scroll · space/ctrl+d page · g/G top/bottom · q/esc/v back" - b.WriteString(dimStyle.Render(pos + " " + help)) - b.WriteString("\n") - return b.String() -} - -// recomputeNameWidth caches the branch-name column width; call whenever the -// branch list changes (it depends only on the set of names, not render state). -func (m *model) recomputeNameWidth() { - w := 0 - for _, br := range m.branches { - w = max(w, ansi.StringWidth(br.name)) - } - m.nameW = min(40, max(6, w)) -} - -func (m model) listView() string { - var b strings.Builder - - dir := "desc" - if m.ascending { - dir = "asc" - } - forceLabel := "safe (-d)" - if m.force { - forceLabel = "FORCE (-D)" - } - idx := m.viewIdx() - header := fmt.Sprintf("git_pruner — %d branches sort: %s %s delete mode: %s", - len(m.branches), m.field, dir, forceLabel) - b.WriteString(headerStyle.Render(header)) - if m.filter != "" || m.state == stateFilter { - f := m.filter - if m.state == stateFilter { - f += "▌" - } - b.WriteString(selStyle.Render(fmt.Sprintf(" filter: %s (%d/%d)", f, len(idx), len(m.branches)))) - } - b.WriteString("\n\n") - - switch { - case len(m.branches) == 0: - b.WriteString(dimStyle.Render("no local branches found")) - b.WriteString("\n") - case len(idx) == 0: - b.WriteString(dimStyle.Render("no branches match filter")) - b.WriteString("\n") - } - - nameW := m.nameW - vis := m.visibleRows() - end := min(m.top+vis, len(idx)) - for p := m.top; p < end; p++ { - b.WriteString(m.renderRow(m.branches[idx[p]], nameW, p == m.cursor)) - b.WriteString("\n") - } - - b.WriteString("\n") - var help string - if m.state == stateFilter { - help = "type to filter · enter keep · esc clear · backspace edit" - } else { - help = "↑/↓ move · space select · a/n all/none · c checkout · / filter · r remote · v view · x gone · p prune · s sort · o order · f force · d delete · ? help · q quit" - } - b.WriteString(dimStyle.Render(help)) - if m.status != "" { - b.WriteString("\n") - b.WriteString(okStyle.Render(m.status)) - } - if m.err != "" { - b.WriteString("\n") - b.WriteString(errStyle.Render(m.err)) - } - return b.String() -} - -// selectGone selects every gone branch that carries nothing missing from the -// base and reports the outcome for the status line. Branches holding unique -// commits stay unselected so discarding them is a deliberate keystroke rather -// than a side effect of `p` or `x`. -func (m *model) selectGone() string { - gone, risky := 0, 0 - for i := range m.branches { - br := &m.branches[i] - if !br.gone || br.isCurrent { - continue - } - gone++ - if br.riskCommits > 0 { - risky++ - continue - } - br.selected = true - } - switch { - case gone == 0: - return "no gone branches" - case risky == 0: - return fmt.Sprintf("%d gone branch(es) selected; press d to prune", gone) - default: - return fmt.Sprintf("%d of %d gone branch(es) selected; %d hold commits not in %s (select with space to discard)", - gone-risky, gone, risky, m.riskBase) - } -} - -func (m model) renderRow(br branch, nameW int, isCursor bool) string { - cursor := " " - if isCursor { - cursor = cursorStyle.Render("> ") - } - sel := "[ ]" - if br.selected { - sel = selStyle.Render("[x]") - } - rem := " " - if br.deleteRemote { - rem = errStyle.Render("R") - } - cur := " " - if br.isCurrent { - cur = currentStyle.Render("*") - } - - name := pad(truncate(br.name, nameW), nameW) - - // Selection outranks the cursor: the row band already marks the cursor, - // and a name that stays yellow under it keeps the pending delete visible. - var nameRendered string - switch { - case br.selected: - nameRendered = selStyle.Render(name) - case br.isCurrent: - nameRendered = currentStyle.Render(name) - case isCursor: - nameRendered = cursorStyle.Render(name) - default: - nameRendered = nameStyle.Render(name) - } - - track := m.trackStr(br) - abs := fmt.Sprintf("%-11s", br.committed.Format("2006-Jan-02")) - rel := fmt.Sprintf("%-13s", br.committedRel) - hash := fmt.Sprintf("%-8s", br.hash) - - row := fmt.Sprintf("%s%s %s %s %s %s %s %s %s %s", - cursor, sel, rem, cur, nameRendered, track, - dimStyle.Render(abs), dimStyle.Render(rel), hashStyle.Render(hash), - subjectStyle.Render(truncate(br.subject, m.subjectWidth(nameW)))) - if isCursor { - row = highlightRow(row, m.width) - } - return row -} - -// highlightRow paints the cursor band behind an already-styled row. Each -// column ends with a reset that would drop a background wrapped around the -// whole row, so the band is re-armed after every reset instead. The row is -// padded first so the band spans the full terminal width. -func highlightRow(row string, width int) string { - const reset = "\x1b[0m" - // Rendering nothing yields just the on/off sequences, or "" when the - // color profile disables styling — then there is no band to paint. - on, ok := strings.CutSuffix(rowBgStyle.Render(""), reset) - if !ok || on == "" { - return row - } - if d := width - ansi.StringWidth(row); d > 0 { - row += strings.Repeat(" ", d) - } - return on + strings.ReplaceAll(row, reset, reset+on) + reset -} - -func (m model) trackStr(br branch) string { - if br.gone { - // Same column style as every other track value, or the columns that - // follow shift left on exactly the rows the user is here to act on. - return trackColStyle.Render(goneStyle.Render("gone")) - } - if br.upstream == "" { - return trackColStyle.Render(dimStyle.Render("-")) - } - s := "" - if br.ahead > 0 { - s += aheadStyle.Render("↑" + strconv.Itoa(br.ahead)) - } - if br.behind > 0 { - s += behindStyle.Render("↓" + strconv.Itoa(br.behind)) - } - if s == "" { - s = currentStyle.Render("=") - } - if br.remoteMerged { // upstream is merged into the remote default — safe to delete - s += okStyle.Render(" ✓") - } - return trackColStyle.Render(s) -} - -func (m model) subjectWidth(nameW int) int { - // Sum of every fixed column width and separator in renderRow's format, - // plus nameW; keep in sync with that format string. The 10 is the track - // column (trackColStyle width); the trailing 8 is the hash column. - used := 2 + 3 + 1 + 1 + 1 + 1 + 1 + 1 + nameW + 2 + 10 + 1 + 11 + 1 + 13 + 1 + 8 + 1 - return max(10, m.width-used) -} - -// truncate shortens s to w terminal cells, appending an ellipsis when it does -// not fit. Measured in display cells rather than bytes so multibyte text is -// never sliced mid-rune and wide (CJK/emoji) characters do not overflow. -func truncate(s string, w int) string { - if w <= 0 { - return "" - } - return ansi.Truncate(s, w, "…") -} - -// pad right-pads s to w display cells. The fmt width verbs count runes, which -// misaligns columns whose content contains wide characters. -func pad(s string, w int) string { - if d := w - ansi.StringWidth(s); d > 0 { - return s + strings.Repeat(" ", d) - } - return s -} - -func (m model) helpView() string { - var b strings.Builder - b.WriteString(headerStyle.Render("git_pruner — help")) - b.WriteString("\n\n") - - writeRows := func(pairs [][2]string) { - for _, p := range pairs { - b.WriteString(" " + cursorStyle.Render(fmt.Sprintf("%-14s", p[0])) + subjectStyle.Render(p[1]) + "\n") - } - } - - writeRows([][2]string{ - {"↑/↓, j/k", "move cursor"}, - {"g/G, home/end", "jump to first/last"}, - {"space", "select / deselect branch"}, - {"a / n", "select all listed / none"}, - {"c", "checkout the branch under the cursor"}, - {"/", "filter by name (enter keep · esc clear)"}, - {"r", "toggle delete of upstream remote branch"}, - {"v", "view branch diff (through delta when installed)"}, - {"x", "select gone branches that hold no unique work"}, - {"p", "fetch --all --prune, then do the same as x"}, - {"s", "cycle sort field (date, name, ahead/behind)"}, - {"o", "toggle sort order (asc/desc)"}, - {"f", "toggle force delete (-d / -D)"}, - {"d, enter", "delete selected branches (local)"}, - {"", "on confirm: y = local only · R = local + remote"}, - {"", "(unmerged -d failures prompt to retry with -D)"}, - {"", "long lists scroll: ↑/↓ · space/ctrl+d · g/G"}, - {"?", "toggle this help screen"}, - {"q, ctrl+c", "quit"}, - }) - - b.WriteString("\n") - b.WriteString(headerStyle.Render("Columns")) - b.WriteString("\n") - writeRows([][2]string{ - {"*", "current branch (cannot be deleted)"}, - {"[x]", "selected for deletion"}, - {"R", "its remote branch will also be deleted"}, - {"↑/↓", "commits ahead of / behind upstream"}, - {"✓", "upstream merged into remote default (safe)"}, - {"gone", "upstream was configured but no longer exists"}, - }) - - 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.")) - b.WriteString("\n\n") - - commit, date := buildInfo() - b.WriteString(dimStyle.Render(fmt.Sprintf("build date: %s · commit: %s", date, commit))) - b.WriteString("\n\n") - - b.WriteString(dimStyle.Render("press any key to return")) - b.WriteString("\n") - return b.String() -} - -func (m model) confirmParts() (header, body, footer []string) { - sel := m.selectedBranches() - flag := "-d (safe)" - if m.force { - flag = "-D (force)" - } - remoteCount := countArmedRemotes(sel) - header = []string{ - headerStyle.Render("Confirm deletion"), - "", - fmt.Sprintf("Local delete mode: %s", flag), - fmt.Sprintf("Deleting %d local branch(es), %d remote branch(es).", len(sel), remoteCount), - "", - } - - for _, br := range sel { - body = append(body, " "+cursorStyle.Render("• "+br.name)) - - date := br.committed.Format("2006-Jan-02") - if br.committedRel != "" { - date += " (" + br.committedRel + ")" - } - body = append(body, " "+dimStyle.Render(fmt.Sprintf("%s %s %s", br.hash, date, truncate(br.subject, 50)))) - - switch { - case br.gone: - body = append(body, " "+goneStyle.Render("upstream gone: "+br.upstream+" — will prune with -D (force)")) - case br.upstream != "": - body = append(body, " "+dimStyle.Render("upstream: "+br.upstream)+" "+m.trackStr(br)) - default: - body = append(body, " "+dimStyle.Render("no upstream")) - } - - if br.upstream != "" && m.remoteDefault != "" { - if br.remoteMerged { - body = append(body, " "+okStyle.Render("✓ merged into "+m.remoteDefault)) - } else { - body = append(body, " "+goneStyle.Render("⚠ not merged into "+m.remoteDefault)) - } - } - - if br.deleteRemote && br.upstream != "" { - body = append(body, " "+errStyle.Render(fmt.Sprintf("+ delete remote %s/%s", br.remoteName(), br.remoteBranch()))) - } - if w := m.riskWarning(br); w != "" { - body = append(body, " "+errStyle.Render(w)) - } - body = append(body, "") - } - - prompt := headerStyle.Render("Delete these branches? ") - if remoteCount > 0 { - prompt += dimStyle.Render(fmt.Sprintf("(y = local only · R = local + remote (%d) · n/esc = cancel)", remoteCount)) - } else { - prompt += dimStyle.Render("(y = yes · n/esc = cancel)") - } - return header, body, []string{prompt} -} - -func (m model) confirmView() string { return m.page(m.confirmParts()) } - -// 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 -// discarded, under -d the delete simply fails. -func (m model) riskWarning(br branch) string { - if br.safeDeletable() && !br.gone { - return "" - } - if br.forcedDelete(m.force) { // -D: the question is what gets discarded - if br.riskCommits > 0 { - return fmt.Sprintf("⚠ %d commit(s) not in %s — force delete (-D) will discard them", br.riskCommits, m.riskBase) - } - if m.riskBase == "" { - return "⚠ no base branch to compare against — force delete (-D) discards any unmerged commits" - } - return "" // measured against a real base: nothing here is at risk - } - // -d will be refused either way; the count is what a force would then cost. - if br.riskCommits > 0 { - return fmt.Sprintf("⚠ not fully merged: %d commit(s) not in %s — safe delete (-d) will fail; use force (f)", br.riskCommits, m.riskBase) - } - return "⚠ not fully merged — safe delete (-d) will fail; use force (f)" -} - -func (m model) forcePromptParts() (header, body, footer []string) { - failures := m.forceableFailures() - - header = []string{ - headerStyle.Render("Force delete unmerged branches?"), - "", - fmt.Sprintf("%d branch(es) were refused by safe delete (-d) because they are not", len(failures)), - "fully merged. Force deleting (-D) will " + errStyle.Render("permanently discard their unmerged commits") + ".", - "", - } - - for _, r := range failures { - body = append(body, " "+cursorStyle.Render("• "+r.br.name)) - // Every branch here failed -d, so its risk was measured before the delete - // ran: riskCommits == 0 means either nothing is missing from the base or - // there was no base to measure against. - switch { - case r.br.riskCommits > 0: - body = append(body, " "+errStyle.Render(fmt.Sprintf("⚠ %d commit(s) not in %s will be lost", r.br.riskCommits, m.riskBase))) - case m.riskBase == "": - body = append(body, " "+errStyle.Render("⚠ no base branch to compare against — unmerged commits may be lost")) - default: - body = append(body, " "+dimStyle.Render("no commits missing from "+m.riskBase)) - } - 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()))) - } - } - - footer = []string{ - "", - headerStyle.Render("Force delete (-D) these branches? ") + dimStyle.Render("(y = yes, discard · n/esc = keep them)"), - } - return header, body, footer -} - -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.localOK { - dst = append(dst, okStyle.Render(" ✓ ")+"deleted local "+r.br.name) - } else { - dst = append(dst, errStyle.Render(" ✗ ")+"local "+r.br.name+": "+r.localErr) - } - switch { - case r.remoteSkipped: - // Say why the armed remote survived, or it reads as a silent failure. - dst = append(dst, errStyle.Render(" ! ")+"kept remote "+r.br.remoteName()+"/"+r.br.remoteBranch()+": local delete failed") - case r.remoteTried && r.remoteOK: - dst = append(dst, okStyle.Render(" ✓ ")+"deleted remote "+r.br.name) - case r.remoteTried: - dst = append(dst, errStyle.Render(" ✗ ")+"remote "+r.br.name+": "+r.remoteErr) - } - return dst -} - -func (m model) deletingParts() (header, body, footer []string) { - spin := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] - header = []string{ - headerStyle.Render(fmt.Sprintf("%s Deleting… (%d/%d)", spin, m.deletesDone(), len(m.results))), - "", - } - for _, r := range m.results { - if r.done { - body = appendResultLines(body, r) - } else { - body = append(body, dimStyle.Render(" "+spin+" deleting "+r.br.name+"…")) - } - } - return header, body, []string{"", dimStyle.Render("working — ctrl+c to abort")} -} - -func (m model) deletingView() string { return m.page(m.deletingParts()) } - -func (m model) resultParts() (header, body, footer []string) { - header = []string{headerStyle.Render("Results"), ""} - for _, r := range m.results { - body = appendResultLines(body, r) - } - return header, body, []string{"", dimStyle.Render("press q/enter to quit")} -} - -func (m model) resultView() string { return m.page(m.resultParts()) } - // buildInfo returns the commit the binary was built from and when it was // compiled, preferring -ldflags values and falling back to Go's embedded VCS info. func buildInfo() (commit, date string) { @@ -2018,16 +75,23 @@ func main() { fmt.Println(versionString()) return } + os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr)) } - m, err := initialModel() + settingsPath = defaultSettingsPath() + m, err := startupModel() if err != nil { fmt.Fprintln(os.Stderr, "git_pruner:", err) os.Exit(1) } p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) - if _, err := p.Run(); err != nil { + final, err := p.Run() + if err != nil { fmt.Fprintln(os.Stderr, "git_pruner:", err) os.Exit(1) } + // Printed after the alt screen closes, so the commands stay in scrollback. + if fm, ok := final.(model); ok { + fmt.Print(restoreSummary(append(fm.history, fm.results...))) + } } diff --git a/main_test.go b/main_test.go index d50e9fd..6057469 100644 --- a/main_test.go +++ b/main_test.go @@ -755,9 +755,19 @@ func TestDeletingIgnoresKeysExceptCtrlC(t *testing.T) { } } - _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + // Quitting mid-run drops pushes not yet started, so the first ctrl+c only + // warns; the second quits. + nm, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + m = nm.(model) + if cmd != nil || !m.abortArmed { + t.Fatal("the first ctrl+c must warn, not quit") + } + if o := stripANSI(m.deletingView()); !strings.Contains(o, "ctrl+c again to quit anyway") { + t.Fatalf("the warning must be on screen:\n%s", o) + } + _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) if cmd == nil { - t.Fatal("ctrl+c must abort") + t.Fatal("the second ctrl+c must abort") } if _, ok := cmd().(tea.QuitMsg); !ok { t.Fatalf("ctrl+c should quit, got %T", cmd()) @@ -1113,7 +1123,7 @@ func TestRemotesPrefersOrigin(t *testing.T) { git(t, repo, "remote", "add", "aaa-fork", repo) git(t, repo, "fetch", "-q", "aaa-fork") - got := loadRemoteRefs().names + got := loadRemoteRefs(false).names if len(got) == 0 || got[0] != "origin" { t.Fatalf("origin should sort first, got %v", got) } @@ -1673,15 +1683,15 @@ func TestGoneCurrentBranchIsNotPruned(t *testing.T) { t.Fatalf("status must not offer to prune the current branch: %q", m.status) } - // Selected by hand, git refuses — a failure -D cannot rescue, so it must not - // raise the force prompt offering a retry that fails identically. + // A mark set past the keys still never reaches a delete. b.selected = true - m = startAndDrain(t, m, true) - if len(m.results) != 1 || m.results[0].localOK { - t.Fatalf("deleting the checked-out branch must fail: %+v", m.results) + if sel := m.selectedBranches(); len(sel) != 0 { + t.Fatalf("the checked-out branch must never be offered for deletion: %v", branchNames(sel)) } - if m.results[0].forceable { - t.Fatalf("a checked-out branch cannot be rescued by -D: %q", m.results[0].localErr) + // 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 { + t.Fatalf("deleting the checked-out branch must fail, unforceable: %+v", r) } if find(m.branches, "feature/tracked") == nil { t.Fatal("feature/tracked must survive the refused delete") @@ -1708,7 +1718,7 @@ func TestLocalOnlyRepo(t *testing.T) { repo := setupLocalRepo(t) chdir(t, repo) - if got := loadRemoteRefs().names; len(got) != 0 { + if got := loadRemoteRefs(false).names; len(got) != 0 { t.Fatalf("no remotes should be configured, got %v", got) } if got := remoteDefault(); got != "" { @@ -1829,24 +1839,24 @@ func TestDetachedHead(t *testing.T) { t.Fatalf("feature/unmerged is not merged into the detached HEAD: %+v", b) } - // With nothing current, `a` selects everything — there is no branch to spare. + // With nothing current, `a` selects everything but the protected trunk. nm, _ := m.updateList(key("a")) m = nm.(model) - if got := len(m.selectedBranches()); got != len(m.branches) { - t.Fatalf("select-all should take all %d branches, got %d", len(m.branches), got) + if got := len(m.selectedBranches()); got != len(m.branches)-1 || find(m.branches, "main").selected { + t.Fatalf("select-all should take all %d branches but main, got %d", len(m.branches)-1, got) } - // Deleting the branch HEAD is parked on is legal while detached, and safe: - // the commits stay reachable from HEAD. + // Deleting a branch at the commit HEAD is parked on is legal while + // detached, and safe: the commits stay reachable from HEAD. nm, _ = m.updateList(key("n")) m = nm.(model) - find(m.branches, "main").selected = true + find(m.branches, "feature/merged").selected = true m = startAndDrain(t, m, true) if len(m.results) != 1 || !m.results[0].localOK { - t.Fatalf("main should delete cleanly while detached: %+v", m.results) + t.Fatalf("feature/merged should delete cleanly while detached: %+v", m.results) } - if find(m.branches, "main") != nil { - t.Fatal("main should be gone") + if find(m.branches, "feature/merged") != nil { + t.Fatal("feature/merged should be gone") } } @@ -1972,7 +1982,7 @@ func TestUnreachableRemote(t *testing.T) { if !strings.Contains(out, "deleted local feature/tracked") { t.Fatalf("results must report the successful local delete:\n%s", out) } - if !strings.Contains(out, "remote feature/tracked:") { + if !strings.Contains(out, "remote origin/feature/tracked:") { t.Fatalf("results must report the failed push:\n%s", out) } } @@ -2068,7 +2078,7 @@ func TestRemoteDeleteRace(t *testing.T) { if remoteHasBranch(t, repo, "feature/tracked") { t.Fatal("the remote branch must be absent afterwards") } - if o := stripANSI(m.resultView()); !strings.Contains(o, "deleted remote feature/tracked") { + if o := stripANSI(m.resultView()); !strings.Contains(o, "deleted remote origin/feature/tracked") { t.Fatalf("results must report the remote as dealt with:\n%s", o) } } @@ -2389,7 +2399,7 @@ func TestResultViewWindowsItsBody(t *testing.T) { if rows := screenRows(m.resultView()); rows > m.height { t.Fatalf("40 results rendered %d rows into a %d-row terminal", rows, m.height) } - if !strings.Contains(stripANSI(m.resultView()), "press q/enter to quit") { + if !strings.Contains(stripANSI(m.resultView()), "enter back to list · q quit") { t.Fatal("the footer must stay on the screen") } @@ -2764,7 +2774,21 @@ func TestDiffRerendersOnResize(t *testing.T) { } m := model{width: 80, height: 24, state: stateDiff, diffRaw: "-a\n+b\n"} m.diffLines, m.diffStyled = styleDiff(m.diffRaw, m.width) - nm, _ := m.Update(tea.WindowSizeMsg{Width: 140, Height: 40}) + nm, cmd := m.Update(tea.WindowSizeMsg{Width: 140, Height: 40}) + m = nm.(model) + if cmd == nil { + t.Fatal("resize must schedule a re-layout") + } + // A second resize before the wait ends makes the first one stale. + stale := cmd() + nm, cmd = m.Update(tea.WindowSizeMsg{Width: 140, Height: 40}) + m = nm.(model) + if _, c := m.Update(stale); c != nil { + t.Fatal("a resize that was overtaken must not run delta") + } + nm, cmd = m.Update(cmd()) // the wait ends; delta runs once + m = nm.(model) + nm, _ = m.Update(cmd()) m = nm.(model) if !m.diffStyled || len(m.diffLines) != 1 || m.diffLines[0] != "w --width=140" { t.Fatalf("resize must re-run delta at the new width, got %q", m.diffLines) diff --git a/model.go b/model.go new file mode 100644 index 0000000..1dc92d7 --- /dev/null +++ b/model.go @@ -0,0 +1,1334 @@ +package main + +import ( + "fmt" + "path" + "slices" + "sort" + "strconv" + "strings" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +type sortField int + +const ( + sortDate sortField = iota + sortName + sortAheadBehind + sortFieldCount // number of sort fields; keep last +) + +func (s sortField) String() string { + switch s { + case sortDate: + return "committerdate" + case sortName: + return "name" + case sortAheadBehind: + return "ahead/behind" + } + return "?" +} + +type viewState int + +const ( + stateList viewState = iota + stateConfirm + stateForcePrompt + stateDeleting + stateResult + stateHelp + stateDiff + stateFilter // the list, with the filter line capturing keystrokes + stateAge // the list, with the merged-and-old prompt capturing digits +) + +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 + // 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. + remoteSkipped bool + restored bool // undo recreated the local branch +} + +// restorable reports whether undo can bring r's local branch back. +func (r deleteResult) restorable() bool { + return r.localOK && !r.restored && r.br.sha != "" +} + +type model struct { + branches []branch // local rows and, once loaded, remoteOnly rows + cursor int + top int // index of first visible row (scroll window) + + field sortField + ascending bool + force bool + nameW int // cached branch-name column width (see recomputeNameWidth) + + filter string // active branch-name filter; "" shows every branch + ageInput string // digits typed into the merged-and-old prompt + showRemote bool // the list shows remoteOnly rows instead of local ones + // remoteLoaded records that the remote rows were asked for once; every + // later reload then keeps them current. + remoteLoaded bool + + state viewState + results []deleteResult + history []deleteResult // earlier runs this session, for the exit summary + // abortArmed is set by a first ctrl+c while deletions run. Quitting then + // can strand a remote copy, so a second press is needed. + abortArmed bool + + config prunerConfig + remoteDefault string // resolved remote default branch, e.g. "origin/main" + remoteDefaultRef string // remoteDefault fully qualified + riskBase string // ref that branch.riskCommits is measured against ("" if unresolved) + riskBaseRef string // riskBase fully qualified, so a same-named tag cannot shadow it + // baseMerged holds the local branches whose tip is an ancestor of riskBaseRef. + // Their riskCommits is 0 by definition, so one query here removes a `git + // cherry` subprocess per branch (see measureRisk). + baseMerged map[string]bool + // merging is set while the startup merge queries run in the background. + // Until they land, ✓ marks and risk counts are unknown, so the keys that + // select by them wait. + merging bool + loadGen int // bumped per branch load, so a late merge result for an old list is dropped + + spinnerFrame int // animation frame for the deleting spinner (deletion counts derive from results) + + diffBranch string // branch whose diff is shown in stateDiff + diffBase string // base ref the diff was computed against + diffRaw string // unified diff as git produced it; delta is re-run from it on resize + diffLines []string // display lines of the diff being viewed + diffStyled bool // lines came from delta and carry their own colors + diffTop int // scroll offset within diffLines + diffLoading bool // the diff (or its re-layout) is being made in the background + diffSeq int // bumped per diff request, so a slow answer for an old one is dropped + + bodyTop int // scroll offset within the confirm/force/result body (see page) + + width, height int + err string + status string // transient info message (e.g. fetch results) + fetching bool // a background fetch --all --prune is in flight + switching bool // a background git switch is in flight +} + +// fetchDoneMsg reports completion of an async `git fetch --all --prune`. +type fetchDoneMsg struct{ err error } + +// fetchPruneCmd fetches all remotes and prunes deleted remote-tracking refs so +// that branches whose upstream is gone are detected. Run as a tea.Cmd to keep +// the UI responsive while the (network-bound) fetch runs. +func fetchPruneCmd() tea.Cmd { + return func() tea.Msg { + _, err := runGit("fetch", "--all", "--prune") + return fetchDoneMsg{err: err} + } +} + +// switchDoneMsg reports completion of an async `git switch`, carrying the +// reloaded repo so the reads run off the update loop too. +type switchDoneMsg struct { + name string // the local branch now checked out + fromRemote bool // the switch created name from a remoteOnly row + err error // the switch itself failed; the repo is untouched + branches []branch + reads repoReads + loadErr error +} + +// switchCmd checks out b and reloads the repo, off the update loop like every +// other mutation (see fetchPruneCmd). `git switch` resolves branch names only, +// so a same-named tag cannot shadow it (see branchRef). A remoteOnly row gets a +// new local branch that tracks it; the start point is the full ref, because a +// bare "origin/x" would lose to a tag of that name. +func switchCmd(b branch, withRemote bool) tea.Cmd { + args := []string{"switch", b.name} + msg := switchDoneMsg{name: b.name} + if b.remoteOnly { + msg.name, msg.fromRemote = b.remoteBranch(), true + args = []string{"switch", "-c", msg.name, "--track", b.ref()} + } + return func() tea.Msg { + if _, msg.err = runGit(args...); msg.err != nil { + return msg + } + msg.branches, msg.reads, msg.loadErr = loadRepo(withRemote) + return msg + } +} + +// repoLoadedMsg carries a background reload (see reloadCmd). +type repoLoadedMsg struct { + branches []branch + reads repoReads + err error +} + +// reloadCmd reloads the repo off the update loop. The first switch to the +// remote view uses it: reading every remote ref's commit can be slow on a big +// remote. +func reloadCmd(withRemote bool) tea.Cmd { + return func() tea.Msg { + b, r, err := loadRepo(withRemote) + return repoLoadedMsg{b, r, err} + } +} + +// mergeInfo is the result of the merge queries (see queryMergeInfo). +type mergeInfo struct { + remoteMerged map[string]bool // remote-tracking names merged into the remote default + baseMerged map[string]bool // local branches whose tip is in the risk base + risk map[string]int // commits not in the base, by ref, for gone branches +} + +// mergeInfoMsg carries the startup merge queries; gen ties it to one load. +type mergeInfoMsg struct { + gen int + info mergeInfo +} + +// diffLoadedMsg carries a diff made in the background; seq ties it to one request. +type diffLoadedMsg struct { + seq int + base string // "" on a re-layout: the base is unchanged + raw string + lines []string + styled bool + err error +} + +// diffCmd reads b's diff and lays it out, off the update loop: a big diff +// through delta takes long enough to freeze the screen. +func diffCmd(seq int, b branch, width int) tea.Cmd { + return func() tea.Msg { + msg := diffLoadedMsg{seq: seq} + msg.raw, msg.base, msg.err = loadDiffRef(b.ref(), b.name) + if msg.err == nil { + msg.lines, msg.styled = styleDiff(msg.raw, width) + } + return msg + } +} + +// restyleTickMsg fires a moment after a resize (see the WindowSizeMsg case). +type restyleTickMsg struct{ seq int } + +// restyleCmd re-runs delta on a diff already read, for a new terminal width. +func restyleCmd(seq int, raw string, width int) tea.Cmd { + return func() tea.Msg { + msg := diffLoadedMsg{seq: seq, raw: raw} + msg.lines, msg.styled = styleDiff(raw, width) + return msg + } +} + +// branchDeletedMsg reports the outcome of one branch's async deletion; idx is +// its position in m.results. +type branchDeletedMsg struct { + idx int + res deleteResult +} + +// spinnerTickMsg advances the deleting-view spinner animation. +type spinnerTickMsg struct{} + +// spinnerFrames are the braille frames cycled while deletions are in flight. +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 { + return func() tea.Msg { + return branchDeletedMsg{idx: idx, res: deleteBranch(b, flag, wantRemote)} + } +} + +// spinnerTickCmd schedules the next spinner frame. +func spinnerTickCmd() tea.Cmd { + return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return spinnerTickMsg{} }) +} + +// loadFirst runs the startup reads. The repo check runs beside them rather than +// ahead of them. It is here to give a clearer message than git's own, not to +// gate the work, and a serial subprocess start is most of what startup costs. +func loadFirst() ([]branch, repoReads, error) { + var repoErr error + checked := make(chan struct{}) + go func() { + _, repoErr = runGit("rev-parse", "--is-inside-work-tree") + close(checked) + }() + branches, reads, err := loadRepo(false) + <-checked + + if repoErr != nil { + return nil, reads, fmt.Errorf("not a git repository (or git is unavailable)") + } + return branches, reads, err +} + +// initialModel loads the repo with every merge query answered before it +// returns. Script mode and the tests use it; the TUI uses startupModel. +func initialModel() (model, error) { + m, err := startupModel() + if err == nil { + m.applyMergeInfo(m.mergeQuery()()) + } + return m, err +} + +// startupModel is initialModel without the wait for the merge queries: the list +// paints after one round of git, and Init fills the ✓ and risk columns in. +func startupModel() (model, error) { + branches, reads, err := loadFirst() + if err != nil { + return model{}, err + } + m := model{field: sortDate, ascending: false, height: 24, width: 100} + loadSettings(&m) // a no-op unless main set settingsPath + m.installBranches(branches, reads) + m.merging = true + return m, nil +} + +// sortBranches re-sorts and keeps the cursor on the branch it was on. +func (m *model) sortBranches() { + current := "" + if b := m.cur(); b != nil { + current = b.name + } + m.sortRows() + m.focusBranch(current) + m.clampCursor() +} + +func (m *model) sortRows() { + less := func(i, j int) bool { + a, b := m.branches[i], m.branches[j] + var r bool + switch m.field { + case sortName: + r = a.name < b.name + case sortAheadBehind: + r = (a.ahead - a.behind) < (b.ahead - b.behind) + default: + r = a.committed.Before(b.committed) + } + if !m.ascending { + return !r + } + return r + } + sort.SliceStable(m.branches, less) +} + +// focusBranch puts the cursor on the named branch's view row. A filtered-out +// or unknown name leaves the cursor where it is. The cursor is a view +// position, not a branch index — this is the one place that mapping is done. +func (m *model) focusBranch(name string) { + for p, i := range m.viewIdx() { + if m.branches[i].name == name { + m.cursor = p + m.adjustScroll() + return + } + } +} + +func (m *model) clampCursor() { + m.cursor = max(0, min(m.cursor, len(m.viewIdx())-1)) + m.adjustScroll() +} + +// scroll applies a mouse-wheel step (delta of -1 up / +1 down) to whichever +// scrollable view is active; other states ignore the wheel. +func (m *model) scroll(delta int) { + switch m.state { + case stateList, stateFilter, stateAge: + m.cursor += delta + m.clampCursor() + case stateDiff: + m.diffTop += delta * 3 // 3 lines per wheel notch, like a pager + m.clampDiff() + case stateConfirm, stateForcePrompt, stateDeleting, stateResult: + m.bodyTop += delta * 3 + m.clampBody() + } +} + +// visibleRows is how many branch rows fit: the header line and a blank above, +// a blank and the help line below, and one line each for a status and an error. +func (m model) visibleRows() int { + used := 4 + if m.status != "" { + used++ + } + if m.err != "" { + used++ + } + return max(1, m.height-used) +} + +// diffRows is how many diff lines fit between the title (2) and position (2) lines. +func (m model) diffRows() int { return max(1, m.height-4) } + +func (m *model) adjustScroll() { + vis := m.visibleRows() + if m.cursor < m.top { + m.top = m.cursor + } + if m.cursor >= m.top+vis { + m.top = m.cursor - vis + 1 + } + m.top = max(0, m.top) +} + +// viewIdx returns the indices of the branches the list shows, in display +// order. The cursor and scroll offsets are positions in this view, not in +// m.branches: marks live on the branches, so hiding a row must not move them. +// The local and remote views are two filters over the one slice, for the same +// reason. +func (m model) viewIdx() []int { + idx := make([]int, 0, len(m.branches)) + f := strings.ToLower(m.filter) + for i, b := range m.branches { + if !m.inView(b) { + continue + } + if f == "" || strings.Contains(strings.ToLower(b.name), f) { + idx = append(idx, i) + } + } + return idx +} + +// inView reports whether b belongs to the view on screen, local or remote. +func (m model) inView(b branch) bool { return b.remoteOnly == m.showRemote } + +func (m *model) cur() *branch { + if idx := m.viewIdx(); m.cursor >= 0 && m.cursor < len(idx) { + return &m.branches[idx[m.cursor]] + } + return nil +} + +// selectedBranches returns the marked branches. Locked ones are dropped here +// too, so a mark that slipped past the keys still cannot reach a delete. +func (m model) selectedBranches() []branch { + var out []branch + for _, b := range m.branches { + if b.selected && !b.locked() { + out = append(out, b) + } + } + return out +} + +// Init starts the merge queries startupModel left out. +func (m model) Init() tea.Cmd { + if !m.merging { + return nil + } + gen, query := m.loadGen, m.mergeQuery() + return func() tea.Msg { return mergeInfoMsg{gen: gen, info: query()} } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.adjustScroll() + if m.state == stateDiff && m.diffStyled { + // delta laid its columns out for the old width. A window drag sends + // dozens of sizes; wait for them to stop, so delta runs once, not + // once per size. + m.diffSeq++ + seq := m.diffSeq + return m, tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return restyleTickMsg{seq} }) + } + return m, nil + case restyleTickMsg: + if msg.seq != m.diffSeq || m.state != stateDiff { + return m, nil // a later resize took over, or the user left + } + m.diffLoading = true + return m, restyleCmd(m.diffSeq, m.diffRaw, m.width) + case mergeInfoMsg: + if msg.gen == m.loadGen { + m.applyMergeInfo(msg.info) + } + return m, nil + case diffLoadedMsg: + if msg.seq != m.diffSeq || m.state != stateDiff { + return m, nil // the user asked for another diff, or left + } + m.diffLoading = false + if msg.err != nil { + m.err = msg.err.Error() + m.state = stateList + return m, nil + } + if msg.base != "" { + m.diffBase = msg.base + } + m.diffRaw = msg.raw + m.diffLines, m.diffStyled = msg.lines, msg.styled + m.clampDiff() + return m, nil + case undoDoneMsg: + m.applyUndo(msg) + return m, nil + case repoLoadedMsg: + if msg.err != nil { + m.err = msg.err.Error() + return m, nil + } + m.status = "" + m.applyKeepMarks(msg.branches, msg.reads) + return m, nil + case fetchDoneMsg: + m.fetching = false + if msg.err != nil { + m.err = msg.err.Error() + m.status = "" + return m, nil + } + if branches, reads, err := loadRepo(m.remoteLoaded); err == nil { + // A fetch is non-destructive, so both the cursor (by name, in + // applyBranches) and the user's pending marks survive it. + m.applyKeepMarks(branches, reads) + } + m.err = "" + m.status = "fetched & pruned — " + m.selectGone() + return m, nil + case switchDoneMsg: + m.switching = false + if msg.err != nil { + // A refused switch (dirty worktree, held by another worktree) + // leaves the repo untouched; surface git's reason and stay put. + m.err = msg.err.Error() + m.status = "" + return m, nil + } + m.err = "" + m.status = "switched to " + msg.name + if msg.fromRemote { + m.showRemote = false // the new local branch is where the user is now + } + if msg.loadErr == nil { + // A switch deletes nothing, so pending marks survive it; carryMarks + // drops the ones now on the current branch. + m.applyKeepMarks(msg.branches, msg.reads) + } + m.focusBranch(msg.name) + return m, nil + case branchDeletedMsg: + if msg.idx >= 0 && msg.idx < len(m.results) { + m.results[msg.idx] = msg.res + } + if m.deletesDone() >= len(m.results) { + m.reloadBranches() + m.bodyTop, m.abortArmed = 0, false + if len(m.forceableFailures()) > 0 { + m.state = stateForcePrompt + } else { + m.state = stateResult + } + } + return m, nil + case spinnerTickMsg: + if m.state == stateDeleting { + m.spinnerFrame++ + return m, spinnerTickCmd() + } + return m, nil + case tea.MouseMsg: + // Handle the wheel ourselves so it scrolls the active view rather than + // the terminal translating it into arrow-key bursts that leak between views. + switch msg.Button { + case tea.MouseButtonWheelUp: + m.scroll(-1) + case tea.MouseButtonWheelDown: + m.scroll(1) + } + return m, nil + case tea.KeyMsg: + switch m.state { + case stateList: + return m.updateList(msg) + case stateFilter: + return m.updateFilter(msg) + case stateAge: + return m.updateAge(msg) + case stateConfirm: + return m.updateConfirm(msg) + case stateForcePrompt: + return m.updateForcePrompt(msg) + case stateDiff: + return m.updateDiff(msg) + case stateDeleting: + // Deletions are in flight; ignore input except an abort. Quitting + // drops pushes not yet started, which can leave a remote copy + // behind a deleted local branch, so the abort needs a second press. + if msg.String() == "ctrl+c" { + if m.abortArmed { + return m, tea.Quit + } + m.abortArmed = true + } + case stateResult: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "enter", "esc": + m.state = stateList + case "u": + return m, m.undo() + default: + m.scrollKeys(msg.String()) + } + case stateHelp: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + m.state = stateList + } + } + return m, nil +} + +// updateFilter handles keys in stateFilter, where the filter line captures +// input: plain letters (q, j, a, …) type into the filter instead of firing +// their list actions. +func (m model) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "enter": + m.state = stateList + case "esc": + m.filter = "" + m.state = stateList + case "backspace": + if r := []rune(m.filter); len(r) > 0 { + m.filter = string(r[:len(r)-1]) + } + default: + if msg.Type == tea.KeyRunes { + m.filter += string(msg.Runes) + m.cursor = 0 + } + } + m.clampCursor() + return m, nil +} + +// updateAge handles the merged-and-old prompt. Only digits type into it. +func (m model) updateAge(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.state = stateList + case "enter": + m.state = stateList + if days, err := strconv.Atoi(m.ageInput); err == nil { + m.err = "" + m.status = m.selectMergedOlder(days, time.Now()) + } + case "backspace": + if n := len(m.ageInput); n > 0 { + m.ageInput = m.ageInput[:n-1] + } + default: + for _, r := range msg.Runes { + if r >= '0' && r <= '9' && len(m.ageInput) < 5 { + m.ageInput += string(r) + } + } + } + return m, nil +} + +// waitForMerge reports whether a key must wait on the startup merge queries, +// and says so on the status line: selecting before they land would act on +// branches whose merge state is not yet known. +func (m *model) waitForMerge() bool { + if m.merging { + m.status = "still reading merge status — try again in a moment" + } + return m.merging +} + +func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "up", "k": + m.cursor-- + m.clampCursor() + case "down", "j": + m.cursor++ + m.clampCursor() + case "g", "home": + m.cursor = 0 + m.clampCursor() + case "G", "end": + m.cursor = len(m.branches) // clampCursor lands it on the last visible row + m.clampCursor() + case " ": + if b := m.cur(); b != nil && !b.locked() { + b.selected = !b.selected + } + case "r": + if b := m.cur(); b != nil && b.upstream != "" && !b.locked() && !b.remoteOnly { + b.deleteRemote = !b.deleteRemote + } + case "tab": + m.showRemote = !m.showRemote + m.cursor, m.top = 0, 0 + if m.showRemote && !m.remoteLoaded { + m.remoteLoaded = true + m.status = "loading remote branches…" + return m, reloadCmd(true) + } + case "/": + m.state = stateFilter + case "m": + m.state, m.ageInput = stateAge, strconv.Itoa(m.config.staleDays) + case "esc": + // n already disarms everything; esc only lifts the filter. + m.filter = "" + m.clampCursor() + case "a": + // Select what the list shows: with a filter active, a marks only the + // matching branches, which is what makes filter-then-select useful. + for _, i := range m.viewIdx() { + if !m.branches[i].locked() { + m.branches[i].selected = true + } + } + case "n": + for i := range m.branches { + m.branches[i].selected = false + m.branches[i].deleteRemote = false + } + case "s": + m.field = (m.field + 1) % sortFieldCount + m.sortBranches() + saveSettings(m) + case "o": + m.ascending = !m.ascending + m.sortBranches() + saveSettings(m) + case "f": + m.force = !m.force + case "u": + return m, m.undo() + case "x": + // The same selection `p` makes after its fetch, for a repo whose + // gone marks are already known; no network round trip needed. + if !m.waitForMerge() { + m.err = "" + m.status = m.selectGone() + } + case "p": + if !m.fetching { + m.fetching = true + m.err = "" + m.status = "fetching --all --prune…" + return m, fetchPruneCmd() + } + case "v": + if b := m.cur(); b != nil { + m.err = "" + m.diffSeq++ + m.diffBranch, m.diffBase = b.name, "" + m.diffRaw, m.diffLines, m.diffStyled = "", nil, false + m.diffTop, m.diffLoading = 0, true + m.state = stateDiff + return m, diffCmd(m.diffSeq, *b, m.width) + } + case "c": + if b := m.cur(); b != nil && !b.isCurrent && !m.switching && !m.fetching { + m.switching = true + m.err = "" + m.status = "switching to " + b.name + "…" + return m, switchCmd(*b, m.remoteLoaded) + } + case "?": + m.state = stateHelp + case "d", "enter": + if len(m.selectedBranches()) > 0 && !m.waitForMerge() { + m.measureSelectedRisk() // the confirm screen states what each delete costs + m.state, m.bodyTop = stateConfirm, 0 + } + } + return m, nil +} + +func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y": + // Local only: never delete remotes on the same key that deletes locals. + if m.localSelectedCount() > 0 { + return m, m.startDeletions(false) + } + case "R": + // Local + remote — only meaningful when at least one remote is armed. + if m.armedRemoteCount() > 0 { + return m, m.startDeletions(true) + } + case "n", "N", "esc", "q": + m.state = stateList + case "ctrl+c": + return m, tea.Quit + default: + m.scrollKeys(msg.String()) + } + return m, nil +} + +// countArmedRemotes counts branches whose remote deletion is armed. +func countArmedRemotes(branches []branch) int { + n := 0 + for _, b := range branches { + if b.remoteArmed() { + n++ + } + } + return n +} + +// armedRemoteCount counts selected branches whose remote deletion is armed. +func (m model) armedRemoteCount() int { + return countArmedRemotes(m.selectedBranches()) +} + +// localSelectedCount counts selected branches that have a local branch to delete. +func (m model) localSelectedCount() int { + n := 0 + for _, b := range m.selectedBranches() { + if !b.remoteOnly { + n++ + } + } + return n +} + +// startDeletions kicks off the asynchronous deletion of the selected branches, +// pre-seeding results, entering stateDeleting, and returning a batch of one cmd +// per branch (which run concurrently) plus the spinner tick. Remote branches are +// pushed --delete only when includeRemote is set (see updateConfirm), so a +// remoteOnly row is left out of a local-only run. +func (m *model) startDeletions(includeRemote bool) tea.Cmd { + m.measureSelectedRisk() // results carry the cost through to the force prompt + var sel []branch + for _, b := range m.selectedBranches() { + if includeRemote || !b.remoteOnly { + sel = append(sel, b) + } + } + m.history = append(m.history, m.results...) + m.results = make([]deleteResult, len(sel)) + m.spinnerFrame, m.abortArmed = 0, false + m.state, m.bodyTop = stateDeleting, 0 + + cmds := []tea.Cmd{spinnerTickCmd()} + 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)) + } + return tea.Batch(cmds...) +} + +// deletesDone counts how many of the current run's deletions have completed. +func (m model) deletesDone() int { + n := 0 + for _, r := range m.results { + if r.done { + n++ + } + } + return n +} + +// forceableFailures returns the results whose safe (-d) local delete was +// refused — the ones a force (-D) delete could clear. +func (m model) forceableFailures() []deleteResult { + var out []deleteResult + for _, r := range m.results { + if !r.localOK && r.forceable { + out = append(out, r) + } + } + return out +} + +func (m model) updateForcePrompt(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y": + m.forceDeleteUnmerged() + m.state, m.bodyTop = stateResult, 0 + case "n", "N", "esc", "q", "enter": + m.state, m.bodyTop = stateResult, 0 + case "ctrl+c": + return m, tea.Quit + default: + m.scrollKeys(msg.String()) + } + return m, nil +} + +func (m *model) clampDiff() { + m.diffTop = max(0, min(m.diffTop, len(m.diffLines)-m.diffRows())) +} + +func (m model) updateDiff(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc", "v": + m.state = stateList + case "ctrl+c": + return m, tea.Quit + case "up", "k": + m.diffTop-- + m.clampDiff() + case "down", "j": + m.diffTop++ + m.clampDiff() + case "ctrl+u", "pgup": + m.diffTop -= m.diffRows() / 2 + m.clampDiff() + case "ctrl+d", "pgdown", " ": + m.diffTop += m.diffRows() / 2 + m.clampDiff() + case "g", "home": + m.diffTop = 0 + case "G", "end": + m.diffTop = len(m.diffLines) + m.clampDiff() + } + return m, nil +} + +// carryMarks copies the user's pending selections from old onto a freshly loaded +// branch set, matching by ref. Used on the fetch path, which reloads every +// branch struct but changes nothing the marks were made about. An armed remote +// delete is dropped when the fetch reveals the upstream is already gone: the push +// it would run can only fail. +func carryMarks(old, fresh []branch) []branch { + prev := make(map[string]branch, len(old)) + for _, b := range old { + prev[b.ref()] = b + } + for i := range fresh { + if p, ok := prev[fresh[i].ref()]; ok { + // Marks never land on the current branch: it cannot be deleted, so + // a carried mark would arm an operation the list refuses to offer + // (relevant after a switch, or when HEAD moved outside the TUI). + // Protection is applied after this, and clears its own marks. + fresh[i].selected = p.selected && !fresh[i].locked() + fresh[i].deleteRemote = p.deleteRemote && !fresh[i].gone && !fresh[i].locked() + } + } + return fresh +} + +// applyBranches installs a freshly-loaded branch set and recomputes everything +// derived from it (name-width, merge info, sort order). The cursor stays on the +// branch it was on. This is the single refresh core shared by every reload. +func (m *model) applyBranches(branches []branch, reads repoReads) { + m.installBranches(branches, reads) + m.applyMergeInfo(m.mergeQuery()()) +} + +// applyKeepMarks is applyBranches for a reload that deleted nothing, so the +// user's pending marks carry over by ref. +func (m *model) applyKeepMarks(branches []branch, reads repoReads) { + m.applyBranches(carryMarks(m.branches, branches), reads) +} + +// installBranches is applyBranches without the merge queries, which cost a +// round of git. startupModel runs them in the background instead. +func (m *model) installBranches(branches []branch, reads repoReads) { + // Read the cursor's branch from the old list, before it is replaced: after, + // the cursor index points into an unsorted new list. + focus := "" + if b := m.cur(); b != nil { + focus = b.name + } + m.loadGen++ + m.merging = false + m.config = reads.config + m.branches = append(branches, remoteOnlyRows(branches, reads.remotes.rows)...) + m.recomputeNameWidth() + m.resolveBases(reads) + for i := range m.branches { + b := &m.branches[i] + b.headMerged = !b.remoteOnly && reads.headMerged[b.name] + b.protected = m.isProtected(*b) + if b.locked() { + b.selected, b.deleteRemote = false, false + } + } + m.sortRows() + m.cursor = 0 + m.focusBranch(focus) + m.clampCursor() +} + +// remoteOnlyRows keeps the remote rows that no local branch tracks. +func remoteOnlyRows(local, remote []branch) []branch { + tracked := make(map[string]bool, len(local)) + for _, b := range local { + tracked[b.upstream] = true + } + var out []branch + for _, r := range remote { + if !tracked[r.name] { + out = append(out, r) + } + } + return out +} + +// isProtected reports whether b is the trunk or matches a pruner.protect glob. +// A remote row is matched by its branch part, so "release/*" covers both views. +func (m model) isProtected(b branch) bool { + name := b.name + if b.remoteOnly { + if b.name == m.remoteDefault { + return true + } + name = b.remoteBranch() + } else if _, trunk, ok := strings.Cut(m.remoteDefault, "/"); b.ref() == m.riskBaseRef || (ok && name == trunk) { + return true + } + for _, p := range m.config.protect { + if ok, _ := path.Match(p, name); ok { + return true + } + } + return false +} + +// reloadBranches refreshes the branch list from git and resets the view to the +// top — appropriate after a mutation that may have removed the cursor's branch. +func (m *model) reloadBranches() { + if branches, reads, err := loadRepo(m.remoteLoaded); err == nil { + m.applyBranches(branches, reads) + m.cursor, m.top = 0, 0 + } +} + +// hasBranch reports whether the loaded list holds a local branch of that name. +func (m model) hasBranch(name string) bool { + for _, b := range m.branches { + if b.name == name && !b.remoteOnly { + return true + } + } + return false +} + +// resolveBases finds the remote default and the risk base. It needs no git +// call: the refs came with the load, and the local fallback is a lookup. +// The refs drive git; the short forms are what the views print. +func (m *model) resolveBases(reads repoReads) { + m.remoteDefaultRef = remoteDefaultFrom(reads.remotes) + m.riskBaseRef = m.remoteDefaultRef + if m.riskBaseRef == "" { + m.riskBaseRef = localDefaultBranch("", m.hasBranch) + } + m.remoteDefault = shortRef(m.remoteDefaultRef) + m.riskBase = shortRef(m.riskBaseRef) +} + +// goneRefs lists the refs of the gone branches. Only these are measured up +// front, because `p` consults the count to decide what it may auto-select. The +// rest wait for measureSelectedRisk: riskCommitCount is a subprocess per branch, +// and measuring every unmergeable branch here would put a network-free repo's +// whole branch list on the clock. +func (m model) goneRefs() []string { + var out []string + for _, b := range m.branches { + if b.gone { + out = append(out, b.ref()) + } + } + return out +} + +// mergeQuery captures the merge queries' inputs now and returns the call. The +// inputs are read here, on the update loop, so a goroutine running the call +// never reads the branch list while Update changes it. +func (m model) mergeQuery() func() mergeInfo { + defRef, baseRef, gone := m.remoteDefaultRef, m.riskBaseRef, m.goneRefs() + return func() mergeInfo { return queryMergeInfo(defRef, baseRef, gone) } +} + +// queryMergeInfo runs the merge queries. It reads no model state, so it can run +// in a tea.Cmd. Neither query needs the other's answer, and each is a +// subprocess start, so they go together. +func queryMergeInfo(defRef, baseRef string, gone []string) mergeInfo { + var info mergeInfo + remote := make(chan struct{}) + go func() { + info.remoteMerged = remoteMergedSet(defRef) + close(remote) + }() + // One query answers "is this branch's tip already in the base?" for the whole + // list, which is the answer for most branches a prune touches. + if baseRef != "" { + info.baseMerged = mergedSet("branch", "--merged", baseRef) + } + <-remote + + var todo []string + info.risk = map[string]int{} + for _, ref := range gone { + if info.baseMerged[shortRef(ref)] { + info.risk[ref] = 0 + } else { + todo = append(todo, ref) + } + } + for i, n := range countRisk(todo, baseRef) { + info.risk[todo[i]] = n + } + return info +} + +// applyMergeInfo writes the merge query results onto the branches. +func (m *model) applyMergeInfo(info mergeInfo) { + m.merging = false + m.baseMerged = info.baseMerged + for i := range m.branches { + b := &m.branches[i] + b.remoteMerged = b.upstream != "" && info.remoteMerged[b.upstream] + b.riskMeasured = false // the branch was just reloaded; any old count is stale + if n, ok := info.risk[b.ref()]; ok { + b.riskCommits, b.riskMeasured = n, true + } + } +} + +// inBase reports whether b's tip is already in the risk base, from the merge +// queries alone. Such a branch has an empty base..branch range, so `git cherry` +// would report nothing. +func (m model) inBase(b branch) bool { + // A remote row is only measured when it is not merged (see + // measureSelectedRisk), so the local set is the only one worth asking. + return !b.remoteOnly && m.baseMerged[b.name] +} + +// countRisk runs riskCommitCountRef for each ref. The counts are independent +// `git cherry` subprocesses whose cost is dominated by process spawn, so they +// run concurrently: measured one at a time, a repo with a hundred gone branches +// spent 1.1s here on every load, fetch and prune. +func countRisk(refs []string, base string) []int { + out := make([]int, len(refs)) + var wg sync.WaitGroup + for i, ref := range refs { + // Each goroutine owns one slice element, so no two write the same one. + wg.Go(func() { out[i] = riskCommitCountRef(ref, base) }) + } + wg.Wait() + return out +} + +// measureRisk fills in the cost-of-deletion count for every not-yet-measured +// branch that want accepts. +func (m *model) measureRisk(want func(branch) bool) { + var idx []int + var refs []string + for i, b := range m.branches { + if b.riskMeasured || !want(b) { + continue + } + // Answer from the merge sets instead of spawning the subprocess. + if m.inBase(b) { + m.branches[i].riskCommits, m.branches[i].riskMeasured = 0, true + continue + } + idx = append(idx, i) + refs = append(refs, b.ref()) + } + for k, n := range countRisk(refs, m.riskBaseRef) { + m.branches[idx[k]].riskCommits, m.branches[idx[k]].riskMeasured = n, true + } +} + +// measureSelectedRisk measures what deleting each selected branch would cost. +// Call before any view that reports the cost: only branches a safe delete would +// refuse are measured, since those are the ones deleted with -D. A remote +// branch not merged into the default is measured too: deleting it can take the +// only shared copy of its commits. +func (m *model) measureSelectedRisk() { + m.measureRisk(func(b branch) bool { + return b.selected && (b.gone || !b.safeDeletable() || (b.remoteOnly && !b.remoteMerged)) + }) +} + +// forceDeleteUnmerged re-runs the deletions that a safe (-d) delete refused, +// this time with -D. It updates the matching result in place so the results +// screen reflects the retry outcome. +func (m *model) forceDeleteUnmerged() { + for i := range m.results { + r := &m.results[i] + if r.localOK || !r.forceable { + continue + } + if _, err := runGit("branch", "-D", r.br.name); err != nil { + r.localErr = err.Error() + continue + } + r.localOK = true + r.localErr = "" + // The armed remote delete was deferred while the local branch survived; + // now that it is gone, honour what the user confirmed. + if r.remoteSkipped { + pushRemoteDelete(r) + } + } + m.reloadBranches() +} + +// undoDoneMsg carries a background undo and the reload after it. +type undoDoneMsg struct { + restored []int // indices into m.results + errs []string + branches []branch + reads repoReads + loadErr error +} + +// undo recreates the local branches the last run deleted, at the commits they +// pointed to, with their upstream config. It runs as a tea.Cmd: each branch is +// up to three git calls, and a wide prune would freeze the screen for seconds. +// The calls stay serial because parallel `git config` writes collide on +// .git/config.lock. A remote branch cannot come back this way: that would be a +// push, so the exit summary prints the command instead. +func (m *model) undo() tea.Cmd { + var todo []int + for i, r := range m.results { + if r.restorable() { + todo = append(todo, i) + } + } + m.state = stateList + if len(todo) == 0 { + m.status = "nothing to undo" + return nil + } + m.status = "restoring…" + results, withRemote := slices.Clone(m.results), m.remoteLoaded + return func() tea.Msg { + var msg undoDoneMsg + for _, i := range todo { + if err := restoreBranch(results[i].br); err != nil { + msg.errs = append(msg.errs, results[i].br.name+": "+err.Error()) + } else { + msg.restored = append(msg.restored, i) + } + } + msg.branches, msg.reads, msg.loadErr = loadRepo(withRemote) + return msg + } +} + +// applyUndo records a finished undo and installs the reload that came with it. +func (m *model) applyUndo(msg undoDoneMsg) { + remote := 0 + for _, i := range msg.restored { + m.results[i].restored = true + } + for _, r := range m.results { + if r.remoteOK { + remote++ + } + } + if msg.loadErr == nil { + m.applyKeepMarks(msg.branches, msg.reads) + } + m.err = strings.Join(msg.errs, "; ") + m.status = fmt.Sprintf("restored %d branch(es)", len(msg.restored)) + if remote > 0 { + m.status += fmt.Sprintf(" · %d remote branch(es) not restored — see the commands printed on quit", remote) + } +} + +// selectGone selects every gone branch that carries nothing missing from the +// base and reports the outcome for the status line. Branches holding unique +// commits stay unselected so discarding them is a deliberate keystroke rather +// than a side effect of `p` or `x`. +func (m *model) selectGone() string { + gone, risky := 0, 0 + for i := range m.branches { + br := &m.branches[i] + if !br.gone || br.locked() { + continue + } + gone++ + if br.riskCommits > 0 { + risky++ + continue + } + br.selected = true + } + switch { + case gone == 0: + return "no gone branches" + case risky == 0: + return fmt.Sprintf("%d gone branch(es) selected; press d to prune", gone) + default: + return fmt.Sprintf("%d of %d gone branch(es) selected; %d hold commits not in %s (select with space to discard)", + gone-risky, gone, risky, m.riskBase) + } +} + +// merged reports whether b's work is already in the default branch. A local +// branch ahead of its upstream holds commits the upstream check cannot see, so +// it only counts when its own tip is in the base. +func (m model) merged(b branch) bool { + if b.remoteOnly { + return b.remoteMerged + } + return m.baseMerged[b.name] || (b.remoteMerged && b.ahead == 0) +} + +// selectMergedOlder selects the listed branches that are merged and whose last +// commit is older than days. It acts on the listed rows only, like `a`. +func (m *model) selectMergedOlder(days int, now time.Time) string { + cutoff := now.AddDate(0, 0, -days) + n := 0 + for _, i := range m.viewIdx() { + b := &m.branches[i] + if b.locked() || !m.merged(*b) || !b.committed.Before(cutoff) { + continue + } + if !b.selected { + b.selected = true + n++ + } + } + return fmt.Sprintf("selected %d merged branch(es) older than %d days", n, days) +} diff --git a/proc_other.go b/proc_other.go new file mode 100644 index 0000000..6c13962 --- /dev/null +++ b/proc_other.go @@ -0,0 +1,8 @@ +//go:build !unix + +package main + +import "os/exec" + +// detachTTY is a no-op where sessions do not exist; netTimeout still bounds the call. +func detachTTY(*exec.Cmd) {} diff --git a/proc_unix.go b/proc_unix.go new file mode 100644 index 0000000..fb1fe0b --- /dev/null +++ b/proc_unix.go @@ -0,0 +1,11 @@ +//go:build unix + +package main + +import ( + "os/exec" + "syscall" +) + +// detachTTY starts cmd in its own session, which has no controlling terminal. +func detachTTY(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} } diff --git a/settings.go b/settings.go new file mode 100644 index 0000000..1a3a7ea --- /dev/null +++ b/settings.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "path/filepath" + "strings" +) + +// settingsPath is where the sort choice is kept between runs. main sets it; +// it stays "" under test, so no test reads or writes the user's real file. +var settingsPath string + +// defaultSettingsPath is /git_pruner/settings, or "" when the +// system has no config dir. +func defaultSettingsPath() string { + dir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(dir, "git_pruner", "settings") +} + +// loadSettings applies the saved sort field and order. A missing or broken file +// leaves the defaults: this is a convenience, never a reason to fail startup. +func loadSettings(m *model) { + if settingsPath == "" { + return + } + data, err := os.ReadFile(settingsPath) + if err != nil { + return + } + for _, line := range strings.Split(string(data), "\n") { + key, val, _ := strings.Cut(strings.TrimSpace(line), "=") + switch key { + case "sort": + for f := range sortFieldCount { + if f.String() == val { + m.field = f + } + } + case "order": + m.ascending = val == "asc" + } + } +} + +// saveSettings writes the sort field and order. Errors are ignored for the +// same reason loadSettings ignores them. +func saveSettings(m model) { + if settingsPath == "" { + return + } + order := "desc" + if m.ascending { + order = "asc" + } + _ = os.MkdirAll(filepath.Dir(settingsPath), 0o755) + _ = os.WriteFile(settingsPath, []byte("sort="+m.field.String()+"\norder="+order+"\n"), 0o644) +} diff --git a/view.go b/view.go new file mode 100644 index 0000000..8c57f90 --- /dev/null +++ b/view.go @@ -0,0 +1,767 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +var ( + currentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + rowBgStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")) // cursor row band + selStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) + goneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + headerStyle = lipgloss.NewStyle().Bold(true) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + + nameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan + hashStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow + subjectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // light gray + aheadStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green + behindStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red + trackColStyle = lipgloss.NewStyle().Width(10) // ahead/behind + optional merged ✓ + + addStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green: additions + delStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red: removals + hunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("13")) // magenta: hunk headers +) + +func (m model) View() string { + switch m.state { + case stateConfirm: + return m.confirmView() + case stateForcePrompt: + return m.forcePromptView() + case stateDeleting: + return m.deletingView() + case stateResult: + return m.resultView() + case stateHelp: + return m.helpView() + case stateDiff: + return m.diffView() + default: + return m.listView() + } +} + +// page renders a screen as fixed header lines, a window into body, and fixed +// footer lines. The prompt on these screens is the whole point of them, and it +// lives in the footer: without a window, a wide selection pushes the question +// past the last row of the terminal, where the user cannot read what they are +// answering. Rendering only the visible rows is also what keeps a long list off +// the cost of every frame. +func (m model) page(header, body, footer []string) string { + rows := m.bodyRows(len(header), len(footer), len(body)) + top := max(0, min(m.bodyTop, len(body)-rows)) + end := min(top+rows, len(body)) + + var b strings.Builder + for _, l := range header { + b.WriteString(l + "\n") + } + for _, l := range body[top:end] { + b.WriteString(l + "\n") + } + if len(body) > rows { + b.WriteString(dimStyle.Render(fmt.Sprintf("[%d-%d / %d] ↑/↓ scroll · space/ctrl+d page · g/G top/bottom", + top+1, end, len(body))) + "\n") + } + for _, l := range footer { + b.WriteString(l + "\n") + } + return b.String() +} + +// bodyRows is how many body lines fit between header and footer. A body that +// does not fit gives up one more row to the position line, so that line never +// pushes the footer off in its turn. +func (m model) bodyRows(header, footer, body int) int { + rows := max(1, m.height-header-footer) + if body > rows { + rows = max(1, rows-1) + } + return rows +} + +// pageParts returns the current state's screen as header, body and footer. The +// scroll keys measure against it too, so the window and the clamp can never +// disagree about how far down the body goes. +func (m model) pageParts() (header, body, footer []string) { + switch m.state { + case stateConfirm: + return m.confirmParts() + case stateForcePrompt: + return m.forcePromptParts() + case stateDeleting: + return m.deletingParts() + case stateResult: + return m.resultParts() + } + return nil, nil, nil +} + +// bodyWindow reports the visible row count and the total body length for the +// current state. +func (m model) bodyWindow() (rows, total int) { + header, body, footer := m.pageParts() + return m.bodyRows(len(header), len(footer), len(body)), len(body) +} + +func (m *model) clampBody() { + rows, total := m.bodyWindow() + m.bodyTop = max(0, min(m.bodyTop, total-rows)) +} + +// scrollKeys applies the shared paging keys to a windowed screen. It reports +// whether the key was one of them, so each screen's own keys stay in charge: +// callers must offer their answers first. +func (m *model) scrollKeys(s string) bool { + rows, total := m.bodyWindow() + switch s { + case "up", "k": + m.bodyTop-- + case "down", "j": + m.bodyTop++ + case "ctrl+u", "pgup": + m.bodyTop -= max(1, rows/2) + case "ctrl+d", "pgdown", " ": + m.bodyTop += max(1, rows/2) + case "g", "home": + m.bodyTop = 0 + case "G", "end": + m.bodyTop = total + default: + return false + } + m.clampBody() + return true +} + +func colorizeDiffLine(line string) string { + switch { + case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"): + return headerStyle.Render(line) + case strings.HasPrefix(line, "diff "), strings.HasPrefix(line, "index "), + strings.HasPrefix(line, "new file"), strings.HasPrefix(line, "deleted file"), + strings.HasPrefix(line, "rename "), strings.HasPrefix(line, "similarity "): + return dimStyle.Render(line) + case strings.HasPrefix(line, "@@"): + return hunkStyle.Render(line) + case strings.HasPrefix(line, "+"): + return addStyle.Render(line) + case strings.HasPrefix(line, "-"): + return delStyle.Render(line) + default: + return line + } +} + +func (m model) diffView() string { + var b strings.Builder + + base := m.diffBase + title := "diff — " + m.diffBranch + if base != "" { + title += " (vs " + base + ")" + } + if m.diffStyled { + title += " · delta" + } + b.WriteString(headerStyle.Render(title)) + b.WriteString("\n\n") + + if len(m.diffLines) == 0 { + msg := "no changes — branch matches " + base + if m.diffLoading { + msg = "loading diff…" + } + b.WriteString(dimStyle.Render(msg)) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render("q/esc back · v back")) + b.WriteString("\n") + return b.String() + } + + vis := m.diffRows() + end := min(m.diffTop+vis, len(m.diffLines)) + for i := m.diffTop; i < end; i++ { + line := truncate(m.diffLines[i], m.width) + if !m.diffStyled { + line = colorizeDiffLine(line) + } + b.WriteString(line) + b.WriteString("\n") + } + + b.WriteString("\n") + pos := fmt.Sprintf("[%d-%d / %d]", m.diffTop+1, end, len(m.diffLines)) + help := "↑/↓ scroll · space/ctrl+d page · g/G top/bottom · q/esc/v back" + b.WriteString(dimStyle.Render(pos + " " + help)) + b.WriteString("\n") + return b.String() +} + +// recomputeNameWidth caches the branch-name column width; call whenever the +// branch list changes (it depends only on the set of names, not render state). +func (m *model) recomputeNameWidth() { + w := 0 + for _, br := range m.branches { + w = max(w, ansi.StringWidth(br.name)) + } + m.nameW = min(40, max(6, w)) +} + +func (m model) listView() string { + var b strings.Builder + + dir := "desc" + if m.ascending { + dir = "asc" + } + forceLabel := "safe (-d)" + if m.force { + forceLabel = "FORCE (-D)" + } + idx := m.viewIdx() + kind, other, total := "local", "remote", 0 + if m.showRemote { + kind, other = "remote-only", "local" + } + for _, br := range m.branches { + if m.inView(br) { + total++ + } + } + // The prompts come before the settings, so a narrow terminal cuts the + // settings rather than what the user is typing. + header := headerStyle.Render(fmt.Sprintf("git_pruner — %d %s branches", total, kind)) + switch { + case m.state == stateAge: + header += selStyle.Render(fmt.Sprintf(" merged & older than: %s▌ days (enter select · esc cancel)", m.ageInput)) + case m.filter != "" || m.state == stateFilter: + f := m.filter + if m.state == stateFilter { + f += "▌" + } + header += selStyle.Render(fmt.Sprintf(" filter: %s (%d/%d)", f, len(idx), total)) + } + header += headerStyle.Render(fmt.Sprintf(" sort: %s %s delete mode: %s", m.field, dir, forceLabel)) + + dimStyle.Render(" tab: "+other) + if m.merging { + header += dimStyle.Render(" · reading merge status…") + } + // Every line is cut to the terminal: a wrapped line takes two screen rows + // that visibleRows did not count, and pushes the bottom lines off. + b.WriteString(truncate(header, m.width)) + b.WriteString("\n\n") + + switch { + case total == 0 && m.showRemote && m.status != "": + // the load is still running; the status line says so + case total == 0: + b.WriteString(dimStyle.Render("no " + kind + " branches found")) + b.WriteString("\n") + case len(idx) == 0: + b.WriteString(dimStyle.Render("no branches match filter")) + b.WriteString("\n") + } + + nameW := m.nameW + vis := m.visibleRows() + // A status line that appeared since the last move takes a row; keep the + // cursor inside the smaller window. + top := min(m.top, m.cursor) + if m.cursor >= top+vis { + top = m.cursor - vis + 1 + } + end := min(top+vis, len(idx)) + for p := top; p < end; p++ { + b.WriteString(m.renderRow(m.branches[idx[p]], nameW, p == m.cursor)) + b.WriteString("\n") + } + + b.WriteString("\n") + var help string + switch { + case m.state == stateFilter: + help = "type to filter · enter keep · esc clear · backspace edit" + case m.state == stateAge: + help = "type the age in days · enter select · esc cancel" + case m.showRemote: + help = "? help · q quit · tab local · space select · a/n all/none · m merged+old · d delete · c checkout · / filter · v view · s sort · o order" + default: + help = "? help · q quit · space select · a/n all/none · m merged+old · x gone · p prune · d delete · u undo · tab remote · / filter · r remote · v view · c checkout · s sort · o order · f force" + } + b.WriteString(dimStyle.Render(truncate(help, m.width))) + if m.status != "" { + b.WriteString("\n") + b.WriteString(okStyle.Render(truncate(m.status, m.width))) + } + if m.err != "" { + b.WriteString("\n") + b.WriteString(errStyle.Render(truncate(m.err, m.width))) + } + return b.String() +} + +func (m model) renderRow(br branch, nameW int, isCursor bool) string { + cursor := " " + if isCursor { + cursor = cursorStyle.Render("> ") + } + sel := "[ ]" + if br.selected { + sel = selStyle.Render("[x]") + } + rem := " " + if br.deleteRemote || (br.remoteOnly && br.selected) { + rem = errStyle.Render("R") + } + // One marker column, in the order git's own `git branch` would show them. + cur := " " + switch { + case br.isCurrent: + cur = currentStyle.Render("*") + case br.worktree: + cur = hunkStyle.Render("+") + case br.protected: + cur = dimStyle.Render("P") + } + + c := m.rowLayout(nameW) + name := pad(truncate(br.name, c.nameW), c.nameW) + + // Selection outranks the cursor: the row band already marks the cursor, + // and a name that stays yellow under it keeps the pending delete visible. + var nameRendered string + switch { + case br.selected: + nameRendered = selStyle.Render(name) + case br.isCurrent: + nameRendered = currentStyle.Render(name) + case isCursor: + nameRendered = cursorStyle.Render(name) + case br.locked(): + nameRendered = dimStyle.Render(name) + default: + nameRendered = nameStyle.Render(name) + } + + row := fmt.Sprintf("%s%s %s %s %s %s", cursor, sel, rem, cur, nameRendered, m.trackStr(br)) + if c.abs { + row += " " + dimStyle.Render(fmt.Sprintf("%-11s", br.committed.Format("2006-Jan-02"))) + } + if c.rel { + row += " " + dimStyle.Render(fmt.Sprintf("%-13s", br.committedRel)) + } + if c.hash { + row += " " + hashStyle.Render(fmt.Sprintf("%-8s", br.hash)) + } + if c.subject > 0 { + row += " " + subjectStyle.Render(truncate(br.subject, c.subject)) + } + // Last guard against a wrap on a terminal too narrow for even the fixed columns. + row = ansi.Truncate(row, max(1, m.width), "") + if isCursor { + row = highlightRow(row, m.width) + } + return row +} + +// rowCols says which optional columns fit the terminal, and how wide the name +// and subject may be. +type rowCols struct { + abs, rel, hash bool + nameW, subject int +} + +// Column widths in renderRow's format. rowFixed is cursor(2) + [x](3) + space + +// R(1) + space + marker(1) + space + name's two trailing spaces + track(10). +const ( + rowFixed = 2 + 3 + 1 + 1 + 1 + 1 + 1 + 2 + 10 + absCost = 1 + 11 + relCost = 1 + 13 + hashCost = 1 + 8 + minSubject = 10 +) + +// rowLayout fits a row to m.width. A row wider than the terminal wraps onto a +// second screen line, which visibleRows does not count, so the list overruns +// and pushes the footer off. The optional columns go in order of least use — +// relative date, hash, absolute date — until a readable subject fits; then the +// name shrinks. O(1). +func (m model) rowLayout(nameW int) rowCols { + c := rowCols{abs: true, rel: true, hash: true, nameW: nameW} + cost := func() int { + n := rowFixed + c.nameW + if c.abs { + n += absCost + } + if c.rel { + n += relCost + } + if c.hash { + n += hashCost + } + return n + } + for _, drop := range []*bool{&c.rel, &c.hash, &c.abs} { + if m.width-cost()-1 >= minSubject { + break + } + *drop = false + } + if over := cost() - m.width; over > 0 { + c.nameW = max(6, c.nameW-over) + } + c.subject = max(0, m.width-cost()-1) + return c +} + +// highlightRow paints the cursor band behind an already-styled row. Each +// column ends with a reset that would drop a background wrapped around the +// whole row, so the band is re-armed after every reset instead. The row is +// padded first so the band spans the full terminal width. +func highlightRow(row string, width int) string { + const reset = "\x1b[0m" + // Rendering nothing yields just the on/off sequences, or "" when the + // color profile disables styling — then there is no band to paint. + on, ok := strings.CutSuffix(rowBgStyle.Render(""), reset) + if !ok || on == "" { + return row + } + if d := width - ansi.StringWidth(row); d > 0 { + row += strings.Repeat(" ", d) + } + return on + strings.ReplaceAll(row, reset, reset+on) + reset +} + +func (m model) trackStr(br branch) string { + if br.remoteOnly { + s := dimStyle.Render("remote") + if br.remoteMerged { + s += okStyle.Render(" ✓") + } + return trackColStyle.Render(s) + } + if br.gone { + // Same column style as every other track value, or the columns that + // follow shift left on exactly the rows the user is here to act on. + return trackColStyle.Render(goneStyle.Render("gone")) + } + if br.upstream == "" { + return trackColStyle.Render(dimStyle.Render("-")) + } + s := "" + if br.ahead > 0 { + s += aheadStyle.Render("↑" + strconv.Itoa(br.ahead)) + } + if br.behind > 0 { + s += behindStyle.Render("↓" + strconv.Itoa(br.behind)) + } + if s == "" { + s = currentStyle.Render("=") + } + if br.remoteMerged { // upstream is merged into the remote default — safe to delete + s += okStyle.Render(" ✓") + } + return trackColStyle.Render(s) +} + +// truncate shortens s to w terminal cells, appending an ellipsis when it does +// not fit. Measured in display cells rather than bytes so multibyte text is +// never sliced mid-rune and wide (CJK/emoji) characters do not overflow. +func truncate(s string, w int) string { + if w <= 0 { + return "" + } + return ansi.Truncate(s, w, "…") +} + +// pad right-pads s to w display cells. The fmt width verbs count runes, which +// misaligns columns whose content contains wide characters. +func pad(s string, w int) string { + if d := w - ansi.StringWidth(s); d > 0 { + return s + strings.Repeat(" ", d) + } + return s +} + +func (m model) helpView() string { + var b strings.Builder + b.WriteString(headerStyle.Render("git_pruner — help")) + b.WriteString("\n\n") + + writeRows := func(pairs [][2]string) { + for _, p := range pairs { + b.WriteString(" " + cursorStyle.Render(fmt.Sprintf("%-14s", p[0])) + subjectStyle.Render(p[1]) + "\n") + } + } + + writeRows([][2]string{ + {"↑/↓, j/k", "move cursor"}, + {"g/G, home/end", "jump to first/last"}, + {"space", "select / deselect branch"}, + {"a / n", "select all listed / none"}, + {"m", "select listed branches merged and older than N days"}, + {"tab", "switch between local and remote-only branches"}, + {"c", "checkout the branch (remote: new local tracking branch)"}, + {"/", "filter by name (enter keep · esc clear)"}, + {"r", "toggle delete of upstream remote branch"}, + {"v", "view branch diff (through delta when installed)"}, + {"x", "select gone branches that hold no unique work"}, + {"p", "fetch --all --prune, then do the same as x"}, + {"u", "undo: recreate the branches the last delete removed"}, + {"s", "cycle sort field (date, name, ahead/behind)"}, + {"o", "toggle sort order (asc/desc)"}, + {"f", "toggle force delete (-d / -D)"}, + {"d, enter", "delete selected branches (local)"}, + {"", "on confirm: y = local only · R = local + remote"}, + {"", "(unmerged -d failures prompt to retry with -D)"}, + {"", "long lists scroll: ↑/↓ · space/ctrl+d · g/G"}, + {"?", "toggle this help screen"}, + {"q, ctrl+c", "quit"}, + }) + + b.WriteString("\n") + b.WriteString(headerStyle.Render("Columns")) + b.WriteString("\n") + writeRows([][2]string{ + {"* / + / P", "current / in another worktree / protected (locked)"}, + {"[x]", "selected for deletion"}, + {"R", "its remote branch will also be deleted"}, + {"↑/↓", "commits ahead of / behind upstream"}, + {"✓", "upstream merged into remote default (safe)"}, + {"gone", "upstream was configured but no longer exists"}, + }) + + 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" + + "Protect more branches with: git config --add pruner.protect 'release/*'")) + b.WriteString("\n\n") + + commit, date := buildInfo() + b.WriteString(dimStyle.Render(fmt.Sprintf("build date: %s · commit: %s", date, commit))) + b.WriteString("\n\n") + + b.WriteString(dimStyle.Render("press any key to return")) + b.WriteString("\n") + return b.String() +} + +func (m model) confirmParts() (header, body, footer []string) { + sel := m.selectedBranches() + flag := "-d (safe)" + if m.force { + flag = "-D (force)" + } + remoteCount, localCount := countArmedRemotes(sel), m.localSelectedCount() + header = []string{ + headerStyle.Render("Confirm deletion"), + "", + fmt.Sprintf("Local delete mode: %s", flag), + fmt.Sprintf("Deleting %d local branch(es), %d remote branch(es).", localCount, remoteCount), + "", + } + + for _, br := range sel { + body = append(body, " "+cursorStyle.Render("• "+br.name)) + + date := br.committed.Format("2006-Jan-02") + if br.committedRel != "" { + date += " (" + br.committedRel + ")" + } + body = append(body, " "+dimStyle.Render(fmt.Sprintf("%s %s %s", br.hash, date, truncate(br.subject, 50)))) + + switch { + case br.remoteOnly: + body = append(body, " "+dimStyle.Render("remote branch — no local copy")) + case br.gone: + body = append(body, " "+goneStyle.Render("upstream gone: "+br.upstream+" — will prune with -D (force)")) + case br.upstream != "": + body = append(body, " "+dimStyle.Render("upstream: "+br.upstream)+" "+m.trackStr(br)) + default: + body = append(body, " "+dimStyle.Render("no upstream")) + } + + switch { + case br.gone: + // A gone branch has no upstream left to test, so remoteMerged is + // always false for it. The risk count is the real answer: say so + // when it is clean, and riskWarning speaks when it is not. + if br.riskMeasured && br.riskCommits == 0 && m.riskBase != "" { + body = append(body, " "+okStyle.Render("✓ no commits missing from "+m.riskBase)) + } + case br.upstream != "" && m.remoteDefault != "": + if br.remoteMerged { + body = append(body, " "+okStyle.Render("✓ merged into "+m.remoteDefault)) + } else { + body = append(body, " "+goneStyle.Render("⚠ not merged into "+m.remoteDefault)) + } + } + + 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.riskWarning(br); w != "" { + body = append(body, " "+errStyle.Render(w)) + } + body = append(body, "") + } + + prompt := headerStyle.Render("Delete these branches? ") + if localCount == 0 { + // Only remote rows: there is no local-only delete to offer on y. + prompt += dimStyle.Render(fmt.Sprintf("(R = delete %d remote branch(es) · n/esc = cancel)", remoteCount)) + } else if remoteCount > 0 { + prompt += dimStyle.Render(fmt.Sprintf("(y = local only · R = local + remote (%d) · n/esc = cancel)", remoteCount)) + } else { + prompt += dimStyle.Render("(y = yes · n/esc = cancel)") + } + return header, body, []string{prompt} +} + +func (m model) confirmView() string { return m.page(m.confirmParts()) } + +// 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 +// discarded, under -d the delete simply fails. +func (m model) riskWarning(br branch) string { + if br.remoteOnly { + if br.riskCommits > 0 { + return fmt.Sprintf("⚠ %d commit(s) not in %s — this may be the only shared copy of them", br.riskCommits, m.riskBase) + } + return "" + } + if br.safeDeletable() && !br.gone { + return "" + } + if br.forcedDelete(m.force) { // -D: the question is what gets discarded + if br.riskCommits > 0 { + return fmt.Sprintf("⚠ %d commit(s) not in %s — force delete (-D) will discard them", br.riskCommits, m.riskBase) + } + if m.riskBase == "" { + return "⚠ no base branch to compare against — force delete (-D) discards any unmerged commits" + } + return "" // measured against a real base: nothing here is at risk + } + // -d will be refused either way; the count is what a force would then cost. + if br.riskCommits > 0 { + return fmt.Sprintf("⚠ not fully merged: %d commit(s) not in %s — safe delete (-d) will fail; use force (f)", br.riskCommits, m.riskBase) + } + return "⚠ not fully merged — safe delete (-d) will fail; use force (f)" +} + +func (m model) forcePromptParts() (header, body, footer []string) { + failures := m.forceableFailures() + + header = []string{ + headerStyle.Render("Force delete unmerged branches?"), + "", + fmt.Sprintf("%d branch(es) were refused by safe delete (-d) because they are not", len(failures)), + "fully merged. Force deleting (-D) will " + errStyle.Render("discard their unmerged commits") + ".", + dimStyle.Render("To get one back: press u on the next screen, or run git branch ."), + dimStyle.Render("git keeps the commits for a few weeks, until gc prunes them."), + "", + } + + for _, r := range failures { + body = append(body, " "+cursorStyle.Render("• "+r.br.name)+dimStyle.Render(" at "+r.br.hash)) + // Every branch here failed -d, so its risk was measured before the delete + // ran: riskCommits == 0 means either nothing is missing from the base or + // there was no base to measure against. + switch { + case r.br.riskCommits > 0: + body = append(body, " "+errStyle.Render(fmt.Sprintf("⚠ %d commit(s) not in %s will be lost", r.br.riskCommits, m.riskBase))) + case m.riskBase == "": + body = append(body, " "+errStyle.Render("⚠ no base branch to compare against — unmerged commits may be lost")) + default: + body = append(body, " "+dimStyle.Render("no commits missing from "+m.riskBase)) + } + 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()))) + } + } + + footer = []string{ + "", + headerStyle.Render("Force delete (-D) these branches? ") + dimStyle.Render("(y = yes, discard · n/esc = keep them)"), + } + return header, body, footer +} + +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 { + switch { + case r.br.remoteOnly: + // No local branch: the remote line below is the whole result. + case r.restored: + dst = append(dst, okStyle.Render(" ↺ ")+"restored local "+r.br.name+" at "+r.br.hash) + case r.localOK: + dst = append(dst, okStyle.Render(" ✓ ")+"deleted local "+r.br.name+dimStyle.Render(" (was "+r.br.hash+")")) + default: + dst = append(dst, errStyle.Render(" ✗ ")+"local "+r.br.name+": "+r.localErr) + } + remote := r.br.remoteName() + "/" + r.br.remoteBranch() + switch { + case r.remoteSkipped: + // Say why the armed remote survived, or it reads as a silent failure. + dst = append(dst, errStyle.Render(" ! ")+"kept remote "+remote+": local delete failed") + case r.remoteTried && r.remoteOK: + dst = append(dst, okStyle.Render(" ✓ ")+"deleted remote "+remote+dimStyle.Render(" (was "+r.br.hash+")")) + case r.remoteTried: + dst = append(dst, errStyle.Render(" ✗ ")+"remote "+remote+": "+r.remoteErr) + } + return dst +} + +func (m model) deletingParts() (header, body, footer []string) { + spin := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] + header = []string{ + headerStyle.Render(fmt.Sprintf("%s Deleting… (%d/%d)", spin, m.deletesDone(), len(m.results))), + "", + } + for _, r := range m.results { + if r.done { + body = appendResultLines(body, r) + } else { + body = append(body, dimStyle.Render(" "+spin+" deleting "+r.br.name+"…")) + } + } + footer = []string{"", dimStyle.Render("working — ctrl+c to abort")} + if m.abortArmed { + footer[1] = errStyle.Render("quitting now can leave a remote branch undeleted — ctrl+c again to quit anyway") + } + return header, body, footer +} + +func (m model) deletingView() string { return m.page(m.deletingParts()) } + +func (m model) resultParts() (header, body, footer []string) { + header = []string{headerStyle.Render("Results"), ""} + for _, r := range m.results { + body = appendResultLines(body, r) + } + footer = []string{"", dimStyle.Render("enter back to list · q quit")} + for _, r := range m.results { + if r.restorable() { + footer[1] = dimStyle.Render("u undo (recreate the local branches) · enter back to list · q quit") + break + } + } + return header, body, footer +} + +func (m model) resultView() string { return m.page(m.resultParts()) }