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