patchette

Merge branch 'cli-polish-take3'

commit f4a2518b27e3828c947dd01c2e5f0a581e1f84dd · parent 2df663fec9 · parent 28b2b86895 · browse this tree
warpfork, 2026-10-27 13:37:01 UTC

diff --git a/bin/patchette b/bin/patchette
index 46e9d49..732fbca 100755
--- a/bin/patchette
+++ b/bin/patchette
@@ -2,9 +2,9 @@
 ## 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>
+##   patchette export [--sigil=quote|--sigil=tab] --subject=<repo> --patches=<dir> <base-rev> <branch>...
+##   patchette import [-f] --subject=<repo> --patches=<dir> [<branch>...]
+##   patchette verify --subject=<repo> --patches=<dir> [<branch>...]
 ##
 ## The on-disk format is specified in docs/hacking/FORMAT.md, and the replay
 ## algorithm in docs/hacking/REPLAYING.md; those documents are the single
@@ -34,9 +34,13 @@ export GIT_ATTR_NOSYSTEM=1
 export LC_ALL=C.UTF-8
 
 PTMP=$(mktemp -d)
+# Where the user invoked us; q() relativizes message paths against this.
+START_PWD=$PWD
 # EXPORT_STAGE is a half-built branch folder; it lives next to its final
 # destination (not in PTMP), so it also needs cleanup if we die mid-build.
 EXPORT_STAGE=''
+# PATCHES_DIR_ABS makes patch-file paths in messages display relative (see q()).
+PATCHES_DIR_ABS=''
 trap 'rm -rf "$PTMP" ${EXPORT_STAGE:+"$EXPORT_STAGE"}' EXIT
 
 ## Report a refusal and quit.
@@ -48,17 +52,70 @@ die() {
 	exit 1
 }
 
+## Report a refusal with captured detail beneath it, and quit.
+## Our message leads; the detail (e.g. child-git stderr, which in a multi-file
+## patch is the only source of which file or hunk failed) follows, indented.
+##
+## - @param $1 the message
+## - @param $2 file holding the detail lines
+## - @stderr the message, then the detail indented
+die_detail() {
+	>&2 echo "patchette: $1"
+	>&2 sed 's/^/    /' "$2"
+	exit 1
+}
+
+## Run a child command with its stderr captured, so every failure presents
+## the same way: our message leads, the child's stderr indents beneath it.
+## On success, captured stderr still passes through -- warnings aren't ours to eat.
+##
+## - @param $1 message to lead with on failure
+## - @param $@ the command
+## - @stdout the command's stdout, untouched
+try() {
+	local msg=$1
+	shift
+	if ! "$@" 2>"$PTMP/child-err"; then
+		die_detail "$msg" "$PTMP/child-err"
+	fi
+	if [[ -s $PTMP/child-err ]]; then
+		>&2 cat "$PTMP/child-err"
+	fi
+}
+
+## Format a user-originated string for inclusion in messages.
+## Paths are shown relative where that reads better: patch-file paths relative
+## to the patches dir, other paths relative to where the user invoked us
+## (the commands cd around internally, so $PWD is no anchor at die time);
+## everything gets quoted (these strings are effectively user-generated content).
+##
+## - @param $1 the string (a path, branch name, ref, slug, ...)
+## - @stdout the quoted, relativized string
+q() {
+	local s=$1
+	if [[ -n ${PATCHES_DIR_ABS:-} && $s == "$PATCHES_DIR_ABS"/* ]]; then
+		s=${s#"$PATCHES_DIR_ABS"/}
+	elif [[ $s == "$START_PWD"/* ]]; then
+		s=${s#"$START_PWD"/}
+	fi
+	printf "'%s'" "$s"
+}
+
 usage() {
 	cat <<-'EOF'
 		patchette: manage branch snapshots as patch files.
 
 		commands:
-		  export [--sigil=quote|--sigil=tab] <subject-repo> <patches-dir> <base-rev> <branch>...
+		  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>
+		  import [-f] --subject=<repo> --patches=<dir> [<branch>...]
+		      replay branch folders (default all); create each branch ref,
+		      refusing existing ones unless -f, which updates them (reflogged)
+		  verify --subject=<repo> --patches=<dir> [<branch>...]
 		      replay against a throwaway clone; check recorded tips reproduce exactly
+		  pair [<patches-dir>]
+		      run inside a subject repo: remember its patches dir (or report it);
+		      paired repos may omit --subject/--patches above
 	EOF
 }
 
@@ -87,7 +144,7 @@ slugify() {
 ## - @param $1 path
 ## - @stdout the absolute path
 absdir() {
-	cd "$1" 2>/dev/null && pwd || die "no such directory: $1"
+	cd "$1" 2>/dev/null && pwd || die "no such directory: $(q "$1")"
 }
 
 ## Parse a STACK control file.
@@ -101,34 +158,34 @@ parse_stack() {
 	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"
+			[[ $line =~ ^[a-z0-9-]+$ ]] || die "$(q "$f"): malformed series entry: $(q "$line")"
+			[[ -z ${seen[$line]:-} ]] || die "$(q "$f"): duplicate series entry: $(q "$line")"
 			seen[$line]=1
 			STACK_SERIES+=("$line")
 			continue
 		fi
 		key=${line%% *}
-		[[ -z ${keys_seen[$key]:-} ]] || die "$f: duplicate $key line"
+		[[ -z ${keys_seen[$key]:-} ]] || die "$(q "$f"): duplicate $key line"
 		keys_seen[$key]=1
 		case $line in
 		"ref "*) STACK_REF=${line#ref } ;;
 		"base "*)
 			STACK_BASE=${line#base }
-			[[ $STACK_BASE =~ $oid ]] || die "$f: base is not a full commit ID: $STACK_BASE"
+			[[ $STACK_BASE =~ $oid ]] || die "$(q "$f"): base is not a full commit ID: $(q "$STACK_BASE")"
 			;;
 		"tip "*)
 			STACK_TIP=${line#tip }
-			[[ $STACK_TIP =~ $oid ]] || die "$f: tip is not a full commit ID: $STACK_TIP"
+			[[ $STACK_TIP =~ $oid ]] || die "$(q "$f"): tip is not a full commit ID: $(q "$STACK_TIP")"
 			;;
 		series) mode=series ;;
-		*) die "$f: unrecognized line: $line" ;;
+		*) die "$(q "$f"): unrecognized line: $(q "$line")" ;;
 		esac
 	done <"$f"
 	# tip is optional: a hand-made STACK has no expectable value for it
 	# until the branch has been built.
 	[[ -n $STACK_REF && -n $STACK_BASE && $mode == series ]] ||
-		die "$f: missing ref, base, or series section"
-	[[ $STACK_REF == refs/heads/* ]] || die "$f: ref is not under refs/heads/: $STACK_REF"
+		die "$(q "$f"): missing ref, base, or series section"
+	[[ $STACK_REF == refs/heads/* ]] || die "$(q "$f"): ref is not under refs/heads/: $(q "$STACK_REF")"
 }
 
 ## Check folder-level concordance between a STACK's series and the *.patch
@@ -145,13 +202,13 @@ validate_branch_dir() {
 	declare -A listed=()
 	for slug in "${STACK_SERIES[@]}"; do
 		listed[$slug]=1
-		[[ -f $dir/$slug.patch ]] || die "$dir/STACK: series lists $slug, but $slug.patch is missing"
+		[[ -f $dir/$slug.patch ]] || die "$(q "$dir/STACK"): series lists $(q "$slug"), but $(q "$slug.patch") is missing"
 	done
 	for f in "$dir"/*.patch; do
 		[[ -f $f ]] || continue # unmatched glob
 		slug=${f##*/}
 		slug=${slug%.patch}
-		[[ -n ${listed[$slug]:-} ]] || die "$f: orphan patch file, not listed in the STACK series"
+		[[ -n ${listed[$slug]:-} ]] || die "$(q "$f"): orphan patch file, not listed in the STACK series"
 	done
 }
 
@@ -182,7 +239,7 @@ replay_series() {
 			lineno=$((lineno + 1))
 			case $state in
 			start)
-				[[ $line == parents\ * ]] || die "$f: line 1 is not a parents line"
+				[[ $line == parents\ * ]] || die "$(q "$f"): line 1 is not a parents line"
 				pline=$line
 				state=headers
 				;;
@@ -192,13 +249,13 @@ replay_series() {
 				elif [[ $line != *' '* ]]; then
 					# Headers are `key value`, and continuation lines begin with
 					# a space, so every header line contains one.
-					die "$f: malformed header line (no space): $line"
+					die "$(q "$f"): malformed header line (no space): $(q "$line")"
 				else
 					headers+=("$line")
 				fi
 				;;
 			msgopen)
-				[[ $line == '----' ]] || die "$f: expected opening ---- flank, got: $line"
+				[[ $line == '----' ]] || die "$(q "$f"): expected opening ---- flank, got: $(q "$line")"
 				state=directives
 				;;
 			directives)
@@ -211,7 +268,7 @@ replay_series() {
 					continue
 				fi
 				if [[ -n $line && $line != '----' && $line != '>' && $line != '> '* && $line != $'\t'* ]]; then
-					die "$f: unrecognized directive line: $line"
+					die "$(q "$f"): unrecognized directive line: $(q "$line")"
 				fi
 				state=message
 				;&
@@ -226,40 +283,42 @@ replay_series() {
 				elif [[ $line == $'\t'* ]]; then
 					printf '%s\n' "${line:1}" >>"$msgpart"
 				else
-					die "$f: malformed message line: $line"
+					die "$(q "$f"): malformed message line: $(q "$line")"
 				fi
 				;;
 			postmsg)
-				[[ -z $line ]] || die "$f: expected blank line after closing flank, got: $line"
+				[[ -z $line ]] || die "$(q "$f"): expected blank line after closing flank, got: $(q "$line")"
 				state=diff
 				break
 				;;
 			esac
 		done <"$f"
-		[[ $state == diff ]] || die "$f: truncated (ended in state $state)"
-		[[ ${#headers[@]} -gt 0 ]] || die "$f: no commit headers"
+		[[ $state == diff ]] || die "$(q "$f"): truncated (ended in state $state)"
+		[[ ${#headers[@]} -gt 0 ]] || die "$(q "$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"
+		[[ ${#prefs[@]} -gt 0 ]] || die "$(q "$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"
+			[[ -n ${sha_of[$pref]:-} ]] || die "$(q "$f"): parent $(q "$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]}"
+		try "$(q "$f"): could not read the first parent's tree" \
+			git read-tree "${parents[0]}"
 		if [[ -s $diffpart ]]; then # empty diff = empty commit; apply must be skipped, not fed nothing
-			# --whitespace=nowarn: the target repo's own apply.whitespace must not
+			# --whitespace=nowarn: the subject repo's own apply.whitespace must not
 			# alter (=fix) or refuse (=error) the recorded bytes, and warnings about
 			# whitespace that's faithfully preserved are just noise.
-			git apply --cached --binary --whitespace=nowarn "$diffpart" || die "$f: diff does not apply onto its parent"
+			try "$(q "$f"): diff does not apply onto its parent" \
+				git apply --cached --binary --whitespace=nowarn "$diffpart"
 		fi
-		tree=$(git write-tree)
+		tree=$(try "$(q "$f"): could not write the replayed tree" git write-tree)
 
 		sha=$({
 			printf 'tree %s\n' "$tree"
@@ -267,55 +326,143 @@ replay_series() {
 			printf '%s\n' "${headers[@]}"
 			printf '\n'
 			cat "$msgpart"
-		} | git hash-object -t commit -w --stdin)
+		} | try "$(q "$f"): could not store the replayed commit" git hash-object -t commit -w --stdin)
 
 		sha_of[$slug]=$sha
 		REPLAY_TIP=$sha
 	done
 }
 
+## Locate the pairing anchor of the repo containing the cwd: the common git
+## dir (shared by all worktrees -- branches are repo-wide, so pairing is too)
+## and the repo root derived from it.
+## Only the standard `<root>/.git` layout can anchor a pairing; a repo whose
+## git dir lives wholly elsewhere (separate-git-dir, bare) has no root to
+## derive, and gets the explicit flags instead.
+##
+## - @param $1 context for refusal messages, e.g. "--subject/--patches not given, and"
+## - @state PAIR_COMMON, PAIR_ROOT -- absolute paths
+locate_pairing_anchor() {
+	local context=$1
+	PAIR_COMMON=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) ||
+		die "$context the cwd is not inside a git repository"
+	PAIR_ROOT=${PAIR_COMMON%/.git}
+	[[ $PAIR_ROOT != "$PAIR_COMMON" ]] ||
+		die "$context this repo's git dir $(q "$PAIR_COMMON") lives outside a worktree root, so pairing can't anchor a relative path; pass explicit flags"
+}
+
+## Resolve the paired subject repo and patches dir (see cmd_pair).
+##
+## - @state PAIRED_SUBJECT, PAIRED_PATCHES -- absolute paths
+read_pairing() {
+	local rel
+	locate_pairing_anchor "--subject/--patches not given, and"
+	rel=$(git config --file "$PAIR_COMMON/patchette" --get patchette.patches 2>/dev/null) ||
+		die "--subject/--patches not given, and $(q "$PAIR_ROOT") is not paired; run 'patchette pair <patches-dir>' there, or pass the flags"
+	PAIRED_SUBJECT=$PAIR_ROOT
+	PAIRED_PATCHES=$PAIR_ROOT/$rel
+}
+
+## Fill absent --subject/--patches values from the pairing.
+## Each flag overrides independently: explicit always wins, per value.
+##
+## - @param $1 current --subject value (may be empty)
+## - @param $2 current --patches value (may be empty)
+## - @state RESOLVED_SUBJECT, RESOLVED_PATCHES
+resolve_repos() {
+	RESOLVED_SUBJECT=$1
+	RESOLVED_PATCHES=$2
+	if [[ -z $RESOLVED_SUBJECT || -z $RESOLVED_PATCHES ]]; then
+		read_pairing
+		RESOLVED_SUBJECT=${RESOLVED_SUBJECT:-$PAIRED_SUBJECT}
+		RESOLVED_PATCHES=${RESOLVED_PATCHES:-$PAIRED_PATCHES}
+	fi
+}
+
+cmd_pair() {
+	[[ $# -le 1 ]] || die "usage: patchette pair [<patches-dir>]"
+	local abs rel
+	locate_pairing_anchor "pair:"
+	if [[ $# -eq 0 ]]; then
+		rel=$(git config --file "$PAIR_COMMON/patchette" --get patchette.patches 2>/dev/null) ||
+			die "$(q "$PAIR_ROOT") is not paired"
+		echo "paired: patches dir is $(q "$rel") (relative to $(q "$PAIR_ROOT"))"
+		return
+	fi
+	# No mkdir here: pairing states a fact about something that exists;
+	# creating directories is export's job.
+	abs=$(absdir "$1")
+	rel=$(realpath --relative-to="$PAIR_ROOT" "$abs")
+	try "could not write the pairing file in $(q "$PAIR_COMMON")" \
+		git config --file "$PAIR_COMMON/patchette" patchette.patches "$rel"
+	echo "paired: $(q "$PAIR_ROOT") -> patches dir $(q "$rel")"
+}
+
 ## List the branch folders of a patches dir, failing closed on strangers,
-## and validate every folder's concordance up front: discordance is a static
-## property of the patches dir, so it's refused before any repo is touched
+## optionally narrowed to named branches.  The chosen folders' concordance is
+## validated up front: discordance is a static property of the patches dir,
+## so it's refused before any repo is touched
 ## (and in import's case, before any ref could be created).
+## Narrowing narrows validation too, so an unrelated broken folder doesn't
+## block working on the named ones.
 ##
 ## - @param $1 patches dir
+## - @param $@ branch names to narrow to (default: all branch folders)
 ## - @state BRANCH_DIRS[] -- absolute paths, each holding a validated STACK
 ## - @state STACK_* -- clobbered; callers re-run parse_stack per folder
 branch_dirs() {
-	local patches=$1 dir
-	BRANCH_DIRS=()
+	local patches=$1 dir sel
+	local -a all=() chosen=()
+	shift
 	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"
+		[[ -f $dir/STACK ]] || die "$(q "$dir") has no STACK file; not a patchette branch folder"
+		all+=("$dir")
+	done
+	[[ ${#all[@]} -gt 0 ]] || die "no branch folders found in $(q "$patches")"
+	if [[ $# -gt 0 ]]; then
+		for sel in "$@"; do
+			dir="$patches/${sel//\//$'\u00a6'}"
+			[[ -f $dir/STACK ]] || die "no branch folder for $(q "$sel") in $(q "$patches")"
+			chosen+=("$dir")
+		done
+	else
+		chosen=("${all[@]}")
+	fi
+	BRANCH_DIRS=()
+	for dir in "${chosen[@]}"; do
 		parse_stack "$dir/STACK"
 		validate_branch_dir "$dir"
 		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
+	local sigil=quote subject='' patches=''
+	while [[ $# -gt 0 && $1 == -* ]]; do
 		case $1 in
 		--sigil=quote | --sigil=tab) sigil=${1#--sigil=} ;;
-		*) die "unknown flag: $1" ;;
+		--subject=*) subject=${1#--subject=} ;;
+		--patches=*) patches=${1#--patches=} ;;
+		*) die "unknown flag: $(q "$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 scratch raw="$PTMP/raw" msg="$PTMP/msg"
-	repo=$(absdir "$1")
-	mkdir -p "$2"
-	out=$(absdir "$2")
-	baserev=$3
-	shift 3
+	[[ $# -ge 2 ]] ||
+		die "usage: patchette export [--sigil=quote|--sigil=tab] [--subject=<repo>] [--patches=<dir>] <base-rev> <branch>..."
+	resolve_repos "$subject" "$patches"
+	repo=$(absdir "$RESOLVED_SUBJECT")
+	mkdir -p "$RESOLVED_PATCHES"
+	out=$(absdir "$RESOLVED_PATCHES")
+	PATCHES_DIR_ABS=$out
+	baserev=$1
+	shift
 
 	base=$(git -C "$repo" rev-parse --verify -q "$baserev^{commit}") ||
-		die "base revision does not resolve to a commit: $baserev"
+		die "base revision does not resolve to a commit: $(q "$baserev")"
 
 	# Patch bytes must depend only on the commits, so the diffs are generated in
 	# a scratch bare repo that borrows the subject's objects via alternates.
@@ -325,18 +472,19 @@ cmd_export() {
 	# of all of them, and its unborn HEAD keeps even committed attributes out.
 	# Only ref resolution touches the subject; from here on it's SHAs.
 	scratch="$PTMP/export.git"
-	git init -q --bare -b scratch --object-format="$(git -C "$repo" rev-parse --show-object-format)" "$scratch"
+	try "could not create the scratch repo for diff generation" \
+		git init -q --bare -b scratch --object-format="$(git -C "$repo" rev-parse --show-object-format)" "$scratch"
 	git -C "$repo" rev-parse --path-format=absolute --git-path objects >"$scratch/objects/info/alternates"
 	cd "$scratch"
 
 	for branch in "$@"; do
 		tip=$(git -C "$repo" rev-parse --verify -q "refs/heads/$branch") ||
-			die "no such branch: $branch"
+			die "no such branch: $(q "$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"
+			[[ -f $dir/STACK ]] || die "$(q "$dir") exists but has no STACK file; not a patchette branch folder, refusing to replace it"
 		fi
 		# Build in a stage folder beside the destination, and swap in only a
 		# finished snapshot: a death mid-export must leave the previous
@@ -356,6 +504,10 @@ cmd_export() {
 		declare -A taken=()   # slug -> 1
 		count=0
 
+		# Walked into a file first: a failure inside a process substitution
+		# would be invisible, and read as an empty branch.
+		try "could not list the commits of $(q "$branch")" \
+			git rev-list --reverse --topo-order "$base..$tip" >"$PTMP/revs"
 		while IFS= read -r sha; do
 			slug=$(slugify "$(git log -1 --format=%s "$sha")")
 			if [[ -n ${taken[$slug]:-} ]]; then
@@ -373,12 +525,13 @@ cmd_export() {
 				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"
+					die "commit $sha has parent $p outside $(q "$baserev")..$(q "$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"
+			try "could not read commit $sha from $(q "$repo")" \
+				git cat-file commit "$sha" >"$raw"
 			sed -n '/^$/,$p' "$raw" | tail -n +2 >"$msg"
 
 			{
@@ -396,12 +549,13 @@ cmd_export() {
 					fi
 				done <"$msg"
 				printf -- '----\n\n'
-				git diff-tree -r -p --full-index --binary --no-renames --diff-algorithm=patience "$sha^" "$sha"
+				try "could not generate the diff for commit $sha" \
+					git diff-tree -r -p --full-index --binary --no-renames --diff-algorithm=patience "$sha^" "$sha"
 			} >"$EXPORT_STAGE/$slug.patch"
 
 			printf '%s\n' "$slug" >>"$EXPORT_STAGE/STACK"
 			count=$((count + 1))
-		done < <(git rev-list --reverse --topo-order "$base..$tip")
+		done <"$PTMP/revs"
 
 		rm -rf "$dir"
 		mv "$EXPORT_STAGE" "$dir"
@@ -410,25 +564,75 @@ cmd_export() {
 	done
 }
 
+## Find the checkout, if any, that has a branch checked out: the main checkout
+## or any linked worktree.  Like git branch -f, -f won't move such a branch,
+## since that checkout's index and files would be left describing the old tip.
+##
+## Expects cwd to be the repo.
+##
+## - @param $1 full ref name
+## - @stdout the checkout's path, or nothing
+checkout_of() {
+	local ref=$1 rec wt=''
+	try "could not list the worktrees of $(q "$PWD")" \
+		git worktree list --porcelain -z >"$PTMP/worktrees"
+	while IFS= read -r -d '' rec; do
+		case $rec in
+		"worktree "*) wt=${rec#worktree } ;;
+		"branch $ref")
+			printf '%s' "$wt"
+			return
+			;;
+		esac
+	done <"$PTMP/worktrees"
+}
+
 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"
+	local force='' subject='' patches='' dir old busy
+	while [[ $# -gt 0 && $1 == -* ]]; do
+		case $1 in
+		-f) force=1 ;;
+		--subject=*) subject=${1#--subject=} ;;
+		--patches=*) patches=${1#--patches=} ;;
+		*) die "unknown flag: $(q "$1")" ;;
+		esac
+		shift
+	done
+	resolve_repos "$subject" "$patches"
+	subject=$(absdir "$RESOLVED_SUBJECT")
+	patches=$(absdir "$RESOLVED_PATCHES")
+	PATCHES_DIR_ABS=$patches
+	branch_dirs "$patches" "$@"
+	cd "$subject"
 	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"
+		old=''
+		if git show-ref --verify --quiet "$STACK_REF"; then
+			[[ -n $force ]] ||
+				die "$(q "$STACK_REF") already exists in $(q "$subject"); refusing to touch it (-f updates it instead)"
+			old=$(git rev-parse --verify "$STACK_REF")
+			busy=$(checkout_of "$STACK_REF")
+			[[ -z $busy ]] ||
+				die "$(q "$STACK_REF") is checked out in $(q "$busy"); refusing to move it under that checkout, even with -f"
+		fi
 		git cat-file -e "$STACK_BASE^{commit}" 2>/dev/null ||
-			die "recorded base $STACK_BASE is not present in $target; fetch from upstream first"
+			die "recorded base $STACK_BASE is not present in $(q "$subject"); 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"
+		# The old-value guard makes the update atomic: empty = must-not-exist
+		# (no clobbering, even racily), a SHA = compare-and-swap on the tip we
+		# just decided to overwrite.  The reflog keeps that old tip findable,
+		# and --create-reflog matters: bare repos don't log refs by default.
+		try "could not update $(q "$STACK_REF") (changed concurrently?)" \
+			git update-ref --create-reflog -m "patchette: import" "$STACK_REF" "$REPLAY_TIP" "$old"
+		if [[ -z $old ]]; then
+			echo "import: created $STACK_REF at $REPLAY_TIP"
+		elif [[ $old == "$REPLAY_TIP" ]]; then
+			echo "import: unchanged: $STACK_REF is already at $REPLAY_TIP"
+		else
+			echo "import: updated $STACK_REF to $REPLAY_TIP (previous tip $old remains in its reflog)"
+		fi
 		if [[ -n $STACK_TIP && $REPLAY_TIP != "$STACK_TIP" ]]; then
 			echo "import: note: replayed tip differs from recorded tip $STACK_TIP (patches edited since the snapshot?)"
 		fi
@@ -436,22 +640,32 @@ cmd_import() {
 }
 
 cmd_verify() {
-	[[ $# -eq 2 ]] || die "usage: patchette verify <subject-repo> <patches-dir>"
-	local subject patches dir bad=0
-	subject=$(absdir "$1")
-	patches=$(absdir "$2")
-	branch_dirs "$patches"
+	local subject='' patches='' dir bad=0
+	while [[ $# -gt 0 && $1 == -* ]]; do
+		case $1 in
+		--subject=*) subject=${1#--subject=} ;;
+		--patches=*) patches=${1#--patches=} ;;
+		*) die "unknown flag: $(q "$1")" ;;
+		esac
+		shift
+	done
+	resolve_repos "$subject" "$patches"
+	subject=$(absdir "$RESOLVED_SUBJECT")
+	patches=$(absdir "$RESOLVED_PATCHES")
+	PATCHES_DIR_ABS=$patches
+	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"
+	try "could not clone $(q "$subject") for verification" \
+		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"
+			die "recorded base $STACK_BASE is not present in $(q "$subject")"
 		replay_series "$dir"
 		if [[ -z $STACK_TIP ]]; then
 			echo "verify: note: $STACK_REF replays cleanly to $REPLAY_TIP, but records no tip to cross-check against"
@@ -468,7 +682,7 @@ cmd_verify() {
 }
 
 case ${1:-} in
-export | import | verify)
+export | import | verify | pair)
 	cmd=$1
 	shift
 	"cmd_$cmd" "$@"
@@ -481,6 +695,6 @@ export | import | verify)
 	exit 1
 	;;
 *)
-	die "unknown command: $1 (try: patchette help)"
+	die "unknown command: $(q "$1") (try: patchette help)"
 	;;
 esac
diff --git a/docs/hacking/REPLAYING.md b/docs/hacking/REPLAYING.md
index ee5bc68..0917272 100644
--- a/docs/hacking/REPLAYING.md
+++ b/docs/hacking/REPLAYING.md
@@ -13,7 +13,7 @@ For each patch name in series order, maintaining a map from reference to replaye
 2. Read the first parent's tree into a scratch index (no worktree is required, ever).
 3. If the diff section is non-empty, apply it to that index
    (`git apply --cached --binary --whitespace=nowarn`; the explicit whitespace flag
-   keeps a target repo's own `apply.whitespace` from rewriting or refusing recorded bytes).
+   keeps the subject repo's own `apply.whitespace` from rewriting or refusing recorded bytes).
    An apply failure is an error: the recorded base is the only supported target.
 4. Write the resulting tree.
 5. Reassemble the raw commit object: `tree` line, one `parent` line per resolved parent,
diff --git a/docs/userguide/USAGE.md b/docs/userguide/USAGE.md
new file mode 100644
index 0000000..79abaa1
--- /dev/null
+++ b/docs/userguide/USAGE.md
@@ -0,0 +1,326 @@
+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]:# (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')
+```
+
+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 `~`).
+
+
+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]:# (snapshot/session)
+```
+~/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 (2 patches)
+
+~/src/frob$ ls ../frob-patches/docs-polish
+STACK
+mention-the-oiling-schedule.patch
+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.
+
+`~/src/frob-patches/docs-polish/STACK` names the branch, the upstream commit it's based on, and its tip;
+then, after `series`, its patches, in order:
+
+[testmark]:# (snapshot/STACK)
+```
+ref refs/heads/docs-polish
+base 5b0130cfd2153bcdb4a6d7ec356e7d805018afc9
+tip 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a
+series
+polish-the-readme
+mention-the-oiling-schedule
+```
+
+`~/src/frob-patches/docs-polish/polish-the-readme.patch` is one commit:
+its parents (`BASE` is the STACK's base), then the rest of its headers exactly as git stored them; its message, between `----` lines; and its diff:
+
+[testmark]:# (snapshot/polish-the-readme.patch)
+```
+parents BASE
+author Ada Downstream <ada@downstream.example> 1700000660 +0000
+committer Ada Downstream <ada@downstream.example> 1700000660 +0000
+
+----
+> Polish the readme
+----
+
+diff --git a/README.md b/README.md
+index a330cd687e7fdede54bbe0aca5df47c06d2e8b64..c195933372242cdbb3c014665ebcff2bb3fb4449 100644
+--- a/README.md
++++ b/README.md
+@@ -3,3 +3,5 @@
+ Upstream docs.
+
+ More upstream docs.
++
++Downstream notes: be kind to the machinery.
+```
+
+Both formats are specified in full in [FORMAT.md](../hacking/FORMAT.md).
+
+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]:# (paired/evolving/session)
+```
+~/src/frob$ patchette export origin/main docs-polish
+export: wrote ~/src/frob-patches/docs-polish (2 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/weekly/daily/' 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 (2 patches)
+
+~/src/frob$ git -C ../frob-patches diff
+diff --git a/docs-polish/STACK b/docs-polish/STACK
+index c56f84d..99ac5cd 100644
+--- a/docs-polish/STACK
++++ b/docs-polish/STACK
+@@ -1,6 +1,6 @@
+ ref refs/heads/docs-polish
+ base 5b0130cfd2153bcdb4a6d7ec356e7d805018afc9
+-tip 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a
++tip 88f146ae1801f5fd8d443d6eb5904f09b88927c6
+ series
+ polish-the-readme
+ mention-the-oiling-schedule
+diff --git a/docs-polish/mention-the-oiling-schedule.patch b/docs-polish/mention-the-oiling-schedule.patch
+index 8b0dda3..1f6c371 100644
+--- a/docs-polish/mention-the-oiling-schedule.patch
++++ b/docs-polish/mention-the-oiling-schedule.patch
+@@ -1,17 +1,17 @@
+ parents polish-the-readme
+ author Ada Downstream <ada@downstream.example> 1700000720 +0000
+-committer Ada Downstream <ada@downstream.example> 1700000720 +0000
++committer Ada Downstream <ada@downstream.example> 1700086400 +0000
+
+ ----
+ > Mention the oiling schedule
+ ----
+
+ diff --git a/README.md b/README.md
+-index c195933372242cdbb3c014665ebcff2bb3fb4449..073a43988cb91f0fe493964670f6d52517082346 100644
++index c195933372242cdbb3c014665ebcff2bb3fb4449..61f9c849c8c9962d481e8ce3874abcc47a640eba 100644
+ --- a/README.md
+ +++ b/README.md
+ @@ -5,3 +5,4 @@ Upstream docs.
+  More upstream docs.
+
+  Downstream notes: be kind to the machinery.
+-+Oil the gears weekly.
+++Oil the gears daily.
+```
+
+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]:# (paired/verifying/session)
+```
+~/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 (2 patches)
+
+~/src/frob$ patchette verify
+verify: ok: refs/heads/docs-polish reproduces recorded tip 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a
+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]:# (paired/recreating/session)
+```
+~/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 (2 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 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a
+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]:# (paired/hand-editing/session)
+```
+~/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]:# (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 (2 patches)
+
+~$ patchette verify --subject=src/frob --patches=src/frob-patches
+verify: ok: refs/heads/docs-polish reproduces recorded tip 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a
+```
+
+
+When something's wrong
+----------------------
+
+[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 (2 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 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a
+
+~/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/lib.sh b/tests/lib.sh
index 6fc8495..49abc6a 100644
--- a/tests/lib.sh
+++ b/tests/lib.sh
@@ -38,7 +38,7 @@ RESULT=0
 ## and the subject on the next, indented:
 ##
 ##   EXPECT refusal "a duplicate series entry is an error" "duplicate series entry" -- \
-##   	"$PATCHETTE" verify "$subject" "$patches"
+##   	"$PATCHETTE" verify --subject="$subject" --patches="$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),
@@ -130,7 +130,7 @@ tick() {
 ## 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.
+## `docs-polish` is the plain example: two ordinary commits, a short series.
 ## `odd-bytes` carries content that git config or attributes could perturb.
 ##
 ## - @param $1 output directory; created.  Gets `upstream/`, `dev/`, and a
@@ -222,12 +222,16 @@ make_corpus() (
 	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 ---
+	# --- second branch: the plain example ---
 	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 'Oil the gears weekly.\n' >>README.md
+	git add -A
+	tick
+	git commit -q -m "Mention the oiling schedule"
 
 	# --- third branch: content that git config or attributes could bite on ---
 	# Trailing whitespace, CRLF, binary, a non-ASCII path with a space, a
diff --git a/tests/test-errors.sh b/tests/test-errors.sh
new file mode 100755
index 0000000..0512815
--- /dev/null
+++ b/tests/test-errors.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+## Error presentation: when child git fails, our message leads and git's
+## stderr follows indented beneath it (in a multi-file patch it's the only
+## source of which file or hunk failed); patch paths in our messages are
+## relative to the patches dir and quoted.
+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 --subject="$work/corpus/dev" --patches="$work/patches" "$base" topic/fancy >/dev/null
+# Corrupt a context line in the diff so git apply itself fails.
+sed -i 's/^ gear 4 turns smoothly$/ gear 4 turns wrongly/' "$work/patches/topic¦fancy/teach-gizmo-to-hum.patch"
+
+rc=0
+"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/patches" >"$work/out" 2>&1 || rc=$?
+EXPECT equal "an unapplicable diff still fails verify" -- "$rc" "1"
+EXPECT success "our message leads, with the patch path relative and quoted" -- \
+	grep -q "^patchette: 'topic¦fancy/teach-gizmo-to-hum.patch': diff does not apply onto its parent$" "$work/out"
+EXPECT success "git's own stderr follows, indented as detail" -- \
+	grep -q "^    error: patch failed" "$work/out"
+ours=$(grep -nm1 '^patchette:' "$work/out" || true)
+detail=$(grep -nm1 '^    error:' "$work/out" || true)
+order=disordered
+if [[ -n $ours && -n $detail && ${ours%%:*} -lt ${detail%%:*} ]]; then order=ordered; fi
+EXPECT equal "and in that order" -- "$order" "ordered"
+
+# Repo paths in messages relativize to where the user invoked us, even though
+# the commands cd around internally.
+git clone -q "$work/corpus/upstream" "$work/fresh"
+git -C "$work/fresh" branch topic/fancy main
+rc=0
+(cd "$work" && "$PATCHETTE" import --subject=fresh --patches=patches) >"$work/out2" 2>&1 || rc=$?
+EXPECT success "repo paths in messages are relative to the invocation dir" -- \
+	grep -q "already exists in 'fresh'" "$work/out2"
+
+exit "$RESULT"
diff --git a/tests/test-failclosed.sh b/tests/test-failclosed.sh
index 31a0164..61071e5 100755
--- a/tests/test-failclosed.sh
+++ b/tests/test-failclosed.sh
@@ -12,7 +12,7 @@ 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
+"$PATCHETTE" export --subject="$work/corpus/dev" --patches="$work/pristine" "$base" topic/fancy docs-polish >/dev/null
 fancy=$'topic\u00a6fancy'
 
 ## Set up a fresh tampering workspace from the pristine export.
@@ -30,73 +30,73 @@ clone_layout() {
 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"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$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"
+	"$PATCHETTE" import --subject="$work/occupied" --patches="$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"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
 
 # An unrecognized bare line in directive position (directly after the
 # opening flank) is an error.
 clone_layout baddirective
 sed -i '0,/^----$/s//----\nmystery-directive/' "$TAMPER/$fancy/note-to-self.patch"
 EXPECT refusal "an unknown directive line is an error" "unrecognized directive" -- \
-	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
 
 # A directive is only legal before message content; after content has begun,
 # the same bare line is just a malformed message line.
 clone_layout latedirective
 sed -i 's/^> Note to self$/> Note to self\nmessage-no-final-newline/' "$TAMPER/$fancy/note-to-self.patch"
 EXPECT refusal "a directive after message content is a parse error" "malformed message line" -- \
-	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
 
 # The directive's pre-spec position (bare line in the header area) must fail
 # loudly, not be swallowed as a verbatim header into the replayed commit.
 clone_layout olddirective
 sed -i '1a message-no-final-newline' "$TAMPER/$fancy/note-to-self.patch"
 EXPECT refusal "a bare line in the header area is a parse error" "malformed header line" -- \
-	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$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"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
 
 # base and tip must be full commit IDs: a ref name or an abbreviation could
 # resolve differently in another repo, or later, so neither is guessed at.
 clone_layout refbase
 sed -i 's/^base .*/base main/' "$TAMPER/$fancy/STACK"
 EXPECT refusal "a ref name as base is an error" "base is not a full commit ID" -- \
-	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
 clone_layout shorttip
 sed -i 's/^tip \(.\{12\}\).*/tip \1/' "$TAMPER/$fancy/STACK"
 EXPECT refusal "an abbreviated tip is an error" "tip is not a full commit ID" -- \
-	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
 clone_layout emptytip
 sed -i 's/^tip .*/tip /' "$TAMPER/$fancy/STACK"
 EXPECT refusal "a tip line with no value is an error, not an absent tip" "tip is not a full commit ID" -- \
-	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
 
 # A repeated STACK key is an error: which value is meant can't be known.
 clone_layout dupkey
 sed -i '1a ref refs/heads/other' "$TAMPER/$fancy/STACK"
 EXPECT refusal "a repeated STACK key is an error" "duplicate ref line" -- \
-	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$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"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
 
 # Folder discordances are errors for every reader: an orphan patch file
 # (nothing would ever read it) and a series entry naming no file must be
@@ -104,25 +104,25 @@ EXPECT refusal "a duplicate series entry is an error" "duplicate series entry" -
 clone_layout orphan
 cp "$TAMPER/$fancy/note-to-self.patch" "$TAMPER/$fancy/stray.patch"
 EXPECT refusal "an orphan patch file is refused by verify" "orphan" -- \
-	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
-git clone -q "$work/corpus/upstream" "$work/orphantarget"
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
+git clone -q "$work/corpus/upstream" "$work/orphanclone"
 EXPECT refusal "an orphan patch file is refused by import" "orphan" -- \
-	"$PATCHETTE" import "$work/orphantarget" "$TAMPER"
+	"$PATCHETTE" import --subject="$work/orphanclone" --patches="$TAMPER"
 EXPECT equal "the refused import created no refs" -- \
-	"$(git -C "$work/orphantarget" for-each-ref --format='%(refname)' 'refs/heads/*' --exclude refs/heads/main)" ""
+	"$(git -C "$work/orphanclone" for-each-ref --format='%(refname)' 'refs/heads/*' --exclude refs/heads/main)" ""
 
 clone_layout missingfile
 rm "$TAMPER/$fancy/note-to-self.patch"
-EXPECT refusal "a series entry naming no file is refused by verify" "note-to-self.patch is missing" -- \
-	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
-EXPECT refusal "a series entry naming no file is refused by import" "note-to-self.patch is missing" -- \
-	"$PATCHETTE" import "$work/orphantarget" "$TAMPER"
+EXPECT refusal "a series entry naming no file is refused by verify" "'note-to-self.patch' is missing" -- \
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
+EXPECT refusal "a series entry naming no file is refused by import" "'note-to-self.patch' is missing" -- \
+	"$PATCHETTE" import --subject="$work/orphanclone" --patches="$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
+	"$PATCHETTE" export --subject="$work/corpus/dev" --patches="$work/notours" "$base" topic/fancy
 EXPECT success "export left the non-patchette folder untouched" -- \
 	test -f "$work/notours/$fancy/precious.txt"
 
@@ -136,18 +136,30 @@ git -C "$work/poisoned" commit -q --allow-empty -m "Out of range"
 git -C "$work/poisoned" branch -f topic/fancy side
 clone_layout atomic
 EXPECT refusal "export dies on an out-of-range parent" "out-of-range" -- \
-	"$PATCHETTE" export "$work/poisoned" "$TAMPER" "$base" topic/fancy docs-polish
+	"$PATCHETTE" export --subject="$work/poisoned" --patches="$TAMPER" "$base" topic/fancy docs-polish
 EXPECT success "the failed re-export left the previous snapshot untouched" -- \
 	diff -r "$work/pristine" "$TAMPER"
 mkdir "$work/freshout"
 EXPECT refusal "export into a fresh dir dies the same way" "out-of-range" -- \
-	"$PATCHETTE" export "$work/poisoned" "$work/freshout" "$base" topic/fancy
+	"$PATCHETTE" export --subject="$work/poisoned" --patches="$work/freshout" "$base" topic/fancy
 EXPECT equal "the failed fresh export left no debris behind" -- \
 	"$(ls -A "$work/freshout")" ""
 
+# A commit walk that fails must fail the export, not read as an empty branch:
+# an empty series swapped in would silently replace a good snapshot.
+# Trigger: a subject missing one mid-branch commit object.
+cp -a "$work/corpus/dev" "$work/holey-dev"
+mid=$(git -C "$work/holey-dev" rev-parse topic/fancy~3)
+rm "$work/holey-dev/.git/objects/${mid:0:2}/${mid:2}"
+clone_layout holey
+EXPECT refusal "export dies when the commit walk fails" "could not list the commits of 'topic/fancy'" -- \
+	"$PATCHETTE" export --subject="$work/holey-dev" --patches="$TAMPER" "$base" topic/fancy
+EXPECT success "the failed walk left the previous snapshot untouched" -- \
+	diff -r "$work/pristine" "$TAMPER"
+
 # 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"
+	"$PATCHETTE" import --subject="$work/empty" --patches="$work/pristine"
 
 exit "$RESULT"
diff --git a/tests/test-force-select.sh b/tests/test-force-select.sh
new file mode 100755
index 0000000..c606471
--- /dev/null
+++ b/tests/test-force-select.sh
@@ -0,0 +1,90 @@
+#!/bin/bash
+## The hand-editing journey: import and verify narrow to named branches,
+## and import -f replays over an existing ref, keeping the old tip in the
+## reflog.  Without -f, an existing ref is still refused.
+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 --subject="$work/corpus/dev" --patches="$work/patches" "$base" topic/fancy docs-polish >/dev/null
+fancy=$'topic\u00a6fancy'
+
+# --- selection ---
+git clone -q "$work/corpus/upstream" "$work/subset"
+"$PATCHETTE" import --subject="$work/subset" --patches="$work/patches" docs-polish >/dev/null
+EXPECT equal "a named-subset import creates only the named refs" -- \
+	"$(git -C "$work/subset" for-each-ref --format='%(refname)' refs/heads/docs-polish refs/heads/topic/fancy)" "refs/heads/docs-polish"
+EXPECT refusal "a selection naming no branch folder is an error" "no branch folder for 'no-such-branch'" -- \
+	"$PATCHETTE" import --subject="$work/subset" --patches="$work/patches" no-such-branch
+
+# Narrowed verify reads only the named folders: tamper the other one and
+# watch selection dodge it while an unnarrowed verify trips on it.
+sed -i 's/> Polish the readme/> Polish the readme harder/' "$work/patches/docs-polish/polish-the-readme.patch"
+EXPECT success "a narrowed verify reads only the named folders" -- \
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/patches" topic/fancy
+EXPECT refusal "while an unnarrowed verify still sees the rest" "MISMATCH" -- \
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/patches"
+
+# Narrowing narrows validation too: a folder that fails validation (here, an
+# orphan patch file) blocks only the commands that include it.
+cp -r "$work/patches" "$work/strayed"
+cp "$work/strayed/docs-polish/polish-the-readme.patch" "$work/strayed/docs-polish/stray.patch"
+EXPECT success "a narrowed verify isn't blocked by another folder's validation failure" -- \
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/strayed" topic/fancy
+EXPECT refusal "while an unnarrowed verify refuses it" "orphan" -- \
+	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/strayed"
+
+# --- import -f ---
+# The docs-polish patch set is now hand-edited; its replay tip differs from
+# the branch the earlier import created.
+EXPECT refusal "an existing ref is refused without -f, which gets mentioned" "-f updates it instead" -- \
+	"$PATCHETTE" import --subject="$work/subset" --patches="$work/patches" docs-polish
+EXPECT equal "the refused ref is untouched" -- \
+	"$(git -C "$work/subset" rev-parse refs/heads/docs-polish)" "$tip_docs_polish"
+
+"$PATCHETTE" import -f --subject="$work/subset" --patches="$work/patches" docs-polish >/dev/null
+newtip=$(git -C "$work/subset" rev-parse refs/heads/docs-polish)
+EXPECT success "import -f updates the ref to the edited replay" -- \
+	test "$newtip" != "$tip_docs_polish"
+EXPECT success "the previous tip remains findable in the reflog" -- \
+	grep -q "$tip_docs_polish" <(git -C "$work/subset" log -g --format=%H refs/heads/docs-polish)
+
+# -f still never moves a branch some checkout has checked out (git branch -f
+# refuses the same): that checkout's index and files would be left behind,
+# reading as a staged revert.  The main checkout and linked worktrees alike.
+git clone -q "$work/corpus/upstream" "$work/busy"
+git -C "$work/busy" switch -qc docs-polish
+EXPECT refusal "import -f refuses a branch checked out in the main checkout" "checked out in" -- \
+	"$PATCHETTE" import -f --subject="$work/busy" --patches="$work/patches" docs-polish
+EXPECT equal "and leaves it where it was" -- \
+	"$(git -C "$work/busy" rev-parse refs/heads/docs-polish)" "$base"
+git -C "$work/busy" branch -q topic/fancy main
+git -C "$work/busy" worktree add -q "$work/busy-wt" topic/fancy
+EXPECT refusal "import -f refuses a branch checked out in a linked worktree" "checked out in" -- \
+	"$PATCHETTE" import -f --subject="$work/busy" --patches="$work/patches" topic/fancy
+EXPECT equal "and leaves it where it was" -- \
+	"$(git -C "$work/busy" rev-parse refs/heads/topic/fancy)" "$base"
+
+# A bare repo has no reflogs by default (core.logAllRefUpdates is off), so
+# this leg is what proves import creates the reflog trail itself rather than
+# inheriting it from non-bare defaults.
+git clone -q --bare "$work/corpus/upstream" "$work/bare.git"
+"$PATCHETTE" import --subject="$work/bare.git" --patches="$work/patches" topic/fancy >/dev/null
+sed -i 's/> Teach gizmo to hum/> Teach gizmo to really hum/' "$work/patches/$fancy/teach-gizmo-to-hum.patch"
+"$PATCHETTE" import -f --subject="$work/bare.git" --patches="$work/patches" topic/fancy >/dev/null
+EXPECT success "even in a bare repo, the previous tip remains in the reflog" -- \
+	grep -q "$tip_topic_fancy" <(git -C "$work/bare.git" log -g --format=%H refs/heads/topic/fancy)
+
+# Forcing a ref to the tip it already has changes nothing, and says so,
+# rather than claiming an update with the same tip as "previous".
+EXPECT success "a no-op import -f reports the ref as unchanged" -- \
+	bash -c '"$@" | grep -q "unchanged"' -- \
+	"$PATCHETTE" import -f --subject="$work/bare.git" --patches="$work/patches" topic/fancy
+
+exit "$RESULT"
diff --git a/tests/test-hermetic.sh b/tests/test-hermetic.sh
index 51835b5..decb7e5 100755
--- a/tests/test-hermetic.sh
+++ b/tests/test-hermetic.sh
@@ -1,6 +1,6 @@
 #!/bin/bash
 ## Hermeticity: hostile repo-local git config and attributes, in the subject
-## or the target repo, or hostile git env inherited from a caller,
+## or the clone being imported into, or hostile git env inherited from a caller,
 ## must not change what patchette writes or replays.
 ##
 ## Nulling global and system config doesn't reach a repo's own .git/config,
@@ -20,7 +20,7 @@ make_corpus "$work/corpus"
 source "$work/corpus/FACTS"
 branches=(topic/fancy docs-polish odd-bytes)
 want_tips=$(printf '%s\n' "$tip_topic_fancy" "$tip_docs_polish" "$tip_odd_bytes")
-"$PATCHETTE" export "$work/corpus/dev" "$work/clean" "$base" "${branches[@]}" >/dev/null
+"$PATCHETTE" export --subject="$work/corpus/dev" --patches="$work/clean" "$base" "${branches[@]}" >/dev/null
 printf 'odd/trailing.txt\n' >"$work/orderfile"
 # Env-case fixtures: a tree holding a hostile .gitattributes (in the subject's
 # object store), a graft that turns docs-polish's tip into a root,
@@ -133,17 +133,17 @@ for c in "${cases[@]}"; do
 	# Command failures here surface on stderr just above the FAIL they cause.
 	cp -a "$work/corpus/dev" "$d/subject"
 	hostilize "$d/subject" "$c"
-	env "${HOSTILE_ENV[@]}" "$PATCHETTE" export "$d/subject" "$d/out" "$base" "${branches[@]}" >/dev/null || true
+	env "${HOSTILE_ENV[@]}" "$PATCHETTE" export --subject="$d/subject" --patches="$d/out" "$base" "${branches[@]}" >/dev/null || true
 	EXPECT success "subject has $c: export bytes match a clean export" -- \
 		diff -r "$work/clean" "$d/out"
 	EXPECT success "subject has $c: verify passes" -- \
-		env "${HOSTILE_ENV[@]}" "$PATCHETTE" verify "$d/subject" "$work/clean"
+		env "${HOSTILE_ENV[@]}" "$PATCHETTE" verify --subject="$d/subject" --patches="$work/clean"
 
-	git clone -q "$work/corpus/upstream" "$d/target"
-	hostilize "$d/target" "$c"
-	env "${HOSTILE_ENV[@]}" "$PATCHETTE" import "$d/target" "$work/clean" >/dev/null || true
-	EXPECT equal "target has $c: import reproduces exact tips" -- \
-		"$(git -C "$d/target" rev-parse "${branches[@]/#/refs/heads/}" 2>/dev/null || true)" "$want_tips"
+	git clone -q "$work/corpus/upstream" "$d/clone"
+	hostilize "$d/clone" "$c"
+	env "${HOSTILE_ENV[@]}" "$PATCHETTE" import --subject="$d/clone" --patches="$work/clean" >/dev/null || true
+	EXPECT equal "import clone has $c: reproduces exact tips" -- \
+		"$(git -C "$d/clone" rev-parse "${branches[@]/#/refs/heads/}" 2>/dev/null || true)" "$want_tips"
 done
 
 exit "$RESULT"
diff --git a/tests/test-optional-tip.sh b/tests/test-optional-tip.sh
index ecbda59..baf3a15 100755
--- a/tests/test-optional-tip.sh
+++ b/tests/test-optional-tip.sh
@@ -12,15 +12,15 @@ trap 'rm -rf "$work"' EXIT
 make_corpus "$work/corpus"
 source "$work/corpus/FACTS"
 
-"$PATCHETTE" export "$work/corpus/dev" "$work/patches" "$base" topic/fancy >/dev/null
+"$PATCHETTE" export --subject="$work/corpus/dev" --patches="$work/patches" "$base" topic/fancy >/dev/null
 sed -i '/^tip /d' "$work/patches/topic¦fancy/STACK"
 
-"$PATCHETTE" verify "$work/corpus/dev" "$work/patches" >"$work/verify.out"
+"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/patches" >"$work/verify.out"
 EXPECT success "verify passes on a tipless STACK, noting it couldn't cross-check" -- \
 	grep -q "records no tip to cross-check" "$work/verify.out"
 
 git clone -q "$work/corpus/upstream" "$work/fresh"
-"$PATCHETTE" import "$work/fresh" "$work/patches" >"$work/import.out"
+"$PATCHETTE" import --subject="$work/fresh" --patches="$work/patches" >"$work/import.out"
 EXPECT equal "import reproduces the branch from a tipless STACK" -- \
 	"$(git -C "$work/fresh" rev-parse refs/heads/topic/fancy)" "$tip_topic_fancy"
 EXPECT equal "import stays silent about tips it cannot compare" -- \
diff --git a/tests/test-pairing.sh b/tests/test-pairing.sh
new file mode 100755
index 0000000..10cf69f
--- /dev/null
+++ b/tests/test-pairing.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+## Pairing: `patchette pair` records a subject repo's patches dir in the
+## common git dir's `patchette` file, letting verbs run flagless from inside
+## any of the repo's worktrees.  Explicit flags win per value; removing the
+## file un-pairs cleanly.
+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"
+dev="$work/corpus/dev"
+
+## Run patchette with a given cwd (pairing resolution is cwd-sensitive).
+##
+## - @param $1 directory to run in
+## - @param $@ patchette args
+patchette_in() {
+	local d=$1
+	shift
+	(cd "$d" && "$PATCHETTE" "$@")
+}
+
+EXPECT refusal "flagless verbs in an unpaired repo refuse helpfully" "not paired" -- \
+	patchette_in "$dev" verify
+
+EXPECT refusal "pair refuses a nonexistent patches dir" "no such directory" -- \
+	patchette_in "$dev" pair ../no-such-dir
+EXPECT success "and creates nothing" -- \
+	test ! -e "$work/corpus/no-such-dir"
+
+"$PATCHETTE" export --subject="$dev" --patches="$work/explicit" "$base" topic/fancy docs-polish >/dev/null
+
+mkdir "$work/corpus/patches"
+patchette_in "$dev" pair ../patches >/dev/null
+EXPECT success "pair writes git-config format readable by git config" -- \
+	test "$(git config --file "$dev/.git/patchette" --get patchette.patches)" = "../patches"
+EXPECT success "flagless pair reports the pairing" -- \
+	bash -c 'cd "$1" && "$2" pair | grep -q "patches dir"' -- "$dev" "$PATCHETTE"
+
+patchette_in "$dev" export "$base" topic/fancy docs-polish >/dev/null
+EXPECT success "flagless export inside the paired repo matches an explicit export byte-for-byte" -- \
+	diff -r "$work/explicit" "$work/corpus/patches"
+
+EXPECT success "flagless verify inside the paired repo passes" -- \
+	patchette_in "$dev" verify
+EXPECT success "flagless verify works from a subdirectory too" -- \
+	patchette_in "$dev/widgets" verify
+
+# Pairing is repo-wide, not per-worktree: it rides the common git dir.
+git -C "$dev" worktree add -q "$work/dev-wt" topic/fancy
+EXPECT success "a linked worktree sees the pairing made in the main worktree" -- \
+	patchette_in "$work/dev-wt" verify
+patchette_in "$work/dev-wt" pair "$work/explicit" >/dev/null
+EXPECT success "pairing from a linked worktree rewrites the one shared pairing" -- \
+	bash -c 'cd "$1" && "$2" pair | grep -q "explicit"' -- "$dev" "$PATCHETTE"
+patchette_in "$dev" pair ../patches >/dev/null
+
+# Explicit flags win, per value: aim --patches at a tampered copy while the
+# subject still comes from the pairing, and the refusal proves what was read.
+cp -r "$work/explicit" "$work/tampered"
+sed -i 's/> The hum is load-bearing./> The hum is decorative./' "$work/tampered/topic¦fancy/teach-gizmo-to-hum.patch"
+EXPECT refusal "an explicit flag wins over the pairing, per value" "MISMATCH" -- \
+	patchette_in "$dev" verify --patches="$work/tampered"
+
+# The recreate journey, flagless: pair a fresh clone, import inside it.
+git clone -q "$work/corpus/upstream" "$work/fresh"
+patchette_in "$work/fresh" pair "$work/explicit" >/dev/null
+patchette_in "$work/fresh" import >/dev/null
+EXPECT equal "flagless import inside a paired fresh clone reproduces tips" -- \
+	"$(git -C "$work/fresh" rev-parse refs/heads/topic/fancy)" "$tip_topic_fancy"
+
+rm "$dev/.git/patchette"
+EXPECT refusal "removing the pairing file un-pairs cleanly" "not paired" -- \
+	patchette_in "$dev" verify
+
+exit "$RESULT"
diff --git a/tests/test-roundtrip.sh b/tests/test-roundtrip.sh
index bd0a503..0244971 100755
--- a/tests/test-roundtrip.sh
+++ b/tests/test-roundtrip.sh
@@ -15,8 +15,8 @@ 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 odd-bytes >/dev/null
-	"$PATCHETTE" export "--sigil=$sigil" "$work/corpus/dev" "$p/again" "$base" topic/fancy docs-polish odd-bytes >/dev/null
+	"$PATCHETTE" export "--sigil=$sigil" --subject="$work/corpus/dev" --patches="$p/patches" "$base" topic/fancy docs-polish odd-bytes >/dev/null
+	"$PATCHETTE" export "--sigil=$sigil" --subject="$work/corpus/dev" --patches="$p/again" "$base" topic/fancy docs-polish odd-bytes >/dev/null
 	EXPECT success "$sigil: export is regeneration-stable (two runs byte-identical)" -- \
 		diff -r "$p/patches" "$p/again"
 
@@ -31,12 +31,12 @@ for sigil in quote tab; do
 		grep -qxF "$want" "$sample"
 
 	EXPECT success "$sigil: verify passes on a fresh export" -- \
-		"$PATCHETTE" verify "$work/corpus/dev" "$p/patches"
+		"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$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
+	"$PATCHETTE" import --subject="$p/fresh" --patches="$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" -- \
diff --git a/tests/test-usage.sh b/tests/test-usage.sh
new file mode 100755
index 0000000..81d87c1
--- /dev/null
+++ b/tests/test-usage.sh
@@ -0,0 +1,121 @@
+#!/bin/bash
+## 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 where `fresh/setting-up/session` leaves off (its commands are run first, quietly); a `fresh/NAME/session` starts from the bare sandbox.
+## `snapshot/session` is a paired session that also shows two of the files it wrote.
+## Shown output has trailing whitespace stripped: it's invisible in the rendered doc, and editors that trim it on save would otherwise break the checks.
+## Run with TESTMARK_REGEN=1 to rewrite the sessions from what patchette does now; review with `git diff`.
+# 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
+
+## session_commands prints a session's commands, one per line: its prompt lines (`~/dir$ command`, or `/dir$ command`), without their prompts.
+## Everything else in a session is output, and skipped.
+##
+## - @stdin a session
+## - @stdout its commands
+session_commands() {
+	local prompt_re='^[~/][^$]*\$ (.*)$' line
+	while IFS= read -r line; do
+		if [[ $line =~ $prompt_re ]]; then printf '%s\n' "${BASH_REMATCH[1]}"; fi
+	done
+}
+
+## run_prelude runs another session's commands in this shell, quietly: the starting point for a session,
+## so its `cd`s and settings carry into the session.
+## 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 session
+## - @stderr on failure only: the failed command, its output, and its exit status
+run_prelude() {
+	local cmd rc capture
+	local -a cmds
+	capture=$(mktemp)
+	mapfile -t cmds < <(session_commands <"$1")
+	for cmd in "${cmds[@]}"; do
+		rc=0
+		eval "$cmd" </dev/null >"$capture" 2>&1 || rc=$?
+		if [[ $rc -ne 0 ]]; then
+			>&2 printf 'prelude command failed, exit status %s: %s\n' "$rc" "$cmd"
+			>&2 cat "$capture"
+			rm -f "$capture"
+			return 1
+		fi
+	done
+	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, and trailing whitespace stripped; a nonzero exit adds an `[exit status: N]` line.
+## 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 cmd rc out sep='' capture
+	local -a cmds
+	capture=$(mktemp)
+	mapfile -t cmds < <(session_commands)
+	for cmd in "${cmds[@]}"; do
+		printf '%s%s$ %s\n' "$sep" "${PWD/#"$HOME"/"~"}" "$cmd"
+		sep=$'\n'
+		rc=0
+		eval "$cmd" </dev/null >"$capture" 2>&1 || rc=$?
+		out=$(sed 's/[[:space:]]*$//' "$capture")
+		[[ -z $out ]] || printf '%s\n' "${out//"$HOME"/"~"}"
+		[[ $rc -eq 0 ]] || printf '[exit status: %s]\n' "$rc"
+	done
+	rm -f "$capture"
+}
+
+## run_in_sandbox runs the case's session in a fresh sandbox home, after another session's commands, 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.
+##
+## - @param $1 optional: the doc hunk of the session to start after
+run_in_sandbox() {
+	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'
+	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 session_commands run_prelude replay_session); if run_prelude \"\$1\"; then replay_session; else cat; exit 1; fi" _ "$prelude" |
+		testcase_output session
+}
+
+exercise_fresh() {
+	run_in_sandbox
+}
+
+exercise_paired() {
+	run_in_sandbox fresh/setting-up/session
+}
+
+## exercise_snapshot runs the first-export session, then shows two of the files it wrote, each as an output of its own.
+exercise_snapshot() {
+	run_in_sandbox fresh/setting-up/session
+	sed 's/[[:space:]]*$//' "$HOME/src/frob-patches/docs-polish/STACK" | testcase_output STACK
+	sed 's/[[:space:]]*$//' "$HOME/src/frob-patches/docs-polish/polish-the-readme.patch" | testcase_output polish-the-readme.patch
+}
+
+testmarktest_provide session_commands run_prelude replay_session run_in_sandbox
+testmarktest_rule 'fresh/*/session' exercise_fresh
+testmarktest_rule 'paired/*/session' exercise_paired
+testmarktest_rule 'snapshot/session' exercise_snapshot
+testmarktest_run ../docs/userguide/USAGE.md