patchette

docs: a usage guide whose example sessions run as tests

commit bd494f7d3002ce3fc2a91592dec0623c3f2c3245 · parent ca2dbbfbbe · browse this tree
Ximianth, 2026-10-26 00:05:35 UTC

docs/userguide/USAGE.md walks through using patchette as terminal
sessions: pairing; snapshotting, and seeing a patch's change as a diff
in the patches repo; verify; rebuilding branches in a fresh clone;
hand-editing patches and import -f; explicit flags; and refusals.

tests/test-usage.sh runs every one.  Each `CASE/session` hunk is its
own input and output: its prompt lines are run in order, in a fresh
bash, in a sandbox home holding an upstream and a clone carrying the
corpus branches; and the session as it ran -- prompts, commands,
output, nonzero exit statuses -- must match the hunk.
TESTMARK_REGEN=1 rewrites the sessions from what patchette does now.
The sandbox home shows as `~` in output, so an absolute path patchette
prints still shows, just stably.

The sessions record today's behavior, including refusals that are
wrong and due to change: a flag after a branch name is read as a branch
name, and an empty --patches= silently falls back to the pairing.
Fixing each will show as a diff of its session.

New test dependency: testmark-bash, sourced from $TESTMARK_BASH, or by
default from a sibling checkout at ../../warplibs/testmark-bash from
the repo root.  Where other clones should get it from is still open.
diff --git a/docs/userguide/USAGE.md b/docs/userguide/USAGE.md
new file mode 100644
index 0000000..79c185a
--- /dev/null
+++ b/docs/userguide/USAGE.md
@@ -0,0 +1,259 @@
+Using Patchette
+===============
+
+Patchette snapshots your branches into a patches repo, as one folder of patch files per branch,
+and rebuilds those exact branches from the patches, commit for commit, in any clone of the upstream.
+You keep working in your own repo, with plain git; patchette only reads it, and writes the branches back when you ask.
+
+Every session below is real: the test suite runs each one, and checks that patchette still prints exactly what's shown.
+They all start from the same place: a home dir with an upstream repo at `~/upstream/frob`,
+and your clone of it at `~/src/frob`, with `main` checked out, carrying two branches of your own work, `topic/fancy` and `docs-polish`.
+
+
+Setting up
+----------
+
+The patches live in a repo of their own, beside your clone.
+Make one, then pair your clone with it, so patchette knows where to look:
+
+[testmark]:# (setting-up/session)
+```
+~$ cd src/frob
+~/src/frob$ git init -b main ../frob-patches
+Initialized empty Git repository in ~/src/frob-patches/.git/
+~/src/frob$ patchette pair ../frob-patches
+paired: '~/src/frob' -> patches dir '../frob-patches'
+~/src/frob$ patchette pair
+paired: patches dir is '../frob-patches' (relative to '~/src/frob')
+```
+
+Pairing is the one thing patchette keeps in your repo: a single value, in `.git/patchette`, never pushed.
+Delete that file to un-pair.
+
+
+Taking snapshots
+----------------
+
+Work on your branches however you like; then export them, and commit the result in the patches repo.
+Export takes the upstream commit your branches are based on, then the branches:
+
+[testmark]:# (snapshotting/session)
+```
+~$ cd src/frob
+~/src/frob$ git init -q -b main ../frob-patches
+~/src/frob$ patchette pair ../frob-patches
+paired: '~/src/frob' -> patches dir '../frob-patches'
+~/src/frob$ patchette export origin/main topic/fancy docs-polish
+export: wrote ~/src/frob-patches/topic¦fancy (8 patches)
+export: wrote ~/src/frob-patches/docs-polish (1 patches)
+~/src/frob$ ls ../frob-patches/docs-polish
+STACK
+polish-the-readme.patch
+~/src/frob$ git -C ../frob-patches add .
+~/src/frob$ git -C ../frob-patches commit -q -m "first snapshot"
+```
+
+Each branch becomes a folder: a `STACK` file saying where it's based and what's in it, and one patch file per commit.
+
+The payoff comes when your patches change.
+Here, a commit on `docs-polish` gets reworked; export again, and the patches repo shows what changed about the change:
+
+[testmark]:# (evolving/session)
+```
+~$ cd src/frob
+~/src/frob$ git init -q -b main ../frob-patches
+~/src/frob$ patchette pair ../frob-patches
+paired: '~/src/frob' -> patches dir '../frob-patches'
+~/src/frob$ patchette export origin/main docs-polish
+export: wrote ~/src/frob-patches/docs-polish (1 patches)
+~/src/frob$ git -C ../frob-patches add .
+~/src/frob$ git -C ../frob-patches commit -q -m "first snapshot"
+~/src/frob$ git switch -q docs-polish
+~/src/frob$ sed -i 's/be kind to/be gentle with/' README.md
+~/src/frob$ git commit -q -a --amend --no-edit
+~/src/frob$ patchette export origin/main docs-polish
+export: wrote ~/src/frob-patches/docs-polish (1 patches)
+~/src/frob$ git -C ../frob-patches diff
+diff --git a/docs-polish/STACK b/docs-polish/STACK
+index d187142..eb8cce4 100644
+--- a/docs-polish/STACK
++++ b/docs-polish/STACK
+@@ -1,5 +1,5 @@
+ ref refs/heads/docs-polish
+ base 5b0130cfd2153bcdb4a6d7ec356e7d805018afc9
+-tip 485a9d568a1be975c873793612c74733df10dd30
++tip c8e5de76c06ca994c681de041257be8b01b28969
+ series
+ polish-the-readme
+diff --git a/docs-polish/polish-the-readme.patch b/docs-polish/polish-the-readme.patch
+index 0373218..1cf464c 100644
+--- a/docs-polish/polish-the-readme.patch
++++ b/docs-polish/polish-the-readme.patch
+@@ -1,13 +1,13 @@
+ parents BASE
+ author Ada Downstream <ada@downstream.example> 1700000660 +0000
+-committer Ada Downstream <ada@downstream.example> 1700000660 +0000
++committer Ada Downstream <ada@downstream.example> 1700086400 +0000
+ 
+ ----
+ > Polish the readme
+ ----
+ 
+ diff --git a/README.md b/README.md
+-index a330cd687e7fdede54bbe0aca5df47c06d2e8b64..c195933372242cdbb3c014665ebcff2bb3fb4449 100644
++index a330cd687e7fdede54bbe0aca5df47c06d2e8b64..4487040f0eaebfa924f8b6011a62557137d3d03d 100644
+ --- a/README.md
+ +++ b/README.md
+ @@ -3,3 +3,5 @@
+@@ -15,4 +15,4 @@ index a330cd687e7fdede54bbe0aca5df47c06d2e8b64..c195933372242cdbb3c014665ebcff2b
+  
+  More upstream docs.
+ +
+-+Downstream notes: be kind to the machinery.
+++Downstream notes: be gentle with the machinery.
+```
+
+Commit that with a message saying why it changed, and the history of your patches tells its own story.
+
+
+Checking a snapshot
+-------------------
+
+`verify` rebuilds each branch from its patches, off to the side, and checks it comes out exactly as it was recorded.
+It never touches your branches.
+Name branches to check just those:
+
+[testmark]:# (verifying/session)
+```
+~$ cd src/frob
+~/src/frob$ git init -q -b main ../frob-patches
+~/src/frob$ patchette pair ../frob-patches
+paired: '~/src/frob' -> patches dir '../frob-patches'
+~/src/frob$ patchette export origin/main topic/fancy docs-polish
+export: wrote ~/src/frob-patches/topic¦fancy (8 patches)
+export: wrote ~/src/frob-patches/docs-polish (1 patches)
+~/src/frob$ patchette verify
+verify: ok: refs/heads/docs-polish reproduces recorded tip 485a9d568a1be975c873793612c74733df10dd30
+verify: ok: refs/heads/topic/fancy reproduces recorded tip cc261dc9f0b878a78e2b80aeb60de397205f61ad
+~/src/frob$ patchette verify topic/fancy
+verify: ok: refs/heads/topic/fancy reproduces recorded tip cc261dc9f0b878a78e2b80aeb60de397205f61ad
+```
+
+
+Rebuilding branches in a fresh clone
+------------------------------------
+
+The patches repo is enough to bring your branches back, exactly, in a clone that never had them.
+Pair the new clone, and import:
+
+[testmark]:# (recreating/session)
+```
+~$ cd src/frob
+~/src/frob$ git init -q -b main ../frob-patches
+~/src/frob$ patchette pair ../frob-patches
+paired: '~/src/frob' -> patches dir '../frob-patches'
+~/src/frob$ patchette export origin/main topic/fancy docs-polish
+export: wrote ~/src/frob-patches/topic¦fancy (8 patches)
+export: wrote ~/src/frob-patches/docs-polish (1 patches)
+~/src/frob$ cd ..
+~/src$ git clone -q ~/upstream/frob frob2
+~/src$ cd frob2
+~/src/frob2$ patchette pair ../frob-patches
+paired: '~/src/frob2' -> patches dir '../frob-patches'
+~/src/frob2$ patchette import
+import: created refs/heads/docs-polish at 485a9d568a1be975c873793612c74733df10dd30
+import: created refs/heads/topic/fancy at cc261dc9f0b878a78e2b80aeb60de397205f61ad
+~/src/frob2$ git rev-parse topic/fancy
+cc261dc9f0b878a78e2b80aeb60de397205f61ad
+~/src/frob2$ git -C ../frob rev-parse topic/fancy
+cc261dc9f0b878a78e2b80aeb60de397205f61ad
+```
+
+
+Editing patches by hand
+-----------------------
+
+Patch files are plain text, and fine to edit directly.
+Then rebuild the branch from them.
+`import` won't replace a branch that already exists unless you say `-f`,
+and even then, not the branch you have checked out: switch away first.
+
+[testmark]:# (hand-editing/session)
+```
+~$ cd src/frob
+~/src/frob$ git init -q -b main ../frob-patches
+~/src/frob$ patchette pair ../frob-patches
+paired: '~/src/frob' -> patches dir '../frob-patches'
+~/src/frob$ patchette export origin/main topic/fancy
+export: wrote ~/src/frob-patches/topic¦fancy (8 patches)
+~/src/frob$ sed -i 's/^> The hum is load-bearing.$/> The hum is load-bearing, and tuned./' ../frob-patches/topic¦fancy/teach-gizmo-to-hum.patch
+~/src/frob$ patchette import topic/fancy
+patchette: 'refs/heads/topic/fancy' already exists in '~/src/frob'; refusing to touch it (-f updates it instead)
+[exit status: 1]
+~/src/frob$ git switch -q topic/fancy
+~/src/frob$ patchette import -f topic/fancy
+patchette: 'refs/heads/topic/fancy' is checked out in '~/src/frob'; refusing to move it under that checkout, even with -f
+[exit status: 1]
+~/src/frob$ git switch -q main
+~/src/frob$ patchette import -f topic/fancy
+import: updated refs/heads/topic/fancy to 57d05b16d9ad81fd92af19d210f0884dc9745e01 (previous tip cc261dc9f0b878a78e2b80aeb60de397205f61ad remains in its reflog)
+import: note: replayed tip differs from recorded tip cc261dc9f0b878a78e2b80aeb60de397205f61ad (patches edited since the snapshot?)
+~/src/frob$ patchette verify topic/fancy
+verify: MISMATCH: refs/heads/topic/fancy replays to 57d05b16d9ad81fd92af19d210f0884dc9745e01, but records tip cc261dc9f0b878a78e2b80aeb60de397205f61ad
+verify:   either the snapshot was unfaithful, or the patches were edited since (patches repo is ahead of its snapshot)
+[exit status: 1]
+~/src/frob$ patchette export origin/main topic/fancy
+export: wrote ~/src/frob-patches/topic¦fancy (8 patches)
+~/src/frob$ patchette verify topic/fancy
+verify: ok: refs/heads/topic/fancy reproduces recorded tip 57d05b16d9ad81fd92af19d210f0884dc9745e01
+```
+
+`verify` fails right after the edit: the patches no longer match the snapshot they were taken as.
+Exporting the rebuilt branch takes a new snapshot of it, and they agree again.
+
+
+Without pairing
+---------------
+
+Pairing is a convenience.
+From anywhere, `--subject` names your repo and `--patches` the patches repo;
+each one, given, wins over the pairing.
+
+[testmark]:# (flags/session)
+```
+~$ git init -q -b main src/frob-patches
+~$ patchette export --subject=src/frob --patches=src/frob-patches origin/main docs-polish
+export: wrote ~/src/frob-patches/docs-polish (1 patches)
+~$ patchette verify --subject=src/frob --patches=src/frob-patches
+verify: ok: refs/heads/docs-polish reproduces recorded tip 485a9d568a1be975c873793612c74733df10dd30
+```
+
+
+When something's wrong
+----------------------
+
+[testmark]:# (refusals/session)
+```
+~$ cd src/frob
+~/src/frob$ patchette verify
+patchette: --subject/--patches not given, and '~/src/frob' is not paired; run 'patchette pair <patches-dir>' there, or pass the flags
+[exit status: 1]
+~/src/frob$ cd /tmp
+/tmp$ patchette verify --subject="$HOME/src/frob"
+patchette: --subject/--patches not given, and the cwd is not inside a git repository
+[exit status: 1]
+/tmp$ cd ~/src/frob
+~/src/frob$ mkdir ../frob-patches
+~/src/frob$ patchette pair ../frob-patches
+paired: '~/src/frob' -> patches dir '../frob-patches'
+~/src/frob$ patchette export origin/main docs-polish
+export: wrote ~/src/frob-patches/docs-polish (1 patches)
+~/src/frob$ patchette verify docs-polish --patches=../frob-patches
+patchette: no branch folder for '--patches=../frob-patches' in '~/src/frob-patches'
+[exit status: 1]
+~/src/frob$ patchette verify --patches=
+verify: ok: refs/heads/docs-polish reproduces recorded tip 485a9d568a1be975c873793612c74733df10dd30
+~/src/frob$ patchette export
+patchette: usage: patchette export [--sigil=quote|--sigil=tab] [--subject=<repo>] [--patches=<dir>] <base-rev> <branch>...
+[exit status: 1]
+```
diff --git a/tests/test-usage.sh b/tests/test-usage.sh
new file mode 100755
index 0000000..1830961
--- /dev/null
+++ b/tests/test-usage.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+## Runs the usage guide's examples.  Each `CASE/session` hunk in docs/userguide/USAGE.md
+## is a terminal session: its prompt lines (`~/dir$ command`, or `/dir$ command`) are run in order, in a fresh sandbox,
+## and the whole session as it ran -- prompts, commands, output -- must match the hunk.
+## Run with TESTMARK_REGEN=1 to rewrite the sessions from what patchette does now; review with `git diff`.
+set -euo pipefail
+source "${TESTMARK_BASH:-$(dirname "${BASH_SOURCE[0]}")/../../../warplibs/testmark-bash}/lib/testmark.bash"
+
+## replay_session runs a session's commands in this shell, in order, and prints the session as it ran.
+## Each command is printed after a prompt naming the cwd (as `~`-relative), then what it printed,
+## with the home dir shown as `~` there too; a nonzero exit adds an `[exit status: N]` line.
+## The old session's other lines (its old output) are dropped: they're what gets regenerated.
+## Commands get /dev/null on stdin, so nothing can wait on input.
+##
+## - @stdin the session, as written
+## - @stdout the session, as it ran
+replay_session() {
+	local prompt_re='^[~/][^$]*\$ (.*)$' line cmd rc out
+	local -a lines
+	local capture
+	capture=$(mktemp)
+	mapfile -t lines
+	for line in "${lines[@]}"; do
+		[[ $line =~ $prompt_re ]] || continue
+		cmd=${BASH_REMATCH[1]}
+		printf '%s$ %s\n' "${PWD/#"$HOME"/"~"}" "$cmd"
+		rc=0
+		eval "$cmd" </dev/null >"$capture" 2>&1 || rc=$?
+		out=$(<"$capture")
+		[[ -z $out ]] || printf '%s\n' "${out//"$HOME"/"~"}"
+		[[ $rc -eq 0 ]] || printf '[exit status: %s]\n' "$rc"
+	done
+	rm -f "$capture"
+}
+
+## exercise_session runs one session in a fresh sandbox home:
+## `~/upstream/frob` is the upstream, and `~/src/frob` a clone of it, with `main` checked out,
+## carrying the branches `topic/fancy`, `docs-polish`, and `odd-bytes` (see make_corpus).
+## Git's identity and clock are pinned, so commits made in a session are reproducible.
+exercise_session() {
+	source "$TESTCASE_DIR/lib.sh"
+	testcase_withtmpdir
+	make_corpus corpus
+	export HOME=$PWD/home
+	mkdir -p "$HOME/upstream" "$HOME/src"
+	mv corpus/upstream "$HOME/upstream/frob"
+	mv corpus/dev "$HOME/src/frob"
+	git -C "$HOME/src/frob" remote set-url origin "$HOME/upstream/frob"
+	git -C "$HOME/src/frob" switch -q main
+	export PATH="${PATCHETTE%/patchette}:$PATH"
+	export GIT_AUTHOR_DATE='@1700086400 +0000' GIT_COMMITTER_DATE='@1700086400 +0000'
+	cd "$HOME"
+	testcase_hunk session |
+		bash --norc --noprofile -c "$(declare -f replay_session); replay_session" |
+		testcase_output session
+}
+
+testmarktest_provide replay_session
+testmarktest_rule '*/session' exercise_session
+testmarktest_run ../docs/userguide/USAGE.md