Skip to content

fix: rebuild-tsv.sh refuses an explicitly-empty CLAUDE_PROJECT_DIR (#417) - #419

Merged
fdaviddpt merged 4 commits into
mainfrom
fix/417
Sep 18, 2026
Merged

fdaviddpt merged 4 commits into
mainfrom
fix/417

Conversation

@fdaviddpt

Copy link
Copy Markdown
Contributor

rebuild-tsv.sh now refuses (exit 2) when CLAUDE_PROJECT_DIR is exported empty, instead of silently falling through to $PWD and rebuilding whatever tree the shell happens to be standing in.

The bug

${CLAUDE_PROJECT_DIR:-} reads "" both when the variable was never exported (the ordinary, documented bash scripts/rebuild-tsv.sh case, which must keep resolving against $PWD) and when something exported it as an empty string -- a caller that meant to name a tree, computed nothing, and exported the empty result anyway. The #231 cross-tree guard's own [ -n ... ] precondition could not tell those two apart, so the empty-but-set case took the same silent skip as a genuine unset and reached JIT_BASE's own $PWD fallback (common.sh) with zero refusal and zero note.

Observed on a maintainer clone: a tick running three unrelated lanes left a live test fixture (tools/00-manual/guard.md, a remind-mode rule matching git push) written into the real .claude/jit-context/, plus 12 real index rows lost in the same rebuild. Any session rooted at the clone in that window would have had the fixture rule fire on git push, and the 12 lost rows went inert with no error anywhere.

The fix

${CLAUDE_PROJECT_DIR+set} tells the two cases apart -- it reads set whenever the variable was exported at all, blank value included, and unset only when nothing ever touched it. An explicitly-empty CLAUDE_PROJECT_DIR is now refused outright (FATAL, exit 2) before the index write ever runs; a genuinely unset one still falls through to $PWD exactly as before.

jit-init.sh's own --base /.claude/jit-context case (seeding at the filesystem root) produces CLAUDE_PROJECT_DIR="" by design -- PROJECT is documented as the empty string for that case. It now derives CPD="${PROJECT:-/}" and passes "/" literally at every site that prints or exports CLAUDE_PROJECT_DIR, so that legitimate call keeps working under the tightened guard rather than hitting the new refusal (caught in two review rounds, including a third site -- the success-path receipt -- missed by the first round's own pinning test).

Testing

tests/test-cross-tree-write-231.sh gained two sections: H pins the new refusal (exit 2, stderr names the reason, tree not written), confirmed red before the fix and green after; I is a positive control confirming a genuinely-unset CLAUDE_PROJECT_DIR still works exactly as before, passing both before and after (proves the fix is additive). tests/test-jit-init.sh gained a regression test pinning every CLAUDE_PROJECT_DIR print/export site in jit-init.sh to the $CPD fallback via fixed-string grep, and asserting no raw $PROJECT interpolation remains outside a comment.

Ran the changelog fragment checker (python3 .oss/assemble_changelog.py --check) and the sibling suites that call rebuild-tsv.sh with real CLAUDE_PROJECT_DIR values -- all green. Did not run the full bash tests/run-all.sh; CI is the authority for the whole matrix.

Out of scope

scripts/jit-doctor.sh and common.sh's jit_worktree_mismatch_line() (shared by every hook) have the same unset-vs-set-empty ambiguity in their own diagnostic prose -- reporting "unset" for what may actually be set-but-empty. Left for a follow-up issue: a different file/subsystem (read-only diagnostic, not a writer) and shared logic used by every hook, out of this issue's declared blast radius.

Closes #417

[AI-generated]

Florian DAVID added 4 commits September 18, 2026 07:33
…stead of silently writing cwd (#417)

`${CLAUDE_PROJECT_DIR:-}` reads "" both when the variable was never exported
(the ordinary, documented `bash scripts/rebuild-tsv.sh` usage, which must keep
resolving against $PWD) and when something exported it as an empty string --
a caller that meant to name a tree, computed nothing, and exported the empty
result anyway. The #231 cross-tree guard's `[ -n ... ]` precondition could not
tell those two apart, so the empty-but-set case took the same silent skip as
a genuine unset and reached JIT_BASE's own $PWD fallback (common.sh) with
zero refusal and zero note -- observed on a maintainer clone as a live test
fixture written into the real .claude/jit-context/ and 12 real index rows
lost in the same rebuild.

${CLAUDE_PROJECT_DIR+set} now tells the two apart: an explicitly empty
CLAUDE_PROJECT_DIR is refused outright (exit 2, FATAL), and a genuinely
unset one still falls through to $PWD exactly as before.

Adds sections H (red before the fix, green after) and I (positive control:
a genuinely unset CLAUDE_PROJECT_DIR keeps working) to
tests/test-cross-tree-write-231.sh, and a changelog.d/417.fixed.md fragment.

Co-Authored-By: Max <noreply>
…OJECT_DIR (#417 review)

Self-review findings on the #417 fix:

- Explore reviewer found that scripts/jit-init.sh's own `--base
  /.claude/jit-context` case (seeding at the filesystem root) sets PROJECT to
  the empty string by design (resolve_dir()'s own comment), and then passed
  it straight through as `CLAUDE_PROJECT_DIR="$PROJECT"` to rebuild-tsv.sh --
  exactly the shape the #417 fix now refuses outright. Fixed by deriving
  `CPD="${PROJECT:-/}"` so the literal root is passed instead of its empty
  spelling; a fixed-string-pinned regression test was added to
  tests/test-jit-init.sh (driving the real root-seed path would require
  writing under the actual filesystem root, which this suite does not do).

- Same reviewer flagged that the new refusal's own comment overstated "before
  anything ever gets a chance to write" -- common.sh's pre-existing #51
  scaffolding (.discovery/state, .discovery/logs) can still be created before
  the check runs, gated on the tree already existing, same as the ordinary
  legitimate case. Tightened the comment and the changelog fragment to say
  "before the index is written" rather than "before anything".

The oss:auditor spawn returned NO FINDINGS on the original commit.

Co-Authored-By: Max <noreply>
…PROJECT (#417 second-pass review)

The second-pass review round on b2c089a caught a third site the first fix
round missed: scripts/jit-init.sh's success-path receipt
("Then write your own beside it, and rebuild:") still interpolated raw
$PROJECT rather than $CPD, because it is an unquoted `echo` interpolation
and not an assignment, so the earlier pinning test's assignment-shaped grep
could not see it. In the root-seed case (--base /.claude/jit-context,
PROJECT=""), a user following this tool's own printed advice would run
`CLAUDE_PROJECT_DIR= bash scripts/rebuild-tsv.sh` and hit the very #417
refusal this fix exists to route around.

Both Explore and oss:auditor's second-pass spawns found this identically.
Fixed by using $CPD at all three sites, and strengthened
tests/test-jit-init.sh's pinning assertions to count every CLAUDE_PROJECT_DIR
print/export site rather than only the assignment form -- confirmed red
against the prior commit (1 CPD use, one raw $PROJECT interpolation) and
green now (2 CPD uses, none raw).

Co-Authored-By: Max <noreply>
…t-init.sh (#417)

tests/test-assertion-helpers.sh's own structural guard ("nothing pipes into
an early-exiting reader") correctly flagged a line the #417 second-pass fix
added: `grep -F ... | grep -vqF '# '` -- the same #56 ordering trap
assert_contains() in tests/test-cross-tree-write-231.sh already avoids
(`| grep -q` exits on its first match and the WRITER takes SIGPIPE, so under
pipefail a real match can report the opposite of what was found). This
failed identically on all three CI platform legs.

Fixed by capturing the grep -F output into a variable first and testing it
with a here-string, matching the existing convention. Confirmed the
assertion still catches the regression it was written for (red against the
b2c089a-era jit-init.sh, green against the current one), and confirmed
directly against test-assertion-helpers.sh's own scan_file() awk pattern
that the rewritten line no longer matches.

Co-Authored-By: Max <noreply>
@fdaviddpt
fdaviddpt merged commit 55a3966 into main Sep 18, 2026
13 checks passed
@fdaviddpt
fdaviddpt deleted the fix/417 branch September 18, 2026 07:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Something wrote a live test fixture into the clone's own .claude/jit-context/00-manual and rebuilt its index

1 participant