patchette

lib.sh

#!/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.
## `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.
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"

	# --- 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"
)

tests/lib.sh · 8.8 KiB · 6f031e7658