diff --git a/Makefile b/Makefile
index 684b16f..64bc08d 100644
--- a/Makefile
+++ b/Makefile
@@ -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)
@@ -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
diff --git a/README.md b/README.md
index dc60d21..0e11c92 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
+
+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.
@@ -65,6 +71,8 @@ In the diff view: `↑`/`↓` scroll, `space`/`ctrl+d` page down, `ctrl+u`/`pgup
## Row format
+
+
```
> [x] R * feature/foo ↑2↓1 ✓ 3 days ago a1b2c3d Fix the thing
```
@@ -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.
diff --git a/assets/branches.png b/assets/branches.png
new file mode 100644
index 0000000..b997da1
Binary files /dev/null and b/assets/branches.png differ
diff --git a/assets/common.tape b/assets/common.tape
new file mode 100644
index 0000000..f151883
--- /dev/null
+++ b/assets/common.tape
@@ -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
diff --git a/assets/demo.gif b/assets/demo.gif
new file mode 100644
index 0000000..286be88
Binary files /dev/null and b/assets/demo.gif differ
diff --git a/assets/demo.tape b/assets/demo.tape
new file mode 100644
index 0000000..8f86fae
--- /dev/null
+++ b/assets/demo.tape
@@ -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
diff --git a/assets/make-demo-repo.sh b/assets/make-demo-repo.sh
new file mode 100755
index 0000000..b7f59e0
--- /dev/null
+++ b/assets/make-demo-repo.sh
@@ -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
}"
+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
+ 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
+ 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"
diff --git a/assets/record.sh b/assets/record.sh
new file mode 100755
index 0000000..e586a0f
--- /dev/null
+++ b/assets/record.sh
@@ -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"
diff --git a/assets/screenshot.tape b/assets/screenshot.tape
new file mode 100644
index 0000000..af9b52a
--- /dev/null
+++ b/assets/screenshot.tape
@@ -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
diff --git a/docs/improvements.md b/docs/improvements.md
index 0c11ae6..214b2c4 100644
--- a/docs/improvements.md
+++ b/docs/improvements.md
@@ -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 ` 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