patchette

bin/patchette: export/import/verify, the take-2 patch layout

commit 8b39158c08bc05104adccb65479f59ad30366363 · parent 7c9fbfe000 · browse this tree
Ximianth, 2026-10-18 11:29:25 UTC

Ports the proven format-spike logic into one executable:

- export snapshots branches as STACK + per-commit .patch files
  (parents line, verbatim headers minus tree/parent, flanked framed
  message, patience diff).  Re-export replaces a branch folder, but
  refuses folders without a STACK file: never delete what we didn't write.
- import replays into the target repo's own object store (scratch index,
  no worktree) and creates each ref with the must-not-exist form of
  update-ref, so a failed import leaves no refs created.
- verify replays against a throwaway shared bare clone, compares
  recorded tips, and flags orphan patch files no replay would ever read.

Two message-framing dialects (quote and tab) are emitted by flag;
the parser accepts both unconditionally while the dialect survey runs.
All git invocations are hermetic (null config, no system attrs), but no
identities or dates are pinned: patchette never authors commits;
replayed commits carry their own bytes.
diff --git a/bin/patchette b/bin/patchette
new file mode 100755
index 0000000..1e30a1c
--- /dev/null
+++ b/bin/patchette
@@ -0,0 +1,436 @@
+#!/bin/bash
+## 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>
+##
+## Layout, per branch (folder name = branch name, `/` encoded as `¦` U+00A6):
+##   STACK         `ref`/`base`/`tip` lines, then `series`, then slugs to EOF
+##   <slug>.patch  one file per commit: parents line, headers, framed message, diff
+##
+## The patch file, exactly:
+##   line 1      `parents <ref>...`          (each ref: BASE, or an earlier slug)
+##   line 2      `message-no-final-newline`  (only in that freak case; contains no
+##               space, so it can never collide with a verbatim `key value` header)
+##   next lines  commit headers verbatim, minus tree/parent
+##   blank line
+##   `----`      opening flank of the message block
+##   next lines  message, framed per the sigil dialect (see below)
+##   `----`      closing flank
+##   blank line
+##   rest        diff vs first parent, to EOF (empty for empty commits)
+##
+## Message framing is exactly invertible and never transforms content bytes.
+## Two sigil dialects exist; export emits one (default `quote`), the parser
+## accepts both unconditionally (the `----` flanks keep mixed acceptance
+## unambiguous: a message line reading `----` is always sigil-framed):
+##   quote:  empty line -> `>`,  else `> ` + line
+##   tab:    empty line -> ``,   else TAB + line
+## A message line in any other shape is a parse error, never a guess.
+##
+## Fidelity and fail-closed behavior outrank convenience: parse errors never
+## guess, every refusal says what it refused and why, and a failed import
+## leaves no refs created (stray unreferenced objects are acceptable and gc-able).
+
+set -euo pipefail
+
+# Hermetic git: no user or system config, no system attributes, fixed locale.
+# Deliberately NO author/committer identities or dates: patchette never authors
+# commits; replayed commits carry their own bytes.
+export GIT_CONFIG_GLOBAL=/dev/null
+export GIT_CONFIG_SYSTEM=/dev/null
+export GIT_CONFIG_NOSYSTEM=1
+export GIT_ATTR_NOSYSTEM=1
+export LC_ALL=C.UTF-8
+
+PTMP=$(mktemp -d)
+trap 'rm -rf "$PTMP"' EXIT
+
+## Report a refusal and quit.
+##
+## - @param $@ the message
+## - @stderr the message, prefixed
+die() {
+	>&2 echo "patchette: $*"
+	exit 1
+}
+
+usage() {
+	cat <<-'EOF'
+		patchette: manage branch snapshots as patch files.
+
+		commands:
+		  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>
+		      replay against a throwaway clone; check recorded tips reproduce exactly
+	EOF
+}
+
+## Derive a patch slug from a commit subject.
+##
+## The rule: lowercase, every run of non-alphanumerics becomes one dash,
+## trim dashes at both ends, truncate to 40 chars, never empty.
+## Collision suffixing is the caller's job (it needs series-wide state).
+##
+## - @param $1 commit subject (or branch name; same rule on purpose)
+## - @stdout the slug
+slugify() {
+	local s=${1,,}
+	s=${s//[^a-z0-9]/-}
+	while [[ $s == *--* ]]; do s=${s//--/-}; done
+	s=${s#-}
+	s=${s%-}
+	s=${s:0:40}
+	s=${s%-}
+	[[ -n $s ]] || s="patch"
+	printf '%s' "$s"
+}
+
+## Resolve a directory argument to an absolute path, or die clearly.
+##
+## - @param $1 path
+## - @stdout the absolute path
+absdir() {
+	cd "$1" 2>/dev/null && pwd || die "no such directory: $1"
+}
+
+## Parse a STACK control file.
+##
+## - @param $1 path to the STACK file
+## - @state STACK_REF, STACK_BASE, STACK_TIP, STACK_SERIES[] -- the parsed fields
+parse_stack() {
+	local f=$1 line mode=head
+	declare -A seen=()
+	STACK_REF='' STACK_BASE='' STACK_TIP='' STACK_SERIES=()
+	while IFS= read -r line; do
+		if [[ $mode == series ]]; then
+			[[ $line =~ ^[a-z0-9-]+$ ]] || die "$f: malformed series entry: $line"
+			[[ -z ${seen[$line]:-} ]] || die "$f: duplicate series entry: $line"
+			seen[$line]=1
+			STACK_SERIES+=("$line")
+			continue
+		fi
+		case $line in
+		"ref "*) STACK_REF=${line#ref } ;;
+		"base "*) STACK_BASE=${line#base } ;;
+		"tip "*) STACK_TIP=${line#tip } ;;
+		series) mode=series ;;
+		*) die "$f: unrecognized line: $line" ;;
+		esac
+	done <"$f"
+	[[ -n $STACK_REF && -n $STACK_BASE && -n $STACK_TIP && $mode == series ]] ||
+		die "$f: missing ref, base, tip, or series section"
+	[[ $STACK_REF == refs/heads/* ]] || die "$f: ref is not under refs/heads/: $STACK_REF"
+}
+
+## Replay one branch folder's series into the current repo's object store.
+##
+## Expects parse_stack to have run for this folder, cwd to be a git repo
+## whose object store already holds the recorded base, and GIT_INDEX_FILE
+## to point at a scratch path.
+##
+## - @param $1 branch folder path
+## - @state REPLAY_TIP -- the SHA of the last commit replayed
+replay_series() {
+	local dir=$1 slug f line lineno state pline nofinal tree sha pref
+	local headers parents prefs mline
+	local msgpart="$PTMP/msgpart" diffpart="$PTMP/diffpart"
+	declare -A sha_of=([BASE]="$STACK_BASE")
+	REPLAY_TIP=$STACK_BASE
+
+	for slug in "${STACK_SERIES[@]}"; do
+		f="$dir/$slug.patch"
+		[[ -f $f ]] || die "$dir/STACK: series lists $slug, but $slug.patch is missing"
+
+		# Parse the patch file: a strict little state machine over its prelude.
+		# The diff section is everything after the blank line that follows the
+		# closing flank; we stop reading there and slice it out by line count.
+		pline='' nofinal='' headers=() lineno=0 state=start
+		: >"$msgpart"
+		while IFS= read -r line; do
+			lineno=$((lineno + 1))
+			case $state in
+			start)
+				[[ $line == parents\ * ]] || die "$f: line 1 is not a parents line"
+				pline=$line
+				state=flagmaybe
+				;;
+			flagmaybe)
+				if [[ $line == message-no-final-newline ]]; then
+					nofinal=1
+					state=headers
+					continue
+				fi
+				state=headers
+				;&
+			headers)
+				if [[ -z $line ]]; then
+					state=msgopen
+				else
+					headers+=("$line")
+				fi
+				;;
+			msgopen)
+				[[ $line == '----' ]] || die "$f: expected opening ---- flank, got: $line"
+				state=message
+				;;
+			message)
+				# Both sigil dialects are accepted here, unconditionally.
+				if [[ $line == '----' ]]; then
+					state=postmsg
+				elif [[ $line == '>' || $line == '' ]]; then
+					printf '\n' >>"$msgpart"
+				elif [[ $line == '> '* ]]; then
+					printf '%s\n' "${line:2}" >>"$msgpart"
+				elif [[ $line == $'\t'* ]]; then
+					printf '%s\n' "${line:1}" >>"$msgpart"
+				else
+					die "$f: malformed message line: $line"
+				fi
+				;;
+			postmsg)
+				[[ -z $line ]] || die "$f: expected blank line after closing flank, got: $line"
+				state=diff
+				break
+				;;
+			esac
+		done <"$f"
+		[[ $state == diff ]] || die "$f: truncated (ended in state $state)"
+		[[ ${#headers[@]} -gt 0 ]] || die "$f: no commit headers"
+		tail -n +$((lineno + 1)) "$f" >"$diffpart"
+		if [[ -n $nofinal ]]; then
+			head -c -1 "$msgpart" >"$msgpart.t" && mv "$msgpart.t" "$msgpart"
+		fi
+
+		read -ra prefs <<<"${pline#parents }"
+		[[ ${#prefs[@]} -gt 0 ]] || die "$f: parents line lists no parents"
+		parents=()
+		for pref in "${prefs[@]}"; do
+			[[ -n ${sha_of[$pref]:-} ]] || die "$f: parent '$pref' is not BASE or an earlier slug in the series"
+			parents+=("${sha_of[$pref]}")
+		done
+
+		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"
+		fi
+		tree=$(git write-tree)
+
+		sha=$({
+			printf 'tree %s\n' "$tree"
+			printf 'parent %s\n' "${parents[@]}"
+			printf '%s\n' "${headers[@]}"
+			printf '\n'
+			cat "$msgpart"
+		} | git hash-object -t commit -w --stdin)
+
+		sha_of[$slug]=$sha
+		REPLAY_TIP=$sha
+	done
+}
+
+## List the branch folders of a patches dir, failing closed on strangers.
+##
+## - @param $1 patches dir
+## - @state BRANCH_DIRS[] -- absolute paths, each guaranteed to hold a STACK
+branch_dirs() {
+	local patches=$1 dir
+	BRANCH_DIRS=()
+	for dir in "$patches"/*/; do
+		dir=${dir%/}
+		[[ -d $dir ]] || continue # unmatched glob
+		[[ -f $dir/STACK ]] || die "$dir has no STACK file; not a patchette branch folder"
+		BRANCH_DIRS+=("$dir")
+	done
+	[[ ${#BRANCH_DIRS[@]} -gt 0 ]] || die "no branch folders found in $patches"
+}
+
+cmd_export() {
+	local sigil=quote
+	while [[ $# -gt 0 && $1 == --* ]]; do
+		case $1 in
+		--sigil=quote | --sigil=tab) sigil=${1#--sigil=} ;;
+		*) die "unknown flag: $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 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}") ||
+		die "base revision does not resolve to a commit: $baserev"
+
+	for branch in "$@"; do
+		tip=$(git 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"
+
+		{
+			printf 'ref refs/heads/%s\n' "$branch"
+			printf 'base %s\n' "$base"
+			printf 'tip %s\n' "$tip"
+			printf 'series\n'
+		} >"$dir/STACK"
+
+		declare -A slug_of=() # commit sha -> assigned slug
+		declare -A taken=()   # slug -> 1
+		count=0
+
+		while IFS= read -r sha; do
+			slug=$(slugify "$(git log -1 --format=%s "$sha")")
+			if [[ -n ${taken[$slug]:-} ]]; then
+				n=2
+				while [[ -n ${taken["$slug-$n"]:-} ]]; do n=$((n + 1)); done
+				slug="$slug-$n"
+			fi
+			taken[$slug]=1
+			slug_of[$sha]=$slug
+
+			parents=()
+			for p in $(git rev-parse "$sha^@"); do
+				if [[ $p == "$base" ]]; then
+					parents+=("BASE")
+				elif [[ -n ${slug_of[$p]:-} ]]; then
+					parents+=("${slug_of[$p]}")
+				else
+					die "commit $sha has parent $p outside $baserev..$branch; out-of-range parents are not supported"
+				fi
+			done
+			[[ ${#parents[@]} -gt 0 ]] || die "commit $sha has no parents (root commit in range); not supported"
+
+			git cat-file commit "$sha" >"$raw"
+			sed -n '/^$/,$p' "$raw" | tail -n +2 >"$msg"
+
+			{
+				printf 'parents %s\n' "${parents[*]}"
+				if [[ -s $msg && $(tail -c 1 "$msg") != '' ]]; then
+					printf 'message-no-final-newline\n'
+				fi
+				sed -n '1,/^$/p' "$raw" | grep -vE '^(tree|parent) ' | sed '/^$/d'
+				printf '\n----\n'
+				while IFS= read -r mline || [[ -n $mline ]]; do
+					if [[ -z $mline ]]; then
+						if [[ $sigil == quote ]]; then printf '>\n'; else printf '\n'; fi
+					else
+						if [[ $sigil == quote ]]; then printf '> %s\n' "$mline"; else printf '\t%s\n' "$mline"; fi
+					fi
+				done <"$msg"
+				printf -- '----\n\n'
+				git diff-tree -r -p --full-index --binary --no-renames --diff-algorithm=patience "$sha^" "$sha"
+			} >"$dir/$slug.patch"
+
+			printf '%s\n' "$slug" >>"$dir/STACK"
+			count=$((count + 1))
+		done < <(git rev-list --reverse --topo-order "$base..refs/heads/$branch")
+
+		echo "export: wrote $dir ($count patches)"
+	done
+}
+
+cmd_import() {
+	[[ $# -eq 2 ]] || die "usage: patchette import <target-repo> <patches-dir>"
+	local target patches dir
+	target=$(absdir "$1")
+	patches=$(absdir "$2")
+	branch_dirs "$patches"
+	cd "$target"
+	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 "$STACK_REF already exists in $target; refusing to touch it"
+		git cat-file -e "$STACK_BASE^{commit}" 2>/dev/null ||
+			die "recorded base $STACK_BASE is not present in $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" ""
+		echo "import: created $STACK_REF at $REPLAY_TIP"
+		if [[ $REPLAY_TIP != "$STACK_TIP" ]]; then
+			echo "import: note: replayed tip differs from recorded tip $STACK_TIP (patches edited since the snapshot?)"
+		fi
+	done
+}
+
+cmd_verify() {
+	[[ $# -eq 2 ]] || die "usage: patchette verify <subject-repo> <patches-dir>"
+	local subject patches dir f slug bad=0
+	declare -A listed
+	subject=$(absdir "$1")
+	patches=$(absdir "$2")
+	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.
+	git clone -q --shared --bare "$subject" "$PTMP/verify.git"
+	cd "$PTMP/verify.git"
+	export GIT_INDEX_FILE="$PTMP/scratch-index"
+
+	for dir in "${BRANCH_DIRS[@]}"; do
+		parse_stack "$dir/STACK"
+		git cat-file -e "$STACK_BASE^{commit}" 2>/dev/null ||
+			die "recorded base $STACK_BASE is not present in $subject"
+		replay_series "$dir"
+		if [[ $REPLAY_TIP == "$STACK_TIP" ]]; then
+			echo "verify: ok: $STACK_REF reproduces recorded tip $STACK_TIP"
+		else
+			echo "verify: MISMATCH: $STACK_REF replays to $REPLAY_TIP, but records tip $STACK_TIP"
+			echo "verify:   either the snapshot was unfaithful, or the patches were edited since (patches repo is ahead of its snapshot)"
+			bad=1
+		fi
+
+		# Orphan patch files are silently inert at replay; nothing else would
+		# ever read them, so flagging them is specifically verify's job.
+		listed=()
+		for slug in "${STACK_SERIES[@]}"; do listed[$slug]=1; done
+		for f in "$dir"/*.patch; do
+			[[ -f $f ]] || continue # unmatched glob
+			slug=${f##*/}
+			slug=${slug%.patch}
+			if [[ -z ${listed[$slug]:-} ]]; then
+				echo "verify: orphan: $f is not listed in STACK"
+				bad=1
+			fi
+		done
+	done
+
+	[[ $bad -eq 0 ]] || exit 1
+}
+
+case ${1:-} in
+export | import | verify)
+	cmd=$1
+	shift
+	"cmd_$cmd" "$@"
+	;;
+-h | --help | help)
+	usage
+	;;
+'')
+	usage
+	exit 1
+	;;
+*)
+	die "unknown command: $1 (try: patchette help)"
+	;;
+esac