patchette

Merge branch 'mvp-and-test-harness'

commit d1e3ee7b4304df7fd6b1dadd262527e8dd911c0f · parent 7c9fbfe000 · parent f61b01a155 · browse this tree
warpfork, 2026-10-18 12:17:11 UTC

diff --git a/bin/patchette b/bin/patchette
new file mode 100755
index 0000000..1e30a1c
--- /dev/null
+++ b/bin/patchette
@@ -0,0 +1,436 @@
+#!/bin/bash
+## patchette: snapshot git branches as reviewable patch files, and replay them
+## back to byte-exact, SHA-identical commits.
+##
+##   patchette export [--sigil=quote|--sigil=tab] <subject-repo> <patches-dir> <base-rev> <branch>...
+##   patchette import <target-repo> <patches-dir>
+##   patchette verify <subject-repo> <patches-dir>
+##
+## Layout, per branch (folder name = branch name, `/` encoded as `¦` U+00A6):
+##   STACK         `ref`/`base`/`tip` lines, then `series`, then slugs to EOF
+##   <slug>.patch  one file per commit: parents line, headers, framed message, diff
+##
+## The patch file, exactly:
+##   line 1      `parents <ref>...`          (each ref: BASE, or an earlier slug)
+##   line 2      `message-no-final-newline`  (only in that freak case; contains no
+##               space, so it can never collide with a verbatim `key value` header)
+##   next lines  commit headers verbatim, minus tree/parent
+##   blank line
+##   `----`      opening flank of the message block
+##   next lines  message, framed per the sigil dialect (see below)
+##   `----`      closing flank
+##   blank line
+##   rest        diff vs first parent, to EOF (empty for empty commits)
+##
+## Message framing is exactly invertible and never transforms content bytes.
+## Two sigil dialects exist; export emits one (default `quote`), the parser
+## accepts both unconditionally (the `----` flanks keep mixed acceptance
+## unambiguous: a message line reading `----` is always sigil-framed):
+##   quote:  empty line -> `>`,  else `> ` + line
+##   tab:    empty line -> ``,   else TAB + line
+## A message line in any other shape is a parse error, never a guess.
+##
+## Fidelity and fail-closed behavior outrank convenience: parse errors never
+## guess, every refusal says what it refused and why, and a failed import
+## leaves no refs created (stray unreferenced objects are acceptable and gc-able).
+
+set -euo pipefail
+
+# Hermetic git: no user or system config, no system attributes, fixed locale.
+# Deliberately NO author/committer identities or dates: patchette never authors
+# commits; replayed commits carry their own bytes.
+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
+
+PTMP=$(mktemp -d)
+trap 'rm -rf "$PTMP"' EXIT
+
+## Report a refusal and quit.
+##
+## - @param $@ the message
+## - @stderr the message, prefixed
+die() {
+	>&2 echo "patchette: $*"
+	exit 1
+}
+
+usage() {
+	cat <<-'EOF'
+		patchette: manage branch snapshots as patch files.
+
+		commands:
+		  export [--sigil=quote|--sigil=tab] <subject-repo> <patches-dir> <base-rev> <branch>...
+		      snapshot each branch into a folder of patch files
+		  import <target-repo> <patches-dir>
+		      replay every branch folder; create each branch ref (must not exist)
+		  verify <subject-repo> <patches-dir>
+		      replay against a throwaway clone; check recorded tips reproduce exactly
+	EOF
+}
+
+## Derive a patch slug from a commit subject.
+##
+## The rule: lowercase, every run of non-alphanumerics becomes one dash,
+## trim dashes at both ends, truncate to 40 chars, never empty.
+## Collision suffixing is the caller's job (it needs series-wide state).
+##
+## - @param $1 commit subject (or branch name; same rule on purpose)
+## - @stdout the slug
+slugify() {
+	local s=${1,,}
+	s=${s//[^a-z0-9]/-}
+	while [[ $s == *--* ]]; do s=${s//--/-}; done
+	s=${s#-}
+	s=${s%-}
+	s=${s:0:40}
+	s=${s%-}
+	[[ -n $s ]] || s="patch"
+	printf '%s' "$s"
+}
+
+## Resolve a directory argument to an absolute path, or die clearly.
+##
+## - @param $1 path
+## - @stdout the absolute path
+absdir() {
+	cd "$1" 2>/dev/null && pwd || die "no such directory: $1"
+}
+
+## Parse a STACK control file.
+##
+## - @param $1 path to the STACK file
+## - @state STACK_REF, STACK_BASE, STACK_TIP, STACK_SERIES[] -- the parsed fields
+parse_stack() {
+	local f=$1 line mode=head
+	declare -A seen=()
+	STACK_REF='' STACK_BASE='' STACK_TIP='' STACK_SERIES=()
+	while IFS= read -r line; do
+		if [[ $mode == series ]]; then
+			[[ $line =~ ^[a-z0-9-]+$ ]] || die "$f: malformed series entry: $line"
+			[[ -z ${seen[$line]:-} ]] || die "$f: duplicate series entry: $line"
+			seen[$line]=1
+			STACK_SERIES+=("$line")
+			continue
+		fi
+		case $line in
+		"ref "*) STACK_REF=${line#ref } ;;
+		"base "*) STACK_BASE=${line#base } ;;
+		"tip "*) STACK_TIP=${line#tip } ;;
+		series) mode=series ;;
+		*) die "$f: unrecognized line: $line" ;;
+		esac
+	done <"$f"
+	[[ -n $STACK_REF && -n $STACK_BASE && -n $STACK_TIP && $mode == series ]] ||
+		die "$f: missing ref, base, tip, or series section"
+	[[ $STACK_REF == refs/heads/* ]] || die "$f: ref is not under refs/heads/: $STACK_REF"
+}
+
+## Replay one branch folder's series into the current repo's object store.
+##
+## Expects parse_stack to have run for this folder, cwd to be a git repo
+## whose object store already holds the recorded base, and GIT_INDEX_FILE
+## to point at a scratch path.
+##
+## - @param $1 branch folder path
+## - @state REPLAY_TIP -- the SHA of the last commit replayed
+replay_series() {
+	local dir=$1 slug f line lineno state pline nofinal tree sha pref
+	local headers parents prefs mline
+	local msgpart="$PTMP/msgpart" diffpart="$PTMP/diffpart"
+	declare -A sha_of=([BASE]="$STACK_BASE")
+	REPLAY_TIP=$STACK_BASE
+
+	for slug in "${STACK_SERIES[@]}"; do
+		f="$dir/$slug.patch"
+		[[ -f $f ]] || die "$dir/STACK: series lists $slug, but $slug.patch is missing"
+
+		# Parse the patch file: a strict little state machine over its prelude.
+		# The diff section is everything after the blank line that follows the
+		# closing flank; we stop reading there and slice it out by line count.
+		pline='' nofinal='' headers=() lineno=0 state=start
+		: >"$msgpart"
+		while IFS= read -r line; do
+			lineno=$((lineno + 1))
+			case $state in
+			start)
+				[[ $line == parents\ * ]] || die "$f: line 1 is not a parents line"
+				pline=$line
+				state=flagmaybe
+				;;
+			flagmaybe)
+				if [[ $line == message-no-final-newline ]]; then
+					nofinal=1
+					state=headers
+					continue
+				fi
+				state=headers
+				;&
+			headers)
+				if [[ -z $line ]]; then
+					state=msgopen
+				else
+					headers+=("$line")
+				fi
+				;;
+			msgopen)
+				[[ $line == '----' ]] || die "$f: expected opening ---- flank, got: $line"
+				state=message
+				;;
+			message)
+				# Both sigil dialects are accepted here, unconditionally.
+				if [[ $line == '----' ]]; then
+					state=postmsg
+				elif [[ $line == '>' || $line == '' ]]; then
+					printf '\n' >>"$msgpart"
+				elif [[ $line == '> '* ]]; then
+					printf '%s\n' "${line:2}" >>"$msgpart"
+				elif [[ $line == $'\t'* ]]; then
+					printf '%s\n' "${line:1}" >>"$msgpart"
+				else
+					die "$f: malformed message line: $line"
+				fi
+				;;
+			postmsg)
+				[[ -z $line ]] || die "$f: expected blank line after closing flank, got: $line"
+				state=diff
+				break
+				;;
+			esac
+		done <"$f"
+		[[ $state == diff ]] || die "$f: truncated (ended in state $state)"
+		[[ ${#headers[@]} -gt 0 ]] || die "$f: no commit headers"
+		tail -n +$((lineno + 1)) "$f" >"$diffpart"
+		if [[ -n $nofinal ]]; then
+			head -c -1 "$msgpart" >"$msgpart.t" && mv "$msgpart.t" "$msgpart"
+		fi
+
+		read -ra prefs <<<"${pline#parents }"
+		[[ ${#prefs[@]} -gt 0 ]] || die "$f: parents line lists no parents"
+		parents=()
+		for pref in "${prefs[@]}"; do
+			[[ -n ${sha_of[$pref]:-} ]] || die "$f: parent '$pref' is not BASE or an earlier slug in the series"
+			parents+=("${sha_of[$pref]}")
+		done
+
+		rm -f "$GIT_INDEX_FILE"
+		git read-tree "${parents[0]}"
+		if [[ -s $diffpart ]]; then # empty diff = empty commit; apply must be skipped, not fed nothing
+			git apply --cached --binary "$diffpart" || die "$f: diff does not apply onto its parent"
+		fi
+		tree=$(git write-tree)
+
+		sha=$({
+			printf 'tree %s\n' "$tree"
+			printf 'parent %s\n' "${parents[@]}"
+			printf '%s\n' "${headers[@]}"
+			printf '\n'
+			cat "$msgpart"
+		} | git hash-object -t commit -w --stdin)
+
+		sha_of[$slug]=$sha
+		REPLAY_TIP=$sha
+	done
+}
+
+## List the branch folders of a patches dir, failing closed on strangers.
+##
+## - @param $1 patches dir
+## - @state BRANCH_DIRS[] -- absolute paths, each guaranteed to hold a STACK
+branch_dirs() {
+	local patches=$1 dir
+	BRANCH_DIRS=()
+	for dir in "$patches"/*/; do
+		dir=${dir%/}
+		[[ -d $dir ]] || continue # unmatched glob
+		[[ -f $dir/STACK ]] || die "$dir has no STACK file; not a patchette branch folder"
+		BRANCH_DIRS+=("$dir")
+	done
+	[[ ${#BRANCH_DIRS[@]} -gt 0 ]] || die "no branch folders found in $patches"
+}
+
+cmd_export() {
+	local sigil=quote
+	while [[ $# -gt 0 && $1 == --* ]]; do
+		case $1 in
+		--sigil=quote | --sigil=tab) sigil=${1#--sigil=} ;;
+		*) die "unknown flag: $1" ;;
+		esac
+		shift
+	done
+	[[ $# -ge 4 ]] || die "usage: patchette export [--sigil=quote|--sigil=tab] <subject-repo> <patches-dir> <base-rev> <branch>..."
+	local repo out baserev base branch dir slug sha tip n p mline count
+	local parents raw="$PTMP/raw" msg="$PTMP/msg"
+	repo=$(absdir "$1")
+	mkdir -p "$2"
+	out=$(absdir "$2")
+	baserev=$3
+	shift 3
+	cd "$repo"
+
+	base=$(git rev-parse --verify -q "$baserev^{commit}") ||
+		die "base revision does not resolve to a commit: $baserev"
+
+	for branch in "$@"; do
+		tip=$(git rev-parse --verify -q "refs/heads/$branch") ||
+			die "no such branch: $branch"
+		dir="$out/${branch//\//$'\u00a6'}"
+		if [[ -e $dir ]]; then
+			# Re-exporting a tracked branch replaces its folder; that's the
+			# normal snapshot flow.  But never delete things we didn't write.
+			[[ -f $dir/STACK ]] || die "$dir exists but has no STACK file; not a patchette branch folder, refusing to replace it"
+			rm -rf "$dir"
+		fi
+		mkdir "$dir"
+
+		{
+			printf 'ref refs/heads/%s\n' "$branch"
+			printf 'base %s\n' "$base"
+			printf 'tip %s\n' "$tip"
+			printf 'series\n'
+		} >"$dir/STACK"
+
+		declare -A slug_of=() # commit sha -> assigned slug
+		declare -A taken=()   # slug -> 1
+		count=0
+
+		while IFS= read -r sha; do
+			slug=$(slugify "$(git log -1 --format=%s "$sha")")
+			if [[ -n ${taken[$slug]:-} ]]; then
+				n=2
+				while [[ -n ${taken["$slug-$n"]:-} ]]; do n=$((n + 1)); done
+				slug="$slug-$n"
+			fi
+			taken[$slug]=1
+			slug_of[$sha]=$slug
+
+			parents=()
+			for p in $(git rev-parse "$sha^@"); do
+				if [[ $p == "$base" ]]; then
+					parents+=("BASE")
+				elif [[ -n ${slug_of[$p]:-} ]]; then
+					parents+=("${slug_of[$p]}")
+				else
+					die "commit $sha has parent $p outside $baserev..$branch; out-of-range parents are not supported"
+				fi
+			done
+			[[ ${#parents[@]} -gt 0 ]] || die "commit $sha has no parents (root commit in range); not supported"
+
+			git cat-file commit "$sha" >"$raw"
+			sed -n '/^$/,$p' "$raw" | tail -n +2 >"$msg"
+
+			{
+				printf 'parents %s\n' "${parents[*]}"
+				if [[ -s $msg && $(tail -c 1 "$msg") != '' ]]; then
+					printf 'message-no-final-newline\n'
+				fi
+				sed -n '1,/^$/p' "$raw" | grep -vE '^(tree|parent) ' | sed '/^$/d'
+				printf '\n----\n'
+				while IFS= read -r mline || [[ -n $mline ]]; do
+					if [[ -z $mline ]]; then
+						if [[ $sigil == quote ]]; then printf '>\n'; else printf '\n'; fi
+					else
+						if [[ $sigil == quote ]]; then printf '> %s\n' "$mline"; else printf '\t%s\n' "$mline"; fi
+					fi
+				done <"$msg"
+				printf -- '----\n\n'
+				git diff-tree -r -p --full-index --binary --no-renames --diff-algorithm=patience "$sha^" "$sha"
+			} >"$dir/$slug.patch"
+
+			printf '%s\n' "$slug" >>"$dir/STACK"
+			count=$((count + 1))
+		done < <(git rev-list --reverse --topo-order "$base..refs/heads/$branch")
+
+		echo "export: wrote $dir ($count patches)"
+	done
+}
+
+cmd_import() {
+	[[ $# -eq 2 ]] || die "usage: patchette import <target-repo> <patches-dir>"
+	local target patches dir
+	target=$(absdir "$1")
+	patches=$(absdir "$2")
+	branch_dirs "$patches"
+	cd "$target"
+	export GIT_INDEX_FILE="$PTMP/scratch-index"
+
+	for dir in "${BRANCH_DIRS[@]}"; do
+		parse_stack "$dir/STACK"
+		git show-ref --verify --quiet "$STACK_REF" &&
+			die "$STACK_REF already exists in $target; refusing to touch it"
+		git cat-file -e "$STACK_BASE^{commit}" 2>/dev/null ||
+			die "recorded base $STACK_BASE is not present in $target; fetch from upstream first"
+		replay_series "$dir"
+		# Empty old-value makes must-not-exist atomic: no clobbering, even racily.
+		git update-ref "$STACK_REF" "$REPLAY_TIP" ""
+		echo "import: created $STACK_REF at $REPLAY_TIP"
+		if [[ $REPLAY_TIP != "$STACK_TIP" ]]; then
+			echo "import: note: replayed tip differs from recorded tip $STACK_TIP (patches edited since the snapshot?)"
+		fi
+	done
+}
+
+cmd_verify() {
+	[[ $# -eq 2 ]] || die "usage: patchette verify <subject-repo> <patches-dir>"
+	local subject patches dir f slug bad=0
+	declare -A listed
+	subject=$(absdir "$1")
+	patches=$(absdir "$2")
+	branch_dirs "$patches"
+
+	# Replay into a throwaway shared bare clone: cheap even for huge repos,
+	# and zero pollution of the subject repo's object store.
+	git clone -q --shared --bare "$subject" "$PTMP/verify.git"
+	cd "$PTMP/verify.git"
+	export GIT_INDEX_FILE="$PTMP/scratch-index"
+
+	for dir in "${BRANCH_DIRS[@]}"; do
+		parse_stack "$dir/STACK"
+		git cat-file -e "$STACK_BASE^{commit}" 2>/dev/null ||
+			die "recorded base $STACK_BASE is not present in $subject"
+		replay_series "$dir"
+		if [[ $REPLAY_TIP == "$STACK_TIP" ]]; then
+			echo "verify: ok: $STACK_REF reproduces recorded tip $STACK_TIP"
+		else
+			echo "verify: MISMATCH: $STACK_REF replays to $REPLAY_TIP, but records tip $STACK_TIP"
+			echo "verify:   either the snapshot was unfaithful, or the patches were edited since (patches repo is ahead of its snapshot)"
+			bad=1
+		fi
+
+		# Orphan patch files are silently inert at replay; nothing else would
+		# ever read them, so flagging them is specifically verify's job.
+		listed=()
+		for slug in "${STACK_SERIES[@]}"; do listed[$slug]=1; done
+		for f in "$dir"/*.patch; do
+			[[ -f $f ]] || continue # unmatched glob
+			slug=${f##*/}
+			slug=${slug%.patch}
+			if [[ -z ${listed[$slug]:-} ]]; then
+				echo "verify: orphan: $f is not listed in STACK"
+				bad=1
+			fi
+		done
+	done
+
+	[[ $bad -eq 0 ]] || exit 1
+}
+
+case ${1:-} in
+export | import | verify)
+	cmd=$1
+	shift
+	"cmd_$cmd" "$@"
+	;;
+-h | --help | help)
+	usage
+	;;
+'')
+	usage
+	exit 1
+	;;
+*)
+	die "unknown command: $1 (try: patchette help)"
+	;;
+esac
diff --git a/tests/lib.sh b/tests/lib.sh
new file mode 100644
index 0000000..c91c667
--- /dev/null
+++ b/tests/lib.sh
@@ -0,0 +1,236 @@
+#!/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
+
+## EXPECT is the single assertion primitive for tests.
+##
+## A failed expectation prints a FAIL line, marks the script failed, and lets
+## the test continue; setup failures, by contrast, abort via set -e.
+## (Hence EXPECT, not ASSERT: an ASSERT would abort.)
+##
+##   EXPECT success <desc> -- <cmd>...
+##   EXPECT refusal <desc> <message-fragment> -- <cmd>...
+##   EXPECT equal   <desc> -- <got> <want>
+##
+## The `--` is required: it marks where EXPECT's own arguments end,
+## and checking its position catches a missing or extra argument at the call site.
+## House layout puts the mode and description on one line, ending in `-- \`,
+## and the subject on the next, indented:
+##
+##   EXPECT refusal "a duplicate series entry is an error" "duplicate series entry" -- \
+##   	"$PATCHETTE" verify "$subject" "$patches"
+##
+## `refusal` passes only when the command exits nonzero AND its output contains
+## the fragment: an exit code alone could come from any guard (or any crash),
+## and each test should stay tied to its specific refusal.
+## Commands run with output captured, shown only on failure.
+## They run on the right side of `||`, which disables set -e within them:
+## fine for external commands, but a shell function passed in won't stop at its first failure.
+## Misuse (unknown mode, misplaced `--`, wrong arity) is a harness bug and exits the script.
+##
+## - @param $1 mode: success, refusal, or equal
+## - @param $2 check description
+## - @stdout one PASS or FAIL line
+## - @state RESULT -- set to 1 on failure
+EXPECT() {
+	local mode=${1:-} desc=${2:-} want='' out rc=0
+	shift 2 || _harness_bug "EXPECT: needs at least <mode> <desc>"
+	if [[ $mode == refusal ]]; then
+		[[ $# -ge 1 ]] || _harness_bug "EXPECT refusal '$desc': missing message fragment"
+		want=$1
+		shift
+	fi
+	[[ ${1:-} == -- ]] || _harness_bug "EXPECT $mode '$desc': expected -- where its own arguments end, got '${1:-}'"
+	shift
+
+	case $mode in
+	success)
+		[[ $# -ge 1 ]] || _harness_bug "EXPECT success '$desc': no command after --"
+		out=$("$@" 2>&1) || rc=$?
+		if [[ $rc -eq 0 ]]; then
+			_pass "$desc"
+		else
+			_fail "$desc: exit $rc; output: $out"
+		fi
+		;;
+	refusal)
+		[[ $# -ge 1 ]] || _harness_bug "EXPECT refusal '$desc': no command after --"
+		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
+		;;
+	equal)
+		[[ $# -eq 2 ]] || _harness_bug "EXPECT equal '$desc': needs exactly <got> <want> after --"
+		if [[ $1 == "$2" ]]; then
+			_pass "$desc"
+		else
+			_fail "$desc: got '$1', want '$2'"
+		fi
+		;;
+	*)
+		_harness_bug "EXPECT: unknown mode '$mode'"
+		;;
+	esac
+}
+
+_pass() {
+	echo "PASS  $*"
+}
+
+_fail() {
+	echo "FAIL  $*"
+	RESULT=1
+}
+
+_harness_bug() {
+	>&2 echo "harness bug: $*"
+	exit 2
+}
+
+_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..e7a5616
--- /dev/null
+++ b/tests/test-failclosed.sh
@@ -0,0 +1,78 @@
+#!/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
+EXPECT success "export left the non-patchette folder untouched" -- \
+	test -f "$work/notours/$fancy/precious.txt"
+
+# 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..dd1324d
--- /dev/null
+++ b/tests/test-roundtrip.sh
@@ -0,0 +1,49 @@
+#!/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
+	EXPECT success "$sigil: export is regeneration-stable (two runs byte-identical)" -- \
+		diff -r "$p/patches" "$p/again"
+
+	# 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
+	EXPECT success "$sigil: emitted message framing matches the dialect" -- \
+		grep -qxF "$want" "$sample"
+
+	EXPECT success "$sigil: verify passes on a fresh export" -- \
+		"$PATCHETTE" verify "$work/corpus/dev" "$p/patches"
+
+	# 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
+	EXPECT equal "$sigil: import reproduces the exact topic/fancy tip" -- \
+		"$(git -C "$p/fresh" rev-parse refs/heads/topic/fancy)" "$tip_topic_fancy"
+	EXPECT equal "$sigil: import reproduces the exact docs-polish tip" -- \
+		"$(git -C "$p/fresh" rev-parse refs/heads/docs-polish)" "$tip_docs_polish"
+
+	EXPECT success "$sigil: fsck --strict clean on the replayed repo" -- \
+		git -C "$p/fresh" fsck --strict
+done
+
+exit "$RESULT"