Skip to content

Keep test git from following a hook's GIT_DIR into this repo - #133

Merged
dduugg merged 2 commits into
mainfrom
isolate-tests-from-inherited-git-env
Sep 26, 2026
Merged

dduugg merged 2 commits into
mainfrom
isolate-tests-from-inherited-git-env

Conversation

@dduugg

@dduugg dduugg commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Git hooks export GIT_DIR and GIT_INDEX_FILE, and in a linked worktree they're absolute paths to the real repository. Several tests run git init, git add --all, and git config in temp dirs with only current_dir set, so under those variables they act on the enclosing repo instead. The repo's own pre-commit hook runs cargo test, so committing from a worktree corrupts the repo.

What happens today

Measured with a probe hook (git 2.55.0):

pre-commit hook env regular clone worktree
GIT_INDEX_FILE .git/index (relative, resolves inside each temp repo) absolute path to the real index
GIT_DIR unset absolute path to the real gitdir

One commit from a worktree on main:

  • flipped the shared .git/config to core.bare = true, which broke the primary clone (git status: "this operation must be run in a work tree");
  • replaced the worktree's index with test fixtures, staging every real file as deleted;
  • with --no-fail-fast, also wrote user.email=test@example.com into the shared config and made fixture commits on the real branch.

The hook fails partway, so nothing gets committed, but the damage is done. A regular clone isn't fully safe either: git commit -a and git commit <path> export an absolute GIT_INDEX_FILE there as well. The same exposure applies to git rebase -x 'cargo test' and git bisect run cargo test in a worktree.

Fix — two test-only layers

A cargo runner. .cargo/config.toml routes every binary cargo launches through dev/without-git-repo-env:

vars=$(git rev-parse --local-env-vars) || exit
unset $vars
exec "$@"

git rev-parse --local-env-vars is git's own list of repository-local variables, the same set it clears when running in a different repo. The runner fails closed: if git can't produce the list, the tests don't run. The simpler unset $(git rev-parse --local-env-vars) fails open, because a failed git leaves unset with no arguments. Clearing the environment before the test process starts also covers what per-command scrubbing can't reach: in-process production ls-files and git add called from unit tests, and every CLI child assert_cmd launches.

A tripwire. assert_git_env_isolated() runs inside every tests/common helper that touches a repo (init_git_repo, git_add_all_files, git_reset_all, is_file_staged, and build_run_config, whose callers stage in-process), in the tracked_files unit tests, and before runner_api's in-process stage. It refuses to proceed if any of those variables survive. That covers runs that bypass the runner: a test binary run directly, --manifest-path from outside the repo, non-unix hosts, or an overriding target runner. It guards the shared helpers, not each git call: tests that stage or run generate are covered because they build their repo through those helpers first, so a new test that skips them should call it too. It lives in tests/support/git_env.rs and is spliced with include!() into both tests/common and the tracked_files unit tests, because unit tests can't import tests/common and integration tests link the library built without cfg(test). That shares one copy without adding test support to the crate's public API.

Why not change production code or the hook

Production behavior is unchanged, deliberately. Users run codeownership validate from their own pre-commit hooks, where git_stage() must stage CODEOWNERS into the index being committed, and ls-files must see that index. The runner only affects what cargo launches inside this repo; the library and the released binary never see it.

.rusty-hook.toml is untouched. rusty-hook runs its command with sh -c and inherits the environment, so the existing hook's cargo test already goes through the runner. Fixing only the hook would have left manual runs, rebase -x, bisect run, and other hook managers exposed, and any shell syntax in the hook string breaks Windows's cmd /C.

Verification

  • The incident, before and after, in throwaway repos using the real rusty-hook 0.11.2 hook files:
    • on main, a worktree commit reproduces the corruption above;
    • with this change, the same commit succeeds with 166 passing, and the primary's config, index, and reflog are unchanged.
  • Direct environment: GIT_DIR and GIT_INDEX_FILE exported by hand at a victim repo leave it byte-identical with this change. On main, the same run corrupts it.
  • Regression tests: five new tests in tests/git_env_isolation_test.rs. They check that the runner strips the variables, fails closed without git, and preserves the test binary's exit status. They also re-run each tests/common helper in a child process under the worktree shape, and init_git_repo under a lone GIT_INDEX_FILE, and check that it refuses. The child is an #[ignore]d test, so a stray marker variable can't turn the parent into a no-op. Each fails when the behavior it guards is removed; for example, a runner that swallows exit status fails with left: Some(0), right: Some(3).
  • Checks: cargo fmt --check and cargo clippy --all-targets --all-features -- -D warnings are clean; cargo test passes 168/0 across 27 binaries, and five runs at --test-threads=16 pass 840/0. Removing the tripwire call from any single helper fails test_git_helpers_refuse_inherited_repo_env. Confirmed under Linux dash and macOS /bin/sh.

Worth knowing

  • cargo run inside this repo also goes through the runner, so it doesn't see these variables either. One consequence: cargo run -- generate --stage from a hook during git commit -a or git commit <path> stages into .git/index rather than the hook's temporary index. The released binary is unaffected.
  • If your own cargo config also sets a [target.'cfg(...)'] runner, cargo refuses to choose between the two (several matching instances of target.'cfg(..)'.runner). Override with CARGO_TARGET_<TRIPLE>_RUNNER; the tripwire still guards git. .cargo/config.toml says the same.
  • The runner is cfg(unix). Elsewhere, the tripwire still refuses before any git write made through the shared test helpers.
  • The tripwire checks git's whole --local-env-vars list, so when the runner is bypassed, harmless variables like GIT_CONFIG_PARAMETERS (set by a git -c parent) also make it refuse. That's deliberate: it fails closed.
  • Bisecting or rebase -x-ing across commits older than this one, from a worktree, is still exposed, since those commits don't have the runner.
  • The rusty-hook dev-dependency's build script runs git rev-parse outside both layers (cargo runs build scripts directly). It's read-only and fails harmlessly under an inherited GIT_DIR.

Sent with Claude Code

Git hooks, `git rebase -x` and `git bisect run` export GIT_DIR and
GIT_INDEX_FILE, and in a linked worktree those are absolute paths to the real
repository. Several tests run `git init`, `git add --all` and `git config` in
temp dirs with only `current_dir` set, so under those variables they act on
the enclosing repo instead. Committing from a worktree ran the pre-commit
hook's `cargo test`, which flipped the shared .git/config to `core.bare =
true` (breaking the primary clone), replaced the worktree's index with test
fixtures, and, with --no-fail-fast, wrote `user.email=test@example.com` into
the shared config and made fixture commits on the real branch. A regular clone
isn't fully safe either: `git commit -a` and `git commit <path>` export an
absolute GIT_INDEX_FILE there too.

Two test-only layers:

- A cargo runner (.cargo/config.toml -> dev/without-git-repo-env) clears every
  variable `git rev-parse --local-env-vars` lists, then execs the test binary.
  It runs before the test process starts, so it also covers what per-command
  scrubbing can't: in-process production `ls-files`/`git add` reached from
  unit tests, and the CLI children tests launch. It fails closed if git can't
  produce the list.
- A tripwire, `assert_git_env_isolated()`, runs before every test git write in
  tests/common, the tracked_files unit tests, and runner_api's in-process
  stage. It refuses if any of those variables survive, covering runs that
  bypass the runner (a test binary run directly, `--manifest-path` from
  outside the repo, non-unix hosts, an overriding runner).

Production code is unchanged and keeps honoring GIT_DIR/GIT_INDEX_FILE: users
run `codeownership validate` from their own hooks, where `git_stage` must
stage into the index being committed. The runner only affects what cargo
launches inside this repo.

Five regression tests in tests/git_env_isolation_test.rs cover the runner
stripping the variables, failing closed without git, and preserving the
binary's exit status, and the tripwire refusing both the worktree shape and a
lone GIT_INDEX_FILE. Each fails when the behavior it guards is removed.
@dduugg
dduugg requested a review from a team as a code owner September 25, 2026 23:58
@github-project-automation github-project-automation Bot moved this to Triage in Modularity Sep 25, 2026
@dduugg dduugg mentioned this pull request Sep 26, 2026
- Move the tripwire to tests/support/git_env.rs, spliced with include!()
  into tests/common and the tracked_files unit tests instead of duplicated.
- Guard build_run_config too, since its callers stage in-process.
- Re-run each tests/common helper in a child under an inherited GIT_DIR, so
  dropping any one helper's tripwire call fails a test.
- Make the child an ignored test, so an ambient marker variable can't turn
  the parent test into a silent no-op.
- Fix the tripwire message for runs that overrode the runner, and document
  the conflict with a user's own cfg(...) runner in .cargo/config.toml.
@dduugg
dduugg merged commit 189382e into main Sep 26, 2026
11 checks passed
@dduugg
dduugg deleted the isolate-tests-from-inherited-git-env branch September 26, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant