patchette

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"