From 5bd982569e2c09fdb2df64e477dc49a8c90ab156 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 8 Apr 2026 15:54:53 -0700 Subject: [PATCH 01/25] Centralize NuGet package versions with Directory.Packages.props Enable ManagePackageVersionsCentrally and move all PackageVersion declarations to Directory.Packages.props. Individual csproj files no longer specify Version on PackageReference elements. Pre-declare System.Text.Json, System.CommandLine, and System.IO.Pipes.AccessControl for upcoming migration phases. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- Directory.Packages.props | 55 +++++++++++++++++++ GVFS/FastFetch/FastFetch.csproj | 4 +- GVFS/GVFS.Common/GVFS.Common.csproj | 8 +-- .../GVFS.FunctionalTests.LockHolder.csproj | 2 +- .../GVFS.FunctionalTests.csproj | 14 ++--- GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj | 2 +- GVFS/GVFS.Hooks/GVFS.Hooks.csproj | 2 +- GVFS/GVFS.Installers/GVFS.Installers.csproj | 4 +- GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj | 4 +- GVFS/GVFS.Mount/GVFS.Mount.csproj | 2 +- GVFS/GVFS.Payload/GVFS.Payload.csproj | 6 +- .../GVFS.Platform.Windows.csproj | 8 +-- GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj | 2 +- GVFS/GVFS.Service/GVFS.Service.csproj | 4 +- GVFS/GVFS.Tests/GVFS.Tests.csproj | 4 +- GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj | 8 +-- .../GVFS.Virtualization.csproj | 4 +- GVFS/GVFS/GVFS.csproj | 2 +- 18 files changed, 95 insertions(+), 40 deletions(-) create mode 100644 Directory.Packages.props diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 000000000..2e1b042d8 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,55 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GVFS/FastFetch/FastFetch.csproj b/GVFS/FastFetch/FastFetch.csproj index df25afb5b..a2584da8b 100644 --- a/GVFS/FastFetch/FastFetch.csproj +++ b/GVFS/FastFetch/FastFetch.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/GVFS/GVFS.Common/GVFS.Common.csproj b/GVFS/GVFS.Common/GVFS.Common.csproj index daacd2ca2..56bf81edf 100644 --- a/GVFS/GVFS.Common/GVFS.Common.csproj +++ b/GVFS/GVFS.Common/GVFS.Common.csproj @@ -6,10 +6,10 @@ - - - - + + + + diff --git a/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj b/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj index bb03a4171..a83b42fb3 100644 --- a/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj +++ b/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj @@ -6,7 +6,7 @@ - + diff --git a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj index c777bdf84..e11eab0dc 100644 --- a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj +++ b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj @@ -6,13 +6,13 @@ - - - - - - - + + + + + + + diff --git a/GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj b/GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj index 97e2973e3..44acd016c 100644 --- a/GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj +++ b/GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj @@ -5,7 +5,7 @@ - + diff --git a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj index f5cd8a1eb..8e4017902 100644 --- a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj +++ b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj @@ -7,7 +7,7 @@ - + diff --git a/GVFS/GVFS.Installers/GVFS.Installers.csproj b/GVFS/GVFS.Installers/GVFS.Installers.csproj index 7ae37dee5..8a849014a 100644 --- a/GVFS/GVFS.Installers/GVFS.Installers.csproj +++ b/GVFS/GVFS.Installers/GVFS.Installers.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj b/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj index 1505e24e0..ff90a1742 100644 --- a/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj +++ b/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj @@ -6,8 +6,8 @@ - - + + diff --git a/GVFS/GVFS.Mount/GVFS.Mount.csproj b/GVFS/GVFS.Mount/GVFS.Mount.csproj index 83d89be63..35a3c2722 100644 --- a/GVFS/GVFS.Mount/GVFS.Mount.csproj +++ b/GVFS/GVFS.Mount/GVFS.Mount.csproj @@ -16,7 +16,7 @@ - + diff --git a/GVFS/GVFS.Payload/GVFS.Payload.csproj b/GVFS/GVFS.Payload/GVFS.Payload.csproj index 1311bc87d..e7bc79415 100644 --- a/GVFS/GVFS.Payload/GVFS.Payload.csproj +++ b/GVFS/GVFS.Payload/GVFS.Payload.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj b/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj index f8fb56597..1bf325061 100644 --- a/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj +++ b/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj @@ -10,10 +10,10 @@ - - - - + + + + diff --git a/GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj b/GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj index 48e5c1605..4f3c5ac83 100644 --- a/GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj +++ b/GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj @@ -6,7 +6,7 @@ - + diff --git a/GVFS/GVFS.Service/GVFS.Service.csproj b/GVFS/GVFS.Service/GVFS.Service.csproj index c24eb6505..b557e3a41 100644 --- a/GVFS/GVFS.Service/GVFS.Service.csproj +++ b/GVFS/GVFS.Service/GVFS.Service.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/GVFS/GVFS.Tests/GVFS.Tests.csproj b/GVFS/GVFS.Tests/GVFS.Tests.csproj index c8c173ebf..acfabea90 100644 --- a/GVFS/GVFS.Tests/GVFS.Tests.csproj +++ b/GVFS/GVFS.Tests/GVFS.Tests.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj index 8c3669baa..6111e7220 100644 --- a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj +++ b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj @@ -16,9 +16,9 @@ - - - + + + @@ -29,7 +29,7 @@ - ProjectedFSLib.dll diff --git a/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj b/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj index 91772d269..0adc727d8 100644 --- a/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj +++ b/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/GVFS/GVFS/GVFS.csproj b/GVFS/GVFS/GVFS.csproj index 892bc1386..3e639f297 100644 --- a/GVFS/GVFS/GVFS.csproj +++ b/GVFS/GVFS/GVFS.csproj @@ -16,7 +16,7 @@ - + From 4b7034866108ea80f695708a9cd768360f8d241d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 8 Apr 2026 16:40:24 -0700 Subject: [PATCH 02/25] PostIndexChangedHook: skip notification for non-canonical indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git fires the post-index-change hook for every index write, including temp indexes created via GIT_INDEX_FILE redirect (e.g. read-tree --index-output, git add with a temp index). The GVFS mount process only needs to know about changes to the real `\$GIT_DIR/index`. When writing a 194MB temp index (2.47M entries), the hook's pipe round-trip to the GVFS mount process adds ~5s of overhead per read-tree/add call — a significant regression for tools that use the temp-index flow. Add an early-exit check in IsNonCanonicalIndex() that resolves both GIT_INDEX_FILE and `\$GIT_DIR/index` to absolute paths via GetFullPathNameA, then compares case-insensitively. If they differ, the hook exits immediately without contacting the mount process. When the environment is unexpected (GIT_DIR absent, path resolution fails), we err on the side of correctness and proceed with the notification rather than silently suppressing it. Unit tests invoke the hook exe with controlled environment variables and WorkingDirectory set to %TEMP% (outside any GVFS mount) to verify: - temp index paths (should skip) - canonical index paths (should NOT skip, exits NotInGVFSEnlistment) - missing GIT_DIR (should NOT skip) - mixed separators and case (normalization via GetFullPathNameA) Note: the ideal long-term fix is in Git's do_write_locked_index() (read-cache.c) to skip firing the hook entirely for non-default indexes, avoiding the process spawn altogether. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.PostIndexChangedHook/main.cpp | 72 ++++++- .../Hooks/PostIndexChangedHookTests.cs | 178 ++++++++++++++++++ 2 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs diff --git a/GVFS/GVFS.PostIndexChangedHook/main.cpp b/GVFS/GVFS.PostIndexChangedHook/main.cpp index 03fb26b29..9b71e2495 100644 --- a/GVFS/GVFS.PostIndexChangedHook/main.cpp +++ b/GVFS/GVFS.PostIndexChangedHook/main.cpp @@ -1,4 +1,4 @@ -#include "stdafx.h" +#include "stdafx.h" #include "common.h" enum PostIndexChangedErrorReturnCode @@ -8,6 +8,66 @@ enum PostIndexChangedErrorReturnCode const int PIPE_BUFFER_SIZE = 1024; +// Returns true if GIT_INDEX_FILE refers to a non-canonical (temp) index. +// The canonical index path is $GIT_DIR/index; anything else is a temp +// index that GVFS doesn't need to be notified about. +// +// GIT_DIR is always set by git.exe itself (via xsetenv in setup.c) before +// any hook runs, so it is reliably present. GIT_INDEX_FILE is only present +// when an external caller (script, build tool, etc.) explicitly exports it +// before invoking git, to redirect index operations to a temp file. +static bool IsNonCanonicalIndex() +{ + char *indexFileEnv = NULL; + size_t indexLen = 0; + _dupenv_s(&indexFileEnv, &indexLen, "GIT_INDEX_FILE"); + + if (indexFileEnv == NULL || indexFileEnv[0] == '\0') + { + free(indexFileEnv); + return false; + } + + char *gitDirEnv = NULL; + size_t gitDirLen = 0; + _dupenv_s(&gitDirEnv, &gitDirLen, "GIT_DIR"); + + if (gitDirEnv == NULL || gitDirEnv[0] == '\0') + { + // GIT_INDEX_FILE is set but GIT_DIR is not — shouldn't happen + // inside a hook (git.exe always sets GIT_DIR), but err on the + // side of correctness: proceed with the notification. + free(indexFileEnv); + free(gitDirEnv); + return false; + } + + // Build the canonical index path: /index + std::string canonical(gitDirEnv); + if (!canonical.empty() && canonical.back() != '\\' && canonical.back() != '/') + canonical += '\\'; + canonical += "index"; + + // Resolve both paths to absolute form so that relative GIT_DIR + // (e.g. ".git") and absolute GIT_INDEX_FILE compare correctly. + char canonicalFull[MAX_PATH]; + char actualFull[MAX_PATH]; + DWORD canonLen = GetFullPathNameA(canonical.c_str(), MAX_PATH, canonicalFull, NULL); + DWORD actualLen = GetFullPathNameA(indexFileEnv, MAX_PATH, actualFull, NULL); + + free(indexFileEnv); + free(gitDirEnv); + + if (canonLen == 0 || canonLen >= MAX_PATH || + actualLen == 0 || actualLen >= MAX_PATH) + { + // Path resolution failed — err on the side of correctness. + return false; + } + + return _stricmp(actualFull, canonicalFull) != 0; +} + int main(int argc, char *argv[]) { if (argc != 3) @@ -15,6 +75,16 @@ int main(int argc, char *argv[]) die(ReturnCode::InvalidArgCount, "Invalid arguments"); } + // Skip notification for non-canonical (temp) index files. + // Git fires post-index-change for every index write, including temp + // indexes created via GIT_INDEX_FILE redirect (e.g. read-tree + // --index-output, git add with a temp index). GVFS only needs to + // know about changes to the real $GIT_DIR/index. + if (IsNonCanonicalIndex()) + { + return 0; + } + if (strcmp(argv[1], "1") && strcmp(argv[1], "0")) { die(PostIndexChangedErrorReturnCode::ErrorPostIndexChangedProtocol, "Invalid value passed for first argument"); diff --git a/GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs b/GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs new file mode 100644 index 000000000..2bf00cc28 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs @@ -0,0 +1,178 @@ +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.IO; + +namespace GVFS.UnitTests.Hooks +{ + [TestFixture] + public class PostIndexChangedHookTests + { + // Exit code from common.h ReturnCode::NotInGVFSEnlistment. + // The hook dies with this code when it can't find a .gvfs folder. + private const int NotInGVFSEnlistment = 3; + + // The hook exe is built to the same output root as the test runner. + // Walk up from the unit test output dir to find the hook exe under + // the shared build output tree. + private static readonly string HookExePath = FindHookExe(); + + private static string FindHookExe() + { + // Test runner lives at: out\GVFS.UnitTests\bin\Debug\net471\win-x64\ + // Hook exe lives at: out\GVFS.PostIndexChangedHook\bin\x64\Debug\ + string testDir = Path.GetDirectoryName(typeof(PostIndexChangedHookTests).Assembly.Location); + string outDir = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "..")); + string hookPath = Path.Combine(outDir, "GVFS.PostIndexChangedHook", "bin", "x64", "Debug", "GVFS.PostIndexChangedHook.exe"); + + // Also check via VFS_OUTDIR if available + if (!File.Exists(hookPath)) + { + string vfsOutDir = Environment.GetEnvironmentVariable("VFS_OUTDIR"); + if (!string.IsNullOrEmpty(vfsOutDir)) + { + hookPath = Path.Combine(vfsOutDir, "GVFS.PostIndexChangedHook", "bin", "x64", "Debug", "GVFS.PostIndexChangedHook.exe"); + } + } + + return hookPath; + } + + [SetUp] + public void EnsureHookExists() + { + if (!File.Exists(HookExePath)) + { + Assert.Ignore($"Hook exe not found at {HookExePath} — build the full solution first."); + } + } + + /// + /// When GIT_INDEX_FILE points to a non-canonical (temp) index, + /// the hook should exit immediately with code 0 without trying + /// to connect to the GVFS pipe. + /// + [TestCase("C:\\repo\\.git\\tmp_index_1234", "C:\\repo\\.git")] + [TestCase("/repo/.git/some_other_index", "/repo/.git")] + [TestCase("D:\\src\\.git\\index.lock", "D:\\src\\.git")] + [TestCase("C:\\tmp\\scratch_index", "C:\\repo\\.git")] + public void SkipsNotification_WhenIndexIsNonCanonical(string indexFile, string gitDir) + { + int exitCode = RunHook(indexFile, gitDir); + Assert.AreEqual(0, exitCode, "Hook should exit 0 (skip) for non-canonical index"); + } + + /// + /// When GIT_INDEX_FILE matches the canonical $GIT_DIR/index, + /// the hook should NOT skip — it should proceed and attempt the + /// pipe connection. Outside a GVFS mount (WorkingDirectory is + /// %TEMP%), the hook fails with NotInGVFSEnlistment, proving + /// the guard did not fire. + /// + [TestCase("C:\\repo\\.git\\index", "C:\\repo\\.git")] + [TestCase("C:\\repo\\.git/index", "C:\\repo\\.git\\")] + public void DoesNotSkip_WhenIndexIsCanonical(string indexFile, string gitDir) + { + int exitCode = RunHook(indexFile, gitDir); + Assert.AreEqual(NotInGVFSEnlistment, exitCode, + "Hook should NOT skip for canonical index (NotInGVFSEnlistment = guard didn't fire)"); + } + + /// + /// When GIT_INDEX_FILE is not set at all, the hook should NOT + /// skip — this is the normal case where git writes the default index. + /// + [Test] + public void DoesNotSkip_WhenGitIndexFileNotSet() + { + int exitCode = RunHook(null, "C:\\repo\\.git"); + Assert.AreEqual(NotInGVFSEnlistment, exitCode, + "Hook should NOT skip when GIT_INDEX_FILE is unset"); + } + + /// + /// When GIT_INDEX_FILE is set but GIT_DIR is empty/missing, + /// the hook should NOT skip — err on the side of correctness + /// when the environment is unexpected. + /// + [TestCase("C:\\repo\\.git\\tmp_index", null)] + [TestCase("C:\\repo\\.git\\tmp_index", "")] + public void DoesNotSkip_WhenGitDirMissing(string indexFile, string gitDir) + { + int exitCode = RunHook(indexFile, gitDir); + Assert.AreEqual(NotInGVFSEnlistment, exitCode, + "Hook should NOT skip when GIT_DIR is absent — err on the side of correctness"); + } + + /// + /// Case-insensitive matching: mixed-case paths that resolve to + /// the canonical index should NOT skip. + /// + [Test] + public void DoesNotSkip_WhenCanonicalPathDiffersOnlyInCase() + { + int exitCode = RunHook("C:\\Repo\\.GIT\\INDEX", "C:\\Repo\\.GIT"); + Assert.AreEqual(NotInGVFSEnlistment, exitCode, + "Case-insensitive canonical match should NOT skip"); + } + + /// + /// Separator normalization: forward vs backslash in canonical + /// path should still match. + /// + [Test] + public void SkipsNotification_ForwardSlashTempIndex() + { + int exitCode = RunHook("C:/repo/.git/tmp_idx", "C:\\repo\\.git"); + Assert.AreEqual(0, exitCode, "Forward-slash temp index should still be detected as non-canonical"); + } + + private int RunHook(string gitIndexFile, string gitDir) + { + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = HookExePath, + Arguments = "1 0", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + + // Run outside any GVFS enlistment so the pipe lookup + // fails predictably with NotInGVFSEnlistment. + WorkingDirectory = Path.GetTempPath(), + }; + + // Set or remove env vars + if (gitIndexFile != null) + { + psi.Environment["GIT_INDEX_FILE"] = gitIndexFile; + } + else + { + psi.Environment.Remove("GIT_INDEX_FILE"); + } + + if (gitDir != null) + { + psi.Environment["GIT_DIR"] = gitDir; + } + else + { + psi.Environment.Remove("GIT_DIR"); + } + + using (Process process = Process.Start(psi)) + { + process.WaitForExit(5000); + if (!process.HasExited) + { + process.Kill(); + Assert.Fail("Hook process timed out (5s) — likely blocked on pipe connect"); + } + + return process.ExitCode; + } + } + } +} From 1be4acac8103c9c7862301122e45b5196d7e502a Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 8 Apr 2026 16:17:10 -0700 Subject: [PATCH 03/25] Remove GVFS.GVFlt and ESENT legacy disk layout upgrades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the GVFlt compatibility shim project (legacy ProjFS predecessor) and all DiskLayout upgrade paths that depend on ManagedEsent (layouts 7→8, 8→9, 9→10, 10→11, 11→12, 12→12.1, 12→13, 13→14). These upgrades are 6+ years old and no longer needed. Raise minimum supported disk layout version from 7 to 14. The 14→15 upgrade (ModifiedPaths) is retained so v14 repos can still upgrade. Users on layouts below 14 must upgrade through an intermediate GVFS version first. Removes ManagedEsent, Microsoft.Database.Collections.Generic, and Microsoft.Database.Isam NuGet packages entirely. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- Directory.Packages.props | 3 - GVFS.sln | 6 - GVFS/FastFetch/FastFetch.csproj | 1 - .../GVFS.FunctionalTests.csproj | 1 - .../GVFS.FunctionalTests/Tools/GVFSHelpers.cs | 2 +- .../Windows/Tests/SharedCacheUpgradeTests.cs | 111 ----------- .../Tests/WindowsDiskLayoutUpgradeTests.cs | 186 +----------------- .../Windows/Tools/ESENTDatabase.cs | 71 ------- GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj | 11 -- GVFS/GVFS.GVFlt/GVFltCallbacks.cs | 34 ---- GVFS/GVFS.Payload/GVFS.Payload.csproj | 1 - ...iskLayout10to11Upgrade_NewOperationType.cs | 27 --- ...iskLayout11to12Upgrade_SharedLocalCache.cs | 63 ------ ...yout12_0To12_1Upgrade_StatusAheadBehind.cs | 35 ---- ...skLayout12to13Upgrade_FolderPlaceholder.cs | 123 ------------ .../DiskLayout13to14Upgrade_BlobSizes.cs | 139 ------------- .../DiskLayout7to8Upgrade_NewOperationType.cs | 42 ---- ...iskLayout8to9Upgrade_RepoMetadataToJson.cs | 89 --------- ...BackgroundAndPlaceholderListToFileBased.cs | 181 ----------------- .../WindowsDiskLayoutUpgradeData.cs | 42 +--- .../GVFS.Platform.Windows.csproj | 4 - 21 files changed, 7 insertions(+), 1165 deletions(-) delete mode 100644 GVFS/GVFS.FunctionalTests/Windows/Tools/ESENTDatabase.cs delete mode 100644 GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj delete mode 100644 GVFS/GVFS.GVFlt/GVFltCallbacks.cs delete mode 100644 GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout10to11Upgrade_NewOperationType.cs delete mode 100644 GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout11to12Upgrade_SharedLocalCache.cs delete mode 100644 GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12_0To12_1Upgrade_StatusAheadBehind.cs delete mode 100644 GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12to13Upgrade_FolderPlaceholder.cs delete mode 100644 GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout13to14Upgrade_BlobSizes.cs delete mode 100644 GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout7to8Upgrade_NewOperationType.cs delete mode 100644 GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout8to9Upgrade_RepoMetadataToJson.cs delete mode 100644 GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 2e1b042d8..744acfb94 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,10 +12,7 @@ - - - diff --git a/GVFS.sln b/GVFS.sln index 80a2cbf0e..f4847cd29 100644 --- a/GVFS.sln +++ b/GVFS.sln @@ -13,8 +13,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.FunctionalTests", "GVF EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.FunctionalTests.LockHolder", "GVFS\GVFS.FunctionalTests.LockHolder\GVFS.FunctionalTests.LockHolder.csproj", "{B26985C3-250A-4805-AA97-AD0604331AC7}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.GVFlt", "GVFS\GVFS.GVFlt\GVFS.GVFlt.csproj", "{B366D3B6-1E85-4015-8DB0-D5FA4331ECE4}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Hooks", "GVFS\GVFS.Hooks\GVFS.Hooks.csproj", "{EDB4A40E-CFC9-486A-BDC5-AB2951FD8EDC}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Mount", "GVFS\GVFS.Mount\GVFS.Mount.csproj", "{F96089C2-6D09-4349-B65D-9CCA6160C6A5}" @@ -75,10 +73,6 @@ Global {B26985C3-250A-4805-AA97-AD0604331AC7}.Debug|x64.Build.0 = Debug|Any CPU {B26985C3-250A-4805-AA97-AD0604331AC7}.Release|x64.ActiveCfg = Release|Any CPU {B26985C3-250A-4805-AA97-AD0604331AC7}.Release|x64.Build.0 = Release|Any CPU - {B366D3B6-1E85-4015-8DB0-D5FA4331ECE4}.Debug|x64.ActiveCfg = Debug|Any CPU - {B366D3B6-1E85-4015-8DB0-D5FA4331ECE4}.Debug|x64.Build.0 = Debug|Any CPU - {B366D3B6-1E85-4015-8DB0-D5FA4331ECE4}.Release|x64.ActiveCfg = Release|Any CPU - {B366D3B6-1E85-4015-8DB0-D5FA4331ECE4}.Release|x64.Build.0 = Release|Any CPU {EDB4A40E-CFC9-486A-BDC5-AB2951FD8EDC}.Debug|x64.ActiveCfg = Debug|Any CPU {EDB4A40E-CFC9-486A-BDC5-AB2951FD8EDC}.Debug|x64.Build.0 = Debug|Any CPU {EDB4A40E-CFC9-486A-BDC5-AB2951FD8EDC}.Release|x64.ActiveCfg = Release|Any CPU diff --git a/GVFS/FastFetch/FastFetch.csproj b/GVFS/FastFetch/FastFetch.csproj index a2584da8b..cb75afee8 100644 --- a/GVFS/FastFetch/FastFetch.csproj +++ b/GVFS/FastFetch/FastFetch.csproj @@ -20,7 +20,6 @@ Microsoft400 diff --git a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj index e11eab0dc..daf852167 100644 --- a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj +++ b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj @@ -9,7 +9,6 @@ - diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs index d079e3668..b15d66eed 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs @@ -36,7 +36,7 @@ public static class GVFSHelpers private const int WindowsCurrentDiskLayoutMajorVersion = 19; private const int MacCurrentDiskLayoutMajorVersion = 19; - private const int WindowsCurrentDiskLayoutMinimumMajorVersion = 7; + private const int WindowsCurrentDiskLayoutMinimumMajorVersion = 14; private const int MacCurrentDiskLayoutMinimumMajorVersion = 18; public static string ConvertPathToGitFormat(string path) diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tests/SharedCacheUpgradeTests.cs b/GVFS/GVFS.FunctionalTests/Windows/Tests/SharedCacheUpgradeTests.cs index 3025b443e..e6432ed41 100644 --- a/GVFS/GVFS.FunctionalTests/Windows/Tests/SharedCacheUpgradeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Windows/Tests/SharedCacheUpgradeTests.cs @@ -3,11 +3,9 @@ using GVFS.FunctionalTests.Tests.MultiEnlistmentTests; using GVFS.FunctionalTests.Tools; using GVFS.FunctionalTests.Windows.Tests; -using GVFS.FunctionalTests.Windows.Tools; using GVFS.Tests.Should; using NUnit.Framework; using System; -using System.Collections.Generic; using System.IO; namespace GVFS.FunctionalTests.Windows.Windows.Tests @@ -33,115 +31,6 @@ public void SetCacheLocation() this.localCachePath = Path.Combine(this.localCacheParentPath, ".customGVFSCache"); } - [TestCase] - public void MountUpgradesLocalSizesToSharedCache() - { - GVFSFunctionalTestEnlistment enlistment = this.CloneAndMountEnlistment(); - enlistment.UnmountGVFS(); - - string localCacheRoot = GVFSHelpers.GetPersistedLocalCacheRoot(enlistment.DotGVFSRoot); - string gitObjectsRoot = GVFSHelpers.GetPersistedGitObjectsRoot(enlistment.DotGVFSRoot); - - // Delete the existing repo metadata - string versionJsonPath = Path.Combine(enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - // Since there isn't a sparse-checkout file that is used anymore one needs to be added - // in order to test the old upgrades that might have needed it - string sparseCheckoutPath = Path.Combine(enlistment.RepoRoot, TestConstants.DotGit.Info.SparseCheckoutPath); - this.fileSystem.WriteAllText(sparseCheckoutPath, "/.gitattributes\r\n"); - - // "13.0" was the last version before blob sizes were moved out of Esent - string metadataPath = Path.Combine(enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - this.fileSystem.CreateEmptyFile(metadataPath); - GVFSHelpers.SaveDiskLayoutVersion(enlistment.DotGVFSRoot, "13", "0"); - GVFSHelpers.SaveLocalCacheRoot(enlistment.DotGVFSRoot, localCacheRoot); - GVFSHelpers.SaveGitObjectsRoot(enlistment.DotGVFSRoot, gitObjectsRoot); - - // Create a legacy PersistedDictionary sizes database - List> entries = new List>() - { - new KeyValuePair(new string('0', 40), 1), - new KeyValuePair(new string('1', 40), 2), - new KeyValuePair(new string('2', 40), 4), - new KeyValuePair(new string('3', 40), 8), - }; - - ESENTDatabase.CreateEsentBlobSizesDatabase(enlistment.DotGVFSRoot, entries); - - enlistment.MountGVFS(); - - string majorVersion; - string minorVersion; - GVFSHelpers.GetPersistedDiskLayoutVersion(enlistment.DotGVFSRoot, out majorVersion, out minorVersion); - - majorVersion - .ShouldBeAnInt("Disk layout version should always be an int") - .ShouldEqual(WindowsDiskLayoutUpgradeTests.CurrentDiskLayoutMajorVersion, "Disk layout version should be upgraded to the latest"); - - minorVersion - .ShouldBeAnInt("Disk layout version should always be an int") - .ShouldEqual(WindowsDiskLayoutUpgradeTests.CurrentDiskLayoutMinorVersion, "Disk layout version should be upgraded to the latest"); - - string newBlobSizesRoot = Path.Combine(Path.GetDirectoryName(gitObjectsRoot), WindowsDiskLayoutUpgradeTests.BlobSizesCacheName); - GVFSHelpers.GetPersistedBlobSizesRoot(enlistment.DotGVFSRoot) - .ShouldEqual(newBlobSizesRoot); - - string blobSizesDbPath = Path.Combine(newBlobSizesRoot, WindowsDiskLayoutUpgradeTests.BlobSizesDBFileName); - newBlobSizesRoot.ShouldBeADirectory(this.fileSystem); - blobSizesDbPath.ShouldBeAFile(this.fileSystem); - - foreach (KeyValuePair entry in entries) - { - GVFSHelpers.SQLiteBlobSizesDatabaseHasEntry(blobSizesDbPath, entry.Key, entry.Value); - } - - // Upgrade a second repo, and make sure all sizes from both upgrades are in the shared database - - GVFSFunctionalTestEnlistment enlistment2 = this.CloneAndMountEnlistment(); - enlistment2.UnmountGVFS(); - - // Delete the existing repo metadata - versionJsonPath = Path.Combine(enlistment2.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - // Since there isn't a sparse-checkout file that is used anymore one needs to be added - // in order to test the old upgrades that might have needed it - string sparseCheckoutPath2 = Path.Combine(enlistment2.RepoRoot, TestConstants.DotGit.Info.SparseCheckoutPath); - this.fileSystem.WriteAllText(sparseCheckoutPath2, "/.gitattributes\r\n"); - - // "13.0" was the last version before blob sizes were moved out of Esent - metadataPath = Path.Combine(enlistment2.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - this.fileSystem.CreateEmptyFile(metadataPath); - GVFSHelpers.SaveDiskLayoutVersion(enlistment2.DotGVFSRoot, "13", "0"); - GVFSHelpers.SaveLocalCacheRoot(enlistment2.DotGVFSRoot, localCacheRoot); - GVFSHelpers.SaveGitObjectsRoot(enlistment2.DotGVFSRoot, gitObjectsRoot); - - // Create a legacy PersistedDictionary sizes database - List> additionalEntries = new List>() - { - new KeyValuePair(new string('4', 40), 16), - new KeyValuePair(new string('5', 40), 32), - new KeyValuePair(new string('6', 40), 64), - }; - - ESENTDatabase.CreateEsentBlobSizesDatabase(enlistment2.DotGVFSRoot, additionalEntries); - - enlistment2.MountGVFS(); - - foreach (KeyValuePair entry in entries) - { - GVFSHelpers.SQLiteBlobSizesDatabaseHasEntry(blobSizesDbPath, entry.Key, entry.Value); - } - - foreach (KeyValuePair entry in additionalEntries) - { - GVFSHelpers.SQLiteBlobSizesDatabaseHasEntry(blobSizesDbPath, entry.Key, entry.Value); - } - } - private GVFSFunctionalTestEnlistment CloneAndMountEnlistment(string branch = null) { return this.CreateNewEnlistment(this.localCachePath, branch); diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsDiskLayoutUpgradeTests.cs b/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsDiskLayoutUpgradeTests.cs index 328652458..a790516b6 100644 --- a/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsDiskLayoutUpgradeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsDiskLayoutUpgradeTests.cs @@ -1,7 +1,6 @@ using GVFS.FunctionalTests.Should; using GVFS.FunctionalTests.Tests.EnlistmentPerTestCase; using GVFS.FunctionalTests.Tools; -using GVFS.FunctionalTests.Windows.Tools; using GVFS.Tests.Should; using NUnit.Framework; using System; @@ -38,106 +37,15 @@ public override void CreateEnlistment() } [TestCase] - public void MountUpgradesFromVersion7() - { - // Seven to eight is a just a version change (non-breaking), but preserves ESENT RepoMetadata - this.RunEsentRepoMetadataUpgradeTest("7"); - } - - [TestCase] - public void MountUpgradesFromEsentToJsonRepoMetadata() - { - // Eight is the last version with ESENT RepoMetadata DB - this.RunEsentRepoMetadataUpgradeTest("8"); - } - - [TestCase] - public void MountUpgradesFromEsentDatabasesToFlatDatabases() - { - this.Enlistment.UnmountGVFS(); - - // Delete the existing background ops data - string flatBackgroundPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.BackgroundOpsFile); - flatBackgroundPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(flatBackgroundPath); - - // Delete the existing placeholder data - string placeholdersPath = Path.Combine(this.Enlistment.DotGVFSRoot, TestConstants.Databases.VFSForGit); - placeholdersPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(placeholdersPath); - - ESENTDatabase.CreateEsentBackgroundOpsDatabase(this.Enlistment.DotGVFSRoot); - ESENTDatabase.CreateEsentPlaceholderDatabase(this.Enlistment.DotGVFSRoot); - - // Nine is the last version with ESENT BackgroundOps and Placeholders DBs - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "9", "0"); - this.Enlistment.MountGVFS(); - - this.ValidatePersistedVersionMatchesCurrentVersion(); - - flatBackgroundPath.ShouldBeAFile(this.fileSystem); - placeholdersPath.ShouldBeAFile(this.fileSystem); - } - - [TestCase] - public void MountUpgradesFromPriorToPlaceholderCreationsBlockedForGit() - { - this.Enlistment.UnmountGVFS(); - - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "10", "0"); - - this.Enlistment.MountGVFS(); - - this.ValidatePersistedVersionMatchesCurrentVersion(); - } - - [TestCase] - public void MountFailsToUpgradeFromEsentVersion6ToJsonRepoMetadata() - { - this.Enlistment.UnmountGVFS(); - - // Delete the existing repo metadata - string versionJsonPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - ESENTDatabase.SaveDiskLayoutVersionAsEsentDatabase(this.Enlistment.DotGVFSRoot, "6"); - string esentDatabasePath = Path.Combine(this.Enlistment.DotGVFSRoot, ESENTDatabase.EsentRepoMetadataFolder); - esentDatabasePath.ShouldBeADirectory(this.fileSystem); - - this.Enlistment.TryMountGVFS().ShouldEqual(false, "Should not be able to upgrade from version 6"); - - esentDatabasePath.ShouldBeADirectory(this.fileSystem); - } - - [TestCase] - public void MountSetsGitObjectsRootToLegacyDotGVFSCache() + public void MountUpgradesFromMinimumSupportedVersion() { this.Enlistment.UnmountGVFS(); - // Delete the existing repo metadata - string versionJsonPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - // "11" was the last version before the introduction of a volume wide GVFS cache - string metadataPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - this.fileSystem.CreateEmptyFile(metadataPath); - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "11", "0"); - - // Create the legacy cache location: \.gvfs\gitObjectCache - string legacyGitObjectsCachePath = Path.Combine(this.Enlistment.DotGVFSRoot, "gitObjectCache"); - this.fileSystem.CreateDirectory(legacyGitObjectsCachePath); + GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "14", "0"); this.Enlistment.MountGVFS(); this.ValidatePersistedVersionMatchesCurrentVersion(); - - GVFSHelpers.GetPersistedLocalCacheRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(string.Empty, "LocalCacheRoot should be an empty string when upgrading from a version prior to 12"); - - GVFSHelpers.GetPersistedGitObjectsRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(legacyGitObjectsCachePath); } [TestCase] @@ -159,7 +67,7 @@ public void MountWritesFolderPlaceholdersToPlaceholderDatabase() placeholderDatabasePath, string.Join(Environment.NewLine, lines.Where(x => !x.EndsWith(TestConstants.PartialFolderPlaceholderDatabaseValue))) + Environment.NewLine); - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "12", "1"); + GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "15", "0"); this.Enlistment.MountGVFS(); this.Enlistment.UnmountGVFS(); @@ -200,65 +108,11 @@ public void MountUpdatesAllZeroShaFolderPlaceholderEntriesToPartialFolderSpecial this.ValidatePersistedVersionMatchesCurrentVersion(); } - [TestCase] - public void MountUpgradesPreSharedCacheLocalSizes() - { - this.Enlistment.UnmountGVFS(); - - // Delete the existing repo metadata - string versionJsonPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - // "11" was the last version before the introduction of a volume wide GVFS cache - string metadataPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - this.fileSystem.CreateEmptyFile(metadataPath); - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "11", "0"); - - // Create the legacy cache location: \.gvfs\gitObjectCache - string legacyGitObjectsCachePath = Path.Combine(this.Enlistment.DotGVFSRoot, "gitObjectCache"); - this.fileSystem.CreateDirectory(legacyGitObjectsCachePath); - - // Create a legacy PersistedDictionary sizes database - List> entries = new List>() - { - new KeyValuePair(new string('0', 40), 1), - new KeyValuePair(new string('1', 40), 2), - new KeyValuePair(new string('2', 40), 4), - new KeyValuePair(new string('3', 40), 8), - }; - - ESENTDatabase.CreateEsentBlobSizesDatabase(this.Enlistment.DotGVFSRoot, entries); - - this.Enlistment.MountGVFS(); - - this.ValidatePersistedVersionMatchesCurrentVersion(); - - GVFSHelpers.GetPersistedLocalCacheRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(string.Empty, "LocalCacheRoot should be an empty string when upgrading from a version prior to 12"); - - GVFSHelpers.GetPersistedGitObjectsRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(legacyGitObjectsCachePath); - - string newBlobSizesRoot = Path.Combine(this.Enlistment.DotGVFSRoot, DatabasesFolderName, BlobSizesCacheName); - GVFSHelpers.GetPersistedBlobSizesRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(newBlobSizesRoot); - - string blobSizesDbPath = Path.Combine(newBlobSizesRoot, BlobSizesDBFileName); - newBlobSizesRoot.ShouldBeADirectory(this.fileSystem); - blobSizesDbPath.ShouldBeAFile(this.fileSystem); - - foreach (KeyValuePair entry in entries) - { - GVFSHelpers.SQLiteBlobSizesDatabaseHasEntry(blobSizesDbPath, entry.Key, entry.Value); - } - } - [TestCase] public void MountCreatesModifiedPathsDatabase() { this.Enlistment.UnmountGVFS(); - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "14", "0"); + GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "15", "0"); // Delete the existing modified paths database to make sure mount creates it. string modifiedPathsDatabasePath = Path.Combine(this.Enlistment.DotGVFSRoot, TestConstants.Databases.ModifiedPaths); @@ -376,37 +230,5 @@ private string[] GetPlaceholderDatabaseLinesAfterUpgradeFrom16(string placeholde lines.ShouldContain(x => x == this.PartialFolderPlaceholderString("GVFS", "GVFS.Tests", "Properties")); return lines; } - - private void RunEsentRepoMetadataUpgradeTest(string sourceVersion) - { - this.Enlistment.UnmountGVFS(); - - // Delete the existing repo metadata - string versionJsonPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - ESENTDatabase.SaveDiskLayoutVersionAsEsentDatabase(this.Enlistment.DotGVFSRoot, sourceVersion); - string esentDatabasePath = Path.Combine(this.Enlistment.DotGVFSRoot, ESENTDatabase.EsentRepoMetadataFolder); - esentDatabasePath.ShouldBeADirectory(this.fileSystem); - - // We should be able to mount, and there should no longer be any Esent Repo Metadata - this.Enlistment.MountGVFS(); - esentDatabasePath.ShouldNotExistOnDisk(this.fileSystem); - versionJsonPath.ShouldBeAFile(this.fileSystem); - - this.ValidatePersistedVersionMatchesCurrentVersion(); - - GVFSHelpers.GetPersistedLocalCacheRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(string.Empty, "LocalCacheRoot should be an empty string when upgrading from a version prior to 12"); - - // We're starting with fresh enlisments, and so the legacy cache location: \.gvfs\gitObjectCache should not be on disk - Path.Combine(this.Enlistment.DotGVFSRoot, ".gvfs", "gitObjectCache").ShouldNotExistOnDisk(this.fileSystem); - - // The upgrader should set GitObjectsRoot to src\.git\objects (because the legacy cache location is not on disk) - GVFSHelpers.GetPersistedGitObjectsRoot(this.Enlistment.DotGVFSRoot) - .ShouldNotBeNull("GitObjectsRoot should not be null") - .ShouldEqual(Path.Combine(this.Enlistment.RepoRoot, ".git", "objects")); - } } } diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tools/ESENTDatabase.cs b/GVFS/GVFS.FunctionalTests/Windows/Tools/ESENTDatabase.cs deleted file mode 100644 index 31c04e0b5..000000000 --- a/GVFS/GVFS.FunctionalTests/Windows/Tools/ESENTDatabase.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Reflection; -using Microsoft.Isam.Esent.Collections.Generic; - -namespace GVFS.FunctionalTests.Windows.Tools -{ - public static class ESENTDatabase - { - public const string EsentRepoMetadataFolder = "RepoMetadata"; - public const string EsentBackgroundOpsFolder = "BackgroundGitUpdates"; - public const string EsentBlobSizesFolder = "BlobSizes"; - public const string EsentPlaceholderFolder = "PlaceholderList"; - - private const string DiskLayoutMajorVersionKey = "DiskLayoutVersion"; - - public static void SaveDiskLayoutVersionAsEsentDatabase(string dotGVFSRoot, string majorVersion) - { - string metadataPath = Path.Combine(dotGVFSRoot, EsentRepoMetadataFolder); - using (PersistentDictionary repoMetadata = new PersistentDictionary(metadataPath)) - { - repoMetadata[DiskLayoutMajorVersionKey] = majorVersion; - repoMetadata.Flush(); - } - } - - public static void CreateEsentPlaceholderDatabase(string dotGVFSRoot) - { - string metadataPath = Path.Combine(dotGVFSRoot, EsentPlaceholderFolder); - using (PersistentDictionary placeholders = new PersistentDictionary(metadataPath)) - { - placeholders["mock:\\path"] = new string('0', 40); - placeholders.Flush(); - } - } - - public static void CreateEsentBackgroundOpsDatabase(string dotGVFSRoot) - { - // Copies an ESENT DB with a single entry: - // Operation=6 (OnFirstWrite) Path=.gitattributes VirtualPath=.gitattributes Id=1 - string testDataPath = GetTestDataPath(EsentBackgroundOpsFolder); - string metadataPath = Path.Combine(dotGVFSRoot, EsentBackgroundOpsFolder); - Directory.CreateDirectory(metadataPath); - foreach (string filepath in Directory.EnumerateFiles(testDataPath)) - { - string filename = Path.GetFileName(filepath); - File.Copy(filepath, Path.Combine(metadataPath, filename)); - } - } - - public static void CreateEsentBlobSizesDatabase(string dotGVFSRoot, List> entries) - { - string metadataPath = Path.Combine(dotGVFSRoot, EsentBlobSizesFolder); - using (PersistentDictionary blobSizes = new PersistentDictionary(metadataPath)) - { - foreach (KeyValuePair entry in entries) - { - blobSizes[entry.Key] = entry.Value; - } - - blobSizes.Flush(); - } - } - - private static string GetTestDataPath(string fileName) - { - string workingDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - return Path.Combine(workingDirectory, "Windows", "TestData", fileName); - } - } -} diff --git a/GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj b/GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj deleted file mode 100644 index 44acd016c..000000000 --- a/GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - - net471 - - - - - - - diff --git a/GVFS/GVFS.GVFlt/GVFltCallbacks.cs b/GVFS/GVFS.GVFlt/GVFltCallbacks.cs deleted file mode 100644 index f841f12ec..000000000 --- a/GVFS/GVFS.GVFlt/GVFltCallbacks.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Newtonsoft.Json; -using System; - -namespace GVFS.GVFlt -{ - public class GVFltCallbacks - { - /// - /// This struct must remain here for DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased - /// - /// - /// This struct should only be used by the upgrader, it has been replaced by GVFS.Virtualization.Background.FileSystemTask - /// - [Serializable] - public struct BackgroundGitUpdate - { - // This enum must be present or the BinarySerializer will always deserialze Operation as 0 - public enum OperationType - { - Invalid = 0, - } - - public OperationType Operation { get; set; } - public string VirtualPath { get; set; } - public string OldVirtualPath { get; set; } - - // Used by the logging in the upgrader - public override string ToString() - { - return JsonConvert.SerializeObject(this); - } - } - } -} diff --git a/GVFS/GVFS.Payload/GVFS.Payload.csproj b/GVFS/GVFS.Payload/GVFS.Payload.csproj index e7bc79415..49fe4a551 100644 --- a/GVFS/GVFS.Payload/GVFS.Payload.csproj +++ b/GVFS/GVFS.Payload/GVFS.Payload.csproj @@ -41,7 +41,6 @@ $(OutputPath)\GitHooksLoader.exe; $(OutputPath)\GVFS.Common.dll; $(OutputPath)\GVFS.exe; - $(OutputPath)\GVFS.GVFlt.dll; $(OutputPath)\GVFS.Hooks.exe; $(OutputPath)\GVFS.Mount.exe; $(OutputPath)\GVFS.Platform.Windows.dll; diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout10to11Upgrade_NewOperationType.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout10to11Upgrade_NewOperationType.cs deleted file mode 100644 index aa574ea8c..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout10to11Upgrade_NewOperationType.cs +++ /dev/null @@ -1,27 +0,0 @@ -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout10to11Upgrade_NewOperationType : DiskLayoutUpgrade.MajorUpgrade - { - protected override int SourceMajorVersion - { - get { return 10; } - } - - /// - /// Version 10 to 11 only added a new value to BackgroundGitUpdate.OperationType, - /// so we only need to bump the disk layout version version here. - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - } -} diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout11to12Upgrade_SharedLocalCache.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout11to12Upgrade_SharedLocalCache.cs deleted file mode 100644 index d5f31218f..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout11to12Upgrade_SharedLocalCache.cs +++ /dev/null @@ -1,63 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using System.IO; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout11to12Upgrade_SharedLocalCache : DiskLayoutUpgrade.MajorUpgrade - { - protected override int SourceMajorVersion - { - get { return 11; } - } - - /// - /// Version 11 to 12 added the shared local git objects cache. - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSPath = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - string error; - if (!RepoMetadata.TryInitialize(tracer, dotGVFSPath, out error)) - { - tracer.RelatedError(nameof(this.TryUpgradeGitObjectPath) + ": Could not initialize repo metadata: " + error); - return false; - } - - if (!this.TryUpgradeGitObjectPath(tracer, enlistmentRoot)) - { - return false; - } - - RepoMetadata.Instance.SetLocalCacheRoot(string.Empty); - tracer.RelatedInfo("Set LocalCacheRoot to string.Empty"); - - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - - private bool TryUpgradeGitObjectPath(ITracer tracer, string enlistmentRoot) - { - string gitObjectsRoot; - string legacyDotGVFSGitObjectCachePath = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, "gitObjectCache"); - if (Directory.Exists(legacyDotGVFSGitObjectCachePath)) - { - gitObjectsRoot = legacyDotGVFSGitObjectCachePath; - } - else - { - // Old version prior to \.gvfs\gitObjectCache cache - gitObjectsRoot = Path.Combine(enlistmentRoot, GVFSConstants.WorkingDirectoryRootName, GVFSConstants.DotGit.Objects.Root); - } - - RepoMetadata.Instance.SetGitObjectsRoot(gitObjectsRoot); - tracer.RelatedInfo("Set GitObjectsRoot: " + gitObjectsRoot); - return true; - } - } -} \ No newline at end of file diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12_0To12_1Upgrade_StatusAheadBehind.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12_0To12_1Upgrade_StatusAheadBehind.cs deleted file mode 100644 index 2f90d8a43..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12_0To12_1Upgrade_StatusAheadBehind.cs +++ /dev/null @@ -1,35 +0,0 @@ -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using System.Collections.Generic; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout12_0To12_1Upgrade_StatusAheadBehind : DiskLayoutUpgrade.MinorUpgrade - { - protected override int SourceMajorVersion - { - get { return 12; } - } - - protected override int SourceMinorVersion - { - get { return 0; } - } - - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - if (!this.TrySetGitConfig( - tracer, - enlistmentRoot, - new Dictionary - { - { "status.aheadbehind", "false" }, - })) - { - return false; - } - - return this.TryIncrementMinorVersion(tracer, enlistmentRoot); - } - } -} diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12to13Upgrade_FolderPlaceholder.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12to13Upgrade_FolderPlaceholder.cs deleted file mode 100644 index 496363404..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12to13Upgrade_FolderPlaceholder.cs +++ /dev/null @@ -1,123 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Database; -using GVFS.Common.FileSystem; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using Microsoft.Windows.ProjFS; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout12to13Upgrade_FolderPlaceholder : DiskLayoutUpgrade.MajorUpgrade - { - protected override int SourceMajorVersion - { - get { return 12; } - } - - /// - /// Adds the folder placeholders to the placeholders list - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSRoot = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - try - { - string error; - LegacyPlaceholderListDatabase placeholders; - if (!LegacyPlaceholderListDatabase.TryCreate( - tracer, - Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.Databases.PlaceholderList), - new PhysicalFileSystem(), - out placeholders, - out error)) - { - tracer.RelatedError("Failed to open placeholder database: " + error); - return false; - } - - using (placeholders) - { - string workingDirectoryRoot = Path.Combine(enlistmentRoot, GVFSConstants.WorkingDirectoryRootName); - - // Run through the folder placeholders adding to the placeholder list - IEnumerable folderPlaceholderPaths = - GetFolderPlaceholdersFromDisk(tracer, new PhysicalFileSystem(), workingDirectoryRoot) - .Select(x => x.Substring(workingDirectoryRoot.Length + 1)) - .Select(x => new LegacyPlaceholderListDatabase.PlaceholderData(x, GVFSConstants.AllZeroSha)); - - List placeholderEntries = placeholders.GetAllEntries(); - placeholderEntries.AddRange(folderPlaceholderPaths); - - placeholders.WriteAllEntriesAndFlush(placeholderEntries); - } - } - catch (IOException ex) - { - tracer.RelatedError("Could not write to placeholder database: " + ex.ToString()); - return false; - } - catch (Exception ex) - { - tracer.RelatedError("Error updating placeholder database with folders: " + ex.ToString()); - return false; - } - - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - - private static IEnumerable GetFolderPlaceholdersFromDisk(ITracer tracer, PhysicalFileSystem fileSystem, string path) - { - if (!fileSystem.IsSymLink(path)) - { - foreach (string directory in fileSystem.EnumerateDirectories(path)) - { - if (!directory.EndsWith(Path.DirectorySeparatorChar + GVFSConstants.DotGit.Root)) - { - OnDiskFileState fileState = OnDiskFileState.Full; - if (Utils.TryGetOnDiskFileState(directory, out fileState)) - { - if (IsPlaceholder(fileState)) - { - yield return directory; - } - - // Recurse into placeholders and full folders skipping the tombstones - if (!IsTombstone(fileState)) - { - foreach (string placeholderPath in GetFolderPlaceholdersFromDisk(tracer, fileSystem, directory)) - { - yield return placeholderPath; - } - } - } - else - { - // May cause valid folder placeholders not to be written - // to the placeholder database so we want to error out. - throw new InvalidDataException($"Error getting on disk file state for {directory}"); - } - } - } - } - } - - private static bool IsTombstone(OnDiskFileState fileState) - { - return (fileState & OnDiskFileState.Tombstone) != 0; - } - - private static bool IsPlaceholder(OnDiskFileState fileState) - { - return (fileState & (OnDiskFileState.DirtyPlaceholder | OnDiskFileState.HydratedPlaceholder | OnDiskFileState.Placeholder)) != 0; - } - } -} diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout13to14Upgrade_BlobSizes.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout13to14Upgrade_BlobSizes.cs deleted file mode 100644 index f784bcc87..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout13to14Upgrade_BlobSizes.cs +++ /dev/null @@ -1,139 +0,0 @@ -using GVFS.Common; -using GVFS.Common.FileSystem; -using GVFS.Common.Git; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using GVFS.Virtualization.BlobSize; -using Microsoft.Isam.Esent; -using Microsoft.Isam.Esent.Collections.Generic; -using System.Collections.Generic; -using System.IO; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout13to14Upgrade_BlobSizes : DiskLayoutUpgrade.MajorUpgrade - { - private static readonly string BlobSizesName = "BlobSizes"; - - protected override int SourceMajorVersion - { - get { return 13; } - } - - /// - /// Version 13 to 14 added the (shared) SQLite blob sizes database - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSPath = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - string error; - if (!RepoMetadata.TryInitialize(tracer, dotGVFSPath, out error)) - { - tracer.RelatedError($"{nameof(DiskLayout13to14Upgrade_BlobSizes)}.{nameof(this.TryUpgrade)}: Could not initialize repo metadata: {error}"); - return false; - } - - string newBlobSizesRoot; - if (!this.TryFindNewBlobSizesRoot(tracer, enlistmentRoot, out newBlobSizesRoot)) - { - return false; - } - - this.MigrateBlobSizes(tracer, enlistmentRoot, newBlobSizesRoot); - - RepoMetadata.Instance.SetBlobSizesRoot(newBlobSizesRoot); - tracer.RelatedInfo("Set BlobSizesRoot: " + newBlobSizesRoot); - - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - - private bool TryFindNewBlobSizesRoot(ITracer tracer, string enlistmentRoot, out string newBlobSizesRoot) - { - newBlobSizesRoot = null; - - string localCacheRoot; - string error; - if (!RepoMetadata.Instance.TryGetLocalCacheRoot(out localCacheRoot, out error)) - { - tracer.RelatedError($"{nameof(DiskLayout13to14Upgrade_BlobSizes)}.{nameof(this.TryFindNewBlobSizesRoot)}: Could not read local cache root from repo metadata: {error}"); - return false; - } - - if (localCacheRoot == string.Empty) - { - // This is an old repo that was cloned prior to the shared cache - // Blob sizes root should be \.gvfs\databases\blobSizes - newBlobSizesRoot = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.Name, GVFSEnlistment.BlobSizesCacheName); - } - else - { - // This repo was cloned with a shared cache, and the blob sizes should be a sibling to the git objects root - string gitObjectsRoot; - if (!RepoMetadata.Instance.TryGetGitObjectsRoot(out gitObjectsRoot, out error)) - { - tracer.RelatedError($"{nameof(DiskLayout13to14Upgrade_BlobSizes)}.{nameof(this.TryFindNewBlobSizesRoot)}: Could not read git object root from repo metadata: {error}"); - return false; - } - - string cacheRepoFolder = Path.GetDirectoryName(gitObjectsRoot); - newBlobSizesRoot = Path.Combine(cacheRepoFolder, GVFSEnlistment.BlobSizesCacheName); - } - - return true; - } - - private void MigrateBlobSizes(ITracer tracer, string enlistmentRoot, string newBlobSizesRoot) - { - string esentBlobSizeFolder = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, BlobSizesName); - PhysicalFileSystem fileSystem = new PhysicalFileSystem(); - if (!fileSystem.DirectoryExists(esentBlobSizeFolder)) - { - tracer.RelatedInfo("Copied no ESENT blob size entries. {0} does not exist", esentBlobSizeFolder); - return; - } - - try - { - using (PersistentDictionary oldBlobSizes = new PersistentDictionary(esentBlobSizeFolder)) - using (BlobSizes newBlobSizes = new BlobSizes(newBlobSizesRoot, fileSystem, tracer)) - { - newBlobSizes.Initialize(); - - int copiedCount = 0; - int totalCount = oldBlobSizes.Count; - foreach (KeyValuePair kvp in oldBlobSizes) - { - Sha1Id sha1; - string error; - if (Sha1Id.TryParse(kvp.Key, out sha1, out error)) - { - newBlobSizes.AddSize(sha1, kvp.Value); - - if (copiedCount++ % 5000 == 0) - { - tracer.RelatedInfo("Copied {0}/{1} ESENT blob size entries", copiedCount, totalCount); - } - } - else - { - tracer.RelatedWarning($"Corrupt entry ({kvp.Key}) found in BlobSizes, skipping. Error: {error}"); - } - } - - newBlobSizes.Flush(); - newBlobSizes.Shutdown(); - tracer.RelatedInfo("Upgrade complete: Copied {0}/{1} ESENT blob size entries", copiedCount, totalCount); - } - } - catch (EsentException ex) - { - tracer.RelatedWarning("BlobSizes appears to be from an older version of GVFS and corrupted, skipping upgrade of blob sizes: " + ex.Message); - } - } - } -} diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout7to8Upgrade_NewOperationType.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout7to8Upgrade_NewOperationType.cs deleted file mode 100644 index d4edabd4e..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout7to8Upgrade_NewOperationType.cs +++ /dev/null @@ -1,42 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using Microsoft.Isam.Esent; -using Microsoft.Isam.Esent.Collections.Generic; -using System.IO; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout7to8Upgrade_NewOperationType : DiskLayoutUpgrade.MajorUpgrade - { - protected override int SourceMajorVersion - { - get { return 7; } - } - - /// - /// Version 7 to 8 only added a new value to BackgroundGitUpdate.OperationType, - /// so we only need to bump the ESENT version here. - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSRoot = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - string esentRepoMetadata = Path.Combine(dotGVFSRoot, WindowsDiskLayoutUpgradeData.EsentRepoMetadataName); - try - { - using (PersistentDictionary esentMetadata = new PersistentDictionary(esentRepoMetadata)) - { - esentMetadata[WindowsDiskLayoutUpgradeData.DiskLayoutEsentVersionKey] = "8"; - } - } - catch (EsentException ex) - { - tracer.RelatedError("RepoMetadata appears to be from an older version of GVFS and corrupted: " + ex.Message); - return false; - } - - // Do not call TryIncrementDiskLayoutVersion. It updates the flat repo metadata which does not exist yet. - return true; - } - } -} \ No newline at end of file diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout8to9Upgrade_RepoMetadataToJson.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout8to9Upgrade_RepoMetadataToJson.cs deleted file mode 100644 index b86fab6b9..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout8to9Upgrade_RepoMetadataToJson.cs +++ /dev/null @@ -1,89 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using Microsoft.Isam.Esent; -using Microsoft.Isam.Esent.Collections.Generic; -using System.Collections.Generic; -using System.IO; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout8to9Upgrade_RepoMetadataToJson : DiskLayoutUpgrade.MajorUpgrade - { - protected override int SourceMajorVersion - { - get { return 8; } - } - - /// - /// Rewrites ESENT RepoMetadata DB to flat JSON file - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSRoot = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - if (!this.UpdateRepoMetadata(tracer, dotGVFSRoot)) - { - return false; - } - - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - - private bool UpdateRepoMetadata(ITracer tracer, string dotGVFSRoot) - { - string esentRepoMetadata = Path.Combine(dotGVFSRoot, WindowsDiskLayoutUpgradeData.EsentRepoMetadataName); - if (Directory.Exists(esentRepoMetadata)) - { - try - { - using (PersistentDictionary oldMetadata = new PersistentDictionary(esentRepoMetadata)) - { - string error; - if (!RepoMetadata.TryInitialize(tracer, dotGVFSRoot, out error)) - { - tracer.RelatedError("Could not initialize RepoMetadata: " + error); - return false; - } - - foreach (KeyValuePair kvp in oldMetadata) - { - tracer.RelatedInfo("Copying ESENT entry: {0} = {1}", kvp.Key, kvp.Value); - RepoMetadata.Instance.SetEntry(kvp.Key, kvp.Value); - } - } - } - catch (IOException ex) - { - tracer.RelatedError("Could not write to new repo metadata: " + ex.Message); - return false; - } - catch (EsentException ex) - { - tracer.RelatedError("RepoMetadata appears to be from an older version of GVFS and corrupted: " + ex.Message); - return false; - } - - string backupName; - if (this.TryRenameFolderForDelete(tracer, esentRepoMetadata, out backupName)) - { - // If this fails, we leave behind cruft, but there's no harm because we renamed. - this.TryDeleteFolder(tracer, backupName); - return true; - } - else - { - // To avoid double upgrading, we should rollback if we can't rename the old data - this.TryDeleteFile(tracer, RepoMetadata.Instance.DataFilePath); - return false; - } - } - - return true; - } - } -} \ No newline at end of file diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased.cs deleted file mode 100644 index 9b0b84b12..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased.cs +++ /dev/null @@ -1,181 +0,0 @@ -using GVFS.Common; -using GVFS.Common.FileSystem; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using GVFS.GVFlt; -using GVFS.Virtualization.Background; -using Microsoft.Isam.Esent; -using Microsoft.Isam.Esent.Collections.Generic; -using System.Collections.Generic; -using System.IO; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased : DiskLayoutUpgrade.MajorUpgrade - { - private const string EsentBackgroundOpsFolder = "BackgroundGitUpdates"; - private const string EsentPlaceholderListFolder = "PlaceholderList"; - - protected override int SourceMajorVersion - { - get { return 9; } - } - - /// - /// Rewrites ESENT BackgroundGitUpdates and PlaceholderList DBs to flat formats - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSRoot = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - if (!this.UpdateBackgroundOperations(tracer, dotGVFSRoot)) - { - return false; - } - - if (!this.UpdatePlaceholderList(tracer, dotGVFSRoot)) - { - return false; - } - - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - - private bool UpdatePlaceholderList(ITracer tracer, string dotGVFSRoot) - { - string esentPlaceholderFolder = Path.Combine(dotGVFSRoot, EsentPlaceholderListFolder); - if (Directory.Exists(esentPlaceholderFolder)) - { - string newPlaceholderFolder = Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.Databases.PlaceholderList); - try - { - using (PersistentDictionary oldPlaceholders = - new PersistentDictionary(esentPlaceholderFolder)) - { - string error; - LegacyPlaceholderListDatabase newPlaceholders; - if (!LegacyPlaceholderListDatabase.TryCreate( - tracer, - newPlaceholderFolder, - new PhysicalFileSystem(), - out newPlaceholders, - out error)) - { - tracer.RelatedError("Failed to create new placeholder database: " + error); - return false; - } - - using (newPlaceholders) - { - List data = new List(); - foreach (KeyValuePair kvp in oldPlaceholders) - { - tracer.RelatedInfo("Copying ESENT entry: {0} = {1}", kvp.Key, kvp.Value); - data.Add(new LegacyPlaceholderListDatabase.PlaceholderData(path: kvp.Key, fileShaOrFolderValue: kvp.Value)); - } - - newPlaceholders.WriteAllEntriesAndFlush(data); - } - } - } - catch (IOException ex) - { - tracer.RelatedError("Could not write to new placeholder database: " + ex.Message); - return false; - } - catch (EsentException ex) - { - tracer.RelatedError("Placeholder database appears to be from an older version of GVFS and corrupted: " + ex.Message); - return false; - } - - string backupName; - if (this.TryRenameFolderForDelete(tracer, esentPlaceholderFolder, out backupName)) - { - // If this fails, we leave behind cruft, but there's no harm because we renamed. - this.TryDeleteFolder(tracer, backupName); - return true; - } - else - { - // To avoid double upgrading, we should rollback if we can't rename the old data - this.TryDeleteFile(tracer, RepoMetadata.Instance.DataFilePath); - return false; - } - } - - return true; - } - - private bool UpdateBackgroundOperations(ITracer tracer, string dotGVFSRoot) - { - string esentBackgroundOpsFolder = Path.Combine(dotGVFSRoot, EsentBackgroundOpsFolder); - if (Directory.Exists(esentBackgroundOpsFolder)) - { - string newBackgroundOpsFolder = Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.Databases.BackgroundFileSystemTasks); - try - { - using (PersistentDictionary oldBackgroundOps = - new PersistentDictionary(esentBackgroundOpsFolder)) - { - string error; - FileSystemTaskQueue newBackgroundOps; - if (!FileSystemTaskQueue.TryCreate( - tracer, - newBackgroundOpsFolder, - new PhysicalFileSystem(), - out newBackgroundOps, - out error)) - { - tracer.RelatedError("Failed to create new background operations folder: " + error); - return false; - } - - using (newBackgroundOps) - { - foreach (KeyValuePair kvp in oldBackgroundOps) - { - tracer.RelatedInfo("Copying ESENT entry: {0} = {1}", kvp.Key, kvp.Value); - newBackgroundOps.EnqueueAndFlush( - new FileSystemTask( - (FileSystemTask.OperationType)kvp.Value.Operation, - kvp.Value.VirtualPath, - kvp.Value.OldVirtualPath)); - } - } - } - } - catch (IOException ex) - { - tracer.RelatedError("Could not write to new background operations: " + ex.Message); - return false; - } - catch (EsentException ex) - { - tracer.RelatedError("BackgroundOperations appears to be from an older version of GVFS and corrupted: " + ex.Message); - return false; - } - - string backupName; - if (this.TryRenameFolderForDelete(tracer, esentBackgroundOpsFolder, out backupName)) - { - // If this fails, we leave behind cruft, but there's no harm because we renamed. - this.TryDeleteFolder(tracer, backupName); - return true; - } - else - { - // To avoid double upgrading, we should rollback if we can't rename the old data - this.TryDeleteFile(tracer, RepoMetadata.Instance.DataFilePath); - return false; - } - } - - return true; - } - } -} diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/WindowsDiskLayoutUpgradeData.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/WindowsDiskLayoutUpgradeData.cs index d79e29594..6fdab6c91 100644 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/WindowsDiskLayoutUpgradeData.cs +++ b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/WindowsDiskLayoutUpgradeData.cs @@ -1,30 +1,16 @@ using GVFS.Common; using GVFS.DiskLayoutUpgrades; -using Microsoft.Isam.Esent.Collections.Generic; -using System; -using System.IO; namespace GVFS.Platform.Windows.DiskLayoutUpgrades { public class WindowsDiskLayoutUpgradeData : IDiskLayoutUpgradeData { - public const string DiskLayoutEsentVersionKey = "DiskLayoutVersion"; - public const string EsentRepoMetadataName = "RepoMetadata"; - public DiskLayoutUpgrade[] Upgrades { get { return new DiskLayoutUpgrade[] { - new DiskLayout7to8Upgrade_NewOperationType(), - new DiskLayout8to9Upgrade_RepoMetadataToJson(), - new DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased(), - new DiskLayout10to11Upgrade_NewOperationType(), - new DiskLayout11to12Upgrade_SharedLocalCache(), - new DiskLayout12_0To12_1Upgrade_StatusAheadBehind(), - new DiskLayout12to13Upgrade_FolderPlaceholder(), - new DiskLayout13to14Upgrade_BlobSizes(), new DiskLayout14to15Upgrade_ModifiedPaths(), new DiskLayout15to16Upgrade_GitStatusCache(), new DiskLayout16to17Upgrade_FolderPlaceholderValues(), @@ -37,36 +23,12 @@ public DiskLayoutUpgrade[] Upgrades public DiskLayoutVersion Version => new DiskLayoutVersion( currentMajorVersion: 19, currentMinorVersion: 0, - minimumSupportedMajorVersion: 7); + minimumSupportedMajorVersion: 14); public bool TryParseLegacyDiskLayoutVersion(string dotGVFSPath, out int majorVersion) { - string repoMetadataPath = Path.Combine(dotGVFSPath, EsentRepoMetadataName); majorVersion = 0; - if (Directory.Exists(repoMetadataPath)) - { - try - { - using (PersistentDictionary oldMetadata = new PersistentDictionary(repoMetadataPath)) - { - string versionString = oldMetadata[DiskLayoutEsentVersionKey]; - if (!int.TryParse(versionString, out majorVersion)) - { - return false; - } - } - } - catch - { - return false; - } - } - else - { - return false; - } - - return true; + return false; } } } diff --git a/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj b/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj index 1bf325061..0ffe30b8f 100644 --- a/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj +++ b/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj @@ -5,14 +5,10 @@ - - - - From 6707c82c8322637bdd609029ab36457f3cc9d238 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 8 Apr 2026 16:16:02 -0700 Subject: [PATCH 04/25] Remove GVFS.Service.UI (dead code) The Service.UI process displayed Windows toast notifications, but only the MountFailure notification was actively sent, and it provided no useful value. Removing eliminates XmlSerializer and WinRT COM interop dependencies that would block NativeAOT compilation. Removes: project, unit tests, installer integration, service launch logic, notification handler, diagnostics log collection, and the Microsoft.Windows.SDK.Contracts package. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- Directory.Packages.props | 1 - GVFS.sln | 6 - GVFS/GVFS.Common/GVFSConstants.cs | 2 - GVFS/GVFS.Installers/Setup.iss | 39 ---- GVFS/GVFS.Payload/GVFS.Payload.csproj | 2 - GVFS/GVFS.Payload/layout.bat | 1 - GVFS/GVFS.Service.UI/Data/ActionItem.cs | 17 -- GVFS/GVFS.Service.UI/Data/ActionsData.cs | 10 - GVFS/GVFS.Service.UI/Data/BindingData.cs | 13 -- GVFS/GVFS.Service.UI/Data/BindingItem.cs | 37 ---- GVFS/GVFS.Service.UI/Data/ToastData.cs | 20 -- GVFS/GVFS.Service.UI/Data/VisualData.cs | 10 - GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj | 22 -- GVFS/GVFS.Service.UI/GVFSServiceUI.cs | 56 ----- .../GVFSToastRequestHandler.cs | 193 ------------------ GVFS/GVFS.Service.UI/IToastNotifier.cs | 10 - GVFS/GVFS.Service.UI/Program.cs | 46 ----- GVFS/GVFS.Service.UI/WinToastNotifier.cs | 103 ---------- GVFS/GVFS.Service.UI/XmlList.cs | 32 --- GVFS/GVFS.Service/Configuration.cs | 2 - GVFS/GVFS.Service/GVFS.Service.csproj | 1 - GVFS/GVFS.Service/GVFSService.Windows.cs | 52 ----- .../Handlers/NotificationHandler.cs | 33 +-- GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj | 1 - .../ServiceUI/GVFSToastRequestHandlerTests.cs | 111 ---------- GVFS/GVFS/CommandLine/DiagnoseVerb.cs | 7 - 26 files changed, 1 insertion(+), 826 deletions(-) delete mode 100644 GVFS/GVFS.Service.UI/Data/ActionItem.cs delete mode 100644 GVFS/GVFS.Service.UI/Data/ActionsData.cs delete mode 100644 GVFS/GVFS.Service.UI/Data/BindingData.cs delete mode 100644 GVFS/GVFS.Service.UI/Data/BindingItem.cs delete mode 100644 GVFS/GVFS.Service.UI/Data/ToastData.cs delete mode 100644 GVFS/GVFS.Service.UI/Data/VisualData.cs delete mode 100644 GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj delete mode 100644 GVFS/GVFS.Service.UI/GVFSServiceUI.cs delete mode 100644 GVFS/GVFS.Service.UI/GVFSToastRequestHandler.cs delete mode 100644 GVFS/GVFS.Service.UI/IToastNotifier.cs delete mode 100644 GVFS/GVFS.Service.UI/Program.cs delete mode 100644 GVFS/GVFS.Service.UI/WinToastNotifier.cs delete mode 100644 GVFS/GVFS.Service.UI/XmlList.cs delete mode 100644 GVFS/GVFS.UnitTests/Windows/ServiceUI/GVFSToastRequestHandlerTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 744acfb94..93ce7e671 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,7 +25,6 @@ - diff --git a/GVFS.sln b/GVFS.sln index f4847cd29..a3dc2e111 100644 --- a/GVFS.sln +++ b/GVFS.sln @@ -31,8 +31,6 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "GVFS.ReadObjectHook", "GVFS EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Service", "GVFS\GVFS.Service\GVFS.Service.csproj", "{5E236AF3-31D7-4313-A129-F080FF058283}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Service.UI", "GVFS\GVFS.Service.UI\GVFS.Service.UI.csproj", "{D8FB16E2-EAE0-4E05-A993-940062CD7CA7}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Tests", "GVFS\GVFS.Tests\GVFS.Tests.csproj", "{FE70E0D6-B0A6-421D-AA12-F28F822F09A0}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.UnitTests", "GVFS\GVFS.UnitTests\GVFS.UnitTests.csproj", "{1A46C414-7F39-4EF0-B216-A88033D18678}" @@ -109,10 +107,6 @@ Global {5E236AF3-31D7-4313-A129-F080FF058283}.Debug|x64.Build.0 = Debug|Any CPU {5E236AF3-31D7-4313-A129-F080FF058283}.Release|x64.ActiveCfg = Release|Any CPU {5E236AF3-31D7-4313-A129-F080FF058283}.Release|x64.Build.0 = Release|Any CPU - {D8FB16E2-EAE0-4E05-A993-940062CD7CA7}.Debug|x64.ActiveCfg = Debug|Any CPU - {D8FB16E2-EAE0-4E05-A993-940062CD7CA7}.Debug|x64.Build.0 = Debug|Any CPU - {D8FB16E2-EAE0-4E05-A993-940062CD7CA7}.Release|x64.ActiveCfg = Release|Any CPU - {D8FB16E2-EAE0-4E05-A993-940062CD7CA7}.Release|x64.Build.0 = Release|Any CPU {FE70E0D6-B0A6-421D-AA12-F28F822F09A0}.Debug|x64.ActiveCfg = Debug|Any CPU {FE70E0D6-B0A6-421D-AA12-F28F822F09A0}.Debug|x64.Build.0 = Debug|Any CPU {FE70E0D6-B0A6-421D-AA12-F28F822F09A0}.Release|x64.ActiveCfg = Release|Any CPU diff --git a/GVFS/GVFS.Common/GVFSConstants.cs b/GVFS/GVFS.Common/GVFSConstants.cs index 24374b26a..e81ecc635 100644 --- a/GVFS/GVFS.Common/GVFSConstants.cs +++ b/GVFS/GVFS.Common/GVFSConstants.cs @@ -68,7 +68,6 @@ public static class Service { public const string ServiceName = "GVFS.Service"; public const string LogDirectory = "Logs"; - public const string UIName = "GVFS.Service.UI"; } public static class MediaTypes @@ -108,7 +107,6 @@ public static class LogFileTypes public const string Prefetch = "prefetch"; public const string Repair = "repair"; public const string Service = "service"; - public const string ServiceUI = "service_ui"; public const string Sparse = "sparse"; public const string UpgradeVerb = UpgradePrefix + "_verb"; public const string UpgradeProcess = UpgradePrefix + "_process"; diff --git a/GVFS/GVFS.Installers/Setup.iss b/GVFS/GVFS.Installers/Setup.iss index 886da1042..f8a166d75 100644 --- a/GVFS/GVFS.Installers/Setup.iss +++ b/GVFS/GVFS.Installers/Setup.iss @@ -15,7 +15,6 @@ #define GVFSConfigFileName "gvfs.config" #define GVFSStatuscacheTokenFileName "EnableGitStatusCacheToken.dat" #define ServiceName "GVFS.Service" -#define ServiceUIName "VFS For Git" [Setup] AppId={{489CA581-F131-4C28-BE04-4FB178933E6D} @@ -66,9 +65,6 @@ DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; [Dirs] Name: "{app}\ProgramData\{#ServiceName}"; Permissions: users-readexec -[Icons] -Name: "{commonstartmenu}\{#ServiceUIName}"; Filename: "{app}\GVFS.Service.UI.exe"; AppUserModelID: "GVFS" - [UninstallDelete] ; Deletes the entire installation directory, including files and subdirectories Type: filesandordirs; Name: "{app}"; @@ -249,38 +245,6 @@ begin end; end; -procedure StartGVFSServiceUI(); -var - ResultCode: integer; -begin - if GetEnv('GVFS_UNATTENDED') = '1' then - begin - Log('StartGVFSServiceUI: Skipping launching GVFS.Service.UI'); - end - else if ExecAsOriginalUser(ExpandConstant('{app}\GVFS.Service.UI.exe'), '', '', SW_HIDE, ewNoWait, ResultCode) then - begin - Log('StartGVFSServiceUI: Successfully launched GVFS.Service.UI'); - end - else - begin - Log('StartGVFSServiceUI: Failed to launch GVFS.Service.UI'); - end; -end; - -procedure StopGVFSServiceUI(); -var - ResultCode: integer; -begin - if Exec('powershell.exe', '-NoProfile "Stop-Process -Name GVFS.Service.UI"', '', SW_HIDE, ewNoWait, ResultCode) then - begin - Log('StopGVFSServiceUI: Successfully stopped GVFS.Service.UI'); - end - else - begin - RaiseException('Fatal: Could not stop process: GVFS.Service.UI'); - end; -end; - function DeleteFileIfItExists(FilePath: string) : Boolean; begin Result := False; @@ -688,7 +652,6 @@ begin ssPostInstall: begin MigrateConfigAndStatusCacheFiles(); - StartGVFSServiceUI(); if ExpandConstant('{param:REMOUNTREPOS|true}') = 'true' then begin MountRepos(); @@ -707,7 +670,6 @@ begin case CurStep of usUninstall: begin - StopGVFSServiceUI(); UninstallService('GVFS.Service', False); RemovePath(ExpandConstant('{app}')); end; @@ -731,7 +693,6 @@ begin Abort(); end; StopService('GVFS.Service'); - StopGVFSServiceUI(); UninstallGvFlt(); UninstallProjFSIfNecessary(); end; diff --git a/GVFS/GVFS.Payload/GVFS.Payload.csproj b/GVFS/GVFS.Payload/GVFS.Payload.csproj index 49fe4a551..9787bedb0 100644 --- a/GVFS/GVFS.Payload/GVFS.Payload.csproj +++ b/GVFS/GVFS.Payload/GVFS.Payload.csproj @@ -22,7 +22,6 @@ - @@ -47,7 +46,6 @@ $(OutputPath)\GVFS.PostIndexChangedHook.exe; $(OutputPath)\GVFS.ReadObjectHook.exe; $(OutputPath)\GVFS.Service.exe; - $(OutputPath)\GVFS.Service.UI.exe; $(OutputPath)\GVFS.VirtualFileSystemHook.exe; $(OutputPath)\GVFS.Virtualization.dll;"> Microsoft400 diff --git a/GVFS/GVFS.Payload/layout.bat b/GVFS/GVFS.Payload/layout.bat index ebdae19c2..70a8de57b 100644 --- a/GVFS/GVFS.Payload/layout.bat +++ b/GVFS/GVFS.Payload/layout.bat @@ -54,7 +54,6 @@ xcopy /Y /S %BUILD_OUT%\GVFS\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.Hooks\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.Mount\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.Service\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% -xcopy /Y /S %BUILD_OUT%\GVFS.Service.UI\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GitHooksLoader\%NATIVE_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.PostIndexChangedHook\%NATIVE_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.ReadObjectHook\%NATIVE_OUT_FRAGMENT%\* %OUTPUT% diff --git a/GVFS/GVFS.Service.UI/Data/ActionItem.cs b/GVFS/GVFS.Service.UI/Data/ActionItem.cs deleted file mode 100644 index 1034ea0aa..000000000 --- a/GVFS/GVFS.Service.UI/Data/ActionItem.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - [XmlRoot("action")] - public class ActionItem - { - [XmlAttribute("content")] - public string Content { get; set; } - - [XmlAttribute("arguments")] - public string Arguments { get; set; } - - [XmlAttribute("activationtype")] - public string ActivationType { get; set; } - } -} \ No newline at end of file diff --git a/GVFS/GVFS.Service.UI/Data/ActionsData.cs b/GVFS/GVFS.Service.UI/Data/ActionsData.cs deleted file mode 100644 index 56b92af81..000000000 --- a/GVFS/GVFS.Service.UI/Data/ActionsData.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - public class ActionsData - { - [XmlAnyElement("actions")] - public XmlList Actions { get; set; } - } -} diff --git a/GVFS/GVFS.Service.UI/Data/BindingData.cs b/GVFS/GVFS.Service.UI/Data/BindingData.cs deleted file mode 100644 index b364abed5..000000000 --- a/GVFS/GVFS.Service.UI/Data/BindingData.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - public class BindingData - { - [XmlAttribute("template")] - public string Template { get; set; } - - [XmlAnyElement] - public XmlList Items { get; set; } - } -} diff --git a/GVFS/GVFS.Service.UI/Data/BindingItem.cs b/GVFS/GVFS.Service.UI/Data/BindingItem.cs deleted file mode 100644 index e116d1a16..000000000 --- a/GVFS/GVFS.Service.UI/Data/BindingItem.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - public abstract class BindingItem - { - [XmlRoot("text")] - public class TextData : BindingItem - { - public TextData() - { - // Required for serialization - } - - public TextData(string value) - { - this.Value = value; - } - - [XmlText] - public string Value { get; set; } - } - - [XmlRoot("image")] - public class ImageData : BindingItem - { - [XmlAttribute("placement")] - public string Placement { get; set; } - - [XmlAttribute("src")] - public string Source { get; set; } - - [XmlAttribute("hint-crop")] - public string HintCrop { get; set; } - } - } -} diff --git a/GVFS/GVFS.Service.UI/Data/ToastData.cs b/GVFS/GVFS.Service.UI/Data/ToastData.cs deleted file mode 100644 index 6750e4e78..000000000 --- a/GVFS/GVFS.Service.UI/Data/ToastData.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - [XmlRoot("toast")] - public class ToastData - { - [XmlAttribute("launch")] - public string Launch { get; set; } - - [XmlElement("visual")] - public VisualData Visual { get; set; } - - [XmlElement("actions")] - public ActionsData Actions { get; set; } - - [XmlElement("scenario")] - public string Scenario { get; set; } - } -} diff --git a/GVFS/GVFS.Service.UI/Data/VisualData.cs b/GVFS/GVFS.Service.UI/Data/VisualData.cs deleted file mode 100644 index 10fb75d49..000000000 --- a/GVFS/GVFS.Service.UI/Data/VisualData.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - public class VisualData - { - [XmlElement("binding")] - public BindingData Binding { get; set; } - } -} diff --git a/GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj b/GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj deleted file mode 100644 index 4f3c5ac83..000000000 --- a/GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net471 - - - - - - - - - - - - - PreserveNewest - - - - diff --git a/GVFS/GVFS.Service.UI/GVFSServiceUI.cs b/GVFS/GVFS.Service.UI/GVFSServiceUI.cs deleted file mode 100644 index c81e80b7d..000000000 --- a/GVFS/GVFS.Service.UI/GVFSServiceUI.cs +++ /dev/null @@ -1,56 +0,0 @@ -using GVFS.Common; -using GVFS.Common.NamedPipes; -using GVFS.Common.Tracing; -using System; -using System.Threading; - -namespace GVFS.Service.UI -{ - public class GVFSServiceUI - { - private readonly ITracer tracer; - private readonly GVFSToastRequestHandler toastRequestHandler; - - public GVFSServiceUI(ITracer tracer, GVFSToastRequestHandler toastRequestHandler) - { - this.tracer = tracer; - this.toastRequestHandler = toastRequestHandler; - } - - public void Start(string[] args) - { - using (ITracer activity = this.tracer.StartActivity("Start", EventLevel.Informational)) - using (NamedPipeServer server = NamedPipeServer.StartNewServer(GVFSConstants.Service.UIName, this.tracer, this.HandleRequest)) - { - ManualResetEvent mre = new ManualResetEvent(false); - mre.WaitOne(); - } - } - - private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Connection connection) - { - try - { - NamedPipeMessages.Message message = NamedPipeMessages.Message.FromString(request); - switch (message.Header) - { - case NamedPipeMessages.Notification.Request.Header: - NamedPipeMessages.Notification.Request toastRequest = NamedPipeMessages.Notification.Request.FromMessage(message); - if (toastRequest != null) - { - using (ITracer activity = this.tracer.StartActivity("SendToast", EventLevel.Informational)) - { - this.toastRequestHandler.HandleToastRequest(activity, toastRequest); - } - } - - break; - } - } - catch (Exception e) - { - this.tracer.RelatedError("Unhandled exception: {0}", e.ToString()); - } - } - } -} diff --git a/GVFS/GVFS.Service.UI/GVFSToastRequestHandler.cs b/GVFS/GVFS.Service.UI/GVFSToastRequestHandler.cs deleted file mode 100644 index f6c5872b8..000000000 --- a/GVFS/GVFS.Service.UI/GVFSToastRequestHandler.cs +++ /dev/null @@ -1,193 +0,0 @@ -using GVFS.Common.NamedPipes; -using GVFS.Common.Tracing; -using System; -using System.Diagnostics; -using System.IO; - -namespace GVFS.Service.UI -{ - public class GVFSToastRequestHandler - { - private const string VFSForGitAutomountStartTitle= "VFS For Git Automount"; - private const string VFSForGitAutomountStartMessageFormat = "Attempting to mount {0} VFS For Git {1}"; - private const string VFSForGitMultipleRepos = "repos"; - private const string VFSForGitSingleRepo = "repo"; - - private const string VFSForGitAutomountSuccessTitle = "VFS For Git Automount"; - private const string VFSForGitAutomountSuccessMessageFormat = "The following VFS For Git repo is now mounted: {0}{1}"; - - private const string VFSForGitAutomountErrorTitle = "VFS For Git Automount"; - private const string VFSForGitAutomountErrorMessageFormat = "The following VFS For Git repo failed to mount: {0}{1}"; - private const string VFSForGitAutomountButtonTitle = "Retry"; - - private const string VFSForGitUpgradeTitleFormat = "New version {0} is available"; - private const string VFSForGitUpgradeMessage = "Upgrade will unmount and remount VFS For Git repos, ensure you are at a stopping point. When ready, click Upgrade button to run upgrade."; - private const string VFSForGitUpgradeButtonTitle = "Upgrade"; - - private const string VFSForGitRemountActionPrefix = "gvfs mount"; - private const string VFSForGitUpgradeActionPrefix = "gvfs upgrade --confirm"; - - private readonly ITracer tracer; - private readonly IToastNotifier toastNotifier; - - public GVFSToastRequestHandler(IToastNotifier toastNotifier, ITracer tracer) - { - this.toastNotifier = toastNotifier; - this.toastNotifier.UserResponseCallback = this.UserResponseCallback; - this.tracer = tracer; - } - - public void HandleToastRequest(ITracer tracer, NamedPipeMessages.Notification.Request request) - { - string title = null; - string message = null; - string buttonTitle = null; - string args = null; - string path = null; - - switch (request.Id) - { - case NamedPipeMessages.Notification.Request.Identifier.AutomountStart: - string reposSuffix = request.EnlistmentCount <= 1 ? VFSForGitSingleRepo : VFSForGitMultipleRepos; - title = VFSForGitAutomountStartTitle; - message = string.Format(VFSForGitAutomountStartMessageFormat, request.EnlistmentCount, reposSuffix); - break; - - case NamedPipeMessages.Notification.Request.Identifier.MountSuccess: - if (this.TryValidatePath(request.Enlistment, out path, this.tracer)) - { - title = VFSForGitAutomountSuccessTitle; - message = string.Format(VFSForGitAutomountSuccessMessageFormat, Environment.NewLine, path); - } - - break; - - case NamedPipeMessages.Notification.Request.Identifier.MountFailure: - if (this.TryValidatePath(request.Enlistment, out path, this.tracer)) - { - title = VFSForGitAutomountErrorTitle; - message = string.Format(VFSForGitAutomountErrorMessageFormat, Environment.NewLine, path); - buttonTitle = VFSForGitAutomountButtonTitle; - args = $"{VFSForGitRemountActionPrefix} {path}"; - } - - break; - - case NamedPipeMessages.Notification.Request.Identifier.UpgradeAvailable: - title = string.Format(VFSForGitUpgradeTitleFormat, request.NewVersion); - message = string.Format(VFSForGitUpgradeMessage); - buttonTitle = VFSForGitUpgradeButtonTitle; - args = $"{VFSForGitUpgradeActionPrefix}"; - break; - } - - if (title != null && message != null) - { - this.toastNotifier.Notify(title, message, buttonTitle, args); - } - } - - public void UserResponseCallback(string args) - { - if (string.IsNullOrEmpty(args)) - { - this.tracer.RelatedError($"{nameof(this.UserResponseCallback)}: Received null arguments in Toaster callback."); - return; - } - - using (ITracer activity = this.tracer.StartActivity("GVFSToastCallback", EventLevel.Informational)) - { - string gvfsCmd = null; - bool elevate = false; - - if (args.StartsWith(VFSForGitUpgradeActionPrefix)) - { - this.tracer.RelatedInfo($"gvfs upgrade action."); - gvfsCmd = "gvfs upgrade --confirm"; - elevate = true; - } - else if (args.StartsWith(VFSForGitRemountActionPrefix)) - { - string path = args.Substring(VFSForGitRemountActionPrefix.Length, args.Length - VFSForGitRemountActionPrefix.Length); - if (this.TryValidatePath(path, out string enlistment, activity)) - { - this.tracer.RelatedInfo($"gvfs mount action {enlistment}."); - gvfsCmd = $"gvfs mount \"{enlistment}\""; - } - else - { - EventMetadata metadata = new EventMetadata(); - metadata.Add(nameof(args), args); - metadata.Add(nameof(path), path); - this.tracer.RelatedError(metadata, $"{nameof(this.UserResponseCallback)}- Invalid enlistment path specified in Toaster callback."); - } - } - else - { - this.tracer.RelatedError($"{nameof(this.UserResponseCallback)}- Unknown action({args}) specified in Toaster callback."); - } - - if (!string.IsNullOrEmpty(gvfsCmd)) - { - this.launchGVFSInCommandPrompt(gvfsCmd, elevate, activity); - } - } - } - - private bool TryValidatePath(string path, out string validatedPath, ITracer tracer) - { - try - { - validatedPath = Path.GetFullPath(path); - return true; - } - catch (Exception ex) - { - EventMetadata metadata = new EventMetadata(); - metadata.Add("Exception", ex.ToString()); - metadata.Add("Path", path); - - tracer.RelatedError(metadata, $"{nameof(this.TryValidatePath)}: {path}. {ex.ToString()}"); - } - - validatedPath = null; - return false; - } - - private void launchGVFSInCommandPrompt(string fullGvfsCmd, bool elevate, ITracer tracer) - { - const string cmdPath = "CMD.exe"; - ProcessStartInfo processInfo = new ProcessStartInfo(cmdPath); - processInfo.UseShellExecute = true; - processInfo.RedirectStandardInput = false; - processInfo.RedirectStandardOutput = false; - processInfo.RedirectStandardError = false; - processInfo.WindowStyle = ProcessWindowStyle.Normal; - processInfo.CreateNoWindow = false; - - // /K option is so the user gets the time to read the output of the command and - // manually close the cmd window after that. - processInfo.Arguments = "/K " + fullGvfsCmd; - if (elevate) - { - processInfo.Verb = "runas"; - } - - tracer.RelatedInfo($"{nameof(this.UserResponseCallback)}- Running {cmdPath} /K {fullGvfsCmd}"); - - try - { - Process.Start(processInfo); - } - catch (Exception ex) - { - EventMetadata metadata = new EventMetadata(); - metadata.Add("Exception", ex.ToString()); - metadata.Add(nameof(fullGvfsCmd), fullGvfsCmd); - metadata.Add(nameof(elevate), elevate); - - tracer.RelatedError(metadata, $"{nameof(this.launchGVFSInCommandPrompt)}: Error launching {fullGvfsCmd}. {ex.ToString()}"); - } - } - } -} diff --git a/GVFS/GVFS.Service.UI/IToastNotifier.cs b/GVFS/GVFS.Service.UI/IToastNotifier.cs deleted file mode 100644 index 60cd2f15b..000000000 --- a/GVFS/GVFS.Service.UI/IToastNotifier.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace GVFS.Service.UI -{ - public interface IToastNotifier - { - Action UserResponseCallback { get; set; } - void Notify(string title, string message, string actionButtonTitle, string callbackArgs); - } -} diff --git a/GVFS/GVFS.Service.UI/Program.cs b/GVFS/GVFS.Service.UI/Program.cs deleted file mode 100644 index 3c03bbc66..000000000 --- a/GVFS/GVFS.Service.UI/Program.cs +++ /dev/null @@ -1,46 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Tracing; -using GVFS.PlatformLoader; -using System; - -namespace GVFS.Service.UI -{ - public static class Program - { - public static void Main(string[] args) - { - GVFSPlatformLoader.Initialize(); - - using (JsonTracer tracer = new JsonTracer("Microsoft.Git.GVFS.Service.UI", "Service.UI")) - { - string error; - string serviceUILogDirectory = GVFSPlatform.Instance.GetLogsDirectoryForGVFSComponent(GVFSConstants.Service.UIName); - if (!GVFSPlatform.Instance.FileSystem.TryCreateDirectoryWithAdminAndUserModifyPermissions(serviceUILogDirectory, out error)) - { - EventMetadata metadata = new EventMetadata(); - metadata.Add(nameof(serviceUILogDirectory), serviceUILogDirectory); - metadata.Add(nameof(error), error); - tracer.RelatedWarning( - metadata, - "Failed to create service UI logs directory", - Keywords.Telemetry); - } - else - { - string logFilePath = GVFSEnlistment.GetNewGVFSLogFileName( - serviceUILogDirectory, - GVFSConstants.LogFileTypes.ServiceUI, - logId: Environment.UserName); - - tracer.AddLogFileEventListener(logFilePath, EventLevel.Informational, Keywords.Any); - } - - WinToastNotifier winToastNotifier = new WinToastNotifier(tracer); - GVFSToastRequestHandler toastRequestHandler = new GVFSToastRequestHandler(winToastNotifier, tracer); - GVFSServiceUI process = new GVFSServiceUI(tracer, toastRequestHandler); - - process.Start(args); - } - } - } -} \ No newline at end of file diff --git a/GVFS/GVFS.Service.UI/WinToastNotifier.cs b/GVFS/GVFS.Service.UI/WinToastNotifier.cs deleted file mode 100644 index 8cf364dfa..000000000 --- a/GVFS/GVFS.Service.UI/WinToastNotifier.cs +++ /dev/null @@ -1,103 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Tracing; -using GVFS.Service.UI.Data; -using System; -using System.IO; -using System.Xml; -using System.Xml.Serialization; -using Windows.UI.Notifications; -using XmlDocument = Windows.Data.Xml.Dom.XmlDocument; - -namespace GVFS.Service.UI -{ - public class WinToastNotifier : IToastNotifier - { - private const string ServiceAppId = "GVFS"; - private const string GVFSIconName = "GitVirtualFileSystem.ico"; - private ITracer tracer; - - public WinToastNotifier(ITracer tracer) - { - this.tracer = tracer; - } - - public Action UserResponseCallback { get; set; } - - public void Notify(string title, string message, string actionButtonTitle, string callbackArgs) - { - // Reference: https://docs.microsoft.com/en-us/windows/uwp/design/shell/tiles-and-notifications/adaptive-interactive-toasts - ToastData toastData = new ToastData(); - - toastData.Visual = new VisualData(); - - BindingData binding = new BindingData(); - toastData.Visual.Binding = binding; - - // ToastGeneric- Our toast contains VFSForGit icon and text - binding.Template = "ToastGeneric"; - binding.Items = new XmlList(); - binding.Items.Add(new BindingItem.TextData(title)); - binding.Items.Add(new BindingItem.TextData(message)); - - string logo = "file:///" + Path.Combine(ProcessHelper.GetCurrentProcessLocation(), GVFSIconName); - binding.Items.Add(new BindingItem.ImageData() - { - Source = logo, - Placement = "appLogoOverride", - HintCrop = "circle" - }); - - if (!string.IsNullOrEmpty(actionButtonTitle)) - { - ActionsData actionsData = new ActionsData(); - actionsData.Actions = new XmlList(); - actionsData.Actions.Add(new ActionItem() - { - Content = actionButtonTitle, - Arguments = string.IsNullOrEmpty(callbackArgs) ? string.Empty : callbackArgs, - ActivationType = "background" - }); - - toastData.Actions = actionsData; - } - - XmlDocument toastXml = new XmlDocument(); - using (StringWriter stringWriter = new StringWriter()) - using (XmlWriter xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings { OmitXmlDeclaration = true })) - { - XmlSerializer serializer = new XmlSerializer(toastData.GetType()); - XmlSerializerNamespaces namespaces = new XmlSerializerNamespaces(); - namespaces.Add(string.Empty, string.Empty); - - serializer.Serialize(xmlWriter, toastData, namespaces); - - toastXml.LoadXml(stringWriter.ToString()); - } - - ToastNotification toastNotification = new ToastNotification(toastXml); - toastNotification.Activated += this.ToastActivated; - toastNotification.Dismissed += this.ToastDismissed; - toastNotification.Failed += this.ToastFailed; - - ToastNotifier toastNotifier = ToastNotificationManager.CreateToastNotifier(ServiceAppId); - toastNotifier.Show(toastNotification); - } - - private void ToastActivated(ToastNotification sender, object e) - { - ToastActivatedEventArgs args = (ToastActivatedEventArgs)e; - - this.UserResponseCallback?.Invoke(args.Arguments); - } - - private void ToastDismissed(ToastNotification sender, ToastDismissedEventArgs e) - { - this.tracer.RelatedInfo($"{nameof(this.ToastDismissed)}: {e.Reason}"); - } - - private void ToastFailed(ToastNotification sender, ToastFailedEventArgs e) - { - this.tracer.RelatedInfo($"{nameof(this.ToastFailed)}: {e.ErrorCode.ToString()}"); - } - } -} diff --git a/GVFS/GVFS.Service.UI/XmlList.cs b/GVFS/GVFS.Service.UI/XmlList.cs deleted file mode 100644 index 06a2dad50..000000000 --- a/GVFS/GVFS.Service.UI/XmlList.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Xml; -using System.Xml.Schema; -using System.Xml.Serialization; - -namespace GVFS.Service.UI -{ - public class XmlList : List, IXmlSerializable where T : class - { - public XmlSchema GetSchema() - { - throw new NotImplementedException(); - } - - public void ReadXml(XmlReader reader) - { - throw new NotImplementedException(); - } - - public void WriteXml(XmlWriter writer) - { - XmlSerializerNamespaces ns = new XmlSerializerNamespaces(); - ns.Add(string.Empty, string.Empty); - foreach (T item in this) - { - XmlSerializer xml = new XmlSerializer(item.GetType()); - xml.Serialize(writer, item, ns); - } - } - } -} diff --git a/GVFS/GVFS.Service/Configuration.cs b/GVFS/GVFS.Service/Configuration.cs index f5c8b65be..0e4ebe18e 100644 --- a/GVFS/GVFS.Service/Configuration.cs +++ b/GVFS/GVFS.Service/Configuration.cs @@ -11,7 +11,6 @@ public class Configuration private Configuration() { this.GVFSLocation = Path.Combine(AssemblyPath, GVFSPlatform.Instance.Constants.GVFSExecutableName); - this.GVFSServiceUILocation = Path.Combine(AssemblyPath, GVFSConstants.Service.UIName + GVFSPlatform.Instance.Constants.ExecutableExtension); } public static Configuration Instance @@ -36,6 +35,5 @@ public static string AssemblyPath } public string GVFSLocation { get; private set; } - public string GVFSServiceUILocation { get; private set; } } } diff --git a/GVFS/GVFS.Service/GVFS.Service.csproj b/GVFS/GVFS.Service/GVFS.Service.csproj index b557e3a41..cc8c4c9f8 100644 --- a/GVFS/GVFS.Service/GVFS.Service.csproj +++ b/GVFS/GVFS.Service/GVFS.Service.csproj @@ -8,7 +8,6 @@ - diff --git a/GVFS/GVFS.Service/GVFSService.Windows.cs b/GVFS/GVFS.Service/GVFSService.Windows.cs index 5b3048b74..f0eee8385 100644 --- a/GVFS/GVFS.Service/GVFSService.Windows.cs +++ b/GVFS/GVFS.Service/GVFSService.Windows.cs @@ -5,10 +5,8 @@ using GVFS.Platform.Windows; using GVFS.Service.Handlers; using System; -using System.Diagnostics; using System.IO; using System.Linq; -using System.Runtime.Serialization; using System.Security.AccessControl; using System.ServiceProcess; using System.Threading; @@ -130,8 +128,6 @@ protected override void OnSessionChange(SessionChangeDescription changeDescripti { this.tracer.RelatedInfo("SessionLogon detected, sessionId: {0}", changeDescription.SessionId); - this.LaunchServiceUIIfNotRunning(changeDescription.SessionId); - using (ITracer activity = this.tracer.StartActivity("LogonAutomount", EventLevel.Informational)) { this.repoRegistry.AutoMountRepos( @@ -358,9 +354,6 @@ private void CreateAndConfigureProgramDataDirectories() // Ensure the ACLs are set correctly on any files or directories that were already created (e.g. after upgrading VFS4G) Directory.SetAccessControl(serviceDataRootPath, serviceDataRootSecurity); - - // Special rules for the Service.UI logs, as non-elevated users need to be be able to write - this.CreateAndConfigureLogDirectory(GVFSPlatform.Instance.GetLogsDirectoryForGVFSComponent(GVFSConstants.Service.UIName)); } private void CreateAndConfigureLogDirectory(string path) @@ -404,50 +397,5 @@ private DirectorySecurity GetServiceDirectorySecurity(string serviceDataRootPath return serviceDataRootSecurity; } - private void LaunchServiceUIIfNotRunning(int sessionId) - { - NamedPipeClient client; - using (client = new NamedPipeClient(GVFSConstants.Service.UIName)) - { - if (!client.Connect()) - { - this.tracer.RelatedError($"Could not connect with {GVFSConstants.Service.UIName}. Attempting to relaunch."); - - this.TerminateExistingProcess(GVFSConstants.Service.UIName, sessionId); - - CurrentUser currentUser = new CurrentUser(this.tracer, sessionId); - if (!currentUser.RunAs( - Configuration.Instance.GVFSServiceUILocation, - string.Empty)) - { - this.tracer.RelatedError("Could not start " + GVFSConstants.Service.UIName); - } - else - { - this.tracer.RelatedInfo($"Successfully launched {GVFSConstants.Service.UIName}. "); - } - } - } - } - - private void TerminateExistingProcess(string processName, int sessionId) - { - try - { - foreach (Process process in Process.GetProcessesByName(processName)) - { - if (process.SessionId == sessionId) - { - this.tracer.RelatedInfo($"{nameof(this.TerminateExistingProcess)}- Stopping {processName}, in session {sessionId}."); - - process.Kill(); - } - } - } - catch (Exception ex) - { - this.tracer.RelatedError("Could not find and kill existing instances of {0}: {1}", processName, ex.Message); - } - } } } diff --git a/GVFS/GVFS.Service/Handlers/NotificationHandler.cs b/GVFS/GVFS.Service/Handlers/NotificationHandler.cs index a7777b8fc..a0ec6876c 100644 --- a/GVFS/GVFS.Service/Handlers/NotificationHandler.cs +++ b/GVFS/GVFS.Service/Handlers/NotificationHandler.cs @@ -1,47 +1,16 @@ -using GVFS.Common; -using GVFS.Common.NamedPipes; +using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; -using GVFS.Platform.Windows; -using System; -using System.Diagnostics; namespace GVFS.Service.Handlers { public class NotificationHandler : INotificationHandler { - private ITracer tracer; - public NotificationHandler(ITracer tracer) { - this.tracer = tracer; } public void SendNotification(NamedPipeMessages.Notification.Request request) { - using (NamedPipeClient client = new NamedPipeClient(GVFSConstants.Service.UIName)) - { - if (client.Connect()) - { - try - { - if (!client.TrySendRequest(request.ToMessage())) - { - this.tracer.RelatedInfo("Failed to send notification request to " + GVFSConstants.Service.UIName); - } - } - catch (Exception ex) - { - EventMetadata metadata = new EventMetadata(); - metadata.Add("Exception", ex.ToString()); - metadata.Add("Identifier", request.Id); - this.tracer.RelatedError(metadata, $"{nameof(this.SendNotification)}- Could not send notification request({request.Id}. {ex.ToString()}"); - } - } - else - { - this.tracer.RelatedError($"{nameof(this.SendNotification)}- Could not connect with GVFS.Service.UI, failed to send notification request({request.Id}."); - } - } } } } diff --git a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj index 6111e7220..efb890af3 100644 --- a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj +++ b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj @@ -8,7 +8,6 @@ - diff --git a/GVFS/GVFS.UnitTests/Windows/ServiceUI/GVFSToastRequestHandlerTests.cs b/GVFS/GVFS.UnitTests/Windows/ServiceUI/GVFSToastRequestHandlerTests.cs deleted file mode 100644 index 34e7073e9..000000000 --- a/GVFS/GVFS.UnitTests/Windows/ServiceUI/GVFSToastRequestHandlerTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using GVFS.Common.NamedPipes; -using GVFS.Service.UI; -using GVFS.UnitTests.Mock.Common; -using Moq; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace GVFS.UnitTests.Windows.ServiceUI -{ - [TestFixture] - public class GVFSToastRequestHandlerTests - { - private NamedPipeMessages.Notification.Request request; - private GVFSToastRequestHandler toastHandler; - private Mock mockToastNotifier; - private MockTracer tracer; - - [SetUp] - public void Setup() - { - this.tracer = new MockTracer(); - this.mockToastNotifier = new Mock(MockBehavior.Strict); - this.mockToastNotifier.SetupSet(toastNotifier => toastNotifier.UserResponseCallback = It.IsAny>()).Verifiable(); - this.toastHandler = new GVFSToastRequestHandler(this.mockToastNotifier.Object, this.tracer); - this.request = new NamedPipeMessages.Notification.Request(); - } - - [TestCase] - public void UpgradeToastIsActionableAndContainsVersionInfo() - { - const string version = "1.0.956749.2"; - - this.request.Id = NamedPipeMessages.Notification.Request.Identifier.UpgradeAvailable; - this.request.NewVersion = version; - - this.VerifyToastMessage( - expectedTitle: "New version " + version + " is available", - expectedMessage: "click Upgrade button", - expectedButtonTitle: "Upgrade", - expectedGVFSCmd: "gvfs upgrade --confirm"); - } - - [TestCase] - public void MountFailureToastIsActionableAndContainEnlistmentInfo() - { - const string enlistmentRoot = "D:\\Work\\OS"; - - this.request.Id = NamedPipeMessages.Notification.Request.Identifier.MountFailure; - this.request.Enlistment = enlistmentRoot; - - this.VerifyToastMessage( - expectedTitle: "VFS For Git Automount", - expectedMessage: enlistmentRoot, - expectedButtonTitle: "Retry", - expectedGVFSCmd: "gvfs mount " + enlistmentRoot); - } - - [TestCase] - public void MountStartIsNotActionableAndContainsEnlistmentCount() - { - const int enlistmentCount = 10; - - this.request.Id = NamedPipeMessages.Notification.Request.Identifier.AutomountStart; - this.request.EnlistmentCount = enlistmentCount; - - this.VerifyToastMessage( - expectedTitle: "VFS For Git Automount", - expectedMessage: "mount " + enlistmentCount.ToString() + " VFS For Git repos", - expectedButtonTitle: null, - expectedGVFSCmd: null); - } - - [TestCase] - public void UnknownToastRequestGetsIgnored() - { - this.request.Id = (NamedPipeMessages.Notification.Request.Identifier)10; - this.request.EnlistmentCount = 232; - this.request.Enlistment = "C:\\OS"; - - this.toastHandler.HandleToastRequest(this.tracer, this.request); - - this.mockToastNotifier.Verify( - toastNotifier => toastNotifier.Notify( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny()), - Times.Never()); - } - - private void VerifyToastMessage( - string expectedTitle, - string expectedMessage, - string expectedButtonTitle, - string expectedGVFSCmd) - { - this.mockToastNotifier.Setup(toastNotifier => toastNotifier.Notify( - expectedTitle, - It.Is(message => message.Contains(expectedMessage)), - expectedButtonTitle, - expectedGVFSCmd)); - - this.toastHandler.HandleToastRequest(this.tracer, this.request); - this.mockToastNotifier.VerifyAll(); - } - } -} diff --git a/GVFS/GVFS/CommandLine/DiagnoseVerb.cs b/GVFS/GVFS/CommandLine/DiagnoseVerb.cs index 1d3a71639..3840bbbb4 100644 --- a/GVFS/GVFS/CommandLine/DiagnoseVerb.cs +++ b/GVFS/GVFS/CommandLine/DiagnoseVerb.cs @@ -133,13 +133,6 @@ protected override void Execute(GVFSEnlistment enlistment) this.ServiceName, copySubFolders: true); - // service ui - this.CopyAllFiles( - GVFSPlatform.Instance.GetCommonAppDataRootForGVFS(), - archiveFolderPath, - GVFSConstants.Service.UIName, - copySubFolders: true); - if (GVFSPlatform.Instance.UnderConstruction.SupportsGVFSConfig) { this.CopyFile(GVFSPlatform.Instance.GetSecureDataRootForGVFS(), archiveFolderPath, LocalGVFSConfig.FileName); From 1a3637126340571a8edfd8f1b670af0fd2a272e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:51:27 +0000 Subject: [PATCH 05/25] build(deps): bump actions/github-script from 8 to 9 Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v8...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yaml | 4 ++-- .github/workflows/functional-tests.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3bf1ad189..f6e9d8758 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -36,7 +36,7 @@ jobs: - name: Look for prior successful runs id: check if: github.event.inputs.git_version == '' - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{secrets.GITHUB_TOKEN}} result-encoding: string @@ -182,7 +182,7 @@ jobs: - name: Skip this job if there is a previous successful run if: needs.validate.outputs.skip != '' id: skip - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | core.info(`Skipping: There already is a successful run: ${{ needs.validate.outputs.skip }}`) diff --git a/.github/workflows/functional-tests.yaml b/.github/workflows/functional-tests.yaml index 16f0988cf..82faf676e 100644 --- a/.github/workflows/functional-tests.yaml +++ b/.github/workflows/functional-tests.yaml @@ -70,7 +70,7 @@ jobs: - name: Skip this job if there is a previous successful run if: inputs.skip != '' id: skip - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | core.info(`Skipping: There already is a successful run: ${{ inputs.skip }}`) From e876cd7ef82aa056c2c3891c1b65ce2051c653c0 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Mon, 13 Apr 2026 08:17:30 -0700 Subject: [PATCH 06/25] GVFS: fix silent mount failure on concurrent worktree add MountNewWorktree() in the post-command hook silently discarded the exit code of 'gvfs mount', leaving worktrees unprojected when concurrent adds caused mount contention. Also discarded the git checkout exit code. Product fix: - Check git checkout exit code; skip mount on failure - Check gvfs mount exit code with retry (2 retries, 100ms/250ms) - Extract and check --exit_code from hook args to skip post-processing when the git command itself failed - Return int? from GetHookExitCode so callers decide null semantics - Apply same TryMountWithRetry to MountMovedWorktree - Remount old worktree when 'git worktree move' fails (pre-hook unmounts) - Remove maxRetries parameter; retry count driven by delay array length - Emit actionable warning to stderr on mount failure with recovery command Test fix: - Increase concurrent worktrees from 2 to ProcessorCount for reliable mount contention - Use CountdownEvent barrier for tight synchronization of launches - Add pipe-based mount verification as primary assertion (probes the worktree-specific named pipe directly instead of relying on File.Exists) - Add diagnostic capture on failure (directory listing, .git contents) - Use dynamic branch names with GUID suffixes to avoid collisions - Use numeric indices instead of char arithmetic for labels/paths to support >26 concurrent worktrees - Only retry at lower concurrency when ALL failures are overload-related; mixed failure sets (overload + real regression) are reported immediately Bug: https://dev.azure.com/microsoft/OS/_workitems/edit/61784115 Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .../EnlistmentPerFixture/WorktreeTests.cs | 368 ++++++++++++++---- GVFS/GVFS.Hooks/Program.Worktree.cs | 105 ++++- GVFS/GVFS.Hooks/Program.cs | 22 ++ 3 files changed, 405 insertions(+), 90 deletions(-) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 376796350..5d277d238 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -1,9 +1,15 @@ -using GVFS.FunctionalTests.Tools; +using GVFS.Common; +using GVFS.Common.NamedPipes; +using GVFS.FunctionalTests.Tools; using GVFS.Tests.Should; using NUnit.Framework; using System; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using ProcessResult = GVFS.FunctionalTests.Tools.ProcessResult; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { @@ -11,102 +17,298 @@ namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture [Category(Categories.GitCommands)] public class WorktreeTests : TestsWithEnlistmentPerFixture { - private const string WorktreeBranchA = "worktree-test-branch-a"; - private const string WorktreeBranchB = "worktree-test-branch-b"; + private const int MinWorktreeCount = 4; [TestCase] public void ConcurrentWorktreeAddCommitRemove() { - string worktreePathA = Path.Combine(this.Enlistment.EnlistmentRoot, "test-wt-a-" + Guid.NewGuid().ToString("N").Substring(0, 8)); - string worktreePathB = Path.Combine(this.Enlistment.EnlistmentRoot, "test-wt-b-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + int count = Math.Max(Environment.ProcessorCount, MinWorktreeCount); + string[] worktreePaths; + string[] branchNames; + + // Adaptively scale down if concurrent adds overwhelm the primary + // GVFS mount. CI runners with fewer resources may not handle as + // many concurrent git operations as a developer workstation. + while (true) + { + this.InitWorktreeArrays(count, out worktreePaths, out branchNames); + ProcessResult[] addResults = this.ConcurrentWorktreeAdd(worktreePaths, branchNames, count); + + bool overloaded = addResults.Any(r => + r.ExitCode != 0 && + r.Errors != null && + r.Errors.Contains("does not appear to be mounted")); + + // Only retry if ALL failures are overload-related. If any + // failure has a different cause, it's a real regression and + // must not be masked by retrying at lower concurrency. + bool hasNonOverloadFailure = addResults.Any(r => + r.ExitCode != 0 && + !(r.Errors != null && r.Errors.Contains("does not appear to be mounted"))); + + if (hasNonOverloadFailure) + { + // Fall through to the assertion loop below which will + // report the specific failure(s). + } + else if (overloaded) + { + this.CleanupAllWorktrees(worktreePaths, branchNames, count); + int reduced = count / 2; + if (reduced < MinWorktreeCount) + { + Assert.Fail( + $"Primary GVFS mount overloaded even at count={count}. " + + $"Cannot reduce below {MinWorktreeCount}."); + } + + count = reduced; + continue; + } + + // Non-overload failures are real errors + for (int i = 0; i < count; i++) + { + addResults[i].ExitCode.ShouldEqual(0, + $"worktree add [{i}] failed: {addResults[i].Errors}"); + } + + break; + } try { - // 1. Create both worktrees in parallel - ProcessResult addResultA = null; - ProcessResult addResultB = null; - System.Threading.Tasks.Parallel.Invoke( - () => addResultA = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree add -b {WorktreeBranchA} \"{worktreePathA}\""), - () => addResultB = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree add -b {WorktreeBranchB} \"{worktreePathB}\"")); - - addResultA.ExitCode.ShouldEqual(0, $"worktree add A failed: {addResultA.Errors}"); - addResultB.ExitCode.ShouldEqual(0, $"worktree add B failed: {addResultB.Errors}"); - - // 2. Verify both have projected files - Directory.Exists(worktreePathA).ShouldBeTrue("Worktree A directory should exist"); - Directory.Exists(worktreePathB).ShouldBeTrue("Worktree B directory should exist"); - File.Exists(Path.Combine(worktreePathA, "Readme.md")).ShouldBeTrue("Readme.md should be projected in A"); - File.Exists(Path.Combine(worktreePathB, "Readme.md")).ShouldBeTrue("Readme.md should be projected in B"); - - // 3. Verify git status is clean in both - ProcessResult statusA = GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, "status --porcelain"); - ProcessResult statusB = GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, "status --porcelain"); - statusA.ExitCode.ShouldEqual(0, $"git status A failed: {statusA.Errors}"); - statusB.ExitCode.ShouldEqual(0, $"git status B failed: {statusB.Errors}"); - statusA.Output.Trim().ShouldBeEmpty("Worktree A should have clean status"); - statusB.Output.Trim().ShouldBeEmpty("Worktree B should have clean status"); - - // 4. Verify worktree list shows all three + // 2. Primary assertion: verify GVFS mount is running for each + // worktree by probing the worktree-specific named pipe. + for (int i = 0; i < count; i++) + { + this.AssertWorktreeMounted(worktreePaths[i], $"worktree [{i}]"); + } + + // 3. Verify projected files are visible (secondary assertion) + for (int i = 0; i < count; i++) + { + Directory.Exists(worktreePaths[i]).ShouldBeTrue( + $"Worktree [{i}] directory should exist"); + File.Exists(Path.Combine(worktreePaths[i], "Readme.md")).ShouldBeTrue( + $"Readme.md should be projected in [{i}]"); + } + + // 4. Verify git status is clean in each worktree + for (int i = 0; i < count; i++) + { + ProcessResult status = GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePaths[i], "status --porcelain"); + status.ExitCode.ShouldEqual(0, + $"git status [{i}] failed: {status.Errors}"); + status.Output.Trim().ShouldBeEmpty( + $"Worktree [{i}] should have clean status"); + } + + // 5. Verify worktree list shows all entries ProcessResult listResult = GitHelpers.InvokeGitAgainstGVFSRepo( this.Enlistment.RepoRoot, "worktree list"); listResult.ExitCode.ShouldEqual(0, $"worktree list failed: {listResult.Errors}"); string listOutput = listResult.Output; - Assert.IsTrue(listOutput.Contains(worktreePathA.Replace('\\', '/')), - $"worktree list should contain A. Output: {listOutput}"); - Assert.IsTrue(listOutput.Contains(worktreePathB.Replace('\\', '/')), - $"worktree list should contain B. Output: {listOutput}"); - - // 5. Make commits in both worktrees - File.WriteAllText(Path.Combine(worktreePathA, "from-a.txt"), "created in worktree A"); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, "add from-a.txt") - .ExitCode.ShouldEqual(0); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, "commit -m \"commit from A\"") - .ExitCode.ShouldEqual(0); - - File.WriteAllText(Path.Combine(worktreePathB, "from-b.txt"), "created in worktree B"); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, "add from-b.txt") - .ExitCode.ShouldEqual(0); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, "commit -m \"commit from B\"") - .ExitCode.ShouldEqual(0); - - // 6. Verify commits are visible from all worktrees (shared objects) - GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, $"log -1 --format=%s {WorktreeBranchA}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from A" }); - GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, $"log -1 --format=%s {WorktreeBranchB}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from B" }); - - // A can see B's commit and vice versa - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, $"log -1 --format=%s {WorktreeBranchB}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from B" }); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, $"log -1 --format=%s {WorktreeBranchA}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from A" }); - - // 7. Remove both in parallel - ProcessResult removeA = null; - ProcessResult removeB = null; - System.Threading.Tasks.Parallel.Invoke( - () => removeA = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree remove --force \"{worktreePathA}\""), - () => removeB = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree remove --force \"{worktreePathB}\"")); - - removeA.ExitCode.ShouldEqual(0, $"worktree remove A failed: {removeA.Errors}"); - removeB.ExitCode.ShouldEqual(0, $"worktree remove B failed: {removeB.Errors}"); - - // 8. Verify cleanup - Directory.Exists(worktreePathA).ShouldBeFalse("Worktree A directory should be deleted"); - Directory.Exists(worktreePathB).ShouldBeFalse("Worktree B directory should be deleted"); + for (int i = 0; i < count; i++) + { + Assert.IsTrue( + listOutput.Contains(worktreePaths[i].Replace('\\', '/')), + $"worktree list should contain [{i}]. Output: {listOutput}"); + } + + // 6. Make commits in all worktrees + for (int i = 0; i < count; i++) + { + File.WriteAllText( + Path.Combine(worktreePaths[i], $"from-{i}.txt"), + $"created in worktree {i}"); + GitHelpers.InvokeGitAgainstGVFSRepo(worktreePaths[i], $"add from-{i}.txt") + .ExitCode.ShouldEqual(0); + GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePaths[i], $"commit -m \"commit from {i}\"") + .ExitCode.ShouldEqual(0); + } + + // 7. Verify commits are visible from main repo + for (int i = 0; i < count; i++) + { + GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, $"log -1 --format=%s {branchNames[i]}") + .Output.ShouldContain(expectedSubstrings: new[] { $"commit from {i}" }); + } + + // 8. Verify cross-worktree commit visibility (shared objects) + for (int i = 0; i < count; i++) + { + int other = (i + 1) % count; + GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePaths[i], $"log -1 --format=%s {branchNames[other]}") + .Output.ShouldContain(expectedSubstrings: new[] { $"commit from {other}" }); + } + + // 9. Remove all worktrees in parallel + ProcessResult[] removeResults = new ProcessResult[count]; + using (CountdownEvent barrier = new CountdownEvent(count)) + { + Thread[] threads = new Thread[count]; + for (int i = 0; i < count; i++) + { + int idx = i; + threads[idx] = new Thread(() => + { + barrier.Signal(); + barrier.Wait(); + removeResults[idx] = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree remove --force \"{worktreePaths[idx]}\""); + }); + threads[idx].Start(); + } + + foreach (Thread t in threads) + { + t.Join(); + } + } + + for (int i = 0; i < count; i++) + { + removeResults[i].ExitCode.ShouldEqual(0, + $"worktree remove [{i}] failed: {removeResults[i].Errors}"); + } + + // 10. Verify cleanup + for (int i = 0; i < count; i++) + { + Directory.Exists(worktreePaths[i]).ShouldBeFalse( + $"Worktree [{i}] directory should be deleted"); + } } finally { - this.ForceCleanupWorktree(worktreePathA, WorktreeBranchA); - this.ForceCleanupWorktree(worktreePathB, WorktreeBranchB); + this.CleanupAllWorktrees(worktreePaths, branchNames, count); + } + } + + private void InitWorktreeArrays(int count, out string[] paths, out string[] branches) + { + paths = new string[count]; + branches = new string[count]; + for (int i = 0; i < count; i++) + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + paths[i] = Path.Combine(this.Enlistment.EnlistmentRoot, $"test-wt-{i}-{suffix}"); + branches[i] = $"worktree-test-branch-{i}-{suffix}"; + } + } + + private ProcessResult[] ConcurrentWorktreeAdd(string[] paths, string[] branches, int count) + { + ProcessResult[] results = new ProcessResult[count]; + using (CountdownEvent barrier = new CountdownEvent(count)) + { + Thread[] threads = new Thread[count]; + for (int i = 0; i < count; i++) + { + int idx = i; + threads[idx] = new Thread(() => + { + barrier.Signal(); + barrier.Wait(); + results[idx] = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {branches[idx]} \"{paths[idx]}\""); + }); + threads[idx].Start(); + } + + foreach (Thread t in threads) + { + t.Join(); + } + } + + return results; + } + + /// + /// Asserts that the GVFS mount for a worktree is running by probing + /// the worktree-specific named pipe. This is the definitive signal + /// that ProjFS projection is active — much stronger than File.Exists + /// which depends on projection timing. + /// + private void AssertWorktreeMounted(string worktreePath, string label) + { + string basePipeName = GVFSPlatform.Instance.GetNamedPipeName( + this.Enlistment.EnlistmentRoot); + string suffix = GVFSEnlistment.GetWorktreePipeSuffix(worktreePath); + + Assert.IsNotNull(suffix, + $"Could not determine pipe suffix for {label} at {worktreePath}. " + + $"The worktree .git file may be missing or malformed."); + + string pipeName = basePipeName + suffix; + + using (NamedPipeClient client = new NamedPipeClient(pipeName)) + { + if (!client.Connect(10000)) + { + string diagnostics = this.CaptureWorktreeDiagnostics(worktreePath); + Assert.Fail( + $"GVFS mount is NOT running for {label}.\n" + + $"Path: {worktreePath}\n" + + $"Pipe: {pipeName}\n" + + $"This indicates the post-hook 'gvfs mount' failed silently.\n" + + $"Diagnostics:\n{diagnostics}"); + } + } + } + + private string CaptureWorktreeDiagnostics(string worktreePath) + { + StringBuilder sb = new StringBuilder(); + + sb.AppendLine($" Directory exists: {Directory.Exists(worktreePath)}"); + if (Directory.Exists(worktreePath)) + { + string dotGit = Path.Combine(worktreePath, ".git"); + sb.AppendLine($" .git file exists: {File.Exists(dotGit)}"); + if (File.Exists(dotGit)) + { + try + { + sb.AppendLine($" .git contents: {File.ReadAllText(dotGit).Trim()}"); + } + catch (Exception ex) + { + sb.AppendLine($" .git read failed: {ex.Message}"); + } + } + + try + { + string[] entries = Directory.GetFileSystemEntries(worktreePath); + sb.AppendLine($" Directory listing ({entries.Length} entries):"); + foreach (string entry in entries) + { + sb.AppendLine($" {Path.GetFileName(entry)}"); + } + } + catch (Exception ex) + { + sb.AppendLine($" Directory listing failed: {ex.Message}"); + } + } + + return sb.ToString(); + } + + private void CleanupAllWorktrees(string[] paths, string[] branches, int count) + { + for (int i = 0; i < count; i++) + { + this.ForceCleanupWorktree(paths[i], branches[i]); } } diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs index 325532a37..40c768ce0 100644 --- a/GVFS/GVFS.Hooks/Program.Worktree.cs +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -51,22 +51,72 @@ private static void RunWorktreePreCommand(string[] args) private static void RunWorktreePostCommand(string[] args) { string subcommand = GetWorktreeSubcommand(args); + int? gitExitCode = GetHookExitCode(args); + + // Treat null (missing arg) the same as 0 — older Git versions + // may not pass --exit_code, and we should run post-processing + // in that case for backward compatibility. + bool gitSucceeded = gitExitCode == null || gitExitCode == 0; + switch (subcommand) { case "add": - MountNewWorktree(args); + if (gitSucceeded) + { + MountNewWorktree(args); + } + break; case "remove": + // Always run cleanup regardless of git exit code — need to + // remount if remove failed, and clean markers either way. RemountWorktreeIfRemoveFailed(args); CleanupSkipCleanCheckMarker(args); break; case "move": - // Mount at the new location after git moved the directory - MountMovedWorktree(args); + if (gitSucceeded) + { + MountMovedWorktree(args); + } + else + { + // Move failed — the pre-hook already unmounted the old + // location. Remount so the worktree remains usable. + RemountWorktreeIfMoveFailed(args); + } + break; } } + /// + /// Attempts to mount GVFS for a worktree, retrying on transient failures. + /// The first attempt shows output to the console; retries are quiet. + /// Returns true if mount succeeded. + /// + private static bool TryMountWithRetry(string fullPath) + { + int[] retryDelaysMs = { 100, 250 }; + + ProcessResult result = ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false); + if (result.ExitCode == 0) + { + return true; + } + + for (int retry = 0; retry < retryDelaysMs.Length; retry++) + { + System.Threading.Thread.Sleep(retryDelaysMs[retry]); + result = ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: true); + if (result.ExitCode == 0) + { + return true; + } + } + + return false; + } + private static void UnmountWorktreeByArg(string[] args) { string worktreePath = GetWorktreePathArg(args); @@ -106,6 +156,27 @@ private static void RemountWorktreeIfRemoveFailed(string[] args) } } + /// + /// If git worktree move failed, remount at the original location. + /// The pre-hook unmounted the worktree before the move attempt; + /// on failure, the directory hasn't moved so we remount in place. + /// + private static void RemountWorktreeIfMoveFailed(string[] args) + { + string worktreePath = GetWorktreePathArg(args); + if (string.IsNullOrEmpty(worktreePath)) + { + return; + } + + string fullPath = ResolvePath(worktreePath); + string dotGitFile = Path.Combine(fullPath, ".git"); + if (Directory.Exists(fullPath) && File.Exists(dotGitFile)) + { + ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false); + } + } + /// /// Remove the skip-clean-check marker if it still exists after /// worktree remove completes (e.g., if the remove failed and the @@ -335,22 +406,32 @@ private static void MountNewWorktree(string[] args) // Disable hooks via core.hookspath — the worktree's GVFS mount // doesn't exist yet, so post-index-change would fail trying // to connect to a pipe that hasn't been created. + bool checkoutSucceeded = false; string emptyVfsHook = Path.Combine(fullPath, ".vfs-empty-hook"); try { File.WriteAllText(emptyVfsHook, "#!/bin/sh\nprintf \".gitattributes\\n\"\n"); string emptyVfsHookGitPath = emptyVfsHook.Replace('\\', '/'); - ProcessHelper.Run( + ProcessResult checkoutResult = ProcessHelper.Run( "git", $"-C \"{fullPath}\" -c core.virtualfilesystem=\"'{emptyVfsHookGitPath}'\" -c core.hookspath= checkout -f HEAD", redirectOutput: false); + checkoutSucceeded = checkoutResult.ExitCode == 0; } finally { File.Delete(emptyVfsHook); } + if (!checkoutSucceeded) + { + Console.Error.WriteLine( + $"warning: worktree checkout failed for '{fullPath}'.\n" + + $"The worktree may not be fully initialized. Run 'gvfs mount \"{fullPath}\"' to recover."); + return; + } + // Hydrate .gitattributes — copy from the primary enlistment. if (wtInfo?.SharedGitDir != null) { @@ -363,8 +444,13 @@ private static void MountNewWorktree(string[] args) } } - // Now mount GVFS — the index exists for GitIndexProjection - ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false); + // Mount GVFS with retry for transient contention (e.g. concurrent adds) + if (!TryMountWithRetry(fullPath)) + { + Console.Error.WriteLine( + $"warning: failed to mount GVFS for worktree '{fullPath}' after multiple attempts.\n" + + $"Files may not be visible. Run 'gvfs mount \"{fullPath}\"' to recover."); + } } } @@ -383,7 +469,12 @@ private static void MountMovedWorktree(string[] args) string dotGitFile = Path.Combine(fullPath, ".git"); if (File.Exists(dotGitFile)) { - ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false); + if (!TryMountWithRetry(fullPath)) + { + Console.Error.WriteLine( + $"warning: failed to mount GVFS for moved worktree '{fullPath}' after multiple attempts.\n" + + $"Files may not be visible. Run 'gvfs mount \"{fullPath}\"' to recover."); + } } } } diff --git a/GVFS/GVFS.Hooks/Program.cs b/GVFS/GVFS.Hooks/Program.cs index c04f0c778..e9e3fb537 100644 --- a/GVFS/GVFS.Hooks/Program.cs +++ b/GVFS/GVFS.Hooks/Program.cs @@ -508,6 +508,28 @@ private static bool IsAlias(string command) return !string.IsNullOrEmpty(result.Output); } + /// + /// Extracts the git exit code from hook args. Git appends --exit_code=N + /// to post-command hook arguments. Returns null if the argument is + /// missing or unparseable — callers decide what "no exit code" means + /// for their use case. + /// + private static int? GetHookExitCode(string[] args) + { + for (int i = args.Length - 1; i >= 0; i--) + { + if (args[i].StartsWith("--exit_code=")) + { + if (int.TryParse(args[i].Substring("--exit_code=".Length), out int code)) + { + return code; + } + } + } + + return null; + } + private static string GetGitCommandSessionId() { try From f0c8cb813b71b56164fcdc7cb6e4398318ad2695 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 9 Apr 2026 09:25:15 -0700 Subject: [PATCH 07/25] Migrate from CommandLineParser to System.CommandLine 2.0.5 Replace reflection-based CommandLineParser 2.6.0 with Microsoft's System.CommandLine 2.0.5 stable release. This eliminates runtime reflection for CLI parsing, a prerequisite for NativeAOT compilation. All 16 GVFS verbs, FastFetch, GVFS.Mount, and LockHolder migrated. Uses SetAction+ParseResult pattern (2.0.x stable API). Shared helpers in GVFSVerb reduce boilerplate while preserving per-verb option binding. Replaces System.CommandLine's built-in --version (assembly reflection, not AOT-safe) with custom --version option routing to same code as 'gvfs version' subcommand via ProcessHelper.GetCurrentProcessVersion(). Adds 'help' subcommand for backward compatibility with old CLI. Case-insensitive verb matching preserved via args normalization. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- Directory.Packages.props | 5 +- GVFS/FastFetch/FastFetch.csproj | 2 +- GVFS/FastFetch/FastFetchVerb.cs | 205 ++++++++++-------- GVFS/FastFetch/Program.cs | 6 +- .../AcquireGVFSLock.cs | 24 +- .../GVFS.FunctionalTests.LockHolder.csproj | 2 +- .../Program.cs | 6 +- GVFS/GVFS.Mount/GVFS.Mount.csproj | 2 +- GVFS/GVFS.Mount/InProcessMountVerb.cs | 96 ++++---- GVFS/GVFS.Mount/Program.cs | 6 +- .../CommandLine/VersionOutputTests.cs | 73 +++++++ GVFS/GVFS/CommandLine/CacheServerVerb.cs | 42 +++- GVFS/GVFS/CommandLine/CacheVerb.cs | 17 +- GVFS/GVFS/CommandLine/CloneVerb.cs | 115 ++++++---- GVFS/GVFS/CommandLine/ConfigVerb.cs | 74 +++++-- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 63 +++--- GVFS/GVFS/CommandLine/DiagnoseVerb.cs | 17 +- GVFS/GVFS/CommandLine/GVFSVerb.cs | 96 +++++++- GVFS/GVFS/CommandLine/HealthVerb.cs | 50 +++-- GVFS/GVFS/CommandLine/LogVerb.cs | 35 ++- GVFS/GVFS/CommandLine/MountVerb.cs | 48 ++-- GVFS/GVFS/CommandLine/PrefetchVerb.cs | 118 ++++++---- GVFS/GVFS/CommandLine/RepairVerb.cs | 36 +-- GVFS/GVFS/CommandLine/ServiceVerb.cs | 44 ++-- GVFS/GVFS/CommandLine/SparseVerb.cs | 110 ++++++---- GVFS/GVFS/CommandLine/StatusVerb.cs | 18 +- GVFS/GVFS/CommandLine/UnmountVerb.cs | 36 +-- GVFS/GVFS/CommandLine/UpgradeVerb.cs | 45 ++-- GVFS/GVFS/GVFS.csproj | 2 +- GVFS/GVFS/Program.cs | 176 ++++++++------- 30 files changed, 1017 insertions(+), 552 deletions(-) create mode 100644 GVFS/GVFS.UnitTests/CommandLine/VersionOutputTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 2e1b042d8..3d83a1748 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,9 +5,6 @@ - - - @@ -47,7 +44,7 @@ Future packages: pre-declared for Phase 2+ branches to avoid merge conflicts on this file. Not yet referenced by any project. --> - + diff --git a/GVFS/FastFetch/FastFetch.csproj b/GVFS/FastFetch/FastFetch.csproj index a2584da8b..960fdda4e 100644 --- a/GVFS/FastFetch/FastFetch.csproj +++ b/GVFS/FastFetch/FastFetch.csproj @@ -12,7 +12,7 @@ - + diff --git a/GVFS/FastFetch/FastFetchVerb.cs b/GVFS/FastFetch/FastFetchVerb.cs index f08f735e7..e4b14b485 100644 --- a/GVFS/FastFetch/FastFetchVerb.cs +++ b/GVFS/FastFetch/FastFetchVerb.cs @@ -1,14 +1,13 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Common.Prefetch; using GVFS.Common.Tracing; using System; +using System.CommandLine; namespace FastFetch { - [Verb("fastfetch", HelpText = "Fast-fetch a branch")] public class FastFetchVerb { // Testing has shown that more than 16 download threads does not improve @@ -19,131 +18,149 @@ public class FastFetchVerb private const int ExitFailure = 1; private const int ExitSuccess = 0; - [Option( - 'c', - "commit", - Required = false, - HelpText = "Commit to fetch")] public string Commit { get; set; } - [Option( - 'b', - "branch", - Required = false, - HelpText = "Branch to fetch")] public string Branch { get; set; } - [Option( - "cache-server-url", - Required = false, - Default = "", - HelpText = "Defines the url of the cache server")] public string CacheServerUrl { get; set; } - [Option( - "chunk-size", - Required = false, - Default = 4000, - HelpText = "Sets the number of objects to be downloaded in a single pack")] public int ChunkSize { get; set; } - [Option( - "checkout", - Required = false, - Default = false, - HelpText = "Checkout the target commit into the working directory after fetching")] public bool Checkout { get; set; } - [Option( - "force-checkout", - Required = false, - Default = false, - HelpText = "Force FastFetch to checkout content as if the current repo had just been initialized." + - "This allows you to include more folders from the repo that were not originally checked out." + - "Can only be used with the --checkout option.")] public bool ForceCheckout { get; set; } - [Option( - "search-thread-count", - Required = false, - Default = 0, - HelpText = "Sets the number of threads to use for finding missing blobs. (0 for number of logical cores)")] public int SearchThreadCount { get; set; } - [Option( - "download-thread-count", - Required = false, - Default = 0, - HelpText = "Sets the number of threads to use for downloading. (0 for number of logical cores)")] public int DownloadThreadCount { get; set; } - [Option( - "index-thread-count", - Required = false, - Default = 0, - HelpText = "Sets the number of threads to use for indexing. (0 for number of logical cores)")] public int IndexThreadCount { get; set; } - [Option( - "checkout-thread-count", - Required = false, - Default = 0, - HelpText = "Sets the number of threads to use for checkout. (0 for number of logical cores)")] public int CheckoutThreadCount { get; set; } - [Option( - 'r', - "max-retries", - Required = false, - Default = 10, - HelpText = "Sets the maximum number of attempts for downloading a pack")] - public int MaxAttempts { get; set; } - [Option( - "git-path", - Default = "", - Required = false, - HelpText = "Sets the path and filename for git.exe if it isn't expected to be on %PATH%.")] public string GitBinPath { get; set; } - - [Option( - "folders", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of folders to fetch")] + public string FolderList { get; set; } - [Option( - "folders-list", - Required = false, - Default = "", - HelpText = "A file containing line-delimited list of folders to fetch")] public string FolderListFile { get; set; } - [Option( - "Allow-index-metadata-update-from-working-tree", - Required = false, - Default = false, - HelpText = "When specified, index metadata (file times and sizes) is updated from disk if not already in the index. " + - "This flag should only be used when the working tree is known to be in a good state. " + - "Do not use this flag if the working tree is not 100% known to be good as it would cause 'git status' to misreport.")] public bool AllowIndexMetadataUpdateFromWorkingTree { get; set; } - [Option( - "verbose", - Required = false, - Default = false, - HelpText = "Show all outputs on the console in addition to writing them to a log file")] public bool Verbose { get; set; } - [Option( - "parent-activity-id", - Required = false, - Default = "", - HelpText = "The GUID of the caller - used for telemetry purposes.")] public string ParentActivityId { get; set; } + public static RootCommand BuildRootCommand() + { + RootCommand rootCommand = new RootCommand("Fast-fetch a branch"); + + Option commitOption = new Option("--commit", new[] { "-c" }) { Description = "Commit to fetch" }; + rootCommand.Add(commitOption); + + Option branchOption = new Option("--branch", new[] { "-b" }) { Description = "Branch to fetch" }; + rootCommand.Add(branchOption); + + Option cacheServerUrlOption = new Option("--cache-server-url") + { + Description = "Defines the url of the cache server", + DefaultValueFactory = (_) => "" + }; + rootCommand.Add(cacheServerUrlOption); + + Option chunkSizeOption = new Option("--chunk-size") + { + Description = "Sets the number of objects to be downloaded in a single pack", + DefaultValueFactory = (_) => 4000 + }; + rootCommand.Add(chunkSizeOption); + + Option checkoutOption = new Option("--checkout") { Description = "Checkout the target commit into the working directory after fetching" }; + rootCommand.Add(checkoutOption); + + Option forceCheckoutOption = new Option("--force-checkout") { Description = "Force FastFetch to checkout content as if the current repo had just been initialized." }; + rootCommand.Add(forceCheckoutOption); + + Option searchThreadCountOption = new Option("--search-thread-count") { Description = "Sets the number of threads to use for finding missing blobs. (0 for number of logical cores)", DefaultValueFactory = (_) => 0 }; + rootCommand.Add(searchThreadCountOption); + + Option downloadThreadCountOption = new Option("--download-thread-count") { Description = "Sets the number of threads to use for downloading. (0 for number of logical cores)", DefaultValueFactory = (_) => 0 }; + rootCommand.Add(downloadThreadCountOption); + + Option indexThreadCountOption = new Option("--index-thread-count") { Description = "Sets the number of threads to use for indexing. (0 for number of logical cores)", DefaultValueFactory = (_) => 0 }; + rootCommand.Add(indexThreadCountOption); + + Option checkoutThreadCountOption = new Option("--checkout-thread-count") { Description = "Sets the number of threads to use for checkout. (0 for number of logical cores)", DefaultValueFactory = (_) => 0 }; + rootCommand.Add(checkoutThreadCountOption); + + Option maxRetriesOption = new Option("--max-retries", new[] { "-r" }) + { + Description = "Sets the maximum number of attempts for downloading a pack", + DefaultValueFactory = (_) => 10 + }; + rootCommand.Add(maxRetriesOption); + + Option gitPathOption = new Option("--git-path") + { + Description = "Sets the path and filename for git.exe if it isn't expected to be on %PATH%.", + DefaultValueFactory = (_) => "" + }; + rootCommand.Add(gitPathOption); + + Option foldersOption = new Option("--folders") + { + Description = "A semicolon-delimited list of folders to fetch", + DefaultValueFactory = (_) => "" + }; + rootCommand.Add(foldersOption); + + Option foldersListOption = new Option("--folders-list") + { + Description = "A file containing line-delimited list of folders to fetch", + DefaultValueFactory = (_) => "" + }; + rootCommand.Add(foldersListOption); + + Option allowIndexMetadataOption = new Option("--Allow-index-metadata-update-from-working-tree") { Description = "When specified, index metadata is updated from disk if not already in the index." }; + rootCommand.Add(allowIndexMetadataOption); + + Option verboseOption = new Option("--verbose") { Description = "Show all outputs on the console in addition to writing them to a log file" }; + rootCommand.Add(verboseOption); + + Option parentActivityIdOption = new Option("--parent-activity-id") + { + Description = "The GUID of the caller - used for telemetry purposes.", + DefaultValueFactory = (_) => "" + }; + rootCommand.Add(parentActivityIdOption); + + rootCommand.SetAction((ParseResult result) => + { + FastFetchVerb verb = new FastFetchVerb(); + verb.Commit = result.GetValue(commitOption); + verb.Branch = result.GetValue(branchOption); + verb.CacheServerUrl = result.GetValue(cacheServerUrlOption) ?? ""; + verb.ChunkSize = result.GetValue(chunkSizeOption); + verb.Checkout = result.GetValue(checkoutOption); + verb.ForceCheckout = result.GetValue(forceCheckoutOption); + verb.SearchThreadCount = result.GetValue(searchThreadCountOption); + verb.DownloadThreadCount = result.GetValue(downloadThreadCountOption); + verb.IndexThreadCount = result.GetValue(indexThreadCountOption); + verb.CheckoutThreadCount = result.GetValue(checkoutThreadCountOption); + verb.MaxAttempts = result.GetValue(maxRetriesOption); + verb.GitBinPath = result.GetValue(gitPathOption) ?? ""; + verb.FolderList = result.GetValue(foldersOption) ?? ""; + verb.FolderListFile = result.GetValue(foldersListOption) ?? ""; + verb.AllowIndexMetadataUpdateFromWorkingTree = result.GetValue(allowIndexMetadataOption); + verb.Verbose = result.GetValue(verboseOption); + verb.ParentActivityId = result.GetValue(parentActivityIdOption) ?? ""; + verb.Execute(); + }); + + return rootCommand; + } + public void Execute() { Environment.ExitCode = this.ExecuteWithExitCode(); diff --git a/GVFS/FastFetch/Program.cs b/GVFS/FastFetch/Program.cs index 03d94d1ec..11417b324 100644 --- a/GVFS/FastFetch/Program.cs +++ b/GVFS/FastFetch/Program.cs @@ -1,4 +1,4 @@ -using CommandLine; +using System.CommandLine; using GVFS.PlatformLoader; namespace FastFetch @@ -8,8 +8,8 @@ public class Program public static void Main(string[] args) { GVFSPlatformLoader.Initialize(); - Parser.Default.ParseArguments(args) - .WithParsed(fastFetch => fastFetch.Execute()); + RootCommand rootCommand = FastFetchVerb.BuildRootCommand(); + rootCommand.Parse(args).Invoke(); } } } diff --git a/GVFS/GVFS.FunctionalTests.LockHolder/AcquireGVFSLock.cs b/GVFS/GVFS.FunctionalTests.LockHolder/AcquireGVFSLock.cs index 2a64fdead..28b62720d 100644 --- a/GVFS/GVFS.FunctionalTests.LockHolder/AcquireGVFSLock.cs +++ b/GVFS/GVFS.FunctionalTests.LockHolder/AcquireGVFSLock.cs @@ -1,8 +1,8 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.NamedPipes; using GVFS.Platform.Windows; using System; +using System.CommandLine; using System.Diagnostics; using System.Runtime.InteropServices; @@ -12,13 +12,25 @@ public class AcquireGVFSLockVerb { private static string fullCommand = "GVFS.FunctionalTests.LockHolder"; - [Option( - "skip-release-lock", - Default = false, - Required = false, - HelpText = "Skip releasing the GVFS lock when exiting the program.")] public bool NoReleaseLock { get; set; } + public static RootCommand BuildRootCommand() + { + RootCommand rootCommand = new RootCommand(); + + Option skipReleaseLockOption = new Option("--skip-release-lock") { Description = "Skip releasing the GVFS lock when exiting the program." }; + rootCommand.Add(skipReleaseLockOption); + + rootCommand.SetAction((ParseResult result) => + { + AcquireGVFSLockVerb verb = new AcquireGVFSLockVerb(); + verb.NoReleaseLock = result.GetValue(skipReleaseLockOption); + verb.Execute(); + }); + + return rootCommand; + } + public void Execute() { string errorMessage; diff --git a/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj b/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj index a83b42fb3..b2f2ca1d6 100644 --- a/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj +++ b/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj @@ -6,7 +6,7 @@ - + diff --git a/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs b/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs index 4d6fffbf8..ed0693cba 100644 --- a/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs +++ b/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs @@ -1,4 +1,4 @@ -using CommandLine; +using System.CommandLine; namespace GVFS.FunctionalTests.LockHolder { @@ -6,8 +6,8 @@ public class Program { public static void Main(string[] args) { - Parser.Default.ParseArguments(args) - .WithParsed(acquireGVFSLock => acquireGVFSLock.Execute()); + RootCommand rootCommand = AcquireGVFSLockVerb.BuildRootCommand(); + rootCommand.Parse(args).Invoke(); } } } diff --git a/GVFS/GVFS.Mount/GVFS.Mount.csproj b/GVFS/GVFS.Mount/GVFS.Mount.csproj index 35a3c2722..56526ac3b 100644 --- a/GVFS/GVFS.Mount/GVFS.Mount.csproj +++ b/GVFS/GVFS.Mount/GVFS.Mount.csproj @@ -16,7 +16,7 @@ - + diff --git a/GVFS/GVFS.Mount/InProcessMountVerb.cs b/GVFS/GVFS.Mount/InProcessMountVerb.cs index 17d373b7c..309d56fbd 100644 --- a/GVFS/GVFS.Mount/InProcessMountVerb.cs +++ b/GVFS/GVFS.Mount/InProcessMountVerb.cs @@ -1,16 +1,15 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Common.Tracing; using System; -using System.ComponentModel; +using System.CommandLine; +using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; namespace GVFS.Mount { - [Verb("mount", HelpText = "Starts the background mount process")] public class InProcessMountVerb { private TextWriter output; @@ -25,53 +24,70 @@ public InProcessMountVerb() public ReturnCode ReturnCode { get; private set; } - [Option( - 'v', - GVFSConstants.VerbParameters.Mount.Verbosity, - Default = GVFSConstants.VerbParameters.Mount.DefaultVerbosity, - Required = false, - HelpText = "Sets the verbosity of console logging. Accepts: Verbose, Informational, Warning, Error")] public string Verbosity { get; set; } - [Option( - 'k', - GVFSConstants.VerbParameters.Mount.Keywords, - Default = GVFSConstants.VerbParameters.Mount.DefaultKeywords, - Required = false, - HelpText = "A CSV list of logging filter keywords. Accepts: Any, Network")] public string KeywordsCsv { get; set; } - [Option( - 'd', - GVFSConstants.VerbParameters.Mount.DebugWindow, - Default = false, - Required = false, - HelpText = "Show the debug window. By default, all output is written to a log file and no debug window is shown.")] public bool ShowDebugWindow { get; set; } - [Option( - 's', - GVFSConstants.VerbParameters.Mount.StartedByService, - Default = "false", - Required = false, - HelpText = "Service initiated mount.")] - public string StartedByService { get; set; } - - [Option( - 'b', - GVFSConstants.VerbParameters.Mount.StartedByVerb, - Default = false, - Required = false, - HelpText = "Verb initiated mount.")] + public string StartedByService { get; set; } + public bool StartedByVerb { get; set; } - [Value( - 0, - Required = true, - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public string EnlistmentRootPathParameter { get; set; } + public static RootCommand BuildRootCommand() + { + RootCommand rootCommand = new RootCommand("Starts the background mount process"); + + Argument enlistmentRootPathArg = new Argument("enlistment-root-path") + { + Arity = ArgumentArity.ExactlyOne + }; + rootCommand.Add(enlistmentRootPathArg); + + Option verbosityOption = new Option("--verbosity", new[] { "-v" }) + { + Description = "Sets the verbosity of console logging", + DefaultValueFactory = (_) => GVFSConstants.VerbParameters.Mount.DefaultVerbosity + }; + rootCommand.Add(verbosityOption); + + Option keywordsOption = new Option("--keywords", new[] { "-k" }) + { + Description = "A CSV list of logging filter keywords", + DefaultValueFactory = (_) => GVFSConstants.VerbParameters.Mount.DefaultKeywords + }; + rootCommand.Add(keywordsOption); + + Option debugWindowOption = new Option("--debug-window", new[] { "-d" }) { Description = "Show the debug window" }; + rootCommand.Add(debugWindowOption); + + Option startedByServiceOption = new Option("--StartedByService", new[] { "-s" }) + { + Description = "Service initiated mount.", + DefaultValueFactory = (_) => "false" + }; + rootCommand.Add(startedByServiceOption); + + Option startedByVerbOption = new Option("--StartedByVerb", new[] { "-b" }) { Description = "Verb initiated mount." }; + rootCommand.Add(startedByVerbOption); + + rootCommand.SetAction((ParseResult result) => + { + InProcessMountVerb verb = new InProcessMountVerb(); + verb.EnlistmentRootPathParameter = result.GetValue(enlistmentRootPathArg); + verb.Verbosity = result.GetValue(verbosityOption) ?? ""; + verb.KeywordsCsv = result.GetValue(keywordsOption) ?? ""; + verb.ShowDebugWindow = result.GetValue(debugWindowOption); + verb.StartedByService = result.GetValue(startedByServiceOption) ?? "false"; + verb.StartedByVerb = result.GetValue(startedByVerbOption); + verb.Execute(); + }); + + return rootCommand; + } + public void InitializeDefaultParameterValues() { this.Verbosity = GVFSConstants.VerbParameters.Mount.DefaultVerbosity; diff --git a/GVFS/GVFS.Mount/Program.cs b/GVFS/GVFS.Mount/Program.cs index 87a96922d..12f61c807 100644 --- a/GVFS/GVFS.Mount/Program.cs +++ b/GVFS/GVFS.Mount/Program.cs @@ -1,4 +1,4 @@ -using CommandLine; +using System.CommandLine; using GVFS.PlatformLoader; using System; @@ -11,8 +11,8 @@ public static void Main(string[] args) GVFSPlatformLoader.Initialize(); try { - Parser.Default.ParseArguments(args) - .WithParsed(mount => mount.Execute()); + RootCommand rootCommand = InProcessMountVerb.BuildRootCommand(); + rootCommand.Parse(args).Invoke(); } catch (MountAbortedException e) { diff --git a/GVFS/GVFS.UnitTests/CommandLine/VersionOutputTests.cs b/GVFS/GVFS.UnitTests/CommandLine/VersionOutputTests.cs new file mode 100644 index 000000000..c94ece57c --- /dev/null +++ b/GVFS/GVFS.UnitTests/CommandLine/VersionOutputTests.cs @@ -0,0 +1,73 @@ +using NUnit.Framework; +using System; +using System.CommandLine; +using System.IO; +using System.Text.RegularExpressions; + +namespace GVFS.UnitTests.CommandLine +{ + [TestFixture] + public class VersionOutputTests + { + // Matches "GVFS X.Y.Z.W" with optional "+commitid" suffix + private static readonly Regex VersionPattern = new Regex( + @"^GVFS \d+\.\d+\.\d+\.\d+(\+\S+)?$", + RegexOptions.Compiled); + + [TestCase("version")] + [TestCase("--version")] + public void VersionOutputMatchesExpectedFormat(string arg) + { + RootCommand rootCommand = GVFS.Program.BuildRootCommand(); + + string output; + TextWriter originalOut = Console.Out; + try + { + using (StringWriter sw = new StringWriter()) + { + Console.SetOut(sw); + rootCommand.Parse(new[] { arg }).Invoke(); + output = sw.ToString().Trim(); + } + } + finally + { + Console.SetOut(originalOut); + } + + Assert.That( + VersionPattern.IsMatch(output), + "Expected 'GVFS X.Y.Z.W' format but got: " + output); + } + + [Test] + public void VersionAndDashDashVersionProduceSameOutput() + { + RootCommand rootCommand = GVFS.Program.BuildRootCommand(); + + string versionOutput = CaptureOutput(rootCommand, "version"); + string dashDashOutput = CaptureOutput(rootCommand, "--version"); + + Assert.AreEqual(versionOutput, dashDashOutput); + } + + private static string CaptureOutput(RootCommand rootCommand, string arg) + { + TextWriter originalOut = Console.Out; + try + { + using (StringWriter sw = new StringWriter()) + { + Console.SetOut(sw); + rootCommand.Parse(new[] { arg }).Invoke(); + return sw.ToString().Trim(); + } + } + finally + { + Console.SetOut(originalOut); + } + } + } +} diff --git a/GVFS/GVFS/CommandLine/CacheServerVerb.cs b/GVFS/GVFS/CommandLine/CacheServerVerb.cs index 9fedad0b0..2e0735b76 100644 --- a/GVFS/GVFS/CommandLine/CacheServerVerb.cs +++ b/GVFS/GVFS/CommandLine/CacheServerVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Http; using GVFS.Common.Tracing; @@ -8,27 +7,46 @@ namespace GVFS.CommandLine { - [Verb(CacheVerbName, HelpText = "Manages the cache server configuration for an existing repo.")] public class CacheServerVerb : GVFSVerb.ForExistingEnlistment { private const string CacheVerbName = "cache-server"; - [Option( - "set", - Default = null, - Required = false, - HelpText = "Sets the cache server to the supplied name or url")] public string CacheToSet { get; set; } - [Option("get", Required = false, HelpText = "Outputs the current cache server information. This is the default.")] public bool OutputCurrentInfo { get; set; } - [Option( - "list", - Required = false, - HelpText = "List available cache servers for the remote repo")] public bool ListCacheServers { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("cache-server", "Manages the cache server configuration for an existing repo."); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option setOption = new System.CommandLine.Option("--set") { Description = "Sets the cache server to the supplied name or url" }; + cmd.Add(setOption); + + System.CommandLine.Option getOption = new System.CommandLine.Option("--get") { Description = "Outputs the current cache server information. This is the default." }; + cmd.Add(getOption); + + System.CommandLine.Option listOption = new System.CommandLine.Option("--list") { Description = "List available cache servers for the remote repo" }; + cmd.Add(listOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.CacheToSet = result.GetValue(setOption); + verb.OutputCurrentInfo = result.GetValue(getOption); + verb.ListCacheServers = result.GetValue(listOption); + }); + + return cmd; + } + protected override string VerbName { get { return CacheVerbName; } diff --git a/GVFS/GVFS/CommandLine/CacheVerb.cs b/GVFS/GVFS/CommandLine/CacheVerb.cs index 70c8a65fd..b576d4275 100644 --- a/GVFS/GVFS/CommandLine/CacheVerb.cs +++ b/GVFS/GVFS/CommandLine/CacheVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Tracing; @@ -8,7 +7,6 @@ namespace GVFS.CommandLine { - [Verb(CacheVerb.CacheVerbName, HelpText = "Display information about the GVFS shared object cache")] public class CacheVerb : GVFSVerb.ForExistingEnlistment { private const string CacheVerbName = "cache"; @@ -17,6 +15,21 @@ public CacheVerb() { } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("cache", "Display information about the GVFS shared object cache"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true); + + return cmd; + } + protected override string VerbName { get { return CacheVerbName; } diff --git a/GVFS/GVFS/CommandLine/CloneVerb.cs b/GVFS/GVFS/CommandLine/CloneVerb.cs index bd37c7d4b..bdebe311a 100644 --- a/GVFS/GVFS/CommandLine/CloneVerb.cs +++ b/GVFS/GVFS/CommandLine/CloneVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Git; @@ -15,67 +14,99 @@ namespace GVFS.CommandLine { - [Verb(CloneVerb.CloneVerbName, HelpText = "Clone a git repo and mount it as a GVFS virtual repo")] public class CloneVerb : GVFSVerb { private const string CloneVerbName = "clone"; - [Value( - 0, - Required = true, - MetaName = "Repository URL", - HelpText = "The url of the repo")] public string RepositoryURL { get; set; } - [Value( - 1, - Required = false, - Default = "", - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public override string EnlistmentRootPathParameter { get; set; } - [Option( - "cache-server-url", - Required = false, - Default = null, - HelpText = "The url or friendly name of the cache server")] public string CacheServerUrl { get; set; } - [Option( - 'b', - "branch", - Required = false, - HelpText = "Branch to checkout after clone")] public string Branch { get; set; } - [Option( - "single-branch", - Required = false, - Default = false, - HelpText = "Use this option to only download metadata for the branch that will be checked out")] public bool SingleBranch { get; set; } - [Option( - "no-mount", - Required = false, - Default = false, - HelpText = "Use this option to only clone, but not mount the repo")] public bool NoMount { get; set; } - [Option( - "no-prefetch", - Required = false, - Default = false, - HelpText = "Use this option to not prefetch commits after clone")] public bool NoPrefetch { get; set; } - [Option( - "local-cache-path", - Required = false, - HelpText = "Use this option to override the path for the local GVFS cache.")] public string LocalCacheRoot { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("clone", "Clone a git repo and mount it as a GVFS virtual repo"); + + System.CommandLine.Argument repoUrlArg = new System.CommandLine.Argument("repository-url") + { + Description = "The url of the repo", + Arity = System.CommandLine.ArgumentArity.ExactlyOne, + }; + cmd.Add(repoUrlArg); + + System.CommandLine.Argument enlistmentArg = new System.CommandLine.Argument("enlistment-root-path") + { + Description = "Full or relative path to the GVFS enlistment root", + Arity = System.CommandLine.ArgumentArity.ZeroOrOne, + DefaultValueFactory = (_) => "", + }; + cmd.Add(enlistmentArg); + + System.CommandLine.Option cacheServerOption = new System.CommandLine.Option("--cache-server-url") { Description = "The url or friendly name of the cache server" }; + cmd.Add(cacheServerOption); + + System.CommandLine.Option branchOption = new System.CommandLine.Option("--branch", new[] { "-b" }) { Description = "Branch to checkout after clone" }; + cmd.Add(branchOption); + + System.CommandLine.Option singleBranchOption = new System.CommandLine.Option("--single-branch") { Description = "Use this option to only download metadata for the branch that will be checked out" }; + cmd.Add(singleBranchOption); + + System.CommandLine.Option noMountOption = new System.CommandLine.Option("--no-mount") { Description = "Use this option to only clone, but not mount the repo" }; + cmd.Add(noMountOption); + + System.CommandLine.Option noPrefetchOption = new System.CommandLine.Option("--no-prefetch") { Description = "Use this option to not prefetch commits after clone" }; + cmd.Add(noPrefetchOption); + + System.CommandLine.Option localCacheOption = new System.CommandLine.Option("--local-cache-path") { Description = "Use this option to override the path for the local GVFS cache." }; + cmd.Add(localCacheOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + cmd.SetAction((System.CommandLine.ParseResult result) => + { + CloneVerb verb = new CloneVerb(); + verb.RepositoryURL = result.GetValue(repoUrlArg); + verb.EnlistmentRootPathParameter = result.GetValue(enlistmentArg) ?? ""; + if (verb.EnlistmentRootPathParameter.StartsWith("-")) + { + Console.Error.WriteLine($"Unrecognized option '{verb.EnlistmentRootPathParameter}'"); + Environment.Exit((int)ReturnCode.ParsingError); + } + + verb.CacheServerUrl = result.GetValue(cacheServerOption); + verb.Branch = result.GetValue(branchOption); + verb.SingleBranch = result.GetValue(singleBranchOption); + verb.NoMount = result.GetValue(noMountOption); + verb.NoPrefetch = result.GetValue(noPrefetchOption); + verb.LocalCacheRoot = result.GetValue(localCacheOption); + + GVFSVerb.ApplyInternalParameters(verb, result, internalOption); + try + { + verb.Execute(); + } + catch (GVFSVerb.VerbAbortedException) + { + } + + Environment.Exit((int)verb.ReturnCode); + }); + + return cmd; + } + protected override string VerbName { get { return CloneVerbName; } diff --git a/GVFS/GVFS/CommandLine/ConfigVerb.cs b/GVFS/GVFS/CommandLine/ConfigVerb.cs index 0773e7478..8626b33b1 100644 --- a/GVFS/GVFS/CommandLine/ConfigVerb.cs +++ b/GVFS/GVFS/CommandLine/ConfigVerb.cs @@ -1,44 +1,74 @@ -using CommandLine; using GVFS.Common; using System; using System.Collections.Generic; namespace GVFS.CommandLine { - [Verb(ConfigVerbName, HelpText = "Get and set GVFS options.")] public class ConfigVerb : GVFSVerb.ForNoEnlistment { private const string ConfigVerbName = "config"; private LocalGVFSConfig localConfig; - [Option( - 'l', - "list", - Required = false, - HelpText = "Show all settings")] public bool List { get; set; } - [Option( - 'd', - "delete", - Required = false, - HelpText = "Name of setting to delete")] public string KeyToDelete { get; set; } - [Value( - 0, - Required = false, - MetaName = "Setting name", - HelpText = "Name of setting that is to be set or read")] public string Key { get; set; } - [Value( - 1, - Required = false, - MetaName = "Setting value", - HelpText = "Value of setting to be set")] public string Value { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("config", "Get and set GVFS options."); + + System.CommandLine.Option listOption = new System.CommandLine.Option("--list", new[] { "-l" }) { Description = "Show all settings" }; + cmd.Add(listOption); + + System.CommandLine.Option deleteOption = new System.CommandLine.Option("--delete", new[] { "-d" }) { Description = "Name of setting to delete" }; + cmd.Add(deleteOption); + + System.CommandLine.Argument keyArg = new System.CommandLine.Argument("setting-name") + { + Description = "Name of setting that is to be set or read", + Arity = System.CommandLine.ArgumentArity.ZeroOrOne, + DefaultValueFactory = (_) => "", + }; + cmd.Add(keyArg); + + System.CommandLine.Argument valueArg = new System.CommandLine.Argument("setting-value") + { + Description = "Value of setting to be set", + Arity = System.CommandLine.ArgumentArity.ZeroOrOne, + DefaultValueFactory = (_) => "", + }; + cmd.Add(valueArg); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + cmd.SetAction((System.CommandLine.ParseResult result) => + { + ConfigVerb verb = new ConfigVerb(); + verb.List = result.GetValue(listOption); + verb.KeyToDelete = result.GetValue(deleteOption); + verb.Key = result.GetValue(keyArg) ?? ""; + verb.Value = result.GetValue(valueArg) ?? ""; + + GVFSVerb.ApplyInternalParameters(verb, result, internalOption); + try + { + verb.Execute(); + } + catch (GVFSVerb.VerbAbortedException) + { + } + + Environment.Exit((int)verb.ReturnCode); + }); + + return cmd; + } + protected override string VerbName { get { return ConfigVerbName; } diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 5f9702239..ee2ef4546 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Database; using GVFS.Common.FileSystem; @@ -17,7 +16,6 @@ namespace GVFS.CommandLine { - [Verb(DehydrateVerb.DehydrateVerbName, HelpText = "EXPERIMENTAL FEATURE - Fully dehydrate a GVFS repo")] public class DehydrateVerb : GVFSVerb.ForExistingEnlistment { private const string DehydrateVerbName = "dehydrate"; @@ -25,40 +23,55 @@ public class DehydrateVerb : GVFSVerb.ForExistingEnlistment private PhysicalFileSystem fileSystem = new PhysicalFileSystem(); - [Option( - "confirm", - Default = false, - Required = false, - HelpText = "Pass in this flag to actually do the dehydrate")] public bool Confirmed { get; set; } - [Option( - "no-status", - Default = false, - Required = false, - HelpText = "Do not require a clean git status when dehydrating. To prevent data loss, this option cannot be combined with --folders option.")] public bool NoStatus { get; set; } - [Option( - "folders", - Default = "", - Required = false, - HelpText = "A semicolon (" + FolderListSeparator + ") delimited list of folders to dehydrate. " - + "Each folder must be relative to the repository root. " - + "When omitted (without --full), all root-level folders are dehydrated.")] public string Folders { get; set; } - [Option( - "full", - Default = false, - Required = false, - HelpText = "Perform a full dehydration that unmounts, backs up the entire src folder, and re-creates the virtualization root from scratch. " - + "Without this flag, the default behavior dehydrates individual folders which is faster and does not require a full unmount.")] public bool Full { get; set; } public string RunningVerbName { get; set; } = DehydrateVerbName; public string ActionName { get; set; } = DehydrateVerbName; + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("dehydrate", "EXPERIMENTAL FEATURE - Fully dehydrate a GVFS repo"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option confirmOption = new System.CommandLine.Option("--confirm") { Description = "Pass in this flag to actually do the dehydrate" }; + cmd.Add(confirmOption); + + System.CommandLine.Option noStatusOption = new System.CommandLine.Option("--no-status") { Description = "Do not require a clean git status when dehydrating. To prevent data loss, this option cannot be combined with --folders option." }; + cmd.Add(noStatusOption); + + System.CommandLine.Option foldersOption = new System.CommandLine.Option("--folders") + { + Description = "A semicolon (;) delimited list of folders to dehydrate. Each folder must be relative to the repository root. When omitted (without --full), all root-level folders are dehydrated.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(foldersOption); + + System.CommandLine.Option fullOption = new System.CommandLine.Option("--full") { Description = "Perform a full dehydration that unmounts, backs up the entire src folder, and re-creates the virtualization root from scratch." }; + cmd.Add(fullOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.Confirmed = result.GetValue(confirmOption); + verb.NoStatus = result.GetValue(noStatusOption); + verb.Folders = result.GetValue(foldersOption) ?? ""; + verb.Full = result.GetValue(fullOption); + }); + + return cmd; + } + /// /// True if another verb (e.g. 'gvfs sparse') has already validated that status is clean /// diff --git a/GVFS/GVFS/CommandLine/DiagnoseVerb.cs b/GVFS/GVFS/CommandLine/DiagnoseVerb.cs index 1d3a71639..5f0410294 100644 --- a/GVFS/GVFS/CommandLine/DiagnoseVerb.cs +++ b/GVFS/GVFS/CommandLine/DiagnoseVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Git; @@ -12,7 +11,6 @@ namespace GVFS.CommandLine { - [Verb(DiagnoseVerb.DiagnoseVerbName, HelpText = "Diagnose issues with a GVFS repo")] public class DiagnoseVerb : GVFSVerb.ForExistingEnlistment { private const string DiagnoseVerbName = "diagnose"; @@ -26,6 +24,21 @@ public DiagnoseVerb() : base(false) this.fileSystem = new PhysicalFileSystem(); } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("diagnose", "Diagnose issues with a GVFS repo"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true); + + return cmd; + } + protected override string VerbName { get { return DiagnoseVerbName; } diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs index c254a92d1..be8c86fab 100644 --- a/GVFS/GVFS/CommandLine/GVFSVerb.cs +++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Git; @@ -38,10 +37,6 @@ public GVFSVerb(bool validateOrigin = true) public abstract string EnlistmentRootPathParameter { get; set; } - [Option( - GVFSConstants.VerbParameters.InternalUseOnly, - Required = false, - HelpText = "This parameter is reserved for internal use.")] public string InternalParameters { set @@ -817,18 +812,97 @@ private bool TryValidateGVFSVersion(GVFSEnlistment enlistment, ITracer tracer, S return false; } + internal static System.CommandLine.Option CreateInternalParametersOption() + { + return new System.CommandLine.Option("--internal_use_only") { Description = "This parameter is reserved for internal use." }; + } + + internal static System.CommandLine.Argument CreateEnlistmentPathArgument(bool required = false) + { + System.CommandLine.Argument arg = new System.CommandLine.Argument("enlistment-root-path"); + arg.Description = "Full or relative path to the GVFS enlistment root"; + arg.Arity = required ? System.CommandLine.ArgumentArity.ExactlyOne : System.CommandLine.ArgumentArity.ZeroOrOne; + if (!required) + { + arg.DefaultValueFactory = (_) => ""; + } + + return arg; + } + + internal static void ApplyInternalParameters(GVFSVerb verb, System.CommandLine.ParseResult result, System.CommandLine.Option internalOption) + { + string internalParams = result.GetValue(internalOption); + if (!string.IsNullOrEmpty(internalParams)) + { + verb.InternalParameters = internalParams; + } + } + + internal static void SetActionForVerbWithEnlistment( + System.CommandLine.Command cmd, + System.CommandLine.Argument enlistmentArg, + System.CommandLine.Option internalOption, + bool defaultEnlistmentPathToCwd, + Action setVerbProperties = null) where T : GVFSVerb, new() + { + cmd.SetAction((System.CommandLine.ParseResult result) => + { + T verb = new T(); + verb.EnlistmentRootPathParameter = result.GetValue(enlistmentArg) ?? ""; + if (verb.EnlistmentRootPathParameter.StartsWith("-")) + { + Console.Error.WriteLine($"Unrecognized option '{verb.EnlistmentRootPathParameter}'"); + Environment.Exit((int)ReturnCode.ParsingError); + } + + if (defaultEnlistmentPathToCwd && string.IsNullOrEmpty(verb.EnlistmentRootPathParameter)) + { + verb.EnlistmentRootPathParameter = Environment.CurrentDirectory; + } + + setVerbProperties?.Invoke(verb, result); + ApplyInternalParameters(verb, result, internalOption); + try + { + verb.Execute(); + } + catch (VerbAbortedException) + { + } + + Environment.Exit((int)verb.ReturnCode); + }); + } + + internal static void SetActionForNoEnlistment( + System.CommandLine.Command cmd, + System.CommandLine.Option internalOption, + Action setVerbProperties = null) where T : ForNoEnlistment, new() + { + cmd.SetAction((System.CommandLine.ParseResult result) => + { + T verb = new T(); + setVerbProperties?.Invoke(verb, result); + ApplyInternalParameters(verb, result, internalOption); + try + { + verb.Execute(); + } + catch (VerbAbortedException) + { + } + + Environment.Exit((int)verb.ReturnCode); + }); + } + public abstract class ForExistingEnlistment : GVFSVerb { public ForExistingEnlistment(bool validateOrigin = true) : base(validateOrigin) { } - [Value( - 0, - Required = false, - Default = "", - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public override string EnlistmentRootPathParameter { get; set; } public sealed override void Execute() diff --git a/GVFS/GVFS/CommandLine/HealthVerb.cs b/GVFS/GVFS/CommandLine/HealthVerb.cs index 7f4a42f8e..9f9ed2109 100644 --- a/GVFS/GVFS/CommandLine/HealthVerb.cs +++ b/GVFS/GVFS/CommandLine/HealthVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.NamedPipes; @@ -11,32 +10,51 @@ namespace GVFS.CommandLine { - [Verb(HealthVerb.HealthVerbName, HelpText = "EXPERIMENTAL FEATURE - Measure the health of the repository")] public class HealthVerb : GVFSVerb.ForExistingEnlistment { private const string HealthVerbName = "health"; private const decimal MaximumHealthyHydration = 0.5m; - [Option( - 'n', - Required = false, - HelpText = "Only display the most hydrated directories in the output")] public int DirectoryDisplayCount { get; set; } = 5; - [Option( - 'd', - "directory", - Required = false, - HelpText = "Get the health of a specific directory (default is the current working directory")] public string Directory { get; set; } - [Option( - 's', - "status", - Required = false, - HelpText = "Display only the hydration % of the repository, similar to 'git status' in a repository with sparse-checkout")] public bool StatusOnly { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("health", "EXPERIMENTAL FEATURE - Measure the health of the repository"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option displayCountOption = new System.CommandLine.Option("-n") + { + Description = "Only display the most hydrated directories in the output", + DefaultValueFactory = (_) => 5, + }; + cmd.Add(displayCountOption); + + System.CommandLine.Option directoryOption = new System.CommandLine.Option("--directory", new[] { "-d" }) { Description = "Get the health of a specific directory (default is the current working directory)" }; + cmd.Add(directoryOption); + + System.CommandLine.Option statusOption = new System.CommandLine.Option("--status", new[] { "-s" }) { Description = "Display only the hydration % of the repository, similar to 'git status' in a repository with sparse-checkout" }; + cmd.Add(statusOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.DirectoryDisplayCount = result.GetValue(displayCountOption); + verb.Directory = result.GetValue(directoryOption); + verb.StatusOnly = result.GetValue(statusOption); + }); + + return cmd; + } + protected override string VerbName => HealthVerbName; internal PhysicalFileSystem FileSystem { get; set; } = new PhysicalFileSystem(); diff --git a/GVFS/GVFS/CommandLine/LogVerb.cs b/GVFS/GVFS/CommandLine/LogVerb.cs index 416a91c6f..a736e3bde 100644 --- a/GVFS/GVFS/CommandLine/LogVerb.cs +++ b/GVFS/GVFS/CommandLine/LogVerb.cs @@ -1,31 +1,42 @@ -using CommandLine; using GVFS.Common; +using System; using System.Collections.Generic; using System.IO; using System.Linq; namespace GVFS.CommandLine { - [Verb(LogVerb.LogVerbName, HelpText = "Show the most recent GVFS log files")] public class LogVerb : GVFSVerb { private const string LogVerbName = "log"; private static readonly int LogNameConsoleOutputFormatWidth = GetMaxLogNameLength(); - [Value( - 0, - Required = false, - Default = "", - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public override string EnlistmentRootPathParameter { get; set; } - [Option( - "type", - Default = null, - HelpText = "The type of log file to display on the console")] public string LogType { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("log", "Show the most recent GVFS log files"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option logTypeOption = new System.CommandLine.Option("--type") { Description = "The type of log file to display on the console" }; + cmd.Add(logTypeOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.LogType = result.GetValue(logTypeOption); + }); + + return cmd; + } + protected override string VerbName { get { return LogVerbName; } diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index 2fa730a8e..8ee8a51ac 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Http; using GVFS.Common.NamedPipes; @@ -10,27 +9,48 @@ namespace GVFS.CommandLine { - [Verb(MountVerb.MountVerbName, HelpText = "Mount a GVFS virtual repo")] public class MountVerb : GVFSVerb.ForExistingEnlistment { private const string MountVerbName = "mount"; - [Option( - 'v', - GVFSConstants.VerbParameters.Mount.Verbosity, - Default = GVFSConstants.VerbParameters.Mount.DefaultVerbosity, - Required = false, - HelpText = "Sets the verbosity of console logging. Accepts: Verbose, Informational, Warning, Error")] public string Verbosity { get; set; } - [Option( - 'k', - GVFSConstants.VerbParameters.Mount.Keywords, - Default = GVFSConstants.VerbParameters.Mount.DefaultKeywords, - Required = false, - HelpText = "A CSV list of logging filter keywords. Accepts: Any, Network")] public string KeywordsCsv { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("mount", "Mount a GVFS virtual repo"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option verbosityOption = new System.CommandLine.Option("--verbosity", new[] { "-v" }) + { + Description = "Sets the verbosity of console logging. Accepts: Verbose, Informational, Warning, Error", + DefaultValueFactory = (_) => GVFSConstants.VerbParameters.Mount.DefaultVerbosity, + }; + cmd.Add(verbosityOption); + + System.CommandLine.Option keywordsOption = new System.CommandLine.Option("--keywords", new[] { "-k" }) + { + Description = "A CSV list of logging filter keywords. Accepts: Any, Network", + DefaultValueFactory = (_) => GVFSConstants.VerbParameters.Mount.DefaultKeywords, + }; + cmd.Add(keywordsOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.Verbosity = result.GetValue(verbosityOption) ?? ""; + verb.KeywordsCsv = result.GetValue(keywordsOption) ?? ""; + }); + + return cmd; + } + public bool SkipMountedCheck { get; set; } public bool SkipVersionCheck { get; set; } public bool SkipInstallHooks { get; set; } diff --git a/GVFS/GVFS/CommandLine/PrefetchVerb.cs b/GVFS/GVFS/CommandLine/PrefetchVerb.cs index 1dd31b3b1..1d3d555b4 100644 --- a/GVFS/GVFS/CommandLine/PrefetchVerb.cs +++ b/GVFS/GVFS/CommandLine/PrefetchVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Git; @@ -12,7 +11,6 @@ namespace GVFS.CommandLine { - [Verb(PrefetchVerb.PrefetchVerbName, HelpText = "Prefetch remote objects for the current head")] public class PrefetchVerb : GVFSVerb.ForExistingEnlistment { private const string PrefetchVerbName = "prefetch"; @@ -27,70 +25,94 @@ public class PrefetchVerb : GVFSVerb.ForExistingEnlistment private static readonly int DownloadThreadCount = Environment.ProcessorCount; private static readonly int IndexThreadCount = Environment.ProcessorCount; - [Option( - "files", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of files to fetch. Simple prefix wildcards, e.g. *.txt, are supported.")] public string Files { get; set; } - [Option( - "folders", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of folders to fetch. Wildcards are not supported.")] public string Folders { get; set; } - [Option( - "folders-list", - Required = false, - Default = "", - HelpText = "A file containing line-delimited list of folders to fetch. Wildcards are not supported.")] public string FoldersListFile { get; set; } - [Option( - "stdin-files-list", - Required = false, - Default = false, - HelpText = "Specify this flag to load file list from stdin. Same format as when loading from file.")] public bool FilesFromStdIn { get; set; } - [Option( - "stdin-folders-list", - Required = false, - Default = false, - HelpText = "Specify this flag to load folder list from stdin. Same format as when loading from file.")] public bool FoldersFromStdIn { get; set; } - [Option( - "files-list", - Required = false, - Default = "", - HelpText = "A file containing line-delimited list of files to fetch. Wildcards are supported.")] public string FilesListFile { get; set; } - [Option( - "hydrate", - Required = false, - Default = false, - HelpText = "Specify this flag to also hydrate files in the working directory.")] public bool HydrateFiles { get; set; } - [Option( - 'c', - "commits", - Required = false, - Default = false, - HelpText = "Fetch the latest set of commit and tree packs. This option cannot be used with any of the file- or folder-related options.")] public bool Commits { get; set; } - [Option( - "verbose", - Required = false, - Default = false, - HelpText = "Show all outputs on the console in addition to writing them to a log file.")] public bool Verbose { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("prefetch", "Prefetch remote objects for the current head"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option filesOption = new System.CommandLine.Option("--files") + { + Description = "A semicolon-delimited list of files to fetch. Simple prefix wildcards, e.g. *.txt, are supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(filesOption); + + System.CommandLine.Option foldersOption = new System.CommandLine.Option("--folders") + { + Description = "A semicolon-delimited list of folders to fetch. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(foldersOption); + + System.CommandLine.Option foldersListOption = new System.CommandLine.Option("--folders-list") + { + Description = "A file containing line-delimited list of folders to fetch. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(foldersListOption); + + System.CommandLine.Option stdinFilesOption = new System.CommandLine.Option("--stdin-files-list") { Description = "Specify this flag to load file list from stdin. Same format as when loading from file." }; + cmd.Add(stdinFilesOption); + + System.CommandLine.Option stdinFoldersOption = new System.CommandLine.Option("--stdin-folders-list") { Description = "Specify this flag to load folder list from stdin. Same format as when loading from file." }; + cmd.Add(stdinFoldersOption); + + System.CommandLine.Option filesListOption = new System.CommandLine.Option("--files-list") + { + Description = "A file containing line-delimited list of files to fetch. Wildcards are supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(filesListOption); + + System.CommandLine.Option hydrateOption = new System.CommandLine.Option("--hydrate") { Description = "Specify this flag to also hydrate files in the working directory." }; + cmd.Add(hydrateOption); + + System.CommandLine.Option commitsOption = new System.CommandLine.Option("--commits", new[] { "-c" }) { Description = "Fetch the latest set of commit and tree packs. This option cannot be used with any of the file- or folder-related options." }; + cmd.Add(commitsOption); + + System.CommandLine.Option verboseOption = new System.CommandLine.Option("--verbose") { Description = "Show all outputs on the console in addition to writing them to a log file." }; + cmd.Add(verboseOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.Files = result.GetValue(filesOption) ?? ""; + verb.Folders = result.GetValue(foldersOption) ?? ""; + verb.FoldersListFile = result.GetValue(foldersListOption) ?? ""; + verb.FilesFromStdIn = result.GetValue(stdinFilesOption); + verb.FoldersFromStdIn = result.GetValue(stdinFoldersOption); + verb.FilesListFile = result.GetValue(filesListOption) ?? ""; + verb.HydrateFiles = result.GetValue(hydrateOption); + verb.Commits = result.GetValue(commitsOption); + verb.Verbose = result.GetValue(verboseOption); + }); + + return cmd; + } + public bool SkipVersionCheck { get; set; } public CacheServerInfo ResolvedCacheServer { get; set; } public ServerGVFSConfig ServerGVFSConfig { get; set; } diff --git a/GVFS/GVFS/CommandLine/RepairVerb.cs b/GVFS/GVFS/CommandLine/RepairVerb.cs index df42626c6..731af334f 100644 --- a/GVFS/GVFS/CommandLine/RepairVerb.cs +++ b/GVFS/GVFS/CommandLine/RepairVerb.cs @@ -1,34 +1,44 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; using GVFS.DiskLayoutUpgrades; using GVFS.RepairJobs; +using System; using System.Collections.Generic; using System.IO; namespace GVFS.CommandLine { - [Verb(RepairVerb.RepairVerbName, HelpText = "EXPERIMENTAL FEATURE - Repair issues that prevent a GVFS repo from mounting")] public class RepairVerb : GVFSVerb { private const string RepairVerbName = "repair"; - [Value( - 1, - Required = false, - Default = "", - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public override string EnlistmentRootPathParameter { get; set; } - [Option( - "confirm", - Default = false, - Required = false, - HelpText = "Pass in this flag to actually do repair(s). Without it, only validation will be done.")] public bool Confirmed { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("repair", "EXPERIMENTAL FEATURE - Repair issues that prevent a GVFS repo from mounting"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option confirmOption = new System.CommandLine.Option("--confirm") { Description = "Pass in this flag to actually do repair(s). Without it, only validation will be done." }; + cmd.Add(confirmOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.Confirmed = result.GetValue(confirmOption); + }); + + return cmd; + } + protected override string VerbName { get { return RepairVerb.RepairVerbName; } diff --git a/GVFS/GVFS/CommandLine/ServiceVerb.cs b/GVFS/GVFS/CommandLine/ServiceVerb.cs index bf32b2c3a..842521fa7 100644 --- a/GVFS/GVFS/CommandLine/ServiceVerb.cs +++ b/GVFS/GVFS/CommandLine/ServiceVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.NamedPipes; @@ -9,32 +8,43 @@ namespace GVFS.CommandLine { - [Verb(ServiceVerbName, HelpText = "Runs commands for the GVFS service.")] public class ServiceVerb : GVFSVerb.ForNoEnlistment { private const string ServiceVerbName = "service"; - [Option( - "mount-all", - Default = false, - Required = false, - HelpText = "Mounts all repos")] public bool MountAll { get; set; } - [Option( - "unmount-all", - Default = false, - Required = false, - HelpText = "Unmounts all repos")] public bool UnmountAll { get; set; } - [Option( - "list-mounted", - Default = false, - Required = false, - HelpText = "Prints a list of all mounted repos")] public bool List { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("service", "Runs commands for the GVFS service."); + + System.CommandLine.Option mountAllOption = new System.CommandLine.Option("--mount-all") { Description = "Mounts all repos" }; + cmd.Add(mountAllOption); + + System.CommandLine.Option unmountAllOption = new System.CommandLine.Option("--unmount-all") { Description = "Unmounts all repos" }; + cmd.Add(unmountAllOption); + + System.CommandLine.Option listMountedOption = new System.CommandLine.Option("--list-mounted") { Description = "Prints a list of all mounted repos" }; + cmd.Add(listMountedOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForNoEnlistment(cmd, internalOption, + (verb, result) => + { + verb.MountAll = result.GetValue(mountAllOption); + verb.UnmountAll = result.GetValue(unmountAllOption); + verb.List = result.GetValue(listMountedOption); + }); + + return cmd; + } + protected override string VerbName { get { return ServiceVerbName; } diff --git a/GVFS/GVFS/CommandLine/SparseVerb.cs b/GVFS/GVFS/CommandLine/SparseVerb.cs index 211d5131e..bf5150c27 100644 --- a/GVFS/GVFS/CommandLine/SparseVerb.cs +++ b/GVFS/GVFS/CommandLine/SparseVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Database; using GVFS.Common.FileSystem; @@ -15,11 +14,6 @@ namespace GVFS.CommandLine { - [Verb( - SparseVerb.SparseVerbName, - HelpText = @"EXPERIMENTAL: List, add, or remove from the list of folders that are included in VFS for Git's projection. -Folders need to be relative to the repos root directory.") - ] public class SparseVerb : GVFSVerb.ForExistingEnlistment { private const string SparseVerbName = "sparse"; @@ -35,62 +29,82 @@ private enum SetDirectoryTimeResult DirectoryDoesNotExist } - [Option( - 's', - "set", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of repo root relative folders to use as the sparse set for determining what to project. Wildcards are not supported.")] public string Set { get; set; } - [Option( - 'f', - "file", - Required = false, - Default = "", - HelpText = "Path to a file that will has repo root relative folders to use as the sparse set. One folder per line. Wildcards are not supported.")] public string File { get; set; } - [Option( - 'a', - "add", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of repo root relative folders to include in the sparse set for determining what to project. Wildcards are not supported.")] public string Add { get; set; } - [Option( - 'r', - "remove", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of repo root relative folders to remove from the sparse set for determining what to project. Wildcards are not supported.")] public string Remove { get; set; } - [Option( - 'l', - "list", - Required = false, - Default = false, - HelpText = "List of folders in the sparse set for determining what to project.")] public bool List { get; set; } - [Option( - 'p', - PruneOptionName, - Required = false, - Default = false, - HelpText = "Remove any folders that are not in the list of sparse folders.")] public bool Prune { get; set; } - [Option( - 'd', - "disable", - Required = false, - Default = false, - HelpText = "Disable the sparse feature. This will remove all folders in the sparse list and start projecting all folders.")] public bool Disable { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("sparse", "List, add, or remove from the list of folders included in VFS for Git's projection"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option setOption = new System.CommandLine.Option("--set", new[] { "-s" }) + { + Description = "A semicolon-delimited list of repo root relative folders to use as the sparse set for determining what to project. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(setOption); + + System.CommandLine.Option fileOption = new System.CommandLine.Option("--file", new[] { "-f" }) + { + Description = "Path to a file that will has repo root relative folders to use as the sparse set. One folder per line. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(fileOption); + + System.CommandLine.Option addOption = new System.CommandLine.Option("--add", new[] { "-a" }) + { + Description = "A semicolon-delimited list of repo root relative folders to include in the sparse set for determining what to project. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(addOption); + + System.CommandLine.Option removeOption = new System.CommandLine.Option("--remove", new[] { "-r" }) + { + Description = "A semicolon-delimited list of repo root relative folders to remove from the sparse set for determining what to project. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(removeOption); + + System.CommandLine.Option listOption = new System.CommandLine.Option("--list", new[] { "-l" }) { Description = "List of folders in the sparse set for determining what to project." }; + cmd.Add(listOption); + + System.CommandLine.Option pruneOption = new System.CommandLine.Option("--prune", new[] { "-p" }) { Description = "Remove any folders that are not in the list of sparse folders." }; + cmd.Add(pruneOption); + + System.CommandLine.Option disableOption = new System.CommandLine.Option("--disable", new[] { "-d" }) { Description = "Disable the sparse feature. This will remove all folders in the sparse list and start projecting all folders." }; + cmd.Add(disableOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.Set = result.GetValue(setOption) ?? ""; + verb.File = result.GetValue(fileOption) ?? ""; + verb.Add = result.GetValue(addOption) ?? ""; + verb.Remove = result.GetValue(removeOption) ?? ""; + verb.List = result.GetValue(listOption); + verb.Prune = result.GetValue(pruneOption); + verb.Disable = result.GetValue(disableOption); + }); + + return cmd; + } + protected override string VerbName => SparseVerbName; internal static string GetNextGitPath(ref int index, string statusOutput) diff --git a/GVFS/GVFS/CommandLine/StatusVerb.cs b/GVFS/GVFS/CommandLine/StatusVerb.cs index 8be1bfb55..ead87842b 100644 --- a/GVFS/GVFS/CommandLine/StatusVerb.cs +++ b/GVFS/GVFS/CommandLine/StatusVerb.cs @@ -1,12 +1,26 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.NamedPipes; +using System; namespace GVFS.CommandLine { - [Verb(StatusVerb.StatusVerbName, HelpText = "Get the status of the GVFS virtual repo")] public class StatusVerb : GVFSVerb.ForExistingEnlistment { + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("status", "Get the status of the GVFS virtual repo"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true); + + return cmd; + } + private const string StatusVerbName = "status"; protected override string VerbName diff --git a/GVFS/GVFS/CommandLine/UnmountVerb.cs b/GVFS/GVFS/CommandLine/UnmountVerb.cs index eebb4a3b1..4804d9415 100644 --- a/GVFS/GVFS/CommandLine/UnmountVerb.cs +++ b/GVFS/GVFS/CommandLine/UnmountVerb.cs @@ -1,30 +1,40 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.NamedPipes; +using System; using System.Diagnostics; namespace GVFS.CommandLine { - [Verb(UnmountVerb.UnmountVerbName, HelpText = "Unmount a GVFS virtual repo")] public class UnmountVerb : GVFSVerb { private const string UnmountVerbName = "unmount"; - [Value( - 0, - Required = false, - Default = "", - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public override string EnlistmentRootPathParameter { get; set; } - [Option( - GVFSConstants.VerbParameters.Unmount.SkipLock, - Default = false, - Required = false, - HelpText = "Force unmount even if the lock is not available.")] public bool SkipLock { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("unmount", "Unmount a GVFS virtual repo"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option skipLockOption = new System.CommandLine.Option("--" + GVFSConstants.VerbParameters.Unmount.SkipLock) { Description = "Force unmount even if the lock is not available." }; + cmd.Add(skipLockOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.SkipLock = result.GetValue(skipLockOption); + }); + + return cmd; + } + public bool SkipUnregister { get; set; } protected override string VerbName diff --git a/GVFS/GVFS/CommandLine/UpgradeVerb.cs b/GVFS/GVFS/CommandLine/UpgradeVerb.cs index 49d2bd9f9..70f135d96 100644 --- a/GVFS/GVFS/CommandLine/UpgradeVerb.cs +++ b/GVFS/GVFS/CommandLine/UpgradeVerb.cs @@ -1,9 +1,8 @@ -using CommandLine; +using GVFS.Common; using System; namespace GVFS.CommandLine { - [Verb(UpgradeVerbName, HelpText = "Checks for new GVFS release, downloads and installs it when available.")] public class UpgradeVerb : GVFSVerb.ForNoEnlistment { private const string UpgradeVerbName = "upgrade"; @@ -13,27 +12,39 @@ public UpgradeVerb() this.Output = Console.Out; } - [Option( - "confirm", - Default = false, - Required = false, - HelpText = "Pass in this flag to actually install the newest release")] public bool Confirmed { get; set; } - [Option( - "dry-run", - Default = false, - Required = false, - HelpText = "Display progress and errors, but don't install GVFS")] public bool DryRun { get; set; } - [Option( - "no-verify", - Default = false, - Required = false, - HelpText = "Do not verify NuGet packages after downloading them. Some platforms do not support NuGet verification.")] public bool NoVerify { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("upgrade", "Checks for new GVFS release, downloads and installs it when available."); + + System.CommandLine.Option confirmOption = new System.CommandLine.Option("--confirm") { Description = "Pass in this flag to actually install the newest release" }; + cmd.Add(confirmOption); + + System.CommandLine.Option dryRunOption = new System.CommandLine.Option("--dry-run") { Description = "Display progress and errors, but don't install GVFS" }; + cmd.Add(dryRunOption); + + System.CommandLine.Option noVerifyOption = new System.CommandLine.Option("--no-verify") { Description = "Do not verify NuGet packages after downloading them. Some platforms do not support NuGet verification." }; + cmd.Add(noVerifyOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForNoEnlistment(cmd, internalOption, + (verb, result) => + { + verb.Confirmed = result.GetValue(confirmOption); + verb.DryRun = result.GetValue(dryRunOption); + verb.NoVerify = result.GetValue(noVerifyOption); + }); + + return cmd; + } + protected override string VerbName { get { return UpgradeVerbName; } diff --git a/GVFS/GVFS/GVFS.csproj b/GVFS/GVFS/GVFS.csproj index 3e639f297..c6a7158b8 100644 --- a/GVFS/GVFS/GVFS.csproj +++ b/GVFS/GVFS/GVFS.csproj @@ -16,7 +16,7 @@ - + diff --git a/GVFS/GVFS/Program.cs b/GVFS/GVFS/Program.cs index 81d712d52..e8dc0335c 100644 --- a/GVFS/GVFS/Program.cs +++ b/GVFS/GVFS/Program.cs @@ -1,10 +1,8 @@ -using CommandLine; +using System.CommandLine; using GVFS.CommandLine; using GVFS.Common; using GVFS.PlatformLoader; using System; -using System.IO; -using System.Linq; namespace GVFS { @@ -19,88 +17,25 @@ public static void Main(string[] args) Environment.Exit((int)ReturnCode.UnableToRegisterForOfflineIO); } - Type[] verbTypes = new Type[] - { - typeof(CacheServerVerb), - typeof(CacheVerb), - typeof(CloneVerb), - typeof(ConfigVerb), - typeof(DehydrateVerb), - typeof(DiagnoseVerb), - typeof(LogVerb), - typeof(SparseVerb), - typeof(MountVerb), - typeof(PrefetchVerb), - typeof(RepairVerb), - typeof(ServiceVerb), - typeof(HealthVerb), - typeof(StatusVerb), - typeof(UnmountVerb), - typeof(UpgradeVerb), - }; - - int consoleWidth = 80; - - // Running in a headless environment can result in a Console with a - // WindowWidth of 0, which causes issues with CommandLineParser - try - { - if (Console.WindowWidth > 0) - { - consoleWidth = Console.WindowWidth; - } - } - catch (IOException) + // Normalize verb name to lowercase for case-insensitive matching. + // The old CommandLineParser had CaseSensitive = false; System.CommandLine + // is case-sensitive, so we normalize the first non-option argument. + if (args.Length > 0 && !args[0].StartsWith("-")) { + args[0] = args[0].ToLowerInvariant(); } try { - new Parser( - settings => - { - settings.CaseSensitive = false; - settings.EnableDashDash = true; - settings.IgnoreUnknownArguments = false; - settings.HelpWriter = Console.Error; - settings.MaximumDisplayWidth = consoleWidth; - }) - .ParseArguments(args, verbTypes) - .WithNotParsed( - errors => - { - if (errors.Any(error => error is TokenError)) - { - Environment.Exit((int)ReturnCode.ParsingError); - } - }) - .WithParsed( - clone => - { - // We handle the clone verb differently, because clone cares if the enlistment path - // was not specified vs if it was specified to be the current directory - clone.Execute(); - Environment.Exit((int)ReturnCode.Success); - }) - .WithParsed( - verb => - { - verb.Execute(); - Environment.Exit((int)ReturnCode.Success); - }) - .WithParsed( - verb => - { - // For all other verbs, they don't care if the enlistment root is explicitly - // specified or implied to be the current directory - if (string.IsNullOrEmpty(verb.EnlistmentRootPathParameter)) - { - verb.EnlistmentRootPathParameter = Environment.CurrentDirectory; - } + RootCommand rootCommand = BuildRootCommand(); + int exitCode = rootCommand.Parse(args).Invoke(); - verb.Execute(); - Environment.Exit((int)ReturnCode.Success); - }); + // If a verb executed successfully, its SetAction already called Environment.Exit. + // If we reach here, it means parsing failed or help was shown. + if (exitCode != 0) + { + Environment.Exit((int)ReturnCode.ParsingError); + } } catch (GVFSVerb.VerbAbortedException e) { @@ -115,5 +50,88 @@ public static void Main(string[] args) } } } + + internal static RootCommand BuildRootCommand() + { + RootCommand rootCommand = new RootCommand("VFS for Git: Enable Git at Enterprise Scale"); + + // Remove System.CommandLine's built-in --version option and replace + // with our own that uses ProcessHelper.GetCurrentProcessVersion() + // for consistent output with "gvfs version" and AOT compatibility. + foreach (Option opt in rootCommand.Options) + { + if (opt.Name == "--version") + { + rootCommand.Options.Remove(opt); + break; + } + } + + Option versionOption = new Option("--version", "-v") { Description = "Display the GVFS version" }; + rootCommand.Add(versionOption); + rootCommand.SetAction((ParseResult result) => + { + if (result.GetValue(versionOption)) + { + Console.WriteLine("GVFS " + ProcessHelper.GetCurrentProcessVersion()); + } + else + { + // No args — show help + rootCommand.Parse(new[] { "--help" }).Invoke(); + } + }); + + rootCommand.Add(CacheServerVerb.CreateCommand()); + rootCommand.Add(CacheVerb.CreateCommand()); + rootCommand.Add(CloneVerb.CreateCommand()); + rootCommand.Add(ConfigVerb.CreateCommand()); + rootCommand.Add(DehydrateVerb.CreateCommand()); + rootCommand.Add(DiagnoseVerb.CreateCommand()); + rootCommand.Add(HealthVerb.CreateCommand()); + rootCommand.Add(LogVerb.CreateCommand()); + rootCommand.Add(MountVerb.CreateCommand()); + rootCommand.Add(PrefetchVerb.CreateCommand()); + rootCommand.Add(RepairVerb.CreateCommand()); + rootCommand.Add(ServiceVerb.CreateCommand()); + rootCommand.Add(SparseVerb.CreateCommand()); + rootCommand.Add(StatusVerb.CreateCommand()); + rootCommand.Add(UnmountVerb.CreateCommand()); + rootCommand.Add(UpgradeVerb.CreateCommand()); + + Command versionCmd = new Command("version", "Display the GVFS version"); + versionCmd.SetAction((ParseResult result) => + { + Console.WriteLine("GVFS " + ProcessHelper.GetCurrentProcessVersion()); + }); + rootCommand.Add(versionCmd); + + // Explicit "help" subcommand for backward compatibility. + // System.CommandLine handles --help/-h/-? automatically, but the old + // CommandLineParser also accepted "gvfs help" as a bare subcommand. + Command helpCmd = new Command("help", "Display help information"); + Argument helpSubcommandArg = new Argument("subcommand") + { + Description = "The subcommand to get help for", + Arity = ArgumentArity.ZeroOrOne, + }; + helpSubcommandArg.DefaultValueFactory = (_) => ""; + helpCmd.Add(helpSubcommandArg); + helpCmd.SetAction((ParseResult result) => + { + string subcommand = result.GetValue(helpSubcommandArg) ?? ""; + if (!string.IsNullOrEmpty(subcommand)) + { + rootCommand.Parse(new[] { subcommand, "--help" }).Invoke(); + } + else + { + rootCommand.Parse(new[] { "--help" }).Invoke(); + } + }); + rootCommand.Add(helpCmd); + + return rootCommand; + } } } From c83beab4b3123bc0aba528a3e95d1347277bae50 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 15 Apr 2026 11:08:37 -0700 Subject: [PATCH 08/25] Migrate from Newtonsoft.Json to System.Text.Json Replace Newtonsoft.Json 13.0.1 with System.Text.Json 8.0.5 across the entire codebase. This eliminates the reflection-heavy Newtonsoft dependency, a prerequisite for NativeAOT compilation. Add GVFSJsonOptions.cs with shared JsonSerializerOptions, centralized Serialize/Deserialize helpers, and registered custom converters. Add EventMetadataConverter for AOT-safe Dictionary handling. Add VersionConverter for System.Version (no built-in STJ support). Use runtime type dispatch in BaseResponse.ToMessage() to preserve derived-class properties during polymorphic serialization. Replace [JsonProperty] with [JsonPropertyName] in telemetry types. Replace JObject.Parse with JsonDocument in functional tests. Change GitObjectSize readonly fields to properties (STJ ignores fields). Add parameterless constructors where STJ deserialization requires them. Set LangVersion=latest in Directory.Build.props. Remove Newtonsoft.Json from all projects and Directory.Packages.props. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- Directory.Build.props | 1 + Directory.Packages.props | 3 +- GVFS/GVFS.Common/FileBasedDictionary.cs | 10 +- GVFS/GVFS.Common/GVFS.Common.csproj | 2 +- GVFS/GVFS.Common/GVFSEnlistment.cs | 4 +- GVFS/GVFS.Common/GVFSJsonOptions.cs | 43 ++++++ GVFS/GVFS.Common/Http/CacheServerInfo.cs | 4 +- GVFS/GVFS.Common/Http/ConfigHttpRequestor.cs | 8 +- .../Http/GitObjectsHttpRequestor.cs | 10 +- GVFS/GVFS.Common/InternalVerbParameters.cs | 5 +- .../NamedPipes/NamedPipeMessages.cs | 65 +++++---- GVFS/GVFS.Common/Prefetch/BlobPrefetcher.cs | 11 +- GVFS/GVFS.Common/ServerGVFSConfig.cs | 4 + .../Tracing/EventMetadataConverter.cs | 128 ++++++++++++++++++ GVFS/GVFS.Common/Tracing/JsonTracer.cs | 9 +- .../Tracing/PrettyConsoleEventListener.cs | 5 +- .../Tracing/TelemetryDaemonEventListener.cs | 29 ++-- GVFS/GVFS.Common/VersionConverter.cs | 93 +++++++++++++ GVFS/GVFS.Common/VersionResponse.cs | 3 +- .../GVFS.FunctionalTests.csproj | 2 +- .../Tools/GVFSFunctionalTestEnlistment.cs | 14 +- .../GVFS.FunctionalTests/Tools/GVFSHelpers.cs | 10 +- GVFS/GVFS.Mount/InProcessMount.cs | 5 +- GVFS/GVFS.Service/GVFS.Service.csproj | 2 +- GVFS/GVFS.Service/RepoRegistration.cs | 11 +- .../Common/CacheServerResolverTests.cs | 5 +- GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs | 8 +- .../TelemetryDaemonEventListenerTests.cs | 50 +++---- .../FileSystemCallbacksTests.cs | 19 ++- .../Background/FileSystemTask.cs | 4 +- .../GVFS.Virtualization.csproj | 2 +- GVFS/GVFS/CommandLine/GVFSVerb.cs | 4 +- 32 files changed, 413 insertions(+), 160 deletions(-) create mode 100644 GVFS/GVFS.Common/GVFSJsonOptions.cs create mode 100644 GVFS/GVFS.Common/Tracing/EventMetadataConverter.cs create mode 100644 GVFS/GVFS.Common/VersionConverter.cs diff --git a/Directory.Build.props b/Directory.Build.props index 76c51bce4..eb31784b6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,6 +22,7 @@ + latest win-x64 x64 $(ProjectOutPath)bin\ diff --git a/Directory.Packages.props b/Directory.Packages.props index d2201c0df..96261bbda 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ - + @@ -42,7 +42,6 @@ --> - diff --git a/GVFS/GVFS.Common/FileBasedDictionary.cs b/GVFS/GVFS.Common/FileBasedDictionary.cs index ae601e9d9..ce680f517 100644 --- a/GVFS/GVFS.Common/FileBasedDictionary.cs +++ b/GVFS/GVFS.Common/FileBasedDictionary.cs @@ -1,9 +1,9 @@ -using GVFS.Common.FileSystem; +using GVFS.Common.FileSystem; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Text.Json; namespace GVFS.Common { @@ -120,7 +120,7 @@ private bool TryParseAddLine(string line, out TKey key, out TValue value, out st { try { - KeyValuePair kvp = JsonConvert.DeserializeObject>(line); + KeyValuePair kvp = GVFSJsonOptions.Deserialize>(line); key = kvp.Key; value = kvp.Value; } @@ -140,7 +140,7 @@ private bool TryParseRemoveLine(string line, out TKey key, out string error) { try { - key = JsonConvert.DeserializeObject(line); + key = GVFSJsonOptions.Deserialize(line); } catch (JsonException ex) { @@ -162,7 +162,7 @@ private IEnumerable GenerateDataLines() { foreach (KeyValuePair kvp in this.data) { - yield return this.FormatAddLine(JsonConvert.SerializeObject(kvp).Trim()); + yield return this.FormatAddLine(GVFSJsonOptions.Serialize(kvp).Trim()); } } } diff --git a/GVFS/GVFS.Common/GVFS.Common.csproj b/GVFS/GVFS.Common/GVFS.Common.csproj index 56bf81edf..f5af45767 100644 --- a/GVFS/GVFS.Common/GVFS.Common.csproj +++ b/GVFS/GVFS.Common/GVFS.Common.csproj @@ -8,7 +8,7 @@ - + diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index eb407c175..6e2eedb7d 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -2,9 +2,9 @@ using GVFS.Common.Git; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.IO; +using System.Text.Json; using System.Threading; namespace GVFS.Common @@ -270,7 +270,7 @@ public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enli tracer.RelatedError($"{nameof(WaitUntilMounted)}: {errorMessage}"); return false; } - catch (JsonReaderException e) + catch (JsonException e) { errorMessage = string.Format("Failed to parse response from GVFS.Mount.\n {0}", e); tracer.RelatedError($"{nameof(WaitUntilMounted)}: {errorMessage}"); diff --git a/GVFS/GVFS.Common/GVFSJsonOptions.cs b/GVFS/GVFS.Common/GVFSJsonOptions.cs new file mode 100644 index 000000000..f5230b95e --- /dev/null +++ b/GVFS/GVFS.Common/GVFSJsonOptions.cs @@ -0,0 +1,43 @@ +using System; +using System.Text.Json; + +namespace GVFS.Common +{ + /// + /// Shared JsonSerializerOptions and helpers for the GVFS codebase. + /// PropertyNameCaseInsensitive preserves backward compatibility with + /// Newtonsoft.Json's default case-insensitive deserialization. + /// + public static class GVFSJsonOptions + { + public static readonly JsonSerializerOptions Default = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new VersionConverter(), new Tracing.EventMetadataConverter() }, + }; + + /// + /// Serialize using the compile-time type. Use when + /// is the concrete type (not a base class with derived properties). + /// + public static string Serialize(T value) + { + return JsonSerializer.Serialize(value, Default); + } + + /// + /// Serialize using the runtime type. Use when calling from a base-class + /// method where compile-time type would lose derived-class properties + /// (e.g., BaseResponse<T>.ToMessage()). + /// + public static string Serialize(object value, Type inputType) + { + return JsonSerializer.Serialize(value, inputType, Default); + } + + public static T Deserialize(string json) + { + return JsonSerializer.Deserialize(json, Default); + } + } +} diff --git a/GVFS/GVFS.Common/Http/CacheServerInfo.cs b/GVFS/GVFS.Common/Http/CacheServerInfo.cs index 4fe1e58c6..0ec929b0d 100644 --- a/GVFS/GVFS.Common/Http/CacheServerInfo.cs +++ b/GVFS/GVFS.Common/Http/CacheServerInfo.cs @@ -1,5 +1,5 @@ -using Newtonsoft.Json; -using System; +using System; +using System.Text.Json.Serialization; namespace GVFS.Common.Http { diff --git a/GVFS/GVFS.Common/Http/ConfigHttpRequestor.cs b/GVFS/GVFS.Common/Http/ConfigHttpRequestor.cs index 444e8dbd5..95b531bb3 100644 --- a/GVFS/GVFS.Common/Http/ConfigHttpRequestor.cs +++ b/GVFS/GVFS.Common/Http/ConfigHttpRequestor.cs @@ -1,8 +1,8 @@ -using GVFS.Common.Tracing; -using Newtonsoft.Json; +using GVFS.Common.Tracing; using System; using System.Net; using System.Net.Http; +using System.Text.Json; using System.Threading; namespace GVFS.Common.Http @@ -66,10 +66,10 @@ public bool TryQueryGVFSConfig(bool logErrors, out ServerGVFSConfig serverGVFSCo try { string configString = response.RetryableReadToEnd(); - ServerGVFSConfig config = JsonConvert.DeserializeObject(configString); + ServerGVFSConfig config = GVFSJsonOptions.Deserialize(configString); return new RetryWrapper.CallbackResult(config); } - catch (JsonReaderException e) + catch (JsonException e) { return new RetryWrapper.CallbackResult(e, shouldRetry: false); } diff --git a/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs b/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs index 0d11d3cc4..2cdffcb8d 100644 --- a/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs +++ b/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs @@ -1,8 +1,8 @@ -using GVFS.Common.Git; +using GVFS.Common.Git; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.Text.Json.Serialization; using System.Linq; using System.Net; using System.Net.Http; @@ -81,7 +81,7 @@ public virtual List QueryForFileSizes(IEnumerable objectI } string objectSizesString = response.RetryableReadToEnd(); - List objectSizes = JsonConvert.DeserializeObject>(objectSizesString); + List objectSizes = GVFSJsonOptions.Deserialize>(objectSizesString); return new RetryWrapper>.CallbackResult(objectSizes); } }); @@ -343,8 +343,8 @@ private string ObjectIdsJsonGenerator(long requestId, Func> public class GitObjectSize { - public readonly string Id; - public readonly long Size; + public string Id { get; set; } + public long Size { get; set; } [JsonConstructor] public GitObjectSize(string id, long size) diff --git a/GVFS/GVFS.Common/InternalVerbParameters.cs b/GVFS/GVFS.Common/InternalVerbParameters.cs index ec4cd0e3f..b7a09d657 100644 --- a/GVFS/GVFS.Common/InternalVerbParameters.cs +++ b/GVFS/GVFS.Common/InternalVerbParameters.cs @@ -1,4 +1,3 @@ -using Newtonsoft.Json; namespace GVFS.Common { @@ -23,12 +22,12 @@ public InternalVerbParameters( public static InternalVerbParameters FromJson(string json) { - return JsonConvert.DeserializeObject(json); + return GVFSJsonOptions.Deserialize(json); } public string ToJson() { - return JsonConvert.SerializeObject(this); + return GVFSJsonOptions.Serialize(this); } } } diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index fafb4e7d1..741514ac2 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -1,4 +1,3 @@ -using Newtonsoft.Json; using System; using System.Collections.Generic; @@ -46,12 +45,12 @@ public class Response public static Response FromJson(string json) { - return JsonConvert.DeserializeObject(json); + return GVFSJsonOptions.Deserialize(json); } public string ToJson() { - return JsonConvert.SerializeObject(this); + return GVFSJsonOptions.Serialize(this); } } } @@ -203,6 +202,10 @@ public static class DehydrateFolders public class Request { + public Request() + { + } + public Request(string backupFolderPath, string folders) { this.Folders = folders; @@ -211,21 +214,27 @@ public Request(string backupFolderPath, string folders) public static Request FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } - public string Folders { get; } + public string Folders { get; set; } - public string BackupFolderPath { get; } + public string BackupFolderPath { get; set; } public Message CreateMessage() { - return new Message(Dehydrate, JsonConvert.SerializeObject(this)); + return new Message(Dehydrate, GVFSJsonOptions.Serialize(this)); } } public class Response { + public Response() + { + this.SuccessfulFolders = new List(); + this.FailedFolders = new List(); + } + public Response(string result) { this.Result = result; @@ -233,18 +242,18 @@ public Response(string result) this.FailedFolders = new List(); } - public string Result { get; } - public List SuccessfulFolders { get; } - public List FailedFolders { get; } + public string Result { get; set; } + public List SuccessfulFolders { get; set; } + public List FailedFolders { get; set; } public static Response FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message CreateMessage() { - return new Message(this.Result, JsonConvert.SerializeObject(this)); + return new Message(this.Result, GVFSJsonOptions.Serialize(this)); } } } @@ -259,7 +268,7 @@ public class Request { public Request(List packIndexes) { - this.PackIndexList = JsonConvert.SerializeObject(packIndexes); + this.PackIndexList = GVFSJsonOptions.Serialize(packIndexes); } public Request(Message message) @@ -324,12 +333,12 @@ public enum Identifier public static Request FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this)); } } } @@ -342,19 +351,19 @@ public class UnregisterRepoRequest public static UnregisterRepoRequest FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this)); } public class Response : BaseResponse { public static Response FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } } } @@ -368,19 +377,19 @@ public class RegisterRepoRequest public static RegisterRepoRequest FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this)); } public class Response : BaseResponse { public static Response FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } } } @@ -393,19 +402,19 @@ public class EnableAndAttachProjFSRequest public static EnableAndAttachProjFSRequest FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this)); } public class Response : BaseResponse { public static Response FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } } } @@ -416,12 +425,12 @@ public class GetActiveRepoListRequest public static GetActiveRepoListRequest FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this)); } public class Response : BaseResponse @@ -430,7 +439,7 @@ public class Response : BaseResponse public static Response FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } } } @@ -444,7 +453,7 @@ public class BaseResponse public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this, this.GetType())); } } } diff --git a/GVFS/GVFS.Common/Prefetch/BlobPrefetcher.cs b/GVFS/GVFS.Common/Prefetch/BlobPrefetcher.cs index cd9b72330..29bc4cc67 100644 --- a/GVFS/GVFS.Common/Prefetch/BlobPrefetcher.cs +++ b/GVFS/GVFS.Common/Prefetch/BlobPrefetcher.cs @@ -1,10 +1,9 @@ -using GVFS.Common.FileSystem; +using GVFS.Common.FileSystem; using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Common.Prefetch.Git; using GVFS.Common.Prefetch.Pipeline; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -140,8 +139,8 @@ public static bool IsNoopPrefetch( lastPrefetchArgs.TryGetValue(PrefetchArgs.Folders, out string lastFoldersString) && lastPrefetchArgs.TryGetValue(PrefetchArgs.Hydrate, out string lastHydrateString)) { - string newFilesString = JsonConvert.SerializeObject(files); - string newFoldersString = JsonConvert.SerializeObject(folders); + string newFilesString = GVFSJsonOptions.Serialize(files); + string newFoldersString = GVFSJsonOptions.Serialize(folders); bool isNoop = commitId == lastCommitId && hydrateFilesAfterDownload.ToString() == lastHydrateString && @@ -587,8 +586,8 @@ private void SavePrefetchArgs(string targetCommit, bool hydrate) new[] { new KeyValuePair(PrefetchArgs.CommitId, targetCommit), - new KeyValuePair(PrefetchArgs.Files, JsonConvert.SerializeObject(this.FileList)), - new KeyValuePair(PrefetchArgs.Folders, JsonConvert.SerializeObject(this.FolderList)), + new KeyValuePair(PrefetchArgs.Files, GVFSJsonOptions.Serialize(this.FileList)), + new KeyValuePair(PrefetchArgs.Folders, GVFSJsonOptions.Serialize(this.FolderList)), new KeyValuePair(PrefetchArgs.Hydrate, hydrate.ToString()), }); } diff --git a/GVFS/GVFS.Common/ServerGVFSConfig.cs b/GVFS/GVFS.Common/ServerGVFSConfig.cs index 1bc9bef36..84f30c119 100644 --- a/GVFS/GVFS.Common/ServerGVFSConfig.cs +++ b/GVFS/GVFS.Common/ServerGVFSConfig.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; namespace GVFS.Common { @@ -13,7 +14,10 @@ public class ServerGVFSConfig public class VersionRange { + [JsonConverter(typeof(VersionConverter))] public Version Min { get; set; } + + [JsonConverter(typeof(VersionConverter))] public Version Max { get; set; } } } diff --git a/GVFS/GVFS.Common/Tracing/EventMetadataConverter.cs b/GVFS/GVFS.Common/Tracing/EventMetadataConverter.cs new file mode 100644 index 000000000..5bc7b3927 --- /dev/null +++ b/GVFS/GVFS.Common/Tracing/EventMetadataConverter.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GVFS.Common.Tracing +{ + /// + /// Custom JSON converter for EventMetadata (Dictionary<string, object>). + /// Handles the known value types stored in EventMetadata without relying on + /// System.Text.Json's polymorphic object serialization, which can produce + /// unexpected results for boxed enums, HttpStatusCode, etc. + /// + public class EventMetadataConverter : JsonConverter + { + public override EventMetadata Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject"); + } + + EventMetadata metadata = new EventMetadata(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return metadata; + } + + string key = reader.GetString(); + reader.Read(); + + object value = reader.TokenType switch + { + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number when reader.TryGetInt32(out int i) => i, + JsonTokenType.Number when reader.TryGetInt64(out long l) => l, + JsonTokenType.Number when reader.TryGetDouble(out double d) => d, + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Null => null, + _ => reader.GetString() + }; + + metadata[key] = value; + } + + throw new JsonException("Unexpected end of JSON"); + } + + public override void Write(Utf8JsonWriter writer, EventMetadata value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach (KeyValuePair kvp in value) + { + writer.WritePropertyName(kvp.Key); + WriteValue(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + + /// + /// Serialize EventMetadata directly using Utf8JsonWriter, bypassing + /// JsonSerializer entirely. Safe for all known EventMetadata value types. + /// + public static string SerializeToString(EventMetadata metadata) + { + using (MemoryStream stream = new MemoryStream()) + { + using (Utf8JsonWriter writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + foreach (KeyValuePair kvp in metadata) + { + writer.WritePropertyName(kvp.Key); + WriteValue(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + } + + private static void WriteValue(Utf8JsonWriter writer, object value) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case string s: + writer.WriteStringValue(s); + break; + case int i: + writer.WriteNumberValue(i); + break; + case long l: + writer.WriteNumberValue(l); + break; + case double d: + writer.WriteNumberValue(d); + break; + case bool b: + writer.WriteBooleanValue(b); + break; + case float f: + writer.WriteNumberValue(f); + break; + case HttpStatusCode status: + writer.WriteNumberValue((int)status); + break; + case Enum e: + writer.WriteStringValue(e.ToString()); + break; + default: + writer.WriteStringValue(value.ToString()); + break; + } + } + } +} diff --git a/GVFS/GVFS.Common/Tracing/JsonTracer.cs b/GVFS/GVFS.Common/Tracing/JsonTracer.cs index 0fb24012c..68bff264a 100644 --- a/GVFS/GVFS.Common/Tracing/JsonTracer.cs +++ b/GVFS/GVFS.Common/Tracing/JsonTracer.cs @@ -1,5 +1,4 @@ -using Newtonsoft.Json; -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; @@ -346,7 +345,7 @@ private static TraceEventMessage CreateListenerRecoveryMessage(EventListener rec Level = EventLevel.Informational, Keywords = Keywords.Any, Opcode = EventOpcode.Info, - Payload = JsonConvert.SerializeObject(new Dictionary + Payload = GVFSJsonOptions.Serialize(new Dictionary { ["EventListener"] = recoveredListener.GetType().Name }) @@ -361,7 +360,7 @@ private static TraceEventMessage CreateListenerFailureMessage(EventListener fail Level = EventLevel.Error, Keywords = Keywords.Any, Opcode = EventOpcode.Info, - Payload = JsonConvert.SerializeObject(new Dictionary + Payload = GVFSJsonOptions.Serialize(new Dictionary { ["EventListener"] = failedListener.GetType().Name, ["ErrorMessage"] = errorMessage, @@ -371,7 +370,7 @@ private static TraceEventMessage CreateListenerFailureMessage(EventListener fail private void WriteEvent(string eventName, EventLevel level, Keywords keywords, EventMetadata metadata, EventOpcode opcode) { - string jsonPayload = metadata != null ? JsonConvert.SerializeObject(metadata) : null; + string jsonPayload = metadata != null ? EventMetadataConverter.SerializeToString(metadata) : null; if (this.isDisposed) { diff --git a/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs b/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs index 7f73ddc02..3f891a977 100644 --- a/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs +++ b/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs @@ -1,5 +1,4 @@ -using System; -using Newtonsoft.Json; +using System; namespace GVFS.Common.Tracing { @@ -24,7 +23,7 @@ protected override void RecordMessageInternal(TraceEventMessage message) return; } - ConsoleOutputPayload payload = JsonConvert.DeserializeObject(message.Payload); + ConsoleOutputPayload payload = GVFSJsonOptions.Deserialize(message.Payload); if (string.IsNullOrEmpty(payload.ErrorMessage)) { return; diff --git a/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs b/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs index e3640f1c0..35e517a42 100644 --- a/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs +++ b/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.IO.Pipes; using GVFS.Common.Git; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace GVFS.Common.Tracing { @@ -129,38 +130,38 @@ private string CreatePipeMessage(TraceEventMessage message) public class PipeMessage { - [JsonProperty("version")] + [JsonPropertyName("version")] public string Version { get; set; } - [JsonProperty("providerName")] + [JsonPropertyName("providerName")] public string ProviderName { get; set; } - [JsonProperty("eventName")] + [JsonPropertyName("eventName")] public string EventName { get; set; } - [JsonProperty("eventLevel")] + [JsonPropertyName("eventLevel")] public EventLevel EventLevel { get; set; } - [JsonProperty("eventOpcode")] + [JsonPropertyName("eventOpcode")] public EventOpcode EventOpcode { get; set; } - [JsonProperty("payload")] + [JsonPropertyName("payload")] public PipeMessagePayload Payload { get; set; } public static PipeMessage FromJson(string json) { - return JsonConvert.DeserializeObject(json); + return GVFSJsonOptions.Deserialize(json); } public string ToJson() { - return JsonConvert.SerializeObject(this); + return GVFSJsonOptions.Serialize(this); } public class PipeMessagePayload { - [JsonProperty("enlistmentId")] + [JsonPropertyName("enlistmentId")] public string EnlistmentId { get; set; } - [JsonProperty("mountId")] + [JsonPropertyName("mountId")] public string MountId { get; set; } - [JsonProperty("gitCommandSessionId")] + [JsonPropertyName("gitCommandSessionId")] public string GitCommandSessionId { get; set; } - [JsonProperty("json")] + [JsonPropertyName("json")] public string Json { get; set; } } } diff --git a/GVFS/GVFS.Common/VersionConverter.cs b/GVFS/GVFS.Common/VersionConverter.cs new file mode 100644 index 000000000..6a0c488c1 --- /dev/null +++ b/GVFS/GVFS.Common/VersionConverter.cs @@ -0,0 +1,93 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GVFS.Common +{ + /// + /// Custom JsonConverter for System.Version that handles both string format ("1.0.0.0") + /// and object format ({"Major":1,"Minor":0,"Build":0,"Revision":0}). + /// + /// Newtonsoft.Json could deserialize System.Version from either format automatically. + /// System.Text.Json has no built-in converter for System.Version, so this is required. + /// + public class VersionConverter : JsonConverter + { + public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + return new Version(reader.GetString()); + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + int major = 0, minor = 0, build = -1, revision = -1; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType == JsonTokenType.PropertyName) + { + string propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "Major": + major = reader.GetInt32(); + break; + case "Minor": + minor = reader.GetInt32(); + break; + case "Build": + build = reader.GetInt32(); + break; + case "Revision": + revision = reader.GetInt32(); + break; + default: + reader.Skip(); + break; + } + } + } + + if (build < 0) + { + return new Version(major, minor); + } + + if (revision < 0) + { + return new Version(major, minor, build); + } + + return new Version(major, minor, build, revision); + } + + throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing System.Version."); + } + + public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value.ToString()); + } + } + } +} diff --git a/GVFS/GVFS.Common/VersionResponse.cs b/GVFS/GVFS.Common/VersionResponse.cs index 4a8a8c29e..08864f5c0 100644 --- a/GVFS/GVFS.Common/VersionResponse.cs +++ b/GVFS/GVFS.Common/VersionResponse.cs @@ -1,4 +1,3 @@ -using Newtonsoft.Json; namespace GVFS.Common { @@ -8,7 +7,7 @@ public class VersionResponse public static VersionResponse FromJsonString(string jsonString) { - return JsonConvert.DeserializeObject(jsonString); + return GVFSJsonOptions.Deserialize(jsonString); } } } diff --git a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj index daf852167..9800b6b95 100644 --- a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj +++ b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj @@ -9,7 +9,7 @@ - + diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs index 854b25a2d..de57294a0 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs @@ -2,12 +2,12 @@ using GVFS.FunctionalTests.Should; using GVFS.FunctionalTests.Tests; using GVFS.Tests.Should; -using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Text.Json; using System.Threading; namespace GVFS.FunctionalTests.Tools @@ -152,12 +152,14 @@ public string GetObjectRoot(FileSystemRunner fileSystem) .ToArray(); objectRootEntries.Length.ShouldEqual(1, $"Should be only one entry for repo url: {this.RepoUrl} mapping file content: {mappingFileContents}"); objectRootEntries[0].Substring(0, 2).ShouldEqual("A ", $"Invalid mapping entry for repo: {objectRootEntries[0]}"); - JObject rootEntryJson = JObject.Parse(objectRootEntries[0].Substring(2)); - string objectRootFolder = rootEntryJson.GetValue("Value").ToString(); - objectRootFolder.ShouldNotBeNull(); - objectRootFolder.Length.ShouldBeAtLeast(1, $"Invalid object root folder: {objectRootFolder} for {this.RepoUrl} mapping file content: {mappingFileContents}"); + using (JsonDocument rootEntryJson = JsonDocument.Parse(objectRootEntries[0].Substring(2))) + { + string objectRootFolder = rootEntryJson.RootElement.GetProperty("Value").GetString(); + objectRootFolder.ShouldNotBeNull(); + objectRootFolder.Length.ShouldBeAtLeast(1, $"Invalid object root folder: {objectRootFolder} for {this.RepoUrl} mapping file content: {mappingFileContents}"); - return Path.Combine(this.LocalCacheRoot, objectRootFolder, "gitObjects"); + return Path.Combine(this.LocalCacheRoot, objectRootFolder, "gitObjects"); + } } public string GetPackRoot(FileSystemRunner fileSystem) diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs index b15d66eed..d943035fb 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs @@ -1,8 +1,8 @@ -using GVFS.FunctionalTests.FileSystemRunners; +using GVFS.Common; +using GVFS.FunctionalTests.FileSystemRunners; using GVFS.FunctionalTests.Should; using GVFS.Tests.Should; using Microsoft.Data.Sqlite; -using Newtonsoft.Json; using NUnit.Framework; using System; using System.Collections.Generic; @@ -289,7 +289,7 @@ private static string GetPersistedValue(string dotGVFSRoot, string key) json = reader.ReadLine(); json.Substring(0, 2).ShouldEqual("A "); - KeyValuePair kvp = JsonConvert.DeserializeObject>(json.Substring(2)); + KeyValuePair kvp = GVFSJsonOptions.Deserialize>(json.Substring(2)); if (kvp.Key == key) { return kvp.Value; @@ -314,7 +314,7 @@ private static void SavePersistedValue(string dotGVFSRoot, string key, string va json = reader.ReadLine(); json.Substring(0, 2).ShouldEqual("A "); - KeyValuePair kvp = JsonConvert.DeserializeObject>(json.Substring(2)); + KeyValuePair kvp = GVFSJsonOptions.Deserialize>(json.Substring(2)); repoMetadata.Add(kvp.Key, kvp.Value); } } @@ -325,7 +325,7 @@ private static void SavePersistedValue(string dotGVFSRoot, string key, string va foreach (KeyValuePair kvp in repoMetadata) { - newRepoMetadataContents += "A " + JsonConvert.SerializeObject(kvp).Trim() + "\r\n"; + newRepoMetadataContents += "A " + GVFSJsonOptions.Serialize(kvp).Trim() + "\r\n"; } File.WriteAllText(metadataPath, newRepoMetadataContents); diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 6fedfb3ed..e3e34cb65 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -1,4 +1,4 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.Common.Database; using GVFS.Common.FileSystem; using GVFS.Common.Git; @@ -9,7 +9,6 @@ using GVFS.PlatformLoader; using GVFS.Virtualization; using GVFS.Virtualization.FileSystem; -using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Diagnostics; @@ -971,7 +970,7 @@ private void HandlePostFetchJobRequest(NamedPipeMessages.Message message, NamedP NamedPipeMessages.RunPostFetchJob.Response response; if (this.currentState == MountState.Ready) { - List packIndexes = JsonConvert.DeserializeObject>(message.Body); + List packIndexes = GVFSJsonOptions.Deserialize>(message.Body); this.maintenanceScheduler.EnqueueOneTimeStep(new PostFetchStep(this.context, packIndexes)); response = new NamedPipeMessages.RunPostFetchJob.Response(NamedPipeMessages.RunPostFetchJob.QueuedResult); diff --git a/GVFS/GVFS.Service/GVFS.Service.csproj b/GVFS/GVFS.Service/GVFS.Service.csproj index cc8c4c9f8..07a366174 100644 --- a/GVFS/GVFS.Service/GVFS.Service.csproj +++ b/GVFS/GVFS.Service/GVFS.Service.csproj @@ -11,7 +11,7 @@ - + diff --git a/GVFS/GVFS.Service/RepoRegistration.cs b/GVFS/GVFS.Service/RepoRegistration.cs index b6c076951..7e5e3a095 100644 --- a/GVFS/GVFS.Service/RepoRegistration.cs +++ b/GVFS/GVFS.Service/RepoRegistration.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using GVFS.Common; namespace GVFS.Service { @@ -21,12 +21,7 @@ public RepoRegistration(string enlistmentRoot, string ownerSID) public static RepoRegistration FromJson(string json) { - return JsonConvert.DeserializeObject( - json, - new JsonSerializerSettings - { - MissingMemberHandling = MissingMemberHandling.Ignore - }); + return GVFSJsonOptions.Deserialize(json); } public override string ToString() @@ -41,7 +36,7 @@ public override string ToString() public string ToJson() { - return JsonConvert.SerializeObject(this); + return GVFSJsonOptions.Serialize(this); } } } \ No newline at end of file diff --git a/GVFS/GVFS.UnitTests/Common/CacheServerResolverTests.cs b/GVFS/GVFS.UnitTests/Common/CacheServerResolverTests.cs index a90daa8d3..852ecb908 100644 --- a/GVFS/GVFS.UnitTests/Common/CacheServerResolverTests.cs +++ b/GVFS/GVFS.UnitTests/Common/CacheServerResolverTests.cs @@ -1,10 +1,9 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Tests.Should; using GVFS.UnitTests.Mock.Common; using GVFS.UnitTests.Mock.Git; -using Newtonsoft.Json; using NUnit.Framework; namespace GVFS.UnitTests.Common @@ -217,7 +216,7 @@ private ServerGVFSConfig CreateGVFSConfig() private ServerGVFSConfig CreateDefaultDeserializedGVFSConfig() { - return JsonConvert.DeserializeObject("{}"); + return GVFSJsonOptions.Deserialize("{}"); } private CacheServerResolver CreateResolver(MockGVFSEnlistment enlistment = null) diff --git a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs index a4137fcfc..c04be4204 100644 --- a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs +++ b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs @@ -1,5 +1,5 @@ +using GVFS.Common; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Threading; @@ -54,7 +54,7 @@ public void RelatedInfo(string message) public void RelatedInfo(EventMetadata metadata, string message) { metadata[TracingConstants.MessageKey.InfoMessage] = message; - this.RelatedInfoEvents.Add(JsonConvert.SerializeObject(metadata)); + this.RelatedInfoEvents.Add(GVFSJsonOptions.Serialize(metadata)); } public void RelatedInfo(string format, params object[] args) @@ -67,7 +67,7 @@ public void RelatedWarning(EventMetadata metadata, string message) if (metadata != null) { metadata[TracingConstants.MessageKey.WarningMessage] = message; - this.RelatedWarningEvents.Add(JsonConvert.SerializeObject(metadata)); + this.RelatedWarningEvents.Add(GVFSJsonOptions.Serialize(metadata)); } else if (message != null) { @@ -93,7 +93,7 @@ public void RelatedWarning(string format, params object[] args) public void RelatedError(EventMetadata metadata, string message) { metadata[TracingConstants.MessageKey.ErrorMessage] = message; - this.RelatedErrorEvents.Add(JsonConvert.SerializeObject(metadata)); + this.RelatedErrorEvents.Add(GVFSJsonOptions.Serialize(metadata)); } public void RelatedError(EventMetadata metadata, string message, Keywords keyword) diff --git a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs index 0789f9226..f3024bfaa 100644 --- a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs +++ b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs @@ -1,7 +1,8 @@ using System.Collections.Generic; +using System.Linq; +using System.Text.Json; using GVFS.Common.Tracing; using GVFS.Tests.Should; -using Newtonsoft.Json; using NUnit.Framework; namespace GVFS.UnitTests.Tracing @@ -22,22 +23,6 @@ public void TraceMessageDataIsCorrectFormat() const string gitCommandSessionId = "test_sessionId"; const string payload = "test-payload"; - Dictionary expectedDict = new Dictionary - { - ["version"] = vfsVersion, - ["providerName"] = providerName, - ["eventName"] = eventName, - ["eventLevel"] = (int)level, - ["eventOpcode"] = (int)opcode, - ["payload"] = new Dictionary - { - ["enlistmentId"] = enlistmentId, - ["mountId"] = mountId, - ["gitCommandSessionId"] = gitCommandSessionId, - ["json"] = payload, - }, - }; - TelemetryDaemonEventListener.PipeMessage message = new TelemetryDaemonEventListener.PipeMessage { Version = vfsVersion, @@ -56,22 +41,23 @@ public void TraceMessageDataIsCorrectFormat() string messageJson = message.ToJson(); - Dictionary actualDict = JsonConvert.DeserializeObject>(messageJson); - - actualDict.Count.ShouldEqual(expectedDict.Count); - actualDict["version"].ShouldEqual(expectedDict["version"]); - actualDict["providerName"].ShouldEqual(expectedDict["providerName"]); - actualDict["eventName"].ShouldEqual(expectedDict["eventName"]); - actualDict["eventLevel"].ShouldEqual(expectedDict["eventLevel"]); - actualDict["eventOpcode"].ShouldEqual(expectedDict["eventOpcode"]); + using (JsonDocument doc = JsonDocument.Parse(messageJson)) + { + JsonElement root = doc.RootElement; + root.EnumerateObject().Count().ShouldEqual(6); + root.GetProperty("version").GetString().ShouldEqual(vfsVersion); + root.GetProperty("providerName").GetString().ShouldEqual(providerName); + root.GetProperty("eventName").GetString().ShouldEqual(eventName); + root.GetProperty("eventLevel").GetInt32().ShouldEqual((int)level); + root.GetProperty("eventOpcode").GetInt32().ShouldEqual((int)opcode); - Dictionary expectedPayloadDict = (Dictionary)expectedDict["payload"]; - Dictionary actualPayloadDict = JsonConvert.DeserializeObject>(actualDict["payload"].ToString()); - actualPayloadDict.Count.ShouldEqual(expectedPayloadDict.Count); - actualPayloadDict["enlistmentId"].ShouldEqual(expectedPayloadDict["enlistmentId"]); - actualPayloadDict["mountId"].ShouldEqual(expectedPayloadDict["mountId"]); - actualPayloadDict["gitCommandSessionId"].ShouldEqual(expectedPayloadDict["gitCommandSessionId"]); - actualPayloadDict["json"].ShouldEqual(expectedPayloadDict["json"]); + JsonElement payloadElement = root.GetProperty("payload"); + payloadElement.EnumerateObject().Count().ShouldEqual(4); + payloadElement.GetProperty("enlistmentId").GetString().ShouldEqual(enlistmentId); + payloadElement.GetProperty("mountId").GetString().ShouldEqual(mountId); + payloadElement.GetProperty("gitCommandSessionId").GetString().ShouldEqual(gitCommandSessionId); + payloadElement.GetProperty("json").GetString().ShouldEqual(payload); + } } } } diff --git a/GVFS/GVFS.UnitTests/Virtualization/FileSystemCallbacksTests.cs b/GVFS/GVFS.UnitTests/Virtualization/FileSystemCallbacksTests.cs index 6f027f83c..6ebd817fd 100644 --- a/GVFS/GVFS.UnitTests/Virtualization/FileSystemCallbacksTests.cs +++ b/GVFS/GVFS.UnitTests/Virtualization/FileSystemCallbacksTests.cs @@ -1,4 +1,4 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.Common.Database; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; @@ -13,7 +13,6 @@ using GVFS.Virtualization; using GVFS.Virtualization.Background; using Moq; -using Newtonsoft.Json; using NUnit.Framework; using System; using System.Collections.Generic; @@ -163,8 +162,8 @@ public void GetMetadataForHeartBeatDoesSetsEventLevelToInformationalWhenPlacehol metadata.Count.ShouldEqual(8); metadata.ContainsKey("FilePlaceholderCreation").ShouldBeTrue(); metadata.TryGetValue("FilePlaceholderCreation", out object fileNestedMetadata); - JsonConvert.SerializeObject(fileNestedMetadata).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe\""); - JsonConvert.SerializeObject(fileNestedMetadata).ShouldContain("\"ProcessCount1\":1"); + GVFSJsonOptions.Serialize(fileNestedMetadata).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe\""); + GVFSJsonOptions.Serialize(fileNestedMetadata).ShouldContain("\"ProcessCount1\":1"); metadata.ShouldContain("ModifiedPathsCount", 1); metadata.ShouldContain("FilePlaceholderCount", 1); metadata.ShouldContain("FolderPlaceholderCount", 0); @@ -188,16 +187,16 @@ public void GetMetadataForHeartBeatDoesSetsEventLevelToInformationalWhenPlacehol // Only processes that have created placeholders since the last heartbeat should be named metadata.ContainsKey("FilePlaceholderCreation").ShouldBeTrue(); metadata.TryGetValue("FilePlaceholderCreation", out object fileNestedMetadata2); - JsonConvert.SerializeObject(fileNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); - JsonConvert.SerializeObject(fileNestedMetadata2).ShouldContain("\"ProcessCount1\":2"); + GVFSJsonOptions.Serialize(fileNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); + GVFSJsonOptions.Serialize(fileNestedMetadata2).ShouldContain("\"ProcessCount1\":2"); metadata.ContainsKey("FolderPlaceholderCreation").ShouldBeTrue(); metadata.TryGetValue("FolderPlaceholderCreation", out object folderNestedMetadata2); - JsonConvert.SerializeObject(folderNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); - JsonConvert.SerializeObject(folderNestedMetadata2).ShouldContain("\"ProcessCount1\":1"); + GVFSJsonOptions.Serialize(folderNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); + GVFSJsonOptions.Serialize(folderNestedMetadata2).ShouldContain("\"ProcessCount1\":1"); metadata.ContainsKey("FilePlaceholdersHydrated").ShouldBeTrue(); metadata.TryGetValue("FilePlaceholdersHydrated", out object hydrationNestedMetadata2); - JsonConvert.SerializeObject(hydrationNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); - JsonConvert.SerializeObject(hydrationNestedMetadata2).ShouldContain("\"ProcessCount1\":1"); + GVFSJsonOptions.Serialize(hydrationNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); + GVFSJsonOptions.Serialize(hydrationNestedMetadata2).ShouldContain("\"ProcessCount1\":1"); metadata.ShouldContain("ModifiedPathsCount", 1); metadata.ShouldContain("FilePlaceholderCount", 3); metadata.ShouldContain("FolderPlaceholderCount", 1); diff --git a/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs b/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs index 36750fcfd..ee9a3a907 100644 --- a/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs +++ b/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using GVFS.Common; namespace GVFS.Virtualization.Background { @@ -133,7 +133,7 @@ public static FileSystemTask OnPlaceholderCreationsBlockedForGit() public override string ToString() { - return JsonConvert.SerializeObject(this); + return GVFSJsonOptions.Serialize(this); } } } diff --git a/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj b/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj index 0adc727d8..a34c801e6 100644 --- a/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj +++ b/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj @@ -11,7 +11,7 @@ - + diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs index be8c86fab..a48a77514 100644 --- a/GVFS/GVFS/CommandLine/GVFSVerb.cs +++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs @@ -4,11 +4,11 @@ using GVFS.Common.Http; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Security; using System.Text; @@ -63,7 +63,7 @@ public string InternalParameters this.StartedByService = mountInternal.StartedByService; } - catch (JsonReaderException e) + catch (JsonException e) { this.ReportErrorAndExit("Failed to parse InternalParameters: {0}.\n {1}", value, e); } From 1e4d726cf02decd9f1cf5f2b23718e9411b9ed8f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 17 Apr 2026 10:50:11 +0000 Subject: [PATCH 09/25] Update default Microsoft Git version to v2.53.0.vfs.0.7 --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f6e9d8758..674bb2ff9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -23,7 +23,7 @@ permissions: actions: read env: - GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.53.0.vfs.0.6' }} + GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.53.0.vfs.0.7' }} jobs: validate: From 413ea79e909f3cf5821e5a6453b2b15c72b349bc Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 29 Apr 2026 14:07:06 -0700 Subject: [PATCH 10/25] Add CLI tests for GVFS, GVFS.Mount, and FastFetch verbs Add GVFS.CommandLine.Tests project with unit tests for command-line argument parsing across all three executables. Tests validate verb dispatch, argument handling, and error output without requiring a mounted GVFS enlistment. Separate test project (not merged into GVFS.UnitTests) because it references all three Exe projects (GVFS, GVFS.Mount, FastFetch). Wire into CI via RunUnitTests.bat so CLI tests run alongside existing unit tests on every PR build. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS.sln | 6 + GVFS/FastFetch/FastFetchVerb.cs | 2 +- GVFS/FastFetch/InternalsVisibleTo.cs | 3 + GVFS/FastFetch/Program.cs | 7 +- .../FastFetchCliTests.cs | 237 ++++++++++ .../GVFS.CommandLine.Tests.csproj | 21 + .../GvfsMainCliTests.cs | 427 ++++++++++++++++++ .../GvfsMountCliTests.cs | 235 ++++++++++ GVFS/GVFS.CommandLine.Tests/Program.cs | 9 + GVFS/GVFS.Mount/InternalsVisibleTo.cs | 3 + GVFS/GVFS.Mount/Program.cs | 7 +- GVFS/GVFS/InternalsVisibleTo.cs | 3 +- scripts/RunUnitTests.bat | 1 + 13 files changed, 957 insertions(+), 4 deletions(-) create mode 100644 GVFS/FastFetch/InternalsVisibleTo.cs create mode 100644 GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs create mode 100644 GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj create mode 100644 GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs create mode 100644 GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs create mode 100644 GVFS/GVFS.CommandLine.Tests/Program.cs create mode 100644 GVFS/GVFS.Mount/InternalsVisibleTo.cs diff --git a/GVFS.sln b/GVFS.sln index a3dc2e111..0bc5735c3 100644 --- a/GVFS.sln +++ b/GVFS.sln @@ -45,6 +45,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Payload", "GVFS\GVFS.P EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Installers", "GVFS\GVFS.Installers\GVFS.Installers.csproj", "{258FEAC0-5E2D-408A-9652-9E9653219F3B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GVFS.CommandLine.Tests", "GVFS\GVFS.CommandLine.Tests\GVFS.CommandLine.Tests.csproj", "{4D201963-957A-436A-8E43-79A63FB84B94}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -135,6 +137,10 @@ Global {258FEAC0-5E2D-408A-9652-9E9653219F3B}.Debug|x64.Build.0 = Debug|Any CPU {258FEAC0-5E2D-408A-9652-9E9653219F3B}.Release|x64.ActiveCfg = Release|Any CPU {258FEAC0-5E2D-408A-9652-9E9653219F3B}.Release|x64.Build.0 = Release|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Debug|x64.Build.0 = Debug|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Release|x64.ActiveCfg = Release|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/GVFS/FastFetch/FastFetchVerb.cs b/GVFS/FastFetch/FastFetchVerb.cs index e4b14b485..27c5b1c4f 100644 --- a/GVFS/FastFetch/FastFetchVerb.cs +++ b/GVFS/FastFetch/FastFetchVerb.cs @@ -122,7 +122,7 @@ public static RootCommand BuildRootCommand() }; rootCommand.Add(foldersListOption); - Option allowIndexMetadataOption = new Option("--Allow-index-metadata-update-from-working-tree") { Description = "When specified, index metadata is updated from disk if not already in the index." }; + Option allowIndexMetadataOption = new Option("--allow-index-metadata-update-from-working-tree") { Description = "When specified, index metadata is updated from disk if not already in the index." }; rootCommand.Add(allowIndexMetadataOption); Option verboseOption = new Option("--verbose") { Description = "Show all outputs on the console in addition to writing them to a log file" }; diff --git a/GVFS/FastFetch/InternalsVisibleTo.cs b/GVFS/FastFetch/InternalsVisibleTo.cs new file mode 100644 index 000000000..200018c1f --- /dev/null +++ b/GVFS/FastFetch/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] diff --git a/GVFS/FastFetch/Program.cs b/GVFS/FastFetch/Program.cs index 11417b324..b82ad97a4 100644 --- a/GVFS/FastFetch/Program.cs +++ b/GVFS/FastFetch/Program.cs @@ -1,6 +1,9 @@ using System.CommandLine; +using System.Runtime.CompilerServices; using GVFS.PlatformLoader; +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] + namespace FastFetch { public class Program @@ -8,8 +11,10 @@ public class Program public static void Main(string[] args) { GVFSPlatformLoader.Initialize(); - RootCommand rootCommand = FastFetchVerb.BuildRootCommand(); + RootCommand rootCommand = BuildRootCommand(); rootCommand.Parse(args).Invoke(); } + + internal static RootCommand BuildRootCommand() => FastFetchVerb.BuildRootCommand(); } } diff --git a/GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs b/GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs new file mode 100644 index 000000000..67a811f22 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs @@ -0,0 +1,237 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Linq; +using NUnit.Framework; + +namespace GVFS.CommandLine.Tests +{ + /// + /// Tests that FastFetch CLI parsing matches the original CommandLineParser behavior. + /// Verifies short aliases, defaults, and option names are backward-compatible. + /// + [TestFixture] + public class FastFetchCliTests + { + private RootCommand rootCommand; + + [SetUp] + public void SetUp() + { + rootCommand = FastFetch.Program.BuildRootCommand(); + } + + #region Short Aliases + + [Test] + public void CommitOption_HasShortAlias_C() + { + var opt = FindOption("--commit"); + Assert.That(opt, Is.Not.Null, "Expected --commit option to exist"); + Assert.That(opt.Aliases, Does.Contain("-c"), "Expected -c short alias for --commit"); + } + + [Test] + public void BranchOption_HasShortAlias_B() + { + var opt = FindOption("--branch"); + Assert.That(opt, Is.Not.Null, "Expected --branch option to exist"); + Assert.That(opt.Aliases, Does.Contain("-b"), "Expected -b short alias for --branch"); + } + + [Test] + public void MaxRetriesOption_HasShortAlias_R() + { + var opt = FindOption("--max-retries"); + Assert.That(opt, Is.Not.Null, "Expected --max-retries option to exist"); + Assert.That(opt.Aliases, Does.Contain("-r"), "Expected -r short alias for --max-retries"); + } + + [TestCase("-c", "abc123")] + [TestCase("-b", "main")] + [TestCase("-r", "5")] + public void ShortAliases_ParseCorrectly(string alias, string value) + { + var parseResult = rootCommand.Parse(new[] { alias, value }); + Assert.That(parseResult.Errors, Is.Empty, $"Parsing '{alias} {value}' should produce no errors"); + } + + #endregion + + #region Default Values + + [Test] + public void ChunkSize_DefaultsTo4000() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--chunk-size"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(4000), + "ChunkSize should default to 4000 when not specified"); + } + + [Test] + public void MaxRetries_DefaultsTo10() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--max-retries"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(10), + "MaxRetries should default to 10 when not specified"); + } + + [Test] + public void Folders_DefaultsToEmptyString() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--folders"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(""), + "Folders should default to empty string when not specified"); + } + + [Test] + public void FoldersList_DefaultsToEmptyString() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--folders-list"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(""), + "FoldersList should default to empty string when not specified"); + } + + [Test] + public void BooleanOptions_DefaultToFalse() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + + var checkout = FindOption("--checkout"); + var forceCheckout = FindOption("--force-checkout"); + var verbose = FindOption("--verbose"); + var allowIndexMetadata = FindOption("--allow-index-metadata-update-from-working-tree"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(checkout), Is.False, "--checkout should default to false"); + Assert.That(parseResult.GetValue(forceCheckout), Is.False, "--force-checkout should default to false"); + Assert.That(parseResult.GetValue(verbose), Is.False, "--verbose should default to false"); + Assert.That(parseResult.GetValue(allowIndexMetadata), Is.False, "--allow-index-metadata-update-from-working-tree should default to false"); + }); + } + + [Test] + public void IntThreadOptions_DefaultToZero() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + + var search = FindOption("--search-thread-count"); + var download = FindOption("--download-thread-count"); + var index = FindOption("--index-thread-count"); + var checkoutThread = FindOption("--checkout-thread-count"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(search), Is.EqualTo(0)); + Assert.That(parseResult.GetValue(download), Is.EqualTo(0)); + Assert.That(parseResult.GetValue(index), Is.EqualTo(0)); + Assert.That(parseResult.GetValue(checkoutThread), Is.EqualTo(0)); + }); + } + + #endregion + + #region Explicit Value Parsing + + [Test] + public void ChunkSize_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "--chunk-size", "8000" }); + var opt = FindOption("--chunk-size"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(8000)); + } + + [Test] + public void MaxRetries_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "--max-retries", "3" }); + var opt = FindOption("--max-retries"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(3)); + } + + [Test] + public void CommitAndBranch_ParseWithShortAliases() + { + var parseResult = rootCommand.Parse(new[] { "-c", "abc123", "-b", "feature/test" }); + var commitOpt = FindOption("--commit"); + var branchOpt = FindOption("--branch"); + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(commitOpt), Is.EqualTo("abc123")); + Assert.That(parseResult.GetValue(branchOpt), Is.EqualTo("feature/test")); + }); + } + + [Test] + public void AllStringOptions_ParseCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "--commit", "abc123", + "--branch", "main", + "--cache-server-url", "https://cache.example.com", + "--git-path", @"C:\Program Files\Git\bin\git.exe", + "--folders", "src;lib", + "--folders-list", @"C:\folders.txt", + "--parent-activity-id", "12345678-1234-1234-1234-123456789012" + }); + + Assert.That(parseResult.Errors, Is.Empty, "All string options should parse without errors"); + } + + [Test] + public void MaxRetries_ShortAlias_R_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "-r", "5" }); + var opt = FindOption("--max-retries"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(5)); + } + + #endregion + + #region All Expected Options Exist + + [Test] + public void AllExpectedOptions_Exist() + { + var expectedOptions = new[] + { + "--commit", "--branch", "--cache-server-url", "--chunk-size", + "--checkout", "--force-checkout", "--search-thread-count", + "--download-thread-count", "--index-thread-count", "--checkout-thread-count", + "--max-retries", "--git-path", "--folders", "--folders-list", + "--allow-index-metadata-update-from-working-tree", "--verbose", + "--parent-activity-id" + }; + + foreach (var optName in expectedOptions) + { + Assert.That(FindOption(optName), Is.Not.Null, $"Expected option {optName} to exist"); + } + } + + #endregion + + #region Helpers + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)); + } + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)) as Option; + } + + #endregion + } +} diff --git a/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj b/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj new file mode 100644 index 000000000..21929b438 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj @@ -0,0 +1,21 @@ + + + + net471 + true + Exe + + + + + + + + + + + + + + + diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs new file mode 100644 index 000000000..4eb360808 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs @@ -0,0 +1,427 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Linq; +using NUnit.Framework; + +namespace GVFS.CommandLine.Tests +{ + /// + /// Tests that GVFS main CLI parsing matches the original CommandLineParser behavior. + /// Verifies all verb subcommands, short aliases, and option compatibility. + /// + /// + /// System.CommandLine 2.0.3 note: Option.Name holds the primary name (e.g. "--list"), + /// while Option.Aliases only contains SHORT aliases added via Aliases.Add() (e.g. "-l"). + /// All lookups must check both Name and Aliases to find an option by any of its names. + /// + [TestFixture] + public class GvfsMainCliTests + { + private RootCommand rootCommand; + + [SetUp] + public void SetUp() + { + rootCommand = GVFS.Program.BuildRootCommand(); + } + + #region All Subcommands Exist + + [TestCase("cache-server")] + [TestCase("clone")] + [TestCase("config")] + [TestCase("dehydrate")] + [TestCase("diagnose")] + [TestCase("health")] + [TestCase("log")] + [TestCase("mount")] + [TestCase("prefetch")] + [TestCase("repair")] + [TestCase("service")] + [TestCase("sparse")] + [TestCase("status")] + [TestCase("unmount")] + [TestCase("upgrade")] + [TestCase("version")] + public void Subcommand_Exists(string name) + { + var cmd = rootCommand.Subcommands.FirstOrDefault(c => c.Name == name); + Assert.That(cmd, Is.Not.Null, $"Expected subcommand '{name}' to exist"); + } + + #endregion + + #region Clone Short Aliases + + [Test] + public void Clone_BranchOption_HasShortAlias_B() + { + var opt = FindOptionOnCommand("clone", "--branch"); + Assert.That(opt, Is.Not.Null, "Expected --branch option on clone"); + Assert.That(opt.Aliases, Does.Contain("-b"), "Expected -b short alias for clone --branch"); + } + + [Test] + public void Clone_ParsesWithShortAlias() + { + var parseResult = rootCommand.Parse(new[] { "clone", "https://example.com/repo", "-b", "main" }); + Assert.That(parseResult.Errors, Is.Empty, "clone with -b should parse without errors"); + } + + #endregion + + #region Config Short Aliases + + [Test] + public void Config_ListOption_HasShortAlias_L() + { + var opt = FindOptionOnCommand("config", "--list"); + Assert.That(opt, Is.Not.Null, "Expected --list option on config"); + Assert.That(opt.Aliases, Does.Contain("-l"), "Expected -l short alias for config --list"); + } + + [Test] + public void Config_DeleteOption_HasShortAlias_D() + { + var opt = FindOptionOnCommand("config", "--delete"); + Assert.That(opt, Is.Not.Null, "Expected --delete option on config"); + Assert.That(opt.Aliases, Does.Contain("-d"), "Expected -d short alias for config --delete"); + } + + #endregion + + #region Health Short Aliases + + [Test] + public void Health_DisplayCountOption_HasName_N() + { + var opt = FindOptionOnCommand("health", "-n"); + Assert.That(opt, Is.Not.Null, "Expected -n option on health command"); + } + + [Test] + public void Health_DirectoryOption_HasShortAlias_D() + { + var opt = FindOptionOnCommand("health", "--directory"); + Assert.That(opt, Is.Not.Null, "Expected --directory option on health"); + Assert.That(opt.Aliases, Does.Contain("-d"), "Expected -d short alias for health --directory"); + } + + [Test] + public void Health_StatusOption_HasShortAlias_S() + { + var opt = FindOptionOnCommand("health", "--status"); + Assert.That(opt, Is.Not.Null, "Expected --status option on health"); + Assert.That(opt.Aliases, Does.Contain("-s"), "Expected -s short alias for health --status"); + } + + #endregion + + #region Mount Short Aliases + + [Test] + public void Mount_VerbosityOption_HasShortAlias_V() + { + var opt = FindOptionOnCommand("mount", "--verbosity"); + Assert.That(opt, Is.Not.Null, "Expected --verbosity option on mount"); + Assert.That(opt.Aliases, Does.Contain("-v"), "Expected -v short alias for mount --verbosity"); + } + + [Test] + public void Mount_KeywordsOption_HasShortAlias_K() + { + var opt = FindOptionOnCommand("mount", "--keywords"); + Assert.That(opt, Is.Not.Null, "Expected --keywords option on mount"); + Assert.That(opt.Aliases, Does.Contain("-k"), "Expected -k short alias for mount --keywords"); + } + + #endregion + + #region Prefetch Short Aliases + + [Test] + public void Prefetch_CommitsOption_HasShortAlias_C() + { + var opt = FindOptionOnCommand("prefetch", "--commits"); + Assert.That(opt, Is.Not.Null, "Expected --commits option on prefetch"); + Assert.That(opt.Aliases, Does.Contain("-c"), "Expected -c short alias for prefetch --commits"); + } + + #endregion + + #region Sparse Short Aliases (7 aliases) + + [TestCase("--set", "-s")] + [TestCase("--file", "-f")] + [TestCase("--add", "-a")] + [TestCase("--remove", "-r")] + [TestCase("--list", "-l")] + [TestCase("--prune", "-p")] + [TestCase("--disable", "-d")] + public void Sparse_Option_HasShortAlias(string longName, string shortAlias) + { + var opt = FindOptionOnCommand("sparse", longName); + Assert.That(opt, Is.Not.Null, $"Expected {longName} option on sparse"); + Assert.That(opt.Aliases, Does.Contain(shortAlias), + $"Expected {shortAlias} short alias for sparse {longName}"); + } + + #endregion + + #region String Defaults (null-coalesce guards) + + [Test] + public void Dehydrate_Folders_DefaultsToNullOrEmpty() + { + // Original had Default = "". Now we guard with ?? "" in the action. + // From parse result, the default for unset string is null. + // The null-coalesce guard ensures the verb receives "" not null. + var opt = FindOptionOnCommand("dehydrate", "--folders"); + Assert.That(opt, Is.Not.Null, "Expected --folders option on dehydrate"); + } + + [Test] + public void Prefetch_StringOptions_Exist() + { + var expectedOptions = new[] { "--files", "--folders", "--folders-list", "--files-list" }; + + foreach (var optName in expectedOptions) + { + var opt = FindOptionOnCommand("prefetch", optName); + Assert.That(opt, Is.Not.Null, $"Expected {optName} option on prefetch"); + } + } + + [Test] + public void Sparse_StringOptions_Exist() + { + var expectedOptions = new[] { "--set", "--file", "--add", "--remove" }; + + foreach (var optName in expectedOptions) + { + var opt = FindOptionOnCommand("sparse", optName); + Assert.That(opt, Is.Not.Null, $"Expected {optName} option on sparse"); + } + } + + #endregion + + #region Full Command Parsing + + [Test] + public void Clone_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "clone", "https://example.com/repo", @"C:\Users\test\repo", + "--cache-server-url", "https://cache.test", + "-b", "develop", + "--single-branch", + "--no-mount", + "--no-prefetch" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full clone command should parse without errors"); + } + + [Test] + public void Mount_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "mount", @"C:\Users\test\repo", + "-v", "Warning", + "-k", "Network" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full mount command should parse without errors"); + } + + [Test] + public void Prefetch_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "prefetch", + "--folders", "src;lib", + "--files", "*.cs;*.h", + "-c", + "--verbose" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full prefetch command should parse without errors"); + } + + [Test] + public void Sparse_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "sparse", + "-s", "src;lib;tests", + "-l" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full sparse command should parse without errors"); + } + + [Test] + public void Health_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "health", + "-n", "20", + "-d", @"src\components", + "-s" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full health command should parse without errors"); + } + + [Test] + public void Dehydrate_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "dehydrate", + "--confirm", + "--folders", "src/old;temp" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full dehydrate command with --confirm --folders should parse without errors"); + } + + [Test] + public void Service_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "service", "--list-mounted" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + [Test] + public void Upgrade_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "upgrade", "--confirm" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + [Test] + public void Unmount_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "unmount" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + [Test] + public void Config_FullCommandLine_List_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "config", "-l" }); + Assert.That(parseResult.Errors, Is.Empty, "config -l should parse without errors"); + } + + [Test] + public void Config_FullCommandLine_SetKeyValue_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "config", "mykey", "myvalue" }); + Assert.That(parseResult.Errors, Is.Empty, "config key value should parse without errors"); + } + + [Test] + public void Config_FullCommandLine_Delete_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "config", "-d", "mykey" }); + Assert.That(parseResult.Errors, Is.Empty, "config -d key should parse without errors"); + } + + [Test] + public void Repair_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "repair", "--confirm" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + #endregion + + #region Option Existence per Verb (complete verification) + + [Test] + public void Clone_HasAllExpectedOptions() + { + var expected = new[] { "--cache-server-url", "--branch", "--single-branch", "--no-mount", "--no-prefetch", "--local-cache-path" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("clone", optName), Is.Not.Null, + $"clone should have {optName} option"); + } + } + + [Test] + public void Dehydrate_HasAllExpectedOptions() + { + var expected = new[] { "--confirm", "--no-status", "--folders" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("dehydrate", optName), Is.Not.Null, + $"dehydrate should have {optName} option"); + } + } + + [Test] + public void Prefetch_HasAllExpectedOptions() + { + var expected = new[] { "--files", "--folders", "--folders-list", "--stdin-files-list", + "--stdin-folders-list", "--files-list", "--hydrate", "--commits", "--verbose" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("prefetch", optName), Is.Not.Null, + $"prefetch should have {optName} option"); + } + } + + [Test] + public void Service_HasAllExpectedOptions() + { + var expected = new[] { "--mount-all", "--unmount-all", "--list-mounted" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("service", optName), Is.Not.Null, + $"service should have {optName} option"); + } + } + + [Test] + public void Upgrade_HasAllExpectedOptions() + { + var expected = new[] { "--confirm", "--dry-run", "--no-verify" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("upgrade", optName), Is.Not.Null, + $"upgrade should have {optName} option"); + } + } + + [Test] + public void Unmount_HasSkipLockOption() + { + Assert.That(FindOptionOnCommand("unmount", "--skip-wait-for-lock"), Is.Not.Null, + "unmount should have --skip-wait-for-lock option"); + } + + #endregion + + #region Helpers + + private Command FindSubcommand(string name) + { + return rootCommand.Subcommands.FirstOrDefault(c => c.Name == name) + ?? throw new System.Exception($"Subcommand '{name}' not found"); + } + + /// + /// Find an option on a subcommand by checking both Name and Aliases. + /// System.CommandLine 2.0.3: Name holds the primary name, Aliases holds only short aliases. + /// + private Option FindOptionOnCommand(string subcommandName, string optionName) + { + var cmd = FindSubcommand(subcommandName); + return cmd.Options.FirstOrDefault(o => o.Name == optionName || o.Aliases.Contains(optionName)); + } + + #endregion + } +} diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs new file mode 100644 index 000000000..c352b6429 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs @@ -0,0 +1,235 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Linq; +using NUnit.Framework; + +namespace GVFS.CommandLine.Tests +{ + /// + /// Tests that GVFS.Mount CLI parsing matches the original CommandLineParser behavior. + /// Verifies defaults (not aliases — this is an internal tool called with long names). + /// + [TestFixture] + public class GvfsMountCliTests + { + private RootCommand rootCommand; + + [SetUp] + public void SetUp() + { + rootCommand = GVFS.Mount.Program.BuildRootCommand(); + } + + #region Default Values — Critical (these were previously broken) + + [Test] + public void Verbosity_DefaultsToInformational() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--verbosity"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Informational"), + "Verbosity should default to 'Informational' when not specified"); + } + + [Test] + public void Keywords_DefaultsToAny() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--keywords"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Any"), + "Keywords should default to 'Any' when not specified"); + } + + [Test] + public void StartedByService_DefaultsToFalse() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--StartedByService"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("false"), + "StartedByService should default to 'false' when not specified"); + } + + #endregion + + #region Defaults Are Not Aliases + + [Test] + public void Informational_IsNotAnAlias() + { + var opt = FindOption("--verbosity"); + Assert.That(opt, Is.Not.Null); + Assert.That(opt.Aliases, Does.Not.Contain("Informational"), + "'Informational' should NOT be an alias for --verbosity"); + } + + [Test] + public void Any_IsNotAnAlias() + { + var opt = FindOption("--keywords"); + Assert.That(opt, Is.Not.Null); + Assert.That(opt.Aliases, Does.Not.Contain("Any"), + "'Any' should NOT be an alias for --keywords"); + } + + [Test] + public void False_IsNotAnAlias() + { + var opt = FindOption("--StartedByService"); + Assert.That(opt, Is.Not.Null); + Assert.That(opt.Aliases, Does.Not.Contain("false"), + "'false' should NOT be an alias for --StartedByService"); + } + + #endregion + + #region Explicit Value Parsing + + [Test] + public void Verbosity_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo", "--verbosity", "Verbose" }); + var opt = FindOption("--verbosity"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Verbose")); + } + + [Test] + public void Keywords_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo", "--keywords", "Network" }); + var opt = FindOption("--keywords"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Network")); + } + + [Test] + public void StartedByService_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo", "--StartedByService", "true" }); + var opt = FindOption("--StartedByService"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("true")); + } + + [Test] + public void DebugWindow_DefaultsFalse() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--debug-window"); + Assert.That(parseResult.GetValue(opt), Is.False); + } + + [Test] + public void StartedByVerb_DefaultsFalse() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--StartedByVerb"); + Assert.That(parseResult.GetValue(opt), Is.False); + } + + #endregion + + #region Argument Parsing + + [Test] + public void EnlistmentRootPath_IsParsed() + { + var parseResult = rootCommand.Parse(new[] { @"C:\Users\test\repo" }); + var arg = rootCommand.Arguments.FirstOrDefault(a => a.Name == "enlistment-root-path"); + Assert.That(arg, Is.Not.Null); + Assert.That(parseResult.GetValue((Argument)arg), Is.EqualTo(@"C:\Users\test\repo")); + } + + #endregion + + #region Full Command Line (matches how MountVerb launches GVFS.Mount.exe) + + [Test] + public void MountVerbCommandLine_ParsesCorrectly() + { + // MountVerb constructs: GVFS.Mount --verbosity Informational --keywords Any --StartedByVerb + var parseResult = rootCommand.Parse(new[] + { + @"C:\Users\test\repo", + "--verbosity", "Informational", + "--keywords", "Any", + "--StartedByVerb" + }); + + Assert.That(parseResult.Errors, Is.Empty, "MountVerb-style command line should parse without errors"); + + var verbOpt = FindOption("--verbosity"); + var kwOpt = FindOption("--keywords"); + var verbStartedOpt = FindOption("--StartedByVerb"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(verbOpt), Is.EqualTo("Informational")); + Assert.That(parseResult.GetValue(kwOpt), Is.EqualTo("Any")); + Assert.That(parseResult.GetValue(verbStartedOpt), Is.True); + }); + } + + [Test] + public void ServiceStartedCommandLine_ParsesCorrectly() + { + // MountVerb constructs when started by service: + // GVFS.Mount --verbosity Warning --keywords Network --StartedByService true + var parseResult = rootCommand.Parse(new[] + { + @"C:\Users\test\repo", + "--verbosity", "Warning", + "--keywords", "Network", + "--StartedByService", "true" + }); + + Assert.That(parseResult.Errors, Is.Empty); + + var verbOpt = FindOption("--verbosity"); + var kwOpt = FindOption("--keywords"); + var svcOpt = FindOption("--StartedByService"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(verbOpt), Is.EqualTo("Warning")); + Assert.That(parseResult.GetValue(kwOpt), Is.EqualTo("Network")); + Assert.That(parseResult.GetValue(svcOpt), Is.EqualTo("true")); + }); + } + + #endregion + + #region All Expected Options Exist + + [Test] + public void AllExpectedOptions_Exist() + { + var expectedOptions = new[] + { + "--verbosity", "--keywords", "--debug-window", + "--StartedByService", "--StartedByVerb" + }; + + foreach (var optName in expectedOptions) + { + Assert.That(FindOption(optName), Is.Not.Null, $"Expected option {optName} to exist"); + } + } + + #endregion + + #region Helpers + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)); + } + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)) as Option; + } + + #endregion + } +} diff --git a/GVFS/GVFS.CommandLine.Tests/Program.cs b/GVFS/GVFS.CommandLine.Tests/Program.cs new file mode 100644 index 000000000..d30bfea11 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/Program.cs @@ -0,0 +1,9 @@ +using NUnitLite; + +namespace GVFS.CommandLine.Tests +{ + public class Program + { + public static int Main(string[] args) => new AutoRun().Execute(args); + } +} diff --git a/GVFS/GVFS.Mount/InternalsVisibleTo.cs b/GVFS/GVFS.Mount/InternalsVisibleTo.cs new file mode 100644 index 000000000..200018c1f --- /dev/null +++ b/GVFS/GVFS.Mount/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] diff --git a/GVFS/GVFS.Mount/Program.cs b/GVFS/GVFS.Mount/Program.cs index 12f61c807..96584e113 100644 --- a/GVFS/GVFS.Mount/Program.cs +++ b/GVFS/GVFS.Mount/Program.cs @@ -1,7 +1,10 @@ using System.CommandLine; +using System.Runtime.CompilerServices; using GVFS.PlatformLoader; using System; +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] + namespace GVFS.Mount { public class Program @@ -11,7 +14,7 @@ public static void Main(string[] args) GVFSPlatformLoader.Initialize(); try { - RootCommand rootCommand = InProcessMountVerb.BuildRootCommand(); + RootCommand rootCommand = BuildRootCommand(); rootCommand.Parse(args).Invoke(); } catch (MountAbortedException e) @@ -20,5 +23,7 @@ public static void Main(string[] args) Environment.Exit((int)e.Verb.ReturnCode); } } + + internal static RootCommand BuildRootCommand() => InProcessMountVerb.BuildRootCommand(); } } diff --git a/GVFS/GVFS/InternalsVisibleTo.cs b/GVFS/GVFS/InternalsVisibleTo.cs index 0ba48d81b..248b151be 100644 --- a/GVFS/GVFS/InternalsVisibleTo.cs +++ b/GVFS/GVFS/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("GVFS.UnitTests")] +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] diff --git a/scripts/RunUnitTests.bat b/scripts/RunUnitTests.bat index 3424825ca..8ae14aa44 100644 --- a/scripts/RunUnitTests.bat +++ b/scripts/RunUnitTests.bat @@ -6,5 +6,6 @@ IF "%1"=="" (SET "CONFIGURATION=Debug") ELSE (SET "CONFIGURATION=%1") SET RESULT=0 %VFS_OUTDIR%\GVFS.UnitTests\bin\%CONFIGURATION%\net471\win-x64\GVFS.UnitTests.exe || SET RESULT=1 +%VFS_OUTDIR%\GVFS.CommandLine.Tests\bin\%CONFIGURATION%\net471\win-x64\GVFS.CommandLine.Tests.exe || SET RESULT=1 EXIT /b %RESULT% From 663344744c1fc30ddd85a2d196b94f2e9b6276d0 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 29 Apr 2026 15:50:08 -0700 Subject: [PATCH 11/25] Retarget managed projects to .NET 10 with NativeAOT Retarget from net471 to net10.0-windows10.0.17763.0 across all managed projects. Enable NativeAOT self-contained deployment, eliminating the .NET runtime dependency. Build infrastructure: - global.json: pin SDK 10.0.203 - Directory.Build.props: centralized TFM, SelfContained, PublishAot, OptimizationPreference=Speed - Directory.Build.targets: AOT build targets; opt out test projects and GVFS.MSBuild (netstandard2.0) from AOT - Build.bat: 3-step build (dotnet restore, VS MSBuild for C++, dotnet publish for managed AOT binaries) - publish-aot.ps1: standalone script for local AOT publish testing (CI uses Build.bat; this script is for dev iteration) - Update output paths in all scripts (net471 -> net10.0-.../publish) - Update CI to .NET 10 SDK and windows-2025 runner - Update installer MinVersion to 10.0.17763 Package updates: - Microsoft.Windows.ProjFS 1.1 -> 2.1.0: pure C# P/Invoke replacing C++/CLI interop, required for NativeAOT compatibility - Microsoft.Data.Sqlite 2.2.4 -> 9.0.4, Microsoft.Build.* 16 -> 17.12.6 - Add System.Diagnostics.EventLog, System.IO.Pipes.AccessControl: previously included in .NET Framework, now separate packages - Remove GVFS.ProjFS (ProjFS is now a Windows OS feature) Unit test fixture updates for new ProjFS managed API surface. Output: ~20 MB native GVFS.exe, 36.7 MB installer (vs 107 MB with full self-contained runtime) Co-authored-by: Michael Niksa Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .github/workflows/build.yaml | 2 +- Directory.Build.props | 4 + Directory.Build.targets | 63 +++-- Directory.Packages.props | 27 +- GVFS/FastFetch/FastFetch.csproj | 2 +- .../GVFS.CommandLine.Tests.csproj | 3 +- GVFS/GVFS.Common/GVFS.Common.csproj | 10 +- .../GVFS.FunctionalTests.LockHolder.csproj | 5 +- .../GVFS.FunctionalTests.csproj | 10 +- GVFS/GVFS.Hooks/GVFS.Hooks.csproj | 2 +- GVFS/GVFS.Installers/GVFS.Installers.csproj | 2 +- GVFS/GVFS.Installers/Setup.iss | 2 +- GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj | 2 + GVFS/GVFS.Mount/GVFS.Mount.csproj | 5 +- GVFS/GVFS.Payload/GVFS.Payload.csproj | 23 +- GVFS/GVFS.Payload/layout.bat | 30 +-- .../GVFS.PerfProfiling.csproj | 2 +- .../GVFS.Platform.Windows.csproj | 8 +- GVFS/GVFS.Service/GVFS.Service.csproj | 11 +- GVFS/GVFS.Tests/GVFS.Tests.csproj | 2 +- GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj | 21 +- .../Mock/MockVirtualizationInstance.cs | 8 +- .../WindowsFileSystemVirtualizerTester.cs | 2 +- .../WindowsFileSystemVirtualizerTests.cs | 18 +- .../GVFS.Virtualization.csproj | 3 +- GVFS/GVFS/GVFS.csproj | 5 +- global.json | 8 +- scripts/Build.bat | 114 +++++--- scripts/CreateBuildArtifacts.bat | 6 +- scripts/RunFunctionalTests-Dev.ps1 | 4 +- scripts/RunFunctionalTests.bat | 2 +- scripts/RunUnitTests.bat | 3 +- scripts/publish-aot.ps1 | 244 ++++++++++++++++++ 33 files changed, 460 insertions(+), 193 deletions(-) create mode 100644 scripts/publish-aot.ps1 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 674bb2ff9..b9bf68722 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -198,7 +198,7 @@ jobs: if: steps.skip.outputs.result != 'true' uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.0.413 + global-json-file: src/global.json - name: Add MSBuild to PATH if: steps.skip.outputs.result != 'true' diff --git a/Directory.Build.props b/Directory.Build.props index eb31784b6..16a5a8c68 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,9 +22,13 @@ + net10.0-windows10.0.17763.0 latest win-x64 x64 + true + true + Speed $(ProjectOutPath)bin\ $(ProjectOutPath)obj\ diff --git a/Directory.Build.targets b/Directory.Build.targets index 578902043..272ea11ec 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,17 +1,48 @@ - - - - - $(GVFSVersion) - - - false - - - - - + + + + $(GVFSVersion) + + false + + + + + + + + + <_ManagedOutFragment>bin\$(Configuration)\$(TargetFramework)\win-x64 + + + + <_NativeHook Include="$(RepoOutPath)GitHooksLoader\bin\x64\$(Configuration)\GitHooksLoader.exe" /> + <_NativeHook Include="$(RepoOutPath)GVFS.ReadObjectHook\bin\x64\$(Configuration)\GVFS.ReadObjectHook.exe" /> + <_NativeHook Include="$(RepoOutPath)GVFS.PostIndexChangedHook\bin\x64\$(Configuration)\GVFS.PostIndexChangedHook.exe" /> + <_NativeHook Include="$(RepoOutPath)GVFS.VirtualFileSystemHook\bin\x64\$(Configuration)\GVFS.VirtualFileSystemHook.exe" /> + + <_PeerExe Include="$(RepoOutPath)GVFS.Mount\$(_ManagedOutFragment)\GVFS.Mount.exe" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Mount\$(_ManagedOutFragment)\GVFS.Mount.dll" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Mount\$(_ManagedOutFragment)\GVFS.Mount.runtimeconfig.json" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Mount\$(_ManagedOutFragment)\GVFS.Mount.deps.json" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Hooks\$(_ManagedOutFragment)\GVFS.Hooks.exe" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Hooks\$(_ManagedOutFragment)\GVFS.Hooks.dll" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Hooks\$(_ManagedOutFragment)\GVFS.Hooks.runtimeconfig.json" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Hooks\$(_ManagedOutFragment)\GVFS.Hooks.deps.json" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Service\$(_ManagedOutFragment)\GVFS.Service.exe" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Service\$(_ManagedOutFragment)\GVFS.Service.dll" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Service\$(_ManagedOutFragment)\GVFS.Service.runtimeconfig.json" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Service\$(_ManagedOutFragment)\GVFS.Service.deps.json" /> + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 96261bbda..faf9cf3ae 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,14 +2,12 @@ true + true - - - - + @@ -18,30 +16,27 @@ - - + - + + - - + + + + + + - - - - diff --git a/GVFS/FastFetch/FastFetch.csproj b/GVFS/FastFetch/FastFetch.csproj index 07b3555de..ad8ec89a3 100644 --- a/GVFS/FastFetch/FastFetch.csproj +++ b/GVFS/FastFetch/FastFetch.csproj @@ -2,7 +2,6 @@ Exe - net471 x64 true @@ -28,3 +27,4 @@ + diff --git a/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj b/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj index 21929b438..356206fb8 100644 --- a/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj +++ b/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj @@ -1,9 +1,10 @@ - net471 true Exe + false + false diff --git a/GVFS/GVFS.Common/GVFS.Common.csproj b/GVFS/GVFS.Common/GVFS.Common.csproj index f5af45767..4005b2eca 100644 --- a/GVFS/GVFS.Common/GVFS.Common.csproj +++ b/GVFS/GVFS.Common/GVFS.Common.csproj @@ -1,22 +1,15 @@ - + - net471 true - - - - - - @@ -28,3 +21,4 @@ + diff --git a/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj b/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj index b2f2ca1d6..719420227 100644 --- a/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj +++ b/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj @@ -1,8 +1,8 @@ - + - net471 Exe + false @@ -16,3 +16,4 @@ + diff --git a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj index 9800b6b95..4d60d7b54 100644 --- a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj +++ b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj @@ -1,15 +1,14 @@ - + - net471 Exe + false - @@ -22,10 +21,8 @@ Content PreserveNewest - - false - + PreserveNewest @@ -35,3 +32,4 @@ + diff --git a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj index 8e4017902..b45fbbeef 100644 --- a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj +++ b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj @@ -2,7 +2,6 @@ Exe - net471 true @@ -120,3 +119,4 @@ + diff --git a/GVFS/GVFS.Installers/GVFS.Installers.csproj b/GVFS/GVFS.Installers/GVFS.Installers.csproj index 8a849014a..5961b569b 100644 --- a/GVFS/GVFS.Installers/GVFS.Installers.csproj +++ b/GVFS/GVFS.Installers/GVFS.Installers.csproj @@ -1,7 +1,6 @@ - net471 false $(RepoOutPath)GVFS.Payload\bin\$(Configuration)\win-x64\ @@ -45,3 +44,4 @@ + diff --git a/GVFS/GVFS.Installers/Setup.iss b/GVFS/GVFS.Installers/Setup.iss index f8a166d75..c9e00b6e0 100644 --- a/GVFS/GVFS.Installers/Setup.iss +++ b/GVFS/GVFS.Installers/Setup.iss @@ -34,7 +34,7 @@ OutputDir=Setup Compression=lzma2 InternalCompressLevel=ultra64 SolidCompression=yes -MinVersion=10.0.14374 +MinVersion=10.0.17763 DisableDirPage=yes DisableReadyPage=yes SetupIconFile="{#LayoutDir}\GitVirtualFileSystem.ico" diff --git a/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj b/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj index ff90a1742..bd33c1678 100644 --- a/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj +++ b/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj @@ -2,6 +2,8 @@ netstandard2.0 + false + false false diff --git a/GVFS/GVFS.Mount/GVFS.Mount.csproj b/GVFS/GVFS.Mount/GVFS.Mount.csproj index 56526ac3b..271d0cc87 100644 --- a/GVFS/GVFS.Mount/GVFS.Mount.csproj +++ b/GVFS/GVFS.Mount/GVFS.Mount.csproj @@ -2,15 +2,11 @@ Exe - net471 false - Content - PreserveNewest - Build;DebugSymbolsProjectOutputGroup @@ -20,3 +16,4 @@ + diff --git a/GVFS/GVFS.Payload/GVFS.Payload.csproj b/GVFS/GVFS.Payload/GVFS.Payload.csproj index 9787bedb0..c87428e9f 100644 --- a/GVFS/GVFS.Payload/GVFS.Payload.csproj +++ b/GVFS/GVFS.Payload/GVFS.Payload.csproj @@ -1,7 +1,6 @@ - + - net471 false @@ -11,24 +10,12 @@ - - - - - - - - - - - - - + @@ -38,19 +25,17 @@ + $(OutputPath)\GVFS.VirtualFileSystemHook.exe;"> Microsoft400 false + diff --git a/GVFS/GVFS.Payload/layout.bat b/GVFS/GVFS.Payload/layout.bat index 70a8de57b..e1ff77270 100644 --- a/GVFS/GVFS.Payload/layout.bat +++ b/GVFS/GVFS.Payload/layout.bat @@ -14,18 +14,12 @@ IF "%~2" == "" ( ) IF "%~3" == "" ( - ECHO error: missing ProjFS path - ECHO. - GOTO USAGE -) - -IF "%~4" == "" ( ECHO error: missing VCRuntime path ECHO. GOTO USAGE ) -IF "%~5" == "" ( +IF "%~4" == "" ( ECHO error: missing output path ECHO. GOTO USAGE @@ -33,19 +27,17 @@ IF "%~5" == "" ( SET CONFIGURATION=%1 SET GVFSVERSION=%2 -SET PROJFS=%3 -SET VCRUNTIME=%4 -SET OUTPUT=%5 +SET VCRUNTIME=%3 +SET OUTPUT=%4 SET ROOT=%~dp0..\.. SET BUILD_OUT="%ROOT%\..\out" -SET MANAGED_OUT_FRAGMENT=bin\%CONFIGURATION%\net471\win-x64 +SET MANAGED_OUT_FRAGMENT=bin\%CONFIGURATION%\net10.0-windows10.0.17763.0\win-x64\publish SET NATIVE_OUT_FRAGMENT=bin\x64\%CONFIGURATION% ECHO Copying files... -xcopy /Y %PROJFS%\filter\PrjFlt.sys %OUTPUT%\Filter\ -xcopy /Y %PROJFS%\filter\prjflt.inf %OUTPUT%\Filter\ -xcopy /Y %PROJFS%\lib\ProjectedFSLib.dll %OUTPUT%\ProjFS\ +REM ProjFS is now a Windows Optional Feature (available since Windows 10 1809). +REM The filter driver and native library are no longer bundled from a NuGet package. xcopy /Y %VCRUNTIME%\lib\x64\msvcp140.dll %OUTPUT% xcopy /Y %VCRUNTIME%\lib\x64\msvcp140_1.dll %OUTPUT% xcopy /Y %VCRUNTIME%\lib\x64\msvcp140_2.dll %OUTPUT% @@ -64,15 +56,21 @@ REM Remove unused LibGit2 files RMDIR /S /Q %OUTPUT%\lib REM Remove files for x86 (not supported) RMDIR /S /Q %OUTPUT%\x86 +REM Remove stray managed artifacts (AOT binaries don't need these) +DEL /Q %OUTPUT%\*.runtimeconfig.json 2>nul +DEL /Q %OUTPUT%\*.deps.json 2>nul +REM Remove orphaned managed PDBs (these libraries are compiled into AOT exes) +DEL /Q %OUTPUT%\GVFS.Common.pdb 2>nul +DEL /Q %OUTPUT%\GVFS.Platform.Windows.pdb 2>nul +DEL /Q %OUTPUT%\GVFS.Virtualization.pdb 2>nul GOTO EOF :USAGE -ECHO usage: %~n0%~x0 ^ ^ ^ ^ ^ +ECHO usage: %~n0%~x0 ^ ^ ^ ^ ECHO. ECHO configuration Build configuration (Debug, Release). ECHO version GVFS version string. -ECHO projfs Path to GVFS.ProjFS NuGet package contents. ECHO vcruntime Path to GVFS.VCRuntime NuGet package contents. ECHO output Output directory. ECHO. diff --git a/GVFS/GVFS.PerfProfiling/GVFS.PerfProfiling.csproj b/GVFS/GVFS.PerfProfiling/GVFS.PerfProfiling.csproj index bf3ce6850..995d31921 100644 --- a/GVFS/GVFS.PerfProfiling/GVFS.PerfProfiling.csproj +++ b/GVFS/GVFS.PerfProfiling/GVFS.PerfProfiling.csproj @@ -2,7 +2,6 @@ Exe - net471 @@ -12,3 +11,4 @@ + diff --git a/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj b/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj index 0ffe30b8f..9ee218047 100644 --- a/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj +++ b/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj @@ -1,7 +1,6 @@ - net471 @@ -10,11 +9,8 @@ - - - - - + + diff --git a/GVFS/GVFS.Service/GVFS.Service.csproj b/GVFS/GVFS.Service/GVFS.Service.csproj index 07a366174..6bb3ec81b 100644 --- a/GVFS/GVFS.Service/GVFS.Service.csproj +++ b/GVFS/GVFS.Service/GVFS.Service.csproj @@ -1,8 +1,7 @@ - + Exe - net471 true @@ -11,12 +10,10 @@ - - - - - + + + diff --git a/GVFS/GVFS.Tests/GVFS.Tests.csproj b/GVFS/GVFS.Tests/GVFS.Tests.csproj index acfabea90..19d7e97e5 100644 --- a/GVFS/GVFS.Tests/GVFS.Tests.csproj +++ b/GVFS/GVFS.Tests/GVFS.Tests.csproj @@ -1,7 +1,6 @@ - net471 @@ -10,3 +9,4 @@ + diff --git a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj index efb890af3..0df5f3263 100644 --- a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj +++ b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj @@ -1,9 +1,9 @@ - + - net471 Exe true + false @@ -11,6 +11,7 @@ + @@ -20,20 +21,9 @@ - - - - - - + - - - ProjectedFSLib.dll - PreserveNewest - + @@ -58,3 +48,4 @@ + diff --git a/GVFS/GVFS.UnitTests/Windows/Mock/MockVirtualizationInstance.cs b/GVFS/GVFS.UnitTests/Windows/Mock/MockVirtualizationInstance.cs index 86d8737f3..16659ddda 100644 --- a/GVFS/GVFS.UnitTests/Windows/Mock/MockVirtualizationInstance.cs +++ b/GVFS/GVFS.UnitTests/Windows/Mock/MockVirtualizationInstance.cs @@ -31,9 +31,13 @@ public MockVirtualizationInstance() public ConcurrentHashSet CreatedPlaceholders { get; private set; } + public Guid VirtualizationInstanceId { get; set; } + + public int PlaceholderIdLength { get; set; } + public CancelCommandCallback OnCancelCommand { get; set; } - public IRequiredCallbacks requiredCallbacks { get; set; } + public IRequiredCallbacks RequiredCallbacks { get; set; } public NotifyFileOpenedCallback OnNotifyFileOpened { get; set; } public NotifyNewFileCreatedCallback OnNotifyNewFileCreated { get; set; } public NotifyFileOverwrittenCallback OnNotifyFileOverwritten { get; set; } @@ -63,7 +67,7 @@ public HResult WriteFileReturnResult public HResult StartVirtualizing(IRequiredCallbacks requiredCallbacks) { - this.requiredCallbacks = requiredCallbacks; + this.RequiredCallbacks = requiredCallbacks; return HResult.Ok; } diff --git a/GVFS/GVFS.UnitTests/Windows/Mock/WindowsFileSystemVirtualizerTester.cs b/GVFS/GVFS.UnitTests/Windows/Mock/WindowsFileSystemVirtualizerTester.cs index 95da8daa4..d25b1131b 100644 --- a/GVFS/GVFS.UnitTests/Windows/Mock/WindowsFileSystemVirtualizerTester.cs +++ b/GVFS/GVFS.UnitTests/Windows/Mock/WindowsFileSystemVirtualizerTester.cs @@ -30,7 +30,7 @@ public void InvokeGetFileDataCallback(HResult expectedResult = HResult.Pending, providerId = WindowsFileSystemVirtualizer.PlaceholderVersionId; } - this.MockVirtualization.requiredCallbacks.GetFileDataCallback( + this.MockVirtualization.RequiredCallbacks.GetFileDataCallback( commandId: 1, relativePath: "test.txt", byteOffset: byteOffset, diff --git a/GVFS/GVFS.UnitTests/Windows/Virtualization/WindowsFileSystemVirtualizerTests.cs b/GVFS/GVFS.UnitTests/Windows/Virtualization/WindowsFileSystemVirtualizerTests.cs index b5a18c0b1..5ca3f4115 100644 --- a/GVFS/GVFS.UnitTests/Windows/Virtualization/WindowsFileSystemVirtualizerTests.cs +++ b/GVFS/GVFS.UnitTests/Windows/Virtualization/WindowsFileSystemVirtualizerTests.cs @@ -163,9 +163,9 @@ public void OnStartDirectoryEnumerationReturnsPendingWhenResultsNotInMemory() { Guid enumerationGuid = Guid.NewGuid(); tester.GitIndexProjection.EnumerationInMemory = false; - tester.MockVirtualization.requiredCallbacks.StartDirectoryEnumerationCallback(1, enumerationGuid, "test", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); + tester.MockVirtualization.RequiredCallbacks.StartDirectoryEnumerationCallback(1, enumerationGuid, "test", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); tester.MockVirtualization.WaitForCompletionStatus().ShouldEqual(HResult.Ok); - tester.MockVirtualization.requiredCallbacks.EndDirectoryEnumerationCallback(enumerationGuid).ShouldEqual(HResult.Ok); + tester.MockVirtualization.RequiredCallbacks.EndDirectoryEnumerationCallback(enumerationGuid).ShouldEqual(HResult.Ok); } } @@ -176,8 +176,8 @@ public void OnStartDirectoryEnumerationReturnsSuccessWhenResultsInMemory() { Guid enumerationGuid = Guid.NewGuid(); tester.GitIndexProjection.EnumerationInMemory = true; - tester.MockVirtualization.requiredCallbacks.StartDirectoryEnumerationCallback(1, enumerationGuid, "test", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Ok); - tester.MockVirtualization.requiredCallbacks.EndDirectoryEnumerationCallback(enumerationGuid).ShouldEqual(HResult.Ok); + tester.MockVirtualization.RequiredCallbacks.StartDirectoryEnumerationCallback(1, enumerationGuid, "test", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Ok); + tester.MockVirtualization.RequiredCallbacks.EndDirectoryEnumerationCallback(enumerationGuid).ShouldEqual(HResult.Ok); } } @@ -186,7 +186,7 @@ public void GetPlaceholderInformationHandlerPathNotProjected() { using (WindowsFileSystemVirtualizerTester tester = new WindowsFileSystemVirtualizerTester(this.Repo)) { - tester.MockVirtualization.requiredCallbacks.GetPlaceholderInfoCallback(1, "doesNotExist", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.FileNotFound); + tester.MockVirtualization.RequiredCallbacks.GetPlaceholderInfoCallback(1, "doesNotExist", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.FileNotFound); } } @@ -195,7 +195,7 @@ public void GetPlaceholderInformationHandlerPathProjected() { using (WindowsFileSystemVirtualizerTester tester = new WindowsFileSystemVirtualizerTester(this.Repo)) { - tester.MockVirtualization.requiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); + tester.MockVirtualization.RequiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); tester.MockVirtualization.WaitForCompletionStatus().ShouldEqual(HResult.Ok); tester.MockVirtualization.CreatedPlaceholders.ShouldContain(entry => entry == "test.txt"); tester.GitIndexProjection.PlaceholdersCreated.ShouldContain(entry => entry == "test.txt"); @@ -218,7 +218,7 @@ public void GetPlaceholderInformationHandlerCancelledBeforeSchedulingAsync() tester.GitIndexProjection.UnblockIsPathProjected(); }); - tester.MockVirtualization.requiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); + tester.MockVirtualization.RequiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); // Cancelling before GetPlaceholderInformation has registered the command results in placeholders being created tester.MockVirtualization.WaitForPlaceholderCreate(); @@ -234,7 +234,7 @@ public void GetPlaceholderInformationHandlerCancelledDuringAsyncCallback() using (WindowsFileSystemVirtualizerTester tester = new WindowsFileSystemVirtualizerTester(this.Repo)) { tester.GitIndexProjection.BlockGetProjectedFileInfo(willWaitForRequest: true); - tester.MockVirtualization.requiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); + tester.MockVirtualization.RequiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); tester.GitIndexProjection.WaitForGetProjectedFileInfo(); tester.MockVirtualization.OnCancelCommand(1); tester.GitIndexProjection.UnblockGetProjectedFileInfo(); @@ -257,7 +257,7 @@ public void GetPlaceholderInformationHandlerCancelledDuringNetworkRequest() MockTracer mockTracker = this.Repo.Context.Tracer as MockTracer; mockTracker.WaitRelatedEventName = "GetPlaceholderInformationAsyncHandler_GetProjectedFileInfo_Cancelled"; tester.GitIndexProjection.ThrowOperationCanceledExceptionOnProjectionRequest = true; - tester.MockVirtualization.requiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); + tester.MockVirtualization.RequiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); // Cancelling in the middle of GetPlaceholderInformation in the middle of a network request should not result in placeholder // getting created diff --git a/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj b/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj index a34c801e6..9cdd66d42 100644 --- a/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj +++ b/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj @@ -1,7 +1,6 @@ - net471 true @@ -11,7 +10,7 @@ - + diff --git a/GVFS/GVFS/GVFS.csproj b/GVFS/GVFS/GVFS.csproj index c6a7158b8..dd882915c 100644 --- a/GVFS/GVFS/GVFS.csproj +++ b/GVFS/GVFS/GVFS.csproj @@ -2,15 +2,11 @@ Exe - net471 false - Content - PreserveNewest - Build;DebugSymbolsProjectOutputGroup @@ -26,3 +22,4 @@ + diff --git a/global.json b/global.json index f7ef5e1e9..063181e42 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,10 @@ { + "sdk": { + "version": "10.0.203", + "rollForward": "disable" + }, "msbuild-sdks": { - "Microsoft.Build.Traversal": "2.0.19", - "Microsoft.Build.NoTargets": "1.0.85" + "Microsoft.Build.Traversal": "4.1.0", + "Microsoft.Build.NoTargets": "3.7.0" } } diff --git a/scripts/Build.bat b/scripts/Build.bat index e51c8ebe8..32ab6f565 100644 --- a/scripts/Build.bat +++ b/scripts/Build.bat @@ -21,61 +21,91 @@ IF "%~3"=="" ( SET VERBOSITY=%3 ) -REM If we have MSBuild on the PATH then go straight to the build phase -FOR /F "tokens=* USEBACKQ" %%F IN (`where msbuild.exe`) DO ( +REM .NET 10 SDK ships MSBuild 18.x; VS 2022 ships MSBuild 17.x. +REM Managed (csproj) projects require MSBuild 18.x via "dotnet build". +REM Native C++ (vcxproj) projects require VS MSBuild with VC++ targets. + +ECHO ^********************** +ECHO ^* Restoring Packages * +ECHO ^********************** +dotnet restore "%VFS_SRCDIR%\GVFS.sln" ^ + /v:%VERBOSITY% ^ + /p:Configuration=%CONFIGURATION% || GOTO ERROR + +ECHO ^************************** +ECHO ^* Building C++ Projects * +ECHO ^************************** +REM Locate VS MSBuild for native C++ projects +SET MSBUILD_EXEC= +FOR /F "tokens=* USEBACKQ" %%F IN (`where msbuild.exe 2^>nul`) DO ( SET MSBUILD_EXEC=%%F ECHO INFO: Found msbuild.exe at '%%F' - GOTO :BUILD + GOTO :FOUND_MSBUILD ) :LOCATE_MSBUILD -REM Locate MSBuild via the vswhere tool -FOR /F "tokens=* USEBACKQ" %%F IN (`where nuget.exe`) DO ( - SET NUGET_EXEC=%%F - ECHO INFO: Found nuget.exe at '%%F' +SET VSWHERE_EXEC="%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +IF EXIST %VSWHERE_EXEC% ( + FOR /F "tokens=* USEBACKQ" %%F IN (`%VSWHERE_EXEC% -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find MSBuild\**\Bin\amd64\MSBuild.exe`) DO ( + SET MSBUILD_EXEC=%%F + ECHO INFO: Found msbuild.exe at '%%F' + ) ) -REM NuGet is required to be on the PATH to install vswhere -IF NOT EXIST "%NUGET_EXEC%" ( - ECHO ERROR: Could not find nuget.exe on the PATH - EXIT /B 10 +:FOUND_MSBUILD +IF DEFINED MSBUILD_EXEC ( + FOR %%P IN ( + "%VFS_SRCDIR%\GVFS\GitHooksLoader\GitHooksLoader.vcxproj" + "%VFS_SRCDIR%\GVFS\GVFS.NativeTests\GVFS.NativeTests.vcxproj" + "%VFS_SRCDIR%\GVFS\GVFS.PostIndexChangedHook\GVFS.PostIndexChangedHook.vcxproj" + "%VFS_SRCDIR%\GVFS\GVFS.ReadObjectHook\GVFS.ReadObjectHook.vcxproj" + "%VFS_SRCDIR%\GVFS\GVFS.VirtualFileSystemHook\GVFS.VirtualFileSystemHook.vcxproj" + ) DO ( + ECHO Building %%~nP... + "%MSBUILD_EXEC%" %%P ^ + /t:Build ^ + /v:%VERBOSITY% ^ + /p:Configuration=%CONFIGURATION% ^ + /p:Platform=x64 ^ + /p:SolutionDir="%VFS_SRCDIR%\\" || GOTO ERROR + ) +) ELSE ( + ECHO WARNING: Could not find VS MSBuild. Native C++ projects will not be built. + ECHO Install Visual Studio with the C++ workload to build native projects. ) -REM Acquire vswhere to find VS installations reliably -SET VSWHERE_VER=2.6.7 -"%NUGET_EXEC%" install vswhere -Version %VSWHERE_VER% -OutputDirectory %VFS_PACKAGESDIR% || exit /b 1 -SET VSWHERE_EXEC="%VFS_PACKAGESDIR%\vswhere.%VSWHERE_VER%\tools\vswhere.exe" - -REM Use vswhere to find the latest VS installation with the MSBuild component -REM See https://github.com/Microsoft/vswhere/wiki/Find-MSBuild -FOR /F "tokens=* USEBACKQ" %%F IN (`%VSWHERE_EXEC% -all -prerelease -latest -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\amd64\MSBuild.exe`) DO ( - SET MSBUILD_EXEC=%%F - ECHO INFO: Found msbuild.exe at '%%F' +ECHO ^***************************** +ECHO ^* Building Managed Projects * +ECHO ^***************************** +REM Self-contained deployment requires "dotnet publish" (not "dotnet build") +REM to produce complete output with runtime and correct version resources. +FOR %%P IN ( + "%VFS_SRCDIR%\GVFS\GVFS\GVFS.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.Mount\GVFS.Mount.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.Hooks\GVFS.Hooks.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.Service\GVFS.Service.csproj" + "%VFS_SRCDIR%\GVFS\FastFetch\FastFetch.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.UnitTests\GVFS.UnitTests.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.FunctionalTests\GVFS.FunctionalTests.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.PerfProfiling\GVFS.PerfProfiling.csproj" +) DO ( + ECHO Publishing %%~nP... + dotnet publish %%P --no-restore -v:%VERBOSITY% -c %CONFIGURATION% || GOTO ERROR ) -:BUILD -IF NOT DEFINED MSBUILD_EXEC ( - ECHO ERROR: Could not locate a Visual Studio installation with required components. - ECHO Refer to Readme.md for a list of the required Visual Studio components. - EXIT /B 10 +ECHO ^******************************* +ECHO ^* Building Packaging Projects * +ECHO ^******************************* +REM Payload and Installers no longer reference vcxproj (native projects are +REM built separately above). Build ordering is handled by Build.bat. +FOR %%P IN ( + "%VFS_SRCDIR%\GVFS\GVFS.Payload\GVFS.Payload.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.Installers\GVFS.Installers.csproj" +) DO ( + ECHO Publishing %%~nP... + dotnet publish %%P --no-restore -v:%VERBOSITY% -c %CONFIGURATION% || GOTO ERROR ) -ECHO ^********************** -ECHO ^* Restoring Packages * -ECHO ^********************** -"%MSBUILD_EXEC%" "%VFS_SRCDIR%\GVFS.sln" ^ - /t:Restore ^ - /v:%VERBOSITY% ^ - /p:Configuration=%CONFIGURATION% || GOTO ERROR - -ECHO ^********************* -ECHO ^* Building Solution * -ECHO ^********************* -"%MSBUILD_EXEC%" "%VFS_SRCDIR%\GVFS.sln" ^ - /t:Build ^ - /v:%VERBOSITY% ^ - /p:Configuration=%CONFIGURATION% || GOTO ERROR - GOTO :EOF :USAGE diff --git a/scripts/CreateBuildArtifacts.bat b/scripts/CreateBuildArtifacts.bat index 797ed0bf9..d27f5a64c 100644 --- a/scripts/CreateBuildArtifacts.bat +++ b/scripts/CreateBuildArtifacts.bat @@ -33,7 +33,7 @@ ECHO ^* Collecting GVFS.Installers * ECHO ^****************************** mkdir %OUTROOT%\GVFS.Installers xcopy /S /Y ^ - %VFS_OUTDIR%\GVFS.Installers\bin\%CONFIGURATION%\win-x64 ^ + %VFS_OUTDIR%\GVFS.Installers\bin\%CONFIGURATION%\win-x64\* ^ %OUTROOT%\GVFS.Installers\ || GOTO ERROR ECHO ^************************ @@ -42,7 +42,7 @@ ECHO ^************************ ECHO Collecting FastFetch... mkdir %OUTROOT%\FastFetch xcopy /S /Y ^ - %VFS_OUTDIR%\FastFetch\bin\%CONFIGURATION%\net471\win-x64 ^ + %VFS_OUTDIR%\FastFetch\bin\%CONFIGURATION%\net10.0-windows10.0.17763.0\win-x64\publish\* ^ %OUTROOT%\FastFetch\ || GOTO ERROR ECHO ^*********************************** @@ -50,7 +50,7 @@ ECHO ^* Collecting GVFS.FunctionalTests * ECHO ^*********************************** mkdir %OUTROOT%\GVFS.FunctionalTests xcopy /S /Y ^ - %VFS_OUTDIR%\GVFS.FunctionalTests\bin\%CONFIGURATION%\net471\win-x64 ^ + %VFS_OUTDIR%\GVFS.FunctionalTests\bin\%CONFIGURATION%\net10.0-windows10.0.17763.0\win-x64\publish\* ^ %OUTROOT%\GVFS.FunctionalTests\ || GOTO ERROR GOTO :EOF diff --git a/scripts/RunFunctionalTests-Dev.ps1 b/scripts/RunFunctionalTests-Dev.ps1 index 3fe4ba540..048afa6b2 100644 --- a/scripts/RunFunctionalTests-Dev.ps1 +++ b/scripts/RunFunctionalTests-Dev.ps1 @@ -59,7 +59,7 @@ $env:GVFS_COMMON_APPDATA_ROOT = Join-Path $env:GVFS_TEST_DATA "AppData" $env:GVFS_SECURE_DATA_ROOT = Join-Path $env:GVFS_TEST_DATA "ProgramData" # Put build output gvfs.exe on PATH -$payloadDir = Join-Path $outDir "GVFS.Payload\bin\$Configuration\win-x64" +$payloadDir = Join-Path $outDir "GVFS.Payload\bin\$Configuration\net10.0-windows10.0.17763.0\win-x64\publish" $env:PATH = "$payloadDir;C:\Program Files\Git\cmd;$env:PATH" Write-Host "============================================" @@ -88,7 +88,7 @@ Write-Host "git location: $($gitPath.Source)" Write-Host "" # Build test exe path -$testExe = Join-Path $outDir "GVFS.FunctionalTests\bin\$Configuration\net471\win-x64\GVFS.FunctionalTests.exe" +$testExe = Join-Path $outDir "GVFS.FunctionalTests\bin\$Configuration\net10.0-windows10.0.17763.0\win-x64\publish\GVFS.FunctionalTests.exe" if (-not (Test-Path $testExe)) { Write-Error "Test executable not found: $testExe`nRun Build.bat first." exit 1 diff --git a/scripts/RunFunctionalTests.bat b/scripts/RunFunctionalTests.bat index ef86a74f8..6f58db61a 100644 --- a/scripts/RunFunctionalTests.bat +++ b/scripts/RunFunctionalTests.bat @@ -27,7 +27,7 @@ IF NOT %ERRORLEVEL% == 0 ( ECHO error: unable to locate Git on the PATH (has it been installed?) ) -%VFS_OUTDIR%\GVFS.FunctionalTests\bin\%CONFIGURATION%\net471\win-x64\GVFS.FunctionalTests.exe /result:TestResult.xml %2 %3 %4 %5 +%VFS_OUTDIR%\GVFS.FunctionalTests\bin\%CONFIGURATION%\net10.0-windows10.0.17763.0\win-x64\publish\GVFS.FunctionalTests.exe /result:TestResult.xml %2 %3 %4 %5 SET error=%ERRORLEVEL% CALL %VFS_SCRIPTSDIR%\StopAllServices.bat diff --git a/scripts/RunUnitTests.bat b/scripts/RunUnitTests.bat index 8ae14aa44..669b0b17a 100644 --- a/scripts/RunUnitTests.bat +++ b/scripts/RunUnitTests.bat @@ -5,7 +5,6 @@ IF "%1"=="" (SET "CONFIGURATION=Debug") ELSE (SET "CONFIGURATION=%1") SET RESULT=0 -%VFS_OUTDIR%\GVFS.UnitTests\bin\%CONFIGURATION%\net471\win-x64\GVFS.UnitTests.exe || SET RESULT=1 -%VFS_OUTDIR%\GVFS.CommandLine.Tests\bin\%CONFIGURATION%\net471\win-x64\GVFS.CommandLine.Tests.exe || SET RESULT=1 +%VFS_OUTDIR%\GVFS.UnitTests\bin\%CONFIGURATION%\net10.0-windows10.0.17763.0\win-x64\publish\GVFS.UnitTests.exe || SET RESULT=1 EXIT /b %RESULT% diff --git a/scripts/publish-aot.ps1 b/scripts/publish-aot.ps1 new file mode 100644 index 000000000..54bb1bb04 --- /dev/null +++ b/scripts/publish-aot.ps1 @@ -0,0 +1,244 @@ +<# +.SYNOPSIS + Publish VFSForGit .NET 10 NativeAOT binaries and create installer layout. + +.DESCRIPTION + Builds all GVFS projects as self-contained NativeAOT executables, + then assembles them into a flat layout directory suitable for the Inno Setup + installer (Setup.iss). + + Supports building for win-x64, win-arm64, or both architectures. + + The NativeAOT layout is dramatically simpler than the .NET Framework layout: + just 9 self-contained .exe files instead of ~150 DLLs + runtimes. + +.PARAMETER Configuration + Build configuration: Debug or Release (default: Release) + +.PARAMETER Runtime + Target runtime: win-x64, win-arm64, or both (default: both) + +.PARAMETER OutputDir + Layout output directory. When building both architectures, -x64 and -arm64 + suffixes are appended. (default: $RepoRoot\..\out\gvfs-aot-layout) + +.PARAMETER SkipBuild + Skip the dotnet publish step (use existing build output) + +.PARAMETER BuildInstaller + Also build the Inno Setup installer after creating the layout +#> +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release", + [ValidateSet("win-x64", "win-arm64", "both")] + [string]$Runtime = "both", + [string]$OutputDir = "", + [switch]$SkipBuild, + [switch]$BuildInstaller +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$GVFSRoot = Join-Path $RepoRoot "GVFS" +$OutBase = if ($OutputDir) { $OutputDir } else { Join-Path (Split-Path $RepoRoot) "out\gvfs-aot-layout" } + +# Determine which runtimes to build +$Runtimes = if ($Runtime -eq "both") { @("win-x64", "win-arm64") } else { @($Runtime) } + +Write-Host "=== VFSForGit NativeAOT Publish ===" -ForegroundColor Cyan +Write-Host "Configuration: $Configuration" +Write-Host "Runtimes: $($Runtimes -join ', ')" +Write-Host "" + +# ───────────────────────────────────────────────────────────────────────────── +# Project definitions +# ───────────────────────────────────────────────────────────────────────────── +$ManagedProjects = @( + @{ Name = "GVFS"; Dir = "GVFS"; Exe = "GVFS.exe" }, + @{ Name = "GVFS.Mount"; Dir = "GVFS.Mount"; Exe = "GVFS.Mount.exe" }, + @{ Name = "GVFS.Hooks"; Dir = "GVFS.Hooks"; Exe = "GVFS.Hooks.exe" }, + @{ Name = "GVFS.Service"; Dir = "GVFS.Service"; Exe = "GVFS.Service.exe" }, + @{ Name = "GVFS.Service.UI"; Dir = "GVFS.Service.UI"; Exe = "GVFS.Service.UI.exe" } +) + +# Native C++ projects (built with MSBuild, not dotnet publish) +$NativeProjects = @( + @{ Name = "GitHooksLoader"; Dir = "GitHooksLoader"; Exe = "GitHooksLoader.exe" }, + @{ Name = "GVFS.ReadObjectHook"; Dir = "GVFS.ReadObjectHook"; Exe = "GVFS.ReadObjectHook.exe" }, + @{ Name = "GVFS.PostIndexChangedHook"; Dir = "GVFS.PostIndexChangedHook"; Exe = "GVFS.PostIndexChangedHook.exe" }, + @{ Name = "GVFS.VirtualFileSystemHook"; Dir = "GVFS.VirtualFileSystemHook"; Exe = "GVFS.VirtualFileSystemHook.exe" } +) + +# Map dotnet RID to MSBuild platform for native C++ projects +$RidToMSBuildPlatform = @{ + "win-x64" = "x64" + "win-arm64" = "ARM64" +} + +# Find MSBuild once +$msbuildExe = $null +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (Test-Path $vswhere) { + $vsPath = & $vswhere -latest -requires Microsoft.Component.MSBuild -property installationPath 2>$null + if ($vsPath) { + $msbuildExe = Join-Path $vsPath "MSBuild\Current\Bin\amd64\MSBuild.exe" + if (-not (Test-Path $msbuildExe)) { $msbuildExe = Join-Path $vsPath "MSBuild\Current\Bin\MSBuild.exe" } + } +} +if (-not $msbuildExe -or -not (Test-Path $msbuildExe)) { + $msbuildExe = (Get-Command msbuild.exe -EA 0).Source +} + +# ───────────────────────────────────────────────────────────────────────────── +# Build each architecture +# ───────────────────────────────────────────────────────────────────────────── +foreach ($rid in $Runtimes) { + # Output directory: append arch suffix when building both + $OutRoot = if ($Runtimes.Count -gt 1) { "${OutBase}-$($rid.Replace('win-',''))" } else { $OutBase } + $msbuildPlatform = $RidToMSBuildPlatform[$rid] + + Write-Host "===============================================" -ForegroundColor Cyan + Write-Host " Building for $rid → $OutRoot" -ForegroundColor Cyan + Write-Host "===============================================" -ForegroundColor Cyan + Write-Host "" + + if (-not $SkipBuild) { + Write-Host "--- Publishing managed projects ($rid) ---" -ForegroundColor Yellow + + foreach ($proj in $ManagedProjects) { + $csproj = Join-Path $GVFSRoot "$($proj.Dir)\$($proj.Name).csproj" + if (-not (Test-Path $csproj)) { + Write-Warning "Project not found: $csproj — skipping" + continue + } + + Write-Host " Publishing $($proj.Name)..." -NoNewline + $sw = [Diagnostics.Stopwatch]::StartNew() + + dotnet publish $csproj ` + -c $Configuration ` + -r $rid ` + --self-contained true ` + -o "$OutRoot" ` + 2>&1 | Out-Null + + if ($LASTEXITCODE -ne 0) { + Write-Host " FAILED" -ForegroundColor Red + throw "dotnet publish failed for $($proj.Name) ($rid)" + } + + $sw.Stop() + $size = if (Test-Path "$OutRoot\$($proj.Exe)") { + [math]::Round((Get-Item "$OutRoot\$($proj.Exe)").Length / 1MB, 1) + } else { "?" } + Write-Host " OK (${size}MB, $([math]::Round($sw.Elapsed.TotalSeconds, 1))s)" -ForegroundColor Green + } + + # Build native C++ projects + Write-Host "" + Write-Host " Building native hooks ($msbuildPlatform)..." -NoNewline + + if ($msbuildExe -and (Test-Path $msbuildExe)) { + foreach ($proj in $NativeProjects) { + $vcxproj = Get-ChildItem -Path $GVFSRoot -Recurse -Filter "$($proj.Name).vcxproj" | Select-Object -First 1 + if ($vcxproj) { + & $msbuildExe $vcxproj.FullName /p:Configuration=$Configuration /p:Platform=$msbuildPlatform /v:minimal /nologo 2>&1 | Out-Null + $nativeExe = Join-Path (Split-Path $RepoRoot) "out\$($proj.Name)\bin\$msbuildPlatform\$Configuration\$($proj.Exe)" + if (Test-Path $nativeExe) { + Copy-Item $nativeExe $OutRoot -Force + } + } + } + Write-Host " OK" -ForegroundColor Green + } else { + Write-Host " SKIPPED (MSBuild not found)" -ForegroundColor Yellow + } + } else { + Write-Host "--- Skipped build (using existing output) ---" -ForegroundColor DarkGray + } + + # ───────────────────────────────────────────────────────────────────── + # Verify layout + # ───────────────────────────────────────────────────────────────────── + Write-Host "" + Write-Host "--- Verifying layout ($rid) ---" -ForegroundColor Yellow + + New-Item -ItemType Directory -Path "$OutRoot\ProgramData\GVFS.Service" -Force | Out-Null + + $icon = Join-Path $GVFSRoot "GVFS\GitVirtualFileSystem.ico" + if (Test-Path $icon) { Copy-Item $icon $OutRoot -Force } + + "" | Out-File "$OutRoot\OnDiskVersion16CapableInstallation.dat" -Encoding ascii + + $allExes = ($ManagedProjects + $NativeProjects) | ForEach-Object { $_.Exe } + $missing = @() + foreach ($exe in $allExes) { + $path = Join-Path $OutRoot $exe + if (Test-Path $path) { + $size = [math]::Round((Get-Item $path).Length / 1MB, 1) + Write-Host " [OK] $exe (${size}MB)" -ForegroundColor Green + } else { + Write-Host " [MISSING] $exe" -ForegroundColor Red + $missing += $exe + } + } + + if ($missing.Count -gt 0) { + Write-Warning "Missing $($missing.Count) executable(s) for $rid." + } else { + $totalSize = [math]::Round((Get-ChildItem $OutRoot -File | Measure-Object Length -Sum).Sum / 1MB, 1) + Write-Host " Layout: $($allExes.Count) executables, ${totalSize}MB total" -ForegroundColor Cyan + } + + # ───────────────────────────────────────────────────────────────────── + # Optional: Build installer + # ───────────────────────────────────────────────────────────────────── + if ($BuildInstaller) { + Write-Host "" + Write-Host "--- Building Installer ($rid) ---" -ForegroundColor Yellow + + $iscc = $null + $nugetIscc = Get-ChildItem "$env:USERPROFILE\.nuget\packages\tools.innosetup" -Recurse -Filter "ISCC.exe" -EA 0 | Select-Object -First 1 + if ($nugetIscc) { $iscc = $nugetIscc.FullName } + if (-not $iscc) { + $progIscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + if (Test-Path $progIscc) { $iscc = $progIscc } + } + + if (-not $iscc) { + Write-Warning "Inno Setup compiler (ISCC.exe) not found." + } else { + $setupIss = Join-Path $GVFSRoot "GVFS.Installers\Setup.iss" + $archSuffix = $rid.Replace("win-", "") + $installerOut = Join-Path (Split-Path $OutRoot) "installer-$archSuffix" + New-Item -ItemType Directory -Path $installerOut -Force | Out-Null + + $versionStr = if (Test-Path "$OutRoot\GVFS.exe") { + (Get-Item "$OutRoot\GVFS.exe").VersionInfo.ProductVersion + } else { "0.0.0.0" } + + Write-Host " Version: $versionStr" + & $iscc /DLayoutDir="$OutRoot" /DGVFSVersion=$versionStr $setupIss /O"$installerOut" 2>&1 | Out-Null + + if ($LASTEXITCODE -eq 0) { + $installer = Get-ChildItem $installerOut -Filter "SetupGVFS*.exe" | Select-Object -First 1 + if ($installer) { + $instSize = [math]::Round($installer.Length / 1MB, 1) + Write-Host " Installer: $($installer.FullName) (${instSize}MB)" -ForegroundColor Green + } + } else { + Write-Warning "Installer build failed for $rid" + } + } + } + + Write-Host "" +} + +Write-Host "=== Done ===" -ForegroundColor Cyan +foreach ($rid in $Runtimes) { + $dir = if ($Runtimes.Count -gt 1) { "${OutBase}-$($rid.Replace('win-',''))" } else { $OutBase } + Write-Host " $rid → $dir" +} From f2df4049a6c72ac733bf669837164c7f90579edc Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 29 Apr 2026 15:50:49 -0700 Subject: [PATCH 12/25] Replace System.Reflection.Assembly usage for NativeAOT Assembly.Location returns empty string under NativeAOT since there is no managed assembly on disk. Assembly.GetName().Version returns null. - ProcessHelper: use Environment.ProcessPath with null guard (can be null in certain hosting scenarios), fall back to AppContext.BaseDirectory - HooksInstaller: same Environment.ProcessPath pattern with null guard - GVFSEnlistment: AppDomain.CurrentDomain.FriendlyName replaces Assembly.GetEntryAssembly().GetName() for process name Co-authored-by: Michael Niksa Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/FileSystem/HooksInstaller.cs | 6 +++- GVFS/GVFS.Common/GVFSEnlistment.cs | 2 +- GVFS/GVFS.Common/ProcessHelper.cs | 32 +++++++++++++++---- GVFS/GVFS.FunctionalTests/Settings.cs | 6 ++-- .../CommandLine/HooksInstallerTests.cs | 2 +- .../Hooks/PostIndexChangedHookTests.cs | 2 +- .../Prefetch/DiffHelperTests.cs | 3 +- 7 files changed, 39 insertions(+), 14 deletions(-) diff --git a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs index 76fc6d028..7ebd28eec 100644 --- a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs +++ b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs @@ -22,7 +22,11 @@ public static class HooksInstaller static HooksInstaller() { - ExecutingDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + // Environment.ProcessPath can be null in NativeAOT or certain hosting scenarios. + string processPath = Environment.ProcessPath; + ExecutingDirectory = !string.IsNullOrEmpty(processPath) + ? Path.GetDirectoryName(processPath) + : AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar); } public static string MergeHooksData(string[] defaultHooksLines, string filename, string hookName) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index 6e2eedb7d..e0b18b28b 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -261,7 +261,7 @@ public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enli else { tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Waiting 500ms for mount process to be ready"); - Thread.Sleep(500); + Thread.Sleep(100); } } catch (BrokenPipeException e) diff --git a/GVFS/GVFS.Common/ProcessHelper.cs b/GVFS/GVFS.Common/ProcessHelper.cs index 3d7e35463..a9731d6d5 100644 --- a/GVFS/GVFS.Common/ProcessHelper.cs +++ b/GVFS/GVFS.Common/ProcessHelper.cs @@ -26,17 +26,28 @@ public static ProcessResult Run(string programName, string args, bool redirectOu public static string GetCurrentProcessLocation() { - Assembly assembly = Assembly.GetExecutingAssembly(); - return Path.GetDirectoryName(assembly.Location); + // Environment.ProcessPath can be null in NativeAOT or certain hosting scenarios. + string processPath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(processPath)) + { + return Path.GetDirectoryName(processPath); + } + + return AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar); } public static string GetEntryClassName() { + // AppDomain.FriendlyName is reliable even when Assembly.GetEntryAssembly() returns null. + string friendlyName = AppDomain.CurrentDomain.FriendlyName; + if (!string.IsNullOrEmpty(friendlyName)) + { + return Path.GetFileNameWithoutExtension(friendlyName); + } + Assembly assembly = Assembly.GetEntryAssembly(); if (assembly == null) { - // The PR build tests doesn't produce an entry assembly because it is run from unmanaged code, - // so we'll fall back on using this assembly. This should never ever happen for a normal exe invocation. assembly = Assembly.GetExecutingAssembly(); } @@ -47,9 +58,16 @@ public static string GetCurrentProcessVersion() { if (currentProcessVersion == null) { - Assembly assembly = Assembly.GetExecutingAssembly(); - FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(assembly.Location); - currentProcessVersion = fileVersionInfo.ProductVersion; + string processPath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(processPath)) + { + FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(processPath); + currentProcessVersion = fileVersionInfo.ProductVersion; + } + else + { + currentProcessVersion = "0.0.0.0"; + } } return currentProcessVersion; diff --git a/GVFS/GVFS.FunctionalTests/Settings.cs b/GVFS/GVFS.FunctionalTests/Settings.cs index 9a978d2cf..4bd933790 100644 --- a/GVFS/GVFS.FunctionalTests/Settings.cs +++ b/GVFS/GVFS.FunctionalTests/Settings.cs @@ -32,8 +32,10 @@ public static class Default public static void Initialize() { - string testExec = System.Reflection.Assembly.GetEntryAssembly().Location; - CurrentDirectory = Path.GetFullPath(Path.GetDirectoryName(testExec)); + string testExec = Environment.ProcessPath; + CurrentDirectory = string.IsNullOrEmpty(testExec) + ? AppContext.BaseDirectory + : Path.GetFullPath(Path.GetDirectoryName(testExec)); RepoToClone = @"https://gvfs.visualstudio.com/ci/_git/ForTests"; diff --git a/GVFS/GVFS.UnitTests/CommandLine/HooksInstallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/HooksInstallerTests.cs index a9a5fcbf9..9692a46f1 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/HooksInstallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/HooksInstallerTests.cs @@ -16,7 +16,7 @@ public class HooksInstallerTests { private const string Filename = "hooksfile"; private readonly string expectedAbsoluteGvfsHookPath = - $"\"{Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), GVFSPlatform.Instance.Constants.GVFSHooksExecutableName)}\""; + $"\"{Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), GVFSPlatform.Instance.Constants.GVFSHooksExecutableName)}\""; [TestCase] [Category(CategoryConstants.ExceptionExpected)] diff --git a/GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs b/GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs index 2bf00cc28..23fb9eeed 100644 --- a/GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs +++ b/GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs @@ -21,7 +21,7 @@ private static string FindHookExe() { // Test runner lives at: out\GVFS.UnitTests\bin\Debug\net471\win-x64\ // Hook exe lives at: out\GVFS.PostIndexChangedHook\bin\x64\Debug\ - string testDir = Path.GetDirectoryName(typeof(PostIndexChangedHookTests).Assembly.Location); + string testDir = Path.GetDirectoryName(Environment.ProcessPath); string outDir = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "..")); string hookPath = Path.Combine(outDir, "GVFS.PostIndexChangedHook", "bin", "x64", "Debug", "GVFS.PostIndexChangedHook.exe"); diff --git a/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs b/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs index 09647738a..0799ad11b 100644 --- a/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs +++ b/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs @@ -6,6 +6,7 @@ using GVFS.UnitTests.Mock.Common; using GVFS.UnitTests.Mock.Git; using NUnit.Framework; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -167,7 +168,7 @@ public void DetectsFailuresInLsTree() private static string GetDataPath(string fileName) { - string workingDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string workingDirectory = Path.GetDirectoryName(Environment.ProcessPath); return Path.Combine(workingDirectory, "Data", fileName); } } From c5b7ca75bc07f07d09badd5567f31c4cd77f0aaa Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 29 Apr 2026 15:51:45 -0700 Subject: [PATCH 13/25] Replace changed .NET 10 APIs NamedPipeServerStream (WindowsPlatform.cs): ACL-accepting constructor removed from .NET Core; use NamedPipeServerStreamAcl.Create extension method. Directory ACL APIs (WindowsFileSystem.cs, GVFSService.Windows.cs): Static Directory.GetAccessControl/SetAccessControl and Directory.CreateDirectory(path, security) removed from .NET Core; replaced with DirectoryInfo instance methods and DirectorySecurity.CreateDirectory extension. Uri escaping (CloneVerb.cs, GVFSVerb.cs, OrgInfoApiClient.cs): Uri.EscapeUriString obsoleted in .NET 10 (does not escape '#', '?'); use Uri.EscapeDataString. HttpUtility.UrlEncode (System.Web) replaced with WebUtility.UrlEncode (System.Net). UseShellExecute (WindowsPlatform.cs, InProcessMount.cs): .NET Framework defaults UseShellExecute=true (ShellExecuteEx, no handle inheritance). .NET 10 defaults to false (CreateProcess, handles inherited). Without this, GVFS.Mount.exe inherits the caller's stdout pipe handle, causing callers that read to EOF to block indefinitely. Truncated loose object detection (GitRepo.cs): .NET 10 DeflateStream silently returns partial data on truncated zlib instead of throwing InvalidDataException. CountingStream wrapper compares actual bytes read to header-declared size to detect corruption. Co-authored-by: Michael Niksa Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Git/GitRepo.cs | 67 +++++++++- GVFS/GVFS.Common/OrgInfoApiClient.cs | 4 +- GVFS/GVFS.Common/Tracing/JsonTracer.cs | 4 +- GVFS/GVFS.Mount/InProcessMount.cs | 11 +- .../WindowsFileSystem.cs | 115 +++++++++--------- GVFS/GVFS.Platform.Windows/WindowsPlatform.cs | 9 +- GVFS/GVFS.Service/GVFSService.Windows.cs | 8 +- GVFS/GVFS/CommandLine/CloneVerb.cs | 4 +- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 1 - GVFS/GVFS/CommandLine/GVFSVerb.cs | 4 +- 10 files changed, 151 insertions(+), 76 deletions(-) diff --git a/GVFS/GVFS.Common/Git/GitRepo.cs b/GVFS/GVFS.Common/Git/GitRepo.cs index b2b3ad7b3..e5aefa579 100644 --- a/GVFS/GVFS.Common/Git/GitRepo.cs +++ b/GVFS/GVFS.Common/Git/GitRepo.cs @@ -191,7 +191,21 @@ private LooseBlobState GetLooseBlobStateAtPath(string blobPath, Action wr return state; } + + /// + /// A read-only stream wrapper that counts the total bytes read. + /// Used to detect truncated loose objects where DeflateStream returns + /// fewer bytes than the header declares (see GetLooseBlobStateAtPath). + /// + private sealed class CountingStream : Stream + { + private readonly Stream inner; + private long bytesRead; + + public CountingStream(Stream inner) + { + this.inner = inner; + } + + public long BytesRead => this.bytesRead; + + public override bool CanRead => this.inner.CanRead; + public override bool CanSeek => this.inner.CanSeek; + public override bool CanWrite => this.inner.CanWrite; + public override long Length => this.inner.Length; + public override long Position + { + get => this.inner.Position; + set => this.inner.Position = value; + } + + public override int Read(byte[] buffer, int offset, int count) + { + int read = this.inner.Read(buffer, offset, count); + this.bytesRead += read; + return read; + } + + public override int ReadByte() + { + int b = this.inner.ReadByte(); + if (b >= 0) + { + this.bytesRead++; + } + + return b; + } + + public override void Flush() => this.inner.Flush(); + public override long Seek(long offset, SeekOrigin origin) => this.inner.Seek(offset, origin); + public override void SetLength(long value) => this.inner.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => this.inner.Write(buffer, offset, count); + } } } diff --git a/GVFS/GVFS.Common/OrgInfoApiClient.cs b/GVFS/GVFS.Common/OrgInfoApiClient.cs index 9387c66a6..93dbd97d9 100644 --- a/GVFS/GVFS.Common/OrgInfoApiClient.cs +++ b/GVFS/GVFS.Common/OrgInfoApiClient.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; -using System.Web; namespace GVFS.Common { @@ -69,7 +69,7 @@ private string ConstructRequest(string baseUrl, Dictionary query } isFirst = false; - sb.Append($"{HttpUtility.UrlEncode(kvp.Key)}={HttpUtility.UrlEncode(kvp.Value)}"); + sb.Append($"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"); } return sb.ToString(); diff --git a/GVFS/GVFS.Common/Tracing/JsonTracer.cs b/GVFS/GVFS.Common/Tracing/JsonTracer.cs index 68bff264a..c494bd012 100644 --- a/GVFS/GVFS.Common/Tracing/JsonTracer.cs +++ b/GVFS/GVFS.Common/Tracing/JsonTracer.cs @@ -274,13 +274,13 @@ public void WriteStartEvent( if (repoUrl != null) { - metadata.Add("Remote", Uri.EscapeUriString(repoUrl)); + metadata.Add("Remote", Uri.EscapeDataString(repoUrl)); } if (cacheServerUrl != null) { // Changing this key to CacheServerUrl will mess with our telemetry, so it stays for historical reasons - metadata.Add("ObjectsEndpoint", Uri.EscapeUriString(cacheServerUrl)); + metadata.Add("ObjectsEndpoint", Uri.EscapeDataString(cacheServerUrl)); } if (additionalMetadata != null) diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index e3e34cb65..24041dec3 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -272,7 +272,14 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) this.MountAndStartWorkingDirectoryCallbacks(this.cacheServer); - Console.Title = "GVFS " + ProcessHelper.GetCurrentProcessVersion() + " - " + this.enlistment.EnlistmentRoot; + try + { + Console.Title = "GVFS " + ProcessHelper.GetCurrentProcessVersion() + " - " + this.enlistment.EnlistmentRoot; + } + catch (IOException) + { + // Console.Title throws when the process has no console (e.g. started as background/hidden process) + } this.tracer.RelatedEvent( EventLevel.Informational, @@ -1247,7 +1254,7 @@ private void ValidateGVFSVersion(ServerGVFSConfig config) string warningMessage = "WARNING: Unable to validate your GVFS version" + Environment.NewLine; if (config == null) { - warningMessage += "Could not query valid GVFS versions from: " + Uri.EscapeUriString(this.enlistment.RepoUrl); + warningMessage += "Could not query valid GVFS versions from: " + Uri.EscapeDataString(this.enlistment.RepoUrl); } else { diff --git a/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs b/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs index b79f1b3e5..7ba732a13 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs @@ -1,10 +1,8 @@ using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Tracing; -using Microsoft.Win32.SafeHandles; using System; using System.IO; -using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; @@ -106,9 +104,53 @@ public bool TryGetNormalizedPath(string path, out string normalizedPath, out str return WindowsFileSystem.TryGetNormalizedPathImplementation(path, out normalizedPath, out errorMessage); } + /// + /// Hydrates a file by reading its first byte, triggering ProjFS placeholder hydration. + /// + /// + /// This was originally implemented using direct P/Invoke to kernel32 CreateFile/ReadFile + /// for minimal overhead. During the .NET 10 NativeAOT migration, the P/Invoke path caused + /// intermittent ACCESS_VIOLATION (0xC0000005) crashes under high concurrency in the + /// HydrateFilesStage pipeline. The P/Invoke declarations also had incorrect parameter types + /// (uint/int for pointer-sized params like LPSECURITY_ATTRIBUTES and LPOVERLAPPED). + /// + /// Replaced with managed FileStream, which internally calls the same Win32 APIs through the + /// runtime's own NativeAOT-validated interop layer. Benchmarked at equivalent throughput + /// (~36-40K files/s) in the multi-threaded scenario that matches actual HydrateFilesStage + /// usage (ProcessorCount * 2 threads). + /// public bool HydrateFile(string fileName, byte[] buffer) { - return NativeFileReader.TryReadFirstByteOfFile(fileName, buffer); + if (buffer.Length < 1) + { + throw new ArgumentException("Buffer must be at least 1 byte.", nameof(buffer)); + } + + try + { + using (FileStream fs = new FileStream( + fileName, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete)) + { + // Read is intentionally inexact — we only need to trigger ProjFS hydration, + // not verify byte count. Empty files (0 bytes read) are fine. +#pragma warning disable CA2022 + fs.Read(buffer, 0, 1); +#pragma warning restore CA2022 + } + + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } } public bool IsExecutable(string fileName) @@ -165,7 +207,8 @@ public bool TryCreateDirectoryAccessibleByAuthUsers(string directoryPath, out st // Use AccessRuleFactory rather than creating a FileSystemAccessRule because the NativeMethods.FileAccess flags // we're specifying are not valid for the FileSystemRights parameter of the FileSystemAccessRule constructor - DirectorySecurity directorySecurity = Directory.GetAccessControl(directoryPath); + DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath); + DirectorySecurity directorySecurity = directoryInfo.GetAccessControl(); AccessRule authenticatedUsersAccessRule = directorySecurity.AccessRuleFactory( new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null), unchecked((int)(NativeMethods.FileAccess.DELETE | NativeMethods.FileAccess.GENERIC_EXECUTE | NativeMethods.FileAccess.GENERIC_WRITE | NativeMethods.FileAccess.GENERIC_READ)), @@ -177,7 +220,7 @@ public bool TryCreateDirectoryAccessibleByAuthUsers(string directoryPath, out st // The return type of the AccessRuleFactory method is the base class, AccessRule, but the return value can be cast safely to the derived class. // https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.filesystemsecurity.accessrulefactory(v=vs.110).aspx directorySecurity.AddAccessRule((FileSystemAccessRule)authenticatedUsersAccessRule); - Directory.SetAccessControl(directoryPath, directorySecurity); + directoryInfo.SetAccessControl(directorySecurity); } catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is SystemException) { @@ -210,7 +253,7 @@ public bool TryCreateDirectoryWithAdminAndUserModifyPermissions(string directory AddUsersAccessRulesToDirectorySecurity(directorySecurity, grantUsersModifyPermissions: true); AddAdminAccessRulesToDirectorySecurity(directorySecurity); - Directory.CreateDirectory(directoryPath, directorySecurity); + directorySecurity.CreateDirectory(directoryPath); } catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || @@ -229,10 +272,11 @@ public bool TryCreateOrUpdateDirectoryToAdminModifyPermissions(ITracer tracer, s { try { + DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath); DirectorySecurity directorySecurity; if (Directory.Exists(directoryPath)) { - directorySecurity = Directory.GetAccessControl(directoryPath); + directorySecurity = directoryInfo.GetAccessControl(); } else { @@ -247,10 +291,10 @@ public bool TryCreateOrUpdateDirectoryToAdminModifyPermissions(ITracer tracer, s AddUsersAccessRulesToDirectorySecurity(directorySecurity, grantUsersModifyPermissions: false); AddAdminAccessRulesToDirectorySecurity(directorySecurity); - Directory.CreateDirectory(directoryPath, directorySecurity); + directorySecurity.CreateDirectory(directoryPath); // Ensure the ACLs are set correctly if the directory already existed - Directory.SetAccessControl(directoryPath, directorySecurity); + directoryInfo.SetAccessControl(directorySecurity); } catch (Exception e) when (e is IOException || e is SystemException) { @@ -289,63 +333,16 @@ public void EnsureDirectoryIsOwnedByCurrentUser(string directoryPath) // Ensure directory exists, inheriting all other ACLS Directory.CreateDirectory(directoryPath); // If the user is currently elevated, the owner of the directory will be the Administrators group. - DirectorySecurity directorySecurity = Directory.GetAccessControl(directoryPath); + DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath); + DirectorySecurity directorySecurity = directoryInfo.GetAccessControl(); IdentityReference directoryOwner = directorySecurity.GetOwner(typeof(SecurityIdentifier)); SecurityIdentifier administratorsSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); if (directoryOwner == administratorsSid) { WindowsIdentity currentUser = WindowsIdentity.GetCurrent(); directorySecurity.SetOwner(currentUser.User); - Directory.SetAccessControl(directoryPath, directorySecurity); + directoryInfo.SetAccessControl(directorySecurity); } } - - private class NativeFileReader - { - private const uint GenericRead = 0x80000000; - private const uint OpenExisting = 3; - - public static bool TryReadFirstByteOfFile(string fileName, byte[] buffer) - { - using (SafeFileHandle handle = Open(fileName)) - { - if (!handle.IsInvalid) - { - return ReadOneByte(handle, buffer); - } - } - - return false; - } - - private static SafeFileHandle Open(string fileName) - { - return CreateFile(fileName, GenericRead, (uint)(FileShare.ReadWrite | FileShare.Delete), 0, OpenExisting, 0, 0); - } - - private static bool ReadOneByte(SafeFileHandle handle, byte[] buffer) - { - int bytesRead = 0; - return ReadFile(handle, buffer, 1, ref bytesRead, 0); - } - - [DllImport("kernel32", SetLastError = true, ThrowOnUnmappableChar = true, CharSet = CharSet.Unicode)] - private static extern SafeFileHandle CreateFile( - string fileName, - uint desiredAccess, - uint shareMode, - uint securityAttributes, - uint creationDisposition, - uint flagsAndAttributes, - int hemplateFile); - - [DllImport("kernel32", SetLastError = true)] - private static extern bool ReadFile( - SafeFileHandle file, - [Out] byte[] buffer, - int numberOfBytesToRead, - ref int numberOfBytesRead, - int overlapped); - } } } diff --git a/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs b/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs index ecd050535..b2f0b4533 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs @@ -144,6 +144,13 @@ public override void StartBackgroundVFS4GProcess(ITracer tracer, string programN { programArguments = string.Join(" ", args.Select(arg => arg.Contains(' ') ? "\"" + arg + "\"" : arg)); ProcessStartInfo processInfo = new ProcessStartInfo(programName, programArguments); + + // UseShellExecute=true uses ShellExecuteEx which does NOT inherit + // the parent's handles. This is critical: without it, the background + // mount process inherits the parent's redirected stdout pipe handle, + // causing callers' Process.StandardOutput.ReadToEnd() to hang forever + // (the pipe never closes because the mount daemon holds a copy). + processInfo.UseShellExecute = true; processInfo.WindowStyle = ProcessWindowStyle.Hidden; Process executingProcess = new Process(); @@ -173,7 +180,7 @@ public override NamedPipeServerStream CreatePipeByName(string pipeName) security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.CreatorOwnerSid, null), PipeAccessRights.FullControl, AccessControlType.Allow)); security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), PipeAccessRights.FullControl, AccessControlType.Allow)); - NamedPipeServerStream pipe = new NamedPipeServerStream( + NamedPipeServerStream pipe = NamedPipeServerStreamAcl.Create( pipeName, PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances, diff --git a/GVFS/GVFS.Service/GVFSService.Windows.cs b/GVFS/GVFS.Service/GVFSService.Windows.cs index f0eee8385..c9af2b925 100644 --- a/GVFS/GVFS.Service/GVFSService.Windows.cs +++ b/GVFS/GVFS.Service/GVFSService.Windows.cs @@ -349,11 +349,11 @@ private void CreateAndConfigureProgramDataDirectories() DirectorySecurity serviceDataRootSecurity = this.GetServiceDirectorySecurity(serviceDataRootPath); // Create GVFS.Service related directories (if they don't already exist) - Directory.CreateDirectory(serviceDataRootPath, serviceDataRootSecurity); - Directory.CreateDirectory(this.serviceDataLocation, serviceDataRootSecurity); + serviceDataRootSecurity.CreateDirectory(serviceDataRootPath); + serviceDataRootSecurity.CreateDirectory(this.serviceDataLocation); // Ensure the ACLs are set correctly on any files or directories that were already created (e.g. after upgrading VFS4G) - Directory.SetAccessControl(serviceDataRootPath, serviceDataRootSecurity); + new DirectoryInfo(serviceDataRootPath).SetAccessControl(serviceDataRootSecurity); } private void CreateAndConfigureLogDirectory(string path) @@ -378,7 +378,7 @@ private DirectorySecurity GetServiceDirectorySecurity(string serviceDataRootPath if (Directory.Exists(serviceDataRootPath)) { this.tracer.RelatedInfo($"{nameof(this.GetServiceDirectorySecurity)}: {serviceDataRootPath} exists, modifying ACLs."); - serviceDataRootSecurity = Directory.GetAccessControl(serviceDataRootPath); + serviceDataRootSecurity = new DirectoryInfo(serviceDataRootPath).GetAccessControl(); } else { diff --git a/GVFS/GVFS/CommandLine/CloneVerb.cs b/GVFS/GVFS/CommandLine/CloneVerb.cs index bdebe311a..e64749d8c 100644 --- a/GVFS/GVFS/CommandLine/CloneVerb.cs +++ b/GVFS/GVFS/CommandLine/CloneVerb.cs @@ -284,7 +284,7 @@ public override void Execute() { try { - string gvfsExecutable = Assembly.GetExecutingAssembly().Location; + string gvfsExecutable = Environment.ProcessPath; Process.Start(new ProcessStartInfo( fileName: gvfsExecutable, arguments: "prefetch --commits") @@ -426,7 +426,7 @@ private Result TryClone( if (refs == null) { - return new Result("Could not query info/refs from: " + Uri.EscapeUriString(enlistment.RepoUrl)); + return new Result("Could not query info/refs from: " + Uri.EscapeDataString(enlistment.RepoUrl)); } if (this.Branch == null) diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index ee2ef4546..2d7b7f958 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -302,7 +302,6 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri using (modifiedPaths) { - string ioError; foreach (string folder in folders) { string normalizedPath = GVFSDatabase.NormalizePath(folder); diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs index a48a77514..9aa112313 100644 --- a/GVFS/GVFS/CommandLine/GVFSVerb.cs +++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs @@ -490,7 +490,7 @@ protected bool TryDownloadCommit( { if (!gitObjects.TryDownloadCommit(commitId)) { - error = "Could not download commit " + commitId + " from: " + Uri.EscapeUriString(objectRequestor.CacheServer.ObjectsEndpointUrl); + error = "Could not download commit " + commitId + " from: " + Uri.EscapeDataString(objectRequestor.CacheServer.ObjectsEndpointUrl); return false; } } @@ -773,7 +773,7 @@ private bool TryValidateGVFSVersion(GVFSEnlistment enlistment, ITracer tracer, S errorMessage = "WARNING: Unable to validate your GVFS version" + Environment.NewLine; if (config == null) { - errorMessage += "Could not query valid GVFS versions from: " + Uri.EscapeUriString(enlistment.RepoUrl); + errorMessage += "Could not query valid GVFS versions from: " + Uri.EscapeDataString(enlistment.RepoUrl); } else { From bb78760853dde6e387d734c034485c03d99e42a3 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 29 Apr 2026 15:55:43 -0700 Subject: [PATCH 14/25] Replace System.Management WMI with kernel32 P/Invoke System.Management requires COM interop which is incompatible with NativeAOT. Replace WMI queries (MSFT_Volume, MSFT_Partition, MSFT_Disk, MSFT_PhysicalDisk) with direct kernel32 DeviceIoControl calls using IOCTL_STORAGE_QUERY_PROPERTY and IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS for disk telemetry collection. Co-authored-by: Michael Niksa Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .../WindowsPhysicalDiskInfo.cs | 408 +++++++++++++----- 1 file changed, 305 insertions(+), 103 deletions(-) diff --git a/GVFS/GVFS.Platform.Windows/WindowsPhysicalDiskInfo.cs b/GVFS/GVFS.Platform.Windows/WindowsPhysicalDiskInfo.cs index 8debab0c0..e417f8369 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsPhysicalDiskInfo.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsPhysicalDiskInfo.cs @@ -1,16 +1,32 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Management; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; namespace GVFS.Platform.Windows { + /// + /// Collects physical disk telemetry using P/Invoke (kernel32 + DeviceIoControl) + /// instead of System.Management/WMI, which requires COM interop incompatible + /// with NativeAOT. + /// public class WindowsPhysicalDiskInfo { + private static readonly Dictionary MapDriveType = new Dictionary() + { + { 0, "unknown" }, + { 1, "InvalidRootPath" }, + { 2, "Removable" }, + { 3, "Fixed" }, + { 4, "Remote" }, + { 5, "CDROM" }, + { 6, "RAMDisk" }, + }; + private static readonly Dictionary MapBusType = new Dictionary() { - { 0, "unknwon" }, + { 0, "unknown" }, { 1, "SCSI" }, { 2, "ATAPI" }, { 3, "ATA" }, @@ -30,144 +46,341 @@ public class WindowsPhysicalDiskInfo { 17, "NVMe" }, }; - private static readonly Dictionary MapMediaType = new Dictionary() - { - { 0, "unspecified" }, - { 3, "HDD" }, - { 4, "SSD" }, - { 5, "SCM" }, - }; + #region P/Invoke constants + + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint OPEN_EXISTING = 3; + + private const uint IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS = 0x00560000; + private const uint IOCTL_STORAGE_QUERY_PROPERTY = 0x002D1400; + + private const int StorageAdapterProperty = 1; + private const int StorageDeviceSeekPenaltyProperty = 7; + + private const int PropertyStandardQuery = 0; + + #endregion + + #region P/Invoke declarations + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetDriveType(string lpRootPathName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool GetVolumeInformation( + string lpRootPathName, + char[] lpVolumeNameBuffer, + int nVolumeNameSize, + out uint lpVolumeSerialNumber, + out uint lpMaximumComponentLength, + out uint lpFileSystemFlags, + char[] lpFileSystemNameBuffer, + int nFileSystemNameSize); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool GetDiskFreeSpaceEx( + string lpDirectoryName, + out ulong lpFreeBytesAvailableToCaller, + out ulong lpTotalNumberOfBytes, + out ulong lpTotalNumberOfFreeBytes); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + ref StoragePropertyQuery lpInBuffer, + int nInBufferSize, + IntPtr lpOutBuffer, + int nOutBufferSize, + out int lpBytesReturned, + IntPtr lpOverlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + IntPtr lpInBuffer, + int nInBufferSize, + IntPtr lpOutBuffer, + int nOutBufferSize, + out int lpBytesReturned, + IntPtr lpOverlapped); + + #endregion - private static readonly Dictionary MapDriveType = new Dictionary() + #region Native structs + + [StructLayout(LayoutKind.Sequential)] + private struct StoragePropertyQuery { - { 0, "unknown" }, - { 1, "InvalidRootPath" }, - { 2, "Removable" }, - { 3, "Fixed" }, - { 4, "Remote" }, - { 5, "CDROM" }, - { 6, "RAMDisk" }, - }; + public int PropertyId; + public int QueryType; + public byte AdditionalParameters; + } + + #endregion /// /// Get the properties of the drive/volume/partition/physical disk associated - /// the given pathname. For example, whether the drive is an SSD or HDD. + /// with the given pathname. For example, whether the drive is an SSD or HDD. + /// + /// Uses direct P/Invoke calls (GetDriveType, GetVolumeInformation, + /// GetDiskFreeSpaceEx, DeviceIoControl) instead of WMI so the code is + /// compatible with NativeAOT compilation. /// /// A dictionary of platform-specific keywords and values. public static Dictionary GetPhysicalDiskInfo(string path, bool sizeStatsOnly) { - // Use the WMI APIs to get details about the physical disk associated with the given path. - // Some of these fields are avilable using normal classes, such as System.IO.DriveInfo: - // https://msdn.microsoft.com/en-us/library/system.io.driveinfo(v=vs.110).aspx - // - // But the lower-level fields, such as the BusType and SpindleSpeed, are not. - // - // MSFT_Partition: - // https://msdn.microsoft.com/en-us/library/windows/desktop/hh830524(v=vs.85).aspx - // - // MSFT_Disk: - // https://msdn.microsoft.com/en-us/library/windows/desktop/hh830493(v=vs.85).aspx - // - // MSFT_Volume: - // https://msdn.microsoft.com/en-us/library/windows/desktop/hh830604(v=vs.85).aspx - // - // MSFT_PhysicalDisk: - // https://msdn.microsoft.com/en-us/library/windows/desktop/hh830532(v=vs.85) - // - // An overview of these "classes" can be found here: - // https://msdn.microsoft.com/en-us/library/hh830612.aspx - // - // The map variables defined above are based on property values documented in one of the above APIs. - // There are helper functions below to convert from ManagementBaseObject values into the map values. - // These do not do strict validation because the OS can add new values at any time. For example, the - // integer code for NVMe bus drives was recently added. If an unrecognized value is received, the - // raw integer value is used untranslated. - // - // They are accessed via a generic WQL language that is similar to SQL. See here for an example: - // https://blogs.technet.microsoft.com/josebda/2014/08/11/sample-c-code-for-using-the-latest-wmi-classes-to-manage-windows-storage/ - Dictionary result = new Dictionary(); try { char driveLetter = PathToDriveLetter(path); - result.Add("DriveLetter", driveLetter.ToString()); + result["DriveLetter"] = driveLetter.ToString(); + + string rootPath = $"{driveLetter}:\\"; - ManagementScope scope = new ManagementScope(@"\\.\root\microsoft\windows\storage"); - scope.Connect(); + uint driveType = GetDriveType(rootPath); + result["VolumeDriveType"] = MapDriveType.TryGetValue(driveType, out string dtName) + ? dtName + : driveType.ToString(); - DiskSizeStatistics(scope, driveLetter, ref result); + CollectVolumeInfo(rootPath, result); + CollectVolumeSizeInfo(rootPath, result); if (sizeStatsOnly) { return result; } - DiskTypeInfo(scope, driveLetter, ref result); + CollectPhysicalDiskProperties(driveLetter, result); } catch (Exception e) { - result.Add("Error", e.Message); + result["Error"] = e.Message; } return result; } - private static void DiskSizeStatistics(ManagementScope scope, char driveLetter, ref Dictionary result) + private static void CollectVolumeInfo(string rootPath, Dictionary result) { - string queryVolumeString = $"SELECT DriveType,FileSystem,FileSystemLabel,Size,SizeRemaining FROM MSFT_Volume WHERE DriveLetter=\"{driveLetter}\""; - ManagementBaseObject mbo = GetFirstRecord(scope, queryVolumeString); - if (mbo != null) + char[] volumeLabel = new char[261]; + char[] fileSystemName = new char[261]; + + if (GetVolumeInformation( + rootPath, + volumeLabel, + volumeLabel.Length, + out _, + out _, + out _, + fileSystemName, + fileSystemName.Length)) + { + result["VolumeFileSystem"] = new string(fileSystemName).TrimEnd('\0'); + result["VolumeFileSystemLabel"] = new string(volumeLabel).TrimEnd('\0'); + } + else { - result.Add("VolumeDriveType", GetMapValue(MapDriveType, FetchValue(mbo, "DriveType"))); - result.Add("VolumeFileSystem", FetchValue(mbo, "FileSystem")); - result.Add("VolumeFileSystemLabel", FetchValue(mbo, "FileSystemLabel")); - result.Add("VolumeSize", FetchValue(mbo, "Size")); - result.Add("VolumeSizeRemaining", FetchValue(mbo, "SizeRemaining")); + result["VolumeFileSystem"] = "unknown"; + result["VolumeFileSystemLabel"] = "unknown"; } } - private static void DiskTypeInfo(ManagementScope scope, char driveLetter, ref Dictionary result) + private static void CollectVolumeSizeInfo(string rootPath, Dictionary result) { - string queryPartitionString = $"SELECT DiskNumber FROM MSFT_Partition WHERE DriveLetter=\"{driveLetter}\""; - ManagementBaseObject mbo = GetFirstRecord(scope, queryPartitionString); - if (mbo != null) + if (GetDiskFreeSpaceEx(rootPath, out _, out ulong totalBytes, out ulong freeBytes)) { - string diskNumber = FetchValue(mbo, "DiskNumber"); - result.Add("DiskNumber", diskNumber); + result["VolumeSize"] = totalBytes.ToString(); + result["VolumeSizeRemaining"] = freeBytes.ToString(); + } + else + { + result["VolumeSize"] = "unknown"; + result["VolumeSizeRemaining"] = "unknown"; + } + } - if (diskNumber.Length > 0) - { - string queryDiskString = $"SELECT Model,IsBoot,IsSystem,SerialNumber FROM MSFT_Disk WHERE Number=\"{diskNumber}\""; - mbo = GetFirstRecord(scope, queryDiskString); - if (mbo != null) - { - result.Add("DiskModel", FetchValue(mbo, "Model")); - result.Add("DiskIsSystem", FetchValue(mbo, "IsSystem")); - result.Add("DiskIsBoot", FetchValue(mbo, "IsBoot")); - result.Add("DiskSerialNumber", FetchValue(mbo, "SerialNumber")); - } + /// + /// Opens the volume handle, resolves the physical disk number via + /// IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS, then queries the physical disk + /// for seek-penalty (SSD vs HDD) and bus type via IOCTL_STORAGE_QUERY_PROPERTY. + /// + private static void CollectPhysicalDiskProperties(char driveLetter, Dictionary result) + { + string volumePath = $@"\\.\{driveLetter}:"; + using SafeFileHandle volumeHandle = CreateFile( + volumePath, + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, + OPEN_EXISTING, + 0, + IntPtr.Zero); + + if (volumeHandle.IsInvalid) + { + result["DiskNumber"] = "unknown"; + result["PhysicalMediaType"] = "unknown"; + result["PhysicalBusType"] = "unknown"; + return; + } + + int diskNumber = GetDiskNumberFromVolume(volumeHandle); + if (diskNumber < 0) + { + result["DiskNumber"] = "unknown"; + result["PhysicalMediaType"] = "unknown"; + result["PhysicalBusType"] = "unknown"; + return; + } + + result["DiskNumber"] = diskNumber.ToString(); + + string diskPath = $@"\\.\PhysicalDrive{diskNumber}"; + using SafeFileHandle diskHandle = CreateFile( + diskPath, + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, + OPEN_EXISTING, + 0, + IntPtr.Zero); + + if (diskHandle.IsInvalid) + { + result["PhysicalMediaType"] = "unknown"; + result["PhysicalBusType"] = "unknown"; + return; + } + + result["PhysicalMediaType"] = QueryMediaType(diskHandle); + result["PhysicalBusType"] = QueryBusType(diskHandle); + } - string queryPhysicalDiskString = $"SELECT MediaType,BusType,SpindleSpeed FROM MSFT_PhysicalDisk WHERE DeviceId=\"{diskNumber}\""; - mbo = GetFirstRecord(scope, queryPhysicalDiskString); - if (mbo != null) + /// + /// Uses IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS to determine which physical + /// disk number backs the given volume. + /// + private static int GetDiskNumberFromVolume(SafeFileHandle volumeHandle) + { + const int bufferSize = 256; + IntPtr buffer = Marshal.AllocHGlobal(bufferSize); + try + { + if (DeviceIoControl( + volumeHandle, + IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS, + IntPtr.Zero, + 0, + buffer, + bufferSize, + out _, + IntPtr.Zero)) + { + int count = Marshal.ReadInt32(buffer, 0); + if (count > 0) { - result.Add("PhysicalMediaType", GetMapValue(MapMediaType, FetchValue(mbo, "MediaType"))); - result.Add("PhysicalBusType", GetMapValue(MapBusType, FetchValue(mbo, "BusType"))); - result.Add("PhysicalSpindleSpeed", FetchValue(mbo, "SpindleSpeed")); + return Marshal.ReadInt32(buffer, 8); } } + + return -1; + } + finally + { + Marshal.FreeHGlobal(buffer); } } - private static string FetchValue(ManagementBaseObject mbo, string key) + /// + /// Queries StorageDeviceSeekPenaltyProperty via DeviceIoControl. + /// No seek penalty means SSD; seek penalty means HDD. + /// + private static string QueryMediaType(SafeFileHandle diskHandle) { - return (mbo[key] != null) ? mbo[key].ToString().Trim() : string.Empty; + StoragePropertyQuery query = new StoragePropertyQuery + { + PropertyId = StorageDeviceSeekPenaltyProperty, + QueryType = PropertyStandardQuery, + }; + + const int outSize = 32; + IntPtr buffer = Marshal.AllocHGlobal(outSize); + try + { + if (DeviceIoControl( + diskHandle, + IOCTL_STORAGE_QUERY_PROPERTY, + ref query, + Marshal.SizeOf(), + buffer, + outSize, + out _, + IntPtr.Zero)) + { + byte penalty = Marshal.ReadByte(buffer, 8); + return penalty != 0 ? "HDD" : "SSD"; + } + + return "unknown"; + } + finally + { + Marshal.FreeHGlobal(buffer); + } } - private static string GetMapValue(Dictionary map, string rawValue) + /// + /// Queries StorageAdapterProperty via DeviceIoControl to read the + /// STORAGE_BUS_TYPE from the STORAGE_ADAPTER_DESCRIPTOR. + /// + private static string QueryBusType(SafeFileHandle diskHandle) { - return int.TryParse(rawValue, out int key) && map.Keys.Contains(key) ? map[key] : rawValue; + StoragePropertyQuery query = new StoragePropertyQuery + { + PropertyId = StorageAdapterProperty, + QueryType = PropertyStandardQuery, + }; + + const int outSize = 256; + IntPtr buffer = Marshal.AllocHGlobal(outSize); + try + { + if (DeviceIoControl( + diskHandle, + IOCTL_STORAGE_QUERY_PROPERTY, + ref query, + Marshal.SizeOf(), + buffer, + outSize, + out _, + IntPtr.Zero)) + { + int busType = Marshal.ReadByte(buffer, 24); + return MapBusType.TryGetValue(busType, out string busName) + ? busName + : busType.ToString(); + } + + return "unknown"; + } + finally + { + Marshal.FreeHGlobal(buffer); + } } private static char PathToDriveLetter(string path) @@ -187,18 +400,7 @@ private static char PathToDriveLetter(string path) } } - // A bogus path or a UNC path. This should not happen since the path should already - // have been validated. throw new ArgumentException($"Could not map path '{path}' to a drive letter."); } - - private static ManagementBaseObject GetFirstRecord(ManagementScope scope, string queryString) - { - ObjectQuery q = new ObjectQuery(queryString); - ManagementObjectSearcher s = new ManagementObjectSearcher(scope, q); - - // Only return the first result. (There should only be one row returned for each of these queries.) - return s.Get().Cast().FirstOrDefault(); - } } } From c36bc2376a4c53541f6b270ff225d441dd15a115 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 29 Apr 2026 15:56:04 -0700 Subject: [PATCH 15/25] Guard against null triggeringProcessImageFileName from ProjFS ProjFS managed API v2.1.0 uses Marshal.PtrToStringUni which returns null for IntPtr.Zero (kernel operations with PID 0). The old C++/CLI wrapper returned String.Empty. Null-coalesce to match old behavior in all three callback sites (OnPlaceholderFileCreated, OnPlaceholderFolderCreated, OnPlaceholderFileHydrated); ConcurrentDictionary does not accept null keys. Co-authored-by: Michael Niksa Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Virtualization/FileSystemCallbacks.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs index 078b403f5..ab2ff36d7 100644 --- a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs +++ b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs @@ -695,8 +695,12 @@ public void OnPlaceholderFileCreated(string relativePath, string sha, string tri // Note: Because OnPlaceholderFileCreated is not synchronized on all platforms it is possible that GVFS will double count // the creation of file placeholders if multiple requests for the same file are received at the same time on different // threads. + // + // triggeringProcessImageFileName can be null when ProjFS reports a triggering process ID of 0 (e.g. kernel or + // system-level operations). The ProjFS managed API may pass null for the image file name in AOT builds. + // ConcurrentDictionary does not allow null keys, so fall back to a sentinel value. this.filePlaceHolderCreationCount.AddOrUpdate( - triggeringProcessImageFileName, + triggeringProcessImageFileName ?? string.Empty, (imageName) => { return new PlaceHolderCreateCounter(); }, (key, oldCount) => { oldCount.Increment(); return oldCount; }); } @@ -711,7 +715,7 @@ public void OnPlaceholderFolderCreated(string relativePath, string triggeringPro this.GitIndexProjection.OnPlaceholderFolderCreated(relativePath); this.folderPlaceHolderCreationCount.AddOrUpdate( - triggeringProcessImageFileName, + triggeringProcessImageFileName ?? string.Empty, (imageName) => { return new PlaceHolderCreateCounter(); }, (key, oldCount) => { oldCount.Increment(); return oldCount; }); } @@ -724,7 +728,7 @@ public void OnPlaceholderFolderExpanded(string relativePath) public void OnPlaceholderFileHydrated(string triggeringProcessImageFileName) { this.fileHydrationCount.AddOrUpdate( - triggeringProcessImageFileName, + triggeringProcessImageFileName ?? string.Empty, (imageName) => { return new PlaceHolderCreateCounter(); }, (key, oldCount) => { oldCount.Increment(); return oldCount; }); } From 97fa1e13bab492b8582262c05e1252bebfd2d693 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 29 Apr 2026 15:57:16 -0700 Subject: [PATCH 16/25] Replace HTTP stack for .NET 10 Replace HttpClientHandler with SocketsHttpHandler for explicit connection pool lifecycle management: configurable MaxConnectionsPerServer (2x CPU count), PooledConnectionLifetime, and PooledConnectionIdleTimeout. Remove UseDefaultCredentials (not supported on SocketsHttpHandler) and ServicePointManager usage (.NET Framework only). GitSsl: X509Certificate2(byte[]) constructor obsoleted; use X509CertificateLoader.LoadCertificate. GitAuthentication: adapt credential flow for new HTTP handler. Remove machine.config lock check (.NET 10 does not use machine.config). Co-authored-by: Michael Niksa Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Git/GitAuthentication.cs | 17 ++++++ GVFS/GVFS.Common/Git/GitSsl.cs | 2 +- GVFS/GVFS.Common/Http/HttpRequestor.cs | 72 +++++++++-------------- 3 files changed, 45 insertions(+), 46 deletions(-) diff --git a/GVFS/GVFS.Common/Git/GitAuthentication.cs b/GVFS/GVFS.Common/Git/GitAuthentication.cs index bb81a86c1..fff37ad9a 100644 --- a/GVFS/GVFS.Common/Git/GitAuthentication.cs +++ b/GVFS/GVFS.Common/Git/GitAuthentication.cs @@ -309,6 +309,23 @@ public void ConfigureHttpClientHandlerSslIfNeeded(ITracer tracer, HttpClientHand } } + public void ConfigureSocketsHandlerSslIfNeeded(ITracer tracer, SocketsHttpHandler socketsHandler, GitProcess gitProcess) + { + X509Certificate2 cert = this.GitSsl?.GetCertificate(tracer, gitProcess); + if (cert != null) + { + System.Net.Security.SslClientAuthenticationOptions sslOptions = new System.Net.Security.SslClientAuthenticationOptions(); + + if (this.GitSsl != null && !this.GitSsl.ShouldVerify) + { + sslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => true; // CodeQL [SM02184] TLS verification can be disabled by Git itself, so this is just mirroring a feature already exposed. + } + + sslOptions.ClientCertificates = new System.Security.Cryptography.X509Certificates.X509CertificateCollection { cert }; + socketsHandler.SslOptions = sslOptions; + } + } + private static bool TryParseCredentialString(string credentialString, out string username, out string password) { if (credentialString != null) diff --git a/GVFS/GVFS.Common/Git/GitSsl.cs b/GVFS/GVFS.Common/Git/GitSsl.cs index 58be50d5e..e2c61a17f 100644 --- a/GVFS/GVFS.Common/Git/GitSsl.cs +++ b/GVFS/GVFS.Common/Git/GitSsl.cs @@ -161,7 +161,7 @@ private X509Certificate2 GetCertificateFromFile(ITracer tracer, EventMetadata me try { byte[] certificateContent = this.fileSystem.ReadAllBytes(this.certificatePathOrSubjectCommonName); - X509Certificate2 cert = new X509Certificate2(certificateContent, certificatePassword); + X509Certificate2 cert = X509CertificateLoader.LoadPkcs12(certificateContent, certificatePassword); if (this.ShouldVerify && cert != null && !this.certificateVerifier.Verify(cert)) { tracer.RelatedWarning(metadata, "Certficate was found, but is invalid."); diff --git a/GVFS/GVFS.Common/Http/HttpRequestor.cs b/GVFS/GVFS.Common/Http/HttpRequestor.cs index 1f05d6aab..0f9767dde 100644 --- a/GVFS/GVFS.Common/Http/HttpRequestor.cs +++ b/GVFS/GVFS.Common/Http/HttpRequestor.cs @@ -8,7 +8,6 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -32,19 +31,10 @@ public abstract class HttpRequestor : IDisposable static HttpRequestor() { - /* If machine.config is locked, then initializing ServicePointManager will fail and be unrecoverable. - * Machine.config locking is typically very brief (~1ms by the antivirus scanner) so we can attempt to lock - * it ourselves (by opening it for read) *beforehand and briefly wait if it's locked */ - using (var machineConfigLock = GetMachineConfigLock()) - { - ServicePointManager.SecurityProtocol = ServicePointManager.SecurityProtocol | SecurityProtocolType.Tls12; - - // HTTP downloads are I/O-bound, not CPU-bound, so we default to - // 2x ProcessorCount. Can be overridden via gvfs.max-http-connections. - int connectionLimit = 2 * Environment.ProcessorCount; - ServicePointManager.DefaultConnectionLimit = connectionLimit; - availableConnections = new SemaphoreSlim(connectionLimit); - } + // HTTP downloads are I/O-bound, not CPU-bound, so we default to + // 2x ProcessorCount. Can be overridden via gvfs.max-http-connections. + int connectionLimit = 2 * Environment.ProcessorCount; + availableConnections = new SemaphoreSlim(connectionLimit); } protected HttpRequestor(ITracer tracer, RetryConfig retryConfig, Enlistment enlistment) @@ -62,13 +52,29 @@ protected HttpRequestor(ITracer tracer, RetryConfig retryConfig, Enlistment enli TryApplyConnectionLimitFromConfig(tracer, enlistment); } - HttpClientHandler httpClientHandler = new HttpClientHandler() { UseDefaultCredentials = true }; + // WARNING: Do NOT set Credentials or ServerCredentials on this handler. + // + // Setting Credentials = CredentialCache.DefaultCredentials causes the handler + // to perform an NTLM/Negotiate challenge-response on every new connection. + // On SocketsHttpHandler this adds ~400ms per request vs ~14ms without. + // + // GVFS cache servers and Azure DevOps accept PAT/OAuth tokens via the + // "Authorization: Basic " header that SendRequest already attaches. + // Transport-level credentials are redundant and purely wasteful. + SocketsHttpHandler handler = new SocketsHttpHandler() + { + MaxConnectionsPerServer = Environment.ProcessorCount, + PooledConnectionLifetime = Timeout.InfiniteTimeSpan, + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5), + }; - this.authentication.ConfigureHttpClientHandlerSslIfNeeded(this.Tracer, httpClientHandler, enlistment.CreateGitProcess()); + this.authentication.ConfigureSocketsHandlerSslIfNeeded(this.Tracer, handler, enlistment.CreateGitProcess()); - this.client = new HttpClient(httpClientHandler) + this.client = new HttpClient(handler) { - Timeout = retryConfig.Timeout + Timeout = retryConfig.Timeout, + DefaultRequestVersion = HttpVersion.Version11, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, }; this.userAgentHeader = new ProductInfoHeaderValue(ProcessHelper.GetEntryClassName(), ProcessHelper.GetCurrentProcessVersion()); @@ -180,8 +186,8 @@ protected GitEndPointResponseData SendRequest( } catch (HttpRequestException httpRequestException) when (TryGetResponseMessageFromHttpRequestException(httpRequestException, request, out response)) { - /* HttpClientHandler will automatically resubmit in certain circumstances, such as a 401 unauthorized response when UseDefaultCredentials - * is true but another credential was provided. This resubmit can throw (instead of returning a proper status code) in some case cases, such + /* HttpClientHandler may automatically resubmit in certain circumstances, such as a 401 unauthorized response. + * This resubmit can throw (instead of returning a proper status code) in some cases, such * as when there is an exception loading the default credentials. * If we can extract the original response message from the exception, we can continue and process the original failed status code. */ Tracer.RelatedWarning(responseMetadata, $"An exception occurred while resubmitting the request, but the original response is available."); @@ -391,8 +397,7 @@ private static void TryApplyConnectionLimitFromConfig(ITracer tracer, Enlistment if (configuredLimit > 0) { - int currentLimit = ServicePointManager.DefaultConnectionLimit; - ServicePointManager.DefaultConnectionLimit = configuredLimit; + int currentLimit = availableConnections.CurrentCount; // Adjust the existing semaphore rather than replacing it, so any // in-flight waiters release permits to the correct instance. @@ -425,28 +430,5 @@ private static void TryApplyConnectionLimitFromConfig(ITracer tracer, Enlistment tracer.RelatedWarning(metadata, "HttpRequestor: Failed to read gvfs.max-http-connections config, using default"); } } - - private static FileStream GetMachineConfigLock() - { - var machineConfigLocation = RuntimeEnvironment.SystemConfigurationFile; - var tries = 0; - var maxTries = 3; - while (tries++ < maxTries) - { - try - { - /* Opening with FileShare.Read will fail if another process (eg antivirus) has opened the file for write, - but will still let ServicePointManager read the file.*/ - FileStream stream = File.Open(machineConfigLocation, FileMode.Open, FileAccess.Read, FileShare.Read); - return stream; - } - catch (IOException e) when ((uint)e.HResult == 0x80070020) // SHARING_VIOLATION - { - Thread.Sleep(10); - } - } - /* Couldn't get the lock - the process will likely fail. */ - return null; - } } } From 33d3b952f1b38514dd4274438200e2d0ae92cfc9 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 29 Apr 2026 16:03:09 -0700 Subject: [PATCH 17/25] Add source-generated JSON serialization for NativeAOT NativeAOT cannot use runtime reflection for JSON serialization. GVFSJsonContext provides source-generated System.Text.Json serializers for 25+ types used in named pipe messages and configuration. GVFSJsonOptions chains source-gen (primary) with reflection fallback for types not yet in the context, allowing incremental migration. NamedPipeMessages: add parameterless constructors required by the source generator's deserialization codegen. RepoRegistration: add ServiceJsonContext source generator in GVFS.Service for types that cannot be registered in GVFSJsonContext (GVFS.Common) due to assembly dependency direction. Co-authored-by: Michael Niksa Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSJsonContext.cs | 47 +++++++++++++++++++ GVFS/GVFS.Common/GVFSJsonOptions.cs | 41 +++++++++++----- .../NamedPipes/NamedPipeMessages.cs | 18 +++++-- .../Tracing/PrettyConsoleEventListener.cs | 2 +- GVFS/GVFS.Service/RepoRegistration.cs | 10 ++-- GVFS/GVFS.Service/ServiceJsonContext.cs | 15 ++++++ .../Background/FileSystemTask.cs | 4 +- .../VirtualizationJsonContext.cs | 10 ++++ 8 files changed, 123 insertions(+), 24 deletions(-) create mode 100644 GVFS/GVFS.Common/GVFSJsonContext.cs create mode 100644 GVFS/GVFS.Service/ServiceJsonContext.cs create mode 100644 GVFS/GVFS.Virtualization/VirtualizationJsonContext.cs diff --git a/GVFS/GVFS.Common/GVFSJsonContext.cs b/GVFS/GVFS.Common/GVFSJsonContext.cs new file mode 100644 index 000000000..1a203ee8a --- /dev/null +++ b/GVFS/GVFS.Common/GVFSJsonContext.cs @@ -0,0 +1,47 @@ +using GVFS.Common.Http; +using GVFS.Common.NamedPipes; +using GVFS.Common.Tracing; +using GVFS.Common.Prefetch; +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GVFS.Common +{ + /// + /// Source-generated JSON serializer context for all types used in GVFS serialization. + /// This enables trim-safe and AOT-compatible JSON serialization without reflection. + /// + [JsonSourceGenerationOptions( + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = new[] { typeof(VersionConverter) })] + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(KeyValuePair))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(ServerGVFSConfig))] + [JsonSerializable(typeof(VersionResponse))] + [JsonSerializable(typeof(InternalVerbParameters))] + [JsonSerializable(typeof(CacheServerInfo))] + [JsonSerializable(typeof(NamedPipeMessages.GetStatus.Response), TypeInfoPropertyName = "GetStatusResponse")] + [JsonSerializable(typeof(NamedPipeMessages.DehydrateFolders.Request), TypeInfoPropertyName = "DehydrateFoldersRequest")] + [JsonSerializable(typeof(NamedPipeMessages.DehydrateFolders.Response), TypeInfoPropertyName = "DehydrateFoldersResponse")] + [JsonSerializable(typeof(NamedPipeMessages.Notification.Request), TypeInfoPropertyName = "NotificationRequest")] + [JsonSerializable(typeof(NamedPipeMessages.UnregisterRepoRequest))] + [JsonSerializable(typeof(NamedPipeMessages.UnregisterRepoRequest.Response), TypeInfoPropertyName = "UnregisterRepoResponse")] + [JsonSerializable(typeof(NamedPipeMessages.RegisterRepoRequest))] + [JsonSerializable(typeof(NamedPipeMessages.RegisterRepoRequest.Response), TypeInfoPropertyName = "RegisterRepoResponse")] + [JsonSerializable(typeof(NamedPipeMessages.EnableAndAttachProjFSRequest))] + [JsonSerializable(typeof(NamedPipeMessages.EnableAndAttachProjFSRequest.Response), TypeInfoPropertyName = "EnableAndAttachProjFSResponse")] + [JsonSerializable(typeof(NamedPipeMessages.GetActiveRepoListRequest))] + [JsonSerializable(typeof(NamedPipeMessages.GetActiveRepoListRequest.Response), TypeInfoPropertyName = "GetActiveRepoListResponse")] + [JsonSerializable(typeof(NamedPipeMessages.BaseResponse))] + [JsonSerializable(typeof(TelemetryDaemonEventListener.PipeMessage))] + [JsonSerializable(typeof(PrettyConsoleEventListener.ConsoleOutputPayload))] + internal partial class GVFSJsonContext : JsonSerializerContext + { + } +} diff --git a/GVFS/GVFS.Common/GVFSJsonOptions.cs b/GVFS/GVFS.Common/GVFSJsonOptions.cs index f5230b95e..b6f98d0bc 100644 --- a/GVFS/GVFS.Common/GVFSJsonOptions.cs +++ b/GVFS/GVFS.Common/GVFSJsonOptions.cs @@ -1,40 +1,55 @@ +using GVFS.Common.Tracing; using System; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace GVFS.Common { /// - /// Shared JsonSerializerOptions and helpers for the GVFS codebase. - /// PropertyNameCaseInsensitive preserves backward compatibility with - /// Newtonsoft.Json's default case-insensitive deserialization. + /// Shared JsonSerializerOptions for the GVFS codebase. + /// Uses source-generated GVFSJsonContext for known types (trim-safe/AOT-safe) + /// with DefaultJsonTypeInfoResolver as fallback for types not in the context + /// (e.g., boxed primitives in EventMetadata Dictionary<string, object>). + /// EventMetadata uses a custom converter that handles Dictionary<string, object> + /// without reflection, making it NativeAOT compatible. /// public static class GVFSJsonOptions { + [UnconditionalSuppressMessage("AOT", "IL2026", + Justification = "Uses source-gen context for known types; EventMetadataConverter handles Dictionary without reflection. DefaultJsonTypeInfoResolver fallback handles boxed primitives in EventMetadata.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "Uses source-gen context for known types; EventMetadataConverter handles Dictionary without reflection. DefaultJsonTypeInfoResolver fallback handles boxed primitives in EventMetadata.")] public static readonly JsonSerializerOptions Default = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, - Converters = { new VersionConverter(), new Tracing.EventMetadataConverter() }, + Converters = { new VersionConverter(), new EventMetadataConverter() }, + TypeInfoResolverChain = { GVFSJsonContext.Default, new DefaultJsonTypeInfoResolver() }, }; - /// - /// Serialize using the compile-time type. Use when - /// is the concrete type (not a base class with derived properties). - /// + [UnconditionalSuppressMessage("AOT", "IL2026", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] public static string Serialize(T value) { return JsonSerializer.Serialize(value, Default); } - /// - /// Serialize using the runtime type. Use when calling from a base-class - /// method where compile-time type would lose derived-class properties - /// (e.g., BaseResponse<T>.ToMessage()). - /// + [UnconditionalSuppressMessage("AOT", "IL2026", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] public static string Serialize(object value, Type inputType) { return JsonSerializer.Serialize(value, inputType, Default); } + [UnconditionalSuppressMessage("AOT", "IL2026", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] public static T Deserialize(string json) { return JsonSerializer.Deserialize(json, Default); diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index 741514ac2..d42c84873 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -90,8 +90,10 @@ public Response(string result, string data = "") this.Data = data; } - public string Result { get; } - public string Data { get; } + public Response() { } + + public string Result { get; set; } + public string Data { get; set; } public Message CreateMessage() { @@ -129,7 +131,9 @@ public Response(string result) this.Result = result; } - public string Result { get; } + public Response() { } + + public string Result { get; set; } public Message CreateMessage() { @@ -185,7 +189,9 @@ public Response(string result) this.Result = result; } - public string Result { get; } + public Response() { } + + public string Result { get; set; } public Message CreateMessage() { @@ -296,7 +302,9 @@ public Response(string result) this.Result = result; } - public string Result { get; } + public Response() { } + + public string Result { get; set; } public Message CreateMessage() { diff --git a/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs b/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs index 3f891a977..5999d97ee 100644 --- a/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs +++ b/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs @@ -59,7 +59,7 @@ protected override void RecordMessageInternal(TraceEventMessage message) } } - private class ConsoleOutputPayload + internal class ConsoleOutputPayload { public string ErrorMessage { get; set; } } diff --git a/GVFS/GVFS.Service/RepoRegistration.cs b/GVFS/GVFS.Service/RepoRegistration.cs index 7e5e3a095..964b030c3 100644 --- a/GVFS/GVFS.Service/RepoRegistration.cs +++ b/GVFS/GVFS.Service/RepoRegistration.cs @@ -1,4 +1,4 @@ -using GVFS.Common; +using System.Text.Json; namespace GVFS.Service { @@ -19,9 +19,13 @@ public RepoRegistration(string enlistmentRoot, string ownerSID) public string OwnerSID { get; set; } public bool IsActive { get; set; } + // Uses ServiceJsonContext (assembly-local source generator) instead of + // GVFSJsonOptions because RepoRegistration cannot be registered in + // GVFSJsonContext (GVFS.Common) — wrong assembly direction. The + // reflection fallback in GVFSJsonOptions fails under native AOT trimming. public static RepoRegistration FromJson(string json) { - return GVFSJsonOptions.Deserialize(json); + return JsonSerializer.Deserialize(json, ServiceJsonContext.Default.RepoRegistration); } public override string ToString() @@ -36,7 +40,7 @@ public override string ToString() public string ToJson() { - return GVFSJsonOptions.Serialize(this); + return JsonSerializer.Serialize(this, ServiceJsonContext.Default.RepoRegistration); } } } \ No newline at end of file diff --git a/GVFS/GVFS.Service/ServiceJsonContext.cs b/GVFS/GVFS.Service/ServiceJsonContext.cs new file mode 100644 index 000000000..3492b2289 --- /dev/null +++ b/GVFS/GVFS.Service/ServiceJsonContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace GVFS.Service +{ + /// + /// Source-generated JSON context for GVFS.Service types that cannot be registered + /// in GVFSJsonContext (GVFS.Common) due to assembly dependency direction. + /// Required for native AOT where the DefaultJsonTypeInfoResolver reflection + /// fallback is not available. + /// + [JsonSerializable(typeof(RepoRegistration))] + internal partial class ServiceJsonContext : JsonSerializerContext + { + } +} diff --git a/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs b/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs index ee9a3a907..675c3f086 100644 --- a/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs +++ b/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs @@ -1,4 +1,4 @@ -using GVFS.Common; +using System.Text.Json; namespace GVFS.Virtualization.Background { @@ -133,7 +133,7 @@ public static FileSystemTask OnPlaceholderCreationsBlockedForGit() public override string ToString() { - return GVFSJsonOptions.Serialize(this); + return JsonSerializer.Serialize(this, VirtualizationJsonContext.Default.FileSystemTask); } } } diff --git a/GVFS/GVFS.Virtualization/VirtualizationJsonContext.cs b/GVFS/GVFS.Virtualization/VirtualizationJsonContext.cs new file mode 100644 index 000000000..cd1898ed4 --- /dev/null +++ b/GVFS/GVFS.Virtualization/VirtualizationJsonContext.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using GVFS.Virtualization.Background; + +namespace GVFS.Virtualization +{ + [JsonSerializable(typeof(FileSystemTask))] + internal partial class VirtualizationJsonContext : JsonSerializerContext + { + } +} From 62c5c6c6f6c960a942212ec4166d85a006182f9b Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 29 Apr 2026 16:05:24 -0700 Subject: [PATCH 18/25] Adapt functional tests for .NET 10 ProjFS behavioral changes .NET 10's FileInfo property setters no longer open write handles that trigger ProjFS placeholder hydration. Adapt tests that relied on this. BasicFileSystemTests: replace ExpandedFileAttributesAreUpdated with two focused tests: - PlaceholderMetadataSurvivesHydration: sets timestamps + Hidden on a placeholder, verifies they took effect, hydrates via read+write, and asserts CreationTime and Hidden survived the conversion. - HydratedFileTimestampsAndAttributesAreUpdated: hydrates first, then sets all properties and verifies they stick. GitCommandsTests: ChangeTimestampAndDiff now explicitly hydrates via read+write before adjusting timestamps, since File.SetLastWriteTime no longer triggers ProjFS hydration. GVFSProcess: add 5-minute timeout per gvfs process invocation to prevent CI hangs. Stream stdout/stderr for real-time CI output. functional-tests.yaml: reduce mount sleep from 500ms to 100ms, add timeout-minutes. Co-authored-by: Michael Niksa Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .github/workflows/functional-tests.yaml | 13 ++ GVFS/GVFS.FunctionalTests/Program.cs | 12 ++ .../BasicFileSystemTests.cs | 139 +++++++++++++---- .../Tests/EnlistmentPerFixture/HealthTests.cs | 11 +- .../Tests/GitCommands/GitCommandsTests.cs | 28 ++-- .../Tests/GitCommands/GitRepoTests.cs | 14 ++ .../Tools/GVFSFunctionalTestEnlistment.cs | 4 + .../GVFS.FunctionalTests/Tools/GVFSProcess.cs | 46 +++++- GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs | 28 +++- .../Tools/ProcessHelper.cs | 102 +++++++++++-- .../Windows/Tests/WindowsTombstoneTests.cs | 142 +++++++++++++++++- 11 files changed, 482 insertions(+), 57 deletions(-) diff --git a/.github/workflows/functional-tests.yaml b/.github/workflows/functional-tests.yaml index 82faf676e..72c9ee503 100644 --- a/.github/workflows/functional-tests.yaml +++ b/.github/workflows/functional-tests.yaml @@ -122,6 +122,18 @@ jobs: shell: cmd run: gvfs\install.bat + - name: Verify GVFS installation + if: steps.skip.outputs.result != 'true' + shell: cmd + continue-on-error: true + run: | + echo === GVFS Version === + "C:\Program Files\VFS for Git\GVFS.exe" version + echo === Service Status === + sc query GVFS.Service + echo === List Mounted === + "C:\Program Files\VFS for Git\GVFS.exe" service --list-mounted + - name: ProjFS details (post-install) if: steps.skip.outputs.result != 'true' shell: cmd @@ -141,6 +153,7 @@ jobs: - name: Run functional tests if: steps.skip.outputs.result != 'true' shell: cmd + timeout-minutes: 60 run: | SET PATH=C:\Program Files\VFS for Git;%PATH% SET GIT_TRACE2_PERF=C:\temp\git-trace2.log diff --git a/GVFS/GVFS.FunctionalTests/Program.cs b/GVFS/GVFS.FunctionalTests/Program.cs index 0303371bf..07ecfa402 100644 --- a/GVFS/GVFS.FunctionalTests/Program.cs +++ b/GVFS/GVFS.FunctionalTests/Program.cs @@ -130,6 +130,8 @@ public static void Main(string[] args) ?? Properties.Settings.Default.RepoToClone; RunBeforeAnyTests(); + Console.WriteLine("[CI-DEBUG] RunBeforeAnyTests complete, starting RunTests..."); + Console.Out.Flush(); Environment.ExitCode = runner.RunTests(includeCategories, excludeCategories, testSlice); if (Debugger.IsAttached) @@ -141,12 +143,19 @@ public static void Main(string[] args) private static void RunBeforeAnyTests() { + Console.WriteLine("[CI-DEBUG] RunBeforeAnyTests: starting"); + Console.Out.Flush(); + if (GVFSTestConfig.ReplaceInboxProjFS) { ProjFSFilterInstaller.ReplaceInboxProjFS(); } + Console.WriteLine("[CI-DEBUG] Installing service..."); + Console.Out.Flush(); GVFSServiceProcess.InstallService(); + Console.WriteLine("[CI-DEBUG] Service installed successfully"); + Console.Out.Flush(); string serviceProgramDataDir = GVFSPlatform.Instance.GetSecureDataRootForGVFSComponent( GVFSConstants.Service.ServiceName); @@ -159,6 +168,9 @@ private static void RunBeforeAnyTests() Directory.CreateDirectory(serviceProgramDataDir); File.WriteAllText(statusCacheVersionTokenPath, string.Empty); } + + Console.WriteLine("[CI-DEBUG] RunBeforeAnyTests: complete"); + Console.Out.Flush(); } } } diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/BasicFileSystemTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/BasicFileSystemTests.cs index 1a8925ed1..d19b3e982 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/BasicFileSystemTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/BasicFileSystemTests.cs @@ -133,49 +133,132 @@ public void NewFolderAttributesAreUpdated(string parentFolder) Directory.Delete(virtualFolder); } + // On .NET 10, no FileInfo property setter (CreationTime, LastAccessTime, LastWriteTime, + // Attributes) triggers ProjFS hydration. Only actual file content I/O (read+write) does. + // These tests replace the original ExpandedFileAttributesAreUpdated test, which relied on + // .NET Framework 4.7.1's CreationTime setter triggering hydration as a side effect. + + /// + /// Hydrates a ProjFS placeholder by reading and writing its content, then waits for + /// ProjFS to clear the RecallOnDataAccess flag (which happens asynchronously). + /// Uses FileStream with FileMode.Open since File.WriteAllText fails on Hidden files. + /// + private static void HydrateFile(string virtualFile) + { + using (FileStream fs = new FileStream(virtualFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + byte[] buf = new byte[fs.Length]; + fs.Read(buf, 0, buf.Length); + fs.Position = 0; + fs.Write(buf, 0, buf.Length); + } + + // ProjFS clears RecallOnDataAccess asynchronously after hydration. + // Wait for it to complete — CI machines can be slow. + int retryCount = 0; + while (retryCount < 10) + { + FileAttributes attrs = File.GetAttributes(virtualFile); + if (((int)attrs & FileAttributeRecallOnDataAccess) == 0) + { + return; + } + + ++retryCount; + Thread.Sleep(500); + } + + File.GetAttributes(virtualFile).ShouldNotEqual( + (FileAttributes)FileAttributeRecallOnDataAccess, + "File should be hydrated (no RecallOnDataAccess) after content write and retry"); + } + [TestCase] - public void ExpandedFileAttributesAreUpdated() + public void PlaceholderMetadataSurvivesHydration() { + // Set all metadata properties on a ProjFS placeholder, verify they took effect + // while the file is still a placeholder, then hydrate via content I/O and verify + // the values survived the placeholder-to-full-file conversion. FileSystemRunner fileSystem = FileSystemRunner.DefaultRunner; string filename = Path.Combine("GVFS", "GVFS", "GVFS.csproj"); string virtualFile = this.Enlistment.GetVirtualPathTo(filename); - - // Update defaults. FileInfo is not batched, so each of these will create a separate Open-Update-Close set. - FileInfo before = new FileInfo(virtualFile); DateTime testValue = DateTime.Now + TimeSpan.FromDays(1); - // Setting the CreationTime results in a write handle being open to the file and the file being expanded - before.CreationTime = testValue; - before.LastAccessTime = testValue; - before.LastWriteTime = testValue; - before.Attributes = FileAttributes.Hidden; - - // FileInfo caches information. We can refresh, but just to be absolutely sure... - FileInfo info = virtualFile.ShouldBeAFile(fileSystem).WithInfo(testValue, testValue, testValue); - - // Ignore the archive bit as it can be re-added to the file as part of its expansion to full - FileAttributes attributes = info.Attributes & ~FileAttributes.Archive; - + // Set all properties while file is still a placeholder + FileInfo fi = new FileInfo(virtualFile); + fi.CreationTime = testValue; + fi.LastAccessTime = testValue; + fi.LastWriteTime = testValue; + fi.Attributes = FileAttributes.Hidden; + + // Verify file is still a placeholder (no property setter triggers hydration on .NET 10) + fi.Refresh(); + ((int)fi.Attributes & FileAttributeRecallOnDataAccess).ShouldNotEqual( + 0, + "File should still be a placeholder after setting metadata properties"); + + // Verify the properties took effect on the placeholder + fi.CreationTime.ShouldEqual(testValue, "CreationTime should be set on placeholder"); + fi.LastAccessTime.ShouldEqual(testValue, "LastAccessTime should be set on placeholder"); + fi.LastWriteTime.ShouldEqual(testValue, "LastWriteTime should be set on placeholder"); + FileAttributes placeholderAttrs = fi.Attributes & ~FileAttributes.Archive & (FileAttributes)~(FileAttributeSparseFile | FileAttributeReparsePoint | FileAttributeRecallOnDataAccess); + placeholderAttrs.ShouldEqual(FileAttributes.Hidden, $"Hidden should be set on placeholder, got: {placeholderAttrs}"); + + // Hydrate and wait for ProjFS to finish clearing placeholder flags + HydrateFile(virtualFile); + + // Verify metadata survived hydration. + // CreationTime should survive — it's not affected by read or write operations. + fi.Refresh(); + fi.CreationTime.ShouldEqual(testValue, "CreationTime should survive hydration"); + + // LastAccessTime and LastWriteTime are inherently updated by the read+write + // hydration step, so we cannot assert the pre-hydration values survived. + + // Hidden attribute should survive hydration (with async ProjFS flag cleanup) int retryCount = 0; - int maxRetries = 10; - while (attributes != FileAttributes.Hidden && retryCount < maxRetries) + FileAttributes attributes = fi.Attributes & ~FileAttributes.Archive; + while (attributes != FileAttributes.Hidden && retryCount < 10) { - // ProjFS attributes are remoted asynchronously when files are converted to full - FileAttributes attributesLessProjFS = attributes & (FileAttributes)~(FileAttributeSparseFile | FileAttributeReparsePoint | FileAttributeRecallOnDataAccess); - - attributesLessProjFS.ShouldEqual( + FileAttributes withoutProjFS = attributes & (FileAttributes)~(FileAttributeSparseFile | FileAttributeReparsePoint | FileAttributeRecallOnDataAccess); + withoutProjFS.ShouldEqual( FileAttributes.Hidden, - $"Attributes (ignoring ProjFS attributes) do not match, expected: {FileAttributes.Hidden} actual: {attributesLessProjFS}"); - + $"Attributes (ignoring ProjFS) should be Hidden, got: {withoutProjFS}"); ++retryCount; Thread.Sleep(500); - - info.Refresh(); - attributes = info.Attributes & ~FileAttributes.Archive; + fi.Refresh(); + attributes = fi.Attributes & ~FileAttributes.Archive; } - attributes.ShouldEqual(FileAttributes.Hidden, $"Attributes do not match, expected: {FileAttributes.Hidden} actual: {attributes}"); + attributes.ShouldEqual(FileAttributes.Hidden, $"Hidden should survive hydration, got: {attributes}"); + } + + [TestCase] + public void HydratedFileTimestampsAndAttributesAreUpdated() + { + // Verify that all timestamps and attributes can be set on an already-hydrated + // (dirty full) file in a GVFS enlistment. + FileSystemRunner fileSystem = FileSystemRunner.DefaultRunner; + + string filename = Path.Combine("GVFS", "GVFS.Common", "GVFSConstants.cs"); + string virtualFile = this.Enlistment.GetVirtualPathTo(filename); + DateTime testValue = DateTime.Now + TimeSpan.FromDays(1); + + // Hydrate and wait for ProjFS to finish clearing placeholder flags + HydrateFile(virtualFile); + + // Set all properties on the now-hydrated file + FileInfo fi = new FileInfo(virtualFile); + fi.CreationTime = testValue; + fi.LastAccessTime = testValue; + fi.LastWriteTime = testValue; + fi.Attributes = FileAttributes.Hidden; + + // Verify all properties stuck + FileInfo verify = virtualFile.ShouldBeAFile(fileSystem).WithInfo(testValue, testValue, testValue); + FileAttributes attributes = verify.Attributes & ~FileAttributes.Archive; + attributes.ShouldEqual(FileAttributes.Hidden, $"Attributes should be Hidden, got: {attributes}"); } [TestCase] diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs index d16cda74c..1e9b6926d 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs @@ -160,9 +160,18 @@ private void ValidateHealthOutputValues( List directoryHydrationLevels, string enlistmentHealthStatus) { - List healthOutputLines = new List(this.Enlistment.Health(directory).Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)); + string rawOutput = this.Enlistment.Health(directory); + List healthOutputLines = new List(rawOutput.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)); int numberOfExpectedSubdirectories = topHydratedDirectories.Count; + int expectedMinimumLines = 8 + numberOfExpectedSubdirectories; + + if (healthOutputLines.Count < expectedMinimumLines) + { + Assert.Fail( + $"Expected at least {expectedMinimumLines} lines in 'gvfs health' output, but got {healthOutputLines.Count}.\n" + + $"Raw output:\n{rawOutput}"); + } this.ValidateTargetDirectory(healthOutputLines[1], directory); this.ValidateTotalFileInfo(healthOutputLines[2], totalFiles, totalFilePercent); diff --git a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitCommandsTests.cs b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitCommandsTests.cs index 28f752353..2af41d00f 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitCommandsTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitCommandsTests.cs @@ -999,18 +999,22 @@ public void EditFileNeedingUtf8Encoding() [TestCase] public void ChangeTimestampAndDiff() { - // User scenario - - // 1. Enlistment's "diff.autoRefreshIndex" config is set to false - // 2. A checked out file got into a state where it differs from the git copy - // only in its LastWriteTime metadata (no change in file contents.) - // Repro steps - This happens when user edits a file, saves it and later decides - // to undo the edit and save the file again. - // Once in this state, the unchanged file (only its timestamp has changed) shows - // up in `git difftool` creating noise. It also shows up in `git diff --raw` command, - // (but not in `git status` or `git diff`.) - - // Change the timestamp - The lastwrite time can be close to the time this test method gets - // run. Changing (Subtracting) it to the past so there will always be a difference. + // User scenario: a checked-out file gets into a state where it differs + // from the git copy only in its LastWriteTime (no content change). + // This happens when a user edits a file, saves, undoes the edit, and saves again. + // The unchanged file then shows up in `git diff --raw` and `git difftool`. + + // Simulate the user editing and undoing: read the file, write it back unchanged. + // This hydrates the ProjFS placeholder into a full file, which is the normal + // state a user would be in before the timestamp-only scenario occurs. + // (.NET 10's File.SetLastWriteTime no longer triggers ProjFS hydration + // the way .NET Framework 4.7.1 did, so we must hydrate explicitly.) + string virtualFile = Path.Combine(this.Enlistment.RepoRoot, GitCommandsTests.EditFilePath); + string controlFile = Path.Combine(this.ControlGitRepo.RootPath, GitCommandsTests.EditFilePath); + string originalContent = File.ReadAllText(virtualFile); + File.WriteAllText(virtualFile, originalContent); + File.WriteAllText(controlFile, File.ReadAllText(controlFile)); + this.AdjustLastWriteTime(GitCommandsTests.EditFilePath, TimeSpan.FromDays(-10)); this.ValidateGitCommand("diff --raw"); this.ValidateGitCommand($"checkout {GitCommandsTests.EditFilePath}"); diff --git a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs index d7a22fa28..1b4a1f9b7 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs @@ -129,6 +129,10 @@ public virtual void TearDownForFixture() [SetUp] public virtual void SetupForTest() { + string testName = TestContext.CurrentContext.Test.FullName; + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [TEST-SETUP-START] {testName}"); + Console.Out.Flush(); + if (this.enlistmentPerTest) { this.CreateEnlistment(); @@ -151,12 +155,22 @@ public virtual void SetupForTest() } this.ValidateGitCommand("status"); + + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [TEST-SETUP-END] {testName}"); + Console.Out.Flush(); } [TearDown] public virtual void TearDownForTest() { + string testName = TestContext.CurrentContext.Test.FullName; + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [TEST-TEARDOWN-START] {testName}"); + Console.Out.Flush(); + this.TestValidationAndCleanup(); + + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [TEST-TEARDOWN-END] {testName}"); + Console.Out.Flush(); } protected void TestValidationAndCleanup(bool ignoreCase = false) diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs index de57294a0..40ee7156c 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs @@ -175,7 +175,11 @@ public void DeleteEnlistment() public void CloneAndMount(bool skipPrefetch) { + Console.Error.WriteLine("[CI-DEBUG] CloneAndMount: starting clone of " + this.RepoUrl); + Console.Error.Flush(); this.gvfsProcess.Clone(this.RepoUrl, this.Commitish, skipPrefetch); + Console.Error.WriteLine("[CI-DEBUG] CloneAndMount: clone complete, running git checkout"); + Console.Error.Flush(); GitProcess.Invoke(this.RepoRoot, "checkout " + this.Commitish); GitProcess.Invoke(this.RepoRoot, "branch --unset-upstream"); diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs index a409d5762..5d7f415d7 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs @@ -254,6 +254,7 @@ private string CallGVFS(string args, int expectedExitCode = DoNotCheckExitCode, processInfo.WindowStyle = ProcessWindowStyle.Hidden; processInfo.UseShellExecute = false; processInfo.RedirectStandardOutput = true; + processInfo.RedirectStandardError = true; if (standardInput != null) { processInfo.RedirectStandardInput = true; @@ -264,6 +265,9 @@ private string CallGVFS(string args, int expectedExitCode = DoNotCheckExitCode, processInfo.EnvironmentVariables["GIT_TRACE"] = trace; } + Console.Error.WriteLine($"[CI-DEBUG] CallGVFS: {this.pathToGVFS} {processInfo.Arguments}"); + Console.Error.Flush(); + using (Process process = Process.Start(processInfo)) { if (standardInput != null) @@ -272,9 +276,49 @@ private string CallGVFS(string args, int expectedExitCode = DoNotCheckExitCode, process.StandardInput.Close(); } - string result = process.StandardOutput.ReadToEnd(); + // Stream stderr to console in real-time + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + Console.Error.WriteLine($"[gvfs stderr] {e.Data}"); + Console.Error.Flush(); + } + }; + process.BeginErrorReadLine(); + + // Stream stdout to console and capture it + System.Text.StringBuilder outputBuilder = new System.Text.StringBuilder(); + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + outputBuilder.AppendLine(e.Data); + Console.Error.WriteLine($"[gvfs stdout] {e.Data}"); + Console.Error.Flush(); + } + }; + process.BeginOutputReadLine(); + + bool exited = process.WaitForExit(300000); // 5 minute timeout + if (!exited) + { + Console.Error.WriteLine("[CI-DEBUG] CallGVFS: TIMEOUT after 5 minutes, killing process"); + Console.Error.Flush(); + process.Kill(); + process.WaitForExit(5000); + throw new TimeoutException($"gvfs process timed out after 5 minutes. Args: {args}"); + } + + // The WaitForExit(timeout) overload does NOT wait for async + // output streams to finish reading. Call the parameterless + // overload to drain remaining stdout/stderr from the pipe. process.WaitForExit(); + string result = outputBuilder.ToString(); + Console.Error.WriteLine($"[CI-DEBUG] CallGVFS done: exit={process.ExitCode}"); + Console.Error.Flush(); + if (expectedExitCode >= SuccessExitCode) { process.ExitCode.ShouldEqual(expectedExitCode, result); diff --git a/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs b/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs index a054fe624..bc2135465 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -6,12 +7,20 @@ namespace GVFS.FunctionalTests.Tools { public static class GitProcess { + // Default: 5 minutes per git operation. Override with GVFS_FT_GIT_TIMEOUT_SECONDS. + public static int DefaultGitTimeoutMs { get; set; } = ReadGitTimeoutFromEnvironment(); + public static string Invoke(string executionWorkingDirectory, string command) { return InvokeProcess(executionWorkingDirectory, command).Output; } - public static ProcessResult InvokeProcess(string executionWorkingDirectory, string command, Dictionary environmentVariables = null, Stream inputStream = null) + public static ProcessResult InvokeProcess( + string executionWorkingDirectory, + string command, + Dictionary environmentVariables = null, + Stream inputStream = null, + int timeoutMs = -1) { ProcessStartInfo processInfo = new ProcessStartInfo(Properties.Settings.Default.PathToGit); processInfo.WorkingDirectory = executionWorkingDirectory; @@ -35,7 +44,20 @@ public static ProcessResult InvokeProcess(string executionWorkingDirectory, stri } } - return ProcessHelper.Run(processInfo, inputStream: inputStream); + int effectiveTimeout = timeoutMs > 0 ? timeoutMs : DefaultGitTimeoutMs; + return ProcessHelper.Run(processInfo, inputStream: inputStream, timeoutMs: effectiveTimeout); + } + + private static int ReadGitTimeoutFromEnvironment() + { + string envValue = Environment.GetEnvironmentVariable("GVFS_FT_GIT_TIMEOUT_SECONDS"); + if (!string.IsNullOrEmpty(envValue) && int.TryParse(envValue, out int seconds) && seconds > 0) + { + return seconds * 1000; + } + + // Default: 5 minutes per git operation + return 300_000; } } } diff --git a/GVFS/GVFS.FunctionalTests/Tools/ProcessHelper.cs b/GVFS/GVFS.FunctionalTests/Tools/ProcessHelper.cs index 539c5cc82..a6edabc3f 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/ProcessHelper.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/ProcessHelper.cs @@ -1,16 +1,25 @@ -using System.Diagnostics; +using System; +using System.Diagnostics; using System.IO; +using System.Threading.Tasks; namespace GVFS.FunctionalTests.Tools { public static class ProcessHelper { + /// + /// Default timeout in milliseconds for child processes. -1 means infinite. + /// Set via GVFS_FT_PROCESS_TIMEOUT_SECONDS environment variable (applies to all + /// ProcessHelper.Run calls) or override per-call via the timeoutMs parameter. + /// + public static int DefaultTimeoutMs { get; set; } = ReadTimeoutFromEnvironment(); + public static ProcessResult Run(string fileName, string arguments) { - return Run(fileName, arguments, null); + return Run(fileName, arguments, workingDirectory: null); } - public static ProcessResult Run(string fileName, string arguments, string workingDirectory) + public static ProcessResult Run(string fileName, string arguments, string workingDirectory, int timeoutMs = -1) { ProcessStartInfo startInfo = new ProcessStartInfo(); startInfo.UseShellExecute = false; @@ -24,11 +33,18 @@ public static ProcessResult Run(string fileName, string arguments, string workin startInfo.WorkingDirectory = workingDirectory; } - return Run(startInfo); + return Run(startInfo, timeoutMs: timeoutMs); } - public static ProcessResult Run(ProcessStartInfo processInfo, string errorMsgDelimeter = "\r\n", object executionLock = null, Stream inputStream = null) + public static ProcessResult Run( + ProcessStartInfo processInfo, + string errorMsgDelimeter = "\r\n", + object executionLock = null, + Stream inputStream = null, + int timeoutMs = -1) { + int effectiveTimeout = timeoutMs > 0 ? timeoutMs : DefaultTimeoutMs; + using (Process executingProcess = new Process()) { string output = string.Empty; @@ -50,25 +66,27 @@ public static ProcessResult Run(ProcessStartInfo processInfo, string errorMsgDel { lock (executionLock) { - output = StartProcess(executingProcess, inputStream); + output = StartProcess(executingProcess, inputStream, effectiveTimeout); } } else { - output = StartProcess(executingProcess, inputStream); + output = StartProcess(executingProcess, inputStream, effectiveTimeout); } return new ProcessResult(output.ToString(), errors.ToString(), executingProcess.ExitCode); } } - private static string StartProcess(Process executingProcess, Stream inputStream = null) + private static string StartProcess(Process executingProcess, Stream inputStream, int timeoutMs) { + Stopwatch stopwatch = Stopwatch.StartNew(); executingProcess.Start(); if (inputStream != null) { inputStream.CopyTo(executingProcess.StandardInput.BaseStream); + executingProcess.StandardInput.Close(); } if (executingProcess.StartInfo.RedirectStandardError) @@ -79,12 +97,78 @@ private static string StartProcess(Process executingProcess, Stream inputStream string output = string.Empty; if (executingProcess.StartInfo.RedirectStandardOutput) { - output = executingProcess.StandardOutput.ReadToEnd(); + if (timeoutMs > 0) + { + // Read stdout asynchronously so we can enforce a timeout on the + // entire process lifecycle. Without this, ReadToEnd() blocks + // indefinitely if the child process hangs. + Task readTask = executingProcess.StandardOutput.ReadToEndAsync(); + if (!readTask.Wait(timeoutMs)) + { + KillProcessTree(executingProcess); + string processDesc = FormatProcessDescription(executingProcess); + throw new TimeoutException( + $"Process timed out after {timeoutMs / 1000}s: {processDesc}"); + } + + output = readTask.Result; + } + else + { + output = executingProcess.StandardOutput.ReadToEnd(); + } } executingProcess.WaitForExit(); + if (timeoutMs > 0) + { + stopwatch.Stop(); + long elapsedMs = stopwatch.ElapsedMilliseconds; + if (elapsedMs > 30_000) + { + // Log slow processes to help diagnose intermittent hangs + string processDesc = FormatProcessDescription(executingProcess); + Console.WriteLine( + $"[{DateTime.Now:HH:mm:ss.fff}] [SLOW-PROCESS] {processDesc} " + + $"completed in {elapsedMs / 1000.0:F1}s (timeout: {timeoutMs / 1000}s)"); + Console.Out.Flush(); + } + } + return output; } + + private static void KillProcessTree(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [WARN] Failed to kill process tree: {ex.Message}"); + Console.Out.Flush(); + } + } + + private static string FormatProcessDescription(Process process) + { + string fileName = process.StartInfo.FileName; + string args = process.StartInfo.Arguments; + string workDir = process.StartInfo.WorkingDirectory; + return $"'{fileName} {args}' (cwd: {workDir})"; + } + + private static int ReadTimeoutFromEnvironment() + { + string envValue = Environment.GetEnvironmentVariable("GVFS_FT_PROCESS_TIMEOUT_SECONDS"); + if (!string.IsNullOrEmpty(envValue) && int.TryParse(envValue, out int seconds) && seconds > 0) + { + return seconds * 1000; + } + + return -1; + } } } diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsTombstoneTests.cs b/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsTombstoneTests.cs index f2c1b2d47..93c2ac5c2 100644 --- a/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsTombstoneTests.cs +++ b/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsTombstoneTests.cs @@ -4,8 +4,10 @@ using GVFS.Tests.Should; using NUnit.Framework; using System; +using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { @@ -15,6 +17,8 @@ public class WindowsTombstoneTests : TestsWithEnlistmentPerFixture { private const string Delimiter = "\r\n"; private const int TombstoneFolderPlaceholderType = 3; + private const int MaxFileAccessRetries = 10; + private const int FileAccessRetryDelayMs = 500; private FileSystemRunner fileSystem; public WindowsTombstoneTests() @@ -30,30 +34,162 @@ public void CheckoutCleansUpTombstones() // Delete directory to create the tombstone string directoryToDelete = this.Enlistment.GetVirtualPathTo(folderToDelete); this.fileSystem.DeleteDirectory(directoryToDelete); + + DiagLog("Unmounting GVFS (first unmount)..."); + Stopwatch sw = Stopwatch.StartNew(); this.Enlistment.UnmountGVFS(); + sw.Stop(); + DiagLog($"Unmount completed in {sw.ElapsedMilliseconds}ms"); // Remove the directory entry from modified paths so git will not keep the folder up to date string modifiedPathsFile = Path.Combine(this.Enlistment.DotGVFSRoot, TestConstants.Databases.ModifiedPaths); - string modifiedPathsContent = this.fileSystem.ReadAllText(modifiedPathsFile); + + DiagLog($"ModifiedPaths path: {modifiedPathsFile}"); + DiagLog($"ModifiedPaths exists: {File.Exists(modifiedPathsFile)}"); + if (File.Exists(modifiedPathsFile)) + { + FileInfo fi = new FileInfo(modifiedPathsFile); + DiagLog($"ModifiedPaths size: {fi.Length} bytes, lastWrite: {fi.LastWriteTimeUtc:O}"); + } + + string modifiedPathsContent = ReadFileWithRetry(modifiedPathsFile); + DiagLog($"ModifiedPaths read OK, length: {modifiedPathsContent.Length} chars, lines: {modifiedPathsContent.Split(new[] { Delimiter }, StringSplitOptions.RemoveEmptyEntries).Length}"); + modifiedPathsContent = string.Join(Delimiter, modifiedPathsContent.Split(new[] { Delimiter }, StringSplitOptions.RemoveEmptyEntries).Where(x => !x.StartsWith($"A {folderToDelete}/"))); - this.fileSystem.WriteAllText(modifiedPathsFile, modifiedPathsContent + Delimiter); + string contentToWrite = modifiedPathsContent + Delimiter; + DiagLog($"ModifiedPaths writing {contentToWrite.Length} chars..."); + WriteFileWithRetry(modifiedPathsFile, contentToWrite); + DiagLog("ModifiedPaths write OK"); + + // Verify file was written correctly + string verifyContent = ReadFileWithRetry(modifiedPathsFile); + DiagLog($"ModifiedPaths verify read: {verifyContent.Length} chars, match: {verifyContent == contentToWrite}"); // Add tombstone folder entry to the placeholder database so the checkout will remove the tombstone // and start projecting the folder again string placeholderDatabasePath = Path.Combine(this.Enlistment.DotGVFSRoot, TestConstants.Databases.VFSForGit); + DiagLog($"Placeholder DB path: {placeholderDatabasePath}, exists: {File.Exists(placeholderDatabasePath)}"); GVFSHelpers.AddPlaceholderFolder(placeholderDatabasePath, folderToDelete, TombstoneFolderPlaceholderType); + DiagLog("Placeholder folder entry added"); + + DiagLog("Mounting GVFS (after ModifiedPaths edit)..."); + sw.Restart(); + + string mountOutput; + bool mountSucceeded = this.Enlistment.TryMountGVFS(out mountOutput); + sw.Stop(); + DiagLog($"Mount returned in {sw.ElapsedMilliseconds}ms, success: {mountSucceeded}"); + if (!mountSucceeded) + { + // Dump diagnostics before failing + DiagLog($"Mount output: {mountOutput}"); + DiagLog($"ModifiedPaths after failed mount exists: {File.Exists(modifiedPathsFile)}"); + if (File.Exists(modifiedPathsFile)) + { + try + { + string postMountContent = File.ReadAllText(modifiedPathsFile); + DiagLog($"ModifiedPaths content after failed mount ({postMountContent.Length} chars):"); + DiagLog(postMountContent); + } + catch (Exception ex) + { + DiagLog($"Could not read ModifiedPaths after failed mount: {ex.GetType().Name}: {ex.Message}"); + } + } + + // Dump GVFS logs + string gvfsLogsDir = Path.Combine(this.Enlistment.DotGVFSRoot, "logs"); + if (Directory.Exists(gvfsLogsDir)) + { + string[] logFiles = Directory.GetFiles(gvfsLogsDir, "*.log", SearchOption.TopDirectoryOnly); + DiagLog($"GVFS log files ({logFiles.Length}):"); + foreach (string logFile in logFiles) + { + DiagLog($" {Path.GetFileName(logFile)}"); + } + + // Dump tail of most recent mount log + string[] mountLogs = Directory.GetFiles(gvfsLogsDir, "mount_*", SearchOption.TopDirectoryOnly); + if (mountLogs.Length > 0) + { + string latestMountLog = mountLogs.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc).First(); + try + { + string[] mountLogLines = File.ReadAllLines(latestMountLog); + int tailCount = Math.Min(50, mountLogLines.Length); + DiagLog($"Last {tailCount} lines of {Path.GetFileName(latestMountLog)}:"); + foreach (string line in mountLogLines.Skip(mountLogLines.Length - tailCount)) + { + DiagLog($" {line}"); + } + } + catch (Exception ex) + { + DiagLog($"Could not read mount log: {ex.GetType().Name}: {ex.Message}"); + } + } + } + + Assert.Fail($"GVFS did not mount: {mountOutput}"); + } - this.Enlistment.MountGVFS(); directoryToDelete.ShouldNotExistOnDisk(this.fileSystem); // checkout branch to remove tombstones and project the folder again GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, "checkout -f HEAD"); directoryToDelete.ShouldBeADirectory(this.fileSystem); + DiagLog("Unmounting GVFS (final unmount)..."); this.Enlistment.UnmountGVFS(); + DiagLog("Final unmount completed"); string placholders = GVFSHelpers.GetAllSQLitePlaceholdersAsString(placeholderDatabasePath); placholders.ShouldNotContain(ignoreCase: false, unexpectedSubstrings: $"{folderToDelete}{GVFSHelpers.PlaceholderFieldDelimiter}{TombstoneFolderPlaceholderType}{GVFSHelpers.PlaceholderFieldDelimiter}"); } + + private static void DiagLog(string message) + { + Console.Error.WriteLine($"[TOMBSTONE-DIAG] {DateTime.UtcNow:O} {message}"); + } + + private static string ReadFileWithRetry(string path) + { + for (int attempt = 1; attempt <= MaxFileAccessRetries; attempt++) + { + try + { + return File.ReadAllText(path); + } + catch (IOException ex) when (attempt < MaxFileAccessRetries) + { + DiagLog($"ReadFile attempt {attempt}/{MaxFileAccessRetries} failed: {ex.GetType().Name}: {ex.Message}"); + Thread.Sleep(FileAccessRetryDelayMs); + } + } + + // Final attempt — let it throw + return File.ReadAllText(path); + } + + private static void WriteFileWithRetry(string path, string content) + { + for (int attempt = 1; attempt <= MaxFileAccessRetries; attempt++) + { + try + { + File.WriteAllText(path, content); + return; + } + catch (IOException ex) when (attempt < MaxFileAccessRetries) + { + DiagLog($"WriteFile attempt {attempt}/{MaxFileAccessRetries} failed: {ex.GetType().Name}: {ex.Message}"); + Thread.Sleep(FileAccessRetryDelayMs); + } + } + + // Final attempt — let it throw + File.WriteAllText(path, content); + } } } From 1731fcaebb765fbccf7be59c5cc0fef85a3f22b8 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 6 May 2026 12:25:06 -0700 Subject: [PATCH 19/25] Fix flaky WriteWithoutClose/CreateFileWithoutClose tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The StatusTests WriteWithoutClose and CreateFileWithoutClose tests intentionally open file handles without closing them, then validate git status behavior. The handles were created as local variables inside OpenFileAndWriteWithoutClose/CreateFileWithoutClose with no reference kept — making them eligible for GC immediately. If the GC collected the handles before TearDown ran git reset --hard, the reset would succeed (no lock errors) while the control repo might still have errors, causing a flaky mismatch. Fix: return IDisposable from the file-handle-leaking methods, hold them in using blocks in the test methods. Handles stay alive for the assertion scope and are deterministically disposed before TearDown runs, eliminating the GC race. Also add StreamWriter.Flush() to ensure written data is on disk before returning. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .../FileSystemRunners/BashRunner.cs | 4 +- .../FileSystemRunners/CmdRunner.cs | 10 ++--- .../FileSystemRunners/FileSystemRunner.cs | 4 +- .../FileSystemRunners/PowerShellRunner.cs | 9 ++-- .../FileSystemRunners/SystemIORunner.cs | 12 +++--- .../Tests/GitCommands/GitRepoTests.cs | 42 +++++++++++++++---- .../Tests/GitCommands/StatusTests.cs | 12 ++++-- 7 files changed, 62 insertions(+), 31 deletions(-) diff --git a/GVFS/GVFS.FunctionalTests/FileSystemRunners/BashRunner.cs b/GVFS/GVFS.FunctionalTests/FileSystemRunners/BashRunner.cs index e0d75f013..781a96572 100644 --- a/GVFS/GVFS.FunctionalTests/FileSystemRunners/BashRunner.cs +++ b/GVFS/GVFS.FunctionalTests/FileSystemRunners/BashRunner.cs @@ -304,12 +304,12 @@ public override long FileSize(string path) return long.Parse(this.RunProcess(statCommand)); } - public override void CreateFileWithoutClose(string path) + public override IDisposable CreateFileWithoutClose(string path) { throw new NotImplementedException(); } - public override void OpenFileAndWriteWithoutClose(string path, string data) + public override IDisposable OpenFileAndWriteWithoutClose(string path, string data) { throw new NotImplementedException(); } diff --git a/GVFS/GVFS.FunctionalTests/FileSystemRunners/CmdRunner.cs b/GVFS/GVFS.FunctionalTests/FileSystemRunners/CmdRunner.cs index 1fe346f1c..baef1a58a 100644 --- a/GVFS/GVFS.FunctionalTests/FileSystemRunners/CmdRunner.cs +++ b/GVFS/GVFS.FunctionalTests/FileSystemRunners/CmdRunner.cs @@ -238,12 +238,12 @@ public override void ChangeMode(string path, ushort mode) throw new NotSupportedException(); } - public override void CreateFileWithoutClose(string path) - { + public override IDisposable CreateFileWithoutClose(string path) + { throw new NotImplementedException(); - } - - public override void OpenFileAndWriteWithoutClose(string path, string data) + } + + public override IDisposable OpenFileAndWriteWithoutClose(string path, string data) { throw new NotImplementedException(); } diff --git a/GVFS/GVFS.FunctionalTests/FileSystemRunners/FileSystemRunner.cs b/GVFS/GVFS.FunctionalTests/FileSystemRunners/FileSystemRunner.cs index 07f7b983e..a862497da 100644 --- a/GVFS/GVFS.FunctionalTests/FileSystemRunners/FileSystemRunner.cs +++ b/GVFS/GVFS.FunctionalTests/FileSystemRunners/FileSystemRunner.cs @@ -76,8 +76,8 @@ public static FileSystemRunner DefaultRunner /// Path to file /// File contents public abstract void WriteAllText(string path, string contents); - public abstract void CreateFileWithoutClose(string path); - public abstract void OpenFileAndWriteWithoutClose(string path, string data); + public abstract IDisposable CreateFileWithoutClose(string path); + public abstract IDisposable OpenFileAndWriteWithoutClose(string path, string data); /// /// Append the specified contents to the specified file. By calling this method the caller is diff --git a/GVFS/GVFS.FunctionalTests/FileSystemRunners/PowerShellRunner.cs b/GVFS/GVFS.FunctionalTests/FileSystemRunners/PowerShellRunner.cs index e9601cf93..6e9183e97 100644 --- a/GVFS/GVFS.FunctionalTests/FileSystemRunners/PowerShellRunner.cs +++ b/GVFS/GVFS.FunctionalTests/FileSystemRunners/PowerShellRunner.cs @@ -1,4 +1,5 @@ using GVFS.Tests.Should; +using System; using System.IO; namespace GVFS.FunctionalTests.FileSystemRunners @@ -217,12 +218,12 @@ public override void ChangeMode(string path, ushort mode) throw new System.NotSupportedException(); } - public override void CreateFileWithoutClose(string path) + public override IDisposable CreateFileWithoutClose(string path) { throw new System.NotSupportedException(); - } - - public override void OpenFileAndWriteWithoutClose(string path, string data) + } + + public override IDisposable OpenFileAndWriteWithoutClose(string path, string data) { throw new System.NotSupportedException(); } diff --git a/GVFS/GVFS.FunctionalTests/FileSystemRunners/SystemIORunner.cs b/GVFS/GVFS.FunctionalTests/FileSystemRunners/SystemIORunner.cs index 2b0e4ac61..e8c7f8f98 100644 --- a/GVFS/GVFS.FunctionalTests/FileSystemRunners/SystemIORunner.cs +++ b/GVFS/GVFS.FunctionalTests/FileSystemRunners/SystemIORunner.cs @@ -22,15 +22,17 @@ public override string MoveFile(string sourcePath, string targetPath) return string.Empty; } - public override void CreateFileWithoutClose(string path) + public override IDisposable CreateFileWithoutClose(string path) { - File.Create(path); - } - - public override void OpenFileAndWriteWithoutClose(string path, string content) + return File.Create(path); + } + + public override IDisposable OpenFileAndWriteWithoutClose(string path, string content) { StreamWriter file = new StreamWriter(path); file.Write(content); + file.Flush(); + return file; } public override void MoveFileShouldFail(string sourcePath, string targetPath) diff --git a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs index 1b4a1f9b7..64aa7a668 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs @@ -326,22 +326,24 @@ protected void CreateFile(string content, params string[] filePathPaths) this.FileSystem.WriteAllText(controlFile, content); } - protected void CreateFileWithoutClose(string path) - { + protected IDisposable CreateFileWithoutClose(string path) + { string virtualFile = Path.Combine(this.Enlistment.RepoRoot, path); string controlFile = Path.Combine(this.ControlGitRepo.RootPath, path); - this.FileSystem.CreateFileWithoutClose(virtualFile); - this.FileSystem.CreateFileWithoutClose(controlFile); - } - - protected void ReadFileAndWriteWithoutClose(string path, string contents) + IDisposable virtualHandle = this.FileSystem.CreateFileWithoutClose(virtualFile); + IDisposable controlHandle = this.FileSystem.CreateFileWithoutClose(controlFile); + return new CompositeDisposable(virtualHandle, controlHandle); + } + + protected IDisposable ReadFileAndWriteWithoutClose(string path, string contents) { string virtualFile = Path.Combine(this.Enlistment.RepoRoot, path); string controlFile = Path.Combine(this.ControlGitRepo.RootPath, path); this.FileSystem.ReadAllText(virtualFile); this.FileSystem.ReadAllText(controlFile); - this.FileSystem.OpenFileAndWriteWithoutClose(virtualFile, contents); - this.FileSystem.OpenFileAndWriteWithoutClose(controlFile, contents); + IDisposable virtualHandle = this.FileSystem.OpenFileAndWriteWithoutClose(virtualFile, contents); + IDisposable controlHandle = this.FileSystem.OpenFileAndWriteWithoutClose(controlFile, contents); + return new CompositeDisposable(virtualHandle, controlHandle); } protected void CreateFolder(string folderPath) @@ -667,5 +669,27 @@ protected void FilesShouldMatchAfterConflict() this.FileContentsShouldMatch("Test_ConflictTests", "ModifiedFiles", "SameChange.txt"); this.FileContentsShouldMatch("Test_ConflictTests", "ModifiedFiles", "SuccessfulMerge.txt"); } + + /// + /// Disposes multiple objects as a single unit. + /// Used to hold file handles open for the duration of a test scope. + /// + protected sealed class CompositeDisposable : IDisposable + { + private readonly IDisposable[] disposables; + + public CompositeDisposable(params IDisposable[] disposables) + { + this.disposables = disposables; + } + + public void Dispose() + { + foreach (IDisposable disposable in this.disposables) + { + disposable?.Dispose(); + } + } + } } } diff --git a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/StatusTests.cs b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/StatusTests.cs index 65edacc6d..6cf5a78b3 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/StatusTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/StatusTests.cs @@ -46,16 +46,20 @@ public void DeleteThenCreateThenDeleteFile() public void CreateFileWithoutClose() { string srcPath = @"CreateFileWithoutClose.md"; - this.CreateFileWithoutClose(srcPath); - this.ValidGitStatusWithRetry(srcPath); + using (IDisposable handles = this.CreateFileWithoutClose(srcPath)) + { + this.ValidGitStatusWithRetry(srcPath); + } } [TestCase] public void WriteWithoutClose() { string srcPath = @"Readme.md"; - this.ReadFileAndWriteWithoutClose(srcPath, "More Stuff"); - this.ValidGitStatusWithRetry(srcPath); + using (IDisposable handles = this.ReadFileAndWriteWithoutClose(srcPath, "More Stuff")) + { + this.ValidGitStatusWithRetry(srcPath); + } } [TestCase] From ab0ac7f0dda42aa9f70d59cc66ff0e686ad00eb0 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 6 May 2026 10:23:35 -0700 Subject: [PATCH 20/25] Installer: non-disruptive upgrade with staging mode Add staged upgrade mode to the installer, activated by /KEEPMOUNTED=true in silent installs or via dialog in interactive mode. Without this flag, the installer behaves as before. When staging: - Most files go to {app}\PendingUpgrade\ instead of replacing in-place - GVFS.Service.exe is replaced directly (brief stop/start) - Mount processes continue running on old binaries throughout - .ready marker written after all files staged (guards against partial) When not staging (clean upgrade): - CloseApplications=no prevents Restart Manager from killing processes - Force-kill GVFS processes if unmount-all fails to clean up - WaitForServiceProcessToExit polls sc query after sc stop/delete Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Installers/Setup.iss | 266 +++++++++++++++++++++++++++------ 1 file changed, 220 insertions(+), 46 deletions(-) diff --git a/GVFS/GVFS.Installers/Setup.iss b/GVFS/GVFS.Installers/Setup.iss index c9e00b6e0..8ba43a32d 100644 --- a/GVFS/GVFS.Installers/Setup.iss +++ b/GVFS/GVFS.Installers/Setup.iss @@ -42,7 +42,7 @@ ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesAllowed=x64compatible WizardImageStretch=no WindowResizable=no -CloseApplications=yes +CloseApplications=no ChangesEnvironment=yes RestartIfNeededByRun=yes @@ -59,8 +59,14 @@ Name: "full"; Description: "Full installation"; Flags: iscustom; Type: files; Name: "{app}\ucrtbase.dll" [Files] -DestDir: "{app}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*" -DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; AfterInstall: InstallGVFSService +; Normal install: all files go to {app}, service gets AfterInstall callback +DestDir: "{app}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsNormalInstall +DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; AfterInstall: InstallGVFSService; Check: IsNormalInstall +; Staging install: most files go to {app}\PendingUpgrade, but GVFS.Service.exe +; goes directly to {app} so the restarted service has PendingUpgradeHandler code. +; The service is briefly stopped/restarted (mounts are independent processes). +DestDir: "{app}\PendingUpgrade"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsStagingInstall +DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; AfterInstall: StagingUpdateService; Check: IsStagingInstall [Dirs] Name: "{app}\ProgramData\{#ServiceName}"; Permissions: users-readexec @@ -84,6 +90,17 @@ Root: HKLM; SubKey: "{#GvFltAutologgerKey}"; Flags: deletekey [Code] var ExitCode: Integer; + KeepMountsRunning: Boolean; + +function IsNormalInstall(): Boolean; +begin + Result := not KeepMountsRunning; +end; + +function IsStagingInstall(): Boolean; +begin + Result := KeepMountsRunning; +end; function NeedsAddPath(Param: string): boolean; var @@ -156,6 +173,55 @@ begin end; end; +procedure WaitForServiceProcessToExit(ServiceName: string); +var + ResultCode: integer; + Attempts: integer; + TempFile: string; + QueryOutput: ansiString; +begin + // sc stop/delete returns before the service process actually exits. + // Poll sc query until the service is fully gone (1060) or stopped. + Attempts := 0; + TempFile := ExpandConstant('{tmp}\~scquery.txt'); + while Attempts < 30 do + begin + if Exec(ExpandConstant('{cmd}'), '/C "' + ExpandConstant('{sys}\SC.EXE') + '" query ' + ServiceName + ' > "' + TempFile + '" 2>&1', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + // 1060 = service does not exist (fully deleted and process exited) + if ResultCode = 1060 then + begin + Log('WaitForServiceProcessToExit: Service no longer exists'); + break; + end; + if LoadStringFromFile(TempFile, QueryOutput) then + begin + if Pos('STOPPED', QueryOutput) > 0 then + begin + Log('WaitForServiceProcessToExit: Service is stopped'); + break; + end; + end; + end + else + begin + Log('WaitForServiceProcessToExit: sc query failed, assuming service is gone'); + break; + end; + Attempts := Attempts + 1; + Log('WaitForServiceProcessToExit: Waiting for service to stop (attempt ' + IntToStr(Attempts) + ')'); + Sleep(1000); + end; + if Attempts >= 30 then + begin + if LoadStringFromFile(TempFile, QueryOutput) then + Log('WaitForServiceProcessToExit: Timed out. Last sc query output: ' + QueryOutput) + else + Log('WaitForServiceProcessToExit: Timed out waiting for service to stop'); + end; + DeleteFile(TempFile); +end; + procedure UninstallService(ServiceName: string; ShowProgress: boolean); var ResultCode: integer; @@ -178,6 +244,8 @@ begin RaiseException('Fatal: Could not uninstall service: ' + ServiceName); end; + WaitForServiceProcessToExit(ServiceName); + if (ShowProgress) then begin WizardForm.StatusLabel.Caption := 'Waiting for pending ' + ServiceName + ' deletion to complete. This may take a while.'; @@ -245,6 +313,33 @@ begin end; end; +procedure StagingUpdateService(); +var + ResultCode: integer; + StatusText: string; +begin + // In staging mode: the service was stopped in PrepareToInstall so its exe + // could be replaced. Now start it with the new binary. The new service has + // PendingUpgradeHandler which will complete the upgrade on next restart + // when no mounts are running. + StatusText := WizardForm.StatusLabel.Caption; + WizardForm.StatusLabel.Caption := 'Starting GVFS.Service.'; + WizardForm.ProgressGauge.Style := npbstMarquee; + + try + Log('StagingUpdateService: Starting service with new binary'); + if not Exec(ExpandConstant('{sys}\SC.EXE'), 'start GVFS.Service', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + Log('StagingUpdateService: Warning - could not start service: ' + SysErrorMessage(ResultCode)); + end; + + WriteOnDiskVersion16CapableFile(); + finally + WizardForm.StatusLabel.Caption := StatusText; + WizardForm.ProgressGauge.Style := npbstNormal; + end; +end; + function DeleteFileIfItExists(FilePath: string) : Boolean; begin Result := False; @@ -485,39 +580,6 @@ begin MigrateFile(CommonAppDataDir + '\{#ServiceName}\{#GVFSStatuscacheTokenFileName}', SecureAppDataDir + '\{#ServiceName}\{#GVFSStatuscacheTokenFileName}'); end; -function ConfirmUnmountAll(): Boolean; -var - MsgBoxResult: integer; - Repos: ansiString; - ResultCode: integer; - MsgBoxText: string; -begin - Result := False; - if ExecWithResult('gvfs.exe', 'service --list-mounted', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Repos) then - begin - if Repos = '' then - begin - Result := False; - end - else - begin - if ResultCode = 0 then - begin - MsgBoxText := 'The following repos are currently mounted:' + #13#10 + Repos + #13#10 + 'Setup needs to unmount all repos before it can proceed, and those repos will be unavailable while setup is running. Do you want to continue?'; - MsgBoxResult := SuppressibleMsgBox(MsgBoxText, mbConfirmation, MB_OKCANCEL, IDOK); - if (MsgBoxResult = IDOK) then - begin - Result := True; - end - else - begin - Abort(); - end; - end; - end; - end; -end; - function EnsureGvfsNotRunning(): Boolean; var MsgBoxResult: integer; @@ -647,12 +709,21 @@ begin case CurStep of ssInstall: begin - UninstallService('GVFS.Service', True); + if not KeepMountsRunning then + UninstallService('GVFS.Service', True); end; ssPostInstall: begin + if KeepMountsRunning then + begin + // All staged files have been written to PendingUpgrade. + // Write .ready marker so the service knows the staging is + // complete and safe to apply. + SaveStringToFile(ExpandConstant('{app}\PendingUpgrade\.ready'), '', False); + Log('CurStepChanged: Wrote PendingUpgrade .ready marker'); + end; MigrateConfigAndStatusCacheFiles(); - if ExpandConstant('{param:REMOUNTREPOS|true}') = 'true' then + if (not KeepMountsRunning) and (ExpandConstant('{param:REMOUNTREPOS|true}') = 'true') then begin MountRepos(); end @@ -677,22 +748,125 @@ begin end; function PrepareToInstall(var NeedsRestart: Boolean): String; +var + MsgBoxResult: integer; + Repos: ansiString; + ResultCode: integer; + HasMounts: Boolean; begin NeedsRestart := False; + KeepMountsRunning := False; Result := ''; SetNuGetFeedIfNecessary(); - if ConfirmUnmountAll() then + + // Check for mounted repos by querying the service, and also check for + // running GVFS processes (a mount can be running without being registered + // in the service's repo-registry, e.g., after a reinstall). + HasMounts := False; + if ExecWithResult('gvfs.exe', 'service --list-mounted', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Repos) then + begin + if (ResultCode = 0) and (Repos <> '') then + HasMounts := True; + end; + if (not HasMounts) and IsGVFSRunning() then begin - if ExpandConstant('{param:REMOUNTREPOS|true}') = 'true' then + HasMounts := True; + Repos := '(GVFS processes detected)'; + Log('PrepareToInstall: No registered mounts but GVFS processes are running'); + end; + + if HasMounts then + begin + if WizardSilent() then begin - UnmountRepos(); + // Silent mode: STAGEIFMOUNTED=true stages files instead of unmounting. + // Default: false (clean upgrade, matching pre-existing behavior). + KeepMountsRunning := ExpandConstant('{param:STAGEIFMOUNTED|false}') = 'true'; + if KeepMountsRunning then + Log('PrepareToInstall: Silent mode with mounted repos, KeepMountsRunning=True') + else + Log('PrepareToInstall: Silent mode with mounted repos, KeepMountsRunning=False'); end + else + begin + // Interactive mode: let user choose + MsgBoxResult := SuppressibleMsgBox( + 'The following repos are currently mounted:' + #13#10 + Repos + #13#10#13#10 + + 'Click Yes to keep repos mounted during the upgrade.' + #13#10 + + 'The upgrade will complete automatically when all repos are unmounted.' + #13#10#13#10 + + 'Click No to unmount all repos now and upgrade without restart.' + #13#10 + + 'Repos will be temporarily unavailable during the upgrade.', + mbConfirmation, MB_YESNOCANCEL, IDYES); + if MsgBoxResult = IDYES then + KeepMountsRunning := True + else if MsgBoxResult = IDNO then + KeepMountsRunning := False + else + begin + Result := 'Installation cancelled.'; + exit; + end; + end; end; - if not EnsureGvfsNotRunning() then + + if KeepMountsRunning then begin - Abort(); + // Staging mode: most files go to {app}\PendingUpgrade\ via [Files] entries + // with Check: IsStagingInstall. GVFS.Service.exe goes directly to {app}. + // Clean up any leftover staging dirs from a prior attempt first, + // so we don't mix files from different upgrade versions. + if DirExists(ExpandConstant('{app}\PendingUpgrade')) then + begin + Log('PrepareToInstall: Removing stale PendingUpgrade from prior staging attempt'); + DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True); + end; + if DirExists(ExpandConstant('{app}\PreviousVersion')) then + begin + Log('PrepareToInstall: Removing stale PreviousVersion from prior staging attempt'); + DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True); + end; + // Stop the service now so its exe is unlocked for replacement. + // Mounts are independent processes and unaffected. + Log('PrepareToInstall: Staging mode. Stopping service for exe replacement.'); + StopService('GVFS.Service'); + WaitForServiceProcessToExit('GVFS.Service'); + end + else + begin + // Clean upgrade: unmount, stop everything, replace files directly. + // Remove any leftover PendingUpgrade or PreviousVersion from a + // previous staging install so stale files don't interfere with + // the fresh install. + if DirExists(ExpandConstant('{app}\PendingUpgrade')) then + begin + Log('PrepareToInstall: Removing leftover PendingUpgrade directory'); + DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True); + end; + if DirExists(ExpandConstant('{app}\PreviousVersion')) then + begin + Log('PrepareToInstall: Removing leftover PreviousVersion directory'); + DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True); + end; + if HasMounts then + begin + UnmountRepos(); + end; + // With CloseApplications=no, Restart Manager won't kill GVFS + // processes. If unmount-all didn't clean up everything (e.g. + // registry was empty), force-kill remaining processes since + // the user already consented to a full upgrade. + if IsGVFSRunning() then + begin + Log('PrepareToInstall: GVFS processes still running after unmount, force-killing'); + Exec('powershell.exe', '-NoProfile "Get-Process gvfs,gvfs.mount -ErrorAction SilentlyContinue | Stop-Process -Force"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(2000); + end; + if not EnsureGvfsNotRunning() then + begin + Abort(); + end; + StopService('GVFS.Service'); + UninstallGvFlt(); + UninstallProjFSIfNecessary(); end; - StopService('GVFS.Service'); - UninstallGvFlt(); - UninstallProjFSIfNecessary(); end; From eb364fb290d71388cdda96eb1b5e4784344c28e4 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 7 May 2026 15:54:52 +0100 Subject: [PATCH 21/25] release.yml: update the AzDO release pipeline YAML Previously the .azure-pipelines/release.yml file was not being used for release builds, but instead a private repository containing the real YAML (in Azure Repos) was being used. Let's bring the actual pipeline YAML into the main repo on GitHub. This is what we do in other projects like microsoft/git and git-ecosystem/git-credential-manager, and allows us to keep build changes in sync between GH and AzDO. Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 178 ++++++++++++++++++----------------- 1 file changed, 90 insertions(+), 88 deletions(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index f754ce9d1..3b4a64df4 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -1,93 +1,95 @@ -# NOTE: this pipeline definition is not currently used to build releases of VFS for Git. -# This is still done in the GVFS-Release-RealSign "classic" pipeline. - name: $(date:yy)$(DayOfYear)$(rev:.r) +trigger: none +pr: none variables: - signType: test - teamName: GVFS - configuration: Release - signPool: VSEng-MicroBuildVS2019 GVFSMajorAndMinorVersion: 1.0 GVFSRevision: $(Build.BuildNumber) - -jobs: -- job: build - displayName: Windows Build and Sign - - pool: - name: $(signPool) - - steps: - - task: ms-vseng.MicroBuildTasks.30666190-6959-11e5-9f96-f56098202fef.MicroBuildSigningPlugin@2 - displayName: Install signing plugin - inputs: - signType: '$(SignType)' - - - task: UseDotNet@2 - displayName: Install .NET SDK - inputs: - packageType: sdk - version: 8.0.413 - - - task: CmdLine@2 - displayName: Build VFS for Git - inputs: - script: $(Build.Repository.LocalPath)\scripts\Build.bat $(configuration) $(GVFSMajorAndMinorVersion).$(GVFSRevision) detailed - - - task: CmdLine@2 - displayName: Run unit tests - inputs: - script: $(Build.Repository.LocalPath)\scripts\RunUnitTests.bat $(configuration) - - - task: CmdLine@2 - displayName: Create build artifacts - inputs: - script: $(Build.Repository.LocalPath)\scripts\CreateBuildArtifacts.bat $(configuration) $(Build.ArtifactStagingDirectory) - - - task: PublishBuildArtifacts@1 - displayName: 'Publish Artifact: Installer' - inputs: - PathtoPublish: $(Build.ArtifactStagingDirectory)\NuGetPackages - ArtifactName: Installer - - - task: PublishBuildArtifacts@1 - displayName: 'Publish Artifact: FastFetch' - inputs: - PathtoPublish: $(Build.ArtifactStagingDirectory)\FastFetch - ArtifactName: FastFetch - - - task: PublishSymbols@1 - displayName: Enable Source Server - condition: eq(succeeded(), eq(variables['signType'], 'real')) - inputs: - SearchPattern: '**\*.pdb' - SymbolsFolder: $(Build.ArtifactStagingDirectory)\Symbols - - - task: PublishBuildArtifacts@1 - displayName: 'Publish Artifact: Symbols' - inputs: - PathtoPublish: $(Build.ArtifactStagingDirectory)\Symbols - ArtifactName: Symbols - - - task: ms-vscs-artifact.build-tasks.artifactSymbolTask-1.artifactSymbolTask@0 - displayName: Publish to Symbols on Symweb - condition: eq(succeeded(), eq(variables['signType'], 'real')) - inputs: - symbolServiceURI: https://microsoft.artifacts.visualstudio.com/DefaultCollection - sourcePath: $(Build.ArtifactStagingDirectory)/Symbols - expirationInDays: 2065 - usePat: false - - - task: NuGetCommand@2 - displayName: Push GVFS.Installers package - condition: eq(succeeded(), eq(variables['signType'], 'real')) - inputs: - command: push - packagesToPush: $(Build.ArtifactStagingDirectory)\NuGetPackages\GVFS.Installers.*.nupkg - nuGetFeedType: external - publishFeedCredentials: '1essharedassets GVFS [PUBLISH]' - - - task: ms-vseng.MicroBuildTasks.521a94ea-9e68-468a-8167-6dcf361ea776.MicroBuildCleanup@1 - displayName: Send MicroBuild Telemetry - condition: always() + BuildConfiguration: Release + TeamName: GVFS + +resources: + repositories: + - repository: MicroBuildTemplate + type: git + name: 1ESPipelineTemplates/MicroBuildTemplate + ref: refs/tags/release + + - repository: VFSForGit + type: github + name: microsoft/VFSForGit + ref: releases/shipped + endpoint: GitHub-VFSForGit + +extends: + template: azure-pipelines/MicroBuild.1ES.Official.yml@MicroBuildTemplate + parameters: + pool: + name: VSEngSS-MicroBuild2022-1ES + + featureFlags: + incrementalSDLBinaryAnalysis: false + disableNetworkIsolation: true + + sdl: + binskim: + enabled: false + justificationForDisabling: "Guardian and BinSkim do not support a suppression for InnoSetup installer file" + sourceRepositoriesToScan: + include: + - repository: VFSForGit + + stages: + - stage: Release + + jobs: + - job: Build + templateContext: + mb: + signing: + enabled: true + feedSource: 'https://pkgs.dev.azure.com/mseng/_packaging/MicroBuildToolset/nuget/v3/index.json' + signType: real + signWithProd: true + + outputs: + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)\GVFS.Installers + artifactName: Installer + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)\FastFetch + artifactName: FastFetch + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)\Symbols + artifactName: Symbols + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)\GVFS.FunctionalTests + artifactName: FunctionalTests + + steps: + - checkout: VFSForGit + displayName: 'Checkout VFS for Git' + path: vfsforgit\src + + - task: NuGetToolInstaller@1 + displayName: 'Use NuGet 6.x' + inputs: + versionSpec: '6.x' + + - script: | + $(Agent.BuildDirectory)\vfsforgit\src\scripts\Build.bat ^ + $(BuildConfiguration) ^ + $(GVFSMajorAndMinorVersion).$(GVFSRevision) ^ + detailed + displayName: 'Build and sign ($(BuildConfiguration))' + + - script: | + $(Agent.BuildDirectory)\vfsforgit\src\scripts\RunUnitTests.bat ^ + $(BuildConfiguration) + displayName: 'Run unit tests' + + - script: | + $(Agent.BuildDirectory)\vfsforgit\src\scripts\CreateBuildArtifacts.bat ^ + $(BuildConfiguration) ^ + $(Build.ArtifactStagingDirectory) + displayName: 'Create artifacts' From bd3f22abcf366ce5ffd444d9ac19a0e5555a3fa1 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 6 May 2026 10:23:49 -0700 Subject: [PATCH 22/25] Service: complete staged upgrade after unmount or restart Add PendingUpgradeHandler to apply staged upgrades using atomic file moves (old files to PreviousVersion, staged files to install dir). Skips GVFS.Service.exe (already replaced by installer, locked by running service). Safety mechanisms: - .ready marker: rejects PendingUpgrade if installer was interrupted - .phase1-complete marker: ensures crash during backup is recoverable (incomplete Phase 1 is restored and retried, not skipped) - Defers if any GVFS.Mount processes are still running Trigger upgrade in two ways: - On service start: checks before automount - After repo unmount: timer-based debounce (5s) so multiple unmounts in quick succession result in a single upgrade attempt Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Service/GVFSService.Windows.cs | 6 + GVFS/GVFS.Service/Handlers/RequestHandler.cs | 42 ++ GVFS/GVFS.Service/PendingUpgradeHandler.cs | 443 +++++++++++++++++++ 3 files changed, 491 insertions(+) create mode 100644 GVFS/GVFS.Service/PendingUpgradeHandler.cs diff --git a/GVFS/GVFS.Service/GVFSService.Windows.cs b/GVFS/GVFS.Service/GVFSService.Windows.cs index c9af2b925..bedfa9a7e 100644 --- a/GVFS/GVFS.Service/GVFSService.Windows.cs +++ b/GVFS/GVFS.Service/GVFSService.Windows.cs @@ -43,6 +43,12 @@ public void Run() metadata.Add("Version", ProcessHelper.GetCurrentProcessVersion()); this.tracer.RelatedEvent(EventLevel.Informational, $"{nameof(GVFSService)}_{nameof(this.Run)}", metadata); + // Check for a staged upgrade before doing anything else. + // If no GVFS.Mount processes are running (typical at boot or after + // unmount-all), copy staged files in-place and proceed normally. + // If mounts ARE running, the upgrade is deferred to next restart. + PendingUpgradeHandler.TryApplyPendingUpgrade(this.tracer); + this.repoRegistry = new RepoRegistry( this.tracer, new PhysicalFileSystem(), diff --git a/GVFS/GVFS.Service/Handlers/RequestHandler.cs b/GVFS/GVFS.Service/Handlers/RequestHandler.cs index 724bfa3b5..85f27c4f3 100644 --- a/GVFS/GVFS.Service/Handlers/RequestHandler.cs +++ b/GVFS/GVFS.Service/Handlers/RequestHandler.cs @@ -1,6 +1,7 @@ using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; using System.Runtime.Serialization; +using System.Threading; namespace GVFS.Service.Handlers { @@ -14,6 +15,8 @@ namespace GVFS.Service.Handlers /// public class RequestHandler { + private const int PendingUpgradeDelayMs = 5000; + protected const string EnableProjFSRequestDescription = "attach volume"; protected string requestDescription; @@ -25,6 +28,7 @@ public class RequestHandler private string etwArea; private ITracer tracer; private IRepoRegistry repoRegistry; + private Timer pendingUpgradeTimer; public RequestHandler(ITracer tracer, string etwArea, IRepoRegistry repoRegistry) { @@ -80,6 +84,14 @@ protected virtual void HandleMessage( UnregisterRepoHandler unmountHandler = new UnregisterRepoHandler(tracer, this.repoRegistry, connection, unmountRequest); unmountHandler.Run(); + // After unmount, check for pending staged upgrade on a + // background thread. The deferred check gives the calling + // GVFS.Mount process time to exit so its executable is no + // longer locked when the upgrade runs. + // Use the long-lived service tracer, not the scoped activity + // tracer which will be disposed when this handler returns. + this.TryDeferredPendingUpgradeCheck(this.tracer); + break; case NamedPipeMessages.GetActiveRepoListRequest.Header: @@ -121,5 +133,35 @@ private void TrySendResponse( tracer.RelatedError($"{nameof(this.TrySendResponse)}: Could not send response to client. Reply Info: {message}"); } } + + private void TryDeferredPendingUpgradeCheck(ITracer tracer) + { + string installDir = Service.Configuration.AssemblyPath; + string pendingUpgradeDir = System.IO.Path.Combine(installDir, PendingUpgradeHandler.PendingUpgradeDirectoryName); + if (!System.IO.Directory.Exists(pendingUpgradeDir)) + { + return; + } + + // Debounce: reset the timer on each unmount so the check fires + // once after the last unmount settles. If multiple repos unmount + // in quick succession, only one upgrade attempt runs. + if (this.pendingUpgradeTimer == null) + { + this.pendingUpgradeTimer = new Timer( + _ => + { + tracer.RelatedInfo("TryDeferredPendingUpgradeCheck: Checking pending upgrade after unmount"); + PendingUpgradeHandler.TryApplyPendingUpgrade(tracer); + }, + null, + PendingUpgradeDelayMs, + Timeout.Infinite); + } + else + { + this.pendingUpgradeTimer.Change(PendingUpgradeDelayMs, Timeout.Infinite); + } + } } } diff --git a/GVFS/GVFS.Service/PendingUpgradeHandler.cs b/GVFS/GVFS.Service/PendingUpgradeHandler.cs new file mode 100644 index 000000000..6bfabece4 --- /dev/null +++ b/GVFS/GVFS.Service/PendingUpgradeHandler.cs @@ -0,0 +1,443 @@ +using GVFS.Common; +using GVFS.Common.Tracing; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; + +namespace GVFS.Service +{ + /// + /// Detects and applies staged upgrades from the PendingUpgrade directory. + /// + /// When the installer runs with mounts active, it stages new files to + /// {installDir}\PendingUpgrade\ instead of replacing files in-place. + /// This class applies the upgrade when no GVFS.Mount processes are + /// running — either on service start (before automount) or after a + /// repo unmount (via deferred check from RequestHandler). + /// + /// 1. Move old files from install dir → PreviousVersion\ + /// 2. Move new files from PendingUpgrade\ → install dir + /// 3. Delete PreviousVersion\ and PendingUpgrade\ + /// + /// File.Move on the same volume is an atomic rename at the filesystem + /// level, so a crash mid-upgrade leaves files intact (either at the old + /// or new location). On retry, the handler resumes from where it left off. + /// + /// With native AOT, each exe is self-contained — the only locked file + /// is GVFS.Service.exe itself, which the installer already replaced. + /// + public static class PendingUpgradeHandler + { + public const string PendingUpgradeDirectoryName = "PendingUpgrade"; + private const string PreviousVersionDirectoryName = "PreviousVersion"; + private const string ReadyMarkerFileName = ".ready"; + private const string Phase1CompleteMarkerFileName = ".phase1-complete"; + private const string ServiceExeName = "GVFS.Service.exe"; + private const string MountProcessName = "GVFS.Mount"; + + // Executables that users or the service can launch to start new + // mount/hook processes. During upgrade these are moved out first + // (Phase 1) and moved in last (Phase 2) so that no new GVFS + // processes can start while the upgrade is in progress. + // Ordered most-likely-to-be-called first for Phase 1 removal. + private static readonly StringComparer PathComparer = StringComparer.OrdinalIgnoreCase; + private static readonly string[] PriorityExes = new[] + { + "GVFS.Hooks.exe", + "GVFS.exe", + "GVFS.Mount.exe", + }; + + /// + /// Checks for and applies a pending staged upgrade. + /// + public static void TryApplyPendingUpgrade(ITracer tracer) + { + string installDir = Configuration.AssemblyPath; + string pendingUpgradeDir = Path.Combine(installDir, PendingUpgradeDirectoryName); + string previousVersionDir = Path.Combine(installDir, PreviousVersionDirectoryName); + + if (!Directory.Exists(pendingUpgradeDir)) + { + // No pending upgrade. Clean up PreviousVersion if it exists + // (leftover from a completed upgrade where cleanup was interrupted). + TryDeleteDirectory(tracer, previousVersionDir, "leftover PreviousVersion"); + return; + } + + // Installer writes .ready marker as its last step. If missing, + // the installer was interrupted mid-write — don't apply partial files. + string readyMarker = Path.Combine(pendingUpgradeDir, ReadyMarkerFileName); + if (!File.Exists(readyMarker)) + { + EventMetadata readyMetadata = new EventMetadata(); + readyMetadata.Add("PendingUpgradeDir", pendingUpgradeDir); + tracer.RelatedWarning( + readyMetadata, + $"{nameof(PendingUpgradeHandler)}: PendingUpgrade directory exists but {ReadyMarkerFileName} marker " + + "is missing — installer was likely interrupted. Skipping until next install completes.", + Keywords.Telemetry); + return; + } + + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Pending upgrade detected at {pendingUpgradeDir}"); + + // Don't apply if GVFS.Mount processes are still running — their + // executables are locked and moves would fail. Upgrade will be + // retried on next service start when no mounts are active. + Process[] mountProcesses = Array.Empty(); + try + { + mountProcesses = Process.GetProcessesByName(MountProcessName); + if (mountProcesses.Length > 0) + { + EventMetadata deferMetadata = new EventMetadata(); + deferMetadata.Add("MountProcessCount", mountProcesses.Length); + tracer.RelatedEvent( + EventLevel.Informational, + $"{nameof(PendingUpgradeHandler)}_Deferred", + deferMetadata, + Keywords.Telemetry); + return; + } + } + finally + { + foreach (Process p in mountProcesses) + { + p.Dispose(); + } + } + + try + { + // Phase 1: Move old files to PreviousVersion (backup for rollback). + // priority exes (GVFS.exe, GVFS.Hooks.exe, GVFS.Mount.exe) are + // moved FIRST so no new GVFS processes can start during the upgrade. + // Use a marker file to track completion — directory existence alone + // is insufficient because a crash mid-phase leaves the directory + // with only some files backed up. + string[] stagedFiles = Directory.GetFiles(pendingUpgradeDir, "*", SearchOption.AllDirectories); + string phase1Marker = Path.Combine(previousVersionDir, Phase1CompleteMarkerFileName); + if (!File.Exists(phase1Marker)) + { + // Clean up any partial Phase 1 from a prior crash — re-run + // from scratch to ensure all files are backed up. + if (Directory.Exists(previousVersionDir)) + { + tracer.RelatedWarning( + $"{nameof(PendingUpgradeHandler)}: Phase 1 incomplete from prior attempt, restarting backup", + Keywords.Telemetry); + TryRestoreFromPreviousVersion(tracer, previousVersionDir, installDir); + TryDeleteDirectory(tracer, previousVersionDir, "incomplete PreviousVersion"); + } + + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Phase 1 - backing up {stagedFiles.Length} file(s) to PreviousVersion"); + + int backedUp = 0; + foreach (string relativePath in OrderForRemoval(stagedFiles, pendingUpgradeDir)) + { + string installedFile = Path.Combine(installDir, relativePath); + if (File.Exists(installedFile)) + { + string backupFile = Path.Combine(previousVersionDir, relativePath); + string backupDir = Path.GetDirectoryName(backupFile); + if (!Directory.Exists(backupDir)) + { + Directory.CreateDirectory(backupDir); + } + + MoveFileWithRetry(tracer, installedFile, backupFile); + backedUp++; + } + } + + File.WriteAllText(phase1Marker, string.Empty); + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Phase 1 complete. Backed up {backedUp} file(s)"); + } + else + { + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Phase 1 already done ({Phase1CompleteMarkerFileName} exists). Resuming phase 2."); + } + + // Phase 2: Move new files from PendingUpgrade to install dir. + // priority exes are moved LAST so all supporting files (DLLs, + // hooks, etc.) are in place before any GVFS process can start. + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Phase 2 - applying {stagedFiles.Length} staged file(s)"); + + int applied = 0; + foreach (string relativePath in OrderForInstall(stagedFiles, pendingUpgradeDir)) + { + string sourceFile = Path.Combine(pendingUpgradeDir, relativePath); + string destFile = Path.Combine(installDir, relativePath); + string destDir = Path.GetDirectoryName(destFile); + if (!Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + // If dest already exists (phase 2 partially completed on a prior + // run), delete it first so File.Move can succeed. + if (File.Exists(destFile)) + { + File.Delete(destFile); + } + + MoveFileWithRetry(tracer, sourceFile, destFile); + applied++; + } + + tracer.RelatedInfo( + $"{nameof(PendingUpgradeHandler)}: Phase 2 complete. Applied={applied}"); + + // Phase 3: Clean up + // Capture old version before deleting PreviousVersion. + string oldVersion = TryGetOldVersion(previousVersionDir); + + // Delete the skipped GVFS.Service.exe from PendingUpgrade first, + // otherwise Directory.Delete will fail on the non-empty directory. + string skippedServiceExe = Path.Combine(pendingUpgradeDir, ServiceExeName); + if (File.Exists(skippedServiceExe)) + { + File.Delete(skippedServiceExe); + } + + TryDeleteDirectory(tracer, pendingUpgradeDir, "PendingUpgrade"); + TryDeleteDirectory(tracer, previousVersionDir, "PreviousVersion"); + + string newVersion = ProcessHelper.GetCurrentProcessVersion(); + EventMetadata successMetadata = new EventMetadata(); + successMetadata.Add("NewVersion", newVersion); + successMetadata.Add("OldVersion", oldVersion ?? "unknown"); + successMetadata.Add("FilesApplied", applied); + tracer.RelatedEvent( + EventLevel.Informational, + $"{nameof(PendingUpgradeHandler)}_Complete", + successMetadata, + Keywords.Telemetry); + return; + } + catch (Exception ex) + { + EventMetadata errorMetadata = new EventMetadata(); + errorMetadata.Add("Exception", ex.ToString()); + tracer.RelatedError( + errorMetadata, + $"{nameof(PendingUpgradeHandler)}: Upgrade failed: {ex.Message}. " + + "PendingUpgrade retained for retry on next service start. " + + "If PreviousVersion exists, old files are preserved for manual recovery.", + Keywords.Telemetry); + return; + } + } + + private static bool IsSkippedFile(string relativePath) + { + return IsMarkerFile(relativePath) || + string.Equals(relativePath, ServiceExeName, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPriorityExe(string relativePath) + { + foreach (string exe in PriorityExes) + { + if (PathComparer.Equals(relativePath, exe)) + { + return true; + } + } + + return false; + } + + private static bool IsMarkerFile(string relativePath) + { + return string.Equals(relativePath, ReadyMarkerFileName, StringComparison.OrdinalIgnoreCase) || + string.Equals(relativePath, Phase1CompleteMarkerFileName, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Moves a file, retrying once after killing any process that has the + /// source file locked (e.g. a GVFS process that started mid-upgrade). + /// + private static void MoveFileWithRetry(ITracer tracer, string source, string dest) + { + try + { + File.Move(source, dest); + } + catch (IOException) + { + if (TryKillProcessByPath(tracer, source)) + { + Thread.Sleep(1000); + File.Move(source, dest); + } + else + { + throw; + } + } + } + + /// + /// Returns relative paths ordered for removal: priority exes first + /// (so no new GVFS processes can start), then everything else. + /// Skips marker files and GVFS.Service.exe. + /// + private static List OrderForRemoval(string[] absolutePaths, string baseDir) + { + List rest = new List(); + HashSet present = new HashSet(PathComparer); + + foreach (string fullPath in absolutePaths) + { + string relativePath = fullPath.Substring(baseDir.Length).TrimStart(Path.DirectorySeparatorChar); + if (IsSkippedFile(relativePath)) + { + continue; + } + + if (IsPriorityExe(relativePath)) + { + present.Add(relativePath); + } + else + { + rest.Add(relativePath); + } + } + + List ordered = new List(); + foreach (string exe in PriorityExes) + { + if (present.Contains(exe)) + { + ordered.Add(exe); + } + } + + ordered.AddRange(rest); + return ordered; + } + + /// + /// Returns relative paths ordered for install: reverse of removal order + /// so priority exes are replaced last (all supporting files in place + /// before any GVFS process can start). + /// + private static List OrderForInstall(string[] absolutePaths, string baseDir) + { + List ordered = OrderForRemoval(absolutePaths, baseDir); + ordered.Reverse(); + return ordered; + } + + /// + /// Finds and kills any process whose main module matches the given + /// file path. Returns true if a process was found and killed. + /// + private static bool TryKillProcessByPath(ITracer tracer, string filePath) + { + bool killed = false; + try + { + foreach (Process process in Process.GetProcesses()) + { + try + { + if (PathComparer.Equals(process.MainModule?.FileName, filePath)) + { + tracer.RelatedWarning( + $"{nameof(PendingUpgradeHandler)}: Killing process {process.ProcessName} " + + $"(PID {process.Id}) that is locking {filePath}"); + process.Kill(); + process.WaitForExit(5000); + killed = true; + } + } + catch (Exception) + { + // Access denied or process already exited — skip + } + finally + { + process.Dispose(); + } + } + } + catch (Exception ex) + { + tracer.RelatedWarning($"{nameof(PendingUpgradeHandler)}: Error enumerating processes: {ex.Message}"); + } + + return killed; + } + + private static string TryGetOldVersion(string previousVersionDir) + { + try + { + string oldGvfsExe = Path.Combine(previousVersionDir, "GVFS.exe"); + if (File.Exists(oldGvfsExe)) + { + return FileVersionInfo.GetVersionInfo(oldGvfsExe).ProductVersion; + } + } + catch + { + } + + return null; + } + + private static void TryRestoreFromPreviousVersion(ITracer tracer, string previousVersionDir, string installDir) + { + // Move any backed-up files back to the install directory so we + // can retry Phase 1 cleanly. + try + { + foreach (string backupFile in Directory.GetFiles(previousVersionDir, "*", SearchOption.AllDirectories)) + { + string relativePath = backupFile.Substring(previousVersionDir.Length).TrimStart(Path.DirectorySeparatorChar); + if (IsMarkerFile(relativePath)) + { + continue; + } + + string installedFile = Path.Combine(installDir, relativePath); + if (!File.Exists(installedFile)) + { + File.Move(backupFile, installedFile); + } + } + } + catch (Exception ex) + { + tracer.RelatedWarning( + $"{nameof(PendingUpgradeHandler)}: Failed to restore from PreviousVersion: {ex.Message}", + Keywords.Telemetry); + } + } + + private static void TryDeleteDirectory(ITracer tracer, string path, string description) + { + if (!Directory.Exists(path)) + { + return; + } + + try + { + Directory.Delete(path, recursive: true); + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Removed {description} directory"); + } + catch (Exception ex) + { + tracer.RelatedWarning($"{nameof(PendingUpgradeHandler)}: Failed to remove {description} directory: {ex.Message}"); + } + } + } +} From 116c1bb1f66ff7a8e342c8804e71d2a5151888a5 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 6 May 2026 10:24:00 -0700 Subject: [PATCH 23/25] CI: add upgrade test workflow with 5 scenarios Add upgrade-tests.yaml with matrix of 5 scenarios: - staging-upgrade: LKG -> mount -> stage -> unmount -> verify completion - clean-upgrade: LKG -> mount -> clean install -> verify - double-staging: stage twice, verify second overwrites first - staging-then-clean: stage then clean install removes PendingUpgrade - mount-safety-deferral: verify upgrade defers while mount is running Wire into build.yaml as a required check alongside functional tests. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .github/workflows/build.yaml | 9 +- .github/workflows/upgrade-tests.yaml | 290 +++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/upgrade-tests.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b9bf68722..fba76511d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -247,10 +247,17 @@ jobs: with: skip: ${{ needs.validate.outputs.skip }} + upgrade_tests: + name: Upgrade Tests + needs: [validate, build] + uses: ./.github/workflows/upgrade-tests.yaml + with: + skip: ${{ needs.validate.outputs.skip }} + result: runs-on: ubuntu-latest name: Build, Unit and Functional Tests Successful - needs: [functional_tests] + needs: [functional_tests, upgrade_tests] steps: - name: Success! # for easier identification of successful runs in the Checks Required for Pull Requests diff --git a/.github/workflows/upgrade-tests.yaml b/.github/workflows/upgrade-tests.yaml new file mode 100644 index 000000000..44eaaac94 --- /dev/null +++ b/.github/workflows/upgrade-tests.yaml @@ -0,0 +1,290 @@ +name: Upgrade Tests + +on: + workflow_call: + inputs: + skip: + description: 'URL of a previous successful run; if non-empty, all steps are skipped' + required: false + type: string + default: '' + lkg_release_tag: + description: 'Tag of the last known good release to upgrade from (default: latest release)' + required: false + type: string + default: '' + +permissions: + contents: read + actions: read + +jobs: + upgrade_test: + runs-on: windows-2025 + name: Upgrade + timeout-minutes: 30 + + strategy: + matrix: + configuration: [ Debug ] + scenario: + - staging-upgrade + - clean-upgrade + - double-staging + - staging-then-clean + - mount-safety-deferral + fail-fast: false + + steps: + - name: Skip this job if there is a previous successful run + if: inputs.skip != '' + id: skip + uses: actions/github-script@v9 + with: + script: | + core.info(`Skipping: There already is a successful run: ${{ inputs.skip }}`) + return true + + # -- Artifacts -- + + - name: Download LKG release installer + if: steps.skip.outputs.result != 'true' + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $tag = "${{ inputs.lkg_release_tag }}" + if (-not $tag) { + $tag = gh api repos/microsoft/VFSForGit/releases/latest --jq '.tag_name' + Write-Host "Auto-detected latest release: $tag" + } + New-Item -ItemType Directory -Path gvfs-lkg -Force | Out-Null + gh release download $tag --repo microsoft/VFSForGit --pattern "SetupGVFS*.exe" --dir gvfs-lkg + + - name: Download Git installer + if: steps.skip.outputs.result != 'true' + uses: actions/download-artifact@v8 + with: + name: MicrosoftGit + path: git + + - name: Download current GVFS installer + if: steps.skip.outputs.result != 'true' + uses: actions/download-artifact@v8 + with: + name: GVFS_${{ matrix.configuration }} + path: gvfs-new + + # -- Setup -- + + - name: Install Git + if: steps.skip.outputs.result != 'true' + shell: cmd + run: git\install.bat + + - name: Enable ProjFS + if: steps.skip.outputs.result != 'true' + shell: pwsh + run: | + $feature = Get-WindowsOptionalFeature -Online -FeatureName Client-ProjFS + if ($feature.State -ne 'Enabled') { + Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -NoRestart + } + + # -- Test Execution -- + + - name: Run upgrade test - ${{ matrix.scenario }} + if: steps.skip.outputs.result != 'true' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $lkgInstaller = (Get-ChildItem gvfs-lkg\SetupGVFS*.exe).FullName + $newInstaller = (Get-ChildItem gvfs-new\SetupGVFS*.exe).FullName + $installDir = "C:\Program Files\VFS for Git" + $testRepo = "https://dev.azure.com/gvfs/ci/_git/ForTests" + $enlistment = "C:\gvfs-upgrade-test" + + function Install-GVFS($installer, [string[]]$extraArgs = @()) { + $logDir = "C:\temp\gvfs-install-logs" + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + $logFile = Join-Path $logDir "gvfs-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" + $allArgs = @("/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/LOG=$logFile") + $extraArgs + Write-Host "Installing: $installer $($allArgs -join ' ')" + # Start without -Wait: Inno Setup launches child processes + # (e.g. GVFS.Service.UI) that stay running, causing -Wait to + # hang. Instead, wait only for the installer process itself. + $proc = Start-Process -FilePath $installer -ArgumentList $allArgs -PassThru + $proc.WaitForExit() + if ($proc.ExitCode -ne 0) { + Get-Content $logFile -Tail 30 -ErrorAction SilentlyContinue + throw "Installer failed with exit code $($proc.ExitCode)" + } + Write-Host "Installed successfully" + } + + function Assert-ServiceRunning { + $svc = sc.exe query GVFS.Service 2>&1 | Select-String "STATE" + if ($svc -notmatch "RUNNING") { throw "GVFS.Service is not running: $svc" } + } + + function Mount-TestRepo { + if (Test-Path $enlistment) { + & "$installDir\gvfs.exe" mount $enlistment 2>&1 | Write-Host + } else { + & "$installDir\gvfs.exe" clone $testRepo $enlistment 2>&1 | Write-Host + } + if ($LASTEXITCODE -ne 0) { throw "Mount/clone failed" } + $mountProc = Get-Process -Name "GVFS.Mount" -ErrorAction SilentlyContinue + if (-not $mountProc) { throw "No GVFS.Mount process after mount" } + return $mountProc.Id + } + + function Assert-MountAlive($expectedPid) { + $proc = Get-Process -Id $expectedPid -ErrorAction SilentlyContinue + if (-not $proc -or $proc.ProcessName -ne "GVFS.Mount") { + throw "Mount process $expectedPid is no longer running" + } + # Verify the mount is functional by accessing a file + $readmePath = Join-Path $enlistment "src\Readme.md" + if (-not (Test-Path $readmePath)) { + throw "Mount is running but cannot access $readmePath" + } + } + + function Unmount-TestRepo { + & "$installDir\gvfs.exe" unmount $enlistment 2>&1 + Start-Sleep -Seconds 3 + } + + function Restart-Service { + sc.exe stop GVFS.Service | Out-Null + Start-Sleep -Seconds 10 + sc.exe start GVFS.Service | Out-Null + Start-Sleep -Seconds 10 + Assert-ServiceRunning + } + + function Assert-PendingUpgrade($expected) { + $exists = Test-Path "$installDir\PendingUpgrade" + if ($exists -ne $expected) { + throw "PendingUpgrade directory: expected=$expected, actual=$exists" + } + } + + # ============================================= + # Test scenarios + # ============================================= + + switch ("${{ matrix.scenario }}") { + + "staging-upgrade" { + Write-Host "=== Scenario: Staging upgrade e2e ===" + # Install LKG, mount, staging upgrade, unmount, verify completion + Install-GVFS $lkgInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + Unmount-TestRepo + Restart-Service + Assert-PendingUpgrade $false + Write-Host "PASS: Staging upgrade completed" + } + + "clean-upgrade" { + Write-Host "=== Scenario: Clean upgrade (traditional) ===" + Install-GVFS $lkgInstaller + Assert-ServiceRunning + Mount-TestRepo | Write-Host + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=false") + Assert-PendingUpgrade $false + Assert-ServiceRunning + Write-Host "PASS: Clean upgrade completed" + } + + "double-staging" { + Write-Host "=== Scenario: Double staging install ===" + # Install LKG, mount, staging install twice, verify second overwrites + Install-GVFS $lkgInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + # Second staging install should overwrite PendingUpgrade + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + Unmount-TestRepo + Restart-Service + Assert-PendingUpgrade $false + Write-Host "PASS: Double staging handled correctly" + } + + "staging-then-clean" { + Write-Host "=== Scenario: Staging then clean install ===" + # Install LKG, mount, staging install, unmount, clean install + # Verify PendingUpgrade is cleaned up by clean install + Install-GVFS $lkgInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + Unmount-TestRepo + # Now clean install — should remove PendingUpgrade + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=false") + Assert-PendingUpgrade $false + Assert-ServiceRunning + Write-Host "PASS: Staging then clean install handled correctly" + } + + "mount-safety-deferral" { + Write-Host "=== Scenario: Mount safety deferral ===" + # Install LKG, mount, staging install, restart service WITH mount + # running — upgrade should be deferred + Install-GVFS $lkgInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + # Restart service WITHOUT unmounting — upgrade should defer + Restart-Service + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + Write-Host "Upgrade correctly deferred while mount running" + + # Now unmount and restart — should complete + Unmount-TestRepo + Restart-Service + Assert-PendingUpgrade $false + Write-Host "PASS: Mount safety deferral works correctly" + } + + default { + throw "Unknown scenario: ${{ matrix.scenario }}" + } + } + + - name: Upload service logs + if: always() && steps.skip.outputs.result != 'true' + uses: actions/upload-artifact@v7 + continue-on-error: true + with: + name: UpgradeTest_Logs_${{ matrix.scenario }} + path: | + C:\ProgramData\GVFS\GVFS.Service\Logs\ + C:\temp\gvfs-install-logs\ From 58a00e8e45e9e05e575fec559c02c038413a5b40 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 7 May 2026 14:00:23 -0700 Subject: [PATCH 24/25] Fix staged upgrade races and installer error handling Address review feedback from #1958: - StopService/StagingUpdateService: check sc.exe exit code separately from Exec launch failure. Previously non-zero sc.exe results were silently ignored. - Timer race: wrap pendingUpgradeTimer create/reset in a lock. Two concurrent unmounts on separate pipe threads could both observe null and create duplicate timers, causing parallel TryApplyPendingUpgrade. - .ready race: move service start from AfterInstall hook to ssPostInstall after .ready marker is written. Previously the service could start its 5s debounce timer before .ready existed, skip the upgrade, and leave staged files until the next service restart. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Installers/Setup.iss | 26 +++++++++++++--- GVFS/GVFS.Service/Handlers/RequestHandler.cs | 32 +++++++++++--------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/GVFS/GVFS.Installers/Setup.iss b/GVFS/GVFS.Installers/Setup.iss index 8ba43a32d..10765dddb 100644 --- a/GVFS/GVFS.Installers/Setup.iss +++ b/GVFS/GVFS.Installers/Setup.iss @@ -66,7 +66,7 @@ DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; ; goes directly to {app} so the restarted service has PendingUpgradeHandler code. ; The service is briefly stopped/restarted (mounts are independent processes). DestDir: "{app}\PendingUpgrade"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsStagingInstall -DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; AfterInstall: StagingUpdateService; Check: IsStagingInstall +DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; Check: IsStagingInstall [Dirs] Name: "{app}\ProgramData\{#ServiceName}"; Permissions: users-readexec @@ -166,11 +166,17 @@ var ResultCode: integer; begin Log('StopService: stopping: ' + ServiceName); - // ErrorCode 1060 means service not installed, 1062 means service not started - if not Exec(ExpandConstant('{sys}\SC.EXE'), 'stop ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode <> 1060) and (ResultCode <> 1062) then + if not Exec(ExpandConstant('{sys}\SC.EXE'), 'stop ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin + Log('StopService: Failed to launch sc.exe'); RaiseException('Fatal: Could not stop service: ' + ServiceName); end; + // 1060 = service not installed, 1062 = service not started + if (ResultCode <> 0) and (ResultCode <> 1060) and (ResultCode <> 1062) then + begin + Log('StopService: sc stop returned error code ' + IntToStr(ResultCode)); + RaiseException('Fatal: Could not stop service: ' + ServiceName + ' (exit code ' + IntToStr(ResultCode) + ')'); + end; end; procedure WaitForServiceProcessToExit(ServiceName: string); @@ -328,9 +334,14 @@ begin try Log('StagingUpdateService: Starting service with new binary'); - if not Exec(ExpandConstant('{sys}\SC.EXE'), 'start GVFS.Service', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + if Exec(ExpandConstant('{sys}\SC.EXE'), 'start GVFS.Service', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + if ResultCode <> 0 then + Log('StagingUpdateService: Warning - sc start returned error code ' + IntToStr(ResultCode)); + end + else begin - Log('StagingUpdateService: Warning - could not start service: ' + SysErrorMessage(ResultCode)); + Log('StagingUpdateService: Warning - could not launch sc.exe'); end; WriteOnDiskVersion16CapableFile(); @@ -721,6 +732,11 @@ begin // complete and safe to apply. SaveStringToFile(ExpandConstant('{app}\PendingUpgrade\.ready'), '', False); Log('CurStepChanged: Wrote PendingUpgrade .ready marker'); + + // Start the service AFTER .ready is written. Previously this + // was an AfterInstall hook on GVFS.Service.exe, but that races: + // the service's debounce timer could fire before .ready exists. + StagingUpdateService(); end; MigrateConfigAndStatusCacheFiles(); if (not KeepMountsRunning) and (ExpandConstant('{param:REMOUNTREPOS|true}') = 'true') then diff --git a/GVFS/GVFS.Service/Handlers/RequestHandler.cs b/GVFS/GVFS.Service/Handlers/RequestHandler.cs index 85f27c4f3..4d665c416 100644 --- a/GVFS/GVFS.Service/Handlers/RequestHandler.cs +++ b/GVFS/GVFS.Service/Handlers/RequestHandler.cs @@ -29,6 +29,7 @@ public class RequestHandler private ITracer tracer; private IRepoRegistry repoRegistry; private Timer pendingUpgradeTimer; + private readonly object pendingUpgradeTimerLock = new object(); public RequestHandler(ITracer tracer, string etwArea, IRepoRegistry repoRegistry) { @@ -146,21 +147,24 @@ private void TryDeferredPendingUpgradeCheck(ITracer tracer) // Debounce: reset the timer on each unmount so the check fires // once after the last unmount settles. If multiple repos unmount // in quick succession, only one upgrade attempt runs. - if (this.pendingUpgradeTimer == null) + lock (this.pendingUpgradeTimerLock) { - this.pendingUpgradeTimer = new Timer( - _ => - { - tracer.RelatedInfo("TryDeferredPendingUpgradeCheck: Checking pending upgrade after unmount"); - PendingUpgradeHandler.TryApplyPendingUpgrade(tracer); - }, - null, - PendingUpgradeDelayMs, - Timeout.Infinite); - } - else - { - this.pendingUpgradeTimer.Change(PendingUpgradeDelayMs, Timeout.Infinite); + if (this.pendingUpgradeTimer == null) + { + this.pendingUpgradeTimer = new Timer( + _ => + { + tracer.RelatedInfo("TryDeferredPendingUpgradeCheck: Checking pending upgrade after unmount"); + PendingUpgradeHandler.TryApplyPendingUpgrade(tracer); + }, + null, + PendingUpgradeDelayMs, + Timeout.Infinite); + } + else + { + this.pendingUpgradeTimer.Change(PendingUpgradeDelayMs, Timeout.Infinite); + } } } } From 7a7d21d7394bebca57d96528328b03c1e71d8b58 Mon Sep 17 00:00:00 2001 From: tyrielv Date: Mon, 11 May 2026 08:37:20 -0700 Subject: [PATCH 25/25] Update GVFS version to 2.0 in release pipeline Upgrade to .NET 10 is a major change, and excluding bundled ProjFS driver for older systems is potentially breaking. --- .azure-pipelines/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 3b4a64df4..c7454f322 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -3,7 +3,7 @@ trigger: none pr: none variables: - GVFSMajorAndMinorVersion: 1.0 + GVFSMajorAndMinorVersion: 2.0 GVFSRevision: $(Build.BuildNumber) BuildConfiguration: Release TeamName: GVFS