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