fix(tests): make git-fixture isolation structural, not per-file (#855) - #856
fix(tests): make git-fixture isolation structural, not per-file (#855)#856scottschreckengaust wants to merge 4 commits into
Conversation
Fifth encounter with one leak (#622/#623, #695, #720/#731, #665): an agent test shells out to git, git resolves the repository from an inherited GIT_DIR rather than the cwd it was handed, and the write lands in the real shared .git/config — core.worktree, core.bare, and a `t <t@t>` identity replacing the developer's own. Downstream, `git status` reports the root dirty while hiding its untracked files, and `git revert` silently no-ops. Why it reads as unreproducible: git exports GIT_DIR/GIT_COMMON_DIR to hooks ONLY in a linked worktree. Under that env `git -C <tmp> init` re-inits the real repository and `git -C <tmp> config user.email t@t` writes the real shared config; run the same tests by hand from the main checkout and nothing leaks. GIT_DIR overrides repository discovery, so it defeats -C, cwd, HOME, --local and the GIT_CONFIG_* pins simultaneously. `check=False` is why it stayed silent: `git init` against an initialised repo exits 0. Each earlier fix hardened the single file where the leak was observed, so none could protect the next file to shell out to git — #665 introduced a fresh unguarded helper seven days after #731 hardened a different one. Three layers instead of a fifth patch. Layer 1 PREVENT — agent/tests/git_env.py becomes the only definition of the location-var tuple and the env builder; conftest's `_isolate_git_location` autouse fixture applies it to every test whether or not the author knew to ask. test_post_hooks.py's private copies are deleted (both files now import the shared one), and test_registry_loader._init_repo no longer writes `git config user.*` at all: identity arrives via GIT_AUTHOR_*/GIT_COMMITTER_*, which outrank every config file. Layer 2 DETECT — pytest_sessionstart fingerprints the shared config (sha256 + key NAMES, never values: a remote URL may embed credentials) and pytest_sessionfinish fails the session if it moved. Mechanism-independent, so it also catches routes Layer 1 does not anticipate. Layer 3 REFUSE — scripts/check-git-config-clean.mjs, `mise run check:git-config-clean`, wired into pre-commit and pre-push. It resolves the config path WITHOUT git, because no `git rev-parse` form survives the state it must report: core.worktree redirects --show-toplevel (the corruption disabling its own alarm), and when it names a deleted pytest tmp_path even --git-common-dir aborts with `fatal: Invalid path`. Exit 0 clean / 1 corrupt / 2 could-not-check — an unreadable config is precisely where a leak hides. Rules match the leak's signature rather than merely unusual settings: a real per-repo identity and a users.noreply.github.com address are deliberately not flagged, since a gate that fired on legitimate configuration would be switched off rather than fixed. Tests: 20 in cdk/test/scripts/check-git-config-clean.test.ts (every rule asserted by making it fire, including core.worktree at a path that no longer exists — the shape that broke two earlier designs) and 14 in agent/tests/test_git_fixture_isolation.py, including a differential witness that an inherited GIT_DIR escapes while isolated_git_env contains. agent 1789 passed / cdk 4366 passed / cli 791 passed; //cdk:synth:quiet fails pre-existing on ec2:DescribeAvailabilityZones (IAM, unrelated). Refs #855, #622, #623, #695, #720, #731, #665 Co-Authored-By: Claude Opus 5 <[email protected]>
isadeks
left a comment
There was a problem hiding this comment.
Request changes — good diagnosis, all three layers fail open
The mechanism write-up is the best part of this PR and I want to be clear that I am not disputing it: GIT_DIR outranking -C, --local, cwd, HOME and the GIT_CONFIG_* pins simultaneously is real, the worktree-only export is real, and the decision to resolve the config path without git at all is correct and non-obvious. I reproduced the core leak before reviewing — standing inside a brand-new scratch repo, git config --local user.name wrote into the other repository's shared config and the scratch repo's own config got nothing. Going structural after four recurrences is the right call.
The problem is that each of the three layers, as built, goes quiet in the state it exists to catch, and one of them will fire on innocent activity while wired into pre-push. Everything below is a bounded fix, not a design objection.
1. Layer 2 disarms on the real corruption shape
agent/tests/git_env.py:102-118 resolves via git rev-parse --path-format=absolute --git-common-dir, returns None on any non-zero exit, and conftest.py:104-105 then early-returns with no message and no marker. I probed four shapes of core.worktree on git 2.50.1:
core.worktree value |
rev-parse |
|---|---|
| absolute, exists | rc 0 |
| absolute, leaf missing | rc 0 |
| absolute, 2+ missing | rc 128 Invalid path |
| relative, missing | rc 128 cannot chdir |
A cleaned-up pytest tmp_path — .../pytest-of-<user>/pytest-N/test_x0 — is the 2-or-more-missing shape. So Layer 2 is blind precisely once the leak has occurred, and it is silent about it rather than reporting "could not check".
The docstring at git_env.py:88-95 asserts the opposite ("--git-common-dir is answered from the gitdir alone", so pollution cannot "disable its own detector"). scripts/check-git-config-clean.mjs:51-65 — in this same PR — gets this exactly right and is why Layer 3 walks the filesystem instead. Layer 2 did not inherit that lesson.
Suggested fix: port the .mjs resolution into git_env.py (walk up for .git, follow gitdir:/commondir, honour GIT_DIR), and make "could not check" loud — when a .git is found but the path or fingerprint cannot be produced, print a COULD NOT CHECK line and set session.exitstatus exactly as the mutation branch does. Reserve the silent pass for the one genuine case: no .git anywhere, e.g. inside the built container image.
2. The whole-file digest false-positives on normal git use
fingerprint_git_config (git_env.py:121-135) digests the entire file, and conftest.py:110 returns only when the digest is unchanged — so any byte change fails the session. The shared config in my checkout carries 276 branch.* keys, which is direct evidence that non-pytest processes write that file as a matter of routine. A git fetch, a git checkout -b in a sibling worktree, a gh pr call, or an editor writing branch.*.vscode-merge-base during a 70-second suite will turn a green run red under a banner asserting "This is the #855 leak: a test wrote into the repository's shared config", with a remedy of --remove-section user — telling the developer to delete their own identity for something no test did.
This is the failure mode your own header warns about at check-git-config-clean.mjs:47-49: a gate that fires on legitimate configuration gets switched off rather than fixed. And because the suite runs at pre-push, it blocks the push.
Suggested fix: fingerprint only the signature keys — the same set Layer 3 rules on — or keep the digest but exclude branch.* and remote.* churn.
3. Layer 3 reports OK on a config it could not read
check-git-config-clean.mjs:219 treats status === 1 as "key absent", but git config --file <p> --get-all <k> also exits 1 when the file cannot be read: permission-denied is only a warning on stderr, and configValues discards stderr. Every rule then returns no values, the anti-vacuity success line prints, and the gate exits 0:
$ chmod 000 <cfg> # cfg carries core.worktree
$ git config --file <cfg> --get-all core.worktree
warning: unable to access '<cfg>': Permission denied rc=1
$ GIT_DIR=... node scripts/check-git-config-clean.mjs
check-git-config-clean: OK — 4 rule(s) (...) clean rc=0
That contradicts the contract stated at lines 67-69, where an unreadable config is explicitly meant to exit 2. Suggested fix: treat rc 1 as key-absent only when stderr is empty; better still, readFileSync the config once in sharedConfigPath() so unreadability is proven up front and exits 2 before any rule runs.
4. Layer 3 never executes for rule 1
Verified under prek 0.3.8 specifically, since that is what this repo installs — not pre-commit:
core.worktree -> existing dir:
error: No `prek.toml` or `.pre-commit-config.yaml` found
core.worktree -> deeply missing dir:
fatal: Invalid path '/private/tmp/prektest/gone'
In both cases the hook entry never runs. prek resolves the repository root and chdirs there itself before invoking any hook, so the deliberately-omitted cd "$(git rev-parse --show-toplevel)" prologue buys nothing for core.worktree — the framework has already been redirected. The reasoning at .pre-commit-config.yaml:25-33 therefore describes a protection that is not in effect for the flagship rule.
In fairness the developer is still blocked from committing, so this is fail-closed in outcome. What is lost is the diagnosis — the carefully written remedy is replaced by "no config file found", which reads like a broken prek install. Rules 2 and 3 are reachable normally; I confirmed the omitted prologue is a harmless no-op for those.
5. Nothing asserts Layer 2 is armed, and disarming it is invisible
conftest.py:75-91 (pytest_sessionstart) has no test. With an early return inserted so nothing is captured, tests/test_git_fixture_isolation.py stays 14/14 and the full agent suite stays 1789/1789 green.
It is undetectable by construction: with nothing captured, _report_shared_git_config_mutation early-returns at conftest.py:104-105, and test_is_inert_when_there_was_nothing_to_protect (test_git_fixture_isolation.py:294) asserts that as a pass. "The detector armed" and "the detector found nothing to protect" are indistinguishable to the suite. This is the vacuity problem the .mjs gate solves with its rulesChecked count — mutating that count away is caught — and Layer 2 has no equivalent. One assertion that _SHARED_GIT_CONFIG is not None when run inside a checkout would close it.
6. Layer 1 test is a tautology where the suite actually runs
conftest.py:279-280 — the location-var strip — is the single most important line in Layer 1. Deleting just that loop, leaving the pins in place, leaves the suite 14/14 green. It only reddens when GIT_DIR happens to be exported into the runner (GIT_DIR=... uv run pytest gives 2 failures).
So test_ambient_location_vars_are_stripped (test_git_fixture_isolation.py:135-141) asserts nothing under plain pytest, in CI, or under mise run build, because os.environ has no location var to strip in those environments. The one environment where it has teeth — the pre-push hook in a linked worktree — is the one nobody runs the suite in on purpose. Getting an env var in place before an autouse fixture runs needs an out-of-process run: pytester or a subprocess pytest with GIT_DIR set.
7. The differential test cannot catch a gutted helper
The PR body and test_git_fixture_isolation.py:5-8 both claim that a test checking only the contained case "would still pass if isolated_git_env were quietly reduced to dict(os.environ)", positioning the differential test as the thing that closes that hole. Under exactly that mutation, test_an_inherited_git_dir_escapes_but_isolated_env_contains passes. Half A leaks because the test sets leaky_env["GIT_DIR"] itself at :109 after calling the function, so it leaks regardless of the implementation; Half B is contained by the ambient autouse fixture, which has already removed GIT_DIR from os.environ, not by the function under test.
The mutation was caught, but by the two plain unit assertions at :75-87 — and both of those pass an explicit base=, so nothing in the suite asserts the strip on the real os.environ path. The test is worth keeping: it proves the git mechanism still behaves as documented. The claim about it should be corrected, since it is cited as the reason the suite cannot be gutted.
Also: Layer 1 closes the GIT_DIR route, not repository discovery
_isolate_git_location strips the location vars and pins GIT_CONFIG_*, but nothing constrains discovery from the inherited cwd, and pytest runs from agent/ inside the checkout. With exactly the fixture's environment, a subprocess.run(["git","config","user.email","t@t"]) with no cwd= or -C still writes the shared config. That is the same leak class, reached by a different route, for the author who "forgot isolated_git_env" — which is the population Layer 1 is advertised to protect. monkeypatch.chdir(tmp_path) in the same autouse fixture makes git fail loudly instead (fatal: not in a git directory); if that is too invasive for the suite, the docstring should say plainly that Layer 1 covers the GIT_DIR route only.
Note also that the fixture deletes GIT_CEILING_DIRECTORIES, which widens discovery rather than narrowing it — setting it to the tmp root would be strictly better, and test_git_fixture_isolation.py:189-191 already sets it back by hand, which reads as an acknowledgement of the hazard.
Non-blocking
Mechanism claims that measure false. On a PR whose primary artifact is explanation, these are what will mislead the next person:
.mjs:38and:256— "pins EVERY linked worktree to one directory". Only the main worktree is redirected; a linked worktree ignorescore.worktreefrom the common config. The other two symptoms in that sentence are correct..mjs:43and:269—core.bare = trueis not "the other stamp the samegit initleaves behind". They are mutually exclusive:GIT_DIRalone writescore.bare=trueand nocore.worktree;GIT_DIRplusGIT_WORK_TREEwritescore.worktreeandbare=false. Both rules are worth keeping, but the causal story sends a reader hunting for a co-occurring key that cannot exist..mjs:257— "git revertno-ops" understates it. Revert succeeds, creates a commit, and writes the reverted file into the hijacked directory while the root's file is untouched. Strictly worse than a no-op and worth saying so.GIT_COMMON_DIRis never exported to hooks in either shape, contrary togit_env.py:15-16,conftest.py:265-267andtest_git_fixture_isolation.py:60-63. Separately,GIT_INDEX_FILEis exported in a plain checkout, which the "unset in a normal checkout" framing denies. The stripping is right; the reason given is too broad..mjs:52—--show-toplevelis redirected from the main worktree, but returns correctly from a linked one. Since linked worktrees are the stated habitat, the qualifier matters.
Two tests do not construct the case they claim. check-git-config-clean.test.ts:247-254 uses a single missing leaf under an existing scratch dir, and test_git_fixture_isolation.py:154-183 uses an existing directory. Both are rc-0 shapes per the table in finding 1, so nothing aborts and neither test discriminates the chosen design from the rejected git-based one. Pointing core.worktree at path.join(scratch, 'gone', 'deleted-tmp-path') fixes the first; the second needs a relative or 2-plus-missing path written by raw file append.
Three copies of GIT_LOCATION_VARS, already diverged. git_env.py:35 has 8 entries and calls itself "the only copy in the tree"; .mjs:105 has 7 and says it mirrors that; .test.ts:74 has 8 and says the same. The omission in the .mjs is deliberate and harmless — :133 sets GIT_CEILING_DIRECTORIES itself — but the comment invites someone to "fix" a drift that is intentional, while a genuine future drift has nothing to catch it: the TS test's only assertion about the set iterates its own local copy. Same shape for the identity: TEST_IDENTITY_NAME is defined at git_env.py:49, re-hardcoded at .test.ts:97, and encoded a third time lowercased as 'abca test' in FIXTURE_NAMES — rename the constant and the gate quietly stops recognising the identity its own fixtures write, which is a false pass in a gate whose worst outcome is a false pass. This repo already has the pattern for this (check-constants-sync.ts, check-types-sync.ts, check-coverage-thresholds-sync.ts, cli/test/constants-parity.test.ts). Worth naming the irony: the thesis here is one definition so the next copy cannot fork from it, and the PR ships three.
Smaller items:
- Rule 2 (
.mjs:262-273) is titled and worded as "core.bareon a repo that has a working tree" but flagscore.bare = trueunconditionally; reached via an inheritedGIT_DIRit would flag a genuinely bare repo with a message asserting something it never checked. .pre-commit-config.yaml:37drops thecdprologue but keepsbash -lc, so acdin the user's profile relocates both themise.tomllookup and the.gitwalk.bash -cavoids it.test_registry_loader.py:298-312keepscheck=Falseon_gitand no call site inspectsreturncode, while its own new comment sayscheck=Falseis why the leak stayed invisible. If setup fails,staged.stdoutis''and the assertions at:344-346and:357-360pass vacuously.test_git_fixture_isolation.py:150—assert expanduser("~") not in (os.environ["GIT_CONFIG_GLOBAL"],)is membership against a 1-tuple, so it asserts inequality rather than the substring containment the comment describes.- Rule-3 data is thinly sampled: 1 of 5
FIXTURE_NAMESexercised (not includingabca test, which is what the fixtures actually write) and 2 of 7RESERVED_EMAIL_SUFFIXES. Both are one-line additions to the existingtest.each. - Nothing asserts the hook is registered in
.pre-commit-config.yamlat both stages or inmise.toml; deleting that stanza disarms Layer 3 with all 20 tests green — the same class as finding 5. - Exit codes 0/1/2 are bare literals documented only in prose; three exported constants imported by the test would tie the contract to both sides. Relatedly,
gitConfigReadreturnsstatus: number | nulland folds spawn-failure into the same branch as a real non-zero exit, which can rendergit exited null. - No multi-problem case anywhere in the suite, so the
--get-allmulti-value loops and theFound N problem(s)plural never run with N greater than 1.
Documentation
CONTRIBUTING.md:96-97 enumerates what runs at pre-commit and at pre-push. This PR adds a hook at both stages and updates neither line, nor the generated mirror at docs/src/content/docs/developer-guide/Contributing.md (mise //docs:sync). Heads-up that #679 edits those same two lines, so whichever lands second will need to reconcile.
The submodule limitation and the local-only scope are both documented honestly, and I appreciated the --no-verify disclosure with the four checks proving the diff innocent. One observation rather than a request: this is now the second PR in the queue where a pre-push gate that is red on main for unrelated reasons forced a bypass. The asymmetry you flag between the pre-push scan and CI's ratcheted variant looks worth its own issue.
What I verified as solid
- The
session.exitstatusmechanism, checked against pytest 9.1.1_pytest/main.py:pytest_sessionfinishruns wheneversessionstartcompleted, thechdirback happens first, and nothing recomputes the status afterwards. No xdist, no wider-scoped fixtures inagent/tests, singleconftest.py— so the ordering holes that would normally worry me do not exist here. - Layer 3's exit-2 paths for no-repo, missing config and a non-runnable git;
always_run: truepluspass_filenames: falseplus both stages, with no file filter that could skip it;mise runpropagates exit 1 end to end. - Nine of nine mutations to the
.mjsgate were caught, including the anti-vacuity count and theexit(1)toexit(0)flip. The suffix-list-not-regex choice is right, and the one-trailing-newline strip at:229-233is a genuinely subtle call, correctly reasoned and covered. _report_shared_git_config_mutation's own error handling: a config that vanishes at finish correctly fails. The asymmetry is only at sessionstart.
Governance is clean: #855 carries approved, is assigned, and is P1; the branch matches the convention; no cdk/src/ changes, so bootstrap policy coverage is not applicable here. All eight CI checks are green.
Summary
The same bug has now been "fixed" four times. Each earlier fix hardened the one test file where the leak was observed, and each was defeated by the next file to shell out to git. This makes the isolation structural — one shared helper, one automatic fixture, and a gate outside the test suite entirely — so the next test file to shell out to git cannot re-introduce it.
Refs #855. Prior attempts: #622 → #623, #720 → #731. Not auto-closing; leaving #855 for a human to close after review.
The mechanism (why the earlier fixes kept losing)
GIT_DIRoverrides repository discovery. It therefore outranks-C,--local, the process cwd,HOME, and theGIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEMpins simultaneously — a write aimed at a throwaway directory lands in the real repository regardless of how carefully the destination was specified.Git exports
GIT_DIR/GIT_COMMON_DIRto hooks only in a linked worktree. That is why this never reproduces from a plain checkout, and why it fires precisely in the contribution flow this repo documents:mise run hooks:pre-push:tests→ pytest → a fixture runninggit init/git configwith the hook'sGIT_DIRstill in its environment.The damage is not cosmetic.
core.worktreein the shared config pins every linked worktree to one directory, sogit statusreports the wrong tree, the root's own untracked files disappear from it, andgit revertsilently no-ops.[user]replacement destroys commit signing attribution — the failure that started this issue.git config --globalclobbering~/.gitconfigagent/tests/test_post_hooks.py's identitiesagent/tests/test_registry_loader.py, which writesuser.name/user.emailof its ownThree layers
Layer 1 — prevent. New
agent/tests/git_env.pyis the single definition of the isolated environment:GIT_LOCATION_VARS(8 vars) stripped first, thenHOME/XDG_CONFIG_HOME/GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM/GIT_CONFIG_NOSYSTEMand the fourGIT_AUTHOR_*/GIT_COMMITTER_*identity vars pinned. Order matters: while any location var is set, every pin below it is bypassed. An autouse_isolate_git_locationfixture inconftest.pyapplies the same stripping toos.environfor every test, so a fixture that forgetsisolated_git_envstill cannot reach outside itstmp_path. Identity now arrives via env vars —test_registry_loader.py's twogit config user.*writes are deleted, andtest_post_hooks.py's three duplicate copies of this logic are deleted in favour of the shared helper (−89 lines).Layer 2 — detect.
pytest_sessionstartfingerprints the shared config;pytest_sessionfinishre-reads it and, on any change, prints the offending key names with copy-pasteable remedies and setssession.exitstatus = TESTS_FAILED. Key names, not values, because aremote.*.urlcan embed credentials and this is printed into CI logs.Layer 3 — refuse.
scripts/check-git-config-clean.mjs+mise run check:git-config-clean, wired first in.pre-commit-config.yamlat bothpre-commitandpre-push. Blocks the operation while the config carries the signature, no matter which tool wrote it.Layer 2, proved end-to-end
Not asserted — run. A throwaway probe test appended
[user] name = t / email = t@tto a fake shared config, withGIT_COMMON_DIRaimed at a scratch directory (the real repository was never in scope). The probe was deleted afterwards:A green test run that still exits 1 — which is the whole point, since no test can observe a mutation made by a test scheduled after it. The no-mutation counter-case exited 0.
Layer 3, verbatim output
Run against a repo carrying the full signature:
Two design points worth the reviewer's attention:
git rev-parseform survives the state being detected:--show-toplevelis redirected bycore.worktree(the corruption switching off its own alarm), and--git-common-diraborts withfatal: Invalid pathwhencore.worktreenames a directory that no longer exists — i.e. a deleted pytesttmp_path, the shape this leak actually leaves behind. The first draft of the script had exactly that bug;cdk/test/scripts/check-git-config-clean.test.tshas a test for it. Reads go throughgit config --file <path>(git's own parser, no repository discovery) fromcwd: '/'with the location vars stripped, becausegit config --filestill performs repository setup for its working directory first.user.emailis common and deliberately not flagged;core.bare = false(whichgit initwrites itself) is not flagged. A gate that fired on legitimate configuration would be switched off rather than fixed.Exit codes:
0clean ·1corruption found ·2could not check. Case 2 is a failure: an unreadable config is exactly the state in which a leak would go unnoticed.Tests
cdk/test/scripts/check-git-config-clean.test.ts(20 tests) andagent/tests/test_git_fixture_isolation.py(14 tests). Both are built to fail if the guard stops guarding:git configcommand run twice, once with an inheritedGIT_DIRand once throughisolated_git_env, asserted to escape in the first case and be contained in the second. A test that only checked the contained case would still pass ifisolated_git_envwere quietly reduced todict(os.environ). The "leak" half writes into a purpose-built fake shared repo undertmp_path.isolated_git_env, asserted on — jest here may itself be running under the pre-push hook, and a test suite for this gate that caused the leak while setting up would be a poor joke. Its last test re-hashes the real shared config and asserts it is byte-identical to the digest captured inbeforeAll.check-git-config-clean.test.tslives undercdk/test/for a root-level script for the same reason as the existingcheck-constants-sync.test.ts: there is no test tree at the repo root. It exercises a subprocess, so it contributes nothing tocdk/srccoverage.Verification
agentpytest//cdk:test//cli:test//docs:build, link-check,drift-prevention, jira-forge-app//cdk:synth:quietec2:DescribeAvailabilityZonesin this account; unrelated to this changeThe strongest evidence the leak is closed is not an assertion: after full 4366-test cdk and 1789-test agent runs, the shared
.git/configwas byte-identical — verified by content (nocore.worktree, no[user]section), not bygit status, which this corruption is capable of falsifying. The gate also passed in its first live pre-commit invocation on this very commit, and the commit is signed (G) under the correct identity — the step that failed when the leak last struck.Disclosure: pushed with
--no-verifyThe pre-push hook runs whole-repo
security:sast:masking, which is currently red onmainacross 46 files (pre-existing, tracked in #756); CI runs the ratcheted:rangevariant instead. Before bypassing I proved my own diff innocent:security:sast:masking:rangewith baselineorigin/main→ rc 0security:sastconfigs against only my 7 changed code files → rc 0comm -12of the 46 flagged files against my 9 changed files → emptysecurity:secrets:range→ no leaksSo the bypass carried none of my findings. This asymmetry between the pre-push gate and CI is itself worth fixing separately.
Scope notes
core.worktreein a submodule's own config, so committing from inside one would flag rule 1. This repo has no submodules; if that changes, exempt them explicitly rather than dropping the rule. Documented in the script header.🤖 Generated with Claude Code