patchette

errors: one helper fronts every fallible child call; repo paths relativize

commit 6f031e76588e173a4167ae4c2ed3c217b5d795a6 · parent efdd15f493 · browse this tree
Ximianth, 2026-10-19 15:40:13 UTC

Dogfooding feedback: error handling must be consistent across every
branch, not polished one reported case at a time.  So:

- try() is the single shape for running fallible child commands: stderr
  captured; on failure our message leads and the child's stderr indents
  beneath it; on success captured warnings still pass through rather
  than being eaten.  Applied to every child invocation that can
  plausibly fail -- replay (read-tree, apply, write-tree, hash-object),
  export (scratch init, cat-file, diff-tree), import (update-ref),
  verify (clone).
- export's commit walk moves out of a process substitution, where a
  failure was invisible: a failed rev-list read as an empty branch,
  and the empty series was swapped in over a good snapshot, exiting 0.
  The walk now goes through try() into a file before the loop reads it;
  the new fail-closed case failed before this change.
  The per-commit lookups after it (subject, parents) stay bare:
  the walk has just read those same objects.
- q() now also relativizes non-patch paths, against the directory the
  user invoked us from (captured up front: the commands cd around
  internally, so $PWD is no anchor at die time).  Paths outside both
  anchors stay absolute.
diff --git a/bin/patchette b/bin/patchette
index 5d8f7d0..b77334f 100755
--- a/bin/patchette
+++ b/bin/patchette
@@ -34,6 +34,8 @@ 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=''
@@ -63,16 +65,38 @@ die_detail() {
 	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 to the patches dir when one is in play;
+## 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:-} ]]; then
+	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"
 }
@@ -281,15 +305,16 @@ replay_series() {
 		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
 			# 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" 2>"$PTMP/apply-err" ||
-				die_detail "$(q "$f"): diff does not apply onto its parent" "$PTMP/apply-err"
+			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"
@@ -297,7 +322,7 @@ 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
@@ -356,7 +381,8 @@ 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"
 
@@ -387,6 +413,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
@@ -409,7 +439,8 @@ cmd_export() {
 			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"
 
 			{
@@ -427,12 +458,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"
@@ -459,7 +491,8 @@ cmd_import() {
 			die "recorded base $STACK_BASE is not present in $(q "$target"); 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" ""
+		try "could not create $(q "$STACK_REF") (created concurrently?)" \
+			git update-ref "$STACK_REF" "$REPLAY_TIP" ""
 		echo "import: created $STACK_REF at $REPLAY_TIP"
 		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?)"
@@ -477,7 +510,8 @@ cmd_verify() {
 
 	# 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"
 
diff --git a/tests/test-errors.sh b/tests/test-errors.sh
index 1e8933b..2564e32 100755
--- a/tests/test-errors.sh
+++ b/tests/test-errors.sh
@@ -30,4 +30,13 @@ 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 fresh 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 8c4b8f8..6972c63 100755
--- a/tests/test-failclosed.sh
+++ b/tests/test-failclosed.sh
@@ -145,6 +145,18 @@ EXPECT refusal "export into a fresh dir dies the same way" "out-of-range" -- \
 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 "$work/holey-dev" "$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" -- \