patchette

tests: roundtrip and fail-closed suites over a hostile corpus

commit 94fd195743f8a7135ebdd235ab431e180a7bcaa1 · parent 8b39158c08 · browse this tree
Ximianth, 2026-10-18 11:31:42 UTC

tests/lib.sh carries the corpus builder and the pinned-identity, tick-clock
git environment -- fixed identities belong in the tests, never in the tool.
The corpus keeps every hostile case: a subject-slug collision, a rename,
an in-range merge, a jj-style change-id header, an empty commit, a body
with a blank line, and a message without a final newline.

test-roundtrip.sh runs both sigil dialects through: regeneration-stable
export, verify, import into a fresh upstream clone reproducing exact tips,
and a strict fsck.  It also greps the emitted framing, so the dialect loop
can never silently degrade into testing quote twice.

test-failclosed.sh checks each refusal by its specific message, not just
the exit code, tying every case to the guard it exercises.  Each guard
was observed to be load-bearing by removing it and watching the matching
test fail (tip comparison, duplicate-series, orphan flagging, the export
STACK guard, and the sigil flag were probed concretely).
diff --git a/tests/lib.sh b/tests/lib.sh
new file mode 100644
index 0000000..d8a8501
--- /dev/null
+++ b/tests/lib.sh
@@ -0,0 +1,184 @@
+#!/bin/bash
+## Shared helpers for the patchette tests.  Sourced, not executed.
+## Provides a hermetic, fully-pinned git environment (fixed identities and a
+## tick clock belong HERE, not in the tool: tests author commits, patchette
+## never does), the test corpus builder, and PASS/FAIL reporting helpers.
+
+set -euo pipefail
+
+export GIT_CONFIG_GLOBAL=/dev/null
+export GIT_CONFIG_SYSTEM=/dev/null
+export GIT_CONFIG_NOSYSTEM=1
+export GIT_ATTR_NOSYSTEM=1
+export LC_ALL=C.UTF-8
+export TZ=UTC
+
+export GIT_AUTHOR_NAME="Ada Downstream"
+export GIT_AUTHOR_EMAIL="ada@downstream.example"
+export GIT_COMMITTER_NAME="Ada Downstream"
+export GIT_COMMITTER_EMAIL="ada@downstream.example"
+
+PATCHETTE=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/patchette
+
+RESULT=0
+
+## Report one passing check.
+##
+## - @param $@ description
+pass() {
+	echo "PASS  $*"
+}
+
+## Report one failing check and mark the test script failed.
+##
+## - @param $@ description
+## - @state RESULT -- set to 1
+fail() {
+	echo "FAIL  $*"
+	RESULT=1
+}
+
+## Run a command expected to refuse, and check it does so for the right reason.
+##
+## PASSes only when the command exits nonzero AND its output contains the
+## expected message fragment: an exit code alone could come from any guard
+## (or any crash), and we want each test tied to its specific refusal.
+##
+## - @param $1 check description
+## - @param $2 message fragment the combined output must contain
+## - @param $@ the command
+expect_refusal() {
+	local desc=$1 want=$2 out rc=0
+	shift 2
+	out=$("$@" 2>&1) || rc=$?
+	if [[ $rc -eq 0 ]]; then
+		fail "$desc: expected a refusal, but the command succeeded"
+	elif [[ $out != *"$want"* ]]; then
+		fail "$desc: refused, but not with the expected message '$want'; got: $out"
+	else
+		pass "$desc"
+	fi
+}
+
+_TICK=1700000000
+
+## Advance the fixed clock by one minute, for the next commit.
+##
+## - @state GIT_AUTHOR_DATE, GIT_COMMITTER_DATE -- set to the new clock value.
+tick() {
+	_TICK=$((_TICK + 60))
+	export GIT_AUTHOR_DATE="@${_TICK} +0000"
+	export GIT_COMMITTER_DATE="@${_TICK} +0000"
+}
+
+## Build the test corpus: an upstream repo, and a dev clone carrying two
+## to-be-tracked branches.  Every commit is hostile on purpose.
+##
+## `topic/fancy` exercises: linear commits, a subject-slug collision, a rename,
+## a merge (with an in-range side branch), an extra commit header in the
+## jj `change-id` style, an empty commit, a body with a blank line, and a
+## message without a final newline.
+## `docs-polish` exists to make the layout's branch mapping visible.
+##
+## - @param $1 output directory; created.  Gets `upstream/`, `dev/`, and a
+##   sourceable `FACTS` file recording the base SHA and branch tips.
+make_corpus() (
+	local out=$1 base first raw new
+	mkdir -p "$out"
+	out=$(cd "$out" && pwd)
+
+	# --- upstream ---
+	git init -q -b main "$out/upstream"
+	cd "$out/upstream"
+	{
+		printf 'The frobnicator.\n'
+		for i in 1 2 3 4 5 6 7 8; do printf 'gear %s turns smoothly\n' "$i"; done
+	} >frobnicator.txt
+	mkdir widgets
+	printf 'widget: mk1\nstate: dusty\n' >widgets/widget.txt
+	printf '# Machinery\n\nUpstream docs.\n' >README.md
+	git add -A
+	tick
+	git commit -q -m "Initial machinery"
+	printf '\nMore upstream docs.\n' >>README.md
+	git add -A
+	tick
+	git commit -q -m "Document the machinery"
+	base=$(git rev-parse HEAD)
+
+	# --- dev clone, branch topic/fancy ---
+	git clone -q "$out/upstream" "$out/dev"
+	cd "$out/dev"
+	git switch -qc topic/fancy
+
+	# plain linear change
+	sed -i 's/gear 3 turns smoothly/gear 3 turns BETTER/' frobnicator.txt
+	git add -A
+	tick
+	git commit -q -m "Fix the frobnicator"
+	first=$(git rev-parse HEAD)
+
+	# same subject again: forces the slug collision rule to show itself
+	sed -i 's/gear 5 turns smoothly/gear 5 turns BETTER/' frobnicator.txt
+	git add -A
+	tick
+	git commit -q -m "Fix the frobnicator"
+
+	# rename plus edit
+	mkdir gizmos
+	git mv widgets/widget.txt gizmos/widget.txt
+	sed -i 's/state: dusty/state: polished/' gizmos/widget.txt
+	git add -A
+	tick
+	git commit -q -m "Move widget into place"
+
+	# a side branch forked in-range, then merged: exercises parents-as-slugs
+	git switch -qc sprocket-work "$first"
+	printf '# Sprockets\n\nThey spin.\n' >SPROCKETS.md
+	git add -A
+	tick
+	git commit -q -m "Add sprocket docs"
+	git switch -q topic/fancy
+	tick
+	git merge -q --no-ff -m "Merge sprocket work" sprocket-work
+	git branch -qD sprocket-work
+
+	# extra commit header, jj change-id style; handcrafted via raw object rewrite.
+	# The body (with its blank line) exercises message framing in the exports.
+	sed -i 's/gear 7 turns smoothly/gear 7 hums quietly/' frobnicator.txt
+	git add -A
+	tick
+	git commit -q -m "Teach gizmo to hum" -m $'The hum is load-bearing.\nDo not ask why.'
+	raw=$(mktemp)
+	git cat-file commit HEAD >"$raw"
+	new=$(sed '/^committer /a change-id kpqxwvuzyntsmrlokqjihvgzfyexwdcu' "$raw" |
+		git hash-object -t commit -w --stdin)
+	rm "$raw"
+	git update-ref refs/heads/topic/fancy "$new"
+
+	# empty commit
+	tick
+	git commit -q --allow-empty -m "Note to self"
+
+	# message without a final newline: porcelain never makes one, but the object
+	# model allows it, and total fidelity means total
+	sed -i 's/gear 8 turns smoothly/gear 8 clicks twice/' frobnicator.txt
+	git add -A
+	tick
+	git commit -q -m "Scribble scribble"
+	new=$(git cat-file commit HEAD | head -c -1 | git hash-object -t commit -w --stdin)
+	git update-ref refs/heads/topic/fancy "$new"
+
+	# --- second branch, small on purpose ---
+	git switch -qc docs-polish main
+	printf '\nDownstream notes: be kind to the machinery.\n' >>README.md
+	git add -A
+	tick
+	git commit -q -m "Polish the readme"
+
+	{
+		printf 'base=%s\n' "$base"
+		printf 'tip_topic_fancy=%s\n' "$(git rev-parse topic/fancy)"
+		printf 'tip_docs_polish=%s\n' "$(git rev-parse docs-polish)"
+	} >"$out/FACTS"
+)
diff --git a/tests/run.sh b/tests/run.sh
new file mode 100755
index 0000000..9247a77
--- /dev/null
+++ b/tests/run.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+## Run every tests/test-*.sh script.  Each prints PASS/FAIL lines and exits
+## nonzero on failure; this driver reports per-script status and fails if any did.
+set -uo pipefail
+cd "$(dirname "$0")"
+
+fail=0
+for t in test-*.sh; do
+	echo "=== $t ==="
+	if "./$t"; then
+		echo "ok    $t"
+	else
+		echo "FAIL  $t (exit $?)"
+		fail=1
+	fi
+done
+
+echo
+if [[ $fail -eq 0 ]]; then
+	echo "all test scripts passed"
+else
+	echo "TEST FAILURES"
+fi
+exit "$fail"
diff --git a/tests/test-failclosed.sh b/tests/test-failclosed.sh
new file mode 100755
index 0000000..7b0c805
--- /dev/null
+++ b/tests/test-failclosed.sh
@@ -0,0 +1,81 @@
+#!/bin/bash
+## Fail-closed behavior: every tampered or conflicting input is refused,
+## loudly, and for its own specific reason (we check the message, not just
+## the exit code, so each case stays tied to the guard it exercises).
+set -euo pipefail
+cd "$(dirname "$0")"
+source ./lib.sh
+
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT
+
+make_corpus "$work/corpus"
+source "$work/corpus/FACTS"
+
+"$PATCHETTE" export "$work/corpus/dev" "$work/pristine" "$base" topic/fancy docs-polish >/dev/null
+fancy=$'topic\u00a6fancy'
+
+## Set up a fresh tampering workspace from the pristine export.
+##
+## - @param $1 case name; becomes the folder name under $work
+## - @state TAMPER -- path to the fresh copy
+clone_layout() {
+	TAMPER="$work/$1"
+	cp -r "$work/pristine" "$TAMPER"
+}
+
+# A tampered patch must make verify fail.  Editing message content keeps the
+# diff applying cleanly, so the only witness left is the tip comparison --
+# exactly the guard this case is about.
+clone_layout tampered
+sed -i 's/> The hum is load-bearing./> The hum is decorative./' "$TAMPER/$fancy/teach-gizmo-to-hum.patch"
+expect_refusal "verify fails on a tampered patch" "MISMATCH" \
+	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+
+# Import onto an existing branch is refused (before any replay work).
+git clone -q "$work/corpus/upstream" "$work/occupied"
+git -C "$work/occupied" branch docs-polish main
+expect_refusal "import onto an existing branch is refused" "already exists" \
+	"$PATCHETTE" import "$work/occupied" "$work/pristine"
+
+# A malformed message line is a parse error, never a guess.
+clone_layout badmsg
+sed -i 's/> Do not ask why./| Do not ask why./' "$TAMPER/$fancy/teach-gizmo-to-hum.patch"
+expect_refusal "a malformed message line is a parse error" "malformed message line" \
+	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+
+# An unknown parent reference is an error.
+clone_layout badparent
+sed -i '1s/.*/parents no-such-slug/' "$TAMPER/$fancy/add-sprocket-docs.patch"
+expect_refusal "an unknown parent reference is an error" "not BASE or an earlier slug" \
+	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+
+# A duplicate series entry is an error.
+clone_layout dupseries
+echo "note-to-self" >>"$TAMPER/$fancy/STACK"
+expect_refusal "a duplicate series entry is an error" "duplicate series entry" \
+	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+
+# An orphan patch file (nothing would ever read it) is flagged by verify.
+clone_layout orphan
+cp "$TAMPER/$fancy/note-to-self.patch" "$TAMPER/$fancy/stray.patch"
+expect_refusal "an orphan patch file is flagged by verify" "orphan" \
+	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+
+# Export never deletes a folder it didn't write.
+mkdir -p "$work/notours/$fancy"
+touch "$work/notours/$fancy/precious.txt"
+expect_refusal "export into a non-patchette folder is refused" "no STACK file" \
+	"$PATCHETTE" export "$work/corpus/dev" "$work/notours" "$base" topic/fancy
+if [[ -f "$work/notours/$fancy/precious.txt" ]]; then
+	pass "export left the non-patchette folder untouched"
+else
+	fail "export deleted contents of a non-patchette folder"
+fi
+
+# Importing where the recorded base is absent tells the user what to do.
+git init -q -b main "$work/empty"
+expect_refusal "import without the recorded base points at fetching" "fetch from upstream first" \
+	"$PATCHETTE" import "$work/empty" "$work/pristine"
+
+exit "$RESULT"
diff --git a/tests/test-roundtrip.sh b/tests/test-roundtrip.sh
new file mode 100755
index 0000000..eec7766
--- /dev/null
+++ b/tests/test-roundtrip.sh
@@ -0,0 +1,64 @@
+#!/bin/bash
+## Roundtrip, for both sigil dialects: export is regeneration-stable,
+## verify passes, and import into a fresh clone of upstream reproduces the
+## exact branch tips, fsck-clean.
+set -euo pipefail
+cd "$(dirname "$0")"
+source ./lib.sh
+
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT
+
+make_corpus "$work/corpus"
+source "$work/corpus/FACTS"
+
+for sigil in quote tab; do
+	p="$work/$sigil"
+
+	"$PATCHETTE" export "--sigil=$sigil" "$work/corpus/dev" "$p/patches" "$base" topic/fancy docs-polish >/dev/null
+	"$PATCHETTE" export "--sigil=$sigil" "$work/corpus/dev" "$p/again" "$base" topic/fancy docs-polish >/dev/null
+	if diff -r "$p/patches" "$p/again" >/dev/null; then
+		pass "$sigil: export is regeneration-stable (two runs byte-identical)"
+	else
+		fail "$sigil: export is not regeneration-stable"
+	fi
+
+	# Guard the guard: confirm the flag actually switched the emitted framing,
+	# or this loop would just test the quote dialect twice.
+	sample="$p/patches/topic¦fancy/teach-gizmo-to-hum.patch"
+	case $sigil in
+	quote) want=$'> The hum is load-bearing.' ;;
+	tab) want=$'\tThe hum is load-bearing.' ;;
+	esac
+	if grep -qxF "$want" "$sample"; then
+		pass "$sigil: emitted message framing matches the dialect"
+	else
+		fail "$sigil: emitted message framing does not match the dialect"
+	fi
+
+	if "$PATCHETTE" verify "$work/corpus/dev" "$p/patches" >/dev/null; then
+		pass "$sigil: verify passes on a fresh export"
+	else
+		fail "$sigil: verify rejected a fresh export"
+	fi
+
+	# The recreate story: upstream has none of our branches; the patches repo
+	# alone must bring them back, byte-exact.
+	git clone -q "$work/corpus/upstream" "$p/fresh"
+	"$PATCHETTE" import "$p/fresh" "$p/patches" >/dev/null
+	got_fancy=$(git -C "$p/fresh" rev-parse refs/heads/topic/fancy)
+	got_docs=$(git -C "$p/fresh" rev-parse refs/heads/docs-polish)
+	if [[ $got_fancy == "$tip_topic_fancy" && $got_docs == "$tip_docs_polish" ]]; then
+		pass "$sigil: import reproduces exact tips"
+	else
+		fail "$sigil: import tips differ: topic/fancy $got_fancy (want $tip_topic_fancy), docs-polish $got_docs (want $tip_docs_polish)"
+	fi
+
+	if git -C "$p/fresh" fsck --strict >/dev/null 2>&1; then
+		pass "$sigil: fsck --strict clean on the replayed repo"
+	else
+		fail "$sigil: fsck --strict rejected the replayed repo"
+	fi
+done
+
+exit "$RESULT"