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