Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ BINARY := git_pruner
BINDIR ?= $(HOME)/shared/bin
TARGET := $(BINDIR)/$(BINARY)

.PHONY: build install test vet clean
.PHONY: build install test vet clean assets

build: $(TARGET)

Expand All @@ -22,3 +22,7 @@ vet:

clean:
rm -f $(TARGET)

# Re-records the README's demo GIF and screenshot; needs vhs on PATH.
assets:
./assets/record.sh
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ matching remote branch.
You can also view a branch's changes as a colorized diff, and fetch-and-prune to find branches
whose upstream has been deleted so they can be cleaned up in one step.

![git_pruner: prune gone branches, arm a remote deletion, confirm, delete](assets/demo.gif)

Above: `p` fetches and prunes, marking three branches whose upstream was deleted and selecting
them; `space` and `r` add a merged branch and arm its remote deletion too; `d` opens the
confirmation, where `R` deletes local **and** remote.

## Install

Requires Go 1.26+ and git on your PATH.
Expand Down Expand Up @@ -65,6 +71,8 @@ In the diff view: `↑`/`↓` scroll, `space`/`ctrl+d` page down, `ctrl+u`/`pgup

## Row format

![The branch list: selection, track and merge columns, dates, hashes and subjects](assets/branches.png)

```
> [x] R * feature/foo ↑2↓1 ✓ 3 days ago a1b2c3d Fix the thing
```
Expand Down Expand Up @@ -144,11 +152,20 @@ make build # build straight to $BINDIR (default ~/shared/bin), skipping ins
make test # go test ./...
make vet # go vet ./...
make clean # remove the binary from $BINDIR
make assets # re-record the README's demo GIF and screenshot (needs vhs)
```

CI runs `gofmt`, `go build`, `go vet`, and `go test -race` on Linux and macOS for every push to
`master` and every pull request (`.github/workflows/ci.yml`).

The README's `assets/demo.gif` and `assets/branches.png` are generated, not hand-captured — rerun
`make assets` (needs [vhs](https://github.com/charmbracelet/vhs): `brew install vhs`) after any
change to the UI. It builds a throwaway repo under `/tmp/git_pruner-demo` with branches in every
interesting state — merged, stale upstream, unmerged work, no upstream — then records both assets
against that one repo so their commit hashes agree. The two tapes share their terminal settings
and setup via `assets/common.tape`, since the GIF and the still sit side by side in this file and
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.

Expand Down
Binary file added assets/branches.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions assets/common.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Shared by demo.tape and screenshot.tape via `Source`. The GIF and the PNG sit
# next to each other in the README, so the geometry and theme below have to
# agree between them — that is why they live here rather than in each tape.
# Height is deliberately NOT set here: it is the one setting the two recordings
# genuinely differ on.
#
# Sourced paths resolve against vhs's cwd, which record.sh pins to the repo root.

Set Shell "bash"
Set FontSize 15
Set Width 1180
Set Padding 24
Set Theme "Catppuccin Mocha"

# Stage the throwaway repo and binary that record.sh built. Kept hidden: the
# tapes Show once the TUI is on screen. This path is the contract with
# record.sh's $DEMO, which checks that the two still agree.
Hide
Type "export PATH=/tmp/git_pruner-demo/bin:$PATH"
Enter
Type "cd /tmp/git_pruner-demo/orbital"
Enter
Binary file added assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions assets/demo.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# The README demo. Regenerate with assets/record.sh (not by running vhs directly:
# the tape assumes the throwaway repo and binary that record.sh stages).
# Story: list -> prune stale upstreams -> arm a remote delete -> confirm -> results.

Output "assets/demo.gif"

# Tall enough for the confirmation screen, the longest of the views recorded
# here. Must precede the Source — see the note in screenshot.tape.
Set Height 580
Set TypingSpeed 55ms

Source "assets/common.tape"

# Unlike the screenshot tape, the shell prompt is on camera here.
Type "PS1='$ ' && clear"
Enter
Show

Sleep 800ms
Type "git_pruner"
Sleep 500ms
Enter
Sleep 2.5s

# Move around the list. `g` first: on launch the cursor sits on whatever branch
# git listed first, so jump to the top to start from a known row.
Type "g"
Sleep 700ms
Type "j"
Sleep 400ms
Type "j"
Sleep 400ms
Type "j"
Sleep 1.2s

# Fetch & prune: the three stale upstreams become "gone" and are auto-selected.
Type "p"
Sleep 3s

# Add a merged branch and arm its remote deletion too.
Type "G"
Sleep 700ms
Space
Sleep 800ms
Type "r"
Sleep 1.8s

# Confirmation screen: the prompt splits because a remote delete is armed.
Type "d"
Sleep 3.2s

# R = local + remote.
Type "R"
Sleep 4s

# Rest on the results screen.
Sleep 3s
Type "q"
Sleep 1.5s
124 changes: 124 additions & 0 deletions assets/make-demo-repo.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Builds a throwaway repo + bare "origin" so git_pruner has realistic branch
# state to render: merged branches, gone upstreams, unmerged work, no-upstream.
#
# macOS only: the date arithmetic below uses BSD `date -v`.
set -euo pipefail

ROOT="${1:?usage: make-demo-repo.sh <dir>}"
rm -rf "$ROOT"
mkdir -p "$ROOT"
REMOTE="$ROOT/origin.git"
WORK="$ROOT/orbital"

git init --quiet --bare -b main "$REMOTE"
git init --quiet -b main "$WORK"
cd "$WORK"

git config user.name "Ada Reyes"
git config user.email "ada@example.com"
git config commit.gpgsign false
git remote add origin "$REMOTE"

# Every commit gets an explicit date so the relative-time column reads naturally.
commit() { # commit <days-ago> <subject> <file>
local days="$1" subject="$2" file="$3"
local when
when=$(date -u -v-"${days}"d +"%Y-%m-%dT%H:%M:%S")
mkdir -p "$(dirname "$file")"
printf '// %s\npackage orbital\n' "$subject" >> "$file"
git add -A
GIT_AUTHOR_DATE="$when" GIT_COMMITTER_DATE="$when" git commit --quiet -m "$subject"
}

branch_from_main() { git checkout --quiet -B "$1" main; }

merge() { # merge <days-ago> <branch>
local when
when=$(date -u -v-"$1"d +"%Y-%m-%dT%H:%M:%S")
GIT_AUTHOR_DATE="$when" GIT_COMMITTER_DATE="$when" \
git merge --quiet --no-ff -m "Merge branch '$2'" "$2"
}

# --- main line -------------------------------------------------------------
commit 210 "Initial commit: orbital service skeleton" README.md
commit 190 "Add HTTP router and health endpoint" src/router.go
commit 150 "Wire Postgres connection pool" src/store/pool.go
commit 96 "Add structured logging middleware" src/middleware/log.go
commit 61 "Support cursor pagination on /events" src/api/events.go
git push --quiet -u origin main

# --- merged into main, remote still present (safe -d) ----------------------
branch_from_main feature/rate-limiter
commit 44 "Add token-bucket rate limiter" src/middleware/ratelimit.go
commit 43 "Rate limiter: per-tenant buckets" src/middleware/ratelimit.go
git push --quiet -u origin feature/rate-limiter

branch_from_main chore/bump-deps
commit 38 "Bump golang.org/x/net to 0.38.0" go.mod
git push --quiet -u origin chore/bump-deps

branch_from_main fix/timezone-parsing
commit 30 "Parse RFC3339 offsets without truncating" src/api/time.go
git push --quiet -u origin fix/timezone-parsing

git checkout --quiet main
merge 23 feature/rate-limiter
merge 22 chore/bump-deps
merge 21 fix/timezone-parsing
commit 20 "Cache tenant lookups for 30s" src/store/tenant.go
git push --quiet origin main

# --- upstream deleted on the remote (shows as "gone" after p) --------------
branch_from_main feature/webhook-retries
commit 27 "Retry webhooks with exponential backoff" src/webhook/retry.go
commit 26 "Cap webhook retries at 5 attempts" src/webhook/retry.go
git push --quiet -u origin feature/webhook-retries

branch_from_main fix/session-leak
commit 24 "Close idle sessions on shutdown" src/store/session.go
git push --quiet -u origin fix/session-leak

branch_from_main release/v2.4.0
commit 18 "Release v2.4.0" CHANGELOG.md
git push --quiet -u origin release/v2.4.0

# Merge them so they carry no unique work, then delete the remote refs: this is
# exactly the state `p` (fetch --prune) is meant to surface.
git checkout --quiet main
merge 17 feature/webhook-retries
merge 16 fix/session-leak
merge 15 release/v2.4.0
git push --quiet origin main

# Delete the upstreams inside the bare repo rather than with `push --delete`,
# which would also drop the local remote-tracking refs and make the branches read
# as gone before the demo ever runs. This way they stay "stale but not yet
# pruned" — the state pressing `p` is there to resolve.
git -C "$REMOTE" branch -q -D feature/webhook-retries fix/session-leak release/v2.4.0

# --- unmerged work, upstream alive (ahead > 0, needs -D) -------------------
branch_from_main feature/oauth-device-flow
commit 9 "Add device authorization grant" src/auth/device.go
commit 7 "Poll token endpoint with backoff" src/auth/device.go
git push --quiet -u origin feature/oauth-device-flow
commit 3 "WIP: verification_uri_complete" src/auth/device.go

branch_from_main feature/audit-log
commit 12 "Append-only audit log writer" src/audit/writer.go
git push --quiet -u origin feature/audit-log
commit 5 "Redact PII from audit entries" src/audit/redact.go

# --- no upstream at all ----------------------------------------------------
branch_from_main spike/graphql-gateway
commit 34 "Spike: graphql gateway in front of REST" src/gateway/schema.go

branch_from_main refactor/storage-adapter
commit 2 "Extract storage behind an adapter interface" src/store/adapter.go

git checkout --quiet main
commit 1 "Emit request IDs on every response header" src/middleware/reqid.go
git push --quiet origin main
# Deliberately no `fetch --prune` here: the deleted upstreams must stay
# unpruned so pressing `p` in the demo is what reveals them as gone.
echo "demo repo ready: $WORK"
44 changes: 44 additions & 0 deletions assets/record.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Regenerates assets/demo.gif and assets/branches.png. Run after any UI change.
#
# Both tapes record against a single build of the throwaway repo so the commit
# hashes agree between the two assets — the screenshot tape is read-only, the
# demo tape deletes branches, so the order below matters.
set -euo pipefail

cd "$(dirname "$0")/.."
DEMO=/tmp/git_pruner-demo
STILL=assets/branches.png

command -v vhs >/dev/null 2>&1 || {
printf '%s\n' "record.sh: vhs is not on PATH (brew install vhs)" >&2
exit 1
}
# The tapes cannot interpolate $DEMO, so they hardcode it; catch the drift.
grep -q "$DEMO" assets/common.tape || {
printf '%s\n' "record.sh: assets/common.tape no longer refers to $DEMO" >&2
exit 1
}

./assets/make-demo-repo.sh "$DEMO"
go build -o "$DEMO/bin/git_pruner" .

# vhs has been observed to skip a Screenshot and still exit 0, which would leave
# the previous still in place and pass silently. Force the failure to be visible.
rm -f "$STILL"
vhs assets/screenshot.tape
[ -f "$STILL" ] || {
printf '%s\n' "record.sh: vhs did not write $STILL" >&2
exit 1
}

# The capture is true-colour but only ever shows terminal text, so a 256-colour
# palette is visually indistinguishable and about a third of the size.
if command -v magick >/dev/null 2>&1; then
magick "$STILL" -strip -colors 256 -dither None \
-define png:compression-level=9 "$STILL"
fi

vhs assets/demo.tape
rm -f "$DEMO/still.gif"
printf '%s\n' "wrote $STILL and assets/demo.gif"
27 changes: 27 additions & 0 deletions assets/screenshot.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Static list-view screenshot for the README. Regenerate with assets/record.sh.
# Read-only: this tape never deletes a branch, so it can share a repo build with
# demo.tape as long as it runs first.

Output "/tmp/git_pruner-demo/still.gif"

# Sized to the list view alone so the still has no dead space below it. Must
# precede the Source: vhs derives the terminal grid from the Set block as a
# whole, and a Height applied after it silently yields the wrong geometry.
Set Height 360

Source "assets/common.tape"

Type "git_pruner"
Enter
Sleep 3s
# `g` first: on launch the cursor sits on whatever branch git listed first.
Type "g"
Sleep 300ms
Type "jjj"
Sleep 500ms
Show
Sleep 300ms
Screenshot "assets/branches.png"
# vhs has been seen to skip a Screenshot and still exit 0, so record.sh checks
# that this file actually appeared rather than trusting the exit status.
Sleep 300ms
9 changes: 9 additions & 0 deletions docs/improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ delete `performDeletions`.
(`visibleRows` is `height-5`; actual emission is `height+1`).
- ANSI and control characters in commit subjects and branch names render raw into the terminal.
- `applyBranches` silently discards the user's existing selections on `p`.
- 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 <default>` 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
Expand Down
Loading