From 0b1d515df98a861be5269e24143fceaf4a8eca1b Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Tue, 25 Aug 2026 23:04:18 -0500 Subject: [PATCH 1/3] fix(#44): harden remaining fixture blocks against silent setup failure Adds a fixture_repo() helper that runs git init + an initial commit in a throwaway dir and, on failure, reports via bad() naming the dir - instead of the bare ( ... ) 2>/dev/null a setup failure could silently vanish into, surfacing downstream as a bogus assertion failure against hooks/_lib.sh rather than "the fixture didn't build". Replaces the 14 remaining unguarded git-init-and-commit fixture blocks (the project-root fixture plus FRESH_A..N). The 3 worktree-specific chains already hardened in #43 are left as-is; they run additional commands (git worktree add) beyond what this helper covers. Closes #44 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011s2om7vED2R8ZohhkN11qe --- tests/run.sh | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index 7cb6cec..f54f8c7 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,17 @@ 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). +fixture_repo() { + ( 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 "$WORK/proj" echo "throughline hook tests" echo "----------------------" @@ -568,7 +578,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 +589,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 +599,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 +612,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 +622,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,7 +650,7 @@ 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' @@ -687,7 +697,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 +707,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 +723,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 +735,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 +744,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 +760,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 +771,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" From fafc06ee2f102ed8113f90e1cb839916ec1b8877 Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Tue, 25 Aug 2026 23:10:14 -0500 Subject: [PATCH 2/3] fix(#44): cover the 3 .gitignore fixture blocks review flagged as missed Extends the hardening to FRESH_G/G2/G3, which stage and commit a pre-written .gitignore rather than an empty commit and so didn't fit fixture_repo() verbatim - adds fixture_repo_gitignore() for that shape. These three back the gitignore-nudge tests (12g/12g2/12g3), the exact class of assertion #42 documented as misreporting a silent fixture failure as a hooks/_lib.sh bug. Also hardens fixture_repo() itself against its own worst failure mode: under POSIX sh, `cd ""` is a no-op rather than an error, so an empty or missing $1 would silently git-init the caller's own cwd instead of reporting via bad(). Not reachable from any current call site (every caller passes a non-empty, freshly-mkdir'd path), but a helper whose entire purpose is turning silent failure loud should not have a silent failure mode of its own. Both gaps found by /review-pr on PR #63; verified live (empty-arg fired bad() with no cwd side effect; a stray probe commit created during verification was cleanly reset before this commit). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011s2om7vED2R8ZohhkN11qe --- tests/run.sh | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index f54f8c7..865b6f8 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -70,11 +70,22 @@ reset_buf() { rm -f "$BUF"/session-*.md "$DATA"/.capture-errors; mkdir -p "$BUF" # 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). +# 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() { + [ -n "${1:-}" ] && [ -d "$1" ] || { bad "fixture setup failed: ${1:-} (no such dir)"; return; } ( 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() { + [ -n "${1:-}" ] && [ -d "$1" ] || { bad "fixture setup failed: ${1:-} (no such dir)"; return; } + ( 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" @@ -657,7 +668,7 @@ has "first activation nudges to gitignore the buffer when not covered" "$O9" 'no 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' @@ -669,7 +680,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' @@ -686,7 +697,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' From be2c114a1028ce326c19edaaf1ae383b3fbeba0e Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Tue, 25 Aug 2026 23:12:05 -0500 Subject: [PATCH 3/3] fix(#44): avoid SC2015 A&&B||C flagged by CI's shellcheck version CI's apt-installed shellcheck flags the &&/|| guard added in fafc06e (SC2015: not if-then-else, the else branch can run when the first check is true) even though local shellcheck 0.11.0 (homebrew) doesn't. Rather than chase version skew, switch both guards to an explicit if/then - unambiguous either way and closer to this file's existing style (see present()/dir_present()/absent() above). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011s2om7vED2R8ZohhkN11qe --- tests/run.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index 865b6f8..0f10057 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -75,14 +75,20 @@ reset_buf() { rm -f "$BUF"/session-*.md "$DATA"/.capture-errors; mkdir -p "$BUF" # loud - `cd ""` is a POSIX sh no-op that would otherwise silently git-init # the caller's own cwd instead of failing. fixture_repo() { - [ -n "${1:-}" ] && [ -d "$1" ] || { bad "fixture setup failed: ${1:-} (no such dir)"; return; } + 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() { - [ -n "${1:-}" ] && [ -d "$1" ] || { bad "fixture setup failed: ${1:-} (no such dir)"; return; } + 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)" }