Why GIT_DIR in Git Hooks Makes a Guard Pass by Hand and Misfire at Push

Date:

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.

The short answer: When git runs a hook, it exports repository-locating environment variables, most importantly 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.

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-dir reports 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-files lists the pushing repo’s index, read as if the other folder were its working tree.
How GIT_DIR leaks out of a hookCorrect commands, wrong repository, no error.
Git runs the hook

It exports GIT_DIR (and friends) so the hook finds its repo.

Guard runs git -C

Aimed at another repo. The inherited GIT_DIR wins; discovery is skipped.

Wrong repo

Every answer is about the pushing repo. Exit 0, well-formed, wrong.

silent

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.

Share post:

Subscribe

Popular

More like this
Related

Replace the WordPress Logo With Your Site Icon, Everywhere It Shows

Changing the login logo is the well-known fix. The WordPress mark also sits in the toolbar, the login popup, every tab title and the admin footer, and on a multisite network each site should show its own icon, not one shared logo.

How to Show a WordPress Shortcode Without Running It

You write a tutorial that mentions a shortcode in a code example, and the published page shows something else: a gallery, a block of other posts, or an empty gap where the example should be. WordPress ran the shortcode you were only trying to mention.

How to Customize the tagDiv Newspaper Theme When the Accent Color Won’t Change

You change the Newspaper theme's accent color in the Theme Panel, save, and the header, menu and footer are still the old red. Then you try to adjust the phone layout outside the Composer and find every setting stored as base64-encoded JSON. Both have a reliable fix, step by step.

How to Update Rank Math SEO Fields Through the REST API Without Exposing Your Focus Keywords

You send a meta description to a post's REST API endpoint, WordPress replies with HTTP 200, and the field in the Rank Math panel is still empty. One function call per field fixes the write. Done the obvious way, it also publishes every focus keyword you chose.