patchette

Merge branch 'local-config-and-atomic-export'

commit 2169820719bc6e15ec02e5578d21c3c407127495 · parent d1e3ee7b43 · parent 8df2a82a3a · browse this tree
Ximianth, 2026-10-18 13:19:36 UTC

diff --git a/bin/patchette b/bin/patchette
index 1e30a1c..a2df97b 100755
--- a/bin/patchette
+++ b/bin/patchette
@@ -46,7 +46,10 @@ export GIT_ATTR_NOSYSTEM=1
 export LC_ALL=C.UTF-8
 
 PTMP=$(mktemp -d)
-trap 'rm -rf "$PTMP"' EXIT
+# 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=''
+trap 'rm -rf "$PTMP" ${EXPORT_STAGE:+"$EXPORT_STAGE"}' EXIT
 
 ## Report a refusal and quit.
 ##
@@ -218,7 +221,10 @@ replay_series() {
 		rm -f "$GIT_INDEX_FILE"
 		git read-tree "${parents[0]}"
 		if [[ -s $diffpart ]]; then # empty diff = empty commit; apply must be skipped, not fed nothing
-			git apply --cached --binary "$diffpart" || die "$f: diff does not apply onto its parent"
+			# --whitespace=nowarn: the target 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"
 		fi
 		tree=$(git write-tree)
 
@@ -262,35 +268,50 @@ cmd_export() {
 	done
 	[[ $# -ge 4 ]] || die "usage: patchette export [--sigil=quote|--sigil=tab] <subject-repo> <patches-dir> <base-rev> <branch>..."
 	local repo out baserev base branch dir slug sha tip n p mline count
-	local parents raw="$PTMP/raw" msg="$PTMP/msg"
+	local parents scratch raw="$PTMP/raw" msg="$PTMP/msg"
 	repo=$(absdir "$1")
 	mkdir -p "$2"
 	out=$(absdir "$2")
 	baserev=$3
 	shift 3
-	cd "$repo"
 
-	base=$(git rev-parse --verify -q "$baserev^{commit}") ||
+	base=$(git -C "$repo" rev-parse --verify -q "$baserev^{commit}") ||
 		die "base revision does not resolve to a commit: $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.
+	# The null-config env can't reach the subject's own .git/config, its
+	# info/attributes, or the checked-out .gitattributes (which would otherwise
+	# make export bytes vary with the checkout); a scratch repo is out of reach
+	# 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"
+	git -C "$repo" rev-parse --path-format=absolute --git-path objects >"$scratch/objects/info/alternates"
+	cd "$scratch"
+
 	for branch in "$@"; do
-		tip=$(git rev-parse --verify -q "refs/heads/$branch") ||
+		tip=$(git -C "$repo" rev-parse --verify -q "refs/heads/$branch") ||
 			die "no such branch: $branch"
 		dir="$out/${branch//\//$'\u00a6'}"
 		if [[ -e $dir ]]; then
 			# Re-exporting a tracked branch replaces its folder; that's the
 			# normal snapshot flow.  But never delete things we didn't write.
 			[[ -f $dir/STACK ]] || die "$dir exists but has no STACK file; not a patchette branch folder, refusing to replace it"
-			rm -rf "$dir"
 		fi
-		mkdir "$dir"
+		# Build in a stage folder beside the destination, and swap in only a
+		# finished snapshot: a death mid-export must leave the previous
+		# snapshot untouched, and no half-written folder posing as a real one.
+		# (Ref components never start with a dot, so the name can't collide.)
+		EXPORT_STAGE="$out/.stage.$$"
+		mkdir "$EXPORT_STAGE"
 
 		{
 			printf 'ref refs/heads/%s\n' "$branch"
 			printf 'base %s\n' "$base"
 			printf 'tip %s\n' "$tip"
 			printf 'series\n'
-		} >"$dir/STACK"
+		} >"$EXPORT_STAGE/STACK"
 
 		declare -A slug_of=() # commit sha -> assigned slug
 		declare -A taken=()   # slug -> 1
@@ -337,12 +358,15 @@ cmd_export() {
 				done <"$msg"
 				printf -- '----\n\n'
 				git diff-tree -r -p --full-index --binary --no-renames --diff-algorithm=patience "$sha^" "$sha"
-			} >"$dir/$slug.patch"
+			} >"$EXPORT_STAGE/$slug.patch"
 
-			printf '%s\n' "$slug" >>"$dir/STACK"
+			printf '%s\n' "$slug" >>"$EXPORT_STAGE/STACK"
 			count=$((count + 1))
-		done < <(git rev-list --reverse --topo-order "$base..refs/heads/$branch")
+		done < <(git rev-list --reverse --topo-order "$base..$tip")
 
+		rm -rf "$dir"
+		mv "$EXPORT_STAGE" "$dir"
+		EXPORT_STAGE=''
 		echo "export: wrote $dir ($count patches)"
 	done
 }
diff --git a/tests/lib.sh b/tests/lib.sh
index c91c667..6fc8495 100644
--- a/tests/lib.sh
+++ b/tests/lib.sh
@@ -131,6 +131,7 @@ tick() {
 ## 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.
+## `odd-bytes` carries content that git config or attributes could perturb.
 ##
 ## - @param $1 output directory; created.  Gets `upstream/`, `dev/`, and a
 ##   sourceable `FACTS` file recording the base SHA and branch tips.
@@ -228,9 +229,41 @@ make_corpus() (
 	tick
 	git commit -q -m "Polish the readme"
 
+	# --- third branch: content that git config or attributes could bite on ---
+	# Trailing whitespace, CRLF, binary, a non-ASCII path with a space, a
+	# symlink, a mode change, and C code whose edit makes nearby hunks with
+	# function-name hints and blank context lines.  The second commit is the
+	# one that matters: creations have no context to perturb.
+	git switch -qc odd-bytes main
+	mkdir odd
+	printf 'clean line\ntrailing   \nclean again\n' >odd/trailing.txt
+	printf 'one\r\ntwo\r\n' >odd/crlf.txt
+	printf 'bin\0ary\0\1\2' >odd/blob.bin
+	printf 'na\xc3\xafve\n' >"odd/naïve file.txt"
+	printf '#!/bin/sh\necho hi\n' >odd/run.sh
+	ln -s trailing.txt odd/link
+	{
+		printf '#include <stdio.h>\n\nint gear(int n)\n{\n\tint x = n;\n\n\tx = x * 2;\n\treturn x;\n}\n'
+		for i in 1 2 3 4 5 6 7 8; do printf '/* filler %s */\n' "$i"; done
+		printf 'int hum(void)\n{\n\tint y = 1;\n\n\ty = y + 1;\n\n\treturn y;\n}\n'
+	} >odd/tool.c
+	git add -A
+	tick
+	git commit -q -m "Add odd bytes"
+	sed -i 's/x = x \* 2;/x = x * 3;/; s/y = y + 1;/y = y + 2;/' odd/tool.c
+	printf 'more trailing \t\n' >>odd/trailing.txt
+	printf 'three\r\n' >>odd/crlf.txt
+	printf '\3\0' >>odd/blob.bin
+	printf 'still na\xc3\xafve\n' >>"odd/naïve file.txt"
+	chmod +x odd/run.sh
+	git add -A
+	tick
+	git commit -q -m "Stir odd bytes"
+
 	{
 		printf 'base=%s\n' "$base"
 		printf 'tip_topic_fancy=%s\n' "$(git rev-parse topic/fancy)"
 		printf 'tip_docs_polish=%s\n' "$(git rev-parse docs-polish)"
+		printf 'tip_odd_bytes=%s\n' "$(git rev-parse odd-bytes)"
 	} >"$out/FACTS"
 )
diff --git a/tests/test-failclosed.sh b/tests/test-failclosed.sh
index e7a5616..ed27b1a 100755
--- a/tests/test-failclosed.sh
+++ b/tests/test-failclosed.sh
@@ -70,6 +70,25 @@ EXPECT refusal "export into a non-patchette folder is refused" "no STACK file" -
 EXPECT success "export left the non-patchette folder untouched" -- \
 	test -f "$work/notours/$fancy/precious.txt"
 
+# A failed export must leave the patches dir as it found it: the previous
+# snapshot intact, and no half-written folder that could pass for a real one.
+# Trigger: rewrite topic/fancy onto a commit forked from BEFORE the base,
+# so its parent is out of range and export dies mid-branch.
+cp -a "$work/corpus/dev" "$work/poisoned"
+git -C "$work/poisoned" switch -qc side "$base^"
+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
+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
+EXPECT equal "the failed fresh export left no debris behind" -- \
+	"$(ls -A "$work/freshout")" ""
+
 # 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" -- \
diff --git a/tests/test-hermetic.sh b/tests/test-hermetic.sh
new file mode 100755
index 0000000..f902f07
--- /dev/null
+++ b/tests/test-hermetic.sh
@@ -0,0 +1,126 @@
+#!/bin/bash
+## Hermeticity: hostile repo-local git config and attributes, in the subject
+## or the target repo, must not change what patchette writes or replays.
+##
+## Nulling global and system config doesn't reach a repo's own .git/config,
+## its info/attributes, or a worktree .gitattributes; git reads all of those.
+## Each case plants one hostile setup into a copy of a repo, then checks:
+## export bytes match a clean export, verify passes, and import reproduces exact tips.
+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"
+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
+printf 'odd/trailing.txt\n' >"$work/orderfile"
+
+# Each case is `|`-separated items:
+# `key=value` sets repo-local config; `@line` appends to the repo's info/attributes;
+# `%line` appends to an uncommitted worktree .gitattributes;
+# `!line` commits a .gitattributes on a new branch and checks it out
+# (export bytes must not vary with which branch happens to be checked out).
+cases=(
+	diff.context=1
+	diff.context=8
+	diff.interHunkContext=10
+	diff.algorithm=histogram
+	diff.algorithm=minimal
+	diff.indentHeuristic=false
+	diff.renames=copies
+	diff.noprefix=true
+	diff.mnemonicPrefix=true
+	'diff.srcPrefix=old/|diff.dstPrefix=new/'
+	diff.relative=true
+	diff.suppressBlankEmpty=true
+	"diff.orderFile=$work/orderfile"
+	diff.wsErrorHighlight=all
+	color.ui=always
+	color.diff=always
+	core.abbrev=12
+	core.quotePath=false
+	core.bigFileThreshold=1
+	core.autocrlf=true
+	core.autocrlf=input
+	core.eol=crlf
+	'core.autocrlf=true|core.safecrlf=true'
+	core.fileMode=false
+	core.symlinks=false
+	core.ignoreCase=true
+	core.precomposeUnicode=true
+	core.whitespace=trailing-space,space-before-tab,cr-at-eol,blank-at-eof
+	core.splitIndex=true
+	index.version=4
+	apply.whitespace=fix
+	apply.whitespace=error
+	apply.ignoreWhitespace=change
+	i18n.logOutputEncoding=ISO-8859-1
+	log.showSignature=true
+	format.pretty=oneline
+	'@* -diff'
+	'@* binary'
+	'@*.c diff=cpp'
+	'@* diff=hostile|diff.hostile.xfuncname=^/\*.*$'
+	'@* diff=hostile|diff.hostile.textconv=tr a-z A-Z'
+	'@* diff=hostile|diff.hostile.binary=true'
+	'@* filter=hostile|filter.hostile.clean=tr a-z A-Z|filter.hostile.smudge=cat'
+	'@* text eol=crlf'
+	'@* text=auto'
+	'@* whitespace=trailing-space,cr-at-eol'
+	'%*.c diff=cpp'
+	'%* -diff'
+	'!* -diff'
+	'!*.c diff=cpp'
+)
+
+## Plant one hostile case into a repo.
+##
+## - @param $1 repo path (non-bare)
+## - @param $2 case string, as described above the table
+hostilize() {
+	local repo=$1 item
+	local -a items
+	IFS='|' read -ra items <<<"$2"
+	for item in "${items[@]}"; do
+		case $item in
+		@*) printf '%s\n' "${item#@}" >>"$repo/.git/info/attributes" ;;
+		%*) printf '%s\n' "${item#%}" >>"$repo/.gitattributes" ;;
+		'!'*)
+			printf '%s\n' "${item#!}" >>"$repo/.gitattributes"
+			git -C "$repo" switch -qc hostile-attrs
+			git -C "$repo" add .gitattributes
+			git -C "$repo" commit -qm "hostile attributes"
+			;;
+		*) git -C "$repo" config "${item%%=*}" "${item#*=}" ;;
+		esac
+	done
+}
+
+n=0
+for c in "${cases[@]}"; do
+	n=$((n + 1))
+	d="$work/case$n"
+	mkdir "$d"
+
+	# Command failures here surface on stderr just above the FAIL they cause.
+	cp -a "$work/corpus/dev" "$d/subject"
+	hostilize "$d/subject" "$c"
+	"$PATCHETTE" export "$d/subject" "$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" -- \
+		"$PATCHETTE" verify "$d/subject" "$work/clean"
+
+	git clone -q "$work/corpus/upstream" "$d/target"
+	hostilize "$d/target" "$c"
+	"$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"
+done
+
+exit "$RESULT"
diff --git a/tests/test-roundtrip.sh b/tests/test-roundtrip.sh
index dd1324d..bd0a503 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 >/dev/null
-	"$PATCHETTE" export "--sigil=$sigil" "$work/corpus/dev" "$p/again" "$base" topic/fancy docs-polish >/dev/null
+	"$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
 	EXPECT success "$sigil: export is regeneration-stable (two runs byte-identical)" -- \
 		diff -r "$p/patches" "$p/again"
 
@@ -41,6 +41,8 @@ for sigil in quote tab; do
 		"$(git -C "$p/fresh" rev-parse refs/heads/topic/fancy)" "$tip_topic_fancy"
 	EXPECT equal "$sigil: import reproduces the exact docs-polish tip" -- \
 		"$(git -C "$p/fresh" rev-parse refs/heads/docs-polish)" "$tip_docs_polish"
+	EXPECT equal "$sigil: import reproduces the exact odd-bytes tip" -- \
+		"$(git -C "$p/fresh" rev-parse refs/heads/odd-bytes)" "$tip_odd_bytes"
 
 	EXPECT success "$sigil: fsck --strict clean on the replayed repo" -- \
 		git -C "$p/fresh" fsck --strict