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. +![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. @@ -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 ``` @@ -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