Skip to content

Commit 3f47768

Browse files
authored
Merge pull request #1 from skunkworker/safety-fixes-and-installer
Safety fixes and installer
2 parents ca0c335 + e4aac66 commit 3f47768

10 files changed

Lines changed: 1218 additions & 107 deletions

File tree

.github/workflows/ci.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
os: [ubuntu-latest, macos-latest]
18+
runs-on: ${{ matrix.os }}
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-go@v5
23+
with:
24+
go-version-file: go.mod
25+
cache: true
26+
27+
# The tests drive a real git binary against throwaway repos, so the runner
28+
# needs a committer identity and a predictable default branch name.
29+
- name: Configure git
30+
run: |
31+
git config --global user.name "CI"
32+
git config --global user.email "ci@example.invalid"
33+
git config --global init.defaultBranch main
34+
35+
- name: gofmt
36+
run: |
37+
unformatted="$(gofmt -l .)"
38+
if [ -n "$unformatted" ]; then
39+
echo "::error::not gofmt'd:"; echo "$unformatted"; exit 1
40+
fi
41+
42+
- run: go build ./...
43+
- run: go vet ./...
44+
- run: go test -race ./...
45+
46+
# Exercise the no-sudo path of the installer end to end so it cannot rot.
47+
- name: install.sh
48+
run: |
49+
./install.sh --bindir "$RUNNER_TEMP/bin"
50+
"$RUNNER_TEMP/bin/git_pruner" version

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# `go build ./...` drops the binary in the repo root; the Makefile installs to
2+
# $(BINDIR) instead.
3+
/git_pruner

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 John Bolliger
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
BINARY := git_pruner
2-
BINDIR := $(HOME)/shared/bin
2+
# Overridable so `make build/clean BINDIR=...` can target the same directory
3+
# install.sh used; the default is the local dev convention.
4+
BINDIR ?= $(HOME)/shared/bin
35
TARGET := $(BINDIR)/$(BINARY)
46

57
.PHONY: build install test vet clean

README.md

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ whose upstream has been deleted so they can be cleaned up in one step.
1313
Requires Go 1.26+ and git on your PATH.
1414

1515
```sh
16-
make build # builds to ~/shared/bin/git_pruner (on PATH)
16+
./install.sh # build + install to a user bin directory
17+
./install.sh --bindir ~/bin # ...or pick the directory yourself
1718
```
1819

20+
With no flags it installs to the first usable of `~/.local/bin` or `~/bin`, falling back to
21+
`/usr/local/bin` (via sudo), and warns if the directory is not on your PATH. See
22+
`./install.sh --help` for the details.
23+
1924
Then run it from inside any git repository:
2025

2126
```sh
@@ -38,7 +43,7 @@ git_pruner version # also --version, -v
3843
| `a` / `n` | Select all / clear selection |
3944
| `r` | Toggle "also delete remote" for the row (needs an upstream) |
4045
| `v` | View the branch's diff (green additions / red removals) |
41-
| `p` | Fetch `--all --prune`, then select branches whose upstream is gone |
46+
| `p` | Fetch `--all --prune`, then select gone branches that hold no unique work |
4247
| `s` | Cycle sort field: committerdate -> name -> ahead/behind |
4348
| `o` | Reverse sort direction |
4449
| `f` | Toggle delete mode: safe `-d` <-> force `-D` |
@@ -67,22 +72,30 @@ In the diff view: `↑`/`↓` scroll, `space`/`ctrl+d` page down, `ctrl+u`/`pgup
6772
- `>` cursor, `[x]` selected, `R` remote deletion armed, `*` current branch
6873
- ahead/behind shown as `↑N↓M` (`=` when in sync, `gone` in red when the upstream was deleted)
6974
- a green `` after the track column means the upstream is merged into the remote default
70-
branch (`origin/HEAD`, else `origin/main`/`origin/master`) — i.e. the remote is safe to delete
75+
branch — i.e. the remote is safe to delete
7176
- relative commit date, short hash, and commit subject
7277

7378
## Viewing a branch's changes
7479

7580
Press `v` to see what a branch contains as a colorized patch — **green** for additions, **red**
7681
for removals, magenta hunk headers. The diff is computed against the repository's default branch
77-
(`origin/HEAD`, falling back to `main`, then `master`) using a three-dot diff
82+
(see [Resolving the default branch](#resolving-the-default-branch)) using a three-dot diff
7883
(`git diff <base>...<branch>`), so it shows only the changes introduced on that branch since it
7984
diverged. The view is scrollable for large diffs; the header shows which base it was compared to.
8085

86+
## Resolving the default branch
87+
88+
The default branch is used as the diff base, as the merge target for the `` indicator, and to
89+
measure what a force delete would discard. It resolves to `<remote>/HEAD` if set, else
90+
`<remote>/main`, else `<remote>/master`, trying each configured remote in turn with `origin`
91+
first — so repositories whose only remote is named something else (`upstream`, a fork, …) still
92+
get merge information. If no remote resolves, a local `main`/`master` is used.
93+
8194
## Pruning gone branches
8295

8396
Press `p` to run `git fetch --all --prune` in the background (the UI stays responsive). Once it
84-
finishes, any local branch whose upstream was deleted is marked **gone** and automatically
85-
selected, and a status line reports how many were found. Press `d` to review and delete them.
97+
finishes, any local branch whose upstream was deleted is marked **gone**, and a status line
98+
reports what was found. Press `d` to review and delete them.
8699

87100
This is the interactive equivalent of:
88101

@@ -93,10 +106,26 @@ git fetch --all --prune && git branch -vv | awk '/: gone]/{print $1}' | xargs gi
93106
Gone branches are always removed with `git branch -D` (force), since `-d` refuses a branch whose
94107
upstream no longer exists — this is why selecting them via `p` prunes them even in safe mode.
95108

109+
Because `-D` discards unmerged commits and git reports **no ahead/behind count for a gone
110+
branch**, git_pruner measures each one against the default branch with `git cherry` and counts
111+
the commits that have no equivalent patch there:
112+
113+
- gone branches holding **no** such commits are auto-selected by `p` — the one-keystroke workflow
114+
- gone branches that **do** hold unique commits are left unselected and reported in the status
115+
line, so discarding them takes a deliberate `space`; the confirmation screen then shows
116+
`⚠ N commit(s) not in <base> — force delete (-D) will discard them`
117+
118+
`git cherry` is used rather than `git rev-list <base>..<branch>` so commits that were
119+
cherry-picked, rebased, or squashed individually into the base are correctly recognized as
120+
already integrated. A group of commits squashed together into one still counts as unique, since
121+
no single equivalent patch exists — which is why the warning reads "not in `<base>`" rather than
122+
claiming the work is unrecoverable.
123+
96124
## Deletion behavior
97125

98126
- Local: `git branch -d` by default (refuses unmerged branches); `f` switches to `git branch -D`.
99-
Branches whose upstream is **gone** are always deleted with `-D`, regardless of the mode.
127+
Branches whose upstream is **gone** are always deleted with `-D`, regardless of the mode, and
128+
the confirmation screen flags any commits that would be discarded (see above).
100129
When a `-d` delete is refused for being unmerged, a follow-up prompt lets you retry those
101130
branches with `-D` without leaving the results — no need to back out and re-select.
102131
- Remote: when armed with `r`, runs `git push <remote> --delete <branch>`, where the remote is
@@ -111,7 +140,22 @@ upstream no longer exists — this is why selecting them via `p` prunes them eve
111140
## Development
112141

113142
```sh
143+
make build # build straight to $BINDIR (default ~/shared/bin), skipping install.sh
114144
make test # go test ./...
115145
make vet # go vet ./...
116-
make clean # remove the installed binary
146+
make clean # remove the binary from $BINDIR
117147
```
148+
149+
CI runs `gofmt`, `go build`, `go vet`, and `go test -race` on Linux and macOS for every push to
150+
`master` and every pull request (`.github/workflows/ci.yml`).
151+
152+
[`docs/improvements.md`](docs/improvements.md) records the codebase analysis, the reasoning behind
153+
the current safety behavior, and the roadmap of remaining work.
154+
155+
The test suite drives a real `git` binary against throwaway repositories created per test, so it
156+
needs `git` on `PATH` and a committer identity (`user.name` / `user.email`); the tests set one
157+
inside each temporary repo.
158+
159+
## License
160+
161+
[MIT](LICENSE)

docs/improvements.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# git_pruner — analysis and improvement roadmap
2+
3+
A deep review of the codebase (2026-07-26), the fixes that came out of it, and the work that
4+
remains. Each finding was reproduced in a throwaway repository before being recorded here;
5+
findings that did **not** survive testing are listed at the bottom so they are not re-litigated.
6+
7+
## Design assessment
8+
9+
The safety model is the strongest part of this codebase and should be preserved as it evolves:
10+
11+
- a confirmation screen that itemizes every branch before anything is deleted
12+
- a deliberate `y` (local) vs `R` (local + remote) split, so remote deletion is never one
13+
accidental keystroke
14+
- `-d``-D` escalation via an explicit prompt rather than silent forcing
15+
- merge status computed from local remote-tracking refs, so it needs no network
16+
- deletions run off the update loop with a live per-branch checklist
17+
18+
The findings below are mostly about places where that model had a gap, not about its design.
19+
20+
---
21+
22+
## Completed
23+
24+
### Tier 1 (all done)
25+
26+
**1. Gone branches could silently discard unpushed commits.** *(the significant one)*
27+
28+
`%(upstream:track)` reports a bare `[gone]` with **no ahead count**, so `branch.ahead` parsed to
29+
`0`. The chain: `p` auto-selected every gone branch → `confirmView`'s unmerged warning was gated
30+
on `!br.gone && br.ahead > 0` and so never fired → `deleteFlag` returned `-D` unconditionally →
31+
`y` destroyed the commits. The `-d``-D` force prompt never fired either, because `-D` succeeds
32+
on the first try. Every other destructive path in the tool warns; this one — the headline `p`
33+
workflow — did not.
34+
35+
Reproduced with a branch that was pushed, had its remote deleted, then accumulated local commits:
36+
37+
```
38+
feature/important [origin/feature/important: gone] track=[gone] → ahead parsed as 0
39+
git rev-list --count main..feature/important → 2 commits destroyed, no warning
40+
```
41+
42+
Fixed by `riskCommitCount`, which measures each gone branch against the default branch. Gone
43+
branches with no unique commits are still auto-selected by `p` (the one-keystroke workflow is
44+
intact); ones holding unique commits are left unselected, reported in the status line, and
45+
flagged on the confirmation screen.
46+
47+
*Why `git cherry` rather than `git rev-list <base>..<branch>`:* both were measured against a
48+
squash-merged branch, a single-commit squash, and genuinely unmerged work:
49+
50+
| branch | `rev-list --count` | `git cherry` `+` lines |
51+
| --------- | ------------------ | ---------------------- |
52+
| squashed (2 commits → 1) | 2 | 2 |
53+
| single-commit squash | 1 | **0** |
54+
| genuinely unmerged | 1 | 1 |
55+
56+
`git cherry` is strictly more accurate at the same cost — it recognizes cherry-picked, rebased,
57+
and singly-squashed work as already integrated. It cannot detect a *group* squash, and nothing
58+
cheap can. That residual over-report is why the warning is worded `N commit(s) not in <base>`
59+
rather than claiming the work is unrecoverable.
60+
61+
**2. `truncate` sliced bytes, emitting invalid UTF-8.** `s[:w-1]` split multibyte runes:
62+
63+
```
64+
truncate("日本語のコミットです", 9) → "日本\xe8\xaa…" validUTF8 = false
65+
truncate("日本語のコミットです", 11) → "日本語\xe3…" validUTF8 = false
66+
```
67+
68+
Byte length also is not display width, so wide (CJK/emoji) columns were mis-sized in both
69+
directions. Fixed with `ansi.Truncate` plus a new `pad` helper; `recomputeNameWidth` now measures
70+
cells via `ansi.StringWidth`.
71+
72+
**3. The `gone` track value was 8 cells wide where every other value was 10**, shifting every
73+
column after it on exactly the rows the user is there to act on. The regression test was verified
74+
to fail against the old code (`date column at cell 31, want 33`) before being kept.
75+
76+
**4. Default-branch resolution hardcoded `origin`.** On a repo whose only remote was `upstream`,
77+
`remoteDefault()` returned `""` and the `✓ merged` indicator plus the confirm-screen merge line
78+
silently vanished — no error, the safety signal simply was not there. `remotes()` now tries every
79+
configured remote with `origin` ordered first.
80+
81+
### Also completed
82+
83+
- `LICENSE` (MIT)
84+
- `.github/workflows/ci.yml` — gofmt, `go build`, `go vet`, `go test -race` on Linux and macOS
85+
- `.gitignore``go build ./...` drops a binary in the repo root
86+
87+
---
88+
89+
## Remaining work
90+
91+
### Tier 2 — robustness
92+
93+
**5. Blocking git calls inside `Update`.** `loadDiff` (`v`), `refreshMergeInfo`, and
94+
`reloadBranches` run synchronously in the update loop. `git branch -r --merged` is
95+
O(remote refs × history) and runs on *every* reload; on a repo with thousands of remote branches
96+
the UI freezes. The `tea.Cmd` pattern already works for fetch — reuse it. Note that
97+
`refreshMergeInfo` now also issues one `git cherry` per gone branch, which raises the stakes.
98+
99+
**6. `runGit` has no timeout and does not disable terminal prompts.** `fetch --all --prune` and
100+
`push --delete` are network-bound; a credential or SSH prompt hangs the TUI with no recovery.
101+
Set `GIT_TERMINAL_PROMPT=0` and attach a `context.WithTimeout` so it fails fast instead.
102+
103+
**7. The tested delete path is not the one users run.** `performDeletions` is test-only by its own
104+
comment; the live async path's completion logic — `branchDeletedMsg``deletesDone` → the
105+
`stateForcePrompt` / `stateResult` transition — is never fed through `Update` in any test. The
106+
riskiest state machine in the program is the untested one. Port the tests to the async path and
107+
delete `performDeletions`.
108+
109+
**8. Smaller items.**
110+
- `listView` runs one line over terminal height when `status` and `err` are both set
111+
(`visibleRows` is `height-5`; actual emission is `height+1`).
112+
- ANSI and control characters in commit subjects and branch names render raw into the terminal.
113+
- `applyBranches` silently discards the user's existing selections on `p`.
114+
- `stateDeleting`'s ctrl+c quits while `git push --delete` children are still running.
115+
116+
### Tier 3 — features for the tool's actual job
117+
118+
**9. `/` incremental filter.** With dozens of branches there is currently no way to narrow the
119+
list — the single biggest UX gap for the repos this tool exists to clean up.
120+
121+
**10. Bulk-select predicates** (merged, older than N days). "Select everything merged and older
122+
than 90 days" is the canonical prune workflow and currently has to be done by hand.
123+
124+
**11. Reflog recovery hint after a `-D`.** The force-prompt screen says "permanently discard their
125+
unmerged commits" without telling the user that `git reflog` can still recover them. Pairs
126+
naturally with finding 1.
127+
128+
### Tier 4 — hygiene
129+
130+
**12. Split `main.go`** (~1,300 lines) into `git.go` / `model.go` / `view.go`.
131+
132+
**13. Make the Makefile's `BINDIR` overridable** — it hardcodes `$HOME/shared/bin`.
133+
134+
---
135+
136+
## Investigated and rejected
137+
138+
**Concurrent `git branch -d` racing on `packed-refs.lock`.** `tea.Batch` runs deletions
139+
concurrently, which looked like it should collide on the packed-refs lock. Tested with 60 parallel
140+
deletes against a freshly packed repo: **all 60 succeeded.** Git's ref-lock retry handles it. No
141+
change needed — recorded so it is not re-investigated.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ go 1.26.2
55
require (
66
github.com/charmbracelet/bubbletea v1.3.10
77
github.com/charmbracelet/lipgloss v1.1.0
8+
github.com/charmbracelet/x/ansi v0.10.1
89
)
910

1011
require (
1112
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1213
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
13-
github.com/charmbracelet/x/ansi v0.10.1 // indirect
1414
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
1515
github.com/charmbracelet/x/term v0.2.1 // indirect
1616
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect

0 commit comments

Comments
 (0)