patchette

patchette

#!/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 [-f] --subject=<repo> --patches=<dir> [<branch>...]
##   patchette verify --subject=<repo> --patches=<dir> [<branch>...]
##
## The on-disk format is specified in docs/hacking/FORMAT.md, and the replay
## algorithm in docs/hacking/REPLAYING.md; those documents are the single
## source of truth, and this tool conforms to them.
##
## Two message-framing sigil dialects exist; export emits one (default `quote`),
## and the parser accepts both unconditionally.
##
## 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.
# Nor any git env inherited from a caller (hooks, aliases, `git -c`):
# git's own list of repo-local vars covers redirecting which repo we read and
# write, injected config, and grafts; the other two would perturb export bytes.
git_local_env=$(git rev-parse --local-env-vars)
unset $git_local_env GIT_ATTR_SOURCE GIT_DIFF_OPTS
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)
# 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=''
# PATCHES_DIR_ABS makes patch-file paths in messages display relative (see q()).
PATCHES_DIR_ABS=''
trap 'rm -rf "$PTMP" ${EXPORT_STAGE:+"$EXPORT_STAGE"}' EXIT

## Report a refusal and quit.
##
## - @param $@ the message
## - @stderr the message, prefixed
die() {
	>&2 echo "patchette: $*"
	exit 1
}

## Report a refusal with captured detail beneath it, and quit.
## Our message leads; the detail (e.g. child-git stderr, which in a multi-file
## patch is the only source of which file or hunk failed) follows, indented.
##
## - @param $1 the message
## - @param $2 file holding the detail lines
## - @stderr the message, then the detail indented
die_detail() {
	>&2 echo "patchette: $1"
	>&2 sed 's/^/    /' "$2"
	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 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:-} && $s == "$PATCHES_DIR_ABS"/* ]]; then
		s=${s#"$PATCHES_DIR_ABS"/}
	elif [[ $s == "$START_PWD"/* ]]; then
		s=${s#"$START_PWD"/}
	fi
	printf "'%s'" "$s"
}

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 [-f] --subject=<repo> --patches=<dir> [<branch>...]
		      replay branch folders (default all); create each branch ref,
		      refusing existing ones unless -f, which updates them (reflogged)
		  verify --subject=<repo> --patches=<dir> [<branch>...]
		      replay against a throwaway clone; check recorded tips reproduce exactly
		  pair [<patches-dir>]
		      run inside a subject repo: remember its patches dir (or report it);
		      paired repos may omit --subject/--patches above
	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: $(q "$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 key
	local oid='^[0-9a-f]{40}([0-9a-f]{24})?$' # SHA-1 or SHA-256, full and lowercase
	declare -A seen=() keys_seen=()
	STACK_REF='' STACK_BASE='' STACK_TIP='' STACK_SERIES=()
	while IFS= read -r line; do
		if [[ $mode == series ]]; then
			[[ $line =~ ^[a-z0-9-]+$ ]] || die "$(q "$f"): malformed series entry: $(q "$line")"
			[[ -z ${seen[$line]:-} ]] || die "$(q "$f"): duplicate series entry: $(q "$line")"
			seen[$line]=1
			STACK_SERIES+=("$line")
			continue
		fi
		key=${line%% *}
		[[ -z ${keys_seen[$key]:-} ]] || die "$(q "$f"): duplicate $key line"
		keys_seen[$key]=1
		case $line in
		"ref "*) STACK_REF=${line#ref } ;;
		"base "*)
			STACK_BASE=${line#base }
			[[ $STACK_BASE =~ $oid ]] || die "$(q "$f"): base is not a full commit ID: $(q "$STACK_BASE")"
			;;
		"tip "*)
			STACK_TIP=${line#tip }
			[[ $STACK_TIP =~ $oid ]] || die "$(q "$f"): tip is not a full commit ID: $(q "$STACK_TIP")"
			;;
		series) mode=series ;;
		*) die "$(q "$f"): unrecognized line: $(q "$line")" ;;
		esac
	done <"$f"
	# tip is optional: a hand-made STACK has no expectable value for it
	# until the branch has been built.
	[[ -n $STACK_REF && -n $STACK_BASE && $mode == series ]] ||
		die "$(q "$f"): missing ref, base, or series section"
	[[ $STACK_REF == refs/heads/* ]] || die "$(q "$f"): ref is not under refs/heads/: $(q "$STACK_REF")"
}

## Check folder-level concordance between a STACK's series and the *.patch
## files beside it.  Any discordance is an error for every reader:
## a series entry naming no file would fail replay anyway, and an orphan
## patch file is silently inert -- nothing would ever read it.
## (Duplicate series entries are caught earlier, in parse_stack.)
##
## Expects parse_stack to have run for this folder.
##
## - @param $1 branch folder path
validate_branch_dir() {
	local dir=$1 slug f
	declare -A listed=()
	for slug in "${STACK_SERIES[@]}"; do
		listed[$slug]=1
		[[ -f $dir/$slug.patch ]] || die "$(q "$dir/STACK"): series lists $(q "$slug"), but $(q "$slug.patch") is missing"
	done
	for f in "$dir"/*.patch; do
		[[ -f $f ]] || continue # unmatched glob
		slug=${f##*/}
		slug=${slug%.patch}
		[[ -n ${listed[$slug]:-} ]] || die "$(q "$f"): orphan patch file, not listed in the STACK series"
	done
}

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

		# 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 "$(q "$f"): line 1 is not a parents line"
				pline=$line
				state=headers
				;;
			headers)
				if [[ -z $line ]]; then
					state=msgopen
				elif [[ $line != *' '* ]]; then
					# Headers are `key value`, and continuation lines begin with
					# a space, so every header line contains one.
					die "$(q "$f"): malformed header line (no space): $(q "$line")"
				else
					headers+=("$line")
				fi
				;;
			msgopen)
				[[ $line == '----' ]] || die "$(q "$f"): expected opening ---- flank, got: $(q "$line")"
				state=directives
				;;
			directives)
				# Bare directive lines may sit directly after the opening flank,
				# before any message content.  Anything unrecognized and equally
				# bare in this position is an error; anything message-shaped
				# means the content has begun.
				if [[ $line == message-no-final-newline ]]; then
					nofinal=1
					continue
				fi
				if [[ -n $line && $line != '----' && $line != '>' && $line != '> '* && $line != $'\t'* ]]; then
					die "$(q "$f"): unrecognized directive line: $(q "$line")"
				fi
				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 "$(q "$f"): malformed message line: $(q "$line")"
				fi
				;;
			postmsg)
				[[ -z $line ]] || die "$(q "$f"): expected blank line after closing flank, got: $(q "$line")"
				state=diff
				break
				;;
			esac
		done <"$f"
		[[ $state == diff ]] || die "$(q "$f"): truncated (ended in state $state)"
		[[ ${#headers[@]} -gt 0 ]] || die "$(q "$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 "$(q "$f"): parents line lists no parents"
		parents=()
		for pref in "${prefs[@]}"; do
			[[ -n ${sha_of[$pref]:-} ]] || die "$(q "$f"): parent $(q "$pref") is not BASE or an earlier slug in the series"
			parents+=("${sha_of[$pref]}")
		done

		rm -f "$GIT_INDEX_FILE"
		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 subject 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.
			try "$(q "$f"): diff does not apply onto its parent" \
				git apply --cached --binary --whitespace=nowarn "$diffpart"
		fi
		tree=$(try "$(q "$f"): could not write the replayed tree" git write-tree)

		sha=$({
			printf 'tree %s\n' "$tree"
			printf 'parent %s\n' "${parents[@]}"
			printf '%s\n' "${headers[@]}"
			printf '\n'
			cat "$msgpart"
		} | try "$(q "$f"): could not store the replayed commit" git hash-object -t commit -w --stdin)

		sha_of[$slug]=$sha
		REPLAY_TIP=$sha
	done
}

## Locate the pairing anchor of the repo containing the cwd: the common git
## dir (shared by all worktrees -- branches are repo-wide, so pairing is too)
## and the repo root derived from it.
## Only the standard `<root>/.git` layout can anchor a pairing; a repo whose
## git dir lives wholly elsewhere (separate-git-dir, bare) has no root to
## derive, and gets the explicit flags instead.
##
## - @param $1 context for refusal messages, e.g. "--subject/--patches not given, and"
## - @state PAIR_COMMON, PAIR_ROOT -- absolute paths
locate_pairing_anchor() {
	local context=$1
	PAIR_COMMON=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) ||
		die "$context the cwd is not inside a git repository"
	PAIR_ROOT=${PAIR_COMMON%/.git}
	[[ $PAIR_ROOT != "$PAIR_COMMON" ]] ||
		die "$context this repo's git dir $(q "$PAIR_COMMON") lives outside a worktree root, so pairing can't anchor a relative path; pass explicit flags"
}

## Resolve the paired subject repo and patches dir (see cmd_pair).
##
## - @state PAIRED_SUBJECT, PAIRED_PATCHES -- absolute paths
read_pairing() {
	local rel
	locate_pairing_anchor "--subject/--patches not given, and"
	rel=$(git config --file "$PAIR_COMMON/patchette" --get patchette.patches 2>/dev/null) ||
		die "--subject/--patches not given, and $(q "$PAIR_ROOT") is not paired; run 'patchette pair <patches-dir>' there, or pass the flags"
	PAIRED_SUBJECT=$PAIR_ROOT
	PAIRED_PATCHES=$PAIR_ROOT/$rel
}

## Fill absent --subject/--patches values from the pairing.
## Each flag overrides independently: explicit always wins, per value.
##
## - @param $1 current --subject value (may be empty)
## - @param $2 current --patches value (may be empty)
## - @state RESOLVED_SUBJECT, RESOLVED_PATCHES
resolve_repos() {
	RESOLVED_SUBJECT=$1
	RESOLVED_PATCHES=$2
	if [[ -z $RESOLVED_SUBJECT || -z $RESOLVED_PATCHES ]]; then
		read_pairing
		RESOLVED_SUBJECT=${RESOLVED_SUBJECT:-$PAIRED_SUBJECT}
		RESOLVED_PATCHES=${RESOLVED_PATCHES:-$PAIRED_PATCHES}
	fi
}

cmd_pair() {
	[[ $# -le 1 ]] || die "usage: patchette pair [<patches-dir>]"
	local abs rel
	locate_pairing_anchor "pair:"
	if [[ $# -eq 0 ]]; then
		rel=$(git config --file "$PAIR_COMMON/patchette" --get patchette.patches 2>/dev/null) ||
			die "$(q "$PAIR_ROOT") is not paired"
		echo "paired: patches dir is $(q "$rel") (relative to $(q "$PAIR_ROOT"))"
		return
	fi
	# No mkdir here: pairing states a fact about something that exists;
	# creating directories is export's job.
	abs=$(absdir "$1")
	rel=$(realpath --relative-to="$PAIR_ROOT" "$abs")
	try "could not write the pairing file in $(q "$PAIR_COMMON")" \
		git config --file "$PAIR_COMMON/patchette" patchette.patches "$rel"
	echo "paired: $(q "$PAIR_ROOT") -> patches dir $(q "$rel")"
}

## List the branch folders of a patches dir, failing closed on strangers,
## optionally narrowed to named branches.  The chosen folders' concordance is
## validated up front: discordance is a static property of the patches dir,
## so it's refused before any repo is touched
## (and in import's case, before any ref could be created).
## Narrowing narrows validation too, so an unrelated broken folder doesn't
## block working on the named ones.
##
## - @param $1 patches dir
## - @param $@ branch names to narrow to (default: all branch folders)
## - @state BRANCH_DIRS[] -- absolute paths, each holding a validated STACK
## - @state STACK_* -- clobbered; callers re-run parse_stack per folder
branch_dirs() {
	local patches=$1 dir sel
	local -a all=() chosen=()
	shift
	for dir in "$patches"/*/; do
		dir=${dir%/}
		[[ -d $dir ]] || continue # unmatched glob
		[[ -f $dir/STACK ]] || die "$(q "$dir") has no STACK file; not a patchette branch folder"
		all+=("$dir")
	done
	[[ ${#all[@]} -gt 0 ]] || die "no branch folders found in $(q "$patches")"
	if [[ $# -gt 0 ]]; then
		for sel in "$@"; do
			dir="$patches/${sel//\//$'\u00a6'}"
			[[ -f $dir/STACK ]] || die "no branch folder for $(q "$sel") in $(q "$patches")"
			chosen+=("$dir")
		done
	else
		chosen=("${all[@]}")
	fi
	BRANCH_DIRS=()
	for dir in "${chosen[@]}"; do
		parse_stack "$dir/STACK"
		validate_branch_dir "$dir"
		BRANCH_DIRS+=("$dir")
	done
}

cmd_export() {
	local sigil=quote subject='' patches=''
	while [[ $# -gt 0 && $1 == -* ]]; do
		case $1 in
		--sigil=quote | --sigil=tab) sigil=${1#--sigil=} ;;
		--subject=*) subject=${1#--subject=} ;;
		--patches=*) patches=${1#--patches=} ;;
		*) die "unknown flag: $(q "$1")" ;;
		esac
		shift
	done
	local repo out baserev base branch dir slug sha tip n p mline count
	local parents scratch raw="$PTMP/raw" msg="$PTMP/msg"
	[[ $# -ge 2 ]] ||
		die "usage: patchette export [--sigil=quote|--sigil=tab] [--subject=<repo>] [--patches=<dir>] <base-rev> <branch>..."
	resolve_repos "$subject" "$patches"
	repo=$(absdir "$RESOLVED_SUBJECT")
	mkdir -p "$RESOLVED_PATCHES"
	out=$(absdir "$RESOLVED_PATCHES")
	PATCHES_DIR_ABS=$out
	baserev=$1
	shift

	base=$(git -C "$repo" rev-parse --verify -q "$baserev^{commit}") ||
		die "base revision does not resolve to a commit: $(q "$baserev")"

	# Patch bytes must depend only on the commits, so the diffs are generated in
	# a scratch bare repo that borrows the subject's objects via alternates.
	# The null-config env can't reach the subject's own .git/config, its
	# info/attributes, or the checked-out .gitattributes (which would otherwise
	# make export bytes vary with the checkout); a scratch repo is out of reach
	# 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"
	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"

	for branch in "$@"; do
		tip=$(git -C "$repo" rev-parse --verify -q "refs/heads/$branch") ||
			die "no such branch: $(q "$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 "$(q "$dir") exists but has no STACK file; not a patchette branch folder, refusing to replace it"
		fi
		# Build in a stage folder beside the destination, and swap in only a
		# finished snapshot: a death mid-export must leave the previous
		# snapshot untouched, and no half-written folder posing as a real one.
		# (Ref components never start with a dot, so the name can't collide.)
		EXPORT_STAGE="$out/.stage.$$"
		mkdir "$EXPORT_STAGE"

		{
			printf 'ref refs/heads/%s\n' "$branch"
			printf 'base %s\n' "$base"
			printf 'tip %s\n' "$tip"
			printf 'series\n'
		} >"$EXPORT_STAGE/STACK"

		declare -A slug_of=() # commit sha -> assigned slug
		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
				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 $(q "$baserev")..$(q "$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"

			try "could not read commit $sha from $(q "$repo")" \
				git cat-file commit "$sha" >"$raw"
			sed -n '/^$/,$p' "$raw" | tail -n +2 >"$msg"

			{
				printf 'parents %s\n' "${parents[*]}"
				sed -n '1,/^$/p' "$raw" | grep -vE '^(tree|parent) ' | sed '/^$/d'
				printf '\n----\n'
				if [[ -s $msg && $(tail -c 1 "$msg") != '' ]]; then
					printf 'message-no-final-newline\n'
				fi
				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'
				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 <"$PTMP/revs"

		rm -rf "$dir"
		mv "$EXPORT_STAGE" "$dir"
		EXPORT_STAGE=''
		echo "export: wrote $dir ($count patches)"
	done
}

## Find the checkout, if any, that has a branch checked out: the main checkout
## or any linked worktree.  Like git branch -f, -f won't move such a branch,
## since that checkout's index and files would be left describing the old tip.
##
## Expects cwd to be the repo.
##
## - @param $1 full ref name
## - @stdout the checkout's path, or nothing
checkout_of() {
	local ref=$1 rec wt=''
	try "could not list the worktrees of $(q "$PWD")" \
		git worktree list --porcelain -z >"$PTMP/worktrees"
	while IFS= read -r -d '' rec; do
		case $rec in
		"worktree "*) wt=${rec#worktree } ;;
		"branch $ref")
			printf '%s' "$wt"
			return
			;;
		esac
	done <"$PTMP/worktrees"
}

cmd_import() {
	local force='' subject='' patches='' dir old busy
	while [[ $# -gt 0 && $1 == -* ]]; do
		case $1 in
		-f) force=1 ;;
		--subject=*) subject=${1#--subject=} ;;
		--patches=*) patches=${1#--patches=} ;;
		*) die "unknown flag: $(q "$1")" ;;
		esac
		shift
	done
	resolve_repos "$subject" "$patches"
	subject=$(absdir "$RESOLVED_SUBJECT")
	patches=$(absdir "$RESOLVED_PATCHES")
	PATCHES_DIR_ABS=$patches
	branch_dirs "$patches" "$@"
	cd "$subject"
	export GIT_INDEX_FILE="$PTMP/scratch-index"

	for dir in "${BRANCH_DIRS[@]}"; do
		parse_stack "$dir/STACK"
		old=''
		if git show-ref --verify --quiet "$STACK_REF"; then
			[[ -n $force ]] ||
				die "$(q "$STACK_REF") already exists in $(q "$subject"); refusing to touch it (-f updates it instead)"
			old=$(git rev-parse --verify "$STACK_REF")
			busy=$(checkout_of "$STACK_REF")
			[[ -z $busy ]] ||
				die "$(q "$STACK_REF") is checked out in $(q "$busy"); refusing to move it under that checkout, even with -f"
		fi
		git cat-file -e "$STACK_BASE^{commit}" 2>/dev/null ||
			die "recorded base $STACK_BASE is not present in $(q "$subject"); fetch from upstream first"
		replay_series "$dir"
		# The old-value guard makes the update atomic: empty = must-not-exist
		# (no clobbering, even racily), a SHA = compare-and-swap on the tip we
		# just decided to overwrite.  The reflog keeps that old tip findable,
		# and --create-reflog matters: bare repos don't log refs by default.
		try "could not update $(q "$STACK_REF") (changed concurrently?)" \
			git update-ref --create-reflog -m "patchette: import" "$STACK_REF" "$REPLAY_TIP" "$old"
		if [[ -z $old ]]; then
			echo "import: created $STACK_REF at $REPLAY_TIP"
		elif [[ $old == "$REPLAY_TIP" ]]; then
			echo "import: unchanged: $STACK_REF is already at $REPLAY_TIP"
		else
			echo "import: updated $STACK_REF to $REPLAY_TIP (previous tip $old remains in its reflog)"
		fi
		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?)"
		fi
	done
}

cmd_verify() {
	local subject='' patches='' dir bad=0
	while [[ $# -gt 0 && $1 == -* ]]; do
		case $1 in
		--subject=*) subject=${1#--subject=} ;;
		--patches=*) patches=${1#--patches=} ;;
		*) die "unknown flag: $(q "$1")" ;;
		esac
		shift
	done
	resolve_repos "$subject" "$patches"
	subject=$(absdir "$RESOLVED_SUBJECT")
	patches=$(absdir "$RESOLVED_PATCHES")
	PATCHES_DIR_ABS=$patches
	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.
	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"

	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 $(q "$subject")"
		replay_series "$dir"
		if [[ -z $STACK_TIP ]]; then
			echo "verify: note: $STACK_REF replays cleanly to $REPLAY_TIP, but records no tip to cross-check against"
		elif [[ $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
	done

	[[ $bad -eq 0 ]] || exit 1
}

case ${1:-} in
export | import | verify | pair)
	cmd=$1
	shift
	"cmd_$cmd" "$@"
	;;
-h | --help | help)
	usage
	;;
'')
	usage
	exit 1
	;;
*)
	die "unknown command: $(q "$1") (try: patchette help)"
	;;
esac

bin/patchette · 25.0 KiB · f4a2518b27