Merge branch 'cli-polish-take3'
commit f4a2518b27e3828c947dd01c2e5f0a581e1f84dd · parent 2df663fec9 · parent 28b2b86895
· browse this tree
warpfork, 2026-10-27 13:37:01 UTC
diff --git a/bin/patchette b/bin/patchette index 46e9d49..732fbca 100755 --- a/bin/patchette +++ b/bin/patchette @@ -2,9 +2,9 @@ ## 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> +## 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 @@ -34,9 +34,13 @@ 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. @@ -48,17 +52,70 @@ die() { 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>... + 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> + 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 } @@ -87,7 +144,7 @@ slugify() { ## - @param $1 path ## - @stdout the absolute path absdir() { - cd "$1" 2>/dev/null && pwd || die "no such directory: $1" + cd "$1" 2>/dev/null && pwd || die "no such directory: $(q "$1")" } ## Parse a STACK control file. @@ -101,34 +158,34 @@ parse_stack() { 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" + [[ $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 "$f: duplicate $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 "$f: base is not a full commit ID: $STACK_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 "$f: tip is not a full commit ID: $STACK_TIP" + [[ $STACK_TIP =~ $oid ]] || die "$(q "$f"): tip is not a full commit ID: $(q "$STACK_TIP")" ;; series) mode=series ;; - *) die "$f: unrecognized line: $line" ;; + *) 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 "$f: missing ref, base, or series section" - [[ $STACK_REF == refs/heads/* ]] || die "$f: ref is not under refs/heads/: $STACK_REF" + 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 @@ -145,13 +202,13 @@ validate_branch_dir() { declare -A listed=() for slug in "${STACK_SERIES[@]}"; do listed[$slug]=1 - [[ -f $dir/$slug.patch ]] || die "$dir/STACK: series lists $slug, but $slug.patch is missing" + [[ -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 "$f: orphan patch file, not listed in the STACK series" + [[ -n ${listed[$slug]:-} ]] || die "$(q "$f"): orphan patch file, not listed in the STACK series" done } @@ -182,7 +239,7 @@ replay_series() { lineno=$((lineno + 1)) case $state in start) - [[ $line == parents\ * ]] || die "$f: line 1 is not a parents line" + [[ $line == parents\ * ]] || die "$(q "$f"): line 1 is not a parents line" pline=$line state=headers ;; @@ -192,13 +249,13 @@ replay_series() { elif [[ $line != *' '* ]]; then # Headers are `key value`, and continuation lines begin with # a space, so every header line contains one. - die "$f: malformed header line (no space): $line" + die "$(q "$f"): malformed header line (no space): $(q "$line")" else headers+=("$line") fi ;; msgopen) - [[ $line == '----' ]] || die "$f: expected opening ---- flank, got: $line" + [[ $line == '----' ]] || die "$(q "$f"): expected opening ---- flank, got: $(q "$line")" state=directives ;; directives) @@ -211,7 +268,7 @@ replay_series() { continue fi if [[ -n $line && $line != '----' && $line != '>' && $line != '> '* && $line != $'\t'* ]]; then - die "$f: unrecognized directive line: $line" + die "$(q "$f"): unrecognized directive line: $(q "$line")" fi state=message ;& @@ -226,40 +283,42 @@ replay_series() { elif [[ $line == $'\t'* ]]; then printf '%s\n' "${line:1}" >>"$msgpart" else - die "$f: malformed message line: $line" + die "$(q "$f"): malformed message line: $(q "$line")" fi ;; postmsg) - [[ -z $line ]] || die "$f: expected blank line after closing flank, got: $line" + [[ -z $line ]] || die "$(q "$f"): expected blank line after closing flank, got: $(q "$line")" state=diff break ;; esac done <"$f" - [[ $state == diff ]] || die "$f: truncated (ended in state $state)" - [[ ${#headers[@]} -gt 0 ]] || die "$f: no commit headers" + [[ $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 "$f: parents line lists no parents" + [[ ${#prefs[@]} -gt 0 ]] || die "$(q "$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" + [[ -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" - git read-tree "${parents[0]}" + 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 target repo's own apply.whitespace must not + # --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. - git apply --cached --binary --whitespace=nowarn "$diffpart" || die "$f: diff does not apply onto its parent" + try "$(q "$f"): diff does not apply onto its parent" \ + git apply --cached --binary --whitespace=nowarn "$diffpart" fi - tree=$(git write-tree) + tree=$(try "$(q "$f"): could not write the replayed tree" git write-tree) sha=$({ printf 'tree %s\n' "$tree" @@ -267,55 +326,143 @@ replay_series() { printf '%s\n' "${headers[@]}" printf '\n' cat "$msgpart" - } | git hash-object -t commit -w --stdin) + } | 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, -## and validate every folder's concordance up front: discordance is a static -## property of the patches dir, so it's refused before any repo is touched +## 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 - 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 "$dir has no STACK file; not a patchette branch folder" + [[ -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 - [[ ${#BRANCH_DIRS[@]} -gt 0 ]] || die "no branch folders found in $patches" } cmd_export() { - local sigil=quote - while [[ $# -gt 0 && $1 == --* ]]; do + local sigil=quote subject='' patches='' + while [[ $# -gt 0 && $1 == -* ]]; do case $1 in --sigil=quote | --sigil=tab) sigil=${1#--sigil=} ;; - *) die "unknown flag: $1" ;; + --subject=*) subject=${1#--subject=} ;; + --patches=*) patches=${1#--patches=} ;; + *) die "unknown flag: $(q "$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 scratch raw="$PTMP/raw" msg="$PTMP/msg" - repo=$(absdir "$1") - mkdir -p "$2" - out=$(absdir "$2") - baserev=$3 - shift 3 + [[ $# -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: $baserev" + 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. @@ -325,18 +472,19 @@ cmd_export() { # 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" - git init -q --bare -b scratch --object-format="$(git -C "$repo" rev-parse --show-object-format)" "$scratch" + 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: $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 "$dir exists but has no STACK file; not a patchette branch folder, refusing to replace it" + [[ -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 @@ -356,6 +504,10 @@ cmd_export() { 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 @@ -373,12 +525,13 @@ cmd_export() { 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" + 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" - git cat-file commit "$sha" >"$raw" + try "could not read commit $sha from $(q "$repo")" \ + git cat-file commit "$sha" >"$raw" sed -n '/^$/,$p' "$raw" | tail -n +2 >"$msg" { @@ -396,12 +549,13 @@ cmd_export() { fi done <"$msg" printf -- '----\n\n' - git diff-tree -r -p --full-index --binary --no-renames --diff-algorithm=patience "$sha^" "$sha" + 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 < <(git rev-list --reverse --topo-order "$base..$tip") + done <"$PTMP/revs" rm -rf "$dir" mv "$EXPORT_STAGE" "$dir" @@ -410,25 +564,75 @@ cmd_export() { 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() { - [[ $# -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" + 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" - git show-ref --verify --quiet "$STACK_REF" && - die "$STACK_REF already exists in $target; refusing to touch it" + 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 $target; fetch from upstream first" + die "recorded base $STACK_BASE is not present in $(q "$subject"); 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" + # 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 @@ -436,22 +640,32 @@ cmd_import() { } cmd_verify() { - [[ $# -eq 2 ]] || die "usage: patchette verify <subject-repo> <patches-dir>" - local subject patches dir bad=0 - subject=$(absdir "$1") - patches=$(absdir "$2") - branch_dirs "$patches" + 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. - git clone -q --shared --bare "$subject" "$PTMP/verify.git" + 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 $subject" + 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" @@ -468,7 +682,7 @@ cmd_verify() { } case ${1:-} in -export | import | verify) +export | import | verify | pair) cmd=$1 shift "cmd_$cmd" "$@" @@ -481,6 +695,6 @@ export | import | verify) exit 1 ;; *) - die "unknown command: $1 (try: patchette help)" + die "unknown command: $(q "$1") (try: patchette help)" ;; esac diff --git a/docs/hacking/REPLAYING.md b/docs/hacking/REPLAYING.md index ee5bc68..0917272 100644 --- a/docs/hacking/REPLAYING.md +++ b/docs/hacking/REPLAYING.md @@ -13,7 +13,7 @@ For each patch name in series order, maintaining a map from reference to replaye 2. Read the first parent's tree into a scratch index (no worktree is required, ever). 3. If the diff section is non-empty, apply it to that index (`git apply --cached --binary --whitespace=nowarn`; the explicit whitespace flag - keeps a target repo's own `apply.whitespace` from rewriting or refusing recorded bytes). + keeps the subject repo's own `apply.whitespace` from rewriting or refusing recorded bytes). An apply failure is an error: the recorded base is the only supported target. 4. Write the resulting tree. 5. Reassemble the raw commit object: `tree` line, one `parent` line per resolved parent, diff --git a/docs/userguide/USAGE.md b/docs/userguide/USAGE.md new file mode 100644 index 0000000..79abaa1 --- /dev/null +++ b/docs/userguide/USAGE.md @@ -0,0 +1,326 @@ +Using Patchette +=============== + +Patchette snapshots your branches into a patches repo, as one folder of patch files per branch, +and rebuilds those exact branches from the patches, commit for commit, in any clone of the upstream. +You keep working in your own repo, with plain git; patchette only reads it, and writes the branches back when you ask. + +Every session below is real: the test suite runs each one, and checks that patchette still prints exactly what's shown. +They all start from the same place: a home dir with an upstream repo at `~/upstream/frob`, +and your clone of it at `~/src/frob`, with `main` checked out, carrying two branches of your own work, `topic/fancy` and `docs-polish`. + + +Setting up +---------- + +The patches live in a repo of their own, beside your clone. +Make one, then pair your clone with it, so patchette knows where to look: + +[testmark]:# (fresh/setting-up/session) +``` +~$ cd src/frob + +~/src/frob$ git init -b main ../frob-patches +Initialized empty Git repository in ~/src/frob-patches/.git/ + +~/src/frob$ patchette pair ../frob-patches +paired: '~/src/frob' -> patches dir '../frob-patches' + +~/src/frob$ patchette pair +paired: patches dir is '../frob-patches' (relative to '~/src/frob') +``` + +Pairing is the one thing patchette keeps in your repo: a single value, in `.git/patchette`, never pushed. +Delete that file to un-pair. + +The sessions in the rest of this guide each start as though you'd just done that +(apart from the last two sections, which start unpaired, from `~`). + + +Taking snapshots +---------------- + +Work on your branches however you like; then export them, and commit the result in the patches repo. +Export takes the upstream commit your branches are based on, then the branches: + +[testmark]:# (snapshot/session) +``` +~/src/frob$ patchette export origin/main topic/fancy docs-polish +export: wrote ~/src/frob-patches/topic¦fancy (8 patches) +export: wrote ~/src/frob-patches/docs-polish (2 patches) + +~/src/frob$ ls ../frob-patches/docs-polish +STACK +mention-the-oiling-schedule.patch +polish-the-readme.patch + +~/src/frob$ git -C ../frob-patches add . + +~/src/frob$ git -C ../frob-patches commit -q -m "first snapshot" +``` + +Each branch becomes a folder: a `STACK` file saying where it's based and what's in it, and one patch file per commit. + +`~/src/frob-patches/docs-polish/STACK` names the branch, the upstream commit it's based on, and its tip; +then, after `series`, its patches, in order: + +[testmark]:# (snapshot/STACK) +``` +ref refs/heads/docs-polish +base 5b0130cfd2153bcdb4a6d7ec356e7d805018afc9 +tip 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a +series +polish-the-readme +mention-the-oiling-schedule +``` + +`~/src/frob-patches/docs-polish/polish-the-readme.patch` is one commit: +its parents (`BASE` is the STACK's base), then the rest of its headers exactly as git stored them; its message, between `----` lines; and its diff: + +[testmark]:# (snapshot/polish-the-readme.patch) +``` +parents BASE +author Ada Downstream <ada@downstream.example> 1700000660 +0000 +committer Ada Downstream <ada@downstream.example> 1700000660 +0000 + +---- +> Polish the readme +---- + +diff --git a/README.md b/README.md +index a330cd687e7fdede54bbe0aca5df47c06d2e8b64..c195933372242cdbb3c014665ebcff2bb3fb4449 100644 +--- a/README.md ++++ b/README.md +@@ -3,3 +3,5 @@ + Upstream docs. + + More upstream docs. ++ ++Downstream notes: be kind to the machinery. +``` + +Both formats are specified in full in [FORMAT.md](../hacking/FORMAT.md). + +The payoff comes when your patches change. +Here, a commit on `docs-polish` gets reworked; export again, and the patches repo shows what changed about the change: + +[testmark]:# (paired/evolving/session) +``` +~/src/frob$ patchette export origin/main docs-polish +export: wrote ~/src/frob-patches/docs-polish (2 patches) + +~/src/frob$ git -C ../frob-patches add . + +~/src/frob$ git -C ../frob-patches commit -q -m "first snapshot" + +~/src/frob$ git switch -q docs-polish + +~/src/frob$ sed -i 's/weekly/daily/' README.md + +~/src/frob$ git commit -q -a --amend --no-edit + +~/src/frob$ patchette export origin/main docs-polish +export: wrote ~/src/frob-patches/docs-polish (2 patches) + +~/src/frob$ git -C ../frob-patches diff +diff --git a/docs-polish/STACK b/docs-polish/STACK +index c56f84d..99ac5cd 100644 +--- a/docs-polish/STACK ++++ b/docs-polish/STACK +@@ -1,6 +1,6 @@ + ref refs/heads/docs-polish + base 5b0130cfd2153bcdb4a6d7ec356e7d805018afc9 +-tip 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a ++tip 88f146ae1801f5fd8d443d6eb5904f09b88927c6 + series + polish-the-readme + mention-the-oiling-schedule +diff --git a/docs-polish/mention-the-oiling-schedule.patch b/docs-polish/mention-the-oiling-schedule.patch +index 8b0dda3..1f6c371 100644 +--- a/docs-polish/mention-the-oiling-schedule.patch ++++ b/docs-polish/mention-the-oiling-schedule.patch +@@ -1,17 +1,17 @@ + parents polish-the-readme + author Ada Downstream <ada@downstream.example> 1700000720 +0000 +-committer Ada Downstream <ada@downstream.example> 1700000720 +0000 ++committer Ada Downstream <ada@downstream.example> 1700086400 +0000 + + ---- + > Mention the oiling schedule + ---- + + diff --git a/README.md b/README.md +-index c195933372242cdbb3c014665ebcff2bb3fb4449..073a43988cb91f0fe493964670f6d52517082346 100644 ++index c195933372242cdbb3c014665ebcff2bb3fb4449..61f9c849c8c9962d481e8ce3874abcc47a640eba 100644 + --- a/README.md + +++ b/README.md + @@ -5,3 +5,4 @@ Upstream docs. + More upstream docs. + + Downstream notes: be kind to the machinery. +-+Oil the gears weekly. +++Oil the gears daily. +``` + +Commit that with a message saying why it changed, and the history of your patches tells its own story. + + +Checking a snapshot +------------------- + +`verify` rebuilds each branch from its patches, off to the side, and checks it comes out exactly as it was recorded. +It never touches your branches. +Name branches to check just those: + +[testmark]:# (paired/verifying/session) +``` +~/src/frob$ patchette export origin/main topic/fancy docs-polish +export: wrote ~/src/frob-patches/topic¦fancy (8 patches) +export: wrote ~/src/frob-patches/docs-polish (2 patches) + +~/src/frob$ patchette verify +verify: ok: refs/heads/docs-polish reproduces recorded tip 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a +verify: ok: refs/heads/topic/fancy reproduces recorded tip cc261dc9f0b878a78e2b80aeb60de397205f61ad + +~/src/frob$ patchette verify topic/fancy +verify: ok: refs/heads/topic/fancy reproduces recorded tip cc261dc9f0b878a78e2b80aeb60de397205f61ad +``` + + +Rebuilding branches in a fresh clone +------------------------------------ + +The patches repo is enough to bring your branches back, exactly, in a clone that never had them. +Pair the new clone, and import: + +[testmark]:# (paired/recreating/session) +``` +~/src/frob$ patchette export origin/main topic/fancy docs-polish +export: wrote ~/src/frob-patches/topic¦fancy (8 patches) +export: wrote ~/src/frob-patches/docs-polish (2 patches) + +~/src/frob$ cd .. + +~/src$ git clone -q ~/upstream/frob frob2 + +~/src$ cd frob2 + +~/src/frob2$ patchette pair ../frob-patches +paired: '~/src/frob2' -> patches dir '../frob-patches' + +~/src/frob2$ patchette import +import: created refs/heads/docs-polish at 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a +import: created refs/heads/topic/fancy at cc261dc9f0b878a78e2b80aeb60de397205f61ad + +~/src/frob2$ git rev-parse topic/fancy +cc261dc9f0b878a78e2b80aeb60de397205f61ad + +~/src/frob2$ git -C ../frob rev-parse topic/fancy +cc261dc9f0b878a78e2b80aeb60de397205f61ad +``` + + +Editing patches by hand +----------------------- + +Patch files are plain text, and fine to edit directly. +Then rebuild the branch from them. +`import` won't replace a branch that already exists unless you say `-f`, +and even then, not the branch you have checked out: switch away first. + +[testmark]:# (paired/hand-editing/session) +``` +~/src/frob$ patchette export origin/main topic/fancy +export: wrote ~/src/frob-patches/topic¦fancy (8 patches) + +~/src/frob$ sed -i 's/^> The hum is load-bearing.$/> The hum is load-bearing, and tuned./' ../frob-patches/topic¦fancy/teach-gizmo-to-hum.patch + +~/src/frob$ patchette import topic/fancy +patchette: 'refs/heads/topic/fancy' already exists in '~/src/frob'; refusing to touch it (-f updates it instead) +[exit status: 1] + +~/src/frob$ git switch -q topic/fancy + +~/src/frob$ patchette import -f topic/fancy +patchette: 'refs/heads/topic/fancy' is checked out in '~/src/frob'; refusing to move it under that checkout, even with -f +[exit status: 1] + +~/src/frob$ git switch -q main + +~/src/frob$ patchette import -f topic/fancy +import: updated refs/heads/topic/fancy to 57d05b16d9ad81fd92af19d210f0884dc9745e01 (previous tip cc261dc9f0b878a78e2b80aeb60de397205f61ad remains in its reflog) +import: note: replayed tip differs from recorded tip cc261dc9f0b878a78e2b80aeb60de397205f61ad (patches edited since the snapshot?) + +~/src/frob$ patchette verify topic/fancy +verify: MISMATCH: refs/heads/topic/fancy replays to 57d05b16d9ad81fd92af19d210f0884dc9745e01, but records tip cc261dc9f0b878a78e2b80aeb60de397205f61ad +verify: either the snapshot was unfaithful, or the patches were edited since (patches repo is ahead of its snapshot) +[exit status: 1] + +~/src/frob$ patchette export origin/main topic/fancy +export: wrote ~/src/frob-patches/topic¦fancy (8 patches) + +~/src/frob$ patchette verify topic/fancy +verify: ok: refs/heads/topic/fancy reproduces recorded tip 57d05b16d9ad81fd92af19d210f0884dc9745e01 +``` + +`verify` fails right after the edit: the patches no longer match the snapshot they were taken as. +Exporting the rebuilt branch takes a new snapshot of it, and they agree again. + + +Without pairing +--------------- + +Pairing is a convenience. +From anywhere, `--subject` names your repo and `--patches` the patches repo; +each one, given, wins over the pairing. + +[testmark]:# (fresh/flags/session) +``` +~$ git init -q -b main src/frob-patches + +~$ patchette export --subject=src/frob --patches=src/frob-patches origin/main docs-polish +export: wrote ~/src/frob-patches/docs-polish (2 patches) + +~$ patchette verify --subject=src/frob --patches=src/frob-patches +verify: ok: refs/heads/docs-polish reproduces recorded tip 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a +``` + + +When something's wrong +---------------------- + +[testmark]:# (fresh/refusals/session) +``` +~$ cd src/frob + +~/src/frob$ patchette verify +patchette: --subject/--patches not given, and '~/src/frob' is not paired; run 'patchette pair <patches-dir>' there, or pass the flags +[exit status: 1] + +~/src/frob$ cd /tmp + +/tmp$ patchette verify --subject="$HOME/src/frob" +patchette: --subject/--patches not given, and the cwd is not inside a git repository +[exit status: 1] + +/tmp$ cd ~/src/frob + +~/src/frob$ mkdir ../frob-patches + +~/src/frob$ patchette pair ../frob-patches +paired: '~/src/frob' -> patches dir '../frob-patches' + +~/src/frob$ patchette export origin/main docs-polish +export: wrote ~/src/frob-patches/docs-polish (2 patches) + +~/src/frob$ patchette verify docs-polish --patches=../frob-patches +patchette: no branch folder for '--patches=../frob-patches' in '~/src/frob-patches' +[exit status: 1] + +~/src/frob$ patchette verify --patches= +verify: ok: refs/heads/docs-polish reproduces recorded tip 2dfecf1d7ce26028ef2dcc7430b5b9bc9c8aa86a + +~/src/frob$ patchette export +patchette: usage: patchette export [--sigil=quote|--sigil=tab] [--subject=<repo>] [--patches=<dir>] <base-rev> <branch>... +[exit status: 1] +``` diff --git a/tests/lib.sh b/tests/lib.sh index 6fc8495..49abc6a 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -38,7 +38,7 @@ RESULT=0 ## and the subject on the next, indented: ## ## EXPECT refusal "a duplicate series entry is an error" "duplicate series entry" -- \ -## "$PATCHETTE" verify "$subject" "$patches" +## "$PATCHETTE" verify --subject="$subject" --patches="$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), @@ -130,7 +130,7 @@ tick() { ## 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. +## `docs-polish` is the plain example: two ordinary commits, a short series. ## `odd-bytes` carries content that git config or attributes could perturb. ## ## - @param $1 output directory; created. Gets `upstream/`, `dev/`, and a @@ -222,12 +222,16 @@ make_corpus() ( 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 --- + # --- second branch: the plain example --- 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 'Oil the gears weekly.\n' >>README.md + git add -A + tick + git commit -q -m "Mention the oiling schedule" # --- third branch: content that git config or attributes could bite on --- # Trailing whitespace, CRLF, binary, a non-ASCII path with a space, a diff --git a/tests/test-errors.sh b/tests/test-errors.sh new file mode 100755 index 0000000..0512815 --- /dev/null +++ b/tests/test-errors.sh @@ -0,0 +1,42 @@ +#!/bin/bash +## Error presentation: when child git fails, our message leads and git's +## stderr follows indented beneath it (in a multi-file patch it's the only +## source of which file or hunk failed); patch paths in our messages are +## relative to the patches dir and quoted. +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 --subject="$work/corpus/dev" --patches="$work/patches" "$base" topic/fancy >/dev/null +# Corrupt a context line in the diff so git apply itself fails. +sed -i 's/^ gear 4 turns smoothly$/ gear 4 turns wrongly/' "$work/patches/topic¦fancy/teach-gizmo-to-hum.patch" + +rc=0 +"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/patches" >"$work/out" 2>&1 || rc=$? +EXPECT equal "an unapplicable diff still fails verify" -- "$rc" "1" +EXPECT success "our message leads, with the patch path relative and quoted" -- \ + grep -q "^patchette: 'topic¦fancy/teach-gizmo-to-hum.patch': diff does not apply onto its parent$" "$work/out" +EXPECT success "git's own stderr follows, indented as detail" -- \ + grep -q "^ error: patch failed" "$work/out" +ours=$(grep -nm1 '^patchette:' "$work/out" || true) +detail=$(grep -nm1 '^ error:' "$work/out" || true) +order=disordered +if [[ -n $ours && -n $detail && ${ours%%:*} -lt ${detail%%:*} ]]; then order=ordered; fi +EXPECT equal "and in that order" -- "$order" "ordered" + +# Repo paths in messages relativize to where the user invoked us, even though +# the commands cd around internally. +git clone -q "$work/corpus/upstream" "$work/fresh" +git -C "$work/fresh" branch topic/fancy main +rc=0 +(cd "$work" && "$PATCHETTE" import --subject=fresh --patches=patches) >"$work/out2" 2>&1 || rc=$? +EXPECT success "repo paths in messages are relative to the invocation dir" -- \ + grep -q "already exists in 'fresh'" "$work/out2" + +exit "$RESULT" diff --git a/tests/test-failclosed.sh b/tests/test-failclosed.sh index 31a0164..61071e5 100755 --- a/tests/test-failclosed.sh +++ b/tests/test-failclosed.sh @@ -12,7 +12,7 @@ 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 +"$PATCHETTE" export --subject="$work/corpus/dev" --patches="$work/pristine" "$base" topic/fancy docs-polish >/dev/null fancy=$'topic\u00a6fancy' ## Set up a fresh tampering workspace from the pristine export. @@ -30,73 +30,73 @@ clone_layout() { 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" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$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" + "$PATCHETTE" import --subject="$work/occupied" --patches="$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" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER" # An unrecognized bare line in directive position (directly after the # opening flank) is an error. clone_layout baddirective sed -i '0,/^----$/s//----\nmystery-directive/' "$TAMPER/$fancy/note-to-self.patch" EXPECT refusal "an unknown directive line is an error" "unrecognized directive" -- \ - "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER" # A directive is only legal before message content; after content has begun, # the same bare line is just a malformed message line. clone_layout latedirective sed -i 's/^> Note to self$/> Note to self\nmessage-no-final-newline/' "$TAMPER/$fancy/note-to-self.patch" EXPECT refusal "a directive after message content is a parse error" "malformed message line" -- \ - "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER" # The directive's pre-spec position (bare line in the header area) must fail # loudly, not be swallowed as a verbatim header into the replayed commit. clone_layout olddirective sed -i '1a message-no-final-newline' "$TAMPER/$fancy/note-to-self.patch" EXPECT refusal "a bare line in the header area is a parse error" "malformed header line" -- \ - "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$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" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER" # base and tip must be full commit IDs: a ref name or an abbreviation could # resolve differently in another repo, or later, so neither is guessed at. clone_layout refbase sed -i 's/^base .*/base main/' "$TAMPER/$fancy/STACK" EXPECT refusal "a ref name as base is an error" "base is not a full commit ID" -- \ - "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER" clone_layout shorttip sed -i 's/^tip \(.\{12\}\).*/tip \1/' "$TAMPER/$fancy/STACK" EXPECT refusal "an abbreviated tip is an error" "tip is not a full commit ID" -- \ - "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER" clone_layout emptytip sed -i 's/^tip .*/tip /' "$TAMPER/$fancy/STACK" EXPECT refusal "a tip line with no value is an error, not an absent tip" "tip is not a full commit ID" -- \ - "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER" # A repeated STACK key is an error: which value is meant can't be known. clone_layout dupkey sed -i '1a ref refs/heads/other' "$TAMPER/$fancy/STACK" EXPECT refusal "a repeated STACK key is an error" "duplicate ref line" -- \ - "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$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" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER" # Folder discordances are errors for every reader: an orphan patch file # (nothing would ever read it) and a series entry naming no file must be @@ -104,25 +104,25 @@ EXPECT refusal "a duplicate series entry is an error" "duplicate series entry" - clone_layout orphan cp "$TAMPER/$fancy/note-to-self.patch" "$TAMPER/$fancy/stray.patch" EXPECT refusal "an orphan patch file is refused by verify" "orphan" -- \ - "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" -git clone -q "$work/corpus/upstream" "$work/orphantarget" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER" +git clone -q "$work/corpus/upstream" "$work/orphanclone" EXPECT refusal "an orphan patch file is refused by import" "orphan" -- \ - "$PATCHETTE" import "$work/orphantarget" "$TAMPER" + "$PATCHETTE" import --subject="$work/orphanclone" --patches="$TAMPER" EXPECT equal "the refused import created no refs" -- \ - "$(git -C "$work/orphantarget" for-each-ref --format='%(refname)' 'refs/heads/*' --exclude refs/heads/main)" "" + "$(git -C "$work/orphanclone" for-each-ref --format='%(refname)' 'refs/heads/*' --exclude refs/heads/main)" "" clone_layout missingfile rm "$TAMPER/$fancy/note-to-self.patch" -EXPECT refusal "a series entry naming no file is refused by verify" "note-to-self.patch is missing" -- \ - "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" -EXPECT refusal "a series entry naming no file is refused by import" "note-to-self.patch is missing" -- \ - "$PATCHETTE" import "$work/orphantarget" "$TAMPER" +EXPECT refusal "a series entry naming no file is refused by verify" "'note-to-self.patch' is missing" -- \ + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER" +EXPECT refusal "a series entry naming no file is refused by import" "'note-to-self.patch' is missing" -- \ + "$PATCHETTE" import --subject="$work/orphanclone" --patches="$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 + "$PATCHETTE" export --subject="$work/corpus/dev" --patches="$work/notours" "$base" topic/fancy EXPECT success "export left the non-patchette folder untouched" -- \ test -f "$work/notours/$fancy/precious.txt" @@ -136,18 +136,30 @@ git -C "$work/poisoned" commit -q --allow-empty -m "Out of range" git -C "$work/poisoned" branch -f topic/fancy side clone_layout atomic EXPECT refusal "export dies on an out-of-range parent" "out-of-range" -- \ - "$PATCHETTE" export "$work/poisoned" "$TAMPER" "$base" topic/fancy docs-polish + "$PATCHETTE" export --subject="$work/poisoned" --patches="$TAMPER" "$base" topic/fancy docs-polish EXPECT success "the failed re-export left the previous snapshot untouched" -- \ diff -r "$work/pristine" "$TAMPER" mkdir "$work/freshout" EXPECT refusal "export into a fresh dir dies the same way" "out-of-range" -- \ - "$PATCHETTE" export "$work/poisoned" "$work/freshout" "$base" topic/fancy + "$PATCHETTE" export --subject="$work/poisoned" --patches="$work/freshout" "$base" topic/fancy EXPECT equal "the failed fresh export left no debris behind" -- \ "$(ls -A "$work/freshout")" "" +# A commit walk that fails must fail the export, not read as an empty branch: +# an empty series swapped in would silently replace a good snapshot. +# Trigger: a subject missing one mid-branch commit object. +cp -a "$work/corpus/dev" "$work/holey-dev" +mid=$(git -C "$work/holey-dev" rev-parse topic/fancy~3) +rm "$work/holey-dev/.git/objects/${mid:0:2}/${mid:2}" +clone_layout holey +EXPECT refusal "export dies when the commit walk fails" "could not list the commits of 'topic/fancy'" -- \ + "$PATCHETTE" export --subject="$work/holey-dev" --patches="$TAMPER" "$base" topic/fancy +EXPECT success "the failed walk left the previous snapshot untouched" -- \ + diff -r "$work/pristine" "$TAMPER" + # 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" + "$PATCHETTE" import --subject="$work/empty" --patches="$work/pristine" exit "$RESULT" diff --git a/tests/test-force-select.sh b/tests/test-force-select.sh new file mode 100755 index 0000000..c606471 --- /dev/null +++ b/tests/test-force-select.sh @@ -0,0 +1,90 @@ +#!/bin/bash +## The hand-editing journey: import and verify narrow to named branches, +## and import -f replays over an existing ref, keeping the old tip in the +## reflog. Without -f, an existing ref is still refused. +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 --subject="$work/corpus/dev" --patches="$work/patches" "$base" topic/fancy docs-polish >/dev/null +fancy=$'topic\u00a6fancy' + +# --- selection --- +git clone -q "$work/corpus/upstream" "$work/subset" +"$PATCHETTE" import --subject="$work/subset" --patches="$work/patches" docs-polish >/dev/null +EXPECT equal "a named-subset import creates only the named refs" -- \ + "$(git -C "$work/subset" for-each-ref --format='%(refname)' refs/heads/docs-polish refs/heads/topic/fancy)" "refs/heads/docs-polish" +EXPECT refusal "a selection naming no branch folder is an error" "no branch folder for 'no-such-branch'" -- \ + "$PATCHETTE" import --subject="$work/subset" --patches="$work/patches" no-such-branch + +# Narrowed verify reads only the named folders: tamper the other one and +# watch selection dodge it while an unnarrowed verify trips on it. +sed -i 's/> Polish the readme/> Polish the readme harder/' "$work/patches/docs-polish/polish-the-readme.patch" +EXPECT success "a narrowed verify reads only the named folders" -- \ + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/patches" topic/fancy +EXPECT refusal "while an unnarrowed verify still sees the rest" "MISMATCH" -- \ + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/patches" + +# Narrowing narrows validation too: a folder that fails validation (here, an +# orphan patch file) blocks only the commands that include it. +cp -r "$work/patches" "$work/strayed" +cp "$work/strayed/docs-polish/polish-the-readme.patch" "$work/strayed/docs-polish/stray.patch" +EXPECT success "a narrowed verify isn't blocked by another folder's validation failure" -- \ + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/strayed" topic/fancy +EXPECT refusal "while an unnarrowed verify refuses it" "orphan" -- \ + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/strayed" + +# --- import -f --- +# The docs-polish patch set is now hand-edited; its replay tip differs from +# the branch the earlier import created. +EXPECT refusal "an existing ref is refused without -f, which gets mentioned" "-f updates it instead" -- \ + "$PATCHETTE" import --subject="$work/subset" --patches="$work/patches" docs-polish +EXPECT equal "the refused ref is untouched" -- \ + "$(git -C "$work/subset" rev-parse refs/heads/docs-polish)" "$tip_docs_polish" + +"$PATCHETTE" import -f --subject="$work/subset" --patches="$work/patches" docs-polish >/dev/null +newtip=$(git -C "$work/subset" rev-parse refs/heads/docs-polish) +EXPECT success "import -f updates the ref to the edited replay" -- \ + test "$newtip" != "$tip_docs_polish" +EXPECT success "the previous tip remains findable in the reflog" -- \ + grep -q "$tip_docs_polish" <(git -C "$work/subset" log -g --format=%H refs/heads/docs-polish) + +# -f still never moves a branch some checkout has checked out (git branch -f +# refuses the same): that checkout's index and files would be left behind, +# reading as a staged revert. The main checkout and linked worktrees alike. +git clone -q "$work/corpus/upstream" "$work/busy" +git -C "$work/busy" switch -qc docs-polish +EXPECT refusal "import -f refuses a branch checked out in the main checkout" "checked out in" -- \ + "$PATCHETTE" import -f --subject="$work/busy" --patches="$work/patches" docs-polish +EXPECT equal "and leaves it where it was" -- \ + "$(git -C "$work/busy" rev-parse refs/heads/docs-polish)" "$base" +git -C "$work/busy" branch -q topic/fancy main +git -C "$work/busy" worktree add -q "$work/busy-wt" topic/fancy +EXPECT refusal "import -f refuses a branch checked out in a linked worktree" "checked out in" -- \ + "$PATCHETTE" import -f --subject="$work/busy" --patches="$work/patches" topic/fancy +EXPECT equal "and leaves it where it was" -- \ + "$(git -C "$work/busy" rev-parse refs/heads/topic/fancy)" "$base" + +# A bare repo has no reflogs by default (core.logAllRefUpdates is off), so +# this leg is what proves import creates the reflog trail itself rather than +# inheriting it from non-bare defaults. +git clone -q --bare "$work/corpus/upstream" "$work/bare.git" +"$PATCHETTE" import --subject="$work/bare.git" --patches="$work/patches" topic/fancy >/dev/null +sed -i 's/> Teach gizmo to hum/> Teach gizmo to really hum/' "$work/patches/$fancy/teach-gizmo-to-hum.patch" +"$PATCHETTE" import -f --subject="$work/bare.git" --patches="$work/patches" topic/fancy >/dev/null +EXPECT success "even in a bare repo, the previous tip remains in the reflog" -- \ + grep -q "$tip_topic_fancy" <(git -C "$work/bare.git" log -g --format=%H refs/heads/topic/fancy) + +# Forcing a ref to the tip it already has changes nothing, and says so, +# rather than claiming an update with the same tip as "previous". +EXPECT success "a no-op import -f reports the ref as unchanged" -- \ + bash -c '"$@" | grep -q "unchanged"' -- \ + "$PATCHETTE" import -f --subject="$work/bare.git" --patches="$work/patches" topic/fancy + +exit "$RESULT" diff --git a/tests/test-hermetic.sh b/tests/test-hermetic.sh index 51835b5..decb7e5 100755 --- a/tests/test-hermetic.sh +++ b/tests/test-hermetic.sh @@ -1,6 +1,6 @@ #!/bin/bash ## Hermeticity: hostile repo-local git config and attributes, in the subject -## or the target repo, or hostile git env inherited from a caller, +## or the clone being imported into, or hostile git env inherited from a caller, ## must not change what patchette writes or replays. ## ## Nulling global and system config doesn't reach a repo's own .git/config, @@ -20,7 +20,7 @@ make_corpus "$work/corpus" source "$work/corpus/FACTS" branches=(topic/fancy docs-polish odd-bytes) want_tips=$(printf '%s\n' "$tip_topic_fancy" "$tip_docs_polish" "$tip_odd_bytes") -"$PATCHETTE" export "$work/corpus/dev" "$work/clean" "$base" "${branches[@]}" >/dev/null +"$PATCHETTE" export --subject="$work/corpus/dev" --patches="$work/clean" "$base" "${branches[@]}" >/dev/null printf 'odd/trailing.txt\n' >"$work/orderfile" # Env-case fixtures: a tree holding a hostile .gitattributes (in the subject's # object store), a graft that turns docs-polish's tip into a root, @@ -133,17 +133,17 @@ for c in "${cases[@]}"; do # Command failures here surface on stderr just above the FAIL they cause. cp -a "$work/corpus/dev" "$d/subject" hostilize "$d/subject" "$c" - env "${HOSTILE_ENV[@]}" "$PATCHETTE" export "$d/subject" "$d/out" "$base" "${branches[@]}" >/dev/null || true + env "${HOSTILE_ENV[@]}" "$PATCHETTE" export --subject="$d/subject" --patches="$d/out" "$base" "${branches[@]}" >/dev/null || true EXPECT success "subject has $c: export bytes match a clean export" -- \ diff -r "$work/clean" "$d/out" EXPECT success "subject has $c: verify passes" -- \ - env "${HOSTILE_ENV[@]}" "$PATCHETTE" verify "$d/subject" "$work/clean" + env "${HOSTILE_ENV[@]}" "$PATCHETTE" verify --subject="$d/subject" --patches="$work/clean" - git clone -q "$work/corpus/upstream" "$d/target" - hostilize "$d/target" "$c" - env "${HOSTILE_ENV[@]}" "$PATCHETTE" import "$d/target" "$work/clean" >/dev/null || true - EXPECT equal "target has $c: import reproduces exact tips" -- \ - "$(git -C "$d/target" rev-parse "${branches[@]/#/refs/heads/}" 2>/dev/null || true)" "$want_tips" + git clone -q "$work/corpus/upstream" "$d/clone" + hostilize "$d/clone" "$c" + env "${HOSTILE_ENV[@]}" "$PATCHETTE" import --subject="$d/clone" --patches="$work/clean" >/dev/null || true + EXPECT equal "import clone has $c: reproduces exact tips" -- \ + "$(git -C "$d/clone" rev-parse "${branches[@]/#/refs/heads/}" 2>/dev/null || true)" "$want_tips" done exit "$RESULT" diff --git a/tests/test-optional-tip.sh b/tests/test-optional-tip.sh index ecbda59..baf3a15 100755 --- a/tests/test-optional-tip.sh +++ b/tests/test-optional-tip.sh @@ -12,15 +12,15 @@ trap 'rm -rf "$work"' EXIT make_corpus "$work/corpus" source "$work/corpus/FACTS" -"$PATCHETTE" export "$work/corpus/dev" "$work/patches" "$base" topic/fancy >/dev/null +"$PATCHETTE" export --subject="$work/corpus/dev" --patches="$work/patches" "$base" topic/fancy >/dev/null sed -i '/^tip /d' "$work/patches/topic¦fancy/STACK" -"$PATCHETTE" verify "$work/corpus/dev" "$work/patches" >"$work/verify.out" +"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$work/patches" >"$work/verify.out" EXPECT success "verify passes on a tipless STACK, noting it couldn't cross-check" -- \ grep -q "records no tip to cross-check" "$work/verify.out" git clone -q "$work/corpus/upstream" "$work/fresh" -"$PATCHETTE" import "$work/fresh" "$work/patches" >"$work/import.out" +"$PATCHETTE" import --subject="$work/fresh" --patches="$work/patches" >"$work/import.out" EXPECT equal "import reproduces the branch from a tipless STACK" -- \ "$(git -C "$work/fresh" rev-parse refs/heads/topic/fancy)" "$tip_topic_fancy" EXPECT equal "import stays silent about tips it cannot compare" -- \ diff --git a/tests/test-pairing.sh b/tests/test-pairing.sh new file mode 100755 index 0000000..10cf69f --- /dev/null +++ b/tests/test-pairing.sh @@ -0,0 +1,80 @@ +#!/bin/bash +## Pairing: `patchette pair` records a subject repo's patches dir in the +## common git dir's `patchette` file, letting verbs run flagless from inside +## any of the repo's worktrees. Explicit flags win per value; removing the +## file un-pairs cleanly. +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" +dev="$work/corpus/dev" + +## Run patchette with a given cwd (pairing resolution is cwd-sensitive). +## +## - @param $1 directory to run in +## - @param $@ patchette args +patchette_in() { + local d=$1 + shift + (cd "$d" && "$PATCHETTE" "$@") +} + +EXPECT refusal "flagless verbs in an unpaired repo refuse helpfully" "not paired" -- \ + patchette_in "$dev" verify + +EXPECT refusal "pair refuses a nonexistent patches dir" "no such directory" -- \ + patchette_in "$dev" pair ../no-such-dir +EXPECT success "and creates nothing" -- \ + test ! -e "$work/corpus/no-such-dir" + +"$PATCHETTE" export --subject="$dev" --patches="$work/explicit" "$base" topic/fancy docs-polish >/dev/null + +mkdir "$work/corpus/patches" +patchette_in "$dev" pair ../patches >/dev/null +EXPECT success "pair writes git-config format readable by git config" -- \ + test "$(git config --file "$dev/.git/patchette" --get patchette.patches)" = "../patches" +EXPECT success "flagless pair reports the pairing" -- \ + bash -c 'cd "$1" && "$2" pair | grep -q "patches dir"' -- "$dev" "$PATCHETTE" + +patchette_in "$dev" export "$base" topic/fancy docs-polish >/dev/null +EXPECT success "flagless export inside the paired repo matches an explicit export byte-for-byte" -- \ + diff -r "$work/explicit" "$work/corpus/patches" + +EXPECT success "flagless verify inside the paired repo passes" -- \ + patchette_in "$dev" verify +EXPECT success "flagless verify works from a subdirectory too" -- \ + patchette_in "$dev/widgets" verify + +# Pairing is repo-wide, not per-worktree: it rides the common git dir. +git -C "$dev" worktree add -q "$work/dev-wt" topic/fancy +EXPECT success "a linked worktree sees the pairing made in the main worktree" -- \ + patchette_in "$work/dev-wt" verify +patchette_in "$work/dev-wt" pair "$work/explicit" >/dev/null +EXPECT success "pairing from a linked worktree rewrites the one shared pairing" -- \ + bash -c 'cd "$1" && "$2" pair | grep -q "explicit"' -- "$dev" "$PATCHETTE" +patchette_in "$dev" pair ../patches >/dev/null + +# Explicit flags win, per value: aim --patches at a tampered copy while the +# subject still comes from the pairing, and the refusal proves what was read. +cp -r "$work/explicit" "$work/tampered" +sed -i 's/> The hum is load-bearing./> The hum is decorative./' "$work/tampered/topic¦fancy/teach-gizmo-to-hum.patch" +EXPECT refusal "an explicit flag wins over the pairing, per value" "MISMATCH" -- \ + patchette_in "$dev" verify --patches="$work/tampered" + +# The recreate journey, flagless: pair a fresh clone, import inside it. +git clone -q "$work/corpus/upstream" "$work/fresh" +patchette_in "$work/fresh" pair "$work/explicit" >/dev/null +patchette_in "$work/fresh" import >/dev/null +EXPECT equal "flagless import inside a paired fresh clone reproduces tips" -- \ + "$(git -C "$work/fresh" rev-parse refs/heads/topic/fancy)" "$tip_topic_fancy" + +rm "$dev/.git/patchette" +EXPECT refusal "removing the pairing file un-pairs cleanly" "not paired" -- \ + patchette_in "$dev" verify + +exit "$RESULT" diff --git a/tests/test-roundtrip.sh b/tests/test-roundtrip.sh index bd0a503..0244971 100755 --- a/tests/test-roundtrip.sh +++ b/tests/test-roundtrip.sh @@ -15,8 +15,8 @@ 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 odd-bytes >/dev/null - "$PATCHETTE" export "--sigil=$sigil" "$work/corpus/dev" "$p/again" "$base" topic/fancy docs-polish odd-bytes >/dev/null + "$PATCHETTE" export "--sigil=$sigil" --subject="$work/corpus/dev" --patches="$p/patches" "$base" topic/fancy docs-polish odd-bytes >/dev/null + "$PATCHETTE" export "--sigil=$sigil" --subject="$work/corpus/dev" --patches="$p/again" "$base" topic/fancy docs-polish odd-bytes >/dev/null EXPECT success "$sigil: export is regeneration-stable (two runs byte-identical)" -- \ diff -r "$p/patches" "$p/again" @@ -31,12 +31,12 @@ for sigil in quote tab; do grep -qxF "$want" "$sample" EXPECT success "$sigil: verify passes on a fresh export" -- \ - "$PATCHETTE" verify "$work/corpus/dev" "$p/patches" + "$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$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 + "$PATCHETTE" import --subject="$p/fresh" --patches="$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" -- \ diff --git a/tests/test-usage.sh b/tests/test-usage.sh new file mode 100755 index 0000000..81d87c1 --- /dev/null +++ b/tests/test-usage.sh @@ -0,0 +1,121 @@ +#!/bin/bash +## Runs the usage guide's examples. Each `.../session` hunk in docs/userguide/USAGE.md +## is a terminal session: its prompt lines (`~/dir$ command`, or `/dir$ command`) are run in order, in a fresh sandbox, +## and the whole session as it ran -- prompts, commands, output -- must match the hunk. +## A `paired/NAME/session` starts where `fresh/setting-up/session` leaves off (its commands are run first, quietly); a `fresh/NAME/session` starts from the bare sandbox. +## `snapshot/session` is a paired session that also shows two of the files it wrote. +## Shown output has trailing whitespace stripped: it's invisible in the rendered doc, and editors that trim it on save would otherwise break the checks. +## Run with TESTMARK_REGEN=1 to rewrite the sessions from what patchette does now; review with `git diff`. +# No set -e up here: testmark-bash mis-reports failing cases under it. Cases run under set -euo pipefail regardless. +source "${TESTMARK_BASH:-$(dirname "${BASH_SOURCE[0]}")/../../../warplibs/testmark-bash}/lib/testmark.bash" || exit 1 + +## session_commands prints a session's commands, one per line: its prompt lines (`~/dir$ command`, or `/dir$ command`), without their prompts. +## Everything else in a session is output, and skipped. +## +## - @stdin a session +## - @stdout its commands +session_commands() { + local prompt_re='^[~/][^$]*\$ (.*)$' line + while IFS= read -r line; do + if [[ $line =~ $prompt_re ]]; then printf '%s\n' "${BASH_REMATCH[1]}"; fi + done +} + +## run_prelude runs another session's commands in this shell, quietly: the starting point for a session, +## so its `cd`s and settings carry into the session. +## A failing command stops it, printing the command and its output on stderr, so the case fails, even under regen; +## the caller then passes the session through untouched, so regen can't write over it. +## +## - @param $1 path to the prelude session +## - @stderr on failure only: the failed command, its output, and its exit status +run_prelude() { + local cmd rc capture + local -a cmds + capture=$(mktemp) + mapfile -t cmds < <(session_commands <"$1") + for cmd in "${cmds[@]}"; do + rc=0 + eval "$cmd" </dev/null >"$capture" 2>&1 || rc=$? + if [[ $rc -ne 0 ]]; then + >&2 printf 'prelude command failed, exit status %s: %s\n' "$rc" "$cmd" + >&2 cat "$capture" + rm -f "$capture" + return 1 + fi + done + rm -f "$capture" +} + +## replay_session runs a session's commands in this shell, in order, and prints the session as it ran. +## Each command is printed after a prompt naming the cwd (as `~`-relative), then what it printed, +## with the home dir shown as `~` there too, and trailing whitespace stripped; a nonzero exit adds an `[exit status: N]` line. +## A blank line separates each command from the output before it. +## The old session's other lines (its old output, and blank lines) are dropped: they're what gets regenerated. +## Commands get /dev/null on stdin, so nothing can wait on input. +## +## - @stdin the session, as written +## - @stdout the session, as it ran +replay_session() { + local cmd rc out sep='' capture + local -a cmds + capture=$(mktemp) + mapfile -t cmds < <(session_commands) + for cmd in "${cmds[@]}"; do + printf '%s%s$ %s\n' "$sep" "${PWD/#"$HOME"/"~"}" "$cmd" + sep=$'\n' + rc=0 + eval "$cmd" </dev/null >"$capture" 2>&1 || rc=$? + out=$(sed 's/[[:space:]]*$//' "$capture") + [[ -z $out ]] || printf '%s\n' "${out//"$HOME"/"~"}" + [[ $rc -eq 0 ]] || printf '[exit status: %s]\n' "$rc" + done + rm -f "$capture" +} + +## run_in_sandbox runs the case's session in a fresh sandbox home, after another session's commands, if one is named. +## The sandbox: `~/upstream/frob` is the upstream, and `~/src/frob` a clone of it, with `main` checked out, +## carrying the branches `topic/fancy`, `docs-polish`, and `odd-bytes` (see make_corpus). +## Git's identity and clock are pinned, so commits made in a session are reproducible. +## +## - @param $1 optional: the doc hunk of the session to start after +run_in_sandbox() { + source "$TESTCASE_DIR/lib.sh" + testcase_withtmpdir + make_corpus corpus + export HOME=$PWD/home + mkdir -p "$HOME/upstream" "$HOME/src" + mv corpus/upstream "$HOME/upstream/frob" + mv corpus/dev "$HOME/src/frob" + git -C "$HOME/src/frob" remote set-url origin "$HOME/upstream/frob" + git -C "$HOME/src/frob" switch -q main + export PATH="${PATCHETTE%/patchette}:$PATH" + export GIT_AUTHOR_DATE='@1700086400 +0000' GIT_COMMITTER_DATE='@1700086400 +0000' + local prelude=$PWD/prelude.sh + : >"$prelude" + if [[ -n ${1:-} ]]; then testcase_doc_hunk "$1" >"$prelude"; fi + cd "$HOME" + testcase_hunk session | + bash --norc --noprofile -c "$(declare -f session_commands run_prelude replay_session); if run_prelude \"\$1\"; then replay_session; else cat; exit 1; fi" _ "$prelude" | + testcase_output session +} + +exercise_fresh() { + run_in_sandbox +} + +exercise_paired() { + run_in_sandbox fresh/setting-up/session +} + +## exercise_snapshot runs the first-export session, then shows two of the files it wrote, each as an output of its own. +exercise_snapshot() { + run_in_sandbox fresh/setting-up/session + sed 's/[[:space:]]*$//' "$HOME/src/frob-patches/docs-polish/STACK" | testcase_output STACK + sed 's/[[:space:]]*$//' "$HOME/src/frob-patches/docs-polish/polish-the-readme.patch" | testcase_output polish-the-readme.patch +} + +testmarktest_provide session_commands run_prelude replay_session run_in_sandbox +testmarktest_rule 'fresh/*/session' exercise_fresh +testmarktest_rule 'paired/*/session' exercise_paired +testmarktest_rule 'snapshot/session' exercise_snapshot +testmarktest_run ../docs/userguide/USAGE.md