diff --git a/GVFS/GVFS.Common/Enlistment.cs b/GVFS/GVFS.Common/Enlistment.cs
index a9208b0edf..dcc0b58452 100644
--- a/GVFS/GVFS.Common/Enlistment.cs
+++ b/GVFS/GVFS.Common/Enlistment.cs
@@ -1,5 +1,6 @@
using GVFS.Common.FileSystem;
using GVFS.Common.Git;
+using GVFS.Common.Tracing;
using System;
using System.IO;
@@ -117,5 +118,10 @@ public virtual GitProcess CreateGitProcess()
{
return new GitProcess(this);
}
+
+ public virtual GitProcess CreateGitProcess(ITracer tracer)
+ {
+ return new GitProcess(this, tracer);
+ }
}
}
diff --git a/GVFS/GVFS.Common/Git/GitConfigHelper.cs b/GVFS/GVFS.Common/Git/GitConfigHelper.cs
index eb0b08c879..bd156e0b88 100644
--- a/GVFS/GVFS.Common/Git/GitConfigHelper.cs
+++ b/GVFS/GVFS.Common/Git/GitConfigHelper.cs
@@ -4,7 +4,7 @@
namespace GVFS.Common.Git
{
///
- /// Helper methods for git config-style file reading and parsing.
+ /// Helper methods for reading Git config files and parsing config values.
///
public static class GitConfigHelper
{
@@ -30,6 +30,34 @@ public static bool TrySanitizeConfigFileLine(string fileLine, out string sanitiz
return !string.IsNullOrWhiteSpace(sanitizedLine);
}
+ ///
+ /// Parses an integer setting. A null or blank value selects .
+ ///
+ public static bool TryParseIntSetting(string configName, string valueString, int defaultValue, int minValue, out int value, out string error)
+ {
+ value = defaultValue;
+ error = string.Empty;
+
+ if (string.IsNullOrWhiteSpace(valueString))
+ {
+ return true;
+ }
+
+ if (!int.TryParse(valueString, out value))
+ {
+ error = string.Format("Misconfigured config setting {0}, could not parse value `{1}` as an int", configName, valueString);
+ return false;
+ }
+
+ if (value < minValue)
+ {
+ error = string.Format("Invalid value {0} for setting {1}, value must be greater than or equal to {2}", value, configName, minValue);
+ return false;
+ }
+
+ return true;
+ }
+
///
/// Get the settings for a section in a given config file.
///
diff --git a/GVFS/GVFS.Common/Git/GitConfigReadRetry.cs b/GVFS/GVFS.Common/Git/GitConfigReadRetry.cs
new file mode 100644
index 0000000000..690df4ce20
--- /dev/null
+++ b/GVFS/GVFS.Common/Git/GitConfigReadRetry.cs
@@ -0,0 +1,172 @@
+using GVFS.Common.Tracing;
+using System;
+using System.Threading;
+
+namespace GVFS.Common.Git
+{
+ internal static class GitConfigReadRetry
+ {
+ internal const int MaxAttempts = 5;
+ internal const int InitialDelayMilliseconds = 20;
+
+ private static readonly string[] TransientReasons =
+ {
+ "Permission denied",
+ "Device or resource busy",
+ "being used by another process",
+ "sharing violation",
+ };
+
+ internal static T InvokeResult(
+ string configName,
+ Func readConfig,
+ Func isTransientFailure,
+ ITracer tracer,
+ Action delay,
+ int maxAttempts = MaxAttempts)
+ {
+ for (int attempt = 1; ; ++attempt)
+ {
+ T result = readConfig();
+ if (!isTransientFailure(result))
+ {
+ return result;
+ }
+
+ if (attempt >= maxAttempts)
+ {
+ TraceExhausted(tracer, configName, attempt);
+ return result;
+ }
+
+ TimeSpan retryDelay = GetRetryDelay(attempt);
+ TraceRetry(tracer, configName, attempt, maxAttempts, retryDelay);
+ delay(retryDelay);
+ }
+ }
+
+ internal static T InvokeCatching(
+ string configName,
+ Func readConfig,
+ Func isTransientFailure,
+ ITracer tracer,
+ Action delay,
+ int maxAttempts = MaxAttempts)
+ {
+ for (int attempt = 1; ; ++attempt)
+ {
+ try
+ {
+ return readConfig();
+ }
+ catch (Exception e) when (isTransientFailure(e))
+ {
+ if (attempt >= maxAttempts)
+ {
+ TraceExhausted(tracer, configName, attempt);
+ throw;
+ }
+
+ TimeSpan retryDelay = GetRetryDelay(attempt);
+ TraceRetry(tracer, configName, attempt, maxAttempts, retryDelay);
+ delay(retryDelay);
+ }
+ }
+ }
+
+ internal static bool IsTransientLibGit2Error(Exception exception)
+ {
+ return
+ exception is LibGit2Exception &&
+ IsTransientFileAccessError(exception.Message);
+ }
+
+ ///
+ /// Both git.exe and libgit2 report a file failure as ... '<path>': <reason>.
+ /// Only the reason after the final ': is tested, so a path or key that contains
+ /// a transient phrase does not match.
+ ///
+ internal static bool IsTransientFileAccessError(string error, string lineMustContain = null)
+ {
+ if (string.IsNullOrEmpty(error))
+ {
+ return false;
+ }
+
+ foreach (string line in error.Split('\n'))
+ {
+ if (lineMustContain != null && !ContainsOrdinalIgnoreCase(line, lineMustContain))
+ {
+ continue;
+ }
+
+ int reasonSeparator = line.LastIndexOf("': ", StringComparison.Ordinal);
+ if (reasonSeparator < 0)
+ {
+ continue;
+ }
+
+ string reason = line.Substring(reasonSeparator + 3);
+ foreach (string transientReason in TransientReasons)
+ {
+ if (ContainsOrdinalIgnoreCase(reason, transientReason))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ internal static bool ContainsOrdinalIgnoreCase(string value, string expected)
+ {
+ return
+ value != null &&
+ value.IndexOf(expected, StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
+ internal static void Delay(TimeSpan delay)
+ {
+ Thread.Sleep(delay);
+ }
+
+ private static TimeSpan GetRetryDelay(int failedAttempt)
+ {
+ return TimeSpan.FromMilliseconds(InitialDelayMilliseconds * (1 << (failedAttempt - 1)));
+ }
+
+ // A repository URL with embedded credentials can become part of a deprecated cache
+ // server config key name.
+ private static string GetTraceableConfigName(string configName)
+ {
+ return configName != null && configName.IndexOf('@') >= 0
+ ? ""
+ : configName;
+ }
+
+ private static void TraceRetry(ITracer tracer, string configName, int failedAttempt, int maxAttempts, TimeSpan retryDelay)
+ {
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add("ConfigName", GetTraceableConfigName(configName));
+ metadata.Add("AttemptNumber", failedAttempt);
+ metadata.Add("MaxAttempts", maxAttempts);
+ metadata.Add("RetryDelayMilliseconds", retryDelay.TotalMilliseconds);
+ tracer.RelatedWarning(
+ metadata,
+ "Transient failure while reading git config. Retrying.",
+ Keywords.Telemetry);
+ }
+
+ private static void TraceExhausted(ITracer tracer, string configName, int attempts)
+ {
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add("ConfigName", GetTraceableConfigName(configName));
+ metadata.Add("Attempts", attempts);
+ tracer.RelatedWarning(
+ metadata,
+ "Transient failure while reading git config persisted after all attempts.",
+ Keywords.Telemetry);
+ }
+ }
+}
diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs
index df54900b7c..615f2cbd0a 100644
--- a/GVFS/GVFS.Common/Git/GitProcess.cs
+++ b/GVFS/GVFS.Common/Git/GitProcess.cs
@@ -56,6 +56,8 @@ public class GitProcess : ICredentialStore
private string gitBinPath;
private string workingDirectoryRoot;
private string dotGitRoot;
+ private readonly ITracer tracer;
+ private readonly Action configReadDelay;
private Process executingProcess;
private bool stopping;
@@ -85,11 +87,26 @@ static GitProcess()
}
public GitProcess(Enlistment enlistment)
- : this(enlistment.GitBinPath, enlistment.WorkingDirectoryBackingRoot)
+ : this(enlistment.GitBinPath, enlistment.WorkingDirectoryBackingRoot, NullTracer.Instance, GitConfigReadRetry.Delay)
+ {
+ }
+
+ public GitProcess(Enlistment enlistment, ITracer tracer)
+ : this(enlistment.GitBinPath, enlistment.WorkingDirectoryBackingRoot, tracer, GitConfigReadRetry.Delay)
+ {
+ }
+
+ internal GitProcess(Enlistment enlistment, ITracer tracer, Action configReadDelay)
+ : this(enlistment.GitBinPath, enlistment.WorkingDirectoryBackingRoot, tracer, configReadDelay)
{
}
public GitProcess(string gitBinPath, string workingDirectoryRoot)
+ : this(gitBinPath, workingDirectoryRoot, NullTracer.Instance, GitConfigReadRetry.Delay)
+ {
+ }
+
+ internal GitProcess(string gitBinPath, string workingDirectoryRoot, ITracer tracer, Action configReadDelay)
{
if (string.IsNullOrWhiteSpace(gitBinPath))
{
@@ -98,6 +115,8 @@ public GitProcess(string gitBinPath, string workingDirectoryRoot)
this.gitBinPath = gitBinPath;
this.workingDirectoryRoot = workingDirectoryRoot;
+ this.tracer = tracer ?? NullTracer.Instance;
+ this.configReadDelay = configReadDelay ?? GitConfigReadRetry.Delay;
if (this.workingDirectoryRoot != null)
{
@@ -127,22 +146,28 @@ public static Result Init(Enlistment enlistment)
public static ConfigResult GetFromGlobalConfig(string gitBinPath, string settingName)
{
- return new ConfigResult(
- new GitProcess(gitBinPath, workingDirectoryRoot: null).InvokeGitOutsideEnlistment("config --global " + settingName),
+ GitProcess git = new GitProcess(gitBinPath, workingDirectoryRoot: null);
+ return new ConfigResult(git.InvokeConfigRead(
+ settingName,
+ () => git.InvokeGitOutsideEnlistment("config --global " + settingName)),
settingName);
}
public static ConfigResult GetFromSystemConfig(string gitBinPath, string settingName)
{
- return new ConfigResult(
- new GitProcess(gitBinPath, workingDirectoryRoot: null).InvokeGitOutsideEnlistment("config --system " + settingName),
+ GitProcess git = new GitProcess(gitBinPath, workingDirectoryRoot: null);
+ return new ConfigResult(git.InvokeConfigRead(
+ settingName,
+ () => git.InvokeGitOutsideEnlistment("config --system " + settingName)),
settingName);
}
public static ConfigResult GetFromFileConfig(string gitBinPath, string configFile, string settingName)
{
- return new ConfigResult(
- new GitProcess(gitBinPath, workingDirectoryRoot: null).InvokeGitOutsideEnlistment("config --file " + configFile + " " + settingName),
+ GitProcess git = new GitProcess(gitBinPath, workingDirectoryRoot: null);
+ return new ConfigResult(git.InvokeConfigRead(
+ settingName,
+ () => git.InvokeGitOutsideEnlistment("config --file " + configFile + " " + settingName)),
settingName);
}
@@ -469,7 +494,9 @@ public Result SetInFileConfig(string configFile, string settingName, string valu
public bool TryGetConfigUrlMatch(string section, string repositoryUrl, out Dictionary configSettings)
{
// See GetFromConfig for why pre-command hook is disabled.
- Result result = this.InvokeGitAgainstDotGitFolder($"config --get-urlmatch {section} {repositoryUrl}", usePreCommandHook: false);
+ Result result = this.InvokeConfigRead(
+ section,
+ () => this.InvokeGitAgainstDotGitFolder($"config --get-urlmatch {section} {repositoryUrl}", usePreCommandHook: false));
if (result.ExitCodeIsFailure)
{
configSettings = null;
@@ -485,7 +512,11 @@ public bool TryGetAllConfig(bool localOnly, out Dictionary this.InvokeGitAgainstDotGitFolder("config --list " + localParameter, usePreCommandHook: false)),
+ "--list");
if (result.TryParseAsString(out string output, out string _, string.Empty))
{
@@ -515,16 +546,22 @@ public virtual ConfigResult GetFromConfig(string settingName, bool forceOutsideE
// mutation happens), and skipping the hook makes us robust to a broken
// hook config in the enlistment - which is exactly what we'd be trying
// to repair via TryUpdateHooks at mount time.
- return
+ Result result =
fileSystem.DirectoryExists(this.workingDirectoryRoot) && !forceOutsideEnlistment
- ? new ConfigResult(this.InvokeGitAgainstDotGitFolder(command, usePreCommandHook: false), settingName)
- : new ConfigResult(this.InvokeGitOutsideEnlistment(command), settingName);
+ ? this.InvokeConfigRead(settingName, () => this.InvokeGitAgainstDotGitFolder(command, usePreCommandHook: false))
+ : this.InvokeConfigRead(settingName, () => this.InvokeGitOutsideEnlistment(command));
+
+ return new ConfigResult(result, settingName);
}
public ConfigResult GetFromLocalConfig(string settingName)
{
// See GetFromConfig above for why pre-command hook is disabled here.
- return new ConfigResult(this.InvokeGitAgainstDotGitFolder("config --local " + settingName, usePreCommandHook: false), settingName);
+ return new ConfigResult(
+ this.InvokeConfigRead(
+ settingName,
+ () => this.InvokeGitAgainstDotGitFolder("config --local " + settingName, usePreCommandHook: false)),
+ settingName);
}
///
@@ -556,7 +593,12 @@ public ConfigResult GetOriginUrl()
{
/* Disable precommand hook because this config call is used during mounting process
* which needs to be able to fix a bad precommand hook configuration. */
- return new ConfigResult(this.InvokeGitAgainstDotGitFolder("config --local remote.origin.url", usePreCommandHook: false), "remote.origin.url");
+ const string ConfigName = "remote.origin.url";
+ return new ConfigResult(
+ this.InvokeConfigRead(
+ ConfigName,
+ () => this.InvokeGitAgainstDotGitFolder("config --local " + ConfigName, usePreCommandHook: false)),
+ ConfigName);
}
public Result DiffTree(string sourceTreeish, string targetTreeish, Action onResult)
@@ -1138,6 +1180,23 @@ private static bool CurrentProcessHasVisibleConsoleWindowSafe()
}
}
+ private Result InvokeConfigRead(string configName, Func readConfig)
+ {
+ return GitConfigReadRetry.InvokeResult(
+ configName,
+ readConfig,
+ IsTransientConfigReadFailure,
+ this.tracer,
+ this.configReadDelay);
+ }
+
+ private static bool IsTransientConfigReadFailure(Result result)
+ {
+ return
+ result.ExitCodeIsFailure &&
+ GitConfigReadRetry.IsTransientFileAccessError(result.Errors, lineMustContain: "unable to access");
+ }
+
private static string ParseValue(string contents, string prefix)
{
int startIndex = contents.IndexOf(prefix) + prefix.Length;
@@ -1392,32 +1451,13 @@ public bool TryParseAsString(out string value, out string error, string defaultV
public bool TryParseAsInt(int defaultValue, int minValue, out int value, out string error)
{
value = defaultValue;
- error = string.Empty;
if (!this.TryParseAsString(out string valueString, out error))
{
return false;
}
- if (string.IsNullOrWhiteSpace(valueString))
- {
- // Use default value
- return true;
- }
-
- if (!int.TryParse(valueString, out value))
- {
- error = string.Format("Misconfigured config setting {0}, could not parse value `{1}` as an int", this.configName, valueString);
- return false;
- }
-
- if (value < minValue)
- {
- error = string.Format("Invalid value {0} for setting {1}, value must be greater than or equal to {2}", value, this.configName, minValue);
- return false;
- }
-
- return true;
+ return GitConfigHelper.TryParseIntSetting(this.configName, valueString, defaultValue, minValue, out value, out error);
}
}
}
diff --git a/GVFS/GVFS.Common/Git/LibGit2Repo.cs b/GVFS/GVFS.Common/Git/LibGit2Repo.cs
index 6667dd2c82..f4d1bda8cc 100644
--- a/GVFS/GVFS.Common/Git/LibGit2Repo.cs
+++ b/GVFS/GVFS.Common/Git/LibGit2Repo.cs
@@ -1,5 +1,6 @@
using GVFS.Common.Tracing;
using System;
+using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
@@ -27,21 +28,65 @@ namespace GVFS.Common.Git
///
public class LibGit2Repo : IDisposable
{
+ // git.exe reads config from these variables, or uses them to select config files.
+ // libgit2 does not. GIT_DIR selects the repository when no repository path is given.
+ private static readonly string[] ConfigEnvironmentVariablesLibGit2Ignores =
+ {
+ "GIT_CONFIG",
+ "GIT_CONFIG_COUNT",
+ "GIT_CONFIG_GLOBAL",
+ "GIT_CONFIG_NOSYSTEM",
+ "GIT_CONFIG_PARAMETERS",
+ "GIT_CONFIG_SYSTEM",
+ "GIT_DIR",
+ };
+
+ // git.exe also evaluates hasconfig:remote.*.url conditions.
+ private static readonly string[] IncludeConditionsLibGit2Evaluates =
+ {
+ "gitdir:",
+ "gitdir/i:",
+ "onbranch:",
+ };
+
private bool disposedValue = false;
private IntPtr odbHandle = IntPtr.Zero;
+ private readonly Action configReadDelay;
+ private readonly int configReadMaxAttempts;
public delegate void MultiVarConfigCallback(string value);
public LibGit2Repo(ITracer tracer, string repoPath)
+ : this(tracer, repoPath, GitConfigReadRetry.Delay, GitConfigReadRetry.MaxAttempts)
+ {
+ }
+
+ internal LibGit2Repo(ITracer tracer, string repoPath, Action configReadDelay, int configReadMaxAttempts)
{
this.Tracer = tracer;
+ this.configReadDelay = configReadDelay ?? GitConfigReadRetry.Delay;
+ this.configReadMaxAttempts = configReadMaxAttempts;
InitNative();
- IntPtr repoHandle;
- if (TryOpenRepo(repoPath, out repoHandle) != Native.ResultCode.Success)
+ // Opening the repo parses .git\config, so it is subject to the same transient failures as a config read.
+ IntPtr repoHandle = IntPtr.Zero;
+ string reason = null;
+ Native.ResultCode openResult = GitConfigReadRetry.InvokeResult(
+ "repository open",
+ () =>
+ {
+ Native.ResultCode result = TryOpenRepo(repoPath, out repoHandle);
+ reason = result == Native.ResultCode.Success ? null : GetLastNativeError();
+ return result;
+ },
+ (Native.ResultCode result) => result != Native.ResultCode.Success && GitConfigReadRetry.IsTransientFileAccessError(reason),
+ tracer,
+ this.configReadDelay,
+ this.configReadMaxAttempts);
+
+ if (openResult != Native.ResultCode.Success)
{
- string reason = GetLastNativeError();
string message = "Couldn't open repo at " + repoPath + ": " + reason;
tracer.RelatedWarning(message);
@@ -49,6 +94,10 @@ public LibGit2Repo(ITracer tracer, string repoPath)
|| !CheckSafeDirectoryConfigForCaseSensitivityIssue(tracer, repoPath, out repoHandle))
{
ShutdownNative();
+
+ // The native reference is released, so the finalizer must not release it again.
+ this.disposedValue = true;
+ GC.SuppressFinalize(this);
throw new InvalidDataException(message);
}
}
@@ -62,8 +111,15 @@ protected LibGit2Repo()
}
protected LibGit2Repo(ITracer tracer)
+ : this(tracer, GitConfigReadRetry.Delay)
+ {
+ }
+
+ protected LibGit2Repo(ITracer tracer, Action configReadDelay)
{
this.Tracer = tracer;
+ this.configReadDelay = configReadDelay ?? GitConfigReadRetry.Delay;
+ this.configReadMaxAttempts = GitConfigReadRetry.MaxAttempts;
}
~LibGit2Repo()
@@ -274,6 +330,22 @@ public virtual string[] GetMissingSubTrees(string treeSha)
/// Name of the config entry
/// The config value, or null if not found.
public virtual string GetConfigString(string name)
+ {
+ return GitConfigReadRetry.InvokeCatching(
+ name,
+ () => this.GetConfigStringOnce(name),
+ GitConfigReadRetry.IsTransientLibGit2Error,
+ this.Tracer,
+ this.configReadDelay,
+ this.configReadMaxAttempts);
+ }
+
+ protected virtual string GetConfigStringOnce(string name)
+ {
+ return this.ReadRepoConfigString(name, rejectIncludesLibGit2Ignores: false);
+ }
+
+ private string ReadRepoConfigString(string name, bool rejectIncludesLibGit2Ignores)
{
IntPtr configHandle;
if (Native.Config.GetConfig(out configHandle, this.RepoHandle) != Native.ResultCode.Success)
@@ -282,47 +354,116 @@ public virtual string GetConfigString(string name)
}
try
{
- // git_config_get_string returns a borrowed pointer whose lifetime is tied to the
- // config, so libgit2 only allows it on a snapshot (read-only) config. Calling it on
- // the live config returned by git_repository_config fails with "get_string called on
- // a live config object". Snapshot the config first, then read the string from it.
- IntPtr snapshotHandle;
- if (Native.Config.Snapshot(out snapshotHandle, configHandle) != Native.ResultCode.Success)
+ return GetStringFromSnapshot(configHandle, name, rejectIncludesLibGit2Ignores);
+ }
+ finally
+ {
+ Native.Config.Free(configHandle);
+ }
+ }
+
+ private static string GetStringFromSnapshot(IntPtr configHandle, string name, bool rejectIncludesLibGit2Ignores)
+ {
+ // git_config_get_string returns a borrowed pointer whose lifetime is tied to the
+ // config, so libgit2 only allows it on a snapshot (read-only) config. Calling it on
+ // the live config returned by git_repository_config fails with "get_string called on
+ // a live config object". Snapshot the config first, then read the string from it.
+ IntPtr snapshotHandle;
+ if (Native.Config.Snapshot(out snapshotHandle, configHandle) != Native.ResultCode.Success)
+ {
+ throw new LibGit2Exception($"Failed to snapshot config for '{name}': {Native.GetLastError()}");
+ }
+
+ try
+ {
+ if (rejectIncludesLibGit2Ignores)
+ {
+ string condition = FindIncludeConditionLibGit2Ignores(snapshotHandle);
+ if (condition != null)
+ {
+ throw new LibGit2Exception($"Config has an includeIf condition that libgit2 does not evaluate: {condition}");
+ }
+ }
+
+ // git_config_get_string yields a borrowed pointer owned by the (snapshot)
+ // config, so it is retrieved as an IntPtr and copied manually. Marshalling it
+ // directly as an out string would make the interop marshaller free the pointer
+ // with CoTaskMemFree, corrupting libgit2's heap (mismatched allocator).
+ IntPtr valuePtr;
+ Native.ResultCode resultCode = Native.Config.GetString(out valuePtr, snapshotHandle, name);
+ if (resultCode == Native.ResultCode.NotFound)
{
- throw new LibGit2Exception($"Failed to snapshot config for '{name}': {Native.GetLastError()}");
+ return null;
+ }
+ else if (resultCode != Native.ResultCode.Success)
+ {
+ throw new LibGit2Exception($"Failed to get config value for '{name}': {Native.GetLastError()}");
}
+ return valuePtr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(valuePtr);
+ }
+ finally
+ {
+ Native.Config.Free(snapshotHandle);
+ }
+ }
+
+ ///
+ /// Returns the first includeIf condition that libgit2 skips but git.exe can apply, or null.
+ ///
+ private static string FindIncludeConditionLibGit2Ignores(IntPtr configHandle)
+ {
+ const string Prefix = "includeif.";
+ const string Suffix = ".path";
+
+ string ignoredCondition = null;
+ Native.Config.GitConfigMultivarCallback callback = (entryPtr, payload) =>
+ {
try
{
- // git_config_get_string yields a borrowed pointer owned by the (snapshot)
- // config, so it is retrieved as an IntPtr and copied manually. Marshalling it
- // directly as an out string would make the interop marshaller free the pointer
- // with CoTaskMemFree, corrupting libgit2's heap (mismatched allocator).
- IntPtr valuePtr;
- Native.ResultCode resultCode = Native.Config.GetString(out valuePtr, snapshotHandle, name);
- if (resultCode == Native.ResultCode.NotFound)
- {
- return null;
- }
- else if (resultCode != Native.ResultCode.Success)
+ string entryName = Marshal.PtrToStructure(entryPtr).GetName();
+ string condition = entryName.Substring(Prefix.Length, entryName.Length - Prefix.Length - Suffix.Length);
+ foreach (string evaluated in IncludeConditionsLibGit2Evaluates)
{
- throw new LibGit2Exception($"Failed to get config value for '{name}': {Native.GetLastError()}");
+ if (condition.StartsWith(evaluated, StringComparison.Ordinal))
+ {
+ return Native.ResultCode.Success;
+ }
}
- return valuePtr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(valuePtr);
+ ignoredCondition = condition;
}
- finally
+ catch (Exception e)
{
- Native.Config.Free(snapshotHandle);
+ // An exception must not cross the native callback boundary.
+ ignoredCondition = "";
}
- }
- finally
+
+ return Native.ResultCode.Failure;
+ };
+
+ Native.ResultCode resultCode = Native.Config.ForeachMatch(configHandle, @"^includeif\..*\.path$", callback, IntPtr.Zero);
+ GC.KeepAlive(callback);
+ if (ignoredCondition == null && resultCode != Native.ResultCode.Success)
{
- Native.Config.Free(configHandle);
+ throw new LibGit2Exception($"Failed to enumerate includeIf config: {Native.GetLastError()}");
}
+
+ return ignoredCondition;
}
public virtual bool? GetConfigBool(string name)
+ {
+ return GitConfigReadRetry.InvokeCatching(
+ name,
+ () => this.GetConfigBoolOnce(name),
+ GitConfigReadRetry.IsTransientLibGit2Error,
+ this.Tracer,
+ this.configReadDelay,
+ this.configReadMaxAttempts);
+ }
+
+ protected virtual bool? GetConfigBoolOnce(string name)
{
IntPtr configHandle;
if (Native.Config.GetConfig(out configHandle, this.RepoHandle) != Native.ResultCode.Success)
@@ -375,13 +516,14 @@ public bool GetConfigBoolOrDefault(string key, bool defaultValue)
/// LibGit2RepoInvoker.InitializeSharedRepo forces the object store to load, which is
/// wasted work when all that's needed is a single config value. Falls back to
/// if the repo can't be opened or the read fails for any
- /// reason.
+ /// reason. bounds the transient-failure retries
+ /// for latency-sensitive callers.
///
- public static bool GetConfigBoolOrDefault(ITracer tracer, string repoPath, string key, bool defaultValue)
+ public static bool GetConfigBoolOrDefault(ITracer tracer, string repoPath, string key, bool defaultValue, int configReadMaxAttempts = GitConfigReadRetry.MaxAttempts)
{
try
{
- using (LibGit2Repo repo = new LibGit2Repo(tracer, repoPath))
+ using (LibGit2Repo repo = new LibGit2Repo(tracer, repoPath, GitConfigReadRetry.Delay, configReadMaxAttempts))
{
return repo.GetConfigBoolOrDefault(key, defaultValue);
}
@@ -399,15 +541,147 @@ public static bool GetConfigBoolOrDefault(ITracer tracer, string repoPath, strin
}
}
+ ///
+ /// Reads a string config value as git.exe would, without loading the object store. Reads
+ /// the repo at , or the global, XDG, system, and ProgramData
+ /// config when is null. is null when
+ /// the key is unset. Fails when the config has an includeIf condition that libgit2 does not
+ /// evaluate. Callers must also check .
+ ///
+ internal static bool TryGetConfigString(ITracer tracer, string repoPath, string name, out string value, out string error)
+ {
+ tracer = tracer ?? NullTracer.Instance;
+ value = null;
+ error = null;
+
+ try
+ {
+ if (repoPath == null)
+ {
+ value = GetGlobalAndSystemConfigString(tracer, name);
+ }
+ else
+ {
+ using (LibGit2Repo repo = new LibGit2Repo(tracer, repoPath))
+ {
+ value = GitConfigReadRetry.InvokeCatching(
+ name,
+ () => repo.ReadRepoConfigString(name, rejectIncludesLibGit2Ignores: true),
+ GitConfigReadRetry.IsTransientLibGit2Error,
+ tracer,
+ repo.configReadDelay,
+ repo.configReadMaxAttempts);
+ }
+ }
+
+ return true;
+ }
+ catch (Exception e) when (e is InvalidDataException || e is LibGit2Exception)
+ {
+ error = "Error while reading '" + name + "' from config: " + e.Message;
+ return false;
+ }
+ }
+
+ ///
+ /// True when the process environment selects or supplies Git config that libgit2 does not read.
+ ///
+ internal static bool EnvironmentChangesGitConfig()
+ {
+ return EnvironmentChangesGitConfig(Environment.GetEnvironmentVariables());
+ }
+
+ internal static bool EnvironmentChangesGitConfig(IDictionary environment)
+ {
+ foreach (object key in environment.Keys)
+ {
+ string name = key as string;
+ foreach (string variable in ConfigEnvironmentVariablesLibGit2Ignores)
+ {
+ if (string.Equals(name, variable, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static string GetGlobalAndSystemConfigString(ITracer tracer, string name)
+ {
+ Native.Init();
+ try
+ {
+ return GitConfigReadRetry.InvokeCatching(
+ name,
+ () =>
+ {
+ if (Native.Config.GetGlobalAndSystemConfig(out IntPtr configHandle) != Native.ResultCode.Success)
+ {
+ throw new LibGit2Exception($"Failed to get global and system config handle: {Native.GetLastError()}");
+ }
+
+ try
+ {
+ return GetStringFromSnapshot(configHandle, name, rejectIncludesLibGit2Ignores: true);
+ }
+ finally
+ {
+ Native.Config.Free(configHandle);
+ }
+ },
+ GitConfigReadRetry.IsTransientLibGit2Error,
+ tracer,
+ GitConfigReadRetry.Delay);
+ }
+ finally
+ {
+ Native.Shutdown();
+ }
+ }
+
public void ForEachMultiVarConfig(string key, MultiVarConfigCallback callback)
+ {
+ List values = GitConfigReadRetry.InvokeCatching(
+ key,
+ () => this.GetMultiVarConfigValues(key),
+ GitConfigReadRetry.IsTransientLibGit2Error,
+ this.Tracer,
+ this.configReadDelay,
+ this.configReadMaxAttempts);
+
+ foreach (string value in values)
+ {
+ callback(value);
+ }
+ }
+
+ public static void ForEachMultiVarConfigInGlobalAndSystemConfig(string key, MultiVarConfigCallback callback)
+ {
+ List values = GitConfigReadRetry.InvokeCatching(
+ key,
+ () => GetGlobalAndSystemMultiVarConfigValues(key),
+ GitConfigReadRetry.IsTransientLibGit2Error,
+ NullTracer.Instance,
+ GitConfigReadRetry.Delay);
+
+ foreach (string value in values)
+ {
+ callback(value);
+ }
+ }
+
+ protected virtual List GetMultiVarConfigValues(string key)
{
if (Native.Config.GetConfig(out IntPtr configHandle, this.RepoHandle) != Native.ResultCode.Success)
{
throw new LibGit2Exception($"Failed to get config handle: {Native.GetLastError()}");
}
+
try
{
- ForEachMultiVarConfig(configHandle, key, callback);
+ return GetMultiVarConfigValues(configHandle, key);
}
finally
{
@@ -415,15 +689,16 @@ public void ForEachMultiVarConfig(string key, MultiVarConfigCallback callback)
}
}
- public static void ForEachMultiVarConfigInGlobalAndSystemConfig(string key, MultiVarConfigCallback callback)
+ private static List GetGlobalAndSystemMultiVarConfigValues(string key)
{
if (Native.Config.GetGlobalAndSystemConfig(out IntPtr configHandle) != Native.ResultCode.Success)
{
throw new LibGit2Exception($"Failed to get global and system config handle: {Native.GetLastError()}");
}
+
try
{
- ForEachMultiVarConfig(configHandle, key, callback);
+ return GetMultiVarConfigValues(configHandle, key);
}
finally
{
@@ -431,14 +706,15 @@ public static void ForEachMultiVarConfigInGlobalAndSystemConfig(string key, Mult
}
}
- private static void ForEachMultiVarConfig(IntPtr configHandle, string key, MultiVarConfigCallback callback)
+ private static List GetMultiVarConfigValues(IntPtr configHandle, string key)
{
+ List values = new List();
Native.Config.GitConfigMultivarCallback nativeCallback = (entryPtr, payload) =>
{
try
{
- var entry = Marshal.PtrToStructure(entryPtr);
- callback(entry.GetValue());
+ Native.Config.GitConfigEntry entry = Marshal.PtrToStructure(entryPtr);
+ values.Add(entry.GetValue());
}
catch (Exception)
{
@@ -455,6 +731,8 @@ private static void ForEachMultiVarConfig(IntPtr configHandle, string key, Multi
{
throw new LibGit2Exception($"Failed to get multivar config for '{key}': {Native.GetLastError()}");
}
+
+ return values;
}
///
@@ -701,6 +979,13 @@ public static extern ResultCode GetMultivarForeach(
GitConfigMultivarCallback callback,
IntPtr payload);
+ [DllImport(Git2NativeLibName, EntryPoint = "git_config_foreach_match")]
+ public static extern ResultCode ForeachMatch(
+ IntPtr configHandle,
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string regex,
+ GitConfigMultivarCallback callback,
+ IntPtr payload);
+
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate ResultCode GitConfigMultivarCallback(
IntPtr entryPtr,
diff --git a/GVFS/GVFS.Common/GitStatusCacheConfig.cs b/GVFS/GVFS.Common/GitStatusCacheConfig.cs
index 82598fb4ac..415f1e40a1 100644
--- a/GVFS/GVFS.Common/GitStatusCacheConfig.cs
+++ b/GVFS/GVFS.Common/GitStatusCacheConfig.cs
@@ -25,7 +25,7 @@ public GitStatusCacheConfig(TimeSpan backOffTime)
public static bool TryLoadFromGitConfig(ITracer tracer, Enlistment enlistment, out GitStatusCacheConfig gitStatusCacheConfig, out string error)
{
- return TryLoadFromGitConfig(tracer, new GitProcess(enlistment), out gitStatusCacheConfig, out error);
+ return TryLoadFromGitConfig(tracer, enlistment.CreateGitProcess(tracer), out gitStatusCacheConfig, out error);
}
public static bool TryLoadFromGitConfig(ITracer tracer, GitProcess git, out GitStatusCacheConfig gitStatusCacheConfig, out string error)
diff --git a/GVFS/GVFS.Common/Http/CacheServerResolver.cs b/GVFS/GVFS.Common/Http/CacheServerResolver.cs
index 7a0c6e1a73..34a9c17215 100644
--- a/GVFS/GVFS.Common/Http/CacheServerResolver.cs
+++ b/GVFS/GVFS.Common/Http/CacheServerResolver.cs
@@ -18,10 +18,10 @@ public CacheServerResolver(
this.enlistment = enlistment;
}
- public static CacheServerInfo GetCacheServerFromConfig(Enlistment enlistment)
+ public static CacheServerInfo GetCacheServerFromConfig(Enlistment enlistment, ITracer tracer = null)
{
- GitProcess git = enlistment.CreateGitProcess();
- string url = GetUrlFromConfig(enlistment);
+ GitProcess git = CreateGitProcess(enlistment, tracer);
+ string url = GetUrlFromConfig(enlistment, tracer);
string prefetchCacheServerUrl = GetEndpointUrlFromConfig(git, GVFSConstants.GitConfig.PrefetchCacheServer);
string getCacheServerUrl = GetEndpointUrlFromConfig(git, GVFSConstants.GitConfig.GetCacheServer);
string postCacheServerUrl = GetEndpointUrlFromConfig(git, GVFSConstants.GitConfig.PostCacheServer);
@@ -36,9 +36,9 @@ public static CacheServerInfo GetCacheServerFromConfig(Enlistment enlistment)
sizesCacheServerUrl);
}
- public static string GetUrlFromConfig(Enlistment enlistment)
+ public static string GetUrlFromConfig(Enlistment enlistment, ITracer tracer = null)
{
- GitProcess git = enlistment.CreateGitProcess();
+ GitProcess git = CreateGitProcess(enlistment, tracer);
// TODO 1057500: Remove support for encoded-repo-url cache config setting
return
@@ -170,6 +170,13 @@ private static string GetValueFromConfig(GitProcess git, string configName, bool
return value;
}
+ private static GitProcess CreateGitProcess(Enlistment enlistment, ITracer tracer)
+ {
+ return tracer == null
+ ? enlistment.CreateGitProcess()
+ : enlistment.CreateGitProcess(tracer);
+ }
+
private static string GetEndpointUrlFromConfig(GitProcess git, string configName)
{
string url = GetValueFromConfig(git, configName, localOnly: true);
diff --git a/GVFS/GVFS.Common/RetryConfig.cs b/GVFS/GVFS.Common/RetryConfig.cs
index 4462a18bcc..628dd7d017 100644
--- a/GVFS/GVFS.Common/RetryConfig.cs
+++ b/GVFS/GVFS.Common/RetryConfig.cs
@@ -1,6 +1,8 @@
using GVFS.Common.Git;
using GVFS.Common.Tracing;
using System;
+using System.Collections;
+using System.IO;
using System.Linq;
namespace GVFS.Common
@@ -17,6 +19,8 @@ public class RetryConfig
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(DefaultTimeoutSeconds);
+ internal delegate bool ConfigStringReader(string name, out string value, out string error);
+
public RetryConfig(int maxRetries = DefaultMaxRetries)
: this(maxRetries, DefaultTimeout)
{
@@ -38,15 +42,88 @@ public int MaxAttempts
public static bool TryLoadFromGitConfig(ITracer tracer, Enlistment enlistment, out RetryConfig retryConfig, out string error)
{
- return TryLoadFromGitConfig(tracer, new GitProcess(enlistment), out retryConfig, out error);
+ return TryLoadFromGitConfig(
+ tracer,
+ CreateConfigReader(tracer, enlistment, Environment.GetEnvironmentVariables()),
+ out retryConfig,
+ out error);
+ }
+
+ internal static bool TryLoadFromGitConfig(ITracer tracer, GitProcess git, out RetryConfig retryConfig, out string error)
+ {
+ return TryLoadFromGitConfig(
+ tracer,
+ (string name, out string value, out string readError) => git.GetFromConfig(name).TryParseAsString(out value, out readError),
+ out retryConfig,
+ out error);
+ }
+
+ ///
+ /// Reads the settings in process with libgit2, and with git.exe when libgit2 cannot give
+ /// the value that git.exe would give.
+ ///
+ internal static ConfigStringReader CreateConfigReader(ITracer tracer, Enlistment enlistment, IDictionary environment)
+ {
+ GitProcess git = null;
+ ConfigStringReader readWithGit = (string name, out string value, out string error) =>
+ {
+ git = git ?? enlistment.CreateGitProcess(tracer);
+ return git.GetFromConfig(name).TryParseAsString(out value, out error);
+ };
+
+ return LibGit2Repo.EnvironmentChangesGitConfig(environment)
+ ? readWithGit
+ : WithGitFallback(tracer, CreateLibGit2Reader(tracer, enlistment.WorkingDirectoryBackingRoot), readWithGit);
+ }
+
+ internal static ConfigStringReader CreateLibGit2Reader(ITracer tracer, string workingDirectoryRoot)
+ {
+ // GitProcess.GetFromConfig also reads only the global and system config when the
+ // working directory does not exist, for example before clone creates it.
+ string repoPath = Directory.Exists(workingDirectoryRoot) ? workingDirectoryRoot : null;
+ return (string name, out string value, out string error) =>
+ LibGit2Repo.TryGetConfigString(tracer, repoPath, name, out value, out error);
+ }
+
+ ///
+ /// Reads with git.exe when libgit2 fails, for example because it rejects the repository's
+ /// ownership or skips an includeIf condition. A transient file access failure is not read
+ /// again, because libgit2 already used the GitConfigReadRetry budget for it.
+ ///
+ internal static ConfigStringReader WithGitFallback(ITracer tracer, ConfigStringReader readWithLibGit2, ConfigStringReader readWithGit)
+ {
+ return (string name, out string value, out string error) =>
+ {
+ if (readWithLibGit2(name, out value, out error))
+ {
+ return true;
+ }
+
+ if (GitConfigReadRetry.IsTransientFileAccessError(error))
+ {
+ return false;
+ }
+
+ tracer?.RelatedEvent(
+ EventLevel.Informational,
+ "RetryConfig_ReadWithGit",
+ new EventMetadata
+ {
+ { "Area", EtwArea },
+ { "ConfigName", name },
+ { "LibGit2Error", error },
+ });
+
+ return readWithGit(name, out value, out error);
+ };
}
- public static bool TryLoadFromGitConfig(ITracer tracer, GitProcess git, out RetryConfig retryConfig, out string error)
+ internal static bool TryLoadFromGitConfig(ITracer tracer, ConfigStringReader readConfig, out RetryConfig retryConfig, out string error)
{
retryConfig = null;
int maxRetries;
- if (!TryLoadMaxRetries(git, out maxRetries, out error))
+ if (!TryLoadMaxRetries(readConfig, out maxRetries, out error))
{
if (tracer != null)
{
@@ -63,7 +140,7 @@ public static bool TryLoadFromGitConfig(ITracer tracer, GitProcess git, out Retr
}
TimeSpan timeout;
- if (!TryLoadTimeout(git, out timeout, out error))
+ if (!TryLoadTimeout(readConfig, out timeout, out error))
{
if (tracer != null)
{
@@ -99,10 +176,10 @@ public static bool TryLoadFromGitConfig(ITracer tracer, GitProcess git, out Retr
return true;
}
- private static bool TryLoadMaxRetries(GitProcess git, out int attempts, out string error)
+ private static bool TryLoadMaxRetries(ConfigStringReader readConfig, out int attempts, out string error)
{
return TryGetFromGitConfig(
- git,
+ readConfig,
GVFSConstants.GitConfig.MaxRetriesConfig,
DefaultMaxRetries,
MinRetries,
@@ -110,12 +187,12 @@ private static bool TryLoadMaxRetries(GitProcess git, out int attempts, out stri
out error);
}
- private static bool TryLoadTimeout(GitProcess git, out TimeSpan timeout, out string error)
+ private static bool TryLoadTimeout(ConfigStringReader readConfig, out TimeSpan timeout, out string error)
{
timeout = TimeSpan.FromSeconds(0);
int timeoutSeconds;
if (!TryGetFromGitConfig(
- git,
+ readConfig,
GVFSConstants.GitConfig.TimeoutSecondsConfig,
DefaultTimeoutSeconds,
0,
@@ -129,10 +206,15 @@ private static bool TryLoadTimeout(GitProcess git, out TimeSpan timeout, out str
return true;
}
- private static bool TryGetFromGitConfig(GitProcess git, string configName, int defaultValue, int minValue, out int value, out string error)
+ private static bool TryGetFromGitConfig(ConfigStringReader readConfig, string configName, int defaultValue, int minValue, out int value, out string error)
{
- GitProcess.ConfigResult result = git.GetFromConfig(configName);
- return result.TryParseAsInt(defaultValue, minValue, out value, out error);
+ value = defaultValue;
+ if (!readConfig(configName, out string valueString, out error))
+ {
+ return false;
+ }
+
+ return GitConfigHelper.TryParseIntSetting(configName, valueString, defaultValue, minValue, out value, out error);
}
}
}
diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitReadAndGitLockTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitReadAndGitLockTests.cs
index cb7af7571d..e84242e6f3 100644
--- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitReadAndGitLockTests.cs
+++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitReadAndGitLockTests.cs
@@ -4,8 +4,10 @@
using GVFS.Tests.Should;
using NUnit.Framework;
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
+using System.Text;
using System.Threading;
namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture
@@ -131,5 +133,335 @@ public void OrphanedGVFSLockIsCleanedUp()
// an error about "Waiting for GVFS.FunctionalTests.LockHolder"
statusWait.Errors.ShouldEqual(string.Empty);
}
+
+ [TestCase("stash", "list")]
+ [TestCase("stash.exe", "list")]
+ [TestCase("for-each-repo", "--config=gvfs.tests.alias-repositories status --porcelain")]
+ [TestCase("checkout", "--detach HEAD")]
+ [Order(9)]
+ public void BuiltInWithSameNameAliasStillAcquiresLock(string command, string arguments)
+ {
+ string aliasName = command.EndsWith(".exe", StringComparison.Ordinal) ? Path.GetFileNameWithoutExtension(command) : command;
+ string repositoryConfig = command == "for-each-repo"
+ ? "-c \"gvfs.tests.alias-repositories=" + this.Enlistment.RepoRoot + "\" "
+ : string.Empty;
+ ProcessResult result = this.RunWithLockHolder(repositoryConfig + "-c alias." + aliasName + "=version " + command + " " + arguments);
+ result.ExitCode.ShouldEqual(0);
+ result.Errors.ShouldContain(ExpectedStatusWaitingText);
+ }
+
+ [TestCase("gvfs-lock-outer-alias", "status --porcelain")]
+ [TestCase("gvfs-lock-outer-alias", "!git status --porcelain")]
+ [TestCase("gvfs-lock-outer-alias", "gvfs-lock-inner-alias")]
+ [TestCase("MiXeD-lock-alias", "status --porcelain")]
+ [TestCase("git-prefixed-lock-alias", "status --porcelain")]
+ [Order(10)]
+ public void AliasInnerCommandStillAcquiresLock(string aliasName, string expansion)
+ {
+ string command = "-c alias.gvfs-lock-inner-alias=status " +
+ "-c \"alias." + aliasName + "=" + expansion + "\" " + aliasName;
+ ProcessResult result = this.RunWithLockHolder(command);
+ result.ExitCode.ShouldEqual(0);
+ result.Errors.ShouldContain(ExpectedStatusWaitingText);
+ }
+
+ [TestCase]
+ [Order(11)]
+ public void ReadOnlyAliasDoesNotAcquireAnOuterLock()
+ {
+ ProcessResult result = this.RunWithLockHolder(
+ "-c \"alias.gvfs-read-only-alias=!git rev-parse --show-toplevel\" gvfs-read-only-alias",
+ shouldWait: false);
+ result.ExitCode.ShouldEqual(0);
+ result.Output.ShouldContain(this.Enlistment.RepoRoot.Replace('\\', '/'));
+ result.Errors.Contains(ExpectedStatusWaitingText).ShouldBeFalse();
+ }
+
+ [TestCase]
+ [Order(12)]
+ public void UnknownExternalCommandStillAcquiresLock()
+ {
+ ProcessResult result = this.RunWithLockHolder("-c help.autocorrect=never gvfs-unknown-lock-command");
+ result.ExitCode.ShouldEqual(1);
+ result.Errors.ShouldContain(ExpectedStatusWaitingText);
+ }
+
+ [TestCase("git-", false, false)]
+ [TestCase("GIT-", true, false)]
+ [TestCase("git-", false, true)]
+ [Order(13)]
+ public void ExternalWithSameNameAliasStillAcquiresLock(string prefix, bool uppercaseCommand, bool useExecPath)
+ {
+ const string CommandName = "gvfs-lock-external";
+ string directory = Path.Combine(this.Enlistment.EnlistmentRoot, nameof(this.ExternalWithSameNameAliasStillAcquiresLock));
+ Directory.CreateDirectory(directory);
+ string scriptPath = Path.Combine(directory, prefix + CommandName);
+ File.WriteAllText(scriptPath, "#!/bin/sh\nexec git status --porcelain\n");
+ try
+ {
+ Dictionary environment = new Dictionary();
+ if (useExecPath)
+ {
+ environment["GIT_EXEC_PATH"] = directory;
+ }
+ else
+ {
+ environment["PATH"] = directory + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH");
+ }
+
+ string invocation = uppercaseCommand ? CommandName.ToUpperInvariant() : CommandName;
+ ProcessResult result = this.RunWithLockHolder("-c alias." + CommandName + "=version " + invocation, environment);
+ result.ExitCode.ShouldEqual(0);
+ result.Errors.ShouldContain(ExpectedStatusWaitingText);
+ }
+ finally
+ {
+ File.Delete(scriptPath);
+ Directory.Delete(directory);
+ }
+ }
+
+ [TestCase]
+ [Order(14)]
+ public void ExternalExecutableWithSameNameAliasStillAcquiresLock()
+ {
+ const string CommandName = "gvfs-lock-executable";
+ string directory = Path.Combine(this.Enlistment.EnlistmentRoot, nameof(this.ExternalExecutableWithSameNameAliasStillAcquiresLock));
+ Directory.CreateDirectory(directory);
+ string executablePath = Path.Combine(directory, "GIT-" + CommandName + ".EXE");
+ File.Copy(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"), executablePath);
+ try
+ {
+ Dictionary environment = new Dictionary();
+ environment["PATH"] = directory + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH");
+ ProcessResult result = this.RunWithLockHolder("-c alias." + CommandName + "=version " + CommandName + " /d /c echo external-command", environment);
+ result.ExitCode.ShouldEqual(0);
+ result.Output.ShouldContain("external-command");
+ result.Errors.ShouldContain(ExpectedStatusWaitingText);
+ }
+ finally
+ {
+ File.Delete(executablePath);
+ Directory.Delete(directory);
+ }
+ }
+
+ [TestCase("")]
+ [Order(15)]
+ public void DirectoryNamedAfterAnExternalDoesNotHideAnAlias(string suffix)
+ {
+ const string CommandName = "gvfs-directory-alias";
+ string directory = Path.Combine(this.Enlistment.EnlistmentRoot, nameof(this.DirectoryNamedAfterAnExternalDoesNotHideAnAlias));
+ string commandDirectory = Path.Combine(directory, "git-" + CommandName + suffix);
+ Directory.CreateDirectory(commandDirectory);
+ try
+ {
+ Dictionary environment = new Dictionary();
+ environment["PATH"] = directory + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH");
+ ProcessResult result = this.RunWithLockHolder(
+ "-c alias." + CommandName + "=version " + CommandName,
+ environment,
+ shouldWait: false);
+ result.ExitCode.ShouldEqual(0);
+ result.Output.ShouldContain("git version");
+ }
+ finally
+ {
+ Directory.Delete(commandDirectory);
+ Directory.Delete(directory);
+ }
+ }
+
+ [TestCase]
+ [Order(16)]
+ public void PostCommandReleasesOwnedLockAfterExternalDisappears()
+ {
+ const string CommandName = "gvfs-lock-race";
+ string directory = Path.Combine(this.Enlistment.EnlistmentRoot, nameof(this.PostCommandReleasesOwnedLockAfterExternalDisappears));
+ Directory.CreateDirectory(directory);
+ string executablePath = Path.Combine(directory, "git-" + CommandName);
+ File.WriteAllText(executablePath, "#!/bin/sh\nexit 99\n");
+ string hooksExecutable = Path.Combine(Path.GetDirectoryName(Properties.Settings.Default.PathToGVFS), "GVFS.Hooks.exe");
+ Dictionary environment = new Dictionary();
+ environment["PATH"] = directory + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH");
+ environment["GIT_CONFIG_COUNT"] = "1";
+ environment["GIT_CONFIG_KEY_0"] = "alias." + CommandName;
+ environment["GIT_CONFIG_VALUE_0"] = "checkout --detach HEAD";
+
+ // Keep the Git PID alive so orphan cleanup cannot hide a skipped release.
+ using (Process git = new Process())
+ {
+ git.StartInfo = this.CreateGitStartInfo("hash-object --stdin", environment);
+ git.StartInfo.RedirectStandardInput = true;
+ bool started = false;
+ try
+ {
+ started = git.Start();
+ started.ShouldBeTrue();
+ string commandArguments = " git-" + CommandName + " --git-pid=" + git.Id;
+ ProcessStartInfo hook = this.CreateGitStartInfo("pre-command" + commandArguments, environment);
+ hook.FileName = hooksExecutable;
+ ProcessResult preCommand = ProcessHelper.Run(hook, timeoutMs: 30000);
+ preCommand.ExitCode.ShouldEqual(0, preCommand.Output + preCommand.Errors);
+ this.Enlistment.Status().ShouldContain("GVFS Lock: Held by git git-" + CommandName + " (PID:" + git.Id + ")");
+
+ File.Delete(executablePath);
+ hook.Arguments = "post-command" + commandArguments;
+ ProcessResult postCommand = ProcessHelper.Run(hook, timeoutMs: 30000);
+ postCommand.ExitCode.ShouldEqual(0, postCommand.Output + postCommand.Errors);
+ git.HasExited.ShouldBeFalse();
+ this.Enlistment.Status().ShouldContain("GVFS Lock: Free");
+ }
+ finally
+ {
+ if (started)
+ {
+ git.StandardInput.Close();
+ if (!git.WaitForExit(10000))
+ {
+ git.Kill(entireProcessTree: true);
+ git.WaitForExit();
+ Assert.Fail("The parent Git process did not exit.");
+ }
+ }
+
+ File.Delete(executablePath);
+ Directory.Delete(directory);
+ }
+ }
+ }
+
+ [TestCase("\"C:\\invalid quoted path\"")]
+ [TestCase("C:\\invalid|path")]
+ [Order(17)]
+ public void InvalidPathEntryDoesNotPreventAGenuineAlias(string pathEntry)
+ {
+ Dictionary environment = new Dictionary();
+ environment["PATH"] = pathEntry + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH");
+ ProcessResult result = this.RunWithLockHolder(
+ "-c alias.gvfs-invalid-path-alias=version gvfs-invalid-path-alias",
+ environment,
+ shouldWait: false);
+ result.ExitCode.ShouldEqual(0);
+ result.Output.ShouldContain("git version");
+ }
+
+ private ProcessStartInfo CreateGitStartInfo(string command, Dictionary environment = null)
+ {
+ ProcessStartInfo startInfo = new ProcessStartInfo(Properties.Settings.Default.PathToGit, command);
+ startInfo.WorkingDirectory = this.Enlistment.RepoRoot;
+ startInfo.UseShellExecute = false;
+ startInfo.RedirectStandardOutput = true;
+ startInfo.RedirectStandardError = true;
+ startInfo.Environment["GIT_TERMINAL_PROMPT"] = "0";
+ if (environment != null)
+ {
+ foreach (KeyValuePair entry in environment)
+ {
+ startInfo.Environment[entry.Key] = entry.Value;
+ }
+ }
+
+ return startInfo;
+ }
+
+ private ProcessResult RunWithLockHolder(string command, Dictionary environment = null, bool shouldWait = true)
+ {
+ using (Process holder = new Process())
+ using (Process git = new Process())
+ using (ManualResetEventSlim waiting = new ManualResetEventSlim(false))
+ {
+ holder.StartInfo = new ProcessStartInfo(GitHelpers.LockHolderCommandPath)
+ {
+ WorkingDirectory = this.Enlistment.RepoRoot,
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ holder.Start().ShouldBeTrue();
+ bool gitStarted = false;
+ StringBuilder output = new StringBuilder();
+ StringBuilder errors = new StringBuilder();
+ Thread errorReader = null;
+ try
+ {
+ this.Enlistment.WaitForLock("GVFS.FunctionalTests.LockHolder (PID:" + holder.Id + ")")
+ .ShouldBeTrue("The expected lock-holder process did not acquire the lock.");
+ git.StartInfo = this.CreateGitStartInfo(command, environment);
+ git.OutputDataReceived += (sender, args) =>
+ {
+ if (args.Data != null)
+ {
+ output.AppendLine(args.Data);
+ }
+ };
+ errorReader = new Thread(() =>
+ {
+ int character;
+ while ((character = git.StandardError.Read()) != -1)
+ {
+ errors.Append((char)character);
+ if (!waiting.IsSet && errors.ToString().Contains(ExpectedStatusWaitingText))
+ {
+ waiting.Set();
+ }
+ }
+ });
+ gitStarted = git.Start();
+ gitStarted.ShouldBeTrue();
+ git.BeginOutputReadLine();
+ errorReader.Start();
+
+ if (shouldWait)
+ {
+ waiting.Wait(AcquireGVFSLockTimeout).ShouldBeTrue("Git did not wait for the lock holder.");
+ this.ReleaseLockHolder(holder);
+ }
+
+ git.WaitForExit(30000).ShouldBeTrue("Git did not finish within the test timeout.");
+ git.WaitForExit();
+ errorReader.Join(10000).ShouldBeTrue("Git stderr did not close.");
+ if (!shouldWait)
+ {
+ holder.HasExited.ShouldBeFalse();
+ this.Enlistment.Status().ShouldContain("GVFS.FunctionalTests.LockHolder (PID:" + holder.Id + ")");
+ }
+
+ return new ProcessResult(output.ToString(), errors.ToString(), git.ExitCode);
+ }
+ finally
+ {
+ this.ReleaseLockHolder(holder);
+ if (gitStarted && !git.WaitForExit(10000))
+ {
+ git.Kill(entireProcessTree: true);
+ git.WaitForExit();
+ }
+
+ if (errorReader != null && (errorReader.ThreadState & System.Threading.ThreadState.Unstarted) == 0)
+ {
+ errorReader.Join(10000).ShouldBeTrue("Git stderr did not close during cleanup.");
+ }
+ }
+ }
+ }
+
+ private void ReleaseLockHolder(Process holder)
+ {
+ if (!holder.HasExited)
+ {
+ holder.StandardInput.WriteLine("done");
+ holder.StandardInput.Close();
+ if (!holder.WaitForExit(10000))
+ {
+ holder.Kill(entireProcessTree: true);
+ holder.WaitForExit();
+ Assert.Fail("The lock-holder process did not exit.");
+ }
+ }
+
+ holder.ExitCode.ShouldEqual(0, holder.StandardError.ReadToEnd());
+ }
}
}
diff --git a/GVFS/GVFS.FunctionalTests/Tools/GitHelpers.cs b/GVFS/GVFS.FunctionalTests/Tools/GitHelpers.cs
index 48418452d2..db19502c54 100644
--- a/GVFS/GVFS.FunctionalTests/Tools/GitHelpers.cs
+++ b/GVFS/GVFS.FunctionalTests/Tools/GitHelpers.cs
@@ -23,7 +23,7 @@ public static class GitHelpers
private const string WindowsPathSeparator = "\\";
private const string GitPathSeparator = "/";
- private static string LockHolderCommandPath
+ public static string LockHolderCommandPath
{
get
{
diff --git a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj
index 3b996578e7..1e0c60aaa5 100644
--- a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj
+++ b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj
@@ -23,6 +23,9 @@
Common\Git\LibGit2Exception.cs
+
+ Common\Git\GitConfigReadRetry.cs
+
Common\Git\LibGit2Repo.cs
diff --git a/GVFS/GVFS.Hooks/GitCommandLock.cs b/GVFS/GVFS.Hooks/GitCommandLock.cs
new file mode 100644
index 0000000000..31669c29fd
--- /dev/null
+++ b/GVFS/GVFS.Hooks/GitCommandLock.cs
@@ -0,0 +1,68 @@
+using GVFS.Common.NamedPipes;
+using System;
+
+namespace GVFS.Hooks
+{
+ internal static class GitCommandLock
+ {
+ public static bool TryCheckOwnership(
+ int pid,
+ bool isElevated,
+ string fullCommand,
+ Func sendRequest,
+ out bool ownsLock,
+ out string error)
+ {
+ ownsLock = false;
+ error = null;
+ NamedPipeMessages.LockRequest request = new NamedPipeMessages.LockRequest(
+ pid, isElevated, checkAvailabilityOnly: true, fullCommand, gitCommandSessionId: string.Empty);
+ NamedPipeMessages.Message message = sendRequest(request.CreateMessage(NamedPipeMessages.AcquireLock.AcquireRequest));
+ if (message == null)
+ {
+ error = "GVFS returned no lock-ownership response. Run 'gvfs status' to check the mount.";
+ return false;
+ }
+
+ NamedPipeMessages.AcquireLock.Response response;
+ try
+ {
+ response = new NamedPipeMessages.AcquireLock.Response(message);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException)
+ {
+ error = "GVFS returned an invalid lock-ownership response: " + ex.Message;
+ return false;
+ }
+
+ switch (response.Result)
+ {
+ case NamedPipeMessages.AcquireLock.AvailableResult:
+ case NamedPipeMessages.AcquireLock.DenyGVFSResult:
+ return true;
+
+ case NamedPipeMessages.AcquireLock.DenyGitResult:
+ if (response.ResponseData == null || response.ResponseData.PID <= 0)
+ {
+ error = "GVFS returned a lock owner without a valid process ID.";
+ return false;
+ }
+
+ ownsLock = response.ResponseData.PID == pid;
+ return true;
+
+ case NamedPipeMessages.AcquireLock.MountNotReadyResult:
+ error = "GVFS has not finished initializing. Run 'gvfs status' to check the mount.";
+ return false;
+
+ case NamedPipeMessages.AcquireLock.UnmountInProgressResult:
+ error = "GVFS is unmounting.";
+ return false;
+
+ default:
+ error = "GVFS returned an unexpected lock-ownership response: " + response.Result;
+ return false;
+ }
+ }
+ }
+}
diff --git a/GVFS/GVFS.Hooks/GitCommandResolver.cs b/GVFS/GVFS.Hooks/GitCommandResolver.cs
new file mode 100644
index 0000000000..bb8f067e21
--- /dev/null
+++ b/GVFS/GVFS.Hooks/GitCommandResolver.cs
@@ -0,0 +1,175 @@
+using GVFS.Common;
+using System;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+
+namespace GVFS.Hooks
+{
+ internal static class GitCommandResolver
+ {
+ private const int InvalidNameHResult = unchecked((int)0x8007007B);
+
+ public static bool ShouldLock(
+ string hookCommand,
+ Func getAlias,
+ string searchPath,
+ Func fileExists,
+ Action reportError)
+ {
+ if (!TryIsAlias(hookCommand, getAlias, searchPath, fileExists, out bool isAlias, out string error))
+ {
+ reportError(error + " GVFS will keep the command lock.");
+ return true;
+ }
+
+ return !isAlias;
+ }
+
+ public static bool TryIsAlias(
+ string hookCommand,
+ Func getAlias,
+ string searchPath,
+ Func fileExists,
+ out bool isAlias,
+ out string error)
+ {
+ isAlias = false;
+ error = null;
+
+ // Git supplies canonical, unprefixed names for built-ins, and git- for external attempts.
+ if (!hookCommand.StartsWith("git-", StringComparison.Ordinal) ||
+ hookCommand.IndexOfAny(new[] { '\\', '/' }) >= 0)
+ {
+ return true;
+ }
+
+ string command = hookCommand.Substring("git-".Length);
+ ProcessResult result;
+ try
+ {
+ result = getAlias("alias." + command);
+ }
+ catch (Exception ex) when (ex is Win32Exception || ex is InvalidOperationException ||
+ ex is ArgumentException || ex is IOException || ex is UnauthorizedAccessException)
+ {
+ error = $"Unable to query Git alias '{command}': {FormatError(ex)}";
+ return false;
+ }
+
+ if (result.ExitCode == 1 && string.IsNullOrEmpty(result.Output))
+ {
+ return true;
+ }
+
+ if (result.ExitCode != 0)
+ {
+ error = $"Unable to read Git alias '{command}' (exit code {result.ExitCode}). {result.Errors}".TrimEnd();
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(result.Output))
+ {
+ return true;
+ }
+
+ if (string.IsNullOrEmpty(searchPath))
+ {
+ error = $"Unable to resolve Git command '{command}': the inherited PATH is empty.";
+ return false;
+ }
+
+ string candidate = null;
+ try
+ {
+ // Git adds its exec-path to PATH before the hook. Probe Windows names directly:
+ // --list-cmds misses executable names with an uppercase GIT- prefix.
+ foreach (string directory in searchPath.Split(Path.PathSeparator))
+ {
+ if (directory.Length == 0)
+ {
+ continue;
+ }
+
+ string commandPath = directory.TrimEnd('\\', '/') + "\\" + hookCommand;
+ if (!hookCommand.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
+ {
+ candidate = commandPath + ".exe";
+ if (fileExists(candidate))
+ {
+ return true;
+ }
+ }
+
+ candidate = commandPath;
+ if (fileExists(candidate))
+ {
+ return true;
+ }
+ }
+ }
+ catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException)
+ {
+ error = $"Unable to resolve Git command '{command}' at '{candidate}': {FormatError(ex)}";
+ return false;
+ }
+
+ isAlias = true;
+ return true;
+ }
+
+ public static string GetProcessPath(int processId)
+ {
+ using (Process process = Process.GetProcessById(processId))
+ {
+ string path = process.MainModule?.FileName;
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new InvalidOperationException($"Unable to read the executable path for Git PID {processId}.");
+ }
+
+ return path;
+ }
+ }
+
+ public static ProcessStartInfo CreateAliasQuery(string gitPath, string aliasKey)
+ {
+ ProcessStartInfo processInfo = new ProcessStartInfo(gitPath);
+ processInfo.UseShellExecute = false;
+ processInfo.RedirectStandardInput = true;
+ processInfo.RedirectStandardOutput = true;
+ processInfo.RedirectStandardError = true;
+ processInfo.ArgumentList.Add("config");
+ processInfo.ArgumentList.Add("--get");
+ processInfo.ArgumentList.Add(aliasKey);
+ processInfo.Environment["COMMAND_HOOK_LOCK"] = "true";
+ return processInfo;
+ }
+
+ public static bool FileExists(string path)
+ {
+ try
+ {
+ return (File.GetAttributes(path) & FileAttributes.Directory) == 0;
+ }
+ catch (FileNotFoundException)
+ {
+ return false;
+ }
+ catch (DirectoryNotFoundException)
+ {
+ return false;
+ }
+ catch (IOException ex) when (ex.HResult == InvalidNameHResult)
+ {
+ // Git also ignores invalid PATH entries, such as directories enclosed in literal quotes.
+ return false;
+ }
+ }
+
+ private static string FormatError(Exception error)
+ {
+ return $"{error.GetType().Name} (0x{error.HResult:X8}): {error.Message}";
+ }
+ }
+}
diff --git a/GVFS/GVFS.Hooks/KnownGitCommands.cs b/GVFS/GVFS.Hooks/KnownGitCommands.cs
deleted file mode 100644
index a392eace3c..0000000000
--- a/GVFS/GVFS.Hooks/KnownGitCommands.cs
+++ /dev/null
@@ -1,150 +0,0 @@
-using System.Collections.Generic;
-
-namespace GVFS.Hooks
-{
- internal static class KnownGitCommands
- {
- private static HashSet knownCommands = new HashSet()
- {
- "add",
- "am",
- "annotate",
- "apply",
- "archive",
- "bisect--helper",
- "blame",
- "branch",
- "bundle",
- "cat-file",
- "check-attr",
- "check-ignore",
- "check-mailmap",
- "check-ref-format",
- "checkout",
- "checkout-index",
- "cherry",
- "cherry-pick",
- "clean",
- "clone",
- "column",
- "commit",
- "commit-tree",
- "config",
- "count-objects",
- "credential",
- "describe",
- "diff",
- "diff-files",
- "diff-index",
- "diff-tree",
- "fast-export",
- "fetch",
- "fetch-pack",
- "fmt-merge-msg",
- "for-each-ref",
- "format-patch",
- "fsck",
- "fsck-objects",
- "gc",
- "get-tar-commit-id",
- "grep",
- "hash-object",
- "help",
- "index-pack",
- "init",
- "init-db",
- "interpret-trailers",
- "log",
- "ls-files",
- "ls-remote",
- "ls-tree",
- "mailinfo",
- "mailsplit",
- "merge",
- "merge-base",
- "merge-file",
- "merge-index",
- "merge-ours",
- "merge-recursive",
- "merge-recursive-ours",
- "merge-recursive-theirs",
- "merge-subtree",
- "merge-tree",
- "mktag",
- "mktree",
- "mv",
- "name-rev",
- "notes",
- "pack-objects",
- "pack-redundant",
- "pack-refs",
- "patch-id",
- "pickaxe",
- "prune",
- "prune-packed",
- "pull",
- "push",
- "read-tree",
- "rebase",
- "rebase--helper",
- "receive-pack",
- "reflog",
- "remote",
- "remote-ext",
- "remote-fd",
- "repack",
- "replace",
- "rerere",
- "reset",
- "restore",
- "rev-list",
- "rev-parse",
- "revert",
- "rm",
- "send-pack",
- "shortlog",
- "show",
- "show-branch",
- "show-ref",
- "stage",
- "status",
- "stripspace",
- "switch",
- "symbolic-ref",
- "tag",
- "unpack-file",
- "unpack-objects",
- "update-index",
- "update-ref",
- "update-server-info",
- "upload-archive",
- "upload-archive--writer",
- "var",
- "verify-commit",
- "verify-pack",
- "verify-tag",
- "version",
- "whatchanged",
- "worktree",
- "write-tree",
-
- // Externals
- "bisect",
- "filter-branch",
- "gui",
- "merge-octopus",
- "merge-one-file",
- "merge-resolve",
- "mergetool",
- "parse-remote",
- "quiltimport",
- "rebase",
- "submodule",
- };
-
- public static bool Contains(string gitCommand)
- {
- return knownCommands.Contains(gitCommand);
- }
- }
-}
diff --git a/GVFS/GVFS.Hooks/Program.cs b/GVFS/GVFS.Hooks/Program.cs
index 940b2cf37d..1254797817 100644
--- a/GVFS/GVFS.Hooks/Program.cs
+++ b/GVFS/GVFS.Hooks/Program.cs
@@ -22,6 +22,9 @@ public partial class Program
private const int PostCommandSpinnerDelayMs = 500;
+ // Hooks run on the git command path, so config reads retry at most once.
+ private const int ConfigReadMaxAttempts = 2;
+
private static string enlistmentRoot;
private static string enlistmentPipename;
private static string normalizedCurrentDirectory;
@@ -171,11 +174,13 @@ private static bool HasShortFlag(string arg, string flag)
private static bool ConfigurationAllowsHydrationStatus()
{
+ // See ConfigReadMaxAttempts for why the retry budget is small here.
return LibGit2Repo.GetConfigBoolOrDefault(
NullTracer.Instance,
normalizedCurrentDirectory,
GVFSConstants.GitConfig.ShowHydrationStatus,
- GVFSConstants.GitConfig.ShowHydrationStatusDefault);
+ GVFSConstants.GitConfig.ShowHydrationStatusDefault,
+ ConfigReadMaxAttempts);
}
///
@@ -251,7 +256,9 @@ private static void RunLockRequest(string[] args, bool unattended, LockRequestDe
{
try
{
- if (ShouldLock(args))
+ bool checkOwnership = GetHookType(args) == PostCommandHook &&
+ args[1].StartsWith("git-", StringComparison.Ordinal);
+ if (checkOwnership || ShouldLock(args))
{
using (NamedPipeClient pipeClient = new NamedPipeClient(enlistmentPipename))
{
@@ -267,6 +274,30 @@ private static void RunLockRequest(string[] args, bool unattended, LockRequestDe
ExitWithError("GVFS.Hooks: Unable to find parent git.exe process " + "(PID: " + pid + ").");
}
+ if (checkOwnership)
+ {
+ // Executable and alias config can change after acquisition.
+ if (!GitCommandLock.TryCheckOwnership(
+ pid,
+ GVFSHooksPlatform.IsElevated(),
+ GenerateFullCommand(args),
+ request =>
+ {
+ pipeClient.SendRequest(request);
+ return pipeClient.ReadResponse();
+ },
+ out bool ownsLock,
+ out string error))
+ {
+ ExitWithError(error);
+ }
+
+ if (!ownsLock)
+ {
+ return;
+ }
+ }
+
requestToRun(unattended, args, pid, pipeClient);
}
}
@@ -490,13 +521,12 @@ private static bool ShouldLock(string[] args)
return false;
}
- if (!KnownGitCommands.Contains(gitCommand) &&
- IsAlias(gitCommand))
- {
- return false;
- }
-
- return true;
+ return GitCommandResolver.ShouldLock(
+ args[1],
+ aliasKey => ReadGitAlias(args, aliasKey),
+ Environment.GetEnvironmentVariable("PATH"),
+ GitCommandResolver.FileExists,
+ Console.Error.WriteLine);
}
private static string GetHookType(string[] args)
@@ -515,11 +545,18 @@ private static string GetGitCommand(string[] args)
return command;
}
- private static bool IsAlias(string command)
+ private static ProcessResult ReadGitAlias(string[] args, string aliasKey)
{
- ProcessResult result = ProcessHelper.Run("git", "config --get alias." + command);
-
- return !string.IsNullOrEmpty(result.Output);
+ string gitPath = GitCommandResolver.GetProcessPath(GetParentPid(args));
+ return GitConfigReadRetry.InvokeResult(
+ aliasKey,
+ () => ProcessHelper.Run(GitCommandResolver.CreateAliasQuery(gitPath, aliasKey)),
+ (ProcessResult result) =>
+ result.ExitCode != 0 &&
+ GitConfigReadRetry.IsTransientFileAccessError(result.Errors, lineMustContain: "unable to access"),
+ NullTracer.Instance,
+ GitConfigReadRetry.Delay,
+ ConfigReadMaxAttempts);
}
///
diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs
index 0a5c930b5f..4e396efaa0 100644
--- a/GVFS/GVFS.Mount/InProcessMount.cs
+++ b/GVFS/GVFS.Mount/InProcessMount.cs
@@ -1900,7 +1900,7 @@ private bool TrySetRequiredGitConfigSettings()
{
Dictionary requiredSettings = RequiredGitConfig.GetRequiredSettings(this.enlistment);
- GitProcess git = new GitProcess(this.enlistment);
+ GitProcess git = this.enlistment.CreateGitProcess(this.tracer);
Dictionary existingConfigSettings;
if (!git.TryGetAllConfig(localOnly: true, configSettings: out existingConfigSettings))
diff --git a/GVFS/GVFS.Mount/InProcessMountVerb.cs b/GVFS/GVFS.Mount/InProcessMountVerb.cs
index 16328f60d6..6bb0cad92e 100644
--- a/GVFS/GVFS.Mount/InProcessMountVerb.cs
+++ b/GVFS/GVFS.Mount/InProcessMountVerb.cs
@@ -112,7 +112,7 @@ public void Execute()
JsonTracer tracer = this.CreateTracer(enlistment, verbosity, keywords);
- CacheServerInfo cacheServer = CacheServerResolver.GetCacheServerFromConfig(enlistment);
+ CacheServerInfo cacheServer = CacheServerResolver.GetCacheServerFromConfig(enlistment, tracer);
tracer.WriteStartEvent(
enlistment.WorkingDirectoryRoot,
diff --git a/GVFS/GVFS.UnitTests/Common/ConfigReaderTracerTests.cs b/GVFS/GVFS.UnitTests/Common/ConfigReaderTracerTests.cs
new file mode 100644
index 0000000000..f31c3c9159
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Common/ConfigReaderTracerTests.cs
@@ -0,0 +1,106 @@
+using GVFS.Common;
+using GVFS.Common.Git;
+using GVFS.Common.Http;
+using GVFS.Common.Tracing;
+using GVFS.Tests.Should;
+using GVFS.UnitTests.Mock.Common;
+using GVFS.UnitTests.Mock.Git;
+using NUnit.Framework;
+using System.Collections;
+using System.Collections.Generic;
+
+namespace GVFS.UnitTests.Common
+{
+ // The mount process passes its tracer to every config reader so that retries are traced.
+ [TestFixture]
+ public class ConfigReaderTracerTests
+ {
+ [TestCase]
+ public void CacheServerResolverCreatesItsGitProcessWithTheTracer()
+ {
+ MockTracer tracer = new MockTracer();
+ TracerCapturingEnlistment enlistment = this.CreateEnlistment();
+
+ CacheServerResolver.GetCacheServerFromConfig(enlistment, tracer);
+
+ (enlistment.CapturedTracers.Count > 0).ShouldBeTrue();
+ foreach (ITracer capturedTracer in enlistment.CapturedTracers)
+ {
+ capturedTracer.ShouldBeSameAs(tracer);
+ }
+ }
+
+ [TestCase]
+ public void RetryConfigCreatesItsGitProcessWithTheTracerWhenTheEnvironmentChangesGitConfig()
+ {
+ MockTracer tracer = new MockTracer();
+ TracerCapturingEnlistment enlistment = this.CreateEnlistment();
+
+ RetryConfig.TryLoadFromGitConfig(
+ tracer,
+ RetryConfig.CreateConfigReader(tracer, enlistment, new Hashtable { { "GIT_CONFIG_COUNT", "1" } }),
+ out RetryConfig _,
+ out string _);
+
+ // One git.exe process reads both settings.
+ enlistment.CapturedTracers.Count.ShouldEqual(1);
+ enlistment.CapturedTracers[0].ShouldBeSameAs(tracer);
+ }
+
+ [TestCase]
+ public void RetryConfigDoesNotStartGitWhenLibGit2ReadsTheSettings()
+ {
+ TracerCapturingEnlistment enlistment = this.CreateEnlistment();
+
+ // The mock working directory does not exist, so libgit2 reads the empty isolated global config.
+ using (new IsolatedLibGit2Config())
+ {
+ RetryConfig.TryLoadFromGitConfig(
+ new MockTracer(),
+ RetryConfig.CreateConfigReader(new MockTracer(), enlistment, new Hashtable { { "PATH", "C:\\bin" } }),
+ out RetryConfig _,
+ out string _);
+ }
+
+ enlistment.CapturedTracers.Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void GitStatusCacheConfigCreatesItsGitProcessWithTheTracer()
+ {
+ MockTracer tracer = new MockTracer();
+ TracerCapturingEnlistment enlistment = this.CreateEnlistment();
+
+ GitStatusCacheConfig.TryLoadFromGitConfig(tracer, enlistment, out GitStatusCacheConfig _, out string _);
+
+ enlistment.CapturedTracers.Count.ShouldEqual(1);
+ enlistment.CapturedTracers[0].ShouldBeSameAs(tracer);
+ }
+
+ private TracerCapturingEnlistment CreateEnlistment()
+ {
+ MockGitProcess git = new MockGitProcess();
+ git.SetExpectedCommandResult(
+ "config",
+ () => new GitProcess.Result(string.Empty, string.Empty, GitProcess.Result.GenericFailureCode),
+ matchPrefix: true);
+ return new TracerCapturingEnlistment(git);
+ }
+
+ private class TracerCapturingEnlistment : MockGVFSEnlistment
+ {
+ public TracerCapturingEnlistment(MockGitProcess gitProcess)
+ : base(gitProcess)
+ {
+ }
+
+ public List CapturedTracers { get; } = new List();
+
+ public override GitProcess CreateGitProcess(ITracer tracer)
+ {
+ this.CapturedTracers.Add(tracer);
+ return base.CreateGitProcess(tracer);
+ }
+ }
+ }
+}
diff --git a/GVFS/GVFS.UnitTests/Common/GitConfigHelperTests.cs b/GVFS/GVFS.UnitTests/Common/GitConfigHelperTests.cs
index 6f0a6c1412..4980fce7e5 100644
--- a/GVFS/GVFS.UnitTests/Common/GitConfigHelperTests.cs
+++ b/GVFS/GVFS.UnitTests/Common/GitConfigHelperTests.cs
@@ -9,6 +9,29 @@ namespace GVFS.UnitTests.Common
[TestFixture]
public class GitConfigHelperTests
{
+ [TestCase(null, 5)]
+ [TestCase("", 5)]
+ [TestCase(" ", 5)]
+ [TestCase("7", 7)]
+ [TestCase(" 7 ", 7)]
+ [TestCase("0", 0)]
+ public void TryParseIntSettingReturnsTheValueOrTheDefault(string valueString, int expected)
+ {
+ GitConfigHelper.TryParseIntSetting("gvfs.test", valueString, 5, 0, out int value, out string error).ShouldBeTrue(error);
+ value.ShouldEqual(expected);
+ error.ShouldEqual(string.Empty);
+ }
+
+ [TestCase("abc", "Misconfigured config setting gvfs.test, could not parse value `abc` as an int")]
+ [TestCase("1k", "Misconfigured config setting gvfs.test, could not parse value `1k` as an int")]
+ [TestCase("99999999999", "Misconfigured config setting gvfs.test, could not parse value `99999999999` as an int")]
+ [TestCase("-1", "Invalid value -1 for setting gvfs.test, value must be greater than or equal to 0")]
+ public void TryParseIntSettingReportsInvalidValues(string valueString, string expectedError)
+ {
+ GitConfigHelper.TryParseIntSetting("gvfs.test", valueString, 5, 0, out int _, out string error).ShouldBeFalse();
+ error.ShouldEqual(expectedError);
+ }
+
[TestCase]
public void SanitizeEmptyString()
{
diff --git a/GVFS/GVFS.UnitTests/Common/LibGit2RepoConfigLookupTests.cs b/GVFS/GVFS.UnitTests/Common/LibGit2RepoConfigLookupTests.cs
index 843d423b5f..bdd95bc793 100644
--- a/GVFS/GVFS.UnitTests/Common/LibGit2RepoConfigLookupTests.cs
+++ b/GVFS/GVFS.UnitTests/Common/LibGit2RepoConfigLookupTests.cs
@@ -3,6 +3,8 @@
using GVFS.UnitTests.Mock.Common;
using NUnit.Framework;
using System;
+using System.Collections;
+using System.Collections.Generic;
using System.IO;
namespace GVFS.UnitTests.Common
@@ -10,6 +12,9 @@ namespace GVFS.UnitTests.Common
[TestFixture]
public class LibGit2RepoConfigLookupTests
{
+ private const string LockedConfigError =
+ "Failed to get config handle: could not open 'C:\\repo\\.git\\config': The process cannot access the file because it is being used by another process.";
+
[TestCase]
public void GetConfigBoolOrDefaultOnRepoReturnsConfiguredValue()
{
@@ -68,6 +73,186 @@ public void GetConfigBoolOrDefaultOnRepoReturnsDefaultOnInvalidDataExceptionAndL
}
}
+ [TestCase("GIT_CONFIG_PARAMETERS")]
+ [TestCase("GIT_CONFIG_COUNT")]
+ [TestCase("GIT_CONFIG_GLOBAL")]
+ [TestCase("GIT_CONFIG_SYSTEM")]
+ [TestCase("GIT_CONFIG_NOSYSTEM")]
+ [TestCase("GIT_CONFIG")]
+ [TestCase("GIT_DIR")]
+ [TestCase("git_config_parameters")]
+ public void EnvironmentChangesGitConfigDetectsVariablesThatLibGit2Ignores(string name)
+ {
+ Hashtable environment = new Hashtable { { "PATH", "C:\\bin" }, { name, "1" } };
+
+ LibGit2Repo.EnvironmentChangesGitConfig(environment).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void EnvironmentChangesGitConfigIgnoresOtherGitVariables()
+ {
+ Hashtable environment = new Hashtable
+ {
+ { "GIT_TRACE", "1" },
+ { "GIT_CONFIG_KEY_0", "gvfs.max-retries" },
+ { "GIT_WORK_TREE", "C:\\repo" },
+ };
+
+ LibGit2Repo.EnvironmentChangesGitConfig(environment).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryGetConfigStringReportsAnUnopenableRepository()
+ {
+ string missingRepoPath = Path.Combine(
+ Path.GetTempPath(),
+ "LibGit2RepoConfigLookupTests_" + Guid.NewGuid().ToString("N"));
+
+ LibGit2Repo.TryGetConfigString(new MockTracer(), missingRepoPath, "gvfs.test", out string value, out string error).ShouldBeFalse();
+
+ value.ShouldBeNull();
+ error.ShouldContain("Error while reading 'gvfs.test' from config: Couldn't open repo at");
+ }
+
+ [TestCase]
+ public void GetConfigBoolRetriesTransientLibGit2Failure()
+ {
+ MockTracer tracer = new MockTracer();
+ int delayCount = 0;
+ Queue