From 5bdfb3757f249352288a64b59027cb5e748a6600 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 6 Oct 2026 11:34:54 -0700 Subject: [PATCH 1/2] Keep worktree ids and status cache path out of the shared git config A linked worktree mount wrote its ids and status.deserializePath with git config --local. Git resolves --local config for a linked worktree to the config that all worktrees share. So each worktree mount overwrote the ids and the status cache path of the primary enlistment. The status cache path then pointed every enlistment at the cache of the last mount. Git rejects a cache that does not match its own index, so every other enlistment lost the status cache. Required config: - One required-config helper is now used by mount, clone, and repair. - For a linked worktree, the helper enables extensions.worktreeConfig in the shared config. It writes status.deserializePath to the config.worktree of the worktree with git config --file. An unattended worktree writes an empty value, which disables the status cache. - The shared config keeps the status cache path of the primary. A worktree mount restores that value if an older version overwrote it. - A config.worktree value overrides the shared value. So the helper removes every other required setting from the config.worktree of the enlistment, for the primary too. - The primary also enables the extension when a linked worktree has a config.worktree, so repair keeps it. - The helper reads every config entry with git config --list -z. A required setting is correct only when it has exactly one entry with the required value. Git uses the last entry of a key, so the helper rewrites a key with several entries, or with an entry that has no value, with git config --replace-all. - The helper reads the local config and config.worktree with the shared config read retry. The mount passes its tracer, so retries are traced. - A failed shared write is retried up to five times. It counts as done when a new read shows the value, because concurrent worktree mounts write the same values. This also applies to the primary. - Setting names with a quoted subsection, such as credential."https://dev.azure.com".useHttpPath, now match the output of git config --list. Before, every mount rewrote that setting in the shared config. - Optional settings use a smaller helper that sets only missing keys. Its behavior does not change. - The repair job shows the error of a failed required-settings write. Ids: - The mount id is created before the mount tracer. Every event of a mount now carries the id of that mount, not of the previous mount. - A worktree writes gvfs.mount-id to its config.worktree. - The worktree enlistment id is derived from the primary enlistment id and the worktree name. It does not change when the worktree moves. A worktree that is removed and added again with the same name gets the same id. A worktree mount restores the primary enlistment id in the shared config if an older version overwrote it. - The mount reads the primary enlistment id one time, before the tracer. The tracer and the worktree metadata use the same derived id. - For the primary, GetEnlistmentId reads the repo metadata first, and then the shared config. - EnlistmentInfo reports PrimaryEnlistmentId for a worktree. - Warnings about a missing primary enlistment id, a failed mount id write, and a failed shared id restore go to telemetry. Assisted-by: Claude Opus 4.8 and Claude Opus 5.5 Signed-off-by: Tyrie Vella Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- GVFS/GVFS.Common/GVFSConstants.cs | 2 + GVFS/GVFS.Common/GVFSEnlistment.cs | 67 +++- GVFS/GVFS.Common/Git/GitProcess.cs | 67 +++- GVFS/GVFS.Common/Git/RequiredGitConfig.cs | 260 ++++++++++++- GVFS/GVFS.Common/RepoMetadata.cs | 124 ++++++- .../EnlistmentPerFixture/WorktreeTests.cs | 218 +++++++++++ .../GVFS.FunctionalTests/Tools/GVFSHelpers.cs | 11 + GVFS/GVFS.Mount/InProcessMount.cs | 141 ++++--- GVFS/GVFS.Mount/InProcessMountVerb.cs | 31 +- .../Common/GVFSEnlistmentTests.cs | 11 - .../Common/WorktreeEnlistmentTests.cs | 347 +++++++++++++++++- .../Git/RequiredGitConfigTests.cs | 148 ++++++++ GVFS/GVFS/CommandLine/CloneVerb.cs | 8 +- GVFS/GVFS/CommandLine/GVFSVerb.cs | 40 +- GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs | 11 +- 15 files changed, 1359 insertions(+), 127 deletions(-) create mode 100644 GVFS/GVFS.UnitTests/Git/RequiredGitConfigTests.cs 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..96af7f00d0 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,252 @@ 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 disables the status cache for this worktree. Without + // a value, the worktree uses the status cache of the primary. + 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 +281,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 +411,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..e5fc558c61 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,146 @@ 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}\""); + } + } + + 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 +731,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..2194760751 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -42,6 +42,10 @@ public class InProcessMount private const int TrackedTreeCapacity = MissingTreeThresholdForDownloadingCommitPack * 20; 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 +75,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 +301,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 +540,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 +596,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 +622,7 @@ private void InitializeWorktreeMetadata() try { - RepoMetadata.Instance.SaveCloneMetadata(this.tracer, this.enlistment); + RepoMetadata.Instance.SaveWorktreeMetadata(this.tracer, this.enlistment, worktreeEnlistmentId); } finally { @@ -1957,64 +1978,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) - { - 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 + // 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)) - { - 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 +2025,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); From d930947bf52d941ee60db4bf28ed146cd531ca5e Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 5 Nov 2026 13:03:04 -0800 Subject: [PATCH 2/2] fix: address review feedback - delete the status cache of an uncached mount A mount without a GitStatusCache (unattended, or a platform without the status cache) does not update or delete GitStatusCache.dat. Git still reads that file when status.deserializePath names it, so git status can report a clean tree over real edits. This happens when an attended mount of a linked worktree writes the path of the primary to the shared config while the primary runs unattended. It also happens after an upgrade, when the shared config still names the cache of an unattended worktree. The mount now deletes the status cache file of its enlistment when it does not run a GitStatusCache. It retries the delete five times, and then fails the mount. Git skips a path that does not exist. An empty status.deserializePath in config.worktree does not disable a readable shared path: microsoft/git keeps the last readable path. The comment in RequiredGitConfig now says what the empty value does. Add the functional test WorktreeTests.UnattendedMountDeletesTheStatusCacheThatItDoesNotUpdate. The test builds an attended cache, remounts the primary unattended, checks that the cache file is gone, writes the shared path again, and checks that git status shows a new file. Assisted-by: Claude Opus 5.5 Signed-off-by: Tyrie Vella Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- GVFS/GVFS.Common/Git/RequiredGitConfig.cs | 6 ++- .../EnlistmentPerFixture/WorktreeTests.cs | 43 +++++++++++++++++++ GVFS/GVFS.Mount/InProcessMount.cs | 41 ++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/GVFS/GVFS.Common/Git/RequiredGitConfig.cs b/GVFS/GVFS.Common/Git/RequiredGitConfig.cs index 96af7f00d0..1eecde6d45 100644 --- a/GVFS/GVFS.Common/Git/RequiredGitConfig.cs +++ b/GVFS/GVFS.Common/Git/RequiredGitConfig.cs @@ -62,8 +62,10 @@ private static bool TrySetRequiredSettings(GVFSEnlistment enlistment, ITracer tr { if (enlistment.IsWorktree && string.Equals(setting.Key, StatusDeserializePath, StringComparison.OrdinalIgnoreCase)) { - // An empty value disables the status cache for this worktree. Without - // a value, the worktree uses the status cache of the primary. + // 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) { diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index e5fc558c61..0ca3d07f5e 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -678,6 +678,49 @@ public void WorktreeMountRepairsConfigLeftByPreviousVersion() } } + [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")); diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 2194760751..215207ecec 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -41,6 +41,9 @@ 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; @@ -669,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 @@ -1593,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");