patchette

grammar: repo locations are flags; positionals are revs and branches only

commit 023d3c7674edecf6f931f2a38cbde6759380889d · parent 6f031e7658 · browse this tree
Ximianth, 2026-10-19 15:43:35 UTC

Repos were positional, and the planned paired short form would have
collided with them on the same positions -- inviting parse rules that
depend on what happens to exist on the filesystem.  Uprooted: repo
locations now travel as --subject=<repo> and --patches=<dir> on all
three verbs, so a command line parses the same way everywhere, and the
upcoming pairing can simply be the default for absent flags.

There is also no 'target' anymore: import's destination is the same
subject-repo concept the other verbs have, and now wears the same name
everywhere -- flags, variables, and messages.

Rides along, since it lives in the same argument grammar:

- import and verify accept branch names as positionals: operate on just
  those folders.  Narrowing narrows validation too, so an unrelated
  broken folder doesn't block work on the named ones.  A name matching
  no branch folder is an error.
- import -f updates an existing ref where plain import still refuses
  (the refusal mentions -f).  The update keeps the old-value guard
  (empty = must-not-exist, SHA = compare-and-swap) and writes a reflog
  entry even in bare repos, so the previous tip stays findable.
  Like git branch -f, it still refuses a branch that some checkout has
  checked out, main checkout and linked worktrees alike: moving it would
  leave that checkout's index and files reading as a staged revert.
  (git also refuses a branch mid-rebase or mid-bisect in a worktree;
  worktree list doesn't expose that state, so -f doesn't see it.)
diff --git a/bin/patchette b/bin/patchette
index b77334f..ced4e3a 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
@@ -106,11 +106,12 @@ usage() {
 		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
 	EOF
 }
@@ -308,7 +309,7 @@ replay_series() {
 		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.
 			try "$(q "$f"): diff does not apply onto its parent" \
@@ -330,45 +331,66 @@ replay_series() {
 }
 
 ## 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 "$(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 $(q "$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=} ;;
+		--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")
+	[[ -n $subject && -n $patches && $# -ge 2 ]] ||
+		die "usage: patchette export [--sigil=quote|--sigil=tab] --subject=<repo> --patches=<dir> <base-rev> <branch>..."
+	repo=$(absdir "$subject")
+	mkdir -p "$patches"
+	out=$(absdir "$patches")
 	PATCHES_DIR_ABS=$out
-	baserev=$3
-	shift 3
+	baserev=$1
+	shift
 
 	base=$(git -C "$repo" rev-parse --verify -q "$baserev^{commit}") ||
 		die "base revision does not resolve to a commit: $(q "$baserev")"
@@ -473,27 +495,76 @@ 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")
+	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
+	[[ -n $subject && -n $patches ]] ||
+		die "usage: patchette import [-f] --subject=<repo> --patches=<dir> [<branch>...]"
+	subject=$(absdir "$subject")
+	patches=$(absdir "$patches")
 	PATCHES_DIR_ABS=$patches
-	branch_dirs "$patches"
-	cd "$target"
+	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 "$(q "$STACK_REF") already exists in $(q "$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 $(q "$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.
-		try "could not create $(q "$STACK_REF") (created concurrently?)" \
-			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
@@ -501,12 +572,21 @@ 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")
+	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
+	[[ -n $subject && -n $patches ]] ||
+		die "usage: patchette verify --subject=<repo> --patches=<dir> [<branch>...]"
+	subject=$(absdir "$subject")
+	patches=$(absdir "$patches")
 	PATCHES_DIR_ABS=$patches
-	branch_dirs "$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.
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/tests/lib.sh b/tests/lib.sh
index 6fc8495..32d53ce 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),
diff --git a/tests/test-errors.sh b/tests/test-errors.sh
index 2564e32..0512815 100755
--- a/tests/test-errors.sh
+++ b/tests/test-errors.sh
@@ -13,12 +13,12 @@ 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
 # 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 "$work/corpus/dev" "$work/patches" >"$work/out" 2>&1 || rc=$?
+"$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"
@@ -35,7 +35,7 @@ EXPECT equal "and in that order" -- "$order" "ordered"
 git clone -q "$work/corpus/upstream" "$work/fresh"
 git -C "$work/fresh" branch topic/fancy main
 rc=0
-(cd "$work" && "$PATCHETTE" import fresh patches) >"$work/out2" 2>&1 || rc=$?
+(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"
 
diff --git a/tests/test-failclosed.sh b/tests/test-failclosed.sh
index 6972c63..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"
+	"$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 "$work/orphantarget" "$TAMPER"
+	"$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,12 +136,12 @@ 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")" ""
 
@@ -153,13 +153,13 @@ 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 "$work/holey-dev" "$TAMPER" "$base" 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-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" -- \