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