pairing: one pairing per repo, in the common git dir
commit ca2dbbfbbecc3ac4a25e11e2f5d43e01ef518009 · parent 023d3c7674
· browse this tree
Ximianth, 2026-10-19 15:48:02 UTC
A new pair verb, run anywhere inside a subject repo's worktrees, records the patches dir in <common-git-dir>/patchette: git-config format (read and written via git config --file), one patchette.patches key holding the path relative to the repo root. The common dir is the point: branches are repo-wide, so pairing is repo-wide, and every linked worktree reads and writes the same pairing. Only the standard root/.git layout can anchor the relative path; separate-git-dir and bare repos are refused with a pointer at the explicit flags. Verbs fill absent --subject/--patches values from the pairing, each flag overriding independently: explicit always wins, per value. There is no implicit write anywhere: pairing exists only when the user ran pair, removing the file un-pairs, and pair refuses a nonexistent patches dir rather than creating state as a side effect (creating directories is export's job).
diff --git a/bin/patchette b/bin/patchette index ced4e3a..732fbca 100755 --- a/bin/patchette +++ b/bin/patchette @@ -113,6 +113,9 @@ usage() { 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 } @@ -330,6 +333,71 @@ replay_series() { done } +## Locate the pairing anchor of the repo containing the cwd: the common git +## dir (shared by all worktrees -- branches are repo-wide, so pairing is too) +## and the repo root derived from it. +## Only the standard `<root>/.git` layout can anchor a pairing; a repo whose +## git dir lives wholly elsewhere (separate-git-dir, bare) has no root to +## derive, and gets the explicit flags instead. +## +## - @param $1 context for refusal messages, e.g. "--subject/--patches not given, and" +## - @state PAIR_COMMON, PAIR_ROOT -- absolute paths +locate_pairing_anchor() { + local context=$1 + PAIR_COMMON=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "$context the cwd is not inside a git repository" + PAIR_ROOT=${PAIR_COMMON%/.git} + [[ $PAIR_ROOT != "$PAIR_COMMON" ]] || + die "$context this repo's git dir $(q "$PAIR_COMMON") lives outside a worktree root, so pairing can't anchor a relative path; pass explicit flags" +} + +## Resolve the paired subject repo and patches dir (see cmd_pair). +## +## - @state PAIRED_SUBJECT, PAIRED_PATCHES -- absolute paths +read_pairing() { + local rel + locate_pairing_anchor "--subject/--patches not given, and" + rel=$(git config --file "$PAIR_COMMON/patchette" --get patchette.patches 2>/dev/null) || + die "--subject/--patches not given, and $(q "$PAIR_ROOT") is not paired; run 'patchette pair <patches-dir>' there, or pass the flags" + PAIRED_SUBJECT=$PAIR_ROOT + PAIRED_PATCHES=$PAIR_ROOT/$rel +} + +## Fill absent --subject/--patches values from the pairing. +## Each flag overrides independently: explicit always wins, per value. +## +## - @param $1 current --subject value (may be empty) +## - @param $2 current --patches value (may be empty) +## - @state RESOLVED_SUBJECT, RESOLVED_PATCHES +resolve_repos() { + RESOLVED_SUBJECT=$1 + RESOLVED_PATCHES=$2 + if [[ -z $RESOLVED_SUBJECT || -z $RESOLVED_PATCHES ]]; then + read_pairing + RESOLVED_SUBJECT=${RESOLVED_SUBJECT:-$PAIRED_SUBJECT} + RESOLVED_PATCHES=${RESOLVED_PATCHES:-$PAIRED_PATCHES} + fi +} + +cmd_pair() { + [[ $# -le 1 ]] || die "usage: patchette pair [<patches-dir>]" + local abs rel + locate_pairing_anchor "pair:" + if [[ $# -eq 0 ]]; then + rel=$(git config --file "$PAIR_COMMON/patchette" --get patchette.patches 2>/dev/null) || + die "$(q "$PAIR_ROOT") is not paired" + echo "paired: patches dir is $(q "$rel") (relative to $(q "$PAIR_ROOT"))" + return + fi + # No mkdir here: pairing states a fact about something that exists; + # creating directories is export's job. + abs=$(absdir "$1") + rel=$(realpath --relative-to="$PAIR_ROOT" "$abs") + try "could not write the pairing file in $(q "$PAIR_COMMON")" \ + git config --file "$PAIR_COMMON/patchette" patchette.patches "$rel" + echo "paired: $(q "$PAIR_ROOT") -> patches dir $(q "$rel")" +} + ## List the branch folders of a patches dir, failing closed on strangers, ## optionally narrowed to named branches. The chosen folders' concordance is ## validated up front: discordance is a static property of the patches dir, @@ -383,11 +451,12 @@ cmd_export() { done local repo out baserev base branch dir slug sha tip n p mline count local parents scratch raw="$PTMP/raw" msg="$PTMP/msg" - [[ -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") + [[ $# -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 @@ -529,10 +598,9 @@ cmd_import() { esac shift done - [[ -n $subject && -n $patches ]] || - die "usage: patchette import [-f] --subject=<repo> --patches=<dir> [<branch>...]" - subject=$(absdir "$subject") - patches=$(absdir "$patches") + resolve_repos "$subject" "$patches" + subject=$(absdir "$RESOLVED_SUBJECT") + patches=$(absdir "$RESOLVED_PATCHES") PATCHES_DIR_ABS=$patches branch_dirs "$patches" "$@" cd "$subject" @@ -581,10 +649,9 @@ cmd_verify() { esac shift done - [[ -n $subject && -n $patches ]] || - die "usage: patchette verify --subject=<repo> --patches=<dir> [<branch>...]" - subject=$(absdir "$subject") - patches=$(absdir "$patches") + resolve_repos "$subject" "$patches" + subject=$(absdir "$RESOLVED_SUBJECT") + patches=$(absdir "$RESOLVED_PATCHES") PATCHES_DIR_ABS=$patches branch_dirs "$patches" "$@" @@ -615,7 +682,7 @@ cmd_verify() { } case ${1:-} in -export | import | verify) +export | import | verify | pair) cmd=$1 shift "cmd_$cmd" "$@" 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"