diff --git a/GVFS/GVFS.Common/GVFSConstants.cs b/GVFS/GVFS.Common/GVFSConstants.cs
index 64ec277e82..412f65cb42 100644
--- a/GVFS/GVFS.Common/GVFSConstants.cs
+++ b/GVFS/GVFS.Common/GVFSConstants.cs
@@ -178,6 +178,8 @@ public static class DotGit
public const string HeadName = "HEAD";
public const string GitDirPrefix = "gitdir: ";
public const string CommonDirName = "commondir";
+ public const string WorktreeConfigName = "config.worktree";
+ public const string WorktreesName = "worktrees";
public const string SkipCleanCheckName = "skip-clean-check";
public const string IndexName = "index";
public const string PackedRefsName = "packed-refs";
diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs
index 506e36a5ba..a6afc4bafc 100644
--- a/GVFS/GVFS.Common/GVFSEnlistment.cs
+++ b/GVFS/GVFS.Common/GVFSEnlistment.cs
@@ -4,6 +4,8 @@
using GVFS.Common.Tracing;
using System;
using System.IO;
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using System.Threading;
@@ -90,6 +92,16 @@ private GVFSEnlistment(string enlistmentRoot, string gitBinPath, GitAuthenticati
public bool IsWorktree => this.Worktree != null;
+ public string PrimaryDotGVFSRoot => Path.Combine(this.PrimaryEnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot);
+
+ ///
+ /// Path to the config.worktree file of this enlistment. Git reads it only when
+ /// extensions.worktreeConfig is enabled, and then it overrides the shared config.
+ ///
+ public string WorktreeConfigPath => Path.Combine(
+ this.IsWorktree ? this.Worktree.WorktreeGitDir : this.DotGitRoot,
+ GVFSConstants.DotGit.WorktreeConfigName);
+
///
/// Path to the git index file. For worktrees this is in the
/// per-worktree git dir, not in the working directory.
@@ -446,14 +458,61 @@ public bool TryCreateEnlistmentSubFolders()
return true;
}
- public string GetMountId()
+ ///
+ /// Returns the enlistment id. The repo metadata of the primary is the source of
+ /// truth, and the shared git config keeps a copy of it. A linked worktree derives
+ /// its id from the primary id and the worktree name, because it must not write to
+ /// the shared config (see ).
+ ///
+ public string GetEnlistmentId()
{
- return this.GetId(GVFSConstants.GitConfig.MountId);
+ if (this.IsWorktree)
+ {
+ this.TryGetPrimaryEnlistmentId(out string primaryEnlistmentId, out _);
+ return this.GetWorktreeEnlistmentId(primaryEnlistmentId);
+ }
+
+ if (RepoMetadata.TryReadEnlistmentId(this.DotGVFSRoot, out string enlistmentId, out _) &&
+ !string.IsNullOrWhiteSpace(enlistmentId))
+ {
+ return enlistmentId;
+ }
+
+ return this.GetId(GVFSConstants.GitConfig.EnlistmentId);
}
- public string GetEnlistmentId()
+ ///
+ /// Reads the enlistment id from the repo metadata of the primary enlistment.
+ ///
+ public bool TryGetPrimaryEnlistmentId(out string primaryEnlistmentId, out string error)
{
- return this.GetId(GVFSConstants.GitConfig.EnlistmentId);
+ return RepoMetadata.TryReadEnlistmentId(this.PrimaryDotGVFSRoot, out primaryEnlistmentId, out error);
+ }
+
+ ///
+ /// Returns the id that is derived from , or
+ /// the id stored in the worktree's repo metadata when the primary id is empty.
+ ///
+ public string GetWorktreeEnlistmentId(string primaryEnlistmentId)
+ {
+ if (!string.IsNullOrWhiteSpace(primaryEnlistmentId))
+ {
+ return CreateWorktreeEnlistmentId(primaryEnlistmentId, this.Worktree.Name);
+ }
+
+ RepoMetadata.TryReadEnlistmentId(this.DotGVFSRoot, out string storedEnlistmentId, out _);
+ return storedEnlistmentId;
+ }
+
+ ///
+ /// Derives a stable enlistment id for a linked worktree, in the same format as
+ /// other enlistment ids. Git keeps a worktree name when the worktree moves, and
+ /// reuses it when a worktree with the same name is added again.
+ ///
+ public static string CreateWorktreeEnlistmentId(string primaryEnlistmentId, string worktreeName)
+ {
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(primaryEnlistmentId + ":" + worktreeName.ToLowerInvariant()));
+ return Convert.ToHexString(hash, 0, 16).ToLowerInvariant();
}
private void SetOnce(T value, ref T valueToSet)
diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs
index f6287134a3..a3d405784f 100644
--- a/GVFS/GVFS.Common/Git/GitProcess.cs
+++ b/GVFS/GVFS.Common/Git/GitProcess.cs
@@ -523,13 +523,18 @@ public Result AddInLocalConfig(string settingName, string value)
public Result SetInFileConfig(string configFile, string settingName, string value, bool replaceAll = false)
{
return this.InvokeGitOutsideEnlistment(string.Format(
- "config --file {0} {1} \"{2}\" \"{3}\"",
+ "config --file \"{0}\" {1}\"{2}\" \"{3}\"",
configFile,
replaceAll ? "--replace-all " : string.Empty,
settingName,
value));
}
+ public Result DeleteFromFileConfig(string configFile, string settingName)
+ {
+ return this.InvokeGitOutsideEnlistment($"config --file \"{configFile}\" --unset-all {settingName}");
+ }
+
public bool TryGetConfigUrlMatch(string section, string repositoryUrl, out Dictionary configSettings)
{
// See GetFromConfig for why pre-command hook is disabled.
@@ -566,6 +571,29 @@ public bool TryGetAllConfig(bool localOnly, out Dictionary
+ /// Lists the local config with every value of every key, in file order. A key
+ /// without a value, which git reads as boolean true, has a null value.
+ ///
+ public bool TryGetLocalConfigEntries(out Dictionary> configEntries)
+ {
+ // See GetFromConfig for why pre-command hook is disabled.
+ return TryParseConfigEntries(
+ this.InvokeConfigRead("--list --local", () => this.InvokeGitAgainstDotGitFolder("config --list --local -z", usePreCommandHook: false)),
+ out configEntries);
+ }
+
+ ///
+ /// Lists a config file like . Git fails when
+ /// the file does not exist, so check for the file before you call this.
+ ///
+ public bool TryGetFileConfigEntries(string configFile, out Dictionary> configEntries)
+ {
+ return TryParseConfigEntries(
+ this.InvokeConfigRead(configFile, () => this.InvokeGitOutsideEnlistment($"config --file \"{configFile}\" --list -z")),
+ out configEntries);
+ }
+
///
/// Get the config value give a setting name
///
@@ -1449,6 +1477,43 @@ private static string ParseValue(string contents, string prefix)
return value;
}
+ ///
+ /// Parses `git config --list -z` output. Each entry ends with NUL and is either
+ /// "key", for a key without a value, or "key\nvalue". GitProcess reads output by
+ /// line, so the separator arrives as "\r\n" and a final line break follows.
+ ///
+ private static bool TryParseConfigEntries(Result result, out Dictionary> configEntries)
+ {
+ configEntries = null;
+ if (result.ExitCodeIsFailure)
+ {
+ return false;
+ }
+
+ configEntries = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (string entry in (result.Output ?? string.Empty).Split('\0'))
+ {
+ int separator = entry.IndexOf('\n');
+ string key = (separator < 0 ? entry : entry.Substring(0, separator)).TrimEnd('\r');
+ if (key.Length == 0)
+ {
+ continue;
+ }
+
+ string value = separator < 0 ? null : entry.Substring(separator + 1);
+
+ if (!configEntries.TryGetValue(key, out List values))
+ {
+ values = new List();
+ configEntries.Add(key, values);
+ }
+
+ values.Add(value);
+ }
+
+ return true;
+ }
+
///
/// Invokes git.exe without a working directory set.
///
diff --git a/GVFS/GVFS.Common/Git/RequiredGitConfig.cs b/GVFS/GVFS.Common/Git/RequiredGitConfig.cs
index 8f40e2dd37..1eecde6d45 100644
--- a/GVFS/GVFS.Common/Git/RequiredGitConfig.cs
+++ b/GVFS/GVFS.Common/Git/RequiredGitConfig.cs
@@ -1,6 +1,8 @@
+using GVFS.Common.Tracing;
using System;
using System.Collections.Generic;
using System.IO;
+using System.Threading;
namespace GVFS.Common.Git
{
@@ -10,6 +12,254 @@ namespace GVFS.Common.Git
///
public static class RequiredGitConfig
{
+ internal const string WorktreeConfigExtension = "extensions.worktreeConfig";
+ internal const string StatusDeserializePath = "status.deserializePath";
+
+ internal const int SharedWriteAttempts = 5;
+ private const int SharedWriteRetryDelayMs = 100;
+
+ ///
+ /// Writes the required settings that are missing or different.
+ ///
+ ///
+ /// Git resolves --local config for a linked worktree to the config that all
+ /// worktrees share. So a linked worktree writes ,
+ /// the only required setting whose value is specific to an enlistment, to its own
+ /// config.worktree, and enables extensions.worktreeConfig so that git reads that file.
+ /// The shared config keeps the value of the primary enlistment. A config.worktree
+ /// value overrides the shared value, so every other required setting is removed from
+ /// the config.worktree file of the enlistment.
+ ///
+ public static bool TrySetRequiredSettings(GVFSEnlistment enlistment, out string error)
+ {
+ return TrySetRequiredSettings(enlistment, NullTracer.Instance, out error);
+ }
+
+ ///
+ /// Writes the required settings like the overload without a tracer, and sends
+ /// config read retries to .
+ ///
+ public static bool TrySetRequiredSettings(GVFSEnlistment enlistment, ITracer tracer, out string error)
+ {
+ return TrySetRequiredSettings(enlistment, tracer, GetRequiredSettings(enlistment), out error);
+ }
+
+ ///
+ /// Applies like the public overload. Tests use this overload.
+ ///
+ internal static bool TrySetRequiredSettings(GVFSEnlistment enlistment, Dictionary requiredSettings, out string error)
+ {
+ return TrySetRequiredSettings(enlistment, NullTracer.Instance, requiredSettings, out error);
+ }
+
+ private static bool TrySetRequiredSettings(GVFSEnlistment enlistment, ITracer tracer, Dictionary requiredSettings, out string error)
+ {
+ List> sharedSettings = new List>();
+ Dictionary worktreeConfigSettings = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ string primaryStatusCachePath = enlistment.IsWorktree ? GetStatusCachePath(enlistment.PrimaryDotGVFSRoot) : null;
+
+ foreach (KeyValuePair setting in requiredSettings)
+ {
+ if (enlistment.IsWorktree && string.Equals(setting.Key, StatusDeserializePath, StringComparison.OrdinalIgnoreCase))
+ {
+ // An empty value replaces a path that an attended mount of this worktree
+ // wrote. It does not disable a readable shared path, because git keeps the
+ // last readable path. Git rejects the cache of the primary, because that
+ // cache does not match the index of this worktree.
+ worktreeConfigSettings.Add(setting.Key, setting.Value ?? string.Empty);
+ if (setting.Value != null && primaryStatusCachePath != null)
+ {
+ sharedSettings.Add(new KeyValuePair(setting.Key, primaryStatusCachePath));
+ }
+ }
+ else
+ {
+ sharedSettings.Add(setting);
+ }
+ }
+
+ GitProcess git = enlistment.CreateGitProcess(tracer);
+
+ Dictionary> existingSharedEntries;
+ if (!git.TryGetLocalConfigEntries(out existingSharedEntries))
+ {
+ error = "Could not read the local git config";
+ return false;
+ }
+
+ if (enlistment.IsWorktree || HasLinkedWorktreeConfig(enlistment))
+ {
+ sharedSettings.Insert(0, new KeyValuePair(WorktreeConfigExtension, "true"));
+ }
+
+ if (!TryApplySharedSettings(git, sharedSettings, existingSharedEntries, out error))
+ {
+ return false;
+ }
+
+ return TryApplyWorktreeConfigSettings(git, enlistment.WorktreeConfigPath, requiredSettings.Keys, worktreeConfigSettings, out error);
+ }
+
+ private static bool TryApplySharedSettings(
+ GitProcess git,
+ List> settings,
+ Dictionary> existingEntries,
+ out string error)
+ {
+ foreach (KeyValuePair setting in settings)
+ {
+ if (setting.Value != null)
+ {
+ if (!HasOnlyValue(existingEntries, setting.Key, setting.Value) &&
+ !TrySetSharedSetting(git, setting.Key, setting.Value, out error))
+ {
+ return false;
+ }
+ }
+ else if (TryGetEntry(existingEntries, setting.Key, out _))
+ {
+ git.DeleteFromLocalConfig(setting.Key);
+ }
+ }
+
+ error = null;
+ return true;
+ }
+
+ private static bool TrySetSharedSetting(GitProcess git, string settingName, string value, out string error)
+ {
+ for (int attempt = 1; ; attempt++)
+ {
+ // Git uses the last value of a key, so replace every value.
+ GitProcess.Result result = git.SetInLocalConfig(settingName, value, replaceAll: true);
+
+ // Concurrent mounts of linked worktrees can write the same value. A write
+ // fails while another process holds the config lock, so check whether
+ // the value is now present before the next attempt.
+ if (result.ExitCodeIsSuccess || HasLocalConfigValue(git, settingName, value))
+ {
+ error = null;
+ return true;
+ }
+
+ if (attempt == SharedWriteAttempts)
+ {
+ error = $"Could not set '{settingName}': {result.Errors}";
+ return false;
+ }
+
+ Thread.Sleep(SharedWriteRetryDelayMs);
+ }
+ }
+
+ private static bool HasLocalConfigValue(GitProcess git, string settingName, string expectedValue)
+ {
+ return git.TryGetLocalConfigEntries(out Dictionary> entries) &&
+ HasOnlyValue(entries, settingName, expectedValue);
+ }
+
+ private static bool TryApplyWorktreeConfigSettings(
+ GitProcess git,
+ string worktreeConfigPath,
+ IEnumerable requiredSettingNames,
+ Dictionary worktreeConfigSettings,
+ out string error)
+ {
+ Dictionary> existingEntries = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ if (File.Exists(worktreeConfigPath) && !git.TryGetFileConfigEntries(worktreeConfigPath, out existingEntries))
+ {
+ error = "Could not read " + worktreeConfigPath;
+ return false;
+ }
+
+ foreach (string settingName in requiredSettingNames)
+ {
+ if (!worktreeConfigSettings.ContainsKey(settingName) && TryGetEntry(existingEntries, settingName, out _))
+ {
+ GitProcess.Result result = git.DeleteFromFileConfig(worktreeConfigPath, settingName);
+ if (result.ExitCodeIsFailure)
+ {
+ error = $"Could not remove '{settingName}' from {worktreeConfigPath}: {result.Errors}";
+ return false;
+ }
+ }
+ }
+
+ foreach (KeyValuePair setting in worktreeConfigSettings)
+ {
+ if (HasOnlyValue(existingEntries, setting.Key, setting.Value))
+ {
+ continue;
+ }
+
+ GitProcess.Result result = git.SetInFileConfig(worktreeConfigPath, setting.Key, setting.Value, replaceAll: true);
+ if (result.ExitCodeIsFailure)
+ {
+ error = $"Could not set '{setting.Key}' in {worktreeConfigPath}: {result.Errors}";
+ return false;
+ }
+ }
+
+ error = null;
+ return true;
+ }
+
+ ///
+ /// True when the key has exactly one entry and that entry has the value. Git
+ /// uses the last entry of a key, and a key without a value means true.
+ ///
+ private static bool HasOnlyValue(Dictionary> entries, string settingName, string value)
+ {
+ return TryGetEntry(entries, settingName, out List values) &&
+ values.Count == 1 &&
+ string.Equals(values[0], value, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Finds a key in parsed `git config --list` output. That output shows a
+ /// subsection without quotes, for example credential.https://dev.azure.com.usehttppath.
+ ///
+ private static bool TryGetEntry(Dictionary> entries, string settingName, out List values)
+ {
+ return entries.TryGetValue(settingName.Replace("\"", string.Empty), out values);
+ }
+
+ private static bool HasLinkedWorktreeConfig(GVFSEnlistment enlistment)
+ {
+ string worktreesRoot = Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.WorktreesName);
+ if (!Directory.Exists(worktreesRoot))
+ {
+ return false;
+ }
+
+ try
+ {
+ foreach (string worktreeGitDir in Directory.EnumerateDirectories(worktreesRoot))
+ {
+ if (File.Exists(Path.Combine(worktreeGitDir, GVFSConstants.DotGit.WorktreeConfigName)))
+ {
+ return true;
+ }
+ }
+ }
+ catch (DirectoryNotFoundException)
+ {
+ // `git worktree prune` can remove the directory during the enumeration.
+ }
+
+ return false;
+ }
+
+ private static string GetStatusCachePath(string dotGVFSRoot)
+ {
+ if (GVFSEnlistment.IsUnattended(tracer: null) || !GVFSPlatform.Instance.IsGitStatusCacheSupported())
+ {
+ return null;
+ }
+
+ return Paths.ConvertPathToGitFormat(Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.GitStatusCache.CachePath));
+ }
+
///
/// Returns the dictionary of required git config settings for a GVFS enlistment.
/// These settings override any existing local configuration values.
@@ -33,15 +283,7 @@ public static Dictionary GetRequiredSettings(GVFSEnlistment enli
Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.Hooks.RootName, GVFSConstants.DotGit.Hooks.VirtualFileSystemName) +
GVFSPlatform.Instance.Constants.ExecutableExtension) + "'";
- string gitStatusCachePath = null;
- if (!GVFSEnlistment.IsUnattended(tracer: null) && GVFSPlatform.Instance.IsGitStatusCacheSupported())
- {
- gitStatusCachePath = Path.Combine(
- enlistment.DotGVFSRoot,
- GVFSConstants.DotGVFS.GitStatusCache.CachePath);
-
- gitStatusCachePath = Paths.ConvertPathToGitFormat(gitStatusCachePath);
- }
+ string gitStatusCachePath = GetStatusCachePath(enlistment.DotGVFSRoot);
string coreGVFSFlags = Convert.ToInt32(
GitCoreGVFSFlags.SkipShaOnIndex |
@@ -171,7 +413,7 @@ public static Dictionary GetRequiredSettings(GVFSEnlistment enli
// Configure git to use our serialize status file - make git use the serialized status file rather than compute the status by
// parsing the index file and going through the files to determine changes.
- { "status.deserializePath", gitStatusCachePath },
+ { StatusDeserializePath, gitStatusCachePath },
// The GVFS Protocol forbids submodules, so prevent a user's
// global config of "status.submoduleSummary=true" from causing
diff --git a/GVFS/GVFS.Common/RepoMetadata.cs b/GVFS/GVFS.Common/RepoMetadata.cs
index 00854fc69b..5f86009f92 100644
--- a/GVFS/GVFS.Common/RepoMetadata.cs
+++ b/GVFS/GVFS.Common/RepoMetadata.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Threading;
namespace GVFS.Common
{
@@ -10,6 +11,9 @@ public class RepoMetadata
{
public const string MissingDiskLayoutVersionMessage = "Enlistment disk layout version not found. The .gvfs metadata is incomplete. Make sure your work is pushed or backed up, then reclone this enlistment.";
+ private const int ReadEnlistmentIdAttempts = 3;
+ private const int ReadEnlistmentIdRetryDelayMs = 50;
+
private FileBasedDictionary repoMetadata;
private ITracer tracer;
@@ -77,6 +81,35 @@ public static bool TryInitialize(ITracer tracer, PhysicalFileSystem fileSystem,
return true;
}
+ ///
+ /// Reads the enlistment id from the repo metadata in
+ /// without initializing and without creating an id.
+ /// A missing metadata file or a missing value produces an empty string.
+ ///
+ public static bool TryReadEnlistmentId(string dotGVFSPath, out string enlistmentId, out string error)
+ {
+ enlistmentId = string.Empty;
+
+ string dictionaryPath = Path.Combine(dotGVFSPath, GVFSConstants.DotGVFS.Databases.RepoMetadata);
+ if (!File.Exists(dictionaryPath))
+ {
+ error = null;
+ return true;
+ }
+
+ // The mount process that owns this file can replace it at any time, and the
+ // replace makes a concurrent open fail with a sharing violation.
+ for (int attempt = 1; ; attempt++)
+ {
+ if (TryReadEnlistmentIdOnce(dictionaryPath, out enlistmentId, out error) || attempt == ReadEnlistmentIdAttempts)
+ {
+ return error == null;
+ }
+
+ Thread.Sleep(ReadEnlistmentIdRetryDelayMs);
+ }
+ }
+
public static void Shutdown()
{
if (Instance != null)
@@ -141,16 +174,28 @@ public bool TryGetOnDiskLayoutVersion(out int majorVersion, out int minorVersion
public void SaveCloneMetadata(ITracer tracer, GVFSEnlistment enlistment)
{
- this.repoMetadata.SetValuesAndFlush(
- new[]
- {
- new KeyValuePair(Keys.DiskLayoutMajorVersion, GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion.ToString()),
- new KeyValuePair(Keys.DiskLayoutMinorVersion, GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMinorVersion.ToString()),
- new KeyValuePair(Keys.GitObjectsRoot, enlistment.GitObjectsRoot),
- new KeyValuePair(Keys.LocalCacheRoot, enlistment.LocalCacheRoot),
- new KeyValuePair(Keys.BlobSizesRoot, enlistment.BlobSizesRoot),
- new KeyValuePair(Keys.EnlistmentId, CreateNewEnlistmentId(tracer)),
- });
+ this.SaveMetadata(enlistment, CreateNewEnlistmentId(tracer));
+ }
+
+ ///
+ /// Saves the metadata that a worktree mount copies from the primary
+ /// enlistment, and the enlistment id of the worktree. If
+ /// is empty, it keeps an existing id or
+ /// creates a new one.
+ ///
+ public void SaveWorktreeMetadata(ITracer tracer, GVFSEnlistment enlistment, string enlistmentId)
+ {
+ this.SaveMetadata(enlistment, this.GetWorktreeEnlistmentIdToSave(tracer, enlistmentId));
+ }
+
+ internal string GetWorktreeEnlistmentIdToSave(ITracer tracer, string enlistmentId)
+ {
+ if (string.IsNullOrWhiteSpace(enlistmentId) && !this.TryGetEnlistmentId(out enlistmentId))
+ {
+ enlistmentId = CreateNewEnlistmentId(tracer);
+ }
+
+ return enlistmentId;
}
public void SetProjectionInvalid(bool invalid)
@@ -269,6 +314,36 @@ public void SetEntry(string keyName, string valueName)
this.repoMetadata.SetValueAndFlush(keyName, valueName);
}
+ private static bool TryReadEnlistmentIdOnce(string dictionaryPath, out string enlistmentId, out string error)
+ {
+ enlistmentId = string.Empty;
+ try
+ {
+ FileBasedDictionary metadata;
+ if (!FileBasedDictionary.TryCreate(NullTracer.Instance, dictionaryPath, new PhysicalFileSystem(), out metadata, out error))
+ {
+ return false;
+ }
+
+ using (metadata)
+ {
+ string value;
+ if (metadata.TryGetValue(Keys.EnlistmentId, out value))
+ {
+ enlistmentId = value;
+ }
+ }
+ }
+ catch (FileBasedCollectionException e)
+ {
+ error = e.Message;
+ return false;
+ }
+
+ error = null;
+ return true;
+ }
+
private static string CreateNewEnlistmentId(ITracer tracer)
{
string enlistmentId = Guid.NewGuid().ToString("N");
@@ -278,6 +353,35 @@ private static string CreateNewEnlistmentId(ITracer tracer)
return enlistmentId;
}
+ ///
+ /// Gets the enlistment id without creating one when it is missing,
+ /// unlike .
+ ///
+ private bool TryGetEnlistmentId(out string enlistmentId)
+ {
+ if (this.repoMetadata.TryGetValue(Keys.EnlistmentId, out enlistmentId) && !string.IsNullOrWhiteSpace(enlistmentId))
+ {
+ return true;
+ }
+
+ enlistmentId = null;
+ return false;
+ }
+
+ private void SaveMetadata(GVFSEnlistment enlistment, string enlistmentId)
+ {
+ this.repoMetadata.SetValuesAndFlush(
+ new[]
+ {
+ new KeyValuePair(Keys.DiskLayoutMajorVersion, GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion.ToString()),
+ new KeyValuePair(Keys.DiskLayoutMinorVersion, GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMinorVersion.ToString()),
+ new KeyValuePair(Keys.GitObjectsRoot, enlistment.GitObjectsRoot),
+ new KeyValuePair(Keys.LocalCacheRoot, enlistment.LocalCacheRoot),
+ new KeyValuePair(Keys.BlobSizesRoot, enlistment.BlobSizesRoot),
+ new KeyValuePair(Keys.EnlistmentId, enlistmentId),
+ });
+ }
+
private void SetInvalid(string keyName, bool invalid)
{
if (invalid)
diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs
index 1ab25b7080..0ca3d07f5e 100644
--- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs
+++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs
@@ -8,6 +8,7 @@
using System.IO;
using System.Linq;
using System.Text;
+using System.Text.RegularExpressions;
using System.Threading;
using ProcessHelper = GVFS.FunctionalTests.Tools.ProcessHelper;
using ProcessResult = GVFS.FunctionalTests.Tools.ProcessResult;
@@ -19,6 +20,8 @@ namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture
public class WorktreeTests : TestsWithEnlistmentPerFixture
{
private const int MinWorktreeCount = 4;
+ private const string StatusDeserializePath = "status.deserializePath";
+ private const string WorktreeMountIdArguments = "--worktree gvfs.mount-id";
[TestCase]
public void ConcurrentWorktreeAddCommitRemove()
@@ -579,6 +582,189 @@ public void WorktreeDehydrateReportsBackupFailure()
}
}
+ [TestCase]
+ public void WorktreeMountUsesPerWorktreeConfigAndKeepsPrimaryValues()
+ {
+ string primaryEnlistmentId = this.GetPrimaryConfigValue("gvfs.enlistment-id");
+ string primaryMountId = this.GetPrimaryConfigValue("gvfs.mount-id");
+ string primaryStatusCachePath = this.GetPrimaryConfigValue(StatusDeserializePath);
+ Assert.IsNotEmpty(primaryEnlistmentId);
+ Assert.IsNotEmpty(primaryMountId);
+ AssertPathsEqual(primaryStatusCachePath, GetStatusCachePath(this.Enlistment.DotGVFSRoot), "Primary status cache path");
+
+ this.RunWithMountedWorktree("ids-test", (worktreePath, worktreeGitDir) =>
+ {
+ string worktreeDotGVFS = Path.Combine(worktreeGitDir, GVFSTestConfig.DotGVFSRoot);
+ string worktreeEnlistmentId = GVFSHelpers.GetPersistedEnlistmentId(worktreeDotGVFS);
+ AssertIsEnlistmentId(worktreeEnlistmentId);
+ worktreeEnlistmentId.ShouldNotEqual(primaryEnlistmentId);
+ string worktreeMountId = this.GetConfigValue(worktreePath, WorktreeMountIdArguments);
+ Assert.IsNotEmpty(worktreeMountId);
+ worktreeMountId.ShouldNotEqual(primaryMountId);
+ AssertPathsEqual(
+ this.GetConfigValue(worktreePath, StatusDeserializePath),
+ GetStatusCachePath(worktreeDotGVFS),
+ "Worktree status cache path");
+ this.AssertPrimaryValuesUnchanged(primaryEnlistmentId, primaryMountId, primaryStatusCachePath, "Worktree mount");
+
+ string enlistmentInfo = GetLastEnlistmentInfoEvent(worktreeDotGVFS);
+ AssertEventField(enlistmentInfo, "mountId", worktreeMountId);
+ AssertEventField(enlistmentInfo, "EnlistmentId", worktreeEnlistmentId);
+ AssertEventField(enlistmentInfo, "PrimaryEnlistmentId", GVFSHelpers.GetPersistedEnlistmentId(this.Enlistment.DotGVFSRoot));
+
+ GVFSProcess worktreeGVFS = this.CreateWorktreeGVFSProcess(worktreePath);
+ worktreeGVFS.Unmount();
+ worktreeGVFS.Mount();
+
+ GVFSHelpers.GetPersistedEnlistmentId(worktreeDotGVFS).ShouldEqual(worktreeEnlistmentId, "Worktree enlistment id changed on remount");
+ string remountId = this.GetConfigValue(worktreePath, WorktreeMountIdArguments);
+ remountId.ShouldNotEqual(worktreeMountId, "Worktree mount id did not change on remount");
+ AssertEventField(GetLastEnlistmentInfoEvent(worktreeDotGVFS), "mountId", remountId);
+ this.AssertPrimaryValuesUnchanged(primaryEnlistmentId, primaryMountId, primaryStatusCachePath, "Worktree remount");
+ });
+ }
+
+ [TestCase]
+ public void WorktreeMountRepairsConfigLeftByPreviousVersion()
+ {
+ string primaryEnlistmentId = this.GetPrimaryConfigValue("gvfs.enlistment-id");
+ string primaryMountId = this.GetPrimaryConfigValue("gvfs.mount-id");
+ string primaryStatusCachePath = this.GetPrimaryConfigValue(StatusDeserializePath);
+
+ try
+ {
+ this.RunWithMountedWorktree("upgrade-test", (worktreePath, worktreeGitDir) =>
+ {
+ string worktreeDotGVFS = Path.Combine(worktreeGitDir, GVFSTestConfig.DotGVFSRoot);
+ string worktreeConfigPath = Path.Combine(worktreeGitDir, "config.worktree");
+ string worktreeStatusCachePath = GetStatusCachePath(worktreeDotGVFS);
+ string worktreeEnlistmentId = GVFSHelpers.GetPersistedEnlistmentId(worktreeDotGVFS);
+
+ GVFSProcess worktreeGVFS = this.CreateWorktreeGVFSProcess(worktreePath);
+ worktreeGVFS.Unmount();
+
+ // The test recreates the state that an older version leaves: no per-worktree
+ // config, no worktreeConfig extension, worktree values in the shared config,
+ // and a random worktree enlistment id. The primary stays mounted, so the
+ // worktree mount alone must repair the shared values.
+ File.Exists(worktreeConfigPath).ShouldBeTrue("The worktree mount did not create config.worktree");
+ File.Delete(worktreeConfigPath);
+ this.SetPrimaryConfig("--unset extensions.worktreeConfig");
+ this.SetPrimaryConfig($"{StatusDeserializePath} \"{worktreeStatusCachePath}\"");
+ this.SetPrimaryConfig("gvfs.enlistment-id previousversionenlistmentid");
+ GVFSHelpers.SaveEnlistmentId(worktreeDotGVFS, "previousversionworktreeid");
+
+ worktreeGVFS.Mount();
+
+ this.GetPrimaryConfigValue("extensions.worktreeConfig").ShouldEqual("true");
+ this.AssertPrimaryValuesUnchanged(primaryEnlistmentId, primaryMountId, primaryStatusCachePath, "Worktree mount after upgrade");
+ AssertPathsEqual(
+ this.GetConfigValue(worktreePath, StatusDeserializePath),
+ worktreeStatusCachePath,
+ "Worktree status cache path");
+ string worktreeMountId = this.GetConfigValue(worktreePath, WorktreeMountIdArguments);
+ Assert.IsNotEmpty(worktreeMountId);
+ worktreeMountId.ShouldNotEqual(primaryMountId);
+ GVFSHelpers.GetPersistedEnlistmentId(worktreeDotGVFS).ShouldEqual(worktreeEnlistmentId, "Worktree enlistment id did not return to the derived id");
+ });
+ }
+ finally
+ {
+ // Restore the shared values without asserting, so that a cleanup failure
+ // does not hide the original failure.
+ GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, "config --local extensions.worktreeConfig true");
+ GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, $"config --local gvfs.enlistment-id {primaryEnlistmentId}");
+ GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, $"config --local {StatusDeserializePath} \"{primaryStatusCachePath}\"");
+ }
+ }
+
+ [TestCase]
+ public void UnattendedMountDeletesTheStatusCacheThatItDoesNotUpdate()
+ {
+ string primaryStatusCachePath = this.GetPrimaryConfigValue(StatusDeserializePath);
+ string statusCacheFile = Path.Combine(this.Enlistment.DotGVFSRoot, "gitStatusCache", "GitStatusCache.dat");
+ string newFileName = $"unattended-status-{Guid.NewGuid():N}.txt";
+ string newFilePath = Path.Combine(this.Enlistment.RepoRoot, newFileName);
+ string unattendedValue = Environment.GetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable);
+
+ // The attended mount writes a status cache that matches the current index.
+ GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, "status");
+ for (int i = 0; i < 10 && !File.Exists(statusCacheFile); i++)
+ {
+ Thread.Sleep(1000);
+ }
+
+ File.Exists(statusCacheFile).ShouldBeTrue("The attended mount did not write a status cache");
+
+ try
+ {
+ this.Enlistment.UnmountGVFS();
+ Environment.SetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable, "1");
+ this.Enlistment.MountGVFS();
+
+ File.Exists(statusCacheFile).ShouldBeFalse("The unattended mount kept a status cache that it does not update");
+
+ // The test writes the shared path again, as an attended worktree mount does,
+ // and then checks that git status does not use an old cache.
+ this.SetPrimaryConfig($"{StatusDeserializePath} \"{primaryStatusCachePath}\"");
+ File.WriteAllText(newFilePath, "not in the status cache");
+ ProcessResult status = GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, "status --porcelain");
+ status.ExitCode.ShouldEqual(0, status.Errors);
+ status.Output.ShouldContain(newFileName);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable, unattendedValue);
+ try { File.Delete(newFilePath); } catch { }
+ this.Enlistment.UnmountGVFS();
+ this.Enlistment.MountGVFS();
+ }
+ }
+
+ private static string GetStatusCachePath(string dotGVFSRoot)
+ {
+ return GitHelpers.ConvertPathToGitFormat(Path.Combine(dotGVFSRoot, "gitStatusCache", "GitStatusCache.dat"));
+ }
+
+ ///
+ /// Returns the last EnlistmentInfo line of the newest mount log in .
+ ///
+ private static string GetLastEnlistmentInfoEvent(string dotGVFSRoot)
+ {
+ string logPath = Directory.GetFiles(Path.Combine(dotGVFSRoot, "logs"), "gvfs_mount_process_*.log")
+ .OrderBy(path => File.GetCreationTimeUtc(path))
+ .Last();
+
+ using (FileStream stream = new FileStream(logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
+ using (StreamReader reader = new StreamReader(stream))
+ {
+ string enlistmentInfo = reader.ReadToEnd()
+ .Split('\n')
+ .LastOrDefault(line => line.Contains("] EnlistmentInfo "));
+ Assert.IsNotNull(enlistmentInfo, $"No EnlistmentInfo event in {logPath}");
+ return enlistmentInfo;
+ }
+ }
+
+ private static void AssertEventField(string eventLine, string field, string expectedValue)
+ {
+ Assert.IsTrue(
+ Regex.IsMatch(eventLine, $"\"{field}\"\\s*:\\s*\"{Regex.Escape(expectedValue)}\""),
+ $"Expected \"{field}\":\"{expectedValue}\" in: {eventLine}");
+ }
+
+ private static void AssertPathsEqual(string actual, string expected, string label)
+ {
+ Assert.IsTrue(
+ string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase),
+ $"{label}: expected '{expected}' but was '{actual}'");
+ }
+
+ private static void AssertIsEnlistmentId(string value)
+ {
+ Assert.IsTrue(Regex.IsMatch(value ?? string.Empty, "^[0-9a-f]{32}$"), $"'{value}' is not an enlistment id");
+ }
+
private string GetGitIndexPath(string workingDirectory)
{
ProcessResult result = GitHelpers.InvokeGitAgainstGVFSRepo(
@@ -588,6 +774,81 @@ private string GetGitIndexPath(string workingDirectory)
return Path.GetFullPath(result.Output.Trim());
}
+ ///
+ /// Adds a worktree on a new branch in a temporary directory, waits for its mount,
+ /// runs with the worktree path and its git directory, and
+ /// then removes the worktree.
+ ///
+ private void RunWithMountedWorktree(string testName, Action test)
+ {
+ string suffix = Guid.NewGuid().ToString("N").Substring(0, 8);
+ string tempDir = Path.Combine(Path.GetTempPath(), $"gvfs-{testName}-{suffix}");
+ string worktreePath = Path.Combine(tempDir, "wt");
+ string branchName = $"{testName}-branch-{suffix}";
+
+ try
+ {
+ Directory.CreateDirectory(tempDir);
+ ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo(
+ this.Enlistment.RepoRoot,
+ $"worktree add -b {branchName} \"{worktreePath}\"");
+ addResult.ExitCode.ShouldEqual(0, $"worktree add failed: {addResult.Errors}");
+ this.AssertWorktreeMounted(worktreePath, branchName);
+
+ ProcessResult gitDirResult = GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "rev-parse --absolute-git-dir");
+ gitDirResult.ExitCode.ShouldEqual(0, gitDirResult.Errors);
+ test(worktreePath, Path.GetFullPath(gitDirResult.Output.Trim()));
+ }
+ finally
+ {
+ this.ForceCleanupWorktree(worktreePath, branchName);
+ if (Directory.Exists(tempDir))
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { }
+ }
+ }
+ }
+
+ private GVFSProcess CreateWorktreeGVFSProcess(string worktreePath)
+ {
+ return new GVFSProcess(
+ GVFSTestConfig.PathToGVFS,
+ worktreePath,
+ Path.Combine(worktreePath, GVFSTestConfig.DotGVFSRoot),
+ this.Enlistment.ServiceName);
+ }
+
+ private void AssertPrimaryValuesUnchanged(string enlistmentId, string mountId, string statusCachePath, string label)
+ {
+ this.GetPrimaryConfigValue("gvfs.enlistment-id").ShouldEqual(enlistmentId, $"{label} changed the primary enlistment id");
+ this.GetPrimaryConfigValue("gvfs.mount-id").ShouldEqual(mountId, $"{label} changed the primary mount id");
+ this.GetPrimaryConfigValue(StatusDeserializePath).ShouldEqual(statusCachePath, $"{label} changed the primary status cache path");
+ }
+
+ private string GetPrimaryConfigValue(string key)
+ {
+ return this.GetConfigValue(this.Enlistment.RepoRoot, key);
+ }
+
+ ///
+ /// Runs `git config ` in and returns its output.
+ ///
+ private string GetConfigValue(string workingDirectory, string configArguments)
+ {
+ ProcessResult result = GitHelpers.InvokeGitAgainstGVFSRepo(workingDirectory, $"config {configArguments}");
+ result.ExitCode.ShouldEqual(0, $"git config {configArguments} failed: {result.Errors}");
+ return result.Output.Trim();
+ }
+
+ ///
+ /// Runs `git config --local ` in the primary enlistment.
+ ///
+ private void SetPrimaryConfig(string configArguments)
+ {
+ ProcessResult result = GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, $"config --local {configArguments}");
+ result.ExitCode.ShouldEqual(0, $"git config --local {configArguments} failed: {result.Errors}");
+ }
+
private void InitWorktreeArrays(int count, out string[] paths, out string[] branches)
{
paths = new string[count];
diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs
index 632a5869f7..dd485f0d77 100644
--- a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs
+++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs
@@ -30,6 +30,7 @@ public static class GVFSHelpers
private const string GitObjectsRootKey = "GitObjectsRoot";
private const string PlaceholdersNeedUpdate = "PlaceholdersNeedUpdate";
private const string BlobSizesRootKey = "BlobSizesRoot";
+ private const string EnlistmentIdKey = "EnlistmentId";
private const string PrjFSLibPath = "libPrjFSLib.dylib";
private const int PrjFSResultSuccess = 1;
@@ -91,6 +92,16 @@ public static string GetPersistedBlobSizesRoot(string dotGVFSRoot)
return GetPersistedValue(dotGVFSRoot, BlobSizesRootKey);
}
+ public static string GetPersistedEnlistmentId(string dotGVFSRoot)
+ {
+ return GetPersistedValue(dotGVFSRoot, EnlistmentIdKey);
+ }
+
+ public static void SaveEnlistmentId(string dotGVFSRoot, string value)
+ {
+ SavePersistedValue(dotGVFSRoot, EnlistmentIdKey, value);
+ }
+
public static void SQLiteBlobSizesDatabaseHasEntry(string blobSizesDbPath, string blobSha, long blobSize)
{
RunSqliteCommand(blobSizesDbPath, command =>
diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs
index f5eae0859f..215207ecec 100644
--- a/GVFS/GVFS.Mount/InProcessMount.cs
+++ b/GVFS/GVFS.Mount/InProcessMount.cs
@@ -41,7 +41,14 @@ public class InProcessMount
// reliably trigger a commit pack download.
private const int TrackedTreeCapacity = MissingTreeThresholdForDownloadingCommitPack * 20;
+ private const int StatusCacheDeleteAttempts = 5;
+ private const int StatusCacheDeleteRetryDelayMs = 100;
+
private readonly bool showDebugWindow;
+ private readonly string mountId;
+
+ // Set only for a linked worktree mount
+ private readonly string primaryEnlistmentId;
private FileSystemCallbacks fileSystemCallbacks;
private GVFSDatabase gvfsDatabase;
@@ -71,15 +78,18 @@ public class InProcessMount
private HeartbeatThread heartbeat;
private ManualResetEvent unmountEvent;
+
private readonly MissingTreeTracker missingTreeTracker;
// True if InProcessMount is calling git reset as part of processing
// a folder dehydrate request
private volatile bool resetForDehydrateInProgress;
- public InProcessMount(ITracer tracer, GVFSEnlistment enlistment, CacheServerInfo cacheServer, RetryConfig retryConfig, GitStatusCacheConfig gitStatusCacheConfig, bool showDebugWindow)
+ public InProcessMount(ITracer tracer, GVFSEnlistment enlistment, string mountId, string primaryEnlistmentId, CacheServerInfo cacheServer, RetryConfig retryConfig, GitStatusCacheConfig gitStatusCacheConfig, bool showDebugWindow)
{
this.tracer = tracer;
+ this.mountId = mountId;
+ this.primaryEnlistmentId = primaryEnlistmentId;
this.retryConfig = retryConfig;
this.gitStatusCacheConfig = gitStatusCacheConfig;
this.cacheServer = cacheServer;
@@ -294,9 +304,9 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords)
this.tracer.RelatedInfo("ParallelMount: Local validations completed in {0}ms", sw.ElapsedMilliseconds);
- if (!this.TrySetRequiredGitConfigSettings())
+ if (!RequiredGitConfig.TrySetRequiredSettings(this.enlistment, this.tracer, out string configError))
{
- this.FailMountAndExit("Unable to configure git repo");
+ this.FailMountAndExit("Unable to configure git repo: " + configError);
}
this.LogEnlistmentInfoAndSetConfigValues();
@@ -533,7 +543,8 @@ private void ValidateMountPoints()
///
/// For worktree mounts, create the .gvfs metadata directory and
- /// bootstrap RepoMetadata with cache paths from the primary enlistment.
+ /// bootstrap RepoMetadata with cache paths from the primary enlistment
+ /// and with the enlistment ID of the worktree.
///
private void InitializeWorktreeMetadata()
{
@@ -588,12 +599,25 @@ private void InitializeWorktreeMetadata()
RepoMetadata.Shutdown();
}
- // Initialize cache paths on the enlistment so SaveCloneMetadata
+ // Initialize cache paths on the enlistment so SaveWorktreeMetadata
// can persist them into the worktree's metadata
this.enlistment.InitializeCachePaths(localCacheRoot, gitObjectsRoot, blobSizesRoot);
- // Initialize the worktree's own metadata with cache paths,
- // disk layout version, and a new enlistment ID
+ string worktreeEnlistmentId = null;
+ if (string.IsNullOrWhiteSpace(this.primaryEnlistmentId))
+ {
+ this.tracer.RelatedWarning(
+ new EventMetadata(),
+ "The primary enlistment ID is not available. The worktree keeps its stored enlistment ID.",
+ Keywords.Telemetry);
+ }
+ else
+ {
+ worktreeEnlistmentId = GVFSEnlistment.CreateWorktreeEnlistmentId(this.primaryEnlistmentId, this.enlistment.Worktree.Name);
+ }
+
+ // Initialize the worktree's own metadata with cache paths, the disk
+ // layout version, and the enlistment ID of the worktree.
if (!RepoMetadata.TryInitialize(this.tracer, dotGVFSRoot, out error))
{
this.FailMountAndExit("Failed to initialize worktree metadata: " + error);
@@ -601,7 +625,7 @@ private void InitializeWorktreeMetadata()
try
{
- RepoMetadata.Instance.SaveCloneMetadata(this.tracer, this.enlistment);
+ RepoMetadata.Instance.SaveWorktreeMetadata(this.tracer, this.enlistment, worktreeEnlistmentId);
}
finally
{
@@ -648,6 +672,43 @@ private void FailMountAndExit(string error, params object[] args)
this.FailMountAndExit(ReturnCode.GenericError, error, args);
}
+ ///
+ /// Deletes the status cache file of this enlistment. Without a GitStatusCache,
+ /// nothing updates or deletes that file, but git still reads it when
+ /// status.deserializePath names it. For example, an attended mount of a linked
+ /// worktree writes the path of the primary to the shared config.
+ ///
+ private void DeleteStatusCacheFileOrExit()
+ {
+ string statusCachePath = this.context.Enlistment.GitStatusCachePath;
+ for (int attempt = 1; ; attempt++)
+ {
+ try
+ {
+ if (this.context.FileSystem.FileExists(statusCachePath))
+ {
+ this.context.FileSystem.DeleteFile(statusCachePath);
+ }
+
+ return;
+ }
+ catch (IOException e) when (e is FileNotFoundException || e is DirectoryNotFoundException)
+ {
+ return;
+ }
+ catch (Exception e) when (e is IOException || e is UnauthorizedAccessException)
+ {
+ if (attempt == StatusCacheDeleteAttempts)
+ {
+ this.FailMountAndExit("Failed to delete the unused git status cache file {0}: {1}", statusCachePath, e.Message);
+ return;
+ }
+
+ Thread.Sleep(StatusCacheDeleteRetryDelayMs);
+ }
+ }
+ }
+
private void FailMountAndExit(ReturnCode returnCode, string error, params object[] args)
{
// Format once, before taking ownership. RelatedError would otherwise format a
@@ -1572,6 +1633,7 @@ private void MountAndStartWorkingDirectoryCallbacks(CacheServerInfo cache, bool
else
{
this.tracer.RelatedInfo("Git status cache is not enabled");
+ this.DeleteStatusCacheFileOrExit();
}
this.gvfsDatabase = this.CreateOrReportAndExit(() => new GVFSDatabase(this.context.FileSystem, this.context.Enlistment.DotGVFSRoot, new SqliteDatabase()), "Failed to create database connection");
@@ -1957,64 +2019,46 @@ private bool TryCreateAlternatesFile(PhysicalFileSystem fileSystem, out string e
return true;
}
- private bool TrySetRequiredGitConfigSettings()
+ private void LogEnlistmentInfoAndSetConfigValues()
{
- Dictionary requiredSettings = RequiredGitConfig.GetRequiredSettings(this.enlistment);
-
- GitProcess git = this.enlistment.CreateGitProcess(this.tracer);
-
- Dictionary existingConfigSettings;
- if (!git.TryGetAllConfig(localOnly: true, configSettings: out existingConfigSettings))
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add(nameof(RepoMetadata.Instance.EnlistmentId), RepoMetadata.Instance.EnlistmentId);
+ if (this.enlistment.IsWorktree)
{
- return false;
+ metadata.Add("PrimaryEnlistmentId", this.primaryEnlistmentId);
}
- foreach (KeyValuePair setting in requiredSettings)
+ metadata.Add("mountId", this.mountId);
+ metadata.Add("Enlistment", this.enlistment);
+ metadata.Add("PhysicalDiskInfo", GVFSPlatform.Instance.GetPhysicalDiskInfo(this.enlistment.WorkingDirectoryRoot, sizeStatsOnly: false));
+ this.tracer.RelatedEvent(EventLevel.Informational, "EnlistmentInfo", metadata, Keywords.Telemetry);
+
+ GitProcess git = this.enlistment.CreateGitProcess(this.tracer);
+ GitProcess.Result configResult;
+ if (this.enlistment.IsWorktree)
{
- GitConfigSetting existingSetting;
- if (setting.Value != null)
+ // See RequiredGitConfig.TrySetRequiredSettings for why a worktree does not write to the shared config.
+ configResult = git.SetInFileConfig(this.enlistment.WorktreeConfigPath, GVFSConstants.GitConfig.MountId, this.mountId, replaceAll: true);
+ if (configResult.ExitCodeIsFailure)
{
- if (!existingConfigSettings.TryGetValue(setting.Key, out existingSetting) ||
- !existingSetting.HasValue(setting.Value))
- {
- GitProcess.Result setConfigResult = git.SetInLocalConfig(setting.Key, setting.Value);
- if (setConfigResult.ExitCodeIsFailure)
- {
- return false;
- }
- }
- }
- else
- {
- if (existingConfigSettings.TryGetValue(setting.Key, out existingSetting))
- {
- git.DeleteFromLocalConfig(setting.Key);
- }
+ this.tracer.RelatedWarning(
+ new EventMetadata { { "Error", configResult.Errors } },
+ "Could not update worktree config with mount id",
+ Keywords.Telemetry);
}
- }
-
- return true;
- }
- private void LogEnlistmentInfoAndSetConfigValues()
- {
- string mountId = Guid.NewGuid().ToString("N");
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(RepoMetadata.Instance.EnlistmentId), RepoMetadata.Instance.EnlistmentId);
- metadata.Add(nameof(mountId), mountId);
- metadata.Add("Enlistment", this.enlistment);
- metadata.Add("PhysicalDiskInfo", GVFSPlatform.Instance.GetPhysicalDiskInfo(this.enlistment.WorkingDirectoryRoot, sizeStatsOnly: false));
- this.tracer.RelatedEvent(EventLevel.Informational, "EnlistmentInfo", metadata, Keywords.Telemetry);
+ this.RestoreSharedEnlistmentIdOfPrimary(git);
+ return;
+ }
- GitProcess git = new GitProcess(this.enlistment);
- GitProcess.Result configResult = git.SetInLocalConfig(GVFSConstants.GitConfig.EnlistmentId, RepoMetadata.Instance.EnlistmentId, replaceAll: true);
+ configResult = git.SetInLocalConfig(GVFSConstants.GitConfig.EnlistmentId, RepoMetadata.Instance.EnlistmentId, replaceAll: true);
if (configResult.ExitCodeIsFailure)
{
string error = "Could not update config with enlistment id, error: " + configResult.Errors;
this.tracer.RelatedWarning(error);
}
- configResult = git.SetInLocalConfig(GVFSConstants.GitConfig.MountId, mountId, replaceAll: true);
+ configResult = git.SetInLocalConfig(GVFSConstants.GitConfig.MountId, this.mountId, replaceAll: true);
if (configResult.ExitCodeIsFailure)
{
string error = "Could not update config with mount id, error: " + configResult.Errors;
@@ -2022,6 +2066,42 @@ private void LogEnlistmentInfoAndSetConfigValues()
}
}
+ ///
+ /// Keeps the shared enlistment ID equal to the enlistment ID of the primary.
+ /// A worktree mount writes it only when the shared value differs.
+ ///
+ private void RestoreSharedEnlistmentIdOfPrimary(GitProcess git)
+ {
+ if (string.IsNullOrWhiteSpace(this.primaryEnlistmentId))
+ {
+ return;
+ }
+
+ GitProcess.ConfigResult sharedResult = git.GetFromLocalConfig(GVFSConstants.GitConfig.EnlistmentId);
+ if (!sharedResult.TryParseAsString(out string sharedEnlistmentId, out string readError))
+ {
+ this.tracer.RelatedWarning(
+ new EventMetadata { { "Error", readError } },
+ "Could not read the shared enlistment ID",
+ Keywords.Telemetry);
+ return;
+ }
+
+ if (string.Equals(sharedEnlistmentId?.Trim(), this.primaryEnlistmentId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ GitProcess.Result configResult = git.SetInLocalConfig(GVFSConstants.GitConfig.EnlistmentId, this.primaryEnlistmentId, replaceAll: true);
+ if (configResult.ExitCodeIsFailure)
+ {
+ this.tracer.RelatedWarning(
+ new EventMetadata { { "Error", configResult.Errors } },
+ "Could not restore the shared enlistment ID of the primary",
+ Keywords.Telemetry);
+ }
+ }
+
private void UnmountAndStopWorkingDirectoryCallbacks(bool willRemountInSameProcess = false)
{
if (this.maintenanceScheduler != null)
diff --git a/GVFS/GVFS.Mount/InProcessMountVerb.cs b/GVFS/GVFS.Mount/InProcessMountVerb.cs
index cb8364cd3d..ff5a81e4d8 100644
--- a/GVFS/GVFS.Mount/InProcessMountVerb.cs
+++ b/GVFS/GVFS.Mount/InProcessMountVerb.cs
@@ -121,7 +121,30 @@ public void Execute()
Keywords keywords;
this.ParseEnumArgs(out verbosity, out keywords);
- JsonTracer tracer = this.CreateTracer(enlistment, verbosity, keywords);
+ // Create the IDs before the tracer, so every event of this mount carries them.
+ // A worktree mount reads the primary enlistment ID once and uses it everywhere.
+ string mountId = Guid.NewGuid().ToString("N");
+ string primaryEnlistmentId = null;
+ string primaryEnlistmentIdError = null;
+ string enlistmentId;
+ if (enlistment.IsWorktree)
+ {
+ enlistment.TryGetPrimaryEnlistmentId(out primaryEnlistmentId, out primaryEnlistmentIdError);
+ enlistmentId = enlistment.GetWorktreeEnlistmentId(primaryEnlistmentId);
+ }
+ else
+ {
+ enlistmentId = enlistment.GetEnlistmentId();
+ }
+
+ JsonTracer tracer = this.CreateTracer(enlistment, enlistmentId, mountId, verbosity, keywords);
+ if (primaryEnlistmentIdError != null)
+ {
+ tracer.RelatedWarning(
+ new EventMetadata { { "Error", primaryEnlistmentIdError } },
+ "Could not read the primary enlistment ID",
+ Keywords.Telemetry);
+ }
// Everything below this point has a live tracer (and therefore a log
// file on disk), so wrap it in a catch-all: any exception that isn't
@@ -160,7 +183,7 @@ public void Execute()
gitStatusCacheConfig = GitStatusCacheConfig.DefaultConfig;
}
- InProcessMount mountHelper = new InProcessMount(tracer, enlistment, cacheServer, retryConfig, gitStatusCacheConfig, this.ShowDebugWindow);
+ InProcessMount mountHelper = new InProcessMount(tracer, enlistment, mountId, primaryEnlistmentId, cacheServer, retryConfig, gitStatusCacheConfig, this.ShowDebugWindow);
try
{
@@ -194,9 +217,9 @@ private void UnhandledGVFSExceptionHandler(ITracer tracer, object sender, Unhand
tracer.RelatedError(metadata, "UnhandledGVFSExceptionHandler caught unhandled exception");
}
- private JsonTracer CreateTracer(GVFSEnlistment enlistment, EventLevel verbosity, Keywords keywords)
+ private JsonTracer CreateTracer(GVFSEnlistment enlistment, string enlistmentId, string mountId, EventLevel verbosity, Keywords keywords)
{
- JsonTracer tracer = new JsonTracer(GVFSConstants.GVFSEtwProviderName, "GVFSMount", enlistment.GetEnlistmentId(), enlistment.GetMountId());
+ JsonTracer tracer = new JsonTracer(GVFSConstants.GVFSEtwProviderName, "GVFSMount", enlistmentId, mountId);
tracer.AddLogFileEventListener(
GVFSEnlistment.GetNewGVFSLogFileName(enlistment.GVFSLogsRoot, GVFSConstants.LogFileTypes.MountProcess),
verbosity,
diff --git a/GVFS/GVFS.UnitTests/Common/GVFSEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/GVFSEnlistmentTests.cs
index 66ddebbc5c..fb9ab4c9b0 100644
--- a/GVFS/GVFS.UnitTests/Common/GVFSEnlistmentTests.cs
+++ b/GVFS/GVFS.UnitTests/Common/GVFSEnlistmentTests.cs
@@ -10,16 +10,8 @@ namespace GVFS.UnitTests.Common
[TestFixture]
public class GVFSEnlistmentTests
{
- private const string MountId = "85576f54f9ab4388bcdc19b4f6c17696";
private const string EnlistmentId = "520dcf634ce34065a06abaa4010a256f";
- [TestCase]
- public void CanGetMountId()
- {
- TestGVFSEnlistment enlistment = new TestGVFSEnlistment();
- enlistment.GetMountId().ShouldEqual(MountId);
- }
-
[TestCase]
public void CanGetEnlistmentId()
{
@@ -35,9 +27,6 @@ public TestGVFSEnlistment()
: base("mock:\\path", "mock://repoUrl", "mock:\\git", authentication: null)
{
this.gitProcess = new MockGitProcess();
- this.gitProcess.SetExpectedCommandResult(
- "config --local gvfs.mount-id",
- () => new GitProcess.Result(MountId, string.Empty, GitProcess.Result.SuccessCode));
this.gitProcess.SetExpectedCommandResult(
"config --local gvfs.enlistment-id",
() => new GitProcess.Result(EnlistmentId, string.Empty, GitProcess.Result.SuccessCode));
diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs
index b1a3460caf..04fc9038d3 100644
--- a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs
+++ b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs
@@ -1,8 +1,13 @@
using GVFS.Common;
+using GVFS.Common.FileSystem;
using GVFS.Common.Git;
using GVFS.Tests.Should;
+using GVFS.UnitTests.Mock.Common;
using GVFS.UnitTests.Mock.Git;
using NUnit.Framework;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
namespace GVFS.UnitTests.Common
@@ -10,16 +15,25 @@ namespace GVFS.UnitTests.Common
[TestFixture]
public class WorktreeEnlistmentTests
{
+ // SHA-256 of "primaryenlistmentid:agent-wt-1", first 16 bytes as lowercase hex.
+ private const string DerivedEnlistmentIdOfAgentWorktree = "c133def22e85af1a8e16432fc7684258";
+
private string testRoot;
private string primaryRoot;
private string sharedGitDir;
private string worktreePath;
private string worktreeGitDir;
+ private string unattendedValue;
[SetUp]
public void SetUp()
{
- this.testRoot = Path.Combine(Path.GetTempPath(), "GVFSWTEnlTests_" + Path.GetRandomFileName());
+ // The required settings depend on GVFS_UNATTENDED, and git config file
+ // arguments must handle a path with a space.
+ this.unattendedValue = Environment.GetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable);
+ Environment.SetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable, null);
+
+ this.testRoot = Path.Combine(Path.GetTempPath(), "GVFS WT Enl Tests_" + Path.GetRandomFileName());
this.primaryRoot = Path.Combine(this.testRoot, "enlistment");
string primarySrc = Path.Combine(this.primaryRoot, "src");
this.sharedGitDir = Path.Combine(primarySrc, ".git");
@@ -27,6 +41,8 @@ public void SetUp()
this.worktreeGitDir = Path.Combine(this.sharedGitDir, "worktrees", "agent-wt-1");
Directory.CreateDirectory(this.sharedGitDir);
+ Directory.CreateDirectory(Path.Combine(this.sharedGitDir, "objects"));
+ Directory.CreateDirectory(Path.Combine(this.sharedGitDir, "refs"));
Directory.CreateDirectory(this.worktreeGitDir);
Directory.CreateDirectory(this.worktreePath);
Directory.CreateDirectory(Path.Combine(this.primaryRoot, ".gvfs"));
@@ -54,6 +70,7 @@ public void SetUp()
[TearDown]
public void TearDown()
{
+ Environment.SetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable, this.unattendedValue);
if (Directory.Exists(this.testRoot))
{
Directory.Delete(this.testRoot, recursive: true);
@@ -176,5 +193,333 @@ public void RepoUrlIsReadFromSharedConfig()
GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
enlistment.RepoUrl.ShouldEqual("https://mock/repo");
}
+
+ [TestCase]
+ public void WorktreeEnlistmentIdIsEmptyWhenNothingIsPersisted()
+ {
+ this.AppendToSharedConfig("[gvfs]\n\tenlistment-id = primaryenlistmentid\n");
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+
+ enlistment.GetEnlistmentId().ShouldEqual(string.Empty);
+ File.Exists(GetRepoMetadataPath(enlistment.DotGVFSRoot)).ShouldBeFalse("Reading the id must not create worktree repo metadata");
+ File.Exists(GetRepoMetadataPath(this.PrimaryDotGVFSRoot)).ShouldBeFalse("Reading the id must not create primary repo metadata");
+ }
+
+ [TestCase]
+ public void WorktreeEnlistmentIdIsDerivedFromPrimaryRepoMetadata()
+ {
+ this.AppendToSharedConfig("[gvfs]\n\tenlistment-id = sharedconfigenlistmentid\n");
+ EditRepoMetadata(this.PrimaryDotGVFSRoot, metadata => metadata.SetEntry(RepoMetadata.Keys.EnlistmentId, "primaryenlistmentid"));
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+
+ enlistment.GetEnlistmentId().ShouldEqual(DerivedEnlistmentIdOfAgentWorktree);
+ this.CreateWorktreeEnlistment().GetEnlistmentId().ShouldEqual(DerivedEnlistmentIdOfAgentWorktree);
+ RepoMetadata.Instance.ShouldBeNull("Reading the id must not initialize RepoMetadata.Instance");
+ }
+
+ [TestCase]
+ public void WorktreeEnlistmentIdIgnoresCaseOfWorktreeName()
+ {
+ GVFSEnlistment.CreateWorktreeEnlistmentId("primaryenlistmentid", "AGENT-WT-1").ShouldEqual(DerivedEnlistmentIdOfAgentWorktree);
+ }
+
+ [TestCase]
+ public void WorktreeEnlistmentIdFallsBackToWorktreeRepoMetadataWithoutPrimaryId()
+ {
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+ EditRepoMetadata(enlistment.DotGVFSRoot, metadata => metadata.SetEntry(RepoMetadata.Keys.EnlistmentId, "storedworktreeid"));
+
+ enlistment.GetEnlistmentId().ShouldEqual("storedworktreeid");
+ }
+
+ [TestCase]
+ public void WorktreeEnlistmentIdFallsBackToWorktreeRepoMetadataWhenPrimaryMetadataIsLocked()
+ {
+ EditRepoMetadata(this.PrimaryDotGVFSRoot, metadata => metadata.SetEntry(RepoMetadata.Keys.EnlistmentId, "primaryenlistmentid"));
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+ EditRepoMetadata(enlistment.DotGVFSRoot, metadata => metadata.SetEntry(RepoMetadata.Keys.EnlistmentId, "storedworktreeid"));
+
+ using (new FileStream(GetRepoMetadataPath(this.PrimaryDotGVFSRoot), FileMode.Open, FileAccess.ReadWrite, FileShare.None))
+ {
+ enlistment.GetEnlistmentId().ShouldEqual("storedworktreeid");
+ }
+ }
+
+ [TestCase]
+ public void PrimaryEnlistmentIdIsReadWhenLockIsReleasedDuringRetry()
+ {
+ EditRepoMetadata(this.PrimaryDotGVFSRoot, metadata => metadata.SetEntry(RepoMetadata.Keys.EnlistmentId, "primaryenlistmentid"));
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+
+ FileStream lockStream = new FileStream(GetRepoMetadataPath(this.PrimaryDotGVFSRoot), FileMode.Open, FileAccess.ReadWrite, FileShare.None);
+ using (System.Threading.Timer releaseTimer = new System.Threading.Timer(_ => lockStream.Dispose(), null, 20, System.Threading.Timeout.Infinite))
+ {
+ enlistment.TryGetPrimaryEnlistmentId(out string primaryEnlistmentId, out string error).ShouldBeTrue(error);
+ primaryEnlistmentId.ShouldEqual("primaryenlistmentid");
+ }
+
+ lockStream.Dispose();
+ }
+
+ [TestCase("givenid", "storedid", "givenid")]
+ [TestCase("", "storedid", "storedid")]
+ [TestCase(null, "storedid", "storedid")]
+ public void WorktreeMetadataKeepsGivenOrStoredEnlistmentId(string givenId, string storedId, string expectedId)
+ {
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+ EditRepoMetadata(enlistment.DotGVFSRoot, metadata =>
+ {
+ metadata.SetEntry(RepoMetadata.Keys.EnlistmentId, storedId);
+ MockTracer tracer = new MockTracer();
+
+ metadata.GetWorktreeEnlistmentIdToSave(tracer, givenId).ShouldEqual(expectedId);
+ tracer.RelatedEventNames.ShouldNotContain(name => name == "CreateNewEnlistmentId");
+ });
+ }
+
+ [TestCase(null)]
+ [TestCase(" ")]
+ public void WorktreeMetadataCreatesEnlistmentIdWhenNoneIsGivenOrStored(string storedId)
+ {
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+ EditRepoMetadata(enlistment.DotGVFSRoot, metadata =>
+ {
+ if (storedId != null)
+ {
+ metadata.SetEntry(RepoMetadata.Keys.EnlistmentId, storedId);
+ }
+
+ MockTracer tracer = new MockTracer();
+ string enlistmentId = metadata.GetWorktreeEnlistmentIdToSave(tracer, " ");
+
+ Guid.TryParseExact(enlistmentId, "N", out _).ShouldBeTrue(enlistmentId);
+ tracer.RelatedEventNames.ShouldContain(name => name == "CreateNewEnlistmentId");
+ });
+ }
+
+ [TestCase]
+ public void RequiredSettingsEnableWorktreeConfigAndWritePerWorktreeValues()
+ {
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(
+ enlistment,
+ new Dictionary
+ {
+ { "status.deserializePath", "C:/worktree/status.dat" },
+ { "core.autocrlf", "false" },
+ },
+ out error).ShouldBeTrue(error);
+
+ this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual("true");
+ this.GetConfigValues(this.SharedConfigPath, "core.autocrlf").ShouldEqual("false");
+ this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual(this.PrimaryStatusCachePath);
+ this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual("C:/worktree/status.dat");
+ }
+
+ [TestCase]
+ public void RequiredSettingsRepairCopiedWorktreeValueAndSharedValueOfPrimary()
+ {
+ this.AppendToSharedConfig("[status]\n\tdeserializePath = C:/sibling/status.dat\n");
+ File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/sibling/status.dat\n");
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(
+ enlistment,
+ new Dictionary { { "status.deserializePath", "C:/worktree/status.dat" } },
+ out error).ShouldBeTrue(error);
+
+ this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual("C:/worktree/status.dat");
+ this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual(this.PrimaryStatusCachePath);
+ }
+
+ [TestCase]
+ public void RequiredSettingsDisableNullPerWorktreeValueInWorktreeConfig()
+ {
+ this.AppendToSharedConfig("[extensions]\n\tworktreeConfig = true\n[status]\n\tdeserializePath = C:/primary/status.dat\n");
+ File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/worktree/status.dat\n");
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(
+ enlistment,
+ new Dictionary { { "status.deserializePath", null } },
+ out error).ShouldBeTrue(error);
+
+ this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual(string.Empty);
+ File.ReadAllText(this.WorktreeConfigPath).ShouldContain("deserializePath");
+ this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual("C:/primary/status.dat");
+ }
+
+ [TestCase]
+ public void RequiredSettingsRemoveRequiredOverridesFromWorktreeConfig()
+ {
+ File.WriteAllText(this.WorktreeConfigPath, "[core]\n\tautocrlf = true\n[gvfs]\n\tmount-id = worktreemountid\n");
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(
+ enlistment,
+ new Dictionary { { "core.autocrlf", "false" } },
+ out error).ShouldBeTrue(error);
+
+ this.GetConfigValues(this.WorktreeConfigPath, "core.autocrlf").ShouldEqual(string.Empty);
+ this.GetConfigValues(this.WorktreeConfigPath, "gvfs.mount-id").ShouldEqual("worktreemountid");
+ this.GetConfigValues(this.SharedConfigPath, "core.autocrlf").ShouldEqual("false");
+ }
+
+ [TestCase]
+ public void RequiredSettingsReplaceDuplicateWorktreeConfigExtensionValues()
+ {
+ this.AppendToSharedConfig("[extensions]\n\tworktreeConfig = true\n\tworktreeConfig = false\n");
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(enlistment, new Dictionary(), out error).ShouldBeTrue(error);
+
+ this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual("true");
+ }
+
+ [TestCase]
+ public void PrimaryRequiredSettingsEnableWorktreeConfigAndRemoveOverridesWhenLinkedWorktreeHasConfig()
+ {
+ File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/worktree/status.dat\n");
+ string primaryWorktreeConfigPath = Path.Combine(this.sharedGitDir, "config.worktree");
+ File.WriteAllText(primaryWorktreeConfigPath, "[core]\n\tautocrlf = true\n");
+ GVFSEnlistment primary = new GVFSEnlistment(this.primaryRoot, "https://mock/repo", GetGitBinPath(), authentication: null);
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(
+ primary,
+ new Dictionary { { "core.autocrlf", "false" } },
+ out error).ShouldBeTrue(error);
+
+ this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual("true");
+ this.GetConfigValues(primaryWorktreeConfigPath, "core.autocrlf").ShouldEqual(string.Empty);
+ this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual("C:/worktree/status.dat");
+ }
+
+ [TestCase]
+ public void PrimaryRequiredSettingsDoNotEnableWorktreeConfigWithoutLinkedWorktreeConfig()
+ {
+ GVFSEnlistment primary = new GVFSEnlistment(this.primaryRoot, "https://mock/repo", GetGitBinPath(), authentication: null);
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(
+ primary,
+ new Dictionary { { "status.deserializePath", "C:/primary/status.dat" } },
+ out error).ShouldBeTrue(error);
+
+ this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual(string.Empty);
+ this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual("C:/primary/status.dat");
+ File.Exists(Path.Combine(this.sharedGitDir, "config.worktree")).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void RequiredSettingsWriteNothingWhenAppliedAgain()
+ {
+ GVFSEnlistment primary = new GVFSEnlistment(this.primaryRoot, "https://mock/repo", GetGitBinPath(), authentication: null);
+ GVFSEnlistment worktree = this.CreateWorktreeEnlistment();
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(primary, out error).ShouldBeTrue(error);
+ RequiredGitConfig.TrySetRequiredSettings(worktree, out error).ShouldBeTrue(error);
+ DateTime sharedConfigWriteTime = File.GetLastWriteTimeUtc(this.SharedConfigPath);
+ DateTime worktreeConfigWriteTime = File.GetLastWriteTimeUtc(this.WorktreeConfigPath);
+
+ RequiredGitConfig.TrySetRequiredSettings(primary, out error).ShouldBeTrue(error);
+ RequiredGitConfig.TrySetRequiredSettings(worktree, out error).ShouldBeTrue(error);
+
+ File.GetLastWriteTimeUtc(this.SharedConfigPath).ShouldEqual(sharedConfigWriteTime, "A second application wrote the shared config");
+ File.GetLastWriteTimeUtc(this.WorktreeConfigPath).ShouldEqual(worktreeConfigWriteTime, "A second application wrote config.worktree");
+ this.GetConfigValues(this.SharedConfigPath, "credential.https://dev.azure.com.useHttpPath").ShouldEqual("true");
+ }
+
+ [TestCase]
+ public void RequiredSettingsFailWhenWorktreeConfigIsCorrupt()
+ {
+ File.WriteAllText(this.WorktreeConfigPath, "[status\n");
+ GVFSEnlistment enlistment = this.CreateWorktreeEnlistment();
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(
+ enlistment,
+ new Dictionary { { "status.deserializePath", "C:/worktree/status.dat" } },
+ out error).ShouldBeFalse();
+
+ error.ShouldContain("config.worktree");
+ }
+
+ private string PrimaryDotGVFSRoot => Path.Combine(this.primaryRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot);
+
+ private string PrimaryStatusCachePath => Path.Combine(this.PrimaryDotGVFSRoot, "gitStatusCache", "GitStatusCache.dat").Replace('\\', '/');
+
+ private string SharedConfigPath => Path.Combine(this.sharedGitDir, "config");
+
+ private string WorktreeConfigPath => Path.Combine(this.worktreeGitDir, "config.worktree");
+
+ private static string GetGitBinPath()
+ {
+ return GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath() ?? @"C:\Program Files\Git\cmd\git.exe";
+ }
+
+ private static string GetRepoMetadataPath(string dotGVFSRoot)
+ {
+ return Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.Databases.RepoMetadata);
+ }
+
+ private static void EditRepoMetadata(string dotGVFSRoot, Action action)
+ {
+ string error;
+ RepoMetadata.TryInitialize(new MockTracer(), new OverwritingFileSystem(), dotGVFSRoot, out error).ShouldBeTrue(error);
+ try
+ {
+ action(RepoMetadata.Instance);
+ }
+ finally
+ {
+ RepoMetadata.Shutdown();
+ }
+ }
+
+ ///
+ /// Returns every value of in ,
+ /// one per line, or an empty string when the key is missing.
+ ///
+ private string GetConfigValues(string configFile, string key)
+ {
+ ProcessStartInfo startInfo = new ProcessStartInfo(GetGitBinPath(), $"config --file \"{configFile}\" --get-all {key}")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+
+ using (Process process = Process.Start(startInfo))
+ {
+ string output = process.StandardOutput.ReadToEnd();
+ string errors = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+ (process.ExitCode == 0 || process.ExitCode == 1).ShouldBeTrue(errors);
+ return output.Trim();
+ }
+ }
+
+ private void AppendToSharedConfig(string text)
+ {
+ File.AppendAllText(this.SharedConfigPath, text);
+ }
+
+ // The unit test platform does not support MoveAndOverwriteFile, which
+ // FileBasedDictionary uses to replace its data file.
+ private class OverwritingFileSystem : PhysicalFileSystem
+ {
+ public override void MoveAndOverwriteFile(string sourceFileName, string destinationFilename)
+ {
+ File.Move(sourceFileName, destinationFilename, overwrite: true);
+ }
+ }
}
}
diff --git a/GVFS/GVFS.UnitTests/Git/RequiredGitConfigTests.cs b/GVFS/GVFS.UnitTests/Git/RequiredGitConfigTests.cs
new file mode 100644
index 0000000000..af0f8234cf
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Git/RequiredGitConfigTests.cs
@@ -0,0 +1,148 @@
+using GVFS.Common.Git;
+using GVFS.Tests.Should;
+using GVFS.UnitTests.Mock.Common;
+using GVFS.UnitTests.Mock.Git;
+using NUnit.Framework;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GVFS.UnitTests.Git
+{
+ [TestFixture]
+ public class RequiredGitConfigTests
+ {
+ private const string ListLocalConfigCommand = "config --list --local -z";
+ private const string SetCommandPrefix = "config --local --replace-all";
+ private const string ConfigLockError = "error: could not lock config file .git/config: File exists";
+ private const int ConfigLockFailureCode = 255;
+
+ [TestCase]
+ public void AcceptsFailedSharedWriteWhenTheValueIsPresent()
+ {
+ MockGitProcess git = CreateGitProcess(string.Empty, "core.autocrlf\nfalse\0");
+ git.SetExpectedCommandResult(SetCommandPrefix, () => new GitProcess.Result(string.Empty, ConfigLockError, ConfigLockFailureCode), matchPrefix: true);
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(new MockGVFSEnlistment(git), CreateAutoCrlfSettings(), out error).ShouldBeTrue(error);
+
+ CountSetCommands(git).ShouldEqual(1);
+ }
+
+ [TestCase]
+ public void RetriesFailedSharedWriteUntilItSucceeds()
+ {
+ int setAttempts = 0;
+ MockGitProcess git = CreateGitProcess(string.Empty);
+ git.SetExpectedCommandResult(
+ SetCommandPrefix,
+ () => ++setAttempts == 1
+ ? new GitProcess.Result(string.Empty, ConfigLockError, ConfigLockFailureCode)
+ : new GitProcess.Result(string.Empty, string.Empty, GitProcess.Result.SuccessCode),
+ matchPrefix: true);
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(new MockGVFSEnlistment(git), CreateAutoCrlfSettings(), out error).ShouldBeTrue(error);
+
+ CountSetCommands(git).ShouldEqual(2);
+ }
+
+ [TestCase]
+ public void FailsWhenSharedWriteFailsAndTheValueStaysMissing()
+ {
+ MockGitProcess git = CreateGitProcess(string.Empty);
+ git.SetExpectedCommandResult(SetCommandPrefix, () => new GitProcess.Result(string.Empty, ConfigLockError, ConfigLockFailureCode), matchPrefix: true);
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(new MockGVFSEnlistment(git), CreateAutoCrlfSettings(), out error).ShouldBeFalse();
+
+ error.ShouldContain("core.autocrlf", "could not lock config file");
+ CountSetCommands(git).ShouldEqual(RequiredGitConfig.SharedWriteAttempts);
+ }
+
+ [TestCase]
+ public void RetriesFailedSharedWriteWhenAnotherValueIsAlsoPresent()
+ {
+ MockGitProcess git = CreateGitProcess(string.Empty, "core.autocrlf\nfalse\0core.autocrlf\ntrue\0");
+ git.SetExpectedCommandResult(SetCommandPrefix, () => new GitProcess.Result(string.Empty, ConfigLockError, ConfigLockFailureCode), matchPrefix: true);
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(new MockGVFSEnlistment(git), CreateAutoCrlfSettings(), out error).ShouldBeFalse();
+
+ CountSetCommands(git).ShouldEqual(RequiredGitConfig.SharedWriteAttempts);
+ }
+
+ [TestCase]
+ public void DoesNotRewriteAPresentSettingWithQuotedSubsection()
+ {
+ MockGitProcess git = CreateGitProcess("credential.https://dev.azure.com.usehttppath\ntrue\0");
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(
+ new MockGVFSEnlistment(git),
+ new Dictionary { { "credential.\"https://dev.azure.com\".useHttpPath", "true" } },
+ out error).ShouldBeTrue(error);
+
+ git.CommandsRun.Single().ShouldEqual(ListLocalConfigCommand);
+ }
+
+ [TestCase]
+ public void RewritesASettingWithSeveralValues()
+ {
+ MockGitProcess git = CreateGitProcess("core.autocrlf\nfalse\0core.autocrlf\ntrue\0");
+ git.SetExpectedCommandResult(SetCommandPrefix, () => new GitProcess.Result(string.Empty, string.Empty, GitProcess.Result.SuccessCode), matchPrefix: true);
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(new MockGVFSEnlistment(git), CreateAutoCrlfSettings(), out error).ShouldBeTrue(error);
+
+ CountSetCommands(git).ShouldEqual(1);
+ }
+
+ [TestCase]
+ public void RewritesASettingWithoutAValue()
+ {
+ MockGitProcess git = CreateGitProcess("core.autocrlf\0");
+ git.SetExpectedCommandResult(SetCommandPrefix, () => new GitProcess.Result(string.Empty, string.Empty, GitProcess.Result.SuccessCode), matchPrefix: true);
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(new MockGVFSEnlistment(git), CreateAutoCrlfSettings(), out error).ShouldBeTrue(error);
+
+ CountSetCommands(git).ShouldEqual(1);
+ }
+
+ [TestCase]
+ public void FailsWhenTheLocalConfigCannotBeRead()
+ {
+ MockGitProcess git = new MockGitProcess();
+ git.SetExpectedCommandResult(ListLocalConfigCommand, () => new GitProcess.Result(string.Empty, "fatal: bad config line 1 in file .git/config", 128));
+
+ string error;
+ RequiredGitConfig.TrySetRequiredSettings(new MockGVFSEnlistment(git), CreateAutoCrlfSettings(), out error).ShouldBeFalse();
+
+ error.ShouldEqual("Could not read the local git config");
+ }
+
+ ///
+ /// Returns a mock whose local config list gives in order,
+ /// and then the last output again.
+ ///
+ private static MockGitProcess CreateGitProcess(params string[] listOutputs)
+ {
+ int listCount = 0;
+ MockGitProcess git = new MockGitProcess();
+ git.SetExpectedCommandResult(
+ ListLocalConfigCommand,
+ () => new GitProcess.Result(listOutputs[System.Math.Min(listCount++, listOutputs.Length - 1)], string.Empty, GitProcess.Result.SuccessCode));
+ return git;
+ }
+
+ private static Dictionary CreateAutoCrlfSettings()
+ {
+ return new Dictionary { { "core.autocrlf", "false" } };
+ }
+
+ private static int CountSetCommands(MockGitProcess git)
+ {
+ return git.CommandsRun.Count(command => command.StartsWith(SetCommandPrefix) && command.Contains("\"core.autocrlf\" \"false\""));
+ }
+ }
+}
diff --git a/GVFS/GVFS/CommandLine/CloneVerb.cs b/GVFS/GVFS/CommandLine/CloneVerb.cs
index fda0151823..377ecae25b 100644
--- a/GVFS/GVFS/CommandLine/CloneVerb.cs
+++ b/GVFS/GVFS/CommandLine/CloneVerb.cs
@@ -698,8 +698,12 @@ private Result CreateClone(
return new Result(errorMessage);
}
- if (!GVFSVerb.TrySetRequiredGitConfigSettings(enlistment) ||
- !GVFSVerb.TrySetOptionalGitConfigSettings(enlistment))
+ if (!GVFSVerb.TrySetRequiredGitConfigSettings(enlistment, out string configError))
+ {
+ return new Result("Unable to configure git repo: " + configError);
+ }
+
+ if (!GVFSVerb.TrySetOptionalGitConfigSettings(enlistment))
{
return new Result("Unable to configure git repo");
}
diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs
index d1a4348e1d..7844f44ffa 100644
--- a/GVFS/GVFS/CommandLine/GVFSVerb.cs
+++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs
@@ -96,16 +96,9 @@ public string ServicePipeName
protected abstract string VerbName { get; }
- public static bool TrySetRequiredGitConfigSettings(GVFSEnlistment enlistment)
+ public static bool TrySetRequiredGitConfigSettings(GVFSEnlistment enlistment, out string error)
{
- Dictionary requiredSettings = RequiredGitConfig.GetRequiredSettings(enlistment);
-
- if (!TrySetConfig(enlistment, requiredSettings, isRequired: true))
- {
- return false;
- }
-
- return true;
+ return RequiredGitConfig.TrySetRequiredSettings(enlistment, out error);
}
public static bool TrySetOptionalGitConfigSettings(Enlistment enlistment)
@@ -118,7 +111,7 @@ public static bool TrySetOptionalGitConfigSettings(Enlistment enlistment)
{ "status.aheadbehind", "false" },
};
- if (!TrySetConfig(enlistment, optionalSettings, isRequired: false))
+ if (!TrySetMissingConfig(enlistment, optionalSettings))
{
return false;
}
@@ -658,39 +651,24 @@ private static string CreateMountId()
return Guid.NewGuid().ToString("N");
}
- private static bool TrySetConfig(Enlistment enlistment, Dictionary configSettings, bool isRequired)
+ private static bool TrySetMissingConfig(Enlistment enlistment, Dictionary configSettings)
{
GitProcess git = new GitProcess(enlistment);
Dictionary existingConfigSettings;
-
- // If the settings are required, then only check local config settings, because we don't want to depend on
- // global settings that can then change independent of this repo.
- if (!git.TryGetAllConfig(localOnly: isRequired, configSettings: out existingConfigSettings))
+ if (!git.TryGetAllConfig(localOnly: false, configSettings: out existingConfigSettings))
{
return false;
}
foreach (KeyValuePair setting in configSettings)
{
- GitConfigSetting existingSetting;
- if (setting.Value != null)
- {
- if (!existingConfigSettings.TryGetValue(setting.Key, out existingSetting) ||
- (isRequired && !existingSetting.HasValue(setting.Value)))
- {
- GitProcess.Result setConfigResult = git.SetInLocalConfig(setting.Key, setting.Value);
- if (setConfigResult.ExitCodeIsFailure)
- {
- return false;
- }
- }
- }
- else
+ if (!existingConfigSettings.ContainsKey(setting.Key))
{
- if (existingConfigSettings.TryGetValue(setting.Key, out existingSetting))
+ GitProcess.Result setConfigResult = git.SetInLocalConfig(setting.Key, setting.Value);
+ if (setConfigResult.ExitCodeIsFailure)
{
- git.DeleteFromLocalConfig(setting.Key);
+ return false;
}
}
}
diff --git a/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs b/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs
index 2903bcf72d..5a3a8e17d9 100644
--- a/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs
+++ b/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs
@@ -84,8 +84,15 @@ public override FixResult TryFixIssues(List messages)
File.WriteAllText(configPath, string.Empty);
this.Tracer.RelatedInfo("Created empty file: " + configPath);
- if (!GVFSVerb.TrySetRequiredGitConfigSettings(this.Enlistment) ||
- !GVFSVerb.TrySetOptionalGitConfigSettings(this.Enlistment))
+ if (!GVFSVerb.TrySetRequiredGitConfigSettings(this.Enlistment, out string configError))
+ {
+ messages.Add($"Unable to create default {GVFSConstants.DotGit.Config}: {configError}");
+ this.RestoreFromBackupFile(configBackupPath, configPath, messages);
+
+ return FixResult.Failure;
+ }
+
+ if (!GVFSVerb.TrySetOptionalGitConfigSettings(this.Enlistment))
{
messages.Add($"Unable to create default {GVFSConstants.DotGit.Config}.");
this.RestoreFromBackupFile(configBackupPath, configPath, messages);