patchette

fix: repo-local config and attributes can no longer shape patchette's bytes

commit d6aa76ad393ac848c20860a2ebbba9b8ff237af4 · parent eebc221b28 · browse this tree
Ximianth, 2026-10-18 12:55:52 UTC

The hermetic env nulls global and system config, but a repo's own
.git/config, its info/attributes, and the checked-out .gitattributes
were still read.  Swept 50 hostile setups (tests/test-hermetic.sh);
the damage came in two severities:

- corruption: apply.whitespace=fix (or =error) in an import target
  silently rewrote (or refused) recorded bytes during replay.
  Fixed with an explicit --whitespace=nowarn on git apply, which also
  silences the whitespace warning spam for faithfully preserved bytes.
- churn: diff.suppressBlankEmpty, core.quotePath, and any -diff/binary/
  xfuncname attribute changed export bytes.  Attribute sources include
  the subject's committed .gitattributes as read from the CHECKOUT, so
  export bytes varied with which branch happened to be checked out.
  All drifted exports still replayed SHA-exact (churn, not corruption),
  but phantom diffs in the patches repo are exactly what this tool
  exists to not show.

Export now resolves refs in the subject and generates everything else
in a scratch bare repo borrowing the subject's objects via alternates
(the same mechanism clone --shared rests on, as verify already does);
its null config, empty attribute surfaces, and unborn HEAD leave
nothing for a setting to reach.  Measured cost: ~3ms and ~30 inodes per
export run, in the tempdir we already trap-delete.

Patch bytes now depend only on the commits.  Confirmed by running the
pre-fix tool against the suite: it drifts on 9 of the 50 cases, the
fixed tool on none.
diff --git a/bin/patchette b/bin/patchette
index 1e30a1c..6cfaa2f 100755
--- a/bin/patchette
+++ b/bin/patchette
@@ -218,7 +218,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,19 +265,30 @@ 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
@@ -341,7 +355,7 @@ cmd_export() {
 
 			printf '%s\n' "$slug" >>"$dir/STACK"
 			count=$((count + 1))
-		done < <(git rev-list --reverse --topo-order "$base..refs/heads/$branch")
+		done < <(git rev-list --reverse --topo-order "$base..$tip")
 
 		echo "export: wrote $dir ($count patches)"
 	done
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"