From a3f95d48b78f81511a52fb44e0772064617b82fd Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Mon, 1 Jun 2026 12:02:03 -0700 Subject: [PATCH 1/5] Add AGENTS.md and refresh outdated Readme sections This commit does four related things: 1. Add an AGENTS.md at the repo root, following the 2025-2026 cross-tool convention (Linux Foundation Agentic AI Foundation). It is natively read by GitHub Copilot, Claude Code, OpenAI Codex, Cursor, Gemini CLI, Windsurf, Aider, Devin, Zed, and others; agents that have their own native file (e.g. CLAUDE.md) fall back to AGENTS.md when no native file is present. The file captures project-specific knowledge that isn't obvious from a fresh `git clone` and which routinely trips up AI coding assistants: - The wrapped-output layout (build outputs land one level up from the working tree -- catches agents reaching for ./out or similar). Notes the documented `src\` convention and that all commands assume CWD at the enlistment root. - Three explicit build paths scoped to scenario, so agents stop reaching for the full installer build (~5 min, NativeAOT publish + Inno Setup, no incremental support) when they just want to rerun a unit test: Path A -- unit-test inner loop: ~10-15 s (dotnet build .csproj) Path B -- functional-test loop: ~30-60 s (dotnet publish w/ PublishAot=false + SkipCreateInstaller=true) Path C -- installer build: ~5 min (Build.bat, only when you actually need an installer) Path B explicitly calls out the native C++ vcxproj prerequisite, since dotnet publish won't build those. - The NUnit filter footgun: this codebase uses NUnitLite, which supports only --test (not --where). --where is silently ignored and runs every test. For unit tests that's just slow; for functional tests it's hours of wasted runtime against fresh enlistments. - Fully-qualified-name requirement for --test (short names silently match nothing). - The vcpkg caching behavior in Build.bat (don't manually re-run vcpkg unless overlay ports changed). Coding standards are NOT duplicated here -- agents are pointed at CONTRIBUTING.md, which already covers StyleCop, error handling, TryXxx patterns, logging conventions, etc. No new precompiled binaries, no helper scripts, no opinionated workflow recommendations (worktree model etc.) -- just the minimum project-specific knowledge needed to keep an agent productive in this repo. 2. Remove the "maintenance mode" notice from Readme.md. VFS for Git has resumed active development; the line about "only required updates as a reaction to critical security vulnerabilities will prompt a release" is no longer accurate. The Scalar recommendation paragraph (for new deployments) is unchanged. 3. Remove the build-badge table from Readme.md. The four pipelines it referenced are a mix of still-running (gvfs/ci CI - Windows and CI - Windows - Full Functional Tests, both currently green) and never-configured for master (mseng/AzureDevOps GVFS/GitHub VFSForGit Large Repo Perf Tests and Large Repo Build both render the "set up now" placeholder). Active CI signal lives in the GitHub Actions checks on each commit/PR; keeping a separately-maintained badge table just creates a "is this still accurate?" question that no one answers. 4. Update the "Building VFS for Git" section of Readme.md to reflect the current build system: - The .NET 10 SDK has replaced the .NET Core 8 SDK requirement. - The "Install nuget.exe" prerequisite is gone (modern dotnet SDK handles restore). - vcpkg is now required (for native libgit2 et al.); call it out and point at the official getting-started doc. - The .NET Core cross-platform development workload and runtime bullets are obsolete; dropped. - The build script is `Build.bat`, not the historical `BuildGVFSForWindows.bat`. - The installer output path is `out\GVFS.Installers\bin\\win-x64\`, not the historical `BuildOutput\GVFS.Installer.Windows\bin\x64\\`. - Added a pointer at the end of the section to AGENTS.md for AI coding assistants. Assisted-by: Claude Opus 4.7 Signed-off-by: Tyrie Vella --- AGENTS.md | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Readme.md | 19 ++---- 2 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4fafb53e9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,174 @@ +# Agent instructions for VFS for Git + +This file is for AI coding assistants (GitHub Copilot, Claude Code, Cursor, OpenAI +Codex, Gemini CLI, Aider, etc.). It captures project-specific knowledge that isn't +obvious from a fresh `git clone` and which routinely trips up agents. See +[CONTRIBUTING.md](CONTRIBUTING.md) for coding standards (StyleCop rules, error +handling, exception logging) — do not duplicate those here. + +## Repository layout + +The build scripts (`scripts\Build.bat` and friends) put build outputs **one +level up** from the git working tree. The Readme documents the recommended +clone-into-`src\` convention, which matches the layout that `gvfs clone` +creates for end users: + +```powershell +# Recommended (matches Readme.md and CI): +git clone https://github.com/microsoft/VFSForGit C:\Repos\VFSForGit\src +``` + +``` +C:\Repos\VFSForGit\ +├── src\ ← git working tree (.git, GVFS.sln, all source) +├── out\ ← build output (gitignored — it's outside the working tree) +└── packages\ ← NuGet cache +``` + +Cloning without the `src\` suffix also works — outputs just land one level +above wherever you cloned. The rest of this document and the project's own +scripts (CI, Readme) use the `src\` form, so commands assume it. **The +parent directory must be writable** (cloning to `C:\` itself won't work +because the build can't create `C:\out`). + +All commands below run from the **enlistment root** (the parent of `src\`): + +```powershell +cd C:\Repos\VFSForGit # NOT C:\Repos\VFSForGit\src +``` + +This keeps `src\...` and `out\...` paths symmetric and matches what +`Build.bat` does internally. + +## Build paths + +`scripts\Build.bat` does a full installer build with NativeAOT publish plus +Inno Setup. That's ~5 minutes minimum, and AOT has no incremental support +— ilc relinks every executable on every `dotnet publish`. **Do not use +`Build.bat` for dev-loop iteration.** Pick the right path for what you're +doing. + +### Path A — Unit-test inner loop (~10–15 s incremental) + +For C# changes verified by unit tests only. + +```powershell +dotnet build src\GVFS\GVFS.UnitTests\GVFS.UnitTests.csproj -c Debug +& "out\GVFS.UnitTests\bin\Debug\net10.0-windows10.0.17763.0\win-x64\GVFS.UnitTests.exe" --test Fully.Qualified.Class.Or.Method +``` + +Skips `dotnet publish`, AOT, native C++ projects, payload assembly, installer. + +### Path B — Functional-test inner loop (~30–60 s incremental) + +For changes that need the GVFS payload (`gvfs.exe`, hooks, service) but not +an installer. `PublishAot=false` skips ilc (~3–4 min saved); +`SkipCreateInstaller=true` skips Inno Setup (~95 s saved). +`GVFS.Payload` cascades to its dependencies (GVFS, GVFS.Mount, GVFS.Hooks, +GVFS.Service) via `ProjectReference`. + +> **Prerequisite: the native C++ projects must already be built.** They are +> `.vcxproj` (see [Native C++ projects](#native-c-projects-need-msbuild-not-dotnet-build) +> below) and `dotnet publish` will not build them for you. If you have not +> already done a `Build.bat` once in this enlistment, build the native +> projects via VS MSBuild first (or run `Build.bat` once to populate `out\`, +> then iterate with the commands below). After that they are incremental and +> only rebuild when their own sources change. + +```powershell +dotnet publish src\GVFS\GVFS.FunctionalTests\GVFS.FunctionalTests.csproj ` + -c Debug /p:PublishAot=false +dotnet publish src\GVFS\GVFS.Payload\GVFS.Payload.csproj ` + -c Debug /p:PublishAot=false /p:SkipCreateInstaller=true + +src\scripts\RunFunctionalTests-Dev.ps1 Debug --test=GVFS.FunctionalTests.Tests... +``` + +`layout.bat` (invoked by GVFS.Payload) `xcopy`s from each project's `publish\` +or native-output directory — the C# projects do not require AOT, so +`PublishAot=false` produces a fully functional test payload. The native +hook binaries are copied straight from the vcxproj output. + +`RunFunctionalTests-Dev.ps1` runs functional tests against the build output +without requiring admin or a system-wide install. It launches the test +service as a console process. Each invocation gets a unique service name +and data dir, so concurrent runs from different worktrees don't collide. + +### Path C — Installer build (~5 min — only when you need an installer) + +For producing an installable package (testing install/upgrade flows, or +shipping a build). This is the only correct use of `Build.bat`. + +```powershell +# Build.bat takes (configuration, version, verbosity). The 0. prefix on the +# version tells GVFS to treat this as a development build and skip the +# server-side version check. +$v = & { $n = [DateTime]::Now; "0.2.$($n.ToString('yy'))$($n.DayOfYear.ToString('D3')).$([int]($n.TimeOfDay.TotalSeconds / 2))" } +src\scripts\Build.bat Debug $v minimal +``` + +Installer output: `out\GVFS.Installers\bin\Debug\win-x64\SetupGVFS..exe`. + +## Native C++ projects (need MSBuild, not `dotnet build`) + +These five projects are `.vcxproj` and require Visual Studio MSBuild with +the C++ workload. `Build.bat` invokes MSBuild for them automatically; if +you need to rebuild them outside `Build.bat`, use `msbuild`, not +`dotnet build`. + +- `GVFS\GitHooksLoader\GitHooksLoader.vcxproj` +- `GVFS\GVFS.NativeTests\GVFS.NativeTests.vcxproj` +- `GVFS\GVFS.PostIndexChangedHook\GVFS.PostIndexChangedHook.vcxproj` +- `GVFS\GVFS.ReadObjectHook\GVFS.ReadObjectHook.vcxproj` +- `GVFS\GVFS.VirtualFileSystemHook\GVFS.VirtualFileSystemHook.vcxproj` + +These only need to be (re)built when their own sources change; they don't +participate in the C# inner-loop paths above. + +## Running tests + +### NUnit filter syntax — `--test`, NEVER `--where` + +> **⚠️ This codebase uses NUnitLite, which supports ONLY `--test` for name +> filtering. The `--where` filter (from NUnit Console) is silently ignored +> and runs every test.** + +```powershell +# ✅ Correct +& "out\GVFS.UnitTests\bin\...\GVFS.UnitTests.exe" --test "GVFS.UnitTests.Common.WorktreeInfoTests" +src\scripts\RunFunctionalTests-Dev.ps1 Debug --test=GVFS.FunctionalTests.Tests.GVFSVerbTests.UnknownVerb + +# ❌ Wrong — silently runs the entire suite +& "out\GVFS.UnitTests\bin\...\GVFS.UnitTests.exe" --where "class =~ Worktree" +src\scripts\RunFunctionalTests-Dev.ps1 Debug --where "cat == Smoke" +``` + +For unit tests, `--where` is merely annoying (the whole suite runs in +~10 seconds). For functional tests, it's a disaster: each test provisions +its own fresh enlistment, so accidentally running the full suite eats +hours and masks whichever failure you were actually investigating. + +### Fully qualified names required + +`--test` matches against the fully qualified name (`Namespace.Class.Method` +or `Namespace.Class`). Short names like `--test=ReproCherryPickRestoreCorruption` +silently match nothing and the runner reports "0 tests selected" without +making the typo obvious. + +## vcpkg caching + +`Build.bat` checks for `out\vcpkg_installed\dynamic\x64-windows-dynamic\bin\ +git2.dll` as a "vcpkg already installed" marker and skips the vcpkg step +if present. The vcpkg step is the slowest part of a from-scratch build +(several minutes of native compilation), so this caching matters. + +Do not manually delete or re-run vcpkg unless you've changed an overlay +port. If you've copied `out\` from another enlistment as a build-time +shortcut, vcpkg results come along with it. + +## Coding standards + +See [CONTRIBUTING.md](CONTRIBUTING.md) for StyleCop rules, error-handling +patterns (`TryXxx` over exceptions, "fail fast" on data-loss risks), +tracing/logging conventions (Error level reserved for unrecoverable +failures), and the `mock:\\` / `mock://` URL convention for unit tests. diff --git a/Readme.md b/Readme.md index 6267980ba..c72ae98d5 100644 --- a/Readme.md +++ b/Readme.md @@ -1,12 +1,5 @@ # VFS for Git -**Notice:** With the release of VFS for Git 2.32, VFS for Git is in maintenance mode. Only required updates as a reaction to critical security vulnerabilities will prompt a release. - -|Branch|Unit Tests|Functional Tests|Large Repo Perf|Large Repo Build| -|:--:|:--:|:--:|:--:|:--:| -|**master**|[![Build status](https://dev.azure.com/gvfs/ci/_apis/build/status/CI%20-%20Windows?branchName=master)](https://dev.azure.com/gvfs/ci/_build/latest?definitionId=7&branchName=master)|[![Build status](https://dev.azure.com/gvfs/ci/_apis/build/status/CI%20-%20Windows%20-%20Full%20Functional%20Tests?branchName=master)](https://dev.azure.com/gvfs/ci/_build/latest?definitionId=6&branchName=master)|[![Build status](https://dev.azure.com/mseng/AzureDevOps/_apis/build/status/GVFS/GitHub%20VFSForGit%20Large%20Repo%20Perf%20Tests?branchName=master)](https://dev.azure.com/mseng/AzureDevOps/_build/latest?definitionId=7179&branchName=master)|[![Build status](https://dev.azure.com/mseng/AzureDevOps/_apis/build/status/GVFS/GitHub%20VFSForGit%20Large%20Repo%20Build?branchName=master)](https://dev.azure.com/mseng/AzureDevOps/_build/latest?definitionId=7180&branchName=master)| -|**shipped**|[![Build status](https://dev.azure.com/gvfs/ci/_apis/build/status/CI%20-%20Windows?branchName=releases%2Fshipped)](https://dev.azure.com/gvfs/ci/_build/latest?definitionId=7&branchName=releases%2Fshipped)|[![Build status](https://dev.azure.com/gvfs/ci/_apis/build/status/CI%20-%20Windows%20-%20Full%20Functional%20Tests?branchName=releases%2Fshipped)](https://dev.azure.com/gvfs/ci/_build/latest?definitionId=6&branchName=releases%2Fshipped)|[![Build status](https://dev.azure.com/mseng/AzureDevOps/_apis/build/status/GVFS/GitHub%20VFSForGit%20Large%20Repo%20Perf%20Tests?branchName=releases%2Fshipped)](https://dev.azure.com/mseng/AzureDevOps/_build/latest?definitionId=7179&branchName=releases%2Fshipped)|[![Build status](https://dev.azure.com/mseng/AzureDevOps/_apis/build/status/GVFS/GitHub%20VFSForGit%20Large%20Repo%20Build?branchName=releases%2Fshipped)](https://dev.azure.com/mseng/AzureDevOps/_build/latest?definitionId=7180&branchName=releases%2Fshipped)| - ## What is VFS for Git? VFS stands for Virtual File System. VFS for Git virtualizes the file system @@ -46,22 +39,22 @@ If you'd like to build your own VFS for Git Windows installer: * Include the following workloads: * .NET desktop development * Desktop development with C++ - * .NET Core cross-platform development * Include the following additional components: - * .NET Core runtime * Windows 10 or 11 SDK (10.0+) -* Install the .NET Core 8 SDK (https://www.microsoft.com/net/download/dotnet-core/8) -* Install [`nuget.exe`](https://www.nuget.org/downloads) +* Install the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +* Install [`vcpkg`](https://learn.microsoft.com/vcpkg/get-started/get-started) (or use the copy bundled with Visual Studio's C++ workload) * Create a folder to clone into, e.g. `C:\Repos\VFSForGit` * Clone this repo into the `src` subfolder, e.g. `C:\Repos\VFSForGit\src` -* Run `\src\Scripts\BuildGVFSForWindows.bat` +* Run `src\scripts\Build.bat` (defaults to a Debug build) * You can also build in Visual Studio by opening `src\GVFS.sln` (do not upgrade any projects) and building. However, the very first build will fail, and the second and subsequent builds will succeed. This is because the build requires a prebuild code generation step. For details, see the build script in the previous step. Visual Studio 2022 will [automatically prompt you to install these dependencies](https://devblogs.microsoft.com/setup/configure-visual-studio-across-your-organization-with-vsconfig/) when you open the solution. The .vsconfig file that is present in the root of the repository specifies all required components. -The installer can now be found at `C:\Repos\VFSForGit\BuildOutput\GVFS.Installer.Windows\bin\x64\[Debug|Release]\SetupGVFS..exe` +The installer can now be found at `C:\Repos\VFSForGit\out\GVFS.Installers\bin\[Debug|Release]\win-x64\SetupGVFS..exe` + +AI coding assistants working in this repo: see [AGENTS.md](AGENTS.md) for project-specific build/test guidance. ## Trying out VFS for Git From ef7c132a9e0188934f5e9d5076fb09b0458e6e3a Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Mon, 1 Jun 2026 13:56:20 -0700 Subject: [PATCH 2/5] Service: add versioned install layout support to Configuration and PendingUpgradeHandler Add CurrentVersionPath to Configuration that resolves a Current\ junction under the install root, falling back to the flat layout when no junction exists. GVFSLocation is now computed dynamically so junction re-targeting takes effect without restarting the service. Update PendingUpgradeHandler.GetInstalledMountProcesses to recognize mount processes from any Versions\ subdirectory, not just the flat install root. These changes are backward-compatible: on existing flat installs, CurrentVersionPath falls back to AssemblyPath and all behavior is identical. This is groundwork for a versioned install layout (Plan 002) where each version lives in its own directory and a Current junction points to the active version, enabling non-disruptive upgrades. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Service/Configuration.cs | 43 ++++++++++++++++++---- GVFS/GVFS.Service/PendingUpgradeHandler.cs | 32 ++++++++++++---- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/GVFS/GVFS.Service/Configuration.cs b/GVFS/GVFS.Service/Configuration.cs index 0e4ebe18e..e573d5262 100644 --- a/GVFS/GVFS.Service/Configuration.cs +++ b/GVFS/GVFS.Service/Configuration.cs @@ -1,16 +1,25 @@ using GVFS.Common; +using System; using System.IO; namespace GVFS.Service { public class Configuration { + /// + /// Subdirectory name for the junction that points to the active version. + /// When a versioned install layout is present, {app}\Current is a + /// junction pointing to {app}\Versions\{version}. + /// + public const string CurrentVersionDirName = "Current"; + + private static readonly Lazy assemblyPath = + new Lazy(ProcessHelper.GetCurrentProcessLocation); + private static Configuration instance = new Configuration(); - private static string assemblyPath = null; private Configuration() { - this.GVFSLocation = Path.Combine(AssemblyPath, GVFSPlatform.Instance.Constants.GVFSExecutableName); } public static Configuration Instance @@ -21,19 +30,39 @@ public static Configuration Instance } } - public static string AssemblyPath + /// + /// The directory containing GVFS.Service.exe (the install root). + /// + public static string AssemblyPath => assemblyPath.Value; + + /// + /// The directory containing the current version's binaries. + /// With versioned layout this resolves through the Current junction + /// (e.g. {app}\Current{app}\Versions\1.0.X). + /// Falls back to when no Current + /// junction exists (flat/legacy layout). + /// Evaluated on each access so that junction re-targeting is picked up + /// without restarting the service. + /// + public static string CurrentVersionPath { get { - if (assemblyPath == null) + string currentDir = Path.Combine(AssemblyPath, CurrentVersionDirName); + if (Directory.Exists(currentDir)) { - assemblyPath = ProcessHelper.GetCurrentProcessLocation(); + return currentDir; } - return assemblyPath; + // Legacy flat layout — binaries are siblings of the service. + return AssemblyPath; } } - public string GVFSLocation { get; private set; } + /// + /// Full path to the current version's GVFS.exe. Resolved + /// dynamically so that junction re-targeting takes effect immediately. + /// + public string GVFSLocation => Path.Combine(CurrentVersionPath, GVFSPlatform.Instance.Constants.GVFSExecutableName); } } diff --git a/GVFS/GVFS.Service/PendingUpgradeHandler.cs b/GVFS/GVFS.Service/PendingUpgradeHandler.cs index f891402b1..2b3a56098 100644 --- a/GVFS/GVFS.Service/PendingUpgradeHandler.cs +++ b/GVFS/GVFS.Service/PendingUpgradeHandler.cs @@ -82,16 +82,15 @@ public static bool IsPending() /// /// Returns GVFS.Mount processes whose executable is in the install - /// directory. Processes from dev builds or other installs are excluded - /// so they don't block upgrades of the system install. If a process's - /// path cannot be read (access denied, 32/64-bit mismatch), it is - /// included conservatively. + /// directory (or any versioned subdirectory). Processes from dev builds + /// or other installs are excluded so they don't block upgrades of the + /// system install. If a process's path cannot be read (access denied, + /// 32/64-bit mismatch), it is included conservatively. /// Caller must dispose the returned Process objects. /// public static List GetInstalledMountProcesses(ITracer tracer) { string installDir = Configuration.AssemblyPath; - string expectedPath = Path.Combine(installDir, MountExeName); Process[] allMountProcesses = Process.GetProcessesByName(MountProcessName); List installed = new List(); @@ -101,8 +100,7 @@ public static List GetInstalledMountProcesses(ITracer tracer) try { string processPath = process.MainModule?.FileName; - if (processPath != null && - !PathComparer.Equals(processPath, expectedPath)) + if (processPath != null && !IsInstalledMountPath(installDir, processPath)) { include = false; tracer.RelatedInfo( @@ -326,6 +324,26 @@ private static bool IsMarkerFile(string relativePath) string.Equals(relativePath, Phase1CompleteMarkerFileName, StringComparison.OrdinalIgnoreCase); } + /// + /// Returns true if the given GVFS.Mount.exe path is under the install + /// directory — either in the flat layout ({app}\GVFS.Mount.exe) + /// or in a versioned subdirectory ({app}\Versions\*\GVFS.Mount.exe + /// or {app}\Current\GVFS.Mount.exe). + /// + private static bool IsInstalledMountPath(string installDir, string processPath) + { + // Verify filename is actually GVFS.Mount.exe (not some other exe + // under the install dir). + if (!PathComparer.Equals(Path.GetFileName(processPath), MountExeName)) + { + return false; + } + + // Verify the exe lives under the install directory. + string normalizedInstallDir = installDir.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + return processPath.StartsWith(normalizedInstallDir, 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). From faa49e6554d86a6f64e6f0b907654add13c4c508 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Mon, 1 Jun 2026 16:07:57 -0700 Subject: [PATCH 3/5] Add diagnostic logging to GetFileStreamException catch block The GetFileStreamException catch in GetFileStreamHandlerAsyncHandler silently completed the ProjFS command without logging requestMetadata (SHA, virtual path). Other catch blocks in the same handler all log this metadata. Add a RelatedWarning with the HResult value for diagnostic visibility while keeping the severity appropriate for expected/benign HResult.Handle results. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs b/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs index 8977bfa88..f39128e7a 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs @@ -981,6 +981,9 @@ private void GetFileStreamHandlerAsyncHandler( } catch (GetFileStreamException e) { + requestMetadata.Add(TracingConstants.MessageKey.InfoMessage, $"{nameof(this.GetFileStreamHandlerAsyncHandler)}: GetFileStreamException HResult 0x{e.HResult:X8}"); + this.Context.Tracer.RelatedWarning(requestMetadata, nameof(this.GetFileStreamHandlerAsyncHandler) + "_GetFileStreamException"); + this.TryCompleteCommand(commandId, (HResult)e.HResult); return; } From 291a56764bf7ff908a27e7c5f050471a108580e7 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Mon, 1 Jun 2026 10:38:17 -0700 Subject: [PATCH 4/5] Fix deferred telemetry pipe attach for RDP sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At startup, the deferred telemetry attacher only checked the physical console session via WTSGetActiveConsoleSessionId. On Cloud PCs and RDP-only machines, the console session has no logged-in user — the real user is in an RDP session. This meant the startup attach always failed, and the retry timer could never succeed either (SYSTEM has no git global config). A SessionLogon event would fix it, but that only fires for NEW logins — not when the service restarts while a user is already connected. Enumerate all interactive sessions (Active/Connected, session > 0) via WTSEnumerateSessions and try each until the pipe attaches. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Platform.Windows/CurrentUser.cs | 60 +++++++++++- GVFS/GVFS.Service/GVFSService.Windows.cs | 108 +++++++++++++++++----- 2 files changed, 141 insertions(+), 27 deletions(-) diff --git a/GVFS/GVFS.Platform.Windows/CurrentUser.cs b/GVFS/GVFS.Platform.Windows/CurrentUser.cs index 3fe329ae1..1d1ff151f 100644 --- a/GVFS/GVFS.Platform.Windows/CurrentUser.cs +++ b/GVFS/GVFS.Platform.Windows/CurrentUser.cs @@ -134,7 +134,7 @@ public bool RunAs(string processName, string args) if (CreateEnvironmentBlock(ref environment, duplicate, false)) { STARTUP_INFO info = new STARTUP_INFO(); - info.Length = Marshal.SizeOf(typeof(STARTUP_INFO)); + info.Length = Marshal.SizeOf(); PROCESS_INFORMATION procInfo = new PROCESS_INFORMATION(); if (CreateProcessAsUser( @@ -193,6 +193,55 @@ public bool RunAs(string processName, string args) return false; } + /// + /// Returns session IDs for sessions that have a logged-in user + /// whose token can be queried via WTSQueryUserToken. + /// + /// + /// + /// WTS session states relevant to telemetry pipe attachment: + /// + /// + /// StateMeaning + /// Active + /// User logged in, session connected (console or RDP). + /// Has a valid user token. This is the primary case. + /// + /// Connected + /// Client attached but no user logged in (e.g. the console + /// session showing the Windows login screen on a Cloud PC). + /// No user token — WTSQueryUserToken will fail. + /// + /// Disconnected + /// User logged in but client disconnected (e.g. closed RDP + /// window without logging off). The user's profile, processes, + /// and token are still available. Included so the service can + /// attach telemetry even when no client is actively connected. + /// + /// + /// + /// Session 0 is the services session and never has a user token, + /// even when its state is Disconnected. Excluded by the ID > 0 + /// guard. + /// + /// + public static List GetInteractiveSessionIds(ITracer tracer) + { + List sessionIds = new List(); + List sessions = ListSessions(tracer); + foreach (WTS_SESSION_INFO session in sessions) + { + if (session.SessionID > 0 && + (session.State == ConnectionState.Active || + session.State == ConnectionState.Disconnected)) + { + sessionIds.Add(session.SessionID); + } + } + + return sessionIds; + } + public void Dispose() { if (this.token != IntPtr.Zero) @@ -216,7 +265,10 @@ private static IntPtr GetCurrentUserToken(ITracer tracer, int sessionId) } else { - TraceWin32Error(tracer, string.Format("Unable to query user token from session '{0}'.", sessionId)); + // Warning, not error: sessions without a logged-in user + // (e.g. the console session on a Cloud PC) are expected. + Win32Exception e = new Win32Exception(Marshal.GetLastWin32Error()); + tracer.RelatedWarning("Unable to query user token from session '{0}'. Exception: {1}", sessionId, e.Message); } return IntPtr.Zero; @@ -234,12 +286,12 @@ private static List ListSessions(ITracer tracer) int retval = WTSEnumerateSessions(IntPtr.Zero, 0, 1, ref sessionInfo, ref count); if (retval != 0) { - int dataSize = Marshal.SizeOf(typeof(WTS_SESSION_INFO)); + int dataSize = Marshal.SizeOf(); long current = sessionInfo.ToInt64(); for (int i = 0; i < count; i++) { - WTS_SESSION_INFO si = (WTS_SESSION_INFO)Marshal.PtrToStructure((IntPtr)current, typeof(WTS_SESSION_INFO)); + WTS_SESSION_INFO si = Marshal.PtrToStructure((IntPtr)current); current += dataSize; output.Add(si); diff --git a/GVFS/GVFS.Service/GVFSService.Windows.cs b/GVFS/GVFS.Service/GVFSService.Windows.cs index c7f125b83..3b60ba104 100644 --- a/GVFS/GVFS.Service/GVFSService.Windows.cs +++ b/GVFS/GVFS.Service/GVFSService.Windows.cs @@ -5,6 +5,7 @@ using GVFS.Platform.Windows; using GVFS.Service.Handlers; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.AccessControl; @@ -46,6 +47,46 @@ public void Run() metadata.Add("Version", ProcessHelper.GetCurrentProcessVersion()); this.tracer.RelatedEvent(EventLevel.Informational, $"{nameof(GVFSService)}_{nameof(this.Run)}", metadata); + // Set up deferred telemetry pipe attachment FIRST, before any + // telemetry-emitting work (particularly PendingUpgradeHandler, + // whose Deferred/Complete events we want to capture). + // + // The service runs as SYSTEM and can't read the user's global + // git config (where gvfs.telemetry-pipe is configured) at + // startup. The DeferredTelemetryAttacher adds a + // BufferingTelemetryListener that captures events in memory, + // then replays them once the real pipe listener attaches. + // + // Three attach paths exist: + // 1. TryAttachTelemetryPipeForAnySessions() below — tries + // all Active/Disconnected sessions immediately. + // 2. OnSessionChange (SessionLogon) — fires when a new user + // logs in after the service is already running. + // 3. StartRetryTimer — periodic retry (10s, 30s, 1m, 5m) + // as a fallback, reads system config only (no user + // config available without a session to impersonate). + string gitBinRoot = GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath(); + if (!string.IsNullOrEmpty(gitBinRoot)) + { + this.telemetryAttacher = new DeferredTelemetryAttacher( + this.tracer, + GVFSConstants.Service.ServiceName, + enlistmentId: null, + mountId: null); + this.telemetryAttacher.StartRetryTimer(gitBinRoot); + + // If a user is already logged in (e.g. service restart + // during active session), try attaching immediately by + // enumerating all interactive sessions. This is needed + // because WTSGetActiveConsoleSessionId only returns the + // physical console session, which on Cloud PCs / DevBoxes + // (RDP-only) has no logged-in user. The actual user is + // in an RDP session that the console-only check misses. + // SessionLogon events also won't fire for sessions that + // were already established before the service started. + this.TryAttachTelemetryPipeForAnySessions(); + } + // 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. @@ -77,29 +118,6 @@ public void Run() { this.CheckEnableGitStatusCacheTokenFile(); - // Set up deferred telemetry pipe attachment. The service - // runs as SYSTEM and can't read the user's global git - // config at startup, so the daemon listener can't be - // created in the JsonTracer constructor. - string gitBinRoot = GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath(); - if (!string.IsNullOrEmpty(gitBinRoot)) - { - this.telemetryAttacher = new DeferredTelemetryAttacher( - this.tracer, - GVFSConstants.Service.ServiceName, - enlistmentId: null, - mountId: null); - this.telemetryAttacher.StartRetryTimer(gitBinRoot); - - // If a user is already logged in (e.g. service restart - // during active session), try attaching immediately. - int activeSession = NativeMethods.GetActiveConsoleSessionId(); - if (activeSession > 0) - { - this.TryAttachTelemetryPipeForSession(activeSession); - } - } - using (ITracer activity = this.tracer.StartActivity("EnsurePrjFltHealthy", EventLevel.Informational)) { // Make a best-effort to enable PrjFlt. Continue even if it fails. @@ -475,6 +493,50 @@ private void TryAttachTelemetryPipeForSession(int sessionId) } } + /// + /// Enumerates all interactive sessions (Active and Disconnected) + /// and tries to attach the telemetry pipe using each session's + /// user profile. Stops on the first successful attach. + /// + /// + /// This method exists because the console-only check + /// (WTSGetActiveConsoleSessionId) fails on Cloud PCs and + /// RDP-only machines where the console session is in the Connected + /// state (login screen, no user). Disconnected sessions are also + /// checked because an RDP user who disconnected without logging + /// off still has a valid token and git config. + /// + private void TryAttachTelemetryPipeForAnySessions() + { + if (this.telemetryAttacher == null || this.telemetryAttacher.IsAttached) + { + return; + } + + List sessionIds = CurrentUser.GetInteractiveSessionIds(this.tracer); + if (sessionIds.Count == 0) + { + this.tracer.RelatedInfo("TryAttachTelemetryPipeForAnySessions: No interactive sessions found"); + return; + } + + foreach (int sessionId in sessionIds) + { + this.TryAttachTelemetryPipeForSession(sessionId); + if (this.telemetryAttacher.IsAttached) + { + break; + } + } + + if (!this.telemetryAttacher.IsAttached) + { + this.tracer.RelatedWarning( + "TryAttachTelemetryPipeForAnySessions: Could not attach from any of {0} interactive session(s)", + sessionIds.Count); + } + } + private DirectorySecurity GetServiceDirectorySecurity(string serviceDataRootPath) { DirectorySecurity serviceDataRootSecurity; From 1ea8bbd7e5756f67180c47aee46b38ad85cd1085 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 2 Jun 2026 14:02:46 -0700 Subject: [PATCH 5/5] fix worktree mounts using primary enlistment's .gvfs paths Worktree mounts shared the primary enlistment's .gvfs metadata (placeholder database, RepoMetadata, diagnostics, corrupt objects) instead of using their own per-worktree .gvfs directory. This caused stale file content after git reset --hard in worktrees, because placeholder tracking was shared with the primary mount. Root cause: code throughout the codebase derived .gvfs paths from EnlistmentRoot, which always resolves to the primary enlistment root even for worktree mounts. The correct property is DotGVFSRoot, which resolves to the per-worktree .gvfs for worktree mounts and the primary .gvfs for primary mounts. Product fixes: - GVFSDatabase: accept dotGVFSRoot directly instead of deriving from EnlistmentRoot; update InProcessMount, SparseVerb, DiskLayoutUpgrade, EnlistmentPathData, ProfilingEnvironment call sites - GitRepo: resolve DotGVFSRoot at construction for corrupt object paths - GVFSVerb.InitializeLocalCacheAndObjectsPaths: use DotGVFSRoot for RepoMetadata (affects dehydrate, prefetch) - DiagnoseVerb: use DotGVFSRoot for .gvfs file collection and RepoMetadata initialization - CacheVerb: use DotGVFSRoot for RepoMetadata - RequiredGitConfig: narrow parameter to GVFSEnlistment, use DotGVFSRoot - GVFSLogsRoot: derive from DotGVFSRoot instead of re-deriving - GitMaintenanceStep: check WorkingDirectoryRoot for existence - HooksInstaller: use WorkingDirectoryRoot in error messages Rename to prevent recurrence: - Rename Enlistment.EnlistmentRoot to PrimaryEnlistmentRoot, forcing every caller to explicitly choose the right path property. Audit of all ~280 references across ~75 files confirmed each usage is correct. Test: - Add WorktreeUsesPerWorktreePlaceholderDatabase functional test Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/FastFetch/CheckoutPrefetcher.cs | 6 +- GVFS/FastFetch/FastFetchVerb.cs | 2 +- GVFS/FastFetch/GitEnlistment.cs | 2 +- GVFS/GVFS.Common/Database/GVFSDatabase.cs | 4 +- .../DiskLayoutUpgrade_SqlitePlaceholders.cs | 2 +- GVFS/GVFS.Common/Enlistment.cs | 4 +- GVFS/GVFS.Common/FileSystem/HooksInstaller.cs | 4 +- GVFS/GVFS.Common/GVFSEnlistment.cs | 6 +- GVFS/GVFS.Common/Git/GitRepo.cs | 8 +- GVFS/GVFS.Common/Git/RequiredGitConfig.cs | 5 +- .../HealthCalculator/EnlistmentPathData.cs | 2 +- GVFS/GVFS.Common/LocalCacheResolver.cs | 2 +- .../Maintenance/GitMaintenanceStep.cs | 2 +- .../EnlistmentPerFixture/WorktreeTests.cs | 95 +++++++++++++++++++ GVFS/GVFS.Mount/InProcessMount.cs | 12 +-- GVFS/GVFS.Mount/InProcessMountVerb.cs | 2 +- .../ProfilingEnvironment.cs | 2 +- .../Common/Database/GVFSDatabaseTests.cs | 2 +- .../Common/WorktreeEnlistmentTests.cs | 2 +- .../Maintenance/GitMaintenanceQueueTests.cs | 8 +- .../Maintenance/GitMaintenanceStepTests.cs | 2 +- .../Maintenance/LooseObjectStepTests.cs | 2 +- .../PackfileMaintenanceStepTests.cs | 2 +- .../Maintenance/PostFetchStepTests.cs | 2 +- GVFS/GVFS/CommandLine/CacheVerb.cs | 2 +- GVFS/GVFS/CommandLine/CloneVerb.cs | 14 +-- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 10 +- GVFS/GVFS/CommandLine/DiagnoseVerb.cs | 8 +- GVFS/GVFS/CommandLine/GVFSVerb.cs | 10 +- GVFS/GVFS/CommandLine/MountVerb.cs | 12 +-- GVFS/GVFS/CommandLine/PrefetchVerb.cs | 10 +- GVFS/GVFS/CommandLine/RepairVerb.cs | 6 +- GVFS/GVFS/CommandLine/SparseVerb.cs | 8 +- GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs | 2 +- 34 files changed, 181 insertions(+), 81 deletions(-) diff --git a/GVFS/FastFetch/CheckoutPrefetcher.cs b/GVFS/FastFetch/CheckoutPrefetcher.cs index e8a48a941..4064aa7bc 100644 --- a/GVFS/FastFetch/CheckoutPrefetcher.cs +++ b/GVFS/FastFetch/CheckoutPrefetcher.cs @@ -72,7 +72,7 @@ public override void Prefetch(string branchOrCommit, bool isBranch) commitToFetch = branchOrCommit; } - using (new IndexLock(this.Enlistment.EnlistmentRoot, this.Tracer)) + using (new IndexLock(this.Enlistment.PrimaryEnlistmentRoot, this.Tracer)) { this.DownloadMissingCommit(commitToFetch, this.GitObjects); @@ -124,7 +124,7 @@ public override void Prefetch(string branchOrCommit, bool isBranch) if (!indexGen.HasFailures) { Index newIndex = new Index( - this.Enlistment.EnlistmentRoot, + this.Enlistment.PrimaryEnlistmentRoot, this.Tracer, indexGen.TemporaryIndexFilePath, readOnly: false); @@ -200,7 +200,7 @@ private Index GetSourceIndex() if (File.Exists(indexPath)) { - Index output = new Index(this.Enlistment.EnlistmentRoot, this.Tracer, indexPath, readOnly: true); + Index output = new Index(this.Enlistment.PrimaryEnlistmentRoot, this.Tracer, indexPath, readOnly: true); output.Parse(); return output; } diff --git a/GVFS/FastFetch/FastFetchVerb.cs b/GVFS/FastFetch/FastFetchVerb.cs index 27c5b1c4f..737b31ffe 100644 --- a/GVFS/FastFetch/FastFetchVerb.cs +++ b/GVFS/FastFetch/FastFetchVerb.cs @@ -240,7 +240,7 @@ private int ExecuteWithExitCode() CacheServerInfo cacheServer = new CacheServerInfo(this.GetRemoteUrl(enlistment), null); tracer.WriteStartEvent( - enlistment.EnlistmentRoot, + enlistment.PrimaryEnlistmentRoot, enlistment.RepoUrl, cacheServer.Url, new EventMetadata diff --git a/GVFS/FastFetch/GitEnlistment.cs b/GVFS/FastFetch/GitEnlistment.cs index fd08cdda0..2fec7cbbc 100644 --- a/GVFS/FastFetch/GitEnlistment.cs +++ b/GVFS/FastFetch/GitEnlistment.cs @@ -29,7 +29,7 @@ private GitEnlistment(string repoRoot, string gitBinPath) public string FastFetchLogRoot { - get { return Path.Combine(this.EnlistmentRoot, GVFSConstants.DotGit.Root, ".fastfetch"); } + get { return Path.Combine(this.PrimaryEnlistmentRoot, GVFSConstants.DotGit.Root, ".fastfetch"); } } public static GitEnlistment CreateFromCurrentDirectory(string gitBinPath) diff --git a/GVFS/GVFS.Common/Database/GVFSDatabase.cs b/GVFS/GVFS.Common/Database/GVFSDatabase.cs index 4e191a7ff..5104c504a 100644 --- a/GVFS/GVFS.Common/Database/GVFSDatabase.cs +++ b/GVFS/GVFS.Common/Database/GVFSDatabase.cs @@ -21,10 +21,10 @@ public class GVFSDatabase : IGVFSConnectionPool, IDisposable private IDbConnectionFactory connectionFactory; private BlockingCollection connectionPool; - public GVFSDatabase(PhysicalFileSystem fileSystem, string enlistmentRoot, IDbConnectionFactory connectionFactory, int initialPooledConnections = InitialPooledConnections) + public GVFSDatabase(PhysicalFileSystem fileSystem, string dotGVFSRoot, IDbConnectionFactory connectionFactory, int initialPooledConnections = InitialPooledConnections) { this.connectionPool = new BlockingCollection(); - this.databasePath = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.VFSForGit); + this.databasePath = Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.Databases.VFSForGit); this.connectionFactory = connectionFactory; string folderPath = Path.GetDirectoryName(this.databasePath); diff --git a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade_SqlitePlaceholders.cs b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade_SqlitePlaceholders.cs index eeffd14e0..e5a295480 100644 --- a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade_SqlitePlaceholders.cs +++ b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade_SqlitePlaceholders.cs @@ -30,7 +30,7 @@ public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) } using (placeholderList) - using (GVFSDatabase database = new GVFSDatabase(fileSystem, enlistmentRoot, new SqliteDatabase())) + using (GVFSDatabase database = new GVFSDatabase(fileSystem, Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot), new SqliteDatabase())) { PlaceholderTable placeholders = new PlaceholderTable(database); List oldPlaceholderEntries = placeholderList.GetAllEntries(); diff --git a/GVFS/GVFS.Common/Enlistment.cs b/GVFS/GVFS.Common/Enlistment.cs index 5dcfd9e54..a9208b0ed 100644 --- a/GVFS/GVFS.Common/Enlistment.cs +++ b/GVFS/GVFS.Common/Enlistment.cs @@ -21,7 +21,7 @@ protected Enlistment( throw new ArgumentException("Path to git.exe must be set"); } - this.EnlistmentRoot = enlistmentRoot; + this.PrimaryEnlistmentRoot = enlistmentRoot; this.WorkingDirectoryRoot = workingDirectoryRoot; this.WorkingDirectoryBackingRoot = workingDirectoryBackingRoot; this.DotGitRoot = Path.Combine(this.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Root); @@ -52,7 +52,7 @@ protected Enlistment( this.Authentication = authentication ?? new GitAuthentication(gitProcess, this.RepoUrl); } - public string EnlistmentRoot { get; } + public string PrimaryEnlistmentRoot { get; } // Path to the root of the working (i.e. "src") directory. // On platforms where the contents of the working directory are stored diff --git a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs index f7dfae0fc..bdf6a03cb 100644 --- a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs +++ b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs @@ -229,7 +229,7 @@ private static bool TryUpdateHook( metadata.Add(nameof(installedHookPath), installedHookPath); metadata.Add("Exception", e.ToString()); context.Tracer.RelatedError(metadata, "Failed to compare " + hookName + " version"); - errorMessage = "Error comparing " + hookName + " versions. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot); + errorMessage = "Error comparing " + hookName + " versions. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.WorkingDirectoryRoot); return false; } } @@ -248,7 +248,7 @@ private static bool TryUpdateHook( metadata.Add(nameof(installedHookPath), installedHookPath); metadata.Add("Exception", e.ToString()); context.Tracer.RelatedError(metadata, "Failed to copy " + hookName + " to enlistment"); - errorMessage = "Error copying " + hookName + " to enlistment. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot); + errorMessage = "Error copying " + hookName + " to enlistment. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.WorkingDirectoryRoot); return false; } } diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index e0b18b28b..286b32037 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -30,11 +30,11 @@ public GVFSEnlistment(string enlistmentRoot, string repoUrl, string gitBinPath, flushFileBuffersForPacks: true, authentication: authentication) { - this.NamedPipeName = GVFSPlatform.Instance.GetNamedPipeName(this.EnlistmentRoot); - this.DotGVFSRoot = Path.Combine(this.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); + this.NamedPipeName = GVFSPlatform.Instance.GetNamedPipeName(this.PrimaryEnlistmentRoot); + this.DotGVFSRoot = Path.Combine(this.PrimaryEnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); this.GitStatusCacheFolder = Path.Combine(this.DotGVFSRoot, GVFSConstants.DotGVFS.GitStatusCache.Name); this.GitStatusCachePath = Path.Combine(this.DotGVFSRoot, GVFSConstants.DotGVFS.GitStatusCache.CachePath); - this.GVFSLogsRoot = Path.Combine(this.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, GVFSConstants.DotGVFS.LogName); + this.GVFSLogsRoot = Path.Combine(this.DotGVFSRoot, GVFSConstants.DotGVFS.LogName); this.LocalObjectsRoot = Path.Combine(this.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Objects.Root); } diff --git a/GVFS/GVFS.Common/Git/GitRepo.cs b/GVFS/GVFS.Common/Git/GitRepo.cs index e5aefa579..d88ebbd89 100644 --- a/GVFS/GVFS.Common/Git/GitRepo.cs +++ b/GVFS/GVFS.Common/Git/GitRepo.cs @@ -16,6 +16,7 @@ public class GitRepo : IDisposable private PhysicalFileSystem fileSystem; private LibGit2RepoInvoker libgit2RepoInvoker; private Enlistment enlistment; + private string dotGVFSRoot; public GitRepo(ITracer tracer, Enlistment enlistment, PhysicalFileSystem fileSystem, Func repoFactory = null) { @@ -23,6 +24,11 @@ public GitRepo(ITracer tracer, Enlistment enlistment, PhysicalFileSystem fileSys this.enlistment = enlistment; this.fileSystem = fileSystem; + // Resolve the per-worktree .gvfs root if available; otherwise + // derive from EnlistmentRoot (primary enlistments). + this.dotGVFSRoot = (enlistment as GVFSEnlistment)?.DotGVFSRoot + ?? Path.Combine(enlistment.PrimaryEnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); + this.GVFSLock = new GVFSLock(tracer); this.libgit2RepoInvoker = new LibGit2RepoInvoker( @@ -240,7 +246,7 @@ private LooseBlobState GetLooseBlobStateAtPath(string blobPath, Action - public static Dictionary GetRequiredSettings(Enlistment enlistment) + public static Dictionary GetRequiredSettings(GVFSEnlistment enlistment) { string expectedHooksPath = Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.Hooks.RootName); expectedHooksPath = Paths.ConvertPathToGitFormat(expectedHooksPath); @@ -31,8 +31,7 @@ public static Dictionary GetRequiredSettings(Enlistment enlistme if (!GVFSEnlistment.IsUnattended(tracer: null) && GVFSPlatform.Instance.IsGitStatusCacheSupported()) { gitStatusCachePath = Path.Combine( - enlistment.EnlistmentRoot, - GVFSPlatform.Instance.Constants.DotGVFSRoot, + enlistment.DotGVFSRoot, GVFSConstants.DotGVFS.GitStatusCache.CachePath); gitStatusCachePath = Paths.ConvertPathToGitFormat(gitStatusCachePath); diff --git a/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs b/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs index 8a6df6818..d56cfc8bd 100644 --- a/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs +++ b/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs @@ -54,7 +54,7 @@ public void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment) List filePlaceholders = new List(); List folderPlaceholders = new List(); - using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), enlistment.EnlistmentRoot, new SqliteDatabase())) + using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), enlistment.DotGVFSRoot, new SqliteDatabase())) { PlaceholderTable placeholderTable = new PlaceholderTable(database); placeholderTable.GetAllEntries(out filePlaceholders, out folderPlaceholders); diff --git a/GVFS/GVFS.Common/LocalCacheResolver.cs b/GVFS/GVFS.Common/LocalCacheResolver.cs index b0c31dc86..dbdb261e0 100644 --- a/GVFS/GVFS.Common/LocalCacheResolver.cs +++ b/GVFS/GVFS.Common/LocalCacheResolver.cs @@ -33,7 +33,7 @@ public static bool TryGetDefaultLocalCacheRoot(GVFSEnlistment enlistment, out st return true; } - return GVFSPlatform.Instance.TryGetDefaultLocalCacheRoot(enlistment.EnlistmentRoot, out localCacheRoot, out localCacheRootError); + return GVFSPlatform.Instance.TryGetDefaultLocalCacheRoot(enlistment.PrimaryEnlistmentRoot, out localCacheRoot, out localCacheRootError); } public bool TryGetLocalCacheKeyFromLocalConfigOrRemoteCacheServers( diff --git a/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs b/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs index ae14ab4ce..1df648fc4 100644 --- a/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs +++ b/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs @@ -36,7 +36,7 @@ public static bool EnlistmentRootReady(GVFSContext context) // a "Device is not ready" error. try { - return context.FileSystem.DirectoryExists(context.Enlistment.EnlistmentRoot) + return context.FileSystem.DirectoryExists(context.Enlistment.WorkingDirectoryRoot) && context.FileSystem.DirectoryExists(context.Enlistment.GitObjectsRoot); } catch (IOException) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 1f8501043..7de8bb680 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -268,6 +268,101 @@ public void WorktreeOutsideEnlistmentTree() } } + [TestCase] + public void WorktreeUsesPerWorktreePlaceholderDatabase() + { + // Two commits where test4.txt has different content + const string CommitA = "5d7a7d4db1734fb468a4094469ec58d26301b59d"; + const string CommitB = "fec239ea12de1eda6ae5329d4f345784d5b61ff9"; + string testFileRelativePath = Path.Combine( + "Test_EPF_UpdatePlaceholderTests", + "LockToPreventUpdateAndDelete", + "test4.txt"); + const string ContentAtCommitA = "TestFileLockToPreventUpdateAndDelete4 \r\n"; + const string ContentAtCommitB = "Commit2LockToPreventUpdateAndDelete4 \r\n"; + + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string tempDir = Path.Combine(Path.GetTempPath(), $"gvfs-db-test-{suffix}"); + string worktreePath = Path.Combine(tempDir, "wt"); + string branchName = $"db-test-branch-{suffix}"; + + try + { + Directory.CreateDirectory(tempDir); + + // 1. Checkout commitB in the primary and read the file to + // create a placeholder with version B content. + GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, $"checkout {CommitB}") + .ExitCode.ShouldEqual(0, "checkout commitB in primary failed"); + + string primaryFilePath = Path.Combine(this.Enlistment.RepoRoot, testFileRelativePath); + File.ReadAllText(primaryFilePath).ShouldEqual(ContentAtCommitB, + "Primary should have version B content"); + + // 2. Create a worktree at commitA and read the same file. + // Without the fix, the worktree mount writes the placeholder + // entry (SHA_A) to the primary's DB, overwriting SHA_B. + ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {branchName} \"{worktreePath}\" {CommitA}"); + addResult.ExitCode.ShouldEqual(0, + $"worktree add failed: {addResult.Errors}"); + + this.AssertWorktreeMounted(worktreePath, "db-test worktree"); + + string worktreeFilePath = Path.Combine(worktreePath, testFileRelativePath); + File.ReadAllText(worktreeFilePath).ShouldEqual(ContentAtCommitA, + "Worktree should have version A content"); + + // 3. Checkout commitA in the primary. UpdatePlaceholders reads + // the placeholder DB to find files that need updating. + // With a contaminated DB (SHA_A from worktree), the primary's + // placeholder (on-disk SHA_B) looks up-to-date (DB says SHA_A, + // index wants SHA_A — match) and the update is skipped. + // The file retains stale commitB content. + GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, $"checkout {CommitA}") + .ExitCode.ShouldEqual(0, "checkout commitA in primary failed"); + + string contentAfterCheckout = File.ReadAllText(primaryFilePath); + contentAfterCheckout.ShouldEqual(ContentAtCommitA, + $"After checkout to commitA, primary should have version A content.\n" + + $"Got version B content instead — the worktree mount contaminated\n" + + $"the primary's placeholder DB, causing UpdatePlaceholders to skip\n" + + $"the update because the DB SHA matched the index SHA."); + + // 4. Verify the per-worktree placeholder DB exists (isolation check) + GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(worktreePath); + Assert.IsNotNull(wtInfo, "Should be able to resolve worktree info"); + string worktreeDbPath = Path.Combine( + wtInfo.WorktreeGitDir, ".gvfs", "databases", "VFSForGit.sqlite"); + + bool dbExists = false; + for (int i = 0; i < 20; i++) + { + if (File.Exists(worktreeDbPath)) + { + dbExists = true; + break; + } + + System.Threading.Thread.Sleep(500); + } + + Assert.IsTrue(dbExists, + $"Per-worktree VFSForGit.sqlite should exist at {worktreeDbPath}"); + } + finally + { + this.ForceCleanupWorktree(worktreePath, branchName); + if (Directory.Exists(tempDir)) + { + try { Directory.Delete(tempDir, recursive: true); } catch { } + } + } + } + private void InitWorktreeArrays(int count, out string[] paths, out string[] branches) { paths = new string[count]; diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 824940a0f..1272eb876 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -211,7 +211,7 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) this.FailMountAndExit("The .git folder is missing or has invalid contents"); } - if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(this.enlistment.EnlistmentRoot, out string fsError)) + if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(this.enlistment.WorkingDirectoryRoot, out string fsError)) { this.FailMountAndExit("FileSystem unsupported: " + fsError); } @@ -277,7 +277,7 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) try { - Console.Title = "GVFS " + ProcessHelper.GetCurrentProcessVersion() + " - " + this.enlistment.EnlistmentRoot; + Console.Title = "GVFS " + ProcessHelper.GetCurrentProcessVersion() + " - " + this.enlistment.WorkingDirectoryRoot; } catch (IOException) { @@ -365,7 +365,7 @@ private void InitializeWorktreeMetadata() // Use try/finally to guarantee Shutdown() even if an unexpected // exception occurs — the singleton must not be left pointing at // the primary's metadata directory. - string primaryDotGVFS = Path.Combine(this.enlistment.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); + string primaryDotGVFS = Path.Combine(this.enlistment.PrimaryEnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); string error; string gitObjectsRoot; string localCacheRoot; @@ -996,7 +996,7 @@ private void HandlePostFetchJobRequest(NamedPipeMessages.Message message, NamedP private void HandleGetStatusRequest(NamedPipeServer.Connection connection) { NamedPipeMessages.GetStatus.Response response = new NamedPipeMessages.GetStatus.Response(); - response.EnlistmentRoot = this.enlistment.EnlistmentRoot; + response.EnlistmentRoot = this.enlistment.WorkingDirectoryRoot; response.LocalCacheRoot = !string.IsNullOrWhiteSpace(this.enlistment.LocalCacheRoot) ? this.enlistment.LocalCacheRoot : this.enlistment.GitObjectsRoot; response.RepoUrl = this.enlistment.RepoUrl; response.CacheServer = this.cacheServer.ToString(); @@ -1082,7 +1082,7 @@ private void MountAndStartWorkingDirectoryCallbacks(CacheServerInfo cache, bool this.tracer.RelatedInfo("Git status cache is not enabled"); } - this.gvfsDatabase = this.CreateOrReportAndExit(() => new GVFSDatabase(this.context.FileSystem, this.context.Enlistment.EnlistmentRoot, new SqliteDatabase()), "Failed to create database connection"); + this.gvfsDatabase = this.CreateOrReportAndExit(() => new GVFSDatabase(this.context.FileSystem, this.context.Enlistment.DotGVFSRoot, new SqliteDatabase()), "Failed to create database connection"); this.fileSystemCallbacks = this.CreateOrReportAndExit( () => { @@ -1193,7 +1193,7 @@ private void ValidateFileSystemSupportsRequiredFeatures() { string warning; string error; - if (!GVFSPlatform.Instance.KernelDriver.IsSupported(this.enlistment.EnlistmentRoot, out warning, out error)) + if (!GVFSPlatform.Instance.KernelDriver.IsSupported(this.enlistment.WorkingDirectoryRoot, out warning, out error)) { this.FailMountAndExit("Error: {0}", error); } diff --git a/GVFS/GVFS.Mount/InProcessMountVerb.cs b/GVFS/GVFS.Mount/InProcessMountVerb.cs index 309d56fbd..16328f60d 100644 --- a/GVFS/GVFS.Mount/InProcessMountVerb.cs +++ b/GVFS/GVFS.Mount/InProcessMountVerb.cs @@ -115,7 +115,7 @@ public void Execute() CacheServerInfo cacheServer = CacheServerResolver.GetCacheServerFromConfig(enlistment); tracer.WriteStartEvent( - enlistment.EnlistmentRoot, + enlistment.WorkingDirectoryRoot, enlistment.RepoUrl, cacheServer.Url, new EventMetadata diff --git a/GVFS/GVFS.PerfProfiling/ProfilingEnvironment.cs b/GVFS/GVFS.PerfProfiling/ProfilingEnvironment.cs index 4798c85c3..65145acba 100644 --- a/GVFS/GVFS.PerfProfiling/ProfilingEnvironment.cs +++ b/GVFS/GVFS.PerfProfiling/ProfilingEnvironment.cs @@ -76,7 +76,7 @@ private FileSystemCallbacks CreateFileSystemCallbacks() cacheServer, new RetryConfig()); - this.gvfsDatabase = new GVFSDatabase(this.Context.FileSystem, this.Context.Enlistment.EnlistmentRoot, new SqliteDatabase()); + this.gvfsDatabase = new GVFSDatabase(this.Context.FileSystem, this.Context.Enlistment.DotGVFSRoot, new SqliteDatabase()); GVFSGitObjects gitObjects = new GVFSGitObjects(this.Context, objectRequestor); return new FileSystemCallbacks( this.Context, diff --git a/GVFS/GVFS.UnitTests/Common/Database/GVFSDatabaseTests.cs b/GVFS/GVFS.UnitTests/Common/Database/GVFSDatabaseTests.cs index 4ec88b34c..6617b2329 100644 --- a/GVFS/GVFS.UnitTests/Common/Database/GVFSDatabaseTests.cs +++ b/GVFS/GVFS.UnitTests/Common/Database/GVFSDatabaseTests.cs @@ -113,7 +113,7 @@ private void TestGVFSDatabase(Action testCode, bool throwException Mock mockConnectionFactory = new Mock(MockBehavior.Strict); bool firstConnection = true; - string databasePath = Path.Combine("mock:root", ".mockvfsforgit", "databases", "VFSForGit.sqlite"); + string databasePath = Path.Combine("mock:root", "databases", "VFSForGit.sqlite"); mockConnectionFactory.Setup(x => x.OpenNewConnection(databasePath)).Returns(() => { if (firstConnection) diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs index 2541e56ac..dd072b1e5 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs @@ -145,7 +145,7 @@ public void DotGVFSRootIsInWorktreeGitDir() public void EnlistmentRootIsPrimaryRoot() { GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); - enlistment.EnlistmentRoot.ShouldEqual(this.primaryRoot); + enlistment.PrimaryEnlistmentRoot.ShouldEqual(this.primaryRoot); } [TestCase] diff --git a/GVFS/GVFS.UnitTests/Maintenance/GitMaintenanceQueueTests.cs b/GVFS/GVFS.UnitTests/Maintenance/GitMaintenanceQueueTests.cs index c284e3cfc..4897e9976 100644 --- a/GVFS/GVFS.UnitTests/Maintenance/GitMaintenanceQueueTests.cs +++ b/GVFS/GVFS.UnitTests/Maintenance/GitMaintenanceQueueTests.cs @@ -28,13 +28,13 @@ public void GitMaintenanceQueueEnlistmentRootReady() GitMaintenanceQueue queue = new GitMaintenanceQueue(this.context); queue.EnlistmentRootReady().ShouldBeTrue(); - this.fileSystem.Paths.Remove(this.enlistment.EnlistmentRoot); + this.fileSystem.Paths.Remove(this.enlistment.WorkingDirectoryRoot); queue.EnlistmentRootReady().ShouldBeFalse(); this.fileSystem.Paths.Remove(this.enlistment.GitObjectsRoot); queue.EnlistmentRootReady().ShouldBeFalse(); - this.fileSystem.Paths.Add(this.enlistment.EnlistmentRoot); + this.fileSystem.Paths.Add(this.enlistment.WorkingDirectoryRoot); queue.EnlistmentRootReady().ShouldBeFalse(); this.fileSystem.Paths.Add(this.enlistment.GitObjectsRoot); @@ -108,10 +108,10 @@ private void TestSetup() ITracer tracer = new MockTracer(); this.enlistment = new MockGVFSEnlistment(); - // We need to have the EnlistmentRoot and GitObjectsRoot available for jobs to run + // We need to have the WorkingDirectoryRoot and GitObjectsRoot available for jobs to run this.fileSystem = new ReadyFileSystem(new string[] { - this.enlistment.EnlistmentRoot, + this.enlistment.WorkingDirectoryRoot, this.enlistment.GitObjectsRoot }); diff --git a/GVFS/GVFS.UnitTests/Maintenance/GitMaintenanceStepTests.cs b/GVFS/GVFS.UnitTests/Maintenance/GitMaintenanceStepTests.cs index e1f6b134a..d37236fbc 100644 --- a/GVFS/GVFS.UnitTests/Maintenance/GitMaintenanceStepTests.cs +++ b/GVFS/GVFS.UnitTests/Maintenance/GitMaintenanceStepTests.cs @@ -80,7 +80,7 @@ private void TestSetup() { ITracer tracer = new MockTracer(); GVFSEnlistment enlistment = new MockGVFSEnlistment(); - PhysicalFileSystem fileSystem = new MockFileSystem(new MockDirectory(enlistment.EnlistmentRoot, null, null)); + PhysicalFileSystem fileSystem = new MockFileSystem(new MockDirectory(enlistment.PrimaryEnlistmentRoot, null, null)); this.context = new GVFSContext(tracer, fileSystem, null, enlistment); } diff --git a/GVFS/GVFS.UnitTests/Maintenance/LooseObjectStepTests.cs b/GVFS/GVFS.UnitTests/Maintenance/LooseObjectStepTests.cs index d1d8d4b94..47f699889 100644 --- a/GVFS/GVFS.UnitTests/Maintenance/LooseObjectStepTests.cs +++ b/GVFS/GVFS.UnitTests/Maintenance/LooseObjectStepTests.cs @@ -248,7 +248,7 @@ private void TestSetup(DateTime lastRun) // Add object directory to file System List directories = new List() { gitObjectsRoot }; - PhysicalFileSystem fileSystem = new MockFileSystem(new MockDirectory(enlistment.EnlistmentRoot, directories, null)); + PhysicalFileSystem fileSystem = new MockFileSystem(new MockDirectory(enlistment.PrimaryEnlistmentRoot, directories, null)); // Create and return Context this.tracer = new MockTracer(); diff --git a/GVFS/GVFS.UnitTests/Maintenance/PackfileMaintenanceStepTests.cs b/GVFS/GVFS.UnitTests/Maintenance/PackfileMaintenanceStepTests.cs index c9e0f8939..811b55e15 100644 --- a/GVFS/GVFS.UnitTests/Maintenance/PackfileMaintenanceStepTests.cs +++ b/GVFS/GVFS.UnitTests/Maintenance/PackfileMaintenanceStepTests.cs @@ -219,7 +219,7 @@ private void TestSetup(DateTime lastRun, bool failOnVerify = false) // Add object directory to file System List directories = new List() { gitObjectsRoot }; - PhysicalFileSystem fileSystem = new MockFileSystem(new MockDirectory(enlistment.EnlistmentRoot, directories, null)); + PhysicalFileSystem fileSystem = new MockFileSystem(new MockDirectory(enlistment.PrimaryEnlistmentRoot, directories, null)); MockGitRepo repository = new MockGitRepo(this.tracer, enlistment, fileSystem); diff --git a/GVFS/GVFS.UnitTests/Maintenance/PostFetchStepTests.cs b/GVFS/GVFS.UnitTests/Maintenance/PostFetchStepTests.cs index 8f1b35f6b..a35f11a59 100644 --- a/GVFS/GVFS.UnitTests/Maintenance/PostFetchStepTests.cs +++ b/GVFS/GVFS.UnitTests/Maintenance/PostFetchStepTests.cs @@ -91,7 +91,7 @@ private void TestSetup() // Create enlistment using git process GVFSEnlistment enlistment = new MockGVFSEnlistment(this.gitProcess); - PhysicalFileSystem fileSystem = new MockFileSystem(new MockDirectory(enlistment.EnlistmentRoot, null, null)); + PhysicalFileSystem fileSystem = new MockFileSystem(new MockDirectory(enlistment.PrimaryEnlistmentRoot, null, null)); // Create and return Context this.tracer = new MockTracer(); diff --git a/GVFS/GVFS/CommandLine/CacheVerb.cs b/GVFS/GVFS/CommandLine/CacheVerb.cs index b576d4275..8b6538348 100644 --- a/GVFS/GVFS/CommandLine/CacheVerb.cs +++ b/GVFS/GVFS/CommandLine/CacheVerb.cs @@ -216,7 +216,7 @@ private void GetLocalCachePaths(ITracer tracer, GVFSEnlistment enlistment, out s try { string error; - if (RepoMetadata.TryInitialize(tracer, Path.Combine(enlistment.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot), out error)) + if (RepoMetadata.TryInitialize(tracer, enlistment.DotGVFSRoot, out error)) { if (!RepoMetadata.Instance.TryGetLocalCacheRoot(out localCacheRoot, out error)) { diff --git a/GVFS/GVFS/CommandLine/CloneVerb.cs b/GVFS/GVFS/CommandLine/CloneVerb.cs index e64749d8c..977c5b082 100644 --- a/GVFS/GVFS/CommandLine/CloneVerb.cs +++ b/GVFS/GVFS/CommandLine/CloneVerb.cs @@ -162,9 +162,9 @@ public override void Execute() // Create the enlistment root explicitly with CreateDirectoryAccessibleByAuthUsers before calling // AddLogFileEventListener to ensure that elevated and non-elevated users have access to the root. string createDirectoryError; - if (!GVFSPlatform.Instance.FileSystem.TryCreateDirectoryAccessibleByAuthUsers(enlistment.EnlistmentRoot, out createDirectoryError)) + if (!GVFSPlatform.Instance.FileSystem.TryCreateDirectoryAccessibleByAuthUsers(enlistment.PrimaryEnlistmentRoot, out createDirectoryError)) { - this.ReportErrorAndExit($"Failed to create '{enlistment.EnlistmentRoot}': {createDirectoryError}"); + this.ReportErrorAndExit($"Failed to create '{enlistment.PrimaryEnlistmentRoot}': {createDirectoryError}"); } tracer.AddLogFileEventListener( @@ -172,7 +172,7 @@ public override void Execute() EventLevel.Informational, Keywords.Any); tracer.WriteStartEvent( - enlistment.EnlistmentRoot, + enlistment.PrimaryEnlistmentRoot, enlistment.RepoUrl, this.CacheServerUrl, new EventMetadata @@ -214,7 +214,7 @@ public override void Execute() this.Output.WriteLine(" Branch: " + (string.IsNullOrWhiteSpace(this.Branch) ? "Default" : this.Branch)); this.Output.WriteLine(" Cache Server: " + cacheServer); this.Output.WriteLine(" Local Cache: " + resolvedLocalCacheRoot); - this.Output.WriteLine(" Destination: " + enlistment.EnlistmentRoot); + this.Output.WriteLine(" Destination: " + enlistment.PrimaryEnlistmentRoot); RetryConfig retryConfig = this.GetRetryConfig(tracer, enlistment, TimeSpan.FromMinutes(RetryConfig.FetchAndCloneTimeoutMinutes)); @@ -291,7 +291,7 @@ public override void Execute() { UseShellExecute = true, WindowStyle = ProcessWindowStyle.Minimized, - WorkingDirectory = enlistment.EnlistmentRoot + WorkingDirectory = enlistment.PrimaryEnlistmentRoot }); this.Output.WriteLine("\r\nPrefetch of commit graph has been started as a background process. Git operations involving history may be slower until prefetch has completed.\r\n"); } @@ -457,7 +457,7 @@ private Result TryClone( return new Result(error); } - if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(enlistment.EnlistmentRoot, out string fsError)) + if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(enlistment.PrimaryEnlistmentRoot, out string fsError)) { string error = $"FileSystem unsupported: {fsError}"; tracer.RelatedError(error); @@ -754,7 +754,7 @@ private Result CreateClone( // TODO(#1364): Don't call this method on POSIX platforms (or have it no-op on them) private void CreateGitScript(GVFSEnlistment enlistment) { - FileInfo gitCmd = new FileInfo(Path.Combine(enlistment.EnlistmentRoot, "git.cmd")); + FileInfo gitCmd = new FileInfo(Path.Combine(enlistment.PrimaryEnlistmentRoot, "git.cmd")); using (FileStream fs = gitCmd.Create()) using (StreamWriter writer = new StreamWriter(fs)) { diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 2d7b7f958..28ce30357 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -91,7 +91,7 @@ protected override void Execute(GVFSEnlistment enlistment) EventLevel.Informational, Keywords.Any); tracer.WriteStartEvent( - enlistment.EnlistmentRoot, + enlistment.PrimaryEnlistmentRoot, enlistment.RepoUrl, CacheServerResolver.GetUrlFromConfig(enlistment), new EventMetadata @@ -160,7 +160,7 @@ any of your uncommitted changes. in the backup folder, but it will be harder to find them because 'git status' will not work in the backup. -To actually execute the dehydrate, run 'gvfs dehydrate --confirm --full' from {enlistment.EnlistmentRoot}. +To actually execute the dehydrate, run 'gvfs dehydrate --confirm --full' from {enlistment.PrimaryEnlistmentRoot}. "); return; @@ -196,13 +196,13 @@ from a parent of the folders list. if (fullDehydrate && Environment.CurrentDirectory.StartsWith(enlistment.WorkingDirectoryBackingRoot)) { /* If running from /src, the dehydrate would fail because of the handle we are holding on it. */ - this.Output.WriteLine($"Dehydrate --full must be run from {enlistment.EnlistmentRoot}"); + this.Output.WriteLine($"Dehydrate --full must be run from {enlistment.PrimaryEnlistmentRoot}"); return; } bool cleanStatus = this.StatusChecked || this.CheckGitStatus(tracer, enlistment, fullDehydrate); - string backupRoot = Path.GetFullPath(Path.Combine(enlistment.EnlistmentRoot, "dehydrate_backup", DateTime.Now.ToString("yyyyMMdd_HHmmss"))); + string backupRoot = Path.GetFullPath(Path.Combine(enlistment.PrimaryEnlistmentRoot, "dehydrate_backup", DateTime.Now.ToString("yyyyMMdd_HHmmss"))); this.Output.WriteLine(); if (fullDehydrate) @@ -223,7 +223,7 @@ from a parent of the folders list. this.Unmount(tracer); string error; - if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer, enlistment.EnlistmentRoot, out error)) + if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer, enlistment.PrimaryEnlistmentRoot, out error)) { this.ReportErrorAndExit(tracer, error); } diff --git a/GVFS/GVFS/CommandLine/DiagnoseVerb.cs b/GVFS/GVFS/CommandLine/DiagnoseVerb.cs index d1a624db1..928b104df 100644 --- a/GVFS/GVFS/CommandLine/DiagnoseVerb.cs +++ b/GVFS/GVFS/CommandLine/DiagnoseVerb.cs @@ -77,7 +77,7 @@ protected override void Execute(GVFSEnlistment enlistment) this.WriteMessage(enlistment.GitBinPath); this.WriteMessage(string.Empty); - this.WriteMessage("Enlistment root: " + enlistment.EnlistmentRoot); + this.WriteMessage("Enlistment root: " + enlistment.PrimaryEnlistmentRoot); this.WriteMessage("Cache Server: " + CacheServerResolver.GetCacheServerFromConfig(enlistment)); string localCacheRoot; @@ -101,8 +101,8 @@ protected override void Execute(GVFSEnlistment enlistment) this.ShowStatusWhileRunning( () => { - // .gvfs - this.CopyAllFiles(enlistment.EnlistmentRoot, archiveFolderPath, GVFSPlatform.Instance.Constants.DotGVFSRoot, copySubFolders: false); + // .gvfs (top-level files like mount.lock) + this.CopyAllFiles(Path.GetDirectoryName(enlistment.DotGVFSRoot), archiveFolderPath, Path.GetFileName(enlistment.DotGVFSRoot), copySubFolders: false); // driver if (this.FlushKernelDriverLogs()) @@ -274,7 +274,7 @@ private void GetLocalCachePaths(GVFSEnlistment enlistment, out string localCache using (ITracer tracer = new JsonTracer(GVFSConstants.GVFSEtwProviderName, "DiagnoseVerb")) { string error; - if (RepoMetadata.TryInitialize(tracer, Path.Combine(enlistment.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot), out error)) + if (RepoMetadata.TryInitialize(tracer, enlistment.DotGVFSRoot, out error)) { RepoMetadata.Instance.TryGetLocalCacheRoot(out localCacheRoot, out error); RepoMetadata.Instance.TryGetGitObjectsRoot(out gitObjectsRoot, out error); diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs index c44608daf..069ac1661 100644 --- a/GVFS/GVFS/CommandLine/GVFSVerb.cs +++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs @@ -96,7 +96,7 @@ public string ServicePipeName protected abstract string VerbName { get; } - public static bool TrySetRequiredGitConfigSettings(Enlistment enlistment) + public static bool TrySetRequiredGitConfigSettings(GVFSEnlistment enlistment) { Dictionary requiredSettings = RequiredGitConfig.GetRequiredSettings(enlistment); @@ -164,7 +164,7 @@ protected ReturnCode Execute( where TVerb : GVFSVerb.ForExistingEnlistment, new() { TVerb verb = new TVerb(); - verb.EnlistmentRootPathParameter = enlistment.EnlistmentRoot; + verb.EnlistmentRootPathParameter = enlistment.WorkingDirectoryRoot; verb.ServiceName = this.ServiceName; verb.Unattended = this.Unattended; @@ -246,7 +246,7 @@ protected bool TryAuthenticateAndQueryGVFSConfig( out error, out _), "Authenticating", - enlistment.EnlistmentRoot); + enlistment.WorkingDirectoryRoot); if (!result && fallbackCacheServer != null && !string.IsNullOrWhiteSpace(fallbackCacheServer.Url)) { @@ -677,7 +677,7 @@ private void CheckFileSystemSupportsRequiredFeatures(ITracer tracer, Enlistment { string warning; string error; - if (!GVFSPlatform.Instance.KernelDriver.IsSupported(enlistment.EnlistmentRoot, out warning, out error)) + if (!GVFSPlatform.Instance.KernelDriver.IsSupported(enlistment.WorkingDirectoryRoot, out warning, out error)) { this.ReportErrorAndExit(tracer, $"Error: {error}"); } @@ -935,7 +935,7 @@ protected void InitializeLocalCacheAndObjectsPaths( CacheServerInfo cacheServer) { string error; - if (!RepoMetadata.TryInitialize(tracer, Path.Combine(enlistment.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot), out error)) + if (!RepoMetadata.TryInitialize(tracer, enlistment.DotGVFSRoot, out error)) { this.ReportErrorAndExit(tracer, "Failed to initialize repo metadata: " + error); } diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index edaf0b85c..37e5f1041 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -158,7 +158,7 @@ protected override void Execute(GVFSEnlistment enlistment) EventLevel.Verbose, Keywords.Any); tracer.WriteStartEvent( - enlistment.EnlistmentRoot, + enlistment.PrimaryEnlistmentRoot, enlistment.RepoUrl, cacheServerFromConfig.Url, new EventMetadata @@ -169,7 +169,7 @@ protected override void Execute(GVFSEnlistment enlistment) { nameof(this.EnlistmentRootPathParameter), this.EnlistmentRootPathParameter }, }); - if (!GVFSPlatform.Instance.KernelDriver.IsReady(tracer, enlistment.EnlistmentRoot, this.Output, out errorMessage)) + if (!GVFSPlatform.Instance.KernelDriver.IsReady(tracer, enlistment.WorkingDirectoryRoot, this.Output, out errorMessage)) { tracer.RelatedEvent( EventLevel.Informational, @@ -181,7 +181,7 @@ protected override void Execute(GVFSEnlistment enlistment) }); if (!this.ShowStatusWhileRunning( - () => { return this.TryEnableAndAttachPrjFltThroughService(enlistment.EnlistmentRoot, out errorMessage); }, + () => { return this.TryEnableAndAttachPrjFltThroughService(enlistment.WorkingDirectoryRoot, out errorMessage); }, $"Attaching ProjFS to volume")) { this.ReportErrorAndExit(tracer, ReturnCode.FilterError, errorMessage); @@ -255,7 +255,7 @@ private bool TryMount(ITracer tracer, GVFSEnlistment enlistment, string mountExe // For worktrees, pass the worktree path so GVFS.Mount.exe creates the right enlistment string mountPath = enlistment.IsWorktree ? enlistment.WorkingDirectoryRoot - : enlistment.EnlistmentRoot; + : enlistment.PrimaryEnlistmentRoot; tracer.RelatedInfo($"{nameof(this.TryMount)}: Launching background process('{mountExecutableLocation}') for {mountPath}"); @@ -277,7 +277,7 @@ private bool TryMount(ITracer tracer, GVFSEnlistment enlistment, string mountExe tracer.RelatedInfo($"{nameof(this.TryMount)}: Waiting for repo to be mounted"); - return GVFSEnlistment.WaitUntilMounted(tracer, enlistment.NamedPipeName, enlistment.EnlistmentRoot, this.Unattended, out errorMessage); + return GVFSEnlistment.WaitUntilMounted(tracer, enlistment.NamedPipeName, enlistment.WorkingDirectoryRoot, this.Unattended, out errorMessage); } private bool RegisterMount(GVFSEnlistment enlistment, out string errorMessage) @@ -290,7 +290,7 @@ private bool RegisterMount(GVFSEnlistment enlistment, out string errorMessage) // listed and unregistered independently of the primary enlistment. request.EnlistmentRoot = enlistment.IsWorktree ? enlistment.WorkingDirectoryRoot - : enlistment.EnlistmentRoot; + : enlistment.PrimaryEnlistmentRoot; request.OwnerSID = GVFSPlatform.Instance.GetCurrentUser(); diff --git a/GVFS/GVFS/CommandLine/PrefetchVerb.cs b/GVFS/GVFS/CommandLine/PrefetchVerb.cs index 1d3d555b4..17483c34c 100644 --- a/GVFS/GVFS/CommandLine/PrefetchVerb.cs +++ b/GVFS/GVFS/CommandLine/PrefetchVerb.cs @@ -138,7 +138,7 @@ protected override void Execute(GVFSEnlistment enlistment) EventLevel.Informational, Keywords.Any); tracer.WriteStartEvent( - enlistment.EnlistmentRoot, + enlistment.PrimaryEnlistmentRoot, enlistment.RepoUrl, cacheServerFromConfig.Url); @@ -216,8 +216,8 @@ protected override void Execute(GVFSEnlistment enlistment) catch (AggregateException aggregateException) { this.Output.WriteLine( - "Cannot prefetch {0}. " + ConsoleHelper.GetGVFSLogMessage(enlistment.EnlistmentRoot), - enlistment.EnlistmentRoot); + "Cannot prefetch {0}. " + ConsoleHelper.GetGVFSLogMessage(enlistment.WorkingDirectoryRoot), + enlistment.WorkingDirectoryRoot); foreach (Exception innerException in aggregateException.Flatten().InnerExceptions) { tracer.RelatedError( @@ -234,8 +234,8 @@ protected override void Execute(GVFSEnlistment enlistment) catch (Exception e) { this.Output.WriteLine( - "Cannot prefetch {0}. " + ConsoleHelper.GetGVFSLogMessage(enlistment.EnlistmentRoot), - enlistment.EnlistmentRoot); + "Cannot prefetch {0}. " + ConsoleHelper.GetGVFSLogMessage(enlistment.WorkingDirectoryRoot), + enlistment.WorkingDirectoryRoot); tracer.RelatedError( new EventMetadata { diff --git a/GVFS/GVFS/CommandLine/RepairVerb.cs b/GVFS/GVFS/CommandLine/RepairVerb.cs index 731af334f..dc0034adc 100644 --- a/GVFS/GVFS/CommandLine/RepairVerb.cs +++ b/GVFS/GVFS/CommandLine/RepairVerb.cs @@ -92,7 +92,7 @@ To actually execute any necessary repair(s), run 'gvfs repair --confirm' } string error; - if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer: null, enlistmentRoot: enlistment.EnlistmentRoot, error: out error)) + if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer: null, enlistmentRoot: enlistment.PrimaryEnlistmentRoot, error: out error)) { this.ReportErrorAndExit(error); } @@ -129,7 +129,7 @@ To actually execute any necessary repair(s), run 'gvfs repair --confirm' EventLevel.Verbose, Keywords.Any); tracer.WriteStartEvent( - enlistment.EnlistmentRoot, + enlistment.PrimaryEnlistmentRoot, enlistment.RepoUrl, "N/A", new EventMetadata @@ -214,7 +214,7 @@ To actually execute any necessary repair(s), run 'gvfs repair --confirm' this.WriteMessage(tracer, "Repair succeeded, but requires some manual steps before remounting."); break; case RepairJob.FixResult.Failure: - this.WriteMessage(tracer, "Repair failed. " + ConsoleHelper.GetGVFSLogMessage(enlistment.EnlistmentRoot)); + this.WriteMessage(tracer, "Repair failed. " + ConsoleHelper.GetGVFSLogMessage(enlistment.PrimaryEnlistmentRoot)); break; } diff --git a/GVFS/GVFS/CommandLine/SparseVerb.cs b/GVFS/GVFS/CommandLine/SparseVerb.cs index bf5150c27..1ea6e5aed 100644 --- a/GVFS/GVFS/CommandLine/SparseVerb.cs +++ b/GVFS/GVFS/CommandLine/SparseVerb.cs @@ -159,7 +159,7 @@ protected override void Execute(GVFSEnlistment enlistment) string.IsNullOrEmpty(this.Set) && string.IsNullOrEmpty(this.File))) { - this.ListSparseFolders(enlistment.EnlistmentRoot); + this.ListSparseFolders(enlistment.DotGVFSRoot); return; } @@ -183,7 +183,7 @@ protected override void Execute(GVFSEnlistment enlistment) HashSet directories; bool needToChangeProjection = false; - using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), enlistment.EnlistmentRoot, new SqliteDatabase())) + using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), enlistment.DotGVFSRoot, new SqliteDatabase())) { SparseTable sparseTable = new SparseTable(database); directories = sparseTable.GetAll(); @@ -555,9 +555,9 @@ private void CheckOptions() } } - private void ListSparseFolders(string enlistmentRoot) + private void ListSparseFolders(string dotGVFSRoot) { - using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), enlistmentRoot, new SqliteDatabase())) + using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), dotGVFSRoot, new SqliteDatabase())) { SparseTable sparseTable = new SparseTable(database); HashSet directories = sparseTable.GetAll(); diff --git a/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs b/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs index c1f8c5ab9..2903bcf72 100644 --- a/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs +++ b/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs @@ -50,7 +50,7 @@ public override IssueType HasIssue(List messages) try { GVFSEnlistment enlistment = GVFSEnlistment.CreateFromDirectory( - this.Enlistment.EnlistmentRoot, + this.Enlistment.PrimaryEnlistmentRoot, this.Enlistment.GitBinPath, authentication: null);