grammar: repo locations are flags; positionals are revs and branches only
commit 023d3c7674edecf6f931f2a38cbde6759380889d · parent 6f031e7658
· browse this tree
Ximianth, 2026-10-19 15:43:35 UTC
Repos were positional, and the planned paired short form would have collided with them on the same positions -- inviting parse rules that depend on what happens to exist on the filesystem. Uprooted: repo locations now travel as --subject=<repo> and --patches=<dir> on all three verbs, so a command line parses the same way everywhere, and the upcoming pairing can simply be the default for absent flags. There is also no 'target' anymore: import's destination is the same subject-repo concept the other verbs have, and now wears the same name everywhere -- flags, variables, and messages. Rides along, since it lives in the same argument grammar: - import and verify accept branch names as positionals: operate on just those folders. Narrowing narrows validation too, so an unrelated broken folder doesn't block work on the named ones. A name matching no branch folder is an error. - import -f updates an existing ref where plain import still refuses (the refusal mentions -f). The update keeps the old-value guard (empty = must-not-exist, SHA = compare-and-swap) and writes a reflog entry even in bare repos, so the previous tip stays findable. Like git branch -f, it still refuses a branch that some checkout has checked out, main checkout and linked worktrees alike: moving it would leave that checkout's index and files reading as a staged revert. (git also refuses a branch mid-rebase or mid-bisect in a worktree; worktree list doesn't expose that state, so -f doesn't see it.)
diff --git a/bin/patchette b/bin/patchette index b77334f..ced4e3a 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 @@ -106,11 +106,12 @@ usage() { 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 EOF } @@ -308,7 +309,7 @@ replay_series() { 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. try "$(q "$f"): diff does not apply onto its parent" \ @@ -330,45 +331,66 @@ replay_series() { } ## 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 "$(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 $(q "$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=} ;; + --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") + [[ -n $subject && -n $patches && $# -ge 2 ]] || + die "usage: patchette export [--sigil=quote|--sigil=tab] --subject=<repo> --patches=<dir> <base-rev> <branch>..." + repo=$(absdir "$subject") + mkdir -p "$patches" + out=$(absdir "$patches") PATCHES_DIR_ABS=$out - baserev=$3 - shift 3 + baserev=$1 + shift base=$(git -C "$repo" rev-parse --verify -q "$baserev^{commit}") || die "base revision does not resolve to a commit: $(q "$baserev")" @@ -473,27 +495,76 @@ 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") + 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 + [[ -n $subject && -n $patches ]] || + die "usage: patchette import [-f] --subject=<repo> --patches=<dir> [<branch>...]" + subject=$(absdir "$subject") + patches=$(absdir "$patches") PATCHES_DIR_ABS=$patches - branch_dirs "$patches" - cd "$target" + 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 "$(q "$STACK_REF") already exists in $(q "$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 $(q "$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. - try "could not create $(q "$STACK_REF") (created concurrently?)" \ - 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 @@ -501,12 +572,21 @@ 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") + 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 + [[ -n $subject && -n $patches ]] || + die "usage: patchette verify --subject=<repo> --patches=<dir> [<branch>...]" + subject=$(absdir "$subject") + patches=$(absdir "$patches") PATCHES_DIR_ABS=$patches - branch_dirs "$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. 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/tests/lib.sh b/tests/lib.sh index 6fc8495..32d53ce 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), diff --git a/tests/test-errors.sh b/tests/test-errors.sh index 2564e32..0512815 100755 --- a/tests/test-errors.sh +++ b/tests/test-errors.sh @@ -13,12 +13,12 @@ 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 # 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 "$work/corpus/dev" "$work/patches" >"$work/out" 2>&1 || rc=$? +"$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" @@ -35,7 +35,7 @@ EXPECT equal "and in that order" -- "$order" "ordered" git clone -q "$work/corpus/upstream" "$work/fresh" git -C "$work/fresh" branch topic/fancy main rc=0 -(cd "$work" && "$PATCHETTE" import fresh patches) >"$work/out2" 2>&1 || rc=$? +(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" diff --git a/tests/test-failclosed.sh b/tests/test-failclosed.sh index 6972c63..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" + "$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 "$work/orphantarget" "$TAMPER" + "$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,12 +136,12 @@ 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")" "" @@ -153,13 +153,13 @@ 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 "$work/holey-dev" "$TAMPER" "$base" 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-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" -- \