Skip to content

test(scripts): scope portability-suite fixture git commands with -C - #2839

Merged
kyle-sexton merged 1 commit into
mainfrom
fix/test-fixture-git-config-isolation
Aug 16, 2026
Merged

test(scripts): scope portability-suite fixture git commands with -C#2839
kyle-sexton merged 1 commit into
mainfrom
fix/test-fixture-git-config-isolation

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What

The two portability suites build their diff-mode fixtures like this:

out="$(
  cd "$fx" &&
    git init -q &&
    git config user.email test@example.com &&   # <- names no repository
    ...
)"

This PR names the fixture on every fixture-writing git command, adopting the
git -C "$fixture" ... idiom the sibling fixtures in the same directory already
establish (scripts/check-stale-base-overlap.test.sh:22,
scripts/sync-standards-contract.test.sh:69).

21 lines changed, all of them git invocations. No assertion or coverage change.

Honest finding: the named sites were already cwd-scoped

The motivating report described these as unscoped commands writing into the
caller's .git/config. They are not, under normal invocation. The cd "$fx" &&
guard at the head of the && chain scopes them, and I could not reproduce a leak:

$ # verbatim replay of the pre-fix block, run from a worktree of this repo
BEFORE: 0 local user.* keys
subshell rc=0 out=695e71c3ea35179a224c36992b6408d6de284d94
AFTER: 0 local user.* keys
(none in caller repo)
fixture config:
user.email=test@example.com
user.name=test

Whole-suite before/after, against the shared .git/config this worktree and the
main clone both use:

phase suite caller user.* before caller user.* after result
pre-fix (HEAD~1 copies) check-shell-portability.test.sh none none PASS=333 FAIL=0
pre-fix (HEAD~1 copies) check-skill-portability.test.sh none none PASS=89 FAIL=0
post-fix check-shell-portability.test.sh none none PASS=333 FAIL=0
post-fix check-skill-portability.test.sh none none PASS=89 FAIL=0

So this is not a demonstrated bug fix. It is an idiom-consistency and
copy-paste-hazard change: a line reading literally git config user.email test@example.com at statement position in a test file is one paste away from
poisoning a real repository, which is the plausible route to the #2827 damage.
(The branch name says fix/; the change is hygiene. The landed squash commit
takes this PR's title and body, so history records it accurately.)

Note the cd "$fx" is retained deliberately and stays load-bearing — the
relative printf … > 'plugins/…' writes and the relative
bash scripts/check-*-portability.sh invocation all need cwd = $fx. So -C
here is a second, explicit scoping mechanism alongside the existing implicit
one, not a replacement for it. $fx is always mktemp -d output, i.e.
absolute, so -C "$fx" resolves correctly from any cwd.

Follow-up finding: -C is not a fixture-isolation guarantee

The one way I could manufacture the leak is an exported GIT_DIR — the
environment git hands to every hook it invokes. git config's default --local
scope follows GIT_DIR, not the working directory:

GIT_DIR=D:/repos/.../claude-code-plugins/.git
BEFORE:  (no local user.* keys)
subshell rc=0 gitdir-it-used=D:/repos/.../claude-code-plugins/.git
AFTER:   user.email=test@example.com
         user.name=test

git -C "$dir" does not fix that — I verified the prescribed idiom is
equally vulnerable (the fixture ends up with no .git at all and the caller's
config is poisoned):

== git -C "$fx" init/config under exported GIT_DIR ==
fixture has .git? NO
caller config user.*:  user.email=test@example.com  user.name=test

== git -C with GIT_DIR/GIT_WORK_TREE unset in the subshell ==
fixture2 has .git? yes
caller config user.*:  (clean)

Only unset GIT_DIR GIT_WORK_TREE (or env -u) actually isolates a fixture.
I did not build that here — it is a new shape across ~30 test files and is
not justified by any demonstrated need in this repo (no core.hooksPath, no
installed hooks, and CI invokes the suites as plain steps). Filing it as a
finding instead: the established git -C fixture idiom is a readability and
copy-paste guard, not an isolation guarantee.

Sweep

Three passes, all for repo-writing git verbs
(config|init|add|commit|checkout|switch|branch|remote|tag|reset|rm|mv|stash|update-ref|worktree|clone|push|fetch)
at shell-statement position without -C:

  1. All 254 *.test.sh / tests/ / run-tests.sh files — 285 candidate hits.
  2. All *.ps1 *.psm1 *.py *.js *.ts — every hit is a comment, docstring, or
    string literal; no executable git write command in any of them.
  3. The *.sh files that build mktemp -d fixtures but are not named like
    tests (so pass 1 would have missed them) — 2 hits, both
    git init -q -b main "$repo" in plugins/source-control/skills/worktree/fixtures/.

Every site is scoped, by one of:

  • -C <dir> — e.g. sync-standards-contract.test.sh:69.
  • An explicit path operandgit init -q "$REPO", git clone -q <src> <dest>
    (the repo-hygiene, source-control, docs-hygiene and preflight fixtures).
  • A guarded cd into the fixture, (cd "$d" || exit 1; …) or (cd "$d" && …),
    which also covers the git init -q . sites (exec-bit-check.test.sh:25,
    block-noncanonical-commit.test.sh:358) — those are cwd-scoped by the
    enclosing cd, not by their . argument.
  • An explicit git config -f <file> (preflight.test.sh:416,417,495).

Everything else that matched is a quoted test payload — permission-rule strings
like Bash(git push), PowerShell here-string bodies, make_skill heredoc
content — never executed. No remaining unscoped site.

One structural note, reported not touched (the file is owned by a concurrent
lane): scripts/check-stale-base-overlap.test.sh scopes its bare
git checkout / git add / git commit with a top-level cd "$repo" || exit 1
rather than a subshell-local one. It is guarded, so it is scoped — but it is more
fragile than the sibling pattern, since the cwd persists into everything after it.

Verification

  • bash scripts/check-shell-portability.test.sh -> PASS=333 FAIL=0
  • bash scripts/check-skill-portability.test.sh -> PASS=89 FAIL=0
  • No plugin manifest touched, so the changelog-parity gate does not apply
    (it is scoped to plugins/<name>/; there is no root CHANGELOG.md).
    changelog-parity-gate passes on this PR, confirming it.

Pass counts are identical pre- and post-change (333 and 89), which is the
coverage-unchanged proof: the diff touches 21 lines, all of them git
invocations, and no ok " / fail " assertion line.

Related

No linked issue. This is fixture-isolation hygiene in two test suites; it closes
no GitHub issue.

🤖 Generated with Claude Code

https://claude.ai/code/session_018S8a1S71VxhLTRWBtMuEvp

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

The two portability suites build their diff-mode fixtures by chaining
`cd "$fx" && git init -q && git config user.email test@example.com && ...`
inside a command-substitution subshell. That chain IS correctly cwd-scoped
and does not leak (evidence in the PR body), but it leaves lines reading
literally `git config user.email test@example.com` at statement position
in a test file -- one copy-paste away from writing the test identity into
a real repository's .git/config, which is how a contributor's commit was
re-authored and PR #2827 had to be abandoned.

Adopt the `git -C "$fixture" ...` idiom the sibling fixtures in this same
directory already establish (check-stale-base-overlap.test.sh:22,
sync-standards-contract.test.sh:69), so every fixture-writing command
names its target repository instead of implying it from an enclosing cd.
The `cd "$fx"` stays: the gate under test is invoked as
`bash scripts/check-*.sh` and needs the fixture as its working directory.

Fixture isolation only -- no assertion or coverage change. 21 lines
changed, all of them git invocations. check-shell-portability.test.sh
PASS=333 FAIL=0; check-skill-portability.test.sh PASS=89 FAIL=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018S8a1S71VxhLTRWBtMuEvp
@cursor

cursor Bot commented Aug 16, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 16, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 25s —— View job


Claude code review

  • Gather context (PR body, diff, CLAUDE.md, REVIEW.md)
  • Review changed files for correctness/maintainability
  • Post findings

Scope: scripts/check-shell-portability.test.sh, scripts/check-skill-portability.test.sh. This repo carries a claude-security-review workflow, so security is out of scope for this lane (already ran separately and passed).

Findings

None blocking. This is a mechanical, low-risk change — every hunk adds git -C "$fx" to a git invocation that was already inside a cd "$fx" && guard in the same subshell, matching the idiom the sibling fixtures in this directory already use (e.g. scripts/check-stale-base-overlap.test.sh:22).

I traced each of the three converted fixture blocks (verified against the diff and surrounding context):

$fx is defined once via mktemp -d at the top of each block and used consistently; the cd "$fx" && guard is still needed (later relative printf/mkdir/bash scripts/... calls in the same chain depend on cwd) and is correctly left in place alongside the new -C "$fx" flags. No -C site references a stale or wrong variable, no non-git relative-path command was mistakenly touched, and no line was missed within the converted blocks.

The PR body itself is unusually thorough about scope: it's honest that this isn't a demonstrated fix for the originally reported leak (the cd-guarded commands weren't actually unscoped under normal invocation), frames the change correctly as an idiom-consistency / copy-paste-hazard guard, and documents a real follow-up finding (-C doesn't protect against an exported GIT_DIR) without over-scoping this PR to fix it. That's the right call — a repo-wide unset GIT_DIR GIT_WORK_TREE sweep across ~30 test files is a separate, larger change and isn't justified here.

Verification note: I was not able to execute scripts/check-shell-portability.test.sh / scripts/check-skill-portability.test.sh directly in this sandbox (Bash execution required approval not available in this lane), so I can't independently confirm the author's claimed PASS=333/PASS=89 output — that claim is author-reported and unverified by me. The separate ci workflow run for this PR is in progress and will exercise these suites directly.

@claude

claude Bot commented Aug 16, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 39s —— View job


Security review

Skip gate: no security-relevant surface. Both changed files (scripts/check-shell-portability.test.sh, scripts/check-skill-portability.test.sh) are test scripts, and every line changed simply adds -C "$fx" to git commands (init, config, commit, add, rev-parse) that were already scoped by a preceding cd "$fx" && in the same subshell. $fx is an internally generated fixture path (mktemp-style), not attacker- or user-controlled input, so there's no new injection, auth, or trust-boundary surface introduced.

No GitHub Actions workflow files, permissions, secrets handling, or dependency pins are touched.

The PR's own body is candid that this is an idiom-consistency change (matching the git -C pattern already used by sibling fixtures), not a fix for a reproducible leak — the author verified the pre-fix code was already correctly cwd-scoped under normal invocation, and separately documented (as a non-blocking finding) that -C itself doesn't guarantee isolation under an exported GIT_DIR/GIT_WORK_TREE. That's an accurate observation but out of scope for this diff, which doesn't attempt to fix it.

No security issues found.

  • Gather context (PR diff, changed files)
  • Run skip-gate check
  • Review diff for security-relevant surface
  • Post findings

@github-actions

Copy link
Copy Markdown

Last security-reviewed head: f637469fb2732fe2430e8773453181d0f09f8339. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@kyle-sexton
kyle-sexton merged commit ef2b163 into main Aug 16, 2026
51 of 52 checks passed
@kyle-sexton
kyle-sexton deleted the fix/test-fixture-git-config-isolation branch August 16, 2026 09:23
kyle-sexton added a commit that referenced this pull request Aug 16, 2026
## Summary

`ci-status` is the single check the org `ci-gate` ruleset keys on, and
its own comment calls its
`needs` list "the single source of truth for the lane list". Nothing
enforced the other direction. A
job defined in `.github/workflows/ci.yml` but absent from that list
still runs, still reports, and
still turns red in the run list — while `ci-status` reports success and
the merge proceeds. The
`hook-utils-windows` comment already states the doctrine in prose ("a
lane missing from that list is
informational no matter how loudly a comment here calls it a gate");
prose is not a gate.

The gap was computed, not read. Against `origin/main` at `2d20a277`
(i.e. after #2841 landed and
added two lanes of its own):

```console
$ bash derive-gap.sh
defined jobs (incl. ci-status): 37
ci-status.needs entries:        34
--- defined but NOT in ci-status.needs (excluding ci-status itself) ---
managed-scope-sync
state-key-sync
--- in ci-status.needs but NOT defined ---
```

(The issue's "38 defined / 34 named" was an eyeball count on a pre-#2841
tree; the derived numbers
differ, the two missing lanes do not.)

## Fix

**Both missing lanes gate — neither omission was deliberate.**

| Job | Decision | Why |
| --- | --- | --- |
| `managed-scope-sync` | **Add to `needs`** | Structurally identical to
the four sync lanes already in `needs` (`hook-utils-sync`,
`parse-concern-value-sync`, `resolve-convention-pattern-sync`,
`standards-contract-sync`): same `--check` / lib self-test /
`--check-bump` triple. Not advisory, not schedule- or event-scoped — the
`if: github.event_name == 'pull_request'` sits on the bump **step**,
never on the job, so the job runs and reports on every PR and every
push. `scripts/cross-plugin-source-registry.txt` already advertises it
as the dedicated check for `lib/managed-scope.sh`. |
| `state-key-sync` | **Add to `needs`** | Same shape, same registry
claim for `lib/state-key.sh`. |

No job in this workflow is intentionally omitted, so this PR ships
**zero** opt-out annotations.

`cross-plugin-source-drift` (already in `needs`) covers a *drifted copy*
of either cluster, so this
was never a total hole — but it does not cover the `--check-bump` half
(lib changed, carrying plugin
version not bumped), which only the dedicated lanes run.

**The guard.** `scripts/check-lane-coverage.sh --check` proves the
defined-job set and
`ci-status.needs` are equal in both directions, and is itself wired into
`ci-status.needs` as
`lane-coverage-gate` (self-covering: its own absence from `needs` would
be caught by itself). It
reports four classes:

- `UNGATED LANE` — defined, not in `needs`, not annotated. The class
#2856 filed.
- `DANGLING NEED` — a `needs` entry naming no defined job.
- `STALE OPT-OUT` — a job annotated as deliberately ungated that *is* in
`needs`.
- `BARE OPT-OUT` — `# lane-coverage-ok:` with no reason after the colon.

**Required, not advisory** — deliberately. An advisory lane-coverage
check would be a green-and-silent
surface whose entire purpose is detecting green-and-silent surfaces,
which
`docs/conventions/liveness-assertion/` names as non-conforming. The
false-positive risk that normally
argues for advisory is absent here: the gate reads one file, takes no
diff, no base ref and no
network, and every YAML shape it does not model (flow-sequence `needs:
[a, b]`, scalar `needs:`, an
aggregate with no `needs:`, an unmodelled 2-space key under `jobs:`)
exits **2 (inconclusive)** rather
than 0 — reporting coverage from a file it did not parse would be the
gate committing the very defect
it detects.

**The opt-out** is the same annotated-exemption shape `#
silent-skip-ok:` uses for
`scripts/check-silent-skips.sh` — a `# lane-coverage-ok: <reason>`
comment in the contiguous 2-space
block immediately above the job key, or trailing the key itself. The
reason lives next to the thing it
excuses, in the file a reviewer is already reading, with no separate
list to drift. It carries a stale
guard (the annotation cannot outlive what it excuses) and a bare
annotation **fails** rather than
passing as "annotated", so it can never become a silent off switch.

No job's logic, `runs-on`, triggers, or `if:` conditions changed. The
`ci.yml` diff is
**31 insertions, 0 deletions**: three `needs:` entries and one new job
block.

## Verification

**The guard fires on the real defect and stops firing once fixed** —
constructed and run, not reasoned
about. On the unfixed `ci.yml` (pre-edit, at `origin/main`):

```console
$ bash scripts/check-lane-coverage.sh --check ; echo "EXIT=$?"
UNGATED LANE: job 'managed-scope-sync' is defined in .github/workflows/ci.yml but absent from ci-status.needs, so it cannot gate a merge. ...
UNGATED LANE: job 'state-key-sync' is defined in .github/workflows/ci.yml but absent from ci-status.needs, so it cannot gate a merge. ...
check-lane-coverage: 2 coverage defect(s) in .github/workflows/ci.yml
EXIT=1
```

After the wiring, the same derivation returns an empty gap and the gate
is green:

```console
$ bash derive-gap.sh
defined jobs (incl. ci-status): 38
ci-status.needs entries:        37
--- defined but NOT in ci-status.needs (excluding ci-status itself) ---
--- in ci-status.needs but NOT defined ---

$ bash scripts/check-lane-coverage.sh --check
check-lane-coverage: .github/workflows/ci.yml — all 37 lane(s) reachable from ci-status.needs
```

## Test plan

`scripts/check-lane-coverage.test.sh` — 20 cases, synthetic workflow
fixtures built per case, all
`ALL PASS`:

- a job absent from `needs` fails **1** and names the job; every job
present passes **0**
- annotated opt-out passes **0**; trailing-comment form passes **0**
- opt-out with no reason fails **1** (`BARE OPT-OUT`)
- opt-out on a job that *is* in `needs` fails **1** (`STALE OPT-OUT`)
- an annotation separated from its key by a blank line does **not**
exempt the job
- `needs` entry naming no defined job fails **1** (`DANGLING NEED`)
- flow-sequence `needs`, scalar `needs`, absent `needs`, empty `needs`,
unmodelled 2-space key,
unknown aggregate id, missing file, bad usage, no `jobs:` mapping — all
exit **2**, never 0
- the repository's own `ci.yml` exits **0**

Fixtures are plain files under `mktemp` addressed by absolute path — no
scratch git repo, so the
`git config user.email` caller-config-clobber class (#2839) cannot recur
here.

Also run clean locally: `shellcheck` and `actionlint` on the touched
files;
`check-shell-portability.sh --paths` (both new scripts);
`check-silent-skips.sh`;
`check-discriminating-test-skips.sh`; `check-orphaned-fixtures.sh`;
`check-changelog-parity.sh
--check` and `--check-bump origin/main` (no plugin touched, so no
version bump is owed);
`check-stale-base-overlap.sh --check origin/main`; `affected-tests.sh`
(both new files map to a suite
— no unmapped-file error); `affected-tests.test.sh` (32/32) and
`check-docs-only.test.sh` (21/21),
the two suites the selector picked for the `ci.yml` change.

An independent fresh-context verifier re-derived both sets with its own
script, rebuilt the
fired/not-fired evidence from scratch, and diff-checked that no job's
logic, triggers, or `if:`
conditions changed.

## Related

- Fixes #2856
- Refs #2834, #2841#2841 is where "a new job is automatically
required" was tested and found
false; it added `windows-path-emit-gate` and `windows-path-emit-windows`
and wired both by hand.
This PR makes the hand-wiring mechanical. Branched off `origin/main`
after #2841 merged.
- Refs #532 — `docs/conventions/liveness-assertion/`, the false-green
class this defect belongs to.
- Does not touch `scripts/check-silent-revert.sh` or
`scripts/silent-revert-incidents.txt` (#2843).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 17, 2026
…#2876)

## Summary

A test suite that builds a git fixture can write that fixture's identity
into
the **caller's** repository. This makes that impossible in the shared
harnesses,
fixes the Python suite that actually caused it, and adds a CI gate so a
new
suite cannot reintroduce it.

**A correction to the framing in #2840 first, because the fix depends on
it.**
The issue says "`GIT_DIR` is exactly what git exports into every hook it
invokes", implying hooks are the footgun. Measured, that is not the
mechanism:

- `git -C <dir>` **does** chdir. What an exported **absolute** `GIT_DIR`
overrides is repository **discovery**, and `git config` writes its
default
`--local` scope to whatever `--git-dir` finally resolves to. So under an
  absolute `GIT_DIR`, `git -C <fixture> config user.email X` writes the
  **caller's** config and leaves the fixture with **no `.git` at all**.
- The **relative** form (`GIT_DIR=.git`) is safe, because `-C` chdirs
first and
  `.git` then resolves against the new cwd.

So the footgun is the **absolute** form, not the mere presence of
`GIT_DIR`.
Confirmed on this machine: no git hook exists at any scope,
`core.hooksPath` is
unset everywhere, and the sole Claude Code PreToolUse hook invokes no
git — the
exported `GIT_DIR` in the real incident came from an ad-hoc command, not
a hook.

The general shape is worth stating, because it is what justifies
clearing the
environment rather than patching call sites: **an ambient environment
variable
silently redirected a git operation that looked correct at the call
site.** The
same class bit this repo a second time this week through
`MSYS_NO_PATHCONV`,
where an exported value leaked across a compound command and made
`git worktree add` create a directory off the wrong drive. Test fixtures
must
not inherit ambient git-relevant environment at all.

Two adjacent leak paths, both live here, that the issue does not mention
and
that rule out the narrower `-c` transient fix:

- `git -C <linked worktree> config user.email` writes the **main**
repo's
**shared** `.git/config` — git creates no `config.worktree`. This repo
runs
  dozens of linked worktrees.
- `git -C <non-repo dir nested inside a repo> config` walks **upward**
and
  writes the enclosing repo's config.

`git -C <dir> -c user.email=... <cmd>` prevents config poisoning but
still
operates on the wrong repository, and does nothing for either path
above.
Clearing the environment is the fix that covers all three.

Why this matters operationally: a poisoned `user.email` silently
re-authors
commits. Such a commit fails this repo's `required_signatures` rule with
`no_user` and **cannot be force-pushed over** — the branch has to be
abandoned
and rebuilt. That is #2827 -> #2830.

Closes #2840

## Fix

**1. `scripts/test-git-helpers.sh`** — clears `GIT_DIR GIT_WORK_TREE
GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY` once at
**source
time**, so every current and future consumer is immunized without a
per-call-site edit. The same clear is added to
`scripts/run-plugin-tests.sh` and
the two plugin test harnesses (`claude-ops`, `guardrails`).

The guard lives **inside** the harnesses and test files, not as a
CI-side
`env -u` wrapper. The poisoning incident was an ad-hoc **local** run,
and no
single test runner exists (CI names each suite individually). A wrapper
would
not have prevented the actual incident.

**2. Python** —
`plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py` is
the file that fired. Its `create_checkout` runs
`git -C <checkout> config user.email test@example.com`. One module-level
`os.environ.pop` loop at import, not 46 call-site edits: fixtures are
built from
two `TestCase` classes with no shared `setUp`, none of the git
subprocess calls
pass an explicit `env=`, and spawned subprocesses inherit `os.environ`,
so one
clear covers every path. Because it runs at **import**, it takes effect
under
CI's `GuardTests`-only invocation too.

The same guard is added to the only two other Python suites in the repo
that
build git fixtures: `test_prune_babysit_worktrees.py`, which also builds
a
**linked worktree**, and `test_check_contract_clause_coverage.py`, which
runs
`git init` with `cwd=` and no path argument at all — under a leaked
`GIT_DIR`
that initializes the caller's gitdir and stages into the caller's index.

**3. `scripts/check-fixture-git-isolation.sh`** — a new gate, wired into
`ci.yml` as `fixture-git-isolation-gate` and registered in
`ci-status.needs`.

**Why a standalone gate and not a class inside
`scripts/check-shell-portability.sh`** (which was the original plan):
that gate
is scoped to `*.sh`. The file that caused the incident is a `test_*.py`.
**A
gate that structurally cannot see the file that fired is not a gate.**
The
standalone `scripts/check-*.sh` + `scripts/*-baseline.txt` pair is also
this
repo's established shape for exactly this kind of check
(`check-orphaned-fixtures.sh`, `check-changelog-parity.sh`), so this
conforms to
the existing pattern rather than adding a second way. A useful side
effect:
`scripts/check-shell-portability.sh` is **untouched**, so this PR does
not
contend with the other lane working there.

Gate details:

- Covers `*.test.sh`, `test_*.py`, `*_test.py`. The Python clearing
check is
  **file-scoped**, not line-scoped, because the idiom spans lines.
- Detection intents cover the identity-**write** spellings a
`config`-adjacent
pattern missed (`git config --local user.email`, `git -c <k>=<v> init`)
while
**excluding** the `--get` / `--list` **read** spellings, which cannot
poison
  anything.
- A suite whose subject IS this mechanism opts out with a line-anchored
  `fixture-isolation-scope:` declaration, mirroring the existing
`portability-scope:` precedent. Anchoring at the start of the comment
content
is deliberate: prose mentioning the token must not silently exempt a
file.
  This is distinct from the baseline — a declaration records a permanent
  property, the baseline records drainable debt.

## Verification

**Discrimination proof — the regression test fails pre-fix and passes
post-fix.** `scripts/test-git-helpers.test.sh` covers all three leak
paths. It
honors `TEST_GIT_HELPERS_UNDER_TEST` so the pre-fix run needs no
tracked-file
edit. Every assertion reads the caller identity with `config --local
--get`; a
plain `--get` falls through to `~/.gitconfig` at rc=0 and would mask the
leak.

Pre-fix (the `origin/main` harness):

```
harness under test: .../prefix/test-git-helpers.sh
FAIL: A absolute GIT_DIR: caller identity POISONED (before=sentinel@example.invalid after=t@t.test)
FAIL: A absolute GIT_DIR: fixture has NO .git — the work went to the caller instead
FAIL: B linked worktree: caller identity POISONED (before=sentinel@example.invalid after=t@t.test)
FAIL: B linked worktree: fixture has NO .git — the work went to the caller instead
FAIL: C nested non-repo dir: caller identity POISONED (before=sentinel@example.invalid after=nested@example.invalid)
FAIL: C nested non-repo dir: fixture has NO .git — the work went to the caller instead
FAIL: harness does not clear: GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY

passed: 0  failed: 7
```

Post-fix (the shipped harness):

```
harness under test: .../scripts/test-git-helpers.sh
ok: A absolute GIT_DIR: caller identity intact (sentinel@example.invalid)
ok: A absolute GIT_DIR: fixture owns its own .git
ok: B linked worktree: caller identity intact (sentinel@example.invalid)
ok: B linked worktree: fixture owns its own .git
ok: C nested non-repo dir: caller identity intact (sentinel@example.invalid)
ok: C nested non-repo dir: fixture owns its own .git
ok: harness clears every discovery-redirecting variable

passed: 7  failed: 0
```

Scenario B independently confirms the shared-config path: with `GIT_DIR`
set to
a **linked worktree's** gitdir, the write landed in the **main** clone's
config
and `extensions.worktreeConfig` was never created.

**Gate self-test — 23 cases, all pass**, including a live-corpus canary:

```
ok - unknown flag exits 2 with usage
ok - unisolated fixture suite is a violation (exit 1, names the file)
ok - suite that unsets GIT_DIR/GIT_WORK_TREE passes
ok - unsetting GIT_DIR alone is still a violation
ok - sourcing a harness that clears the environment passes
ok - a harness that stops clearing re-exposes its sourcing suites
ok - a baselined violation is grandfathered
ok - a stale baseline entry fails (a line cannot outlive its debt)
ok - a suite that builds no fixture is not conscripted
ok - a comment mentioning git init does not conscript a suite
ok - --list reports verdicts and exits 0
ok - an identity write via 'config --local' is a violation
ok - an identity read alone does not conscript a suite
ok - a read sharing a line does not suppress an identity write
ok - 'config --local --get user.email' is a read, not a fixture write
ok - 'git -c <k>=<v> init' is a violation
ok - python: unisolated fixture suite is a violation
ok - python: popping GIT_DIR and GIT_WORK_TREE passes
ok - python: popping GIT_DIR alone is still a violation
ok - python: naming the variables without popping is still a violation
ok - a declared fixture-isolation scope exempts the suite
ok - a mid-line mention of the scope token does not exempt
ok - live corpus passes against scripts/fixture-git-isolation-baseline.txt

ALL PASS
```

**Gate against the real tree:**

```
$ scripts/check-fixture-git-isolation.sh --check
fixture git isolation: OK (45 isolated, 36 baselined)   # rc=0

$ scripts/check-fixture-git-isolation.sh --list | tail -1
45 isolated, 36 baselined, 0 violating
```

**False-positive rate, measured and disclosed.** The shipped detector
classifies
**81 files as fixture-builders (78 shell + 3 Python)**, re-derived from
the
shipped code rather than quoted from an earlier revision. An independent
audit
of the flagged set found **0 read-only false positives**, **0** files
selected
only by a string literal, and **0** selected only by the broad `worktree
add`
intent — the added intents conscript no file on their own. (That audit
ran
against an 80-file snapshot taken one commit earlier; the single added
file is
this gate's own self-test, which gained fixture text when the cases
below were
written. An earlier pass reporting 137 identity triggers across 75 shell
suites
predates the broadened detection entirely and is superseded.)

Three known limits are recorded rather than hidden:

- A harness sourced through a **variable** (`. "$HELPER"`) is invisible
to the
  harness-resolution branch, which needs a literal `.sh` token. Only
`scripts/test-git-helpers.test.sh` does this, and it carries an explicit
scope
  declaration instead.
- A file holding a `fixture-isolation-scope:` line as **test data**
would be
exempted by it. Verdict order mitigates this — actually clearing
outranks
declaring — so the only file reported `declared` is the one
counter-fixture
  that genuinely cannot clear.
- `--list` exits 0 unconditionally by design; `--check` is the failing
mode.
`--list` also prints the baselined count net of stale entries, so while
draining the baseline the authoritative count is that file's own line
count.

Two false positives were found and closed during development rather than
shipped:

- `audit-fleet.test.sh`, conscripted by `config --get-all user.name` — a
read.
  That is why the read spellings are excluded.
- A line-wide read exclusion that would have let one `--get` suppress a
real
identity write sharing the line. Reads are now removed extent by extent.
Measured before changing anything: four lines in the tracked corpus
carry both
shapes and all four are pure reads, so this closed a latent hole rather
than a
  live one.

Both directions carry test cases.

## Test plan

All run locally on Windows / Git Bash; the output above is real, not
paraphrased.

- [x] `bash scripts/test-git-helpers.test.sh` — 7 pass / 0 fail (rc=0)
- [x] same with `TEST_GIT_HELPERS_UNDER_TEST` = the `origin/main`
harness — 0 pass / 7 fail (rc=1), proving discrimination
- [x] `bash scripts/check-fixture-git-isolation.test.sh` — 21 cases, ALL
PASS (rc=0)
- [x] `scripts/check-fixture-git-isolation.sh --check` — rc=0
- [x] `python -m unittest test_hygiene.GuardTests` (the class CI runs) —
`Ran 127 tests ... OK (skipped=3)`
- [x] `python -m unittest test_hygiene` (whole module) — `Ran 303
tests`, 2 failures, both reproducing **identically on unmodified
`origin/main`**, so pre-existing and not introduced here (tracked in
#2871)
- [x] `python -m unittest discover -s tests -p 'test_*.py'`
(babysit-prs) — `Ran 643 tests ... OK`
- [x] `python scripts/test_check_contract_clause_coverage.py` — `Ran 24
tests ... OK`
- [x] `shellcheck --rcfile=.shellcheckrc -x` on every changed `.sh` —
rc=0 each
- [x] `shfmt -d -i 2 -ci` on every changed `.sh` — clean
- [x] `ruff check` on all three changed `.py` — `All checks passed!`
- [x] exec bits: both new `.sh` recorded `100755` in the index, the
baseline `.txt` `100644`

Not verified locally: GitHub-side execution of the new `ci.yml` job —
that is
what this PR's own CI run establishes.

## Related

- #2840 — the issue this closes. Its "hooks export `GIT_DIR`" framing is
corrected above; the absolute-vs-relative distinction is the actual
mechanism.
- #2827 / #2830 — the abandoned-and-rebuilt PR pair this leak produced.
- #2839 — adopted the `git -C` idiom and deliberately deferred this
hardening.
- #2871 — **filed by this lane**: nine of ten `TestCase` classes in
`test_hygiene.py` never run in CI, including the one holding the
function that
caused the incident. Measured; flipping to the whole module would land
red, so
it needs the staged baseline path. Deliberately **not** changed here —
this PR
  is a fixture-isolation fix, not a CI-topology change.
- #2872 — **filed by this lane**: drain the 36 grandfathered entries in
`scripts/fixture-git-isolation-baseline.txt`. Staged behind the gate so
it
lands green; a gate that lands red is a gate on its way to being
disabled.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 17, 2026
#2916)

No linked issue

## Summary

Structure-only apply-lane batch from the first `/coupling:reduce`
dogfood pass over `scripts/`: five fixture-construction sites across
three test suites hand-rolled the `git init` + identity-config block
that `scripts/test-git-helpers.sh` already publishes, bypassing its
inside-checkout safety guard (the class that bit as #2839) and its
gpgsign/autocrlf hardening.

## Fix

Source `test-git-helpers.sh` in `check-shell-portability.test.sh`,
`check-skill-portability.test.sh`, and
`sync-standards-contract.test.sh`, and replace each inline init+config
block with `git_init_test_repo`. Net −6 lines.
`check-stale-base-overlap.test.sh` is deliberately not converted — its
fixture needs `init -b`, which the helper has no seam for yet (tracked
in #2914's deferred item).

## Verification

- `check-shell-portability.test.sh`: PASS=333 FAIL=0
- `check-skill-portability.test.sh`: PASS=89 FAIL=0
- `sync-standards-contract.test.sh`: PASS=12 FAIL=0
- `shellcheck` clean on all three files

## Related

Refs #2914 (route-lane findings from the same pass); companion to #2913
(the skill that produced this batch)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_016CWMh6HAsgWWi9tLw76hZR

---
_Generated by [Claude
Code](https://claude.ai/code/session_016CWMh6HAsgWWi9tLw76hZR)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

1 participant