diff --git a/scripts/test.sh b/scripts/test.sh
index 8b9cade28..cdc6d3e1d 100755
--- a/scripts/test.sh
+++ b/scripts/test.sh
@@ -357,6 +357,12 @@ else
make -f Makefile.cbm test-par ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"}
fi
+# Step 3a: the runner must ignore an inherited git repository environment
+# (#2003: the pre-commit hook exports GIT_DIR, which overrides `git -C` and
+# pointed fixture git commands at the committer's real repository).
+echo "=== Step 3a: inherited git environment isolation (#2003) ==="
+bash "$ROOT/tests/test_git_env_isolation_contract.sh" "$ROOT/$BUILD_DIR/test-runner"
+
# Step 4: C++ large-TU index-hang regression guard (#410). Runs the PROD binary
# in a subprocess with a wall-clock timeout — a hang must fail, not block the run.
# Opt-in via CBM_RUN_HANG_TEST=1 (it needs the prod binary, which the ASan unit
diff --git a/tests/repro/repro_main.c b/tests/repro/repro_main.c
index 7fc1cc20b..b7c478ac7 100644
--- a/tests/repro/repro_main.c
+++ b/tests/repro/repro_main.c
@@ -26,6 +26,7 @@ int tf_fail_count = 0;
int tf_skip_count = 0;
#include "test_framework.h"
+#include "test_helpers.h"
#include "repro_runner.h"
#include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */
#include "daemon/bootstrap.h" /* refuse the developer's default daemon rendezvous */
@@ -139,6 +140,8 @@ int main(void) {
* loudly instead of reaching it. */
cbm_daemon_bootstrap_forbid_default_runtime_for_test(true);
#endif
+ /* #2003: fixture git commands must never reach the caller's repository. */
+ th_clear_git_repo_env();
/* Unbuffered: a reproduction may crash/_exit (or a sanitizer may _exit on a
* leak) before stdio flushes — keep every printed line so the summary and the
diff --git a/tests/test_git_env_isolation_contract.sh b/tests/test_git_env_isolation_contract.sh
new file mode 100644
index 000000000..17a2b185d
--- /dev/null
+++ b/tests/test_git_env_isolation_contract.sh
@@ -0,0 +1,124 @@
+#!/usr/bin/env bash
+# Contract (#2003): the test runner must be immune to an inherited git
+# repository environment.
+#
+# Git exports GIT_DIR, GIT_INDEX_FILE, ... into hooks, and those variables take
+# precedence over `git -C
`. The pre-commit hook runs the suite, so every
+# fixture git command in the runner used to act on the committer's REAL
+# repository: fixture assertions failed, and branches, commits and linked
+# worktrees were written into the contributor's clone. The runner now clears
+# git's local-env list at startup (th_clear_git_repo_env in tests/test_helpers.h).
+#
+# This drives the real runner with those variables aimed at a throwaway decoy
+# repository and asserts both halves: the git fixture suite passes, and the
+# decoy is left exactly as it was (no refs, no linked worktrees).
+#
+# Usage: tests/test_git_env_isolation_contract.sh [path/to/test-runner]
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+RUNNER="${1:-$ROOT/build/c/test-runner}"
+SUITE="git_context"
+
+if [ ! -x "$RUNNER" ]; then
+ echo "FAIL: test runner not found at '$RUNNER'; build it first (make -f Makefile.cbm build/c/test-runner)." >&2
+ exit 1
+fi
+if ! command -v git >/dev/null 2>&1; then
+ echo "SKIP: git is not on PATH, so there is no git environment to leak."
+ exit 0
+fi
+
+# This script's own git calls must address the decoy, not whatever repository
+# the caller (e.g. the pre-commit hook) exported.
+while IFS= read -r variable; do
+ unset "$variable"
+done < <(git rev-parse --local-env-vars)
+
+WORK="$(mktemp -d "${TMPDIR:-/tmp}/cbm-git-env-XXXXXX")"
+cleanup() { rm -rf "$WORK"; }
+trap cleanup EXIT
+
+decoy_state() {
+ printf 'refs:\n'
+ git -C "$1" for-each-ref
+ printf 'worktrees:\n'
+ git -C "$1" worktree list --porcelain
+}
+
+# Baseline: the same suite in a clean environment. Comparing summaries (not just
+# exit codes) matters: with GIT_WORK_TREE inherited, fixture commits fail and
+# the suite SKIPs as "git not available" instead of failing -- a silent green.
+BASE_LOG="$WORK/baseline.log"
+"$RUNNER" "$SUITE" >"$BASE_LOG" 2>&1 || {
+ echo "FAIL: '$SUITE' is red even in a clean environment; fix that first." >&2
+ grep -E 'FAIL|passed' "$BASE_LOG" >&2 || true
+ exit 1
+}
+
+summary() { grep -E '^ *[0-9]+ passed' "$1" | tail -1; }
+BASE_SUMMARY="$(summary "$BASE_LOG")"
+FAILED=0
+
+# Two inherited shapes: GIT_DIR alone (the reported `GIT_DIR=... scripts/test.sh`
+# case: fixture git commands write into the decoy) and the full set a hook
+# exports (fixture commits fail, so the suite degrades to SKIPs).
+check_variant() {
+ local name="$1"
+ local decoy="$WORK/decoy-$name"
+ shift
+ git init -q "$decoy"
+ local before
+ before="$(decoy_state "$decoy")"
+ local -a assignments=()
+ local var
+ for var in "$@"; do
+ assignments+=("$var=$decoy$(git_env_suffix "$var")")
+ done
+ local log="$WORK/runner-$name.log"
+ local rc
+ set +e
+ env "${assignments[@]}" "$RUNNER" "$SUITE" >"$log" 2>&1
+ rc=$?
+ set -e
+ local env_summary after
+ env_summary="$(summary "$log")"
+ after="$(decoy_state "$decoy")"
+ if [ "$rc" -ne 0 ]; then
+ echo "FAIL[$name]: '$SUITE' exited $rc with an inherited git environment." >&2
+ grep -E 'FAIL|passed' "$log" >&2 || true
+ FAILED=1
+ fi
+ if [ -z "$BASE_SUMMARY" ] || [ "$BASE_SUMMARY" != "$env_summary" ]; then
+ echo "FAIL[$name]: '$SUITE' behaves differently with an inherited git environment." >&2
+ echo " clean env: ${BASE_SUMMARY:-}" >&2
+ echo " inherited env: ${env_summary:-}" >&2
+ grep -E 'FAIL|SKIP' "$log" >&2 || true
+ FAILED=1
+ fi
+ if [ "$before" != "$after" ]; then
+ echo "FAIL[$name]: the test runner modified the repository named by the inherited GIT_DIR." >&2
+ echo "--- before ---" >&2
+ echo "$before" >&2
+ echo "--- after ---" >&2
+ echo "$after" >&2
+ FAILED=1
+ fi
+}
+
+# Where each exported variable points inside a repository, as a hook sees it.
+git_env_suffix() {
+ case "$1" in
+ GIT_WORK_TREE) printf '' ;;
+ GIT_INDEX_FILE) printf '/.git/index' ;;
+ *) printf '/.git' ;;
+ esac
+}
+
+check_variant git-dir GIT_DIR
+check_variant hook-env GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR
+
+if [ "$FAILED" -ne 0 ]; then
+ exit 1
+fi
+echo "PASS: test runner ignores an inherited git repository environment (#2003)."
diff --git a/tests/test_helpers.h b/tests/test_helpers.h
index 2559bafb5..91ea56f50 100644
--- a/tests/test_helpers.h
+++ b/tests/test_helpers.h
@@ -226,4 +226,37 @@ static inline void th_cleanup(const char *path) {
}
}
+/* ── Inherited git repository environment (#2003) ─────────────── */
+
+/* Git exports repository-local variables into hooks (GIT_DIR, GIT_INDEX_FILE,
+ * ...), and they take precedence over `git -C `. A test runner started
+ * from a pre-commit hook (or any shell with GIT_DIR set) would otherwise point
+ * every fixture's git command at the caller's real repository: branches and
+ * worktrees get created there and fixture assertions fail. This is the list
+ * `git rev-parse --local-env-vars` prints; runners clear it once at startup so
+ * every spawned git (and every re-exec'd child) scopes to its fixture. */
+static const char *const th_git_repo_env_vars[] = {
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
+ "GIT_CONFIG",
+ "GIT_CONFIG_PARAMETERS",
+ "GIT_CONFIG_COUNT",
+ "GIT_OBJECT_DIRECTORY",
+ "GIT_DIR",
+ "GIT_WORK_TREE",
+ "GIT_IMPLICIT_WORK_TREE",
+ "GIT_GRAFT_FILE",
+ "GIT_INDEX_FILE",
+ "GIT_NO_REPLACE_OBJECTS",
+ "GIT_REPLACE_REF_BASE",
+ "GIT_PREFIX",
+ "GIT_SHALLOW_FILE",
+ "GIT_COMMON_DIR",
+};
+
+static inline void th_clear_git_repo_env(void) {
+ for (size_t i = 0U; i < sizeof(th_git_repo_env_vars) / sizeof(th_git_repo_env_vars[0]); i++) {
+ (void)cbm_unsetenv(th_git_repo_env_vars[i]);
+ }
+}
+
#endif /* TEST_HELPERS_H */
diff --git a/tests/test_main.c b/tests/test_main.c
index 5c3dcf067..08552160a 100644
--- a/tests/test_main.c
+++ b/tests/test_main.c
@@ -1028,6 +1028,9 @@ extern void suite_dump_verify_io(void);
extern void cbm_kind_in_set_free_cache(void);
int main(int argc, char **argv) {
+ /* #2003: never let a caller's GIT_DIR/GIT_INDEX_FILE/... redirect fixture
+ * git commands at the caller's real repository. */
+ th_clear_git_repo_env();
int memory_limit_probe_rc = tf_maybe_run_windows_memory_limit_probe(argc, argv);
if (memory_limit_probe_rc >= 0) {
return memory_limit_probe_rc;