Skip to content

Retry transient Git config reads - #2130

Merged
Tyrie Vella (tyrielv) merged 7 commits into
microsoft:masterfrom
tyrielv:tyrielv/config-read-retry
Nov 4, 2026
Merged

Tyrie Vella (tyrielv) merged 7 commits into
microsoft:masterfrom
tyrielv:tyrielv/config-read-retry

Conversation

@tyrielv

@tyrielv Tyrie Vella (tyrielv) commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem and Context

Concurrent Git processes can replace .git\config while VFS for Git reads it. On Windows, a reader can then get a short Permission denied or sharing error. This happens, for example, when several worktree mounts read the shared config while one mount writes it.

VFS for Git reads configuration through git.exe and through libgit2. A retry in only one of them would leave the other config readers exposed to the same race.

RetryConfig also started git.exe three times on every load, once each for gvfs.max-retries, gvfs.timeout-seconds, and gvfs.read-timeout-seconds. Mount, clone, prefetch, and dehydrate load it.

Changes

Transient config read retry

  • Add one bounded retry policy, GitConfigReadRetry, for git.exe and libgit2 config reads. It makes five attempts, with exponential delays from 20 to 160 milliseconds.
  • Retry only transient file access failures. The classifier tests only the failure reason after the quoted path, so a path that contains "Permission denied" does not match.
  • Preserve missing-key results. Do not retry non-transient failures. Do not change config writes.
  • Retry the libgit2 repository open, because it parses .git\config.
  • Retry the hook's Git alias lookup. Hooks allow at most one retry, because they run on the Git command path.
  • Trace each retry and the final exhausted attempt. Do not trace a config name that contains URL credentials.
  • Buffer libgit2 multivalue results before callbacks, so that a retry cannot run a callback twice.
  • Use the same policy for git credential fill. Credential fill keeps its stricter check: it retries only an empty-output failure in which git reports a transient file access warning and its config read failure. Each attempt gets only the time that remains of the caller's timeout. Credential fill now reports retries with the shared retry traces, not with a separate CredentialConfigReadFailure event.
  • Add Enlistment.CreateGitProcess(ITracer), so that the mount's config readers report retries to the mount trace.

RetryConfig reads with libgit2

  • Read gvfs.max-retries, gvfs.timeout-seconds, and gvfs.read-timeout-seconds in process with libgit2. An invalid or unreadable setting still uses its default with a warning. Open a transient repository without its object store. Before clone creates the repository, read the global, XDG, system, and ProgramData config, as the git.exe path does.
  • Read with git.exe when libgit2 cannot give the value that git.exe gives:
    • when the environment sets GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, GIT_CONFIG_NOSYSTEM, or GIT_CONFIG. libgit2 also ignores GIT_DIR, but the reader does not fall back for it: Git exports GIT_DIR to hooks in linked worktrees, GVFS reads repositories through an explicit path, and before clone creates the repository an inherited GIT_DIR names an unrelated repository;
    • when the config has an include that libgit2 evaluates differently from git.exe: an includeIf condition other than gitdir: or gitdir/i: (libgit2 skips hasconfig:remote.*.url without an error, and can apply onbranch: on a detached HEAD), a gitdir: pattern that starts with a slash, or a path that uses %(prefix);
    • when libgit2 selects a different global or XDG config file than Git for Windows. Git reads .gitconfig from one home directory (HOME, else HOMEDRIVE and HOMEPATH, else USERPROFILE). libgit2 uses the first of these directories that contains the file. It also searches APPDATA and LOCALAPPDATA for the XDG file;
    • when libgit2 fails to read the setting, for example because it rejects the repository's ownership. The reader traces RetryConfig_ReadWithGit in this case.
  • After a transient failure, do not read again with git.exe, because libgit2 already used the retry budget.
  • Move integer setting parsing to GitConfigHelper, so that both readers report the same default, minimum, and parse errors.
  • When the LibGit2Repo constructor fails, suppress finalization. The constructor already released its libgit2 reference, and the finalizer released it a second time.

Tests

  • Add unit tests for retry recovery, missing keys, persistent and non-transient failures, the reason classifier, exhaustion traces, credential redaction, libgit2 retries, multivalue callbacks, and the repository open retry.
  • Update the credential fill unit tests to assert the shared retry traces. Add a unit test for the retry policy's time budget check and its callback before each retry.
  • Add unit tests that read RetryConfig with libgit2 from a test repository and from an isolated global config. The tests use a non-ASCII repository path.
  • Add unit tests that compare the libgit2 reader with the installed git.exe. The tests check that a hasconfig: include, an onbranch: include on a branch and on a detached HEAD, and a gitdir: pattern without a drive are read through git.exe with the value that git.exe reads. Other tests check that %(prefix) include paths are read through git.exe.
  • Add a unit test that sets HOME to an empty directory and USERPROFILE to a directory with a .gitconfig. The test checks that the libgit2 reader fails, that the installed git.exe reads no value, and that RetryConfig uses the default.
  • Add unit tests for the environment variables that route reads to git.exe, and for the fallback.

Known gaps and deliberate decisions

  • No feature flag for the RetryConfig reader — the reader change affects the success path. Every known difference between libgit2 and git.exe routes the read to git.exe. A gvfs.* flag would itself need a config read.
  • A future Git config feature could diverge — the reader knows the includeIf conditions that libgit2 evaluates today. A new Git config mechanism that libgit2 skips without an error would not be detected.
  • No unit test for the constructor finalization fix — the finalizer calls the real libgit2 shutdown, which all tests in the process share.
  • No functional test for the new reader — unit tests compare libgit2 with the installed git.exe.
  • The fallback event is not telemetry — its error text can contain user paths.
  • An exhausted --file or --global read can look unset — git config --file exits 1 with only warning: lines, which ConfigResult treats as "not configured".
  • GetConfigString cannot observe a locked config — libgit2 serves its parsed snapshot, so its retry helps GetConfigBool and repository open.
  • Config writes are not retried — Keep worktree ids and status cache path out of the shared git config #2129 moves the mount's worktree ids out of the shared config.
  • No retry jitter — mounts that fail together retry at the same offsets.
  • Some readers have no tracer — the enlistment constructor, HttpRequestor, and the static global and system readers do not report retries.

@KeithIsSleeping

Copy link
Copy Markdown

[KeithBot]: ⚠️ 1 issue · iteration 5 (46f8c16)

  1. 🟡 Medium RetryConfig reader trusts include configs that libgit2 evaluates unlike git.exe

Validation

  • ❌ libgit2 vs git.exe include parity
  • ✅ Unit suite at source (1423 tests)
  • ✅ Unit suite at merge base (1350 tests)
  • ✅ PR-touched fixtures, 184 tests x5
  • ✅ Real config locks and write race
  • ✅ Failed-constructor finalizer refcount
  • ✅ CI run 41682665059 on source SHA
  • ⏭️ Functional tests on this machine — not run

🤖 KeithBot · claude-opus-5.5, gpt-6.1-sol, gemini-4-argon · reply on a thread to dispute

Use one bounded retry policy for git.exe and libgit2 config reads. Retry only transient file access failures and preserve existing missing-key behavior.

Retry the libgit2 repository open, which parses .git\config. Retry the git config alias lookup in the hooks. Let latency-sensitive hook callers use a smaller attempt budget.

Match a transient failure only on the reason after the last "': " in an error line. A path or key that contains "Permission denied" no longer matches.

Trace when a transient failure persists after all attempts. Do not trace config names that contain URL credentials.

Add Enlistment.CreateGitProcess(ITracer). Use it for the mount cache server, retry, status cache, and required-config readers, so retries reach the mount trace.

Assisted-by: GPT-5.6 Sol
Assisted-by: Claude Sonnet 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
RetryConfig started git.exe once for each of gvfs.max-retries,
gvfs.timeout-seconds, and gvfs.read-timeout-seconds. Read the settings in
process with libgit2. Open a
transient repository without its object store when the working directory
exists. Otherwise, read the global, XDG, system, and ProgramData config,
as GitProcess.GetFromConfig does before clone creates the repository.
The reads use the GitConfigReadRetry policy.

libgit2 does not read GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT,
GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, GIT_CONFIG_NOSYSTEM, or GIT_CONFIG.
When the environment sets any of these variables, read the settings with
git.exe.

libgit2 also ignores GIT_DIR. Do not read with git.exe for it. Git exports
GIT_DIR to hooks in linked worktrees, and GVFS reads repositories through
an explicit path. Before clone creates the repository, an inherited
GIT_DIR names an unrelated repository, so read only the global and system
config.

libgit2 evaluates only the gitdir, gitdir/i, and onbranch includeIf
conditions. It skips other conditions, such as hasconfig:remote.*.url,
without an error. When the config has such a condition, the libgit2 read
fails, and git.exe reads the setting.

When libgit2 cannot read a setting for another reason, for example
because it rejects the repository's ownership, read the setting with
git.exe and trace RetryConfig_ReadWithGit. After a transient file access
failure, do not read again with git.exe, because libgit2 already used the
retry budget.

Move integer setting parsing to GitConfigHelper, so both readers report
the same default, minimum, and parse errors. As before, an invalid or
unreadable setting uses its default and RetryConfig traces a warning.

When the LibGit2Repo constructor fails, it releases its libgit2
reference. Suppress finalization in that case, so the finalizer does not
release the reference a second time.

Assisted-by: Claude Opus 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Credential fill had its own retry loop for a transient config read
failure. It now uses GitConfigReadRetry, so one policy sets the attempt
count, the delays, and the traces for every config read.

Credential fill keeps its stricter check: it retries only an empty-output
failure in which git reports a transient file access warning and its
config read failure, and no other error. Each attempt still gets only the
time that remains of the caller's timeout.

Add two optional parameters to GitConfigReadRetry.InvokeResult. canWait
stops the retries when the caller's time budget cannot cover the next
delay. beforeRetry receives each failed result that will be read again.
Credential fill uses beforeRetry to write git's error output to the local
log.

Credential fill now reports retries with the GitConfigReadRetry warnings
(ConfigName "credential fill"), not with the CredentialConfigReadFailure
event. The transient reasons come from the shared list.

Update the credential fill unit tests to assert the shared traces. Add a
unit test for canWait and beforeRetry.

Assisted-by: Claude Opus 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
The RetryConfig reader trusted three include layouts that libgit2
evaluates differently from git.exe:

  - onbranch: on a detached HEAD. libgit2 can apply the include, because
    it does not set the match result in that case. git.exe does not.
  - A gitdir: or gitdir/i: pattern that starts with a slash. git.exe
    treats it as a path without a drive. libgit2 matches it anywhere, as
    if it started with "**".
  - An include.path or includeIf.*.path that uses %(prefix). git.exe
    expands it to its installation directory. libgit2 skips the include.

The libgit2 read now fails for these layouts, and git.exe reads the
setting. libgit2 is still used for gitdir: and gitdir/i: patterns that
do not start with a slash.

Add unit tests that compare the reader with the installed git.exe for
onbranch: on a branch and on a detached HEAD, and for drive-less gitdir:
patterns. Add unit tests that check that %(prefix) include paths are read
with git.exe.

Assisted-by: Claude Opus 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
…test

AuthenticationInitializationLoadsAllRetrySettings counted the git config
commands that the mock git process ran. RetryConfig now reads the
settings with libgit2 unless the environment changes Git config, so the
test failed when no GIT_CONFIG_* variable was set.

The test now gives the same values to libgit2, through an isolated
global config, and to the mock git process. It checks the values in the
RetryConfig_LoadedRetryConfig event, so it passes on either read path.

Assisted-by: Claude Opus 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
LibGit2Repo.Dispose freed the repository handle and called
git_libgit2_shutdown for every instance. The protected constructors that
test doubles use do not call git_libgit2_init, so disposing or finalizing
such an instance released a libgit2 reference that another owner held.
When the count reached zero, libgit2 released its global state, including
the config search paths that IsolatedLibGit2Config sets. Tests that ran
later read the machine's global config instead. Because a finalizer could
run at any time, the authentication retry-settings test failed only in
some CI runs.

Record whether the constructor initialized libgit2, and release the
repository handle and the libgit2 reference only in that case.

Add a unit test that disposes a MockLibGit2Repo while an isolated config
is active and checks that a later read still uses the isolated config.

Assisted-by: Claude Opus 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
@KeithIsSleeping

Copy link
Copy Markdown

[KeithBot]: ✅ Resolved · iteration 9 (d654acb)

  • RetryConfig reader trusts include configs that libgit2 evaluates unlike git.exe: fixed
  • New changes since iteration 5: no new issues

Validation

  • ⚠️ CI run 41717632977 on d654acb — partial
  • ✅ RetryConfigTests at d654acb
  • ✅ Include parity vs git.exe (r1 probe)
  • ✅ GC-pressure lifetime repro at d654acb
  • ✅ Positive control: b07d3da LibGit2Repo
  • ✅ Full unit suite at d654acb
  • ⏭️ Functional tests — not run

Approving.

🤖 KeithBot · recheck by claude-opus-5.5 · reply on a thread to dispute

git for Windows reads the global config from one home directory: HOME,
else HOMEDRIVE and HOMEPATH, else USERPROFILE. libgit2 searches all of
these directories and uses the first one that contains .gitconfig. For
the XDG file, libgit2 also searches APPDATA and LOCALAPPDATA, which git
does not. So when HOME points to a directory without a .gitconfig,
libgit2 can read the profile .gitconfig while git reads no global file.
The retry settings then differ between the two readers.

Before the libgit2 reader returns a value, compare the global and XDG
files that libgit2 selects with the files that git for Windows reads.
If they differ, fail the libgit2 read, so that RetryConfig reads the
value with git.exe. Do not change the libgit2 search paths, because
that state is process-wide.

Make IsolatedLibGit2Config set HOME and XDG_CONFIG_HOME to match its
search paths. Add a unit test that sets HOME to an empty directory and
USERPROFILE to a directory with a .gitconfig. The test checks that the
libgit2 reader fails, that real git.exe reads no value, and that
RetryConfig uses the default value.

Assisted-by: Claude Opus 5.5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
@tyrielv
Tyrie Vella (tyrielv) merged commit 0889bc6 into microsoft:master Nov 4, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants