Run the check yourself and it is silent. Push, and the same check, from the same commit, prints a page of warnings about repositories that are fine.
GIT_DIR in git hooks, so the hook’s own git commands find the right repo. Those variables are inherited by every child process, and they override git -C <path>. A hook-run tool that inspects a different repository therefore inspects the pushing repo instead, without any error. The fix is to strip those variables from the environment of every git call aimed at another repo, and to test the tool with them set.A guard you only ever ran from a shell has never been tested in the place it actually runs.
Table of Contents
The symptom: silent by hand, noisy at push
Picture a guard that walks a folder of sibling repositories. For each one it lists the shared files that repository provides, then flags any installed copy that no repository claims. Run from a terminal, it is silent: every installed file has a source. Run from a pre-push hook, it flags nearly all of them as orphans, on every push from every repo.
If you shipped a fix for some other bug just before, the noise looks like a regression of that one. In one real case of exactly this shape, it wasn’t. Reproducing it needed one extra step: running the tool with the environment the hook gives it.
Why git -C does not save you
git -C <path> only changes the directory git starts in. Git normally discovers the repository by walking up from there, but discovery is skipped when GIT_DIR is already set. With GIT_DIR exported by the hook:
git -C ../other-repo rev-parse --git-common-dirreports the pushing repo’s git directory. Every sibling now “is” the pushing repo, and a dedupe step collapses the whole workspace into one entry.git -C ../other-repo ls-fileslists the pushing repo’s index, read as if the other folder were its working tree.
It exports GIT_DIR (and friends) so the hook finds its repo.
Aimed at another repo. The inherited GIT_DIR wins; discovery is skipped.
Every answer is about the pushing repo. Exit 0, well-formed, wrong.
Fix: strip the variables from git rev-parse –local-env-vars for every git call aimed at another repo.
Nothing fails. Exit codes are zero, the output is well-formed, and it is about the wrong repository. So the bug survives code review: the commands look correct, and they are correct in every context except the hook.
The commands were right; the environment answered a different question.
Which hooks, and which variables
The git hooks documentation says this plainly. Git exports variables such as GIT_DIR and GIT_WORK_TREE so that git commands run by the hook locate the repository, and a hook that works on a foreign repository should clear them. The details vary by hook and layout. A linked worktree points GIT_DIR into the main repo’s per-worktree directory. pre-commit typically also carries GIT_INDEX_FILE. The safe assumption is that any of the repo-locating variables may be present.
The trap only springs for a tool that reaches outside the repo being pushed. A hook that inspects its own repository is fine: the exported variables point exactly where it wants to look.
The fix: scrub the environment for foreign-repo calls
Give every git call that targets another repository an environment with the repo-locating variables removed. You don’t have to maintain the list yourself: git rev-parse --local-env-vars prints exactly the variables git treats as repository-local, and the githooks documentation uses it in its own example (unset $(git rev-parse --local-env-vars) before a git -C into a foreign repo).
import os, subprocess
def _local_vars():
out = subprocess.run(["git", "rev-parse", "--local-env-vars"],
capture_output=True, text=True)
names = set(out.stdout.split())
return names or {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE",
"GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"}
REPO_VARS = _local_vars()
def git_env():
return {k: v for k, v in os.environ.items() if k not in REPO_VARS}
def git_in(repo, *args):
return subprocess.run(["git", "-C", str(repo), *args],
capture_output=True, text=True, env=git_env())
Code language: Python (python)
Route all cross-repo calls through one helper like this, rather than patching each call site. A single missed call reintroduces the bug, and the missed one is always in the code path nobody re-tested. Leave calls aimed at the pushing repo alone; for those the exported variables are correct.
The mirror image: an override that can never fire
A process’s environment is inherited by its children and never handed back. GIT_DIR leaks down into the hook’s children; nothing travels back up. That catches guards the other way round too. A pre-command hook that inspects a command before it runs, the kind a shell wrapper or command runner uses to block a risky git push --force, is a separate process. If its documented escape hatch says “re-run with FORCE_OK=1 git push --force ...” and the hook checks its own environment for FORCE_OK, the override can never work. The FORCE_OK=1 prefix sets the variable only for the command being inspected, not for the hook doing the inspecting.
From the outside it looks baffling. The guard keeps blocking, its own error message keeps recommending the override, and the override keeps failing silently. The fix is to read the override from the command text: take the leading VAR=value assignments of the command being inspected, and honor them alongside the hook’s environment. Test it the same way as the GIT_DIR case, from the context that actually invokes the hook, not by setting the variable in your own shell.
Environment flows down, never up: a hook can’t see a variable set on the command it inspects.
Test the guard where it runs
The regression test is cheap once you know the cause. Build a throwaway workspace with two small repos, set GIT_DIR to one repo’s git directory, and assert that the tool still sees both. Then run that test against the code before the fix and confirm it fails. A test that never failed proves nothing about the bug it claims to cover.
More generally, list the contexts a guard really runs in (a shell, a hook, CI, a linked worktree) and test the ones that differ. Both bugs above belong to one class: correct where the author developed the tool, wrong where it runs. For the neighboring family of “fails only inside a hook” surprises, where a tool or library is missing from the hook’s PATH, see Windows dev toolchain gotchas. On choosing hooks versus CI in the first place, see git hooks vs GitHub Actions.
The short version
Git exports GIT_DIR (and related variables) into hooks, and every child process inherits them. They override git -C, so a hook-run tool that inspects other repositories quietly inspects the pushing repo instead: no errors, wrong answers. Strip the repo-locating variables from every cross-repo git call through one shared helper, leave calls aimed at the pushing repo alone, and add a test that sets GIT_DIR and fails against the unfixed code.
Frequently Asked Questions
Should I clear these variables for every git command in a hook?
Only for commands aimed at a different repository or worktree. Commands about the repo being pushed should keep them; that is what they’re for.
Which variables are safe to leave alone?
Identity and behavior settings, such as author and committer names or GIT_SSH_COMMAND, don’t locate a repository, and clearing them could change behavior you rely on. Strip only the repo-locating set.
How do I reproduce the bug without pushing?
Set GIT_DIR yourself: GIT_DIR="$(git rev-parse --absolute-git-dir)" python your_guard.py. If the output changes, the guard is affected.