Skip to content

Inherited GIT_DIR / GIT_INDEX_FILE / GIT_DIFF_OPTS override -C and -U: inside a git hook every verb works on the wrong repository, and Patch() can return zero-context hunks #139

Description

@matt-edmondson

What's wrong

RunCommandGitProcessRunner.EnvironmentOverlay (GitIntegration/Execution/RunCommandGitProcessRunner.cs:51-56) adds only GIT_TERMINAL_PROMPT=0 and LC_ALL=C. Everything else in the caller's environment is inherited by every git child process. Two groups of inherited variables beat the arguments the library passes.

1. The repository-locating variables beat -C <LocalPath>

The library chooses the repository with -C. GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY and GIT_COMMON_DIR take priority over repository discovery. Git exports these variables to hooks, and githooks(5) tells hooks to clear them before touching another repository. So a .NET tool that runs from a hook (Husky.NET and similar) and opens a different repository with this library gets the wrong data.

Reproduced with git 2.43, using repo A (contains A.txt) and repo B (contains a modified B.txt). The program calls client.OpenAsync(B), then repo.Status() and repo.Patch():

Environment Status() Patch()
clean B.txt Modified ✅ B.txt ✅
GIT_DIR=$PWD/A/.git A.txt Deleted, B.txt Untracked ❌ A.txt ❌
real pre-commit hook in A, during git commit -a (git sets GIT_INDEX_FILE=…/A/.git/index.lock) throws GitCommandException: fatal: unable to read <sha> ❌ —

In the GIT_DIR row, LocalPath still says B, but git is comparing A's index against B's working tree. Mutating verbs (Add, Apply().ToIndex(), Commit) resolve the same way, so they would write into the other repository's index or object store. The reproduction above covered only the read verbs.

2. GIT_DIFF_OPTS beats the pinned -U{n} in Patch()

GitPatchBuilder always emits -U{n} (GitPatchBuilder.cs:181-183), so that a hostile configuration can't produce a zero-context patch (#121/#128), and WithContext(0) is refused for the same reason. The git docs say GIT_DIFF_OPTS=--unified=N / -uN overrides any -U/--unified on the command line, so an environment variable can defeat that guarantee:

var f = (await repo.Patch().WithContext(3).ForPath(RelativeFilePath.Create("n.txt")).ExecuteAsync()).Files[0];
var r = await repo.Apply(f.PatchFor(f.Hunks)).ToIndex().TryExecuteAsync();

The setup is n.txt committed as lines 1..5, with line 3 changed to X in the working tree:

  • Clean environment: the hunk is @@ -1,5 +1,5 @@, and the apply succeeds.
  • GIT_DIFF_OPTS=-u0: the hunk is @@ -3 +3 @@ with no context. The apply fails with patch failed: n.txt:3 … patch does not apply, because Apply doesn't pass --unidiff-zero.

Suggested fix

Add these keys to EnvironmentOverlay with null values. RunCommand ≥ 1.5.0 removes a variable whose value is null.

GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY,
GIT_ALTERNATE_OBJECT_DIRECTORIES, GIT_COMMON_DIR, GIT_NAMESPACE, GIT_PREFIX,
GIT_DIFF_OPTS

GIT_EXTERNAL_DIFF is already neutralised by --no-ext-diff.

Acceptance criteria

  • A runner test sets GIT_DIR (and GIT_INDEX_FILE) to point at a different repository, and Status() and Patch() still report the repository passed to OpenAsync.
  • A test sets GIT_DIFF_OPTS=-u0, and Patch().WithContext(3) still returns 3-line context hunks that Apply().ToIndex() accepts.

Activity

  1. matt-edmondson commented on Sep 27, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingreadyFully specified; implement as written

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions