diff --git a/tests/run.sh b/tests/run.sh
index 7cb6cec..0f10057 100644
--- a/tests/run.sh
+++ b/tests/run.sh
@@ -45,7 +45,6 @@ FAIL=0
WORK=$(mktemp -d 2>/dev/null || echo "/tmp/tl-tests.$$")
mkdir -p "$WORK/proj/.claude/throughline/buffer"
-( cd "$WORK/proj" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
CLAUDE_PROJECT_DIR="$WORK/proj"
THROUGHLINE_DATA_DIR=".claude/throughline"
export CLAUDE_PROJECT_DIR THROUGHLINE_DATA_DIR
@@ -67,6 +66,34 @@ cap() { printf '%s' "$1" | sh "$H/session-capture.sh"; }
prompt() { printf '%s' "$1" | sh "$H/session-prompt.sh"; }
precompact() { printf '%s' "$1" | sh "$H/session-precompact.sh"; }
reset_buf() { rm -f "$BUF"/session-*.md "$DATA"/.capture-errors; mkdir -p "$BUF"; chmod 755 "$BUF" 2>/dev/null; }
+# fixture_repo
- git init + initial commit in (self-contained per
+# the GIT_AUTHOR_* exports above). On failure, reports via bad() naming the
+# dir instead of leaving the caller silently skipped - the same debugging
+# trap #42/#43 closed for the worktree-specific chains, closed here for the
+# remaining plain git-repo fixtures (#44). The dir/empty-arg guard exists
+# because this helper's entire purpose is turning silent fixture failure
+# loud - `cd ""` is a POSIX sh no-op that would otherwise silently git-init
+# the caller's own cwd instead of failing.
+fixture_repo() {
+ if [ -z "${1:-}" ] || [ ! -d "$1" ]; then
+ bad "fixture setup failed: ${1:-} (no such dir)"
+ return
+ fi
+ ( cd "$1" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null \
+ || bad "fixture setup failed: $1 (git init/commit)"
+}
+# fixture_repo_gitignore - same as fixture_repo, but for fixtures that
+# stage and commit a pre-written .gitignore instead of an empty commit.
+fixture_repo_gitignore() {
+ if [ -z "${1:-}" ] || [ ! -d "$1" ]; then
+ bad "fixture setup failed: ${1:-} (no such dir)"
+ return
+ fi
+ ( cd "$1" && git init -q && git add .gitignore && git commit -q -m init ) 2>/dev/null \
+ || bad "fixture setup failed: $1 (git init/add .gitignore/commit)"
+}
+
+fixture_repo "$WORK/proj"
echo "throughline hook tests"
echo "----------------------"
@@ -568,7 +595,7 @@ ok "flush/precompact did not crash without jq"
# no longer silent.
FRESH_A="$WORK/fresh-a"
mkdir -p "$FRESH_A"
-( cd "$FRESH_A" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_A"
O5=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_A" sh "$H/session-onboard.sh")
has "onboard is no longer silent on a fresh project" "$O5" 'throughline'
dir_present "auto-activation created the configured data dir" "$FRESH_A/.claude/throughline"
@@ -579,7 +606,7 @@ dir_present "auto-activation created the configured data dir" "$FRESH_A/.claude/
# convenience) would otherwise mask this exact case.
FRESH_B="$WORK/fresh-b"
mkdir -p "$FRESH_B"
-( cd "$FRESH_B" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_B"
O6=$(printf '%s' '{"source":"startup","session_id":"T"}' | (unset THROUGHLINE_DATA_DIR; CLAUDE_PROJECT_DIR="$FRESH_B" sh "$H/session-onboard.sh"))
has "onboard auto-activates at the default path when unset" "$O6" 'throughline'
dir_present "default .claude/throughline/ dir created when unset" "$FRESH_B/.claude/throughline"
@@ -589,7 +616,7 @@ dir_present "default .claude/throughline/ dir created when unset" "$FRESH_B/.cla
# the opt-out escape hatch.
FRESH_C="$WORK/fresh-c"
mkdir -p "$FRESH_C"
-( cd "$FRESH_C" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_C"
: > "$FRESH_C/.throughlineignore"
O7=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_C" sh "$H/session-onboard.sh")
eq "onboard stays silent when .throughlineignore is present" "$O7" ""
@@ -602,7 +629,7 @@ absent "capture writes nothing under an ignored project" "$FRESH_C/.claude/throu
# helper, not wired to one specific hook.
FRESH_D="$WORK/fresh-d"
mkdir -p "$FRESH_D"
-( cd "$FRESH_D" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_D"
printf '%s' '{"session_id":"T","tool_name":"Bash","tool_input":{"description":"x","command":"id"}}' | CLAUDE_PROJECT_DIR="$FRESH_D" sh "$H/session-capture.sh"
present "capture-first auto-activates and writes a buffer entry" "$FRESH_D/.claude/throughline/buffer/session-T.md"
@@ -612,7 +639,7 @@ present "capture-first auto-activates and writes a buffer entry" "$FRESH_D/.clau
# masquerading as "user opted out."
FRESH_E="$WORK/fresh-e"
mkdir -p "$FRESH_E"
-( cd "$FRESH_E" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_E"
if [ "$(id -u)" != "0" ]; then
chmod 555 "$FRESH_E" 2>/dev/null
O8=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_E" sh "$H/session-onboard.sh")
@@ -640,14 +667,14 @@ fi
# 12g3 below for the exact regression this would otherwise miss.
FRESH_F="$WORK/fresh-f"
mkdir -p "$FRESH_F"
-( cd "$FRESH_F" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_F"
O9=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_F" sh "$H/session-onboard.sh")
has "first activation nudges to gitignore the buffer when not covered" "$O9" 'not gitignored yet'
FRESH_G="$WORK/fresh-g"
mkdir -p "$FRESH_G"
printf '.claude/throughline/buffer/\n' > "$FRESH_G/.gitignore"
-( cd "$FRESH_G" && git init -q && git add .gitignore && git commit -q -m init ) 2>/dev/null
+fixture_repo_gitignore "$FRESH_G"
O10=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_G" sh "$H/session-onboard.sh")
hasnt "no gitignore nudge when the buffer is already covered" "$O10" 'not gitignored yet'
@@ -659,7 +686,7 @@ hasnt "no gitignore nudge when the buffer is already covered" "$O10" 'not gitign
FRESH_G2="$WORK/fresh-g2"
mkdir -p "$FRESH_G2"
printf '.claude/throughline/\n' > "$FRESH_G2/.gitignore"
-( cd "$FRESH_G2" && git init -q && git add .gitignore && git commit -q -m init ) 2>/dev/null
+fixture_repo_gitignore "$FRESH_G2"
O10B=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_G2" sh "$H/session-onboard.sh")
hasnt "no gitignore nudge when the whole data dir (unwildcarded) covers the buffer too" "$O10B" 'not gitignored yet'
@@ -676,7 +703,7 @@ hasnt "no gitignore nudge when the whole data dir (unwildcarded) covers the buff
FRESH_G3="$WORK/fresh-g3"
mkdir -p "$FRESH_G3"
printf '.claude/throughline/*\n!.claude/throughline/buffer/\n' > "$FRESH_G3/.gitignore"
-( cd "$FRESH_G3" && git init -q && git add .gitignore && git commit -q -m init ) 2>/dev/null
+fixture_repo_gitignore "$FRESH_G3"
O10C=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_G3" sh "$H/session-onboard.sh")
has "gitignore nudge still fires when a wildcard+negation pattern falsely un-ignores the buffer" "$O10C" 'not gitignored yet'
@@ -687,7 +714,7 @@ has "gitignore nudge still fires when a wildcard+negation pattern falsely un-ign
# session's bookkeeping.
FRESH_H="$WORK/fresh-h"
mkdir -p "$FRESH_H"
-( cd "$FRESH_H" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_H"
printf '%s' '{"session_id":"T","tool_name":"Bash","tool_input":{"description":"x","command":"id"}}' | CLAUDE_PROJECT_DIR="$FRESH_H" sh "$H/session-capture.sh"
: > "$FRESH_H/.throughlineignore"
printf '%s' '{"session_id":"T","reason":"end"}' | CLAUDE_PROJECT_DIR="$FRESH_H" sh "$H/session-flush.sh"
@@ -697,7 +724,7 @@ has "flush still stamps a session that was already tracked, despite a new .throu
# 12h. same reasoning, for precompact's boundary marker.
FRESH_I="$WORK/fresh-i"
mkdir -p "$FRESH_I"
-( cd "$FRESH_I" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_I"
printf '%s' '{"session_id":"T","tool_name":"Bash","tool_input":{"description":"x","command":"id"}}' | CLAUDE_PROJECT_DIR="$FRESH_I" sh "$H/session-capture.sh"
: > "$FRESH_I/.throughlineignore"
printf '%s' '{"session_id":"T","trigger":"manual"}' | CLAUDE_PROJECT_DIR="$FRESH_I" sh "$H/session-precompact.sh"
@@ -713,7 +740,7 @@ has "precompact still stamps a session that was already tracked, despite a new .
FRESH_J="$WORK/fresh-j"
mkdir -p "$FRESH_J/.claude/throughline"
printf -- '# Test\n**Last Updated:** 2024-01-01\n' > "$FRESH_J/.claude/throughline/HANDOFF.md"
-( cd "$FRESH_J" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_J"
: > "$FRESH_J/.throughlineignore"
O11=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_J" sh "$H/session-onboard.sh")
has "onboard still points to an existing HANDOFF.md despite .throughlineignore" "$O11" 'Durable handoff exists'
@@ -725,7 +752,7 @@ has "onboard still points to an existing HANDOFF.md despite .throughlineignore"
FRESH_K="$WORK/fresh-k"
mkdir -p "$FRESH_K/.claude/throughline"
printf -- '# Test\n**Last Updated:** 2024-01-01\n' > "$FRESH_K/.claude/throughline/HANDOFF.md"
-( cd "$FRESH_K" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_K"
O12=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_K" sh "$H/session-onboard.sh")
has "gitignore nudge still fires after a handoff has already run" "$O12" 'not gitignored yet'
@@ -734,7 +761,7 @@ has "gitignore nudge still fires after a handoff has already run" "$O12" 'not gi
# starts until the buffer is actually covered.
FRESH_L="$WORK/fresh-l"
mkdir -p "$FRESH_L"
-( cd "$FRESH_L" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_L"
mkdir -p "$FRESH_L/.claude/throughline"
O13=$(printf '%s' '{"source":"compact","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_L" sh "$H/session-onboard.sh")
hasnt "gitignore nudge is suppressed on a compact re-fire" "$O13" 'not gitignored yet'
@@ -750,7 +777,7 @@ hasnt "gitignore nudge is suppressed on a compact re-fire" "$O13" 'not gitignore
FRESH_M="$WORK/fresh-m"
OUTSIDE_DATA="$WORK/outside-data"
mkdir -p "$FRESH_M"
-( cd "$FRESH_M" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_M"
O14=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_M" THROUGHLINE_DATA_DIR="$OUTSIDE_DATA" sh "$H/session-onboard.sh")
hasnt "no gitignore nudge when the data dir is outside the git tree" "$O14" 'not gitignored yet'
@@ -761,7 +788,7 @@ hasnt "no gitignore nudge when the data dir is outside the git tree" "$O14" 'not
# empty do NOT disable.
FRESH_N="$WORK/fresh-n"
mkdir -p "$FRESH_N"
-( cd "$FRESH_N" && git init -q && git commit -q --allow-empty -m init ) 2>/dev/null
+fixture_repo "$FRESH_N"
O15=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_N" THROUGHLINE_DISABLE=1 sh "$H/session-onboard.sh")
eq "disabled: onboard is fully silent on a fresh project" "$O15" ""
absent "disabled: onboard does not bootstrap a data dir" "$FRESH_N/.claude"