patchette

usage: a blank line between commands, and a shared prelude for sessions

commit 271bda88650c9277dc32bc9ae90190f595935100 · parent bd494f7d30 · browse this tree
Ximianth, 2026-10-26 11:01:26 UTC

Sessions were hard to scan: each command ran straight on from the
previous command's output.  The runner now puts a blank line before
each prompt after the first.

Five of the sessions opened with the same three lines of setup (cd,
git init, pair).  That setup is now a `paired/prelude` hunk in the doc,
shown once, right after the section that walks through it; sessions
named `paired/NAME/session` start after it has run, and
`fresh/NAME/session` ones start from the bare sandbox.  The prelude's
lines run in the session's own shell, so its `cd` carries over, and
they print nothing unless one fails.  Sessions share no state: each
still gets its own sandbox, and runs the prelude for itself.

A failing prelude command fails the case, saying which command failed
and what it printed, and the session is passed through untouched, so
regen can't overwrite it.  Without that, a typo in the prelude would
have blanked every paired session under regen -- their commands with
them.

The test script no longer sets -euo pipefail at its top: under set -e,
testmark-bash reports any failing case as "report to the runner was
garbled or cut short" rather than its real failure.  (Minimal repro: a
test script with `set -e` and a rule whose function fails.)  Cases run
under set -euo pipefail regardless; the source of testmark-bash gets an
explicit `|| exit 1` instead.

Every session's output is unchanged apart from the blank lines and the
lines the prelude took over.
diff --git a/docs/userguide/USAGE.md b/docs/userguide/USAGE.md
index 79c185a..df3e303 100644
--- a/docs/userguide/USAGE.md
+++ b/docs/userguide/USAGE.md
@@ -16,13 +16,16 @@ 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)
+[testmark]:# (fresh/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')
 ```
@@ -30,6 +33,16 @@ 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.
 
+The sessions in the rest of this guide each start as though you'd just done that
+(apart from the last two sections, which start unpaired, from `~`):
+
+[testmark]:# (paired/prelude)
+```bash
+cd ~/src/frob
+git init -q -b main ../frob-patches
+patchette pair ../frob-patches
+```
+
 
 Taking snapshots
 ----------------
@@ -37,19 +50,18 @@ 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)
+[testmark]:# (paired/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"
 ```
 
@@ -58,21 +70,24 @@ Each branch becomes a folder: a `STACK` file saying where it's based and what's
 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)
+[testmark]:# (paired/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
@@ -123,18 +138,16 @@ Checking a snapshot
 It never touches your branches.
 Name branches to check just those:
 
-[testmark]:# (verifying/session)
+[testmark]:# (paired/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
 ```
@@ -146,25 +159,28 @@ 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)
+[testmark]:# (paired/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
 ```
@@ -178,32 +194,37 @@ 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)
+[testmark]:# (paired/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
 ```
@@ -219,11 +240,13 @@ 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)
+[testmark]:# (fresh/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
 ```
@@ -232,27 +255,37 @@ verify: ok: refs/heads/docs-polish reproduces recorded tip 485a9d568a1be975c8737
 When something's wrong
 ----------------------
 
-[testmark]:# (refusals/session)
+[testmark]:# (fresh/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
index 1830961..e6a975a 100755
--- a/tests/test-usage.sh
+++ b/tests/test-usage.sh
@@ -1,21 +1,48 @@
 #!/bin/bash
-## Runs the usage guide's examples.  Each `CASE/session` hunk in docs/userguide/USAGE.md
+## Runs the usage guide's examples.  Each `.../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.
+## A `paired/NAME/session` starts after the doc's `paired/prelude` hunk has run; a `fresh/NAME/session` starts from the bare sandbox.
 ## 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"
+# No set -e up here: testmark-bash mis-reports failing cases under it.  Cases run under set -euo pipefail regardless.
+source "${TESTMARK_BASH:-$(dirname "${BASH_SOURCE[0]}")/../../../warplibs/testmark-bash}/lib/testmark.bash" || exit 1
+
+## run_prelude runs a prelude script's commands in this shell, one line each, quietly:
+## a session's starting point, so its `cd`s and settings carry into the session.
+## Blank lines and `#` comments are skipped.
+## A failing command stops it, printing the command and its output on stderr, so the case fails, even under regen;
+## the caller then passes the session through untouched, so regen can't write over it.
+##
+## - @param $1 path to the prelude script
+## - @stderr on failure only: the failed command, its output, and its exit status
+run_prelude() {
+	local line rc capture
+	capture=$(mktemp)
+	while IFS= read -r line; do
+		[[ -n $line && $line != '#'* ]] || continue
+		rc=0
+		eval "$line" </dev/null >"$capture" 2>&1 || rc=$?
+		if [[ $rc -ne 0 ]]; then
+			>&2 printf 'prelude command failed, exit status %s: %s\n' "$rc" "$line"
+			>&2 cat "$capture"
+			rm -f "$capture"
+			return 1
+		fi
+	done <"$1"
+	rm -f "$capture"
+}
 
 ## 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.
+## A blank line separates each command from the output before it.
+## The old session's other lines (its old output, and blank lines) 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 prompt_re='^[~/][^$]*\$ (.*)$' line cmd rc out sep=''
 	local -a lines
 	local capture
 	capture=$(mktemp)
@@ -23,7 +50,8 @@ replay_session() {
 	for line in "${lines[@]}"; do
 		[[ $line =~ $prompt_re ]] || continue
 		cmd=${BASH_REMATCH[1]}
-		printf '%s$ %s\n' "${PWD/#"$HOME"/"~"}" "$cmd"
+		printf '%s%s$ %s\n' "$sep" "${PWD/#"$HOME"/"~"}" "$cmd"
+		sep=$'\n'
 		rc=0
 		eval "$cmd" </dev/null >"$capture" 2>&1 || rc=$?
 		out=$(<"$capture")
@@ -33,11 +61,13 @@ replay_session() {
 	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,
+## run_in_sandbox runs the case's session in a fresh sandbox home, after a prelude hunk, if one is named.
+## The sandbox: `~/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() {
+##
+## - @param $1 optional: the doc hunk holding the prelude
+run_in_sandbox() {
 	source "$TESTCASE_DIR/lib.sh"
 	testcase_withtmpdir
 	make_corpus corpus
@@ -49,12 +79,24 @@ exercise_session() {
 	git -C "$HOME/src/frob" switch -q main
 	export PATH="${PATCHETTE%/patchette}:$PATH"
 	export GIT_AUTHOR_DATE='@1700086400 +0000' GIT_COMMITTER_DATE='@1700086400 +0000'
+	local prelude=$PWD/prelude.sh
+	: >"$prelude"
+	if [[ -n ${1:-} ]]; then testcase_doc_hunk "$1" >"$prelude"; fi
 	cd "$HOME"
 	testcase_hunk session |
-		bash --norc --noprofile -c "$(declare -f replay_session); replay_session" |
+		bash --norc --noprofile -c "$(declare -f run_prelude replay_session); if run_prelude \"\$1\"; then replay_session; else cat; exit 1; fi" _ "$prelude" |
 		testcase_output session
 }
 
-testmarktest_provide replay_session
-testmarktest_rule '*/session' exercise_session
+exercise_fresh() {
+	run_in_sandbox
+}
+
+exercise_paired() {
+	run_in_sandbox paired/prelude
+}
+
+testmarktest_provide run_prelude replay_session run_in_sandbox
+testmarktest_rule 'fresh/*/session' exercise_fresh
+testmarktest_rule 'paired/*/session' exercise_paired
 testmarktest_run ../docs/userguide/USAGE.md