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);