Merge branch 'mvp-and-test-harness'
commit d1e3ee7b4304df7fd6b1dadd262527e8dd911c0f · parent 7c9fbfe000 · parent f61b01a155
· browse this tree
warpfork, 2026-10-18 12:17:11 UTC
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 diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100644 index 0000000..c91c667 --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,236 @@ +#!/bin/bash +## Shared helpers for the patchette tests. Sourced, not executed. +## Provides a hermetic, fully-pinned git environment (fixed identities and a +## tick clock belong HERE, not in the tool: tests author commits, patchette +## never does), the test corpus builder, and PASS/FAIL reporting helpers. + +set -euo pipefail + +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 +export TZ=UTC + +export GIT_AUTHOR_NAME="Ada Downstream" +export GIT_AUTHOR_EMAIL="ada@downstream.example" +export GIT_COMMITTER_NAME="Ada Downstream" +export GIT_COMMITTER_EMAIL="ada@downstream.example" + +PATCHETTE=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/patchette + +RESULT=0 + +## EXPECT is the single assertion primitive for tests. +## +## A failed expectation prints a FAIL line, marks the script failed, and lets +## the test continue; setup failures, by contrast, abort via set -e. +## (Hence EXPECT, not ASSERT: an ASSERT would abort.) +## +## EXPECT success <desc> -- <cmd>... +## EXPECT refusal <desc> <message-fragment> -- <cmd>... +## EXPECT equal <desc> -- <got> <want> +## +## The `--` is required: it marks where EXPECT's own arguments end, +## and checking its position catches a missing or extra argument at the call site. +## House layout puts the mode and description on one line, ending in `-- \`, +## and the subject on the next, indented: +## +## EXPECT refusal "a duplicate series entry is an error" "duplicate series entry" -- \ +## "$PATCHETTE" verify "$subject" "$patches" +## +## `refusal` passes only when the command exits nonzero AND its output contains +## the fragment: an exit code alone could come from any guard (or any crash), +## and each test should stay tied to its specific refusal. +## Commands run with output captured, shown only on failure. +## They run on the right side of `||`, which disables set -e within them: +## fine for external commands, but a shell function passed in won't stop at its first failure. +## Misuse (unknown mode, misplaced `--`, wrong arity) is a harness bug and exits the script. +## +## - @param $1 mode: success, refusal, or equal +## - @param $2 check description +## - @stdout one PASS or FAIL line +## - @state RESULT -- set to 1 on failure +EXPECT() { + local mode=${1:-} desc=${2:-} want='' out rc=0 + shift 2 || _harness_bug "EXPECT: needs at least <mode> <desc>" + if [[ $mode == refusal ]]; then + [[ $# -ge 1 ]] || _harness_bug "EXPECT refusal '$desc': missing message fragment" + want=$1 + shift + fi + [[ ${1:-} == -- ]] || _harness_bug "EXPECT $mode '$desc': expected -- where its own arguments end, got '${1:-}'" + shift + + case $mode in + success) + [[ $# -ge 1 ]] || _harness_bug "EXPECT success '$desc': no command after --" + out=$("$@" 2>&1) || rc=$? + if [[ $rc -eq 0 ]]; then + _pass "$desc" + else + _fail "$desc: exit $rc; output: $out" + fi + ;; + refusal) + [[ $# -ge 1 ]] || _harness_bug "EXPECT refusal '$desc': no command after --" + out=$("$@" 2>&1) || rc=$? + if [[ $rc -eq 0 ]]; then + _fail "$desc: expected a refusal, but the command succeeded" + elif [[ $out != *"$want"* ]]; then + _fail "$desc: refused, but not with the expected message '$want'; got: $out" + else + _pass "$desc" + fi + ;; + equal) + [[ $# -eq 2 ]] || _harness_bug "EXPECT equal '$desc': needs exactly <got> <want> after --" + if [[ $1 == "$2" ]]; then + _pass "$desc" + else + _fail "$desc: got '$1', want '$2'" + fi + ;; + *) + _harness_bug "EXPECT: unknown mode '$mode'" + ;; + esac +} + +_pass() { + echo "PASS $*" +} + +_fail() { + echo "FAIL $*" + RESULT=1 +} + +_harness_bug() { + >&2 echo "harness bug: $*" + exit 2 +} + +_TICK=1700000000 + +## Advance the fixed clock by one minute, for the next commit. +## +## - @state GIT_AUTHOR_DATE, GIT_COMMITTER_DATE -- set to the new clock value. +tick() { + _TICK=$((_TICK + 60)) + export GIT_AUTHOR_DATE="@${_TICK} +0000" + export GIT_COMMITTER_DATE="@${_TICK} +0000" +} + +## Build the test corpus: an upstream repo, and a dev clone carrying two +## to-be-tracked branches. Every commit is hostile on purpose. +## +## `topic/fancy` exercises: linear commits, a subject-slug collision, a rename, +## a merge (with an in-range side branch), an extra commit header in the +## 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. +## +## - @param $1 output directory; created. Gets `upstream/`, `dev/`, and a +## sourceable `FACTS` file recording the base SHA and branch tips. +make_corpus() ( + local out=$1 base first raw new + mkdir -p "$out" + out=$(cd "$out" && pwd) + + # --- upstream --- + git init -q -b main "$out/upstream" + cd "$out/upstream" + { + printf 'The frobnicator.\n' + for i in 1 2 3 4 5 6 7 8; do printf 'gear %s turns smoothly\n' "$i"; done + } >frobnicator.txt + mkdir widgets + printf 'widget: mk1\nstate: dusty\n' >widgets/widget.txt + printf '# Machinery\n\nUpstream docs.\n' >README.md + git add -A + tick + git commit -q -m "Initial machinery" + printf '\nMore upstream docs.\n' >>README.md + git add -A + tick + git commit -q -m "Document the machinery" + base=$(git rev-parse HEAD) + + # --- dev clone, branch topic/fancy --- + git clone -q "$out/upstream" "$out/dev" + cd "$out/dev" + git switch -qc topic/fancy + + # plain linear change + sed -i 's/gear 3 turns smoothly/gear 3 turns BETTER/' frobnicator.txt + git add -A + tick + git commit -q -m "Fix the frobnicator" + first=$(git rev-parse HEAD) + + # same subject again: forces the slug collision rule to show itself + sed -i 's/gear 5 turns smoothly/gear 5 turns BETTER/' frobnicator.txt + git add -A + tick + git commit -q -m "Fix the frobnicator" + + # rename plus edit + mkdir gizmos + git mv widgets/widget.txt gizmos/widget.txt + sed -i 's/state: dusty/state: polished/' gizmos/widget.txt + git add -A + tick + git commit -q -m "Move widget into place" + + # a side branch forked in-range, then merged: exercises parents-as-slugs + git switch -qc sprocket-work "$first" + printf '# Sprockets\n\nThey spin.\n' >SPROCKETS.md + git add -A + tick + git commit -q -m "Add sprocket docs" + git switch -q topic/fancy + tick + git merge -q --no-ff -m "Merge sprocket work" sprocket-work + git branch -qD sprocket-work + + # extra commit header, jj change-id style; handcrafted via raw object rewrite. + # The body (with its blank line) exercises message framing in the exports. + sed -i 's/gear 7 turns smoothly/gear 7 hums quietly/' frobnicator.txt + git add -A + tick + git commit -q -m "Teach gizmo to hum" -m $'The hum is load-bearing.\nDo not ask why.' + raw=$(mktemp) + git cat-file commit HEAD >"$raw" + new=$(sed '/^committer /a change-id kpqxwvuzyntsmrlokqjihvgzfyexwdcu' "$raw" | + git hash-object -t commit -w --stdin) + rm "$raw" + git update-ref refs/heads/topic/fancy "$new" + + # empty commit + tick + git commit -q --allow-empty -m "Note to self" + + # message without a final newline: porcelain never makes one, but the object + # model allows it, and total fidelity means total + sed -i 's/gear 8 turns smoothly/gear 8 clicks twice/' frobnicator.txt + git add -A + tick + git commit -q -m "Scribble scribble" + new=$(git cat-file commit HEAD | head -c -1 | git hash-object -t commit -w --stdin) + git update-ref refs/heads/topic/fancy "$new" + + # --- second branch, small on purpose --- + git switch -qc docs-polish main + printf '\nDownstream notes: be kind to the machinery.\n' >>README.md + git add -A + tick + git commit -q -m "Polish the readme" + + { + 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)" + } >"$out/FACTS" +) diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..9247a77 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,24 @@ +#!/bin/bash +## Run every tests/test-*.sh script. Each prints PASS/FAIL lines and exits +## nonzero on failure; this driver reports per-script status and fails if any did. +set -uo pipefail +cd "$(dirname "$0")" + +fail=0 +for t in test-*.sh; do + echo "=== $t ===" + if "./$t"; then + echo "ok $t" + else + echo "FAIL $t (exit $?)" + fail=1 + fi +done + +echo +if [[ $fail -eq 0 ]]; then + echo "all test scripts passed" +else + echo "TEST FAILURES" +fi +exit "$fail" diff --git a/tests/test-failclosed.sh b/tests/test-failclosed.sh new file mode 100755 index 0000000..e7a5616 --- /dev/null +++ b/tests/test-failclosed.sh @@ -0,0 +1,78 @@ +#!/bin/bash +## Fail-closed behavior: every tampered or conflicting input is refused, +## loudly, and for its own specific reason (we check the message, not just +## the exit code, so each case stays tied to the guard it exercises). +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" + +"$PATCHETTE" export "$work/corpus/dev" "$work/pristine" "$base" topic/fancy docs-polish >/dev/null +fancy=$'topic\u00a6fancy' + +## Set up a fresh tampering workspace from the pristine export. +## +## - @param $1 case name; becomes the folder name under $work +## - @state TAMPER -- path to the fresh copy +clone_layout() { + TAMPER="$work/$1" + cp -r "$work/pristine" "$TAMPER" +} + +# A tampered patch must make verify fail. Editing message content keeps the +# diff applying cleanly, so the only witness left is the tip comparison -- +# exactly the guard this case is about. +clone_layout tampered +sed -i 's/> The hum is load-bearing./> The hum is decorative./' "$TAMPER/$fancy/teach-gizmo-to-hum.patch" +EXPECT refusal "verify fails on a tampered patch" "MISMATCH" -- \ + "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + +# Import onto an existing branch is refused (before any replay work). +git clone -q "$work/corpus/upstream" "$work/occupied" +git -C "$work/occupied" branch docs-polish main +EXPECT refusal "import onto an existing branch is refused" "already exists" -- \ + "$PATCHETTE" import "$work/occupied" "$work/pristine" + +# A malformed message line is a parse error, never a guess. +clone_layout badmsg +sed -i 's/> Do not ask why./| Do not ask why./' "$TAMPER/$fancy/teach-gizmo-to-hum.patch" +EXPECT refusal "a malformed message line is a parse error" "malformed message line" -- \ + "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + +# An unknown parent reference is an error. +clone_layout badparent +sed -i '1s/.*/parents no-such-slug/' "$TAMPER/$fancy/add-sprocket-docs.patch" +EXPECT refusal "an unknown parent reference is an error" "not BASE or an earlier slug" -- \ + "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + +# A duplicate series entry is an error. +clone_layout dupseries +echo "note-to-self" >>"$TAMPER/$fancy/STACK" +EXPECT refusal "a duplicate series entry is an error" "duplicate series entry" -- \ + "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + +# An orphan patch file (nothing would ever read it) is flagged by verify. +clone_layout orphan +cp "$TAMPER/$fancy/note-to-self.patch" "$TAMPER/$fancy/stray.patch" +EXPECT refusal "an orphan patch file is flagged by verify" "orphan" -- \ + "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + +# Export never deletes a folder it didn't write. +mkdir -p "$work/notours/$fancy" +touch "$work/notours/$fancy/precious.txt" +EXPECT refusal "export into a non-patchette folder is refused" "no STACK file" -- \ + "$PATCHETTE" export "$work/corpus/dev" "$work/notours" "$base" topic/fancy +EXPECT success "export left the non-patchette folder untouched" -- \ + test -f "$work/notours/$fancy/precious.txt" + +# 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" -- \ + "$PATCHETTE" import "$work/empty" "$work/pristine" + +exit "$RESULT" diff --git a/tests/test-roundtrip.sh b/tests/test-roundtrip.sh new file mode 100755 index 0000000..dd1324d --- /dev/null +++ b/tests/test-roundtrip.sh @@ -0,0 +1,49 @@ +#!/bin/bash +## Roundtrip, for both sigil dialects: export is regeneration-stable, +## verify passes, and import into a fresh clone of upstream reproduces the +## exact branch tips, fsck-clean. +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" + +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 + EXPECT success "$sigil: export is regeneration-stable (two runs byte-identical)" -- \ + diff -r "$p/patches" "$p/again" + + # Guard the guard: confirm the flag actually switched the emitted framing, + # or this loop would just test the quote dialect twice. + sample="$p/patches/topic¦fancy/teach-gizmo-to-hum.patch" + case $sigil in + quote) want=$'> The hum is load-bearing.' ;; + tab) want=$'\tThe hum is load-bearing.' ;; + esac + EXPECT success "$sigil: emitted message framing matches the dialect" -- \ + grep -qxF "$want" "$sample" + + EXPECT success "$sigil: verify passes on a fresh export" -- \ + "$PATCHETTE" verify "$work/corpus/dev" "$p/patches" + + # The recreate story: upstream has none of our branches; the patches repo + # alone must bring them back, byte-exact. + git clone -q "$work/corpus/upstream" "$p/fresh" + "$PATCHETTE" import "$p/fresh" "$p/patches" >/dev/null + EXPECT equal "$sigil: import reproduces the exact topic/fancy tip" -- \ + "$(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 success "$sigil: fsck --strict clean on the replayed repo" -- \ + git -C "$p/fresh" fsck --strict +done + +exit "$RESULT"