From 1355af22b7707dbe57eb7b7840f6eca2efe94f65 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 11 Mar 2026 14:55:51 -0700 Subject: [PATCH 01/17] common: add worktree detection and enlistment support Add TryGetWorktreeInfo() to detect git worktrees by checking for a .git file (not directory) and reading its gitdir pointer. WorktreeInfo carries the worktree name, paths, and derived pipe suffix. Add GVFSEnlistment.CreateForWorktree() factory that constructs an enlistment with worktree-specific paths: WorkingDirectoryRoot points to the worktree, DotGitRoot uses the shared .git directory, and NamedPipeName includes a worktree-specific suffix. Add WorktreeCommandParser to extract subcommands and positional args from git worktree hook arguments. Add GVFS_SUPPORTS_WORKTREES to GitCoreGVFSFlags enum. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Enlistment.cs | 10 ++- GVFS/GVFS.Common/GVFSConstants.cs | 12 ++- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 83 +++++++++++++++++ GVFS/GVFS.Common/GVFSEnlistment.cs | 90 ++++++++++++++++++- GVFS/GVFS.Common/Git/GitCoreGVFSFlags.cs | 5 ++ .../EnlistmentHydrationSummary.cs | 2 +- 6 files changed, 196 insertions(+), 6 deletions(-) diff --git a/GVFS/GVFS.Common/Enlistment.cs b/GVFS/GVFS.Common/Enlistment.cs index 3f061257f..5dcfd9e54 100644 --- a/GVFS/GVFS.Common/Enlistment.cs +++ b/GVFS/GVFS.Common/Enlistment.cs @@ -62,10 +62,18 @@ protected Enlistment( public string WorkingDirectoryRoot { get; } public string WorkingDirectoryBackingRoot { get; } - public string DotGitRoot { get; private set; } + public string DotGitRoot { get; protected set; } public abstract string GitObjectsRoot { get; protected set; } public abstract string LocalObjectsRoot { get; protected set; } public abstract string GitPackRoot { get; protected set; } + + /// + /// Path to the git index file. Override for worktree-specific paths. + /// + public virtual string GitIndexPath + { + get { return Path.Combine(this.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Index); } + } public string RepoUrl { get; } public bool FlushFileBuffersForPacks { get; } diff --git a/GVFS/GVFS.Common/GVFSConstants.cs b/GVFS/GVFS.Common/GVFSConstants.cs index cf52e1fbb..2d73c1dbb 100644 --- a/GVFS/GVFS.Common/GVFSConstants.cs +++ b/GVFS/GVFS.Common/GVFSConstants.cs @@ -158,10 +158,14 @@ public static class DotGit public static class Logs { + public const string RootName = "logs"; public static readonly string HeadName = "HEAD"; - public static readonly string Root = Path.Combine(DotGit.Root, "logs"); + public static readonly string Root = Path.Combine(DotGit.Root, RootName); public static readonly string Head = Path.Combine(Logs.Root, Logs.HeadName); + + /// Path relative to the git directory (e.g., "logs/HEAD"). + public static readonly string HeadRelativePath = Path.Combine(RootName, HeadName); } public static class Hooks @@ -172,7 +176,8 @@ public static class Hooks public const string ReadObjectName = "read-object"; public const string VirtualFileSystemName = "virtual-filesystem"; public const string PostIndexChangedName = "post-index-change"; - public static readonly string Root = Path.Combine(DotGit.Root, "hooks"); + public const string RootName = "hooks"; + public static readonly string Root = Path.Combine(DotGit.Root, RootName); public static readonly string PreCommandPath = Path.Combine(Hooks.Root, PreCommandHookName); public static readonly string PostCommandPath = Path.Combine(Hooks.Root, PostCommandHookName); public static readonly string ReadObjectPath = Path.Combine(Hooks.Root, ReadObjectName); @@ -201,6 +206,9 @@ public static class Info { public static readonly string Root = Path.Combine(Objects.Root, "info"); public static readonly string Alternates = Path.Combine(Info.Root, "alternates"); + + /// Path relative to the git directory (e.g., "objects/info/alternates"). + public static readonly string AlternatesRelativePath = Path.Combine("objects", "info", "alternates"); } public static class Pack diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index a7e84ba33..ae63fff61 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -1,5 +1,6 @@ using GVFS.Common.Tracing; using System; +using System.IO; using System.Security; namespace GVFS.Common @@ -25,5 +26,87 @@ public static bool IsUnattended(ITracer tracer) return false; } } + + /// + /// Detects if the given directory is a git worktree by checking for + /// a .git file (not directory) containing "gitdir: path/.git/worktrees/name". + /// Returns a pipe name suffix like "_WT_NAME" if so, or null if not a worktree. + /// + public static string GetWorktreePipeSuffix(string directory) + { + WorktreeInfo info = TryGetWorktreeInfo(directory); + return info?.PipeSuffix; + } + + /// + /// Detects if the given directory is a git worktree. If so, returns + /// a WorktreeInfo with the worktree name, git dir path, and shared + /// git dir path. Returns null if not a worktree. + /// + public static WorktreeInfo TryGetWorktreeInfo(string directory) + { + string dotGitPath = Path.Combine(directory, ".git"); + + if (!File.Exists(dotGitPath) || Directory.Exists(dotGitPath)) + { + return null; + } + + try + { + string gitdirLine = File.ReadAllText(dotGitPath).Trim(); + if (!gitdirLine.StartsWith("gitdir: ")) + { + return null; + } + + string gitdirPath = gitdirLine.Substring("gitdir: ".Length).Trim(); + gitdirPath = gitdirPath.Replace('/', Path.DirectorySeparatorChar); + + // Resolve relative paths against the worktree directory + if (!Path.IsPathRooted(gitdirPath)) + { + gitdirPath = Path.GetFullPath(Path.Combine(directory, gitdirPath)); + } + + string worktreeName = Path.GetFileName(gitdirPath); + if (string.IsNullOrEmpty(worktreeName)) + { + return null; + } + + // Read commondir to find the shared .git/ directory + // commondir file contains a relative path like "../../.." + string commondirFile = Path.Combine(gitdirPath, "commondir"); + string sharedGitDir = null; + if (File.Exists(commondirFile)) + { + string commondirContent = File.ReadAllText(commondirFile).Trim(); + sharedGitDir = Path.GetFullPath(Path.Combine(gitdirPath, commondirContent)); + } + + return new WorktreeInfo + { + Name = worktreeName, + WorktreePath = directory, + WorktreeGitDir = gitdirPath, + SharedGitDir = sharedGitDir, + PipeSuffix = "_WT_" + worktreeName.ToUpper(), + }; + } + catch + { + return null; + } + } + + public class WorktreeInfo + { + public string Name { get; set; } + public string WorktreePath { get; set; } + public string WorktreeGitDir { get; set; } + public string SharedGitDir { get; set; } + public string PipeSuffix { get; set; } + } } } diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index 731f1b355..6b2767ac3 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -48,12 +48,59 @@ private GVFSEnlistment(string enlistmentRoot, string gitBinPath, GitAuthenticati { } + // Worktree enlistment — overrides working directory, pipe name, and metadata paths + private GVFSEnlistment(string enlistmentRoot, string gitBinPath, GitAuthentication authentication, WorktreeInfo worktreeInfo, string repoUrl = null) + : base( + enlistmentRoot, + worktreeInfo.WorktreePath, + worktreeInfo.WorktreePath, + repoUrl, + gitBinPath, + flushFileBuffersForPacks: true, + authentication: authentication) + { + this.Worktree = worktreeInfo; + + // Override DotGitRoot to point to the shared .git directory. + // The base constructor sets it to WorkingDirectoryBackingRoot/.git + // which is a file (not directory) in worktrees. + this.DotGitRoot = worktreeInfo.SharedGitDir; + + this.DotGVFSRoot = Path.Combine(worktreeInfo.WorktreeGitDir, GVFSPlatform.Instance.Constants.DotGVFSRoot); + this.NamedPipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot) + worktreeInfo.PipeSuffix; + 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.DotGVFSRoot, GVFSConstants.DotGVFS.LogName); + this.LocalObjectsRoot = Path.Combine(worktreeInfo.SharedGitDir, "objects"); + } + public string NamedPipeName { get; } public string DotGVFSRoot { get; } public string GVFSLogsRoot { get; } + public WorktreeInfo Worktree { get; } + + public bool IsWorktree => this.Worktree != null; + + /// + /// Path to the git index file. For worktrees this is in the + /// per-worktree git dir, not in the working directory. + /// + public override string GitIndexPath + { + get + { + if (this.IsWorktree) + { + return Path.Combine(this.Worktree.WorktreeGitDir, GVFSConstants.DotGit.IndexName); + } + + return base.GitIndexPath; + } + } + public string LocalCacheRoot { get; private set; } public string BlobSizesRoot { get; private set; } @@ -88,6 +135,31 @@ public static GVFSEnlistment CreateFromDirectory( { if (Directory.Exists(directory)) { + // Always check for worktree first. A worktree directory may + // be under the enlistment tree, so TryGetGVFSEnlistmentRoot + // can succeed by walking up — but we need a worktree enlistment. + WorktreeInfo wtInfo = TryGetWorktreeInfo(directory); + if (wtInfo?.SharedGitDir != null) + { + string srcDir = Path.GetDirectoryName(wtInfo.SharedGitDir); + if (srcDir != null) + { + string primaryRoot = Path.GetDirectoryName(srcDir); + if (primaryRoot != null) + { + // Read origin URL via the shared .git dir (not the worktree's + // .git file) because the base Enlistment constructor runs + // git config before we can override DotGitRoot. + string repoUrl = null; + GitProcess git = new GitProcess(gitBinRoot, srcDir); + GitProcess.ConfigResult urlResult = git.GetOriginUrl(); + urlResult.TryParseAsString(out repoUrl, out _); + + return CreateForWorktree(primaryRoot, gitBinRoot, authentication, wtInfo, repoUrl?.Trim()); + } + } + } + string errorMessage; string enlistmentRoot; if (!GVFSPlatform.Instance.TryGetGVFSEnlistmentRoot(directory, out enlistmentRoot, out errorMessage)) @@ -106,6 +178,21 @@ public static GVFSEnlistment CreateFromDirectory( throw new InvalidRepoException($"Directory '{directory}' does not exist"); } + /// + /// Creates a GVFSEnlistment for a git worktree. Uses the primary + /// enlistment root for shared config but maps working directory, + /// metadata, and pipe name to the worktree. + /// + public static GVFSEnlistment CreateForWorktree( + string primaryEnlistmentRoot, + string gitBinRoot, + GitAuthentication authentication, + WorktreeInfo worktreeInfo, + string repoUrl = null) + { + return new GVFSEnlistment(primaryEnlistmentRoot, gitBinRoot, authentication, worktreeInfo, repoUrl); + } + public static string GetNewGVFSLogFileName( string logsRoot, string logFileType, @@ -119,9 +206,8 @@ public static string GetNewGVFSLogFileName( fileSystem: fileSystem); } - public static bool WaitUntilMounted(ITracer tracer, string enlistmentRoot, bool unattended, out string errorMessage) + public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enlistmentRoot, bool unattended, out string errorMessage) { - string pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot); tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Creating NamedPipeClient for pipe '{pipeName}'"); errorMessage = null; diff --git a/GVFS/GVFS.Common/Git/GitCoreGVFSFlags.cs b/GVFS/GVFS.Common/Git/GitCoreGVFSFlags.cs index 411c5bc3c..551be80b2 100644 --- a/GVFS/GVFS.Common/Git/GitCoreGVFSFlags.cs +++ b/GVFS/GVFS.Common/Git/GitCoreGVFSFlags.cs @@ -54,5 +54,10 @@ public enum GitCoreGVFSFlags // While performing a `git fetch` command, use the gvfs-helper to // perform a "prefetch" of commits and trees. PrefetchDuringFetch = 1 << 7, + + // GVFS_SUPPORTS_WORKTREES + // Signals that this GVFS version supports git worktrees, + // allowing `git worktree add/remove` on VFS-enabled repos. + SupportsWorktrees = 1 << 8, } } diff --git a/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs b/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs index 600ba91c5..7c1785ca1 100644 --- a/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs +++ b/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs @@ -87,7 +87,7 @@ public static EnlistmentHydrationSummary CreateSummary( /// internal static int GetIndexFileCount(GVFSEnlistment enlistment, PhysicalFileSystem fileSystem) { - string indexPath = Path.Combine(enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Index); + string indexPath = enlistment.GitIndexPath; using (var indexFile = fileSystem.OpenFileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, callFlushFileBuffers: false)) { if (indexFile.Length < 12) From 02e218c64b7ca335386d54b2ad9ce6313d2ef4ca Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 11 Mar 2026 14:56:06 -0700 Subject: [PATCH 02/17] hooks: make pipe name resolution worktree-aware Update GetGVFSPipeName() in common.windows.cpp to detect when running inside a git worktree. If the current directory contains a .git file (not directory), read the gitdir pointer, extract the worktree name, and append a _WT_ suffix to the pipe name. This single change makes all native hooks (read-object, post-index-changed, virtual-filesystem) connect to the correct worktree-specific GVFS mount process. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .../common.windows.cpp | 78 ++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/GVFS/GVFS.NativeHooks.Common/common.windows.cpp b/GVFS/GVFS.NativeHooks.Common/common.windows.cpp index d062d5758..c973ec67f 100644 --- a/GVFS/GVFS.NativeHooks.Common/common.windows.cpp +++ b/GVFS/GVFS.NativeHooks.Common/common.windows.cpp @@ -52,11 +52,74 @@ PATH_STRING GetFinalPathName(const PATH_STRING& path) return finalPath; } +// Checks if the given directory is a git worktree by looking for a +// ".git" file (not directory). If found, reads it to extract the +// worktree name and returns a pipe name suffix like "_WT_NAME". +// Returns an empty string if not in a worktree. +PATH_STRING GetWorktreePipeSuffix(const wchar_t* directory) +{ + wchar_t dotGitPath[MAX_PATH]; + wcscpy_s(dotGitPath, directory); + size_t checkLen = wcslen(dotGitPath); + if (checkLen > 0 && dotGitPath[checkLen - 1] != L'\\') + wcscat_s(dotGitPath, L"\\"); + wcscat_s(dotGitPath, L".git"); + + DWORD dotGitAttrs = GetFileAttributesW(dotGitPath); + if (dotGitAttrs == INVALID_FILE_ATTRIBUTES || + (dotGitAttrs & FILE_ATTRIBUTE_DIRECTORY)) + { + return PATH_STRING(); + } + + // .git is a file — this is a worktree. Read it to find the + // worktree git directory (format: "gitdir: ") + FILE* gitFile = NULL; + errno_t fopenResult = _wfopen_s(&gitFile, dotGitPath, L"r"); + if (fopenResult != 0 || gitFile == NULL) + return PATH_STRING(); + + char gitdirLine[MAX_PATH * 2]; + if (fgets(gitdirLine, sizeof(gitdirLine), gitFile) == NULL) + { + fclose(gitFile); + return PATH_STRING(); + } + fclose(gitFile); + + char* gitdirPath = gitdirLine; + if (strncmp(gitdirPath, "gitdir: ", 8) == 0) + gitdirPath += 8; + + // Trim trailing whitespace + size_t lineLen = strlen(gitdirPath); + while (lineLen > 0 && (gitdirPath[lineLen - 1] == '\n' || + gitdirPath[lineLen - 1] == '\r' || + gitdirPath[lineLen - 1] == ' ')) + gitdirPath[--lineLen] = '\0'; + + // Extract worktree name — last path component + // e.g., from ".git/worktrees/my-worktree" extract "my-worktree" + char* lastSep = strrchr(gitdirPath, '/'); + if (!lastSep) + lastSep = strrchr(gitdirPath, '\\'); + + if (lastSep == NULL) + return PATH_STRING(); + + wchar_t wtName[MAX_PATH]; + MultiByteToWideChar(CP_UTF8, 0, lastSep + 1, -1, wtName, MAX_PATH); + PATH_STRING suffix = L"_WT_"; + suffix += wtName; + return suffix; +} + PATH_STRING GetGVFSPipeName(const char *appName) { // The pipe name is built using the path of the GVFS enlistment root. // Start in the current directory and walk up the directory tree - // until we find a folder that contains the ".gvfs" folder + // until we find a folder that contains the ".gvfs" folder. + // For worktrees, a suffix is appended to target the worktree's mount. const size_t dotGVFSRelativePathLength = sizeof(L"\\.gvfs") / sizeof(wchar_t); @@ -117,7 +180,18 @@ PATH_STRING GetGVFSPipeName(const char *appName) PATH_STRING namedPipe(CharUpperW(enlistmentRoot)); std::replace(namedPipe.begin(), namedPipe.end(), L':', L'_'); - return L"\\\\.\\pipe\\GVFS_" + namedPipe; + PATH_STRING pipeName = L"\\\\.\\pipe\\GVFS_" + namedPipe; + + // Append worktree suffix if running in a worktree + PATH_STRING worktreeSuffix = GetWorktreePipeSuffix(finalRootPath.c_str()); + if (!worktreeSuffix.empty()) + { + std::transform(worktreeSuffix.begin(), worktreeSuffix.end(), + worktreeSuffix.begin(), ::towupper); + pipeName += worktreeSuffix; + } + + return pipeName; } PIPE_HANDLE CreatePipeToGVFS(const PATH_STRING& pipeName) From 1b64954f03d252c1a18e616dadff05a4413b1078 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 11 Mar 2026 14:56:23 -0700 Subject: [PATCH 03/17] mount: teach gvfs mount/unmount to handle worktrees MountVerb: detect worktree paths via TryGetWorktreeInfo(), create worktree-specific GVFSEnlistment, check worktree-specific pipe for already-mounted state, register worktrees by their own path (not the primary enlistment root). UnmountVerb: resolve worktree pipe name for unmount, unregister by worktree path so the primary enlistment registration is not affected. InProcessMount: bootstrap worktree metadata (.gvfs/ inside worktree gitdir), set absolute paths for core.hookspath and core.virtualfilesystem, skip hook installation for worktree mounts (hooks are shared via hookspath), set GVFS_SUPPORTS_WORKTREES bit. GitIndexProjection/FileSystemCallbacks: use worktree-specific index path instead of assuming primary .git/index. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Mount/InProcessMount.cs | 111 ++++++++++++++++-- GVFS/GVFS.Mount/InProcessMountVerb.cs | 10 +- GVFS/GVFS.Service/GVFSMountProcess.cs | 14 ++- .../FileSystemCallbacks.cs | 2 +- .../Projection/GitIndexProjection.cs | 2 +- GVFS/GVFS/CommandLine/GVFSVerb.cs | 17 ++- GVFS/GVFS/CommandLine/MountVerb.cs | 68 +++++++++-- GVFS/GVFS/CommandLine/UnmountVerb.cs | 50 ++++++-- 8 files changed, 226 insertions(+), 48 deletions(-) diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index c56627703..d5af1b4fa 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -85,6 +85,13 @@ public void Mount(EventLevel verbosity, Keywords keywords) { this.currentState = MountState.Mounting; + // For worktree mounts, create the .gvfs metadata directory and + // bootstrap it with cache paths from the primary enlistment + if (this.enlistment.IsWorktree) + { + this.InitializeWorktreeMetadata(); + } + // Start auth + config query immediately — these are network-bound and don't // depend on repo metadata or cache paths. Every millisecond of network latency // we can overlap with local I/O is a win. @@ -118,7 +125,6 @@ public void Mount(EventLevel verbosity, Keywords keywords) this.tracer.RelatedInfo("ParallelMount: Auth + config completed in {0}ms", sw.ElapsedMilliseconds); return config; }); - // We must initialize repo metadata before starting the pipe server so it // can immediately handle status requests string error; @@ -226,7 +232,10 @@ public void Mount(EventLevel verbosity, Keywords keywords) this.ValidateMountPoints(); string errorMessage; - if (!HooksInstaller.TryUpdateHooks(this.context, out errorMessage)) + + // Worktrees share hooks with the primary enlistment via core.hookspath, + // so skip installation to avoid locking conflicts with the running mount. + if (!this.enlistment.IsWorktree && !HooksInstaller.TryUpdateHooks(this.context, out errorMessage)) { this.FailMountAndExit(errorMessage); } @@ -274,12 +283,87 @@ private void ValidateMountPoints() this.FailMountAndExit("Failed to initialize file system callbacks. Directory \"{0}\" must exist.", this.enlistment.WorkingDirectoryBackingRoot); } - string dotGitPath = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Root); - DirectoryInfo dotGitPathInfo = new DirectoryInfo(dotGitPath); - if (!dotGitPathInfo.Exists) + if (this.enlistment.IsWorktree) + { + // Worktrees have a .git file (not directory) pointing to the shared git dir + string dotGitFile = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Root); + if (!File.Exists(dotGitFile)) + { + this.FailMountAndExit("Failed to mount worktree. File \"{0}\" must exist.", dotGitFile); + } + } + else + { + string dotGitPath = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Root); + DirectoryInfo dotGitPathInfo = new DirectoryInfo(dotGitPath); + if (!dotGitPathInfo.Exists) + { + this.FailMountAndExit("Failed to mount. Directory \"{0}\" must exist.", dotGitPathInfo); + } + } + } + + /// + /// For worktree mounts, create the .gvfs metadata directory and + /// bootstrap RepoMetadata with cache paths from the primary enlistment. + /// + private void InitializeWorktreeMetadata() + { + string dotGVFSRoot = this.enlistment.DotGVFSRoot; + if (!Directory.Exists(dotGVFSRoot)) + { + try + { + Directory.CreateDirectory(dotGVFSRoot); + this.tracer.RelatedInfo($"Created worktree metadata directory: {dotGVFSRoot}"); + } + catch (Exception e) + { + this.FailMountAndExit("Failed to create worktree metadata directory '{0}': {1}", dotGVFSRoot, e.Message); + } + } + + // Bootstrap RepoMetadata from the primary enlistment's metadata + string primaryDotGVFS = Path.Combine(this.enlistment.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); + string error; + if (!RepoMetadata.TryInitialize(this.tracer, primaryDotGVFS, out error)) + { + this.FailMountAndExit("Failed to read primary enlistment metadata: " + error); + } + + string gitObjectsRoot; + if (!RepoMetadata.Instance.TryGetGitObjectsRoot(out gitObjectsRoot, out error)) { - this.FailMountAndExit("Failed to mount. Directory \"{0}\" must exist.", dotGitPathInfo); + this.FailMountAndExit("Failed to read git objects root from primary metadata: " + error); } + + string localCacheRoot; + if (!RepoMetadata.Instance.TryGetLocalCacheRoot(out localCacheRoot, out error)) + { + this.FailMountAndExit("Failed to read local cache root from primary metadata: " + error); + } + + string blobSizesRoot; + if (!RepoMetadata.Instance.TryGetBlobSizesRoot(out blobSizesRoot, out error)) + { + this.FailMountAndExit("Failed to read blob sizes root from primary metadata: " + error); + } + + RepoMetadata.Shutdown(); + + // Initialize cache paths on the enlistment so SaveCloneMetadata + // can persist them into the worktree's metadata + this.enlistment.InitializeCachePaths(localCacheRoot, gitObjectsRoot, blobSizesRoot); + + // Initialize the worktree's own metadata with cache paths, + // disk layout version, and a new enlistment ID + if (!RepoMetadata.TryInitialize(this.tracer, dotGVFSRoot, out error)) + { + this.FailMountAndExit("Failed to initialize worktree metadata: " + error); + } + + RepoMetadata.Instance.SaveCloneMetadata(this.tracer, this.enlistment); + RepoMetadata.Shutdown(); } private NamedPipeServer StartNamedPipe() @@ -1107,7 +1191,7 @@ private void EnsureLocalCacheIsHealthy(ServerGVFSConfig serverGVFSConfig) if (Directory.Exists(this.enlistment.GitObjectsRoot)) { bool gitObjectsRootInAlternates = false; - string alternatesFilePath = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Objects.Info.Alternates); + string alternatesFilePath = Path.Combine(this.enlistment.DotGitRoot, GVFSConstants.DotGit.Objects.Info.AlternatesRelativePath); if (File.Exists(alternatesFilePath)) { try @@ -1243,8 +1327,8 @@ private bool TryCreateAlternatesFile(PhysicalFileSystem fileSystem, out string e { try { - string alternatesFilePath = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Objects.Info.Alternates); - string tempFilePath = alternatesFilePath + ".tmp"; + string alternatesFilePath = Path.Combine(this.enlistment.DotGitRoot, GVFSConstants.DotGit.Objects.Info.AlternatesRelativePath); + string tempFilePath= alternatesFilePath + ".tmp"; fileSystem.WriteAllText(tempFilePath, this.enlistment.GitObjectsRoot); fileSystem.MoveAndOverwriteFile(tempFilePath, alternatesFilePath); } @@ -1255,10 +1339,9 @@ private bool TryCreateAlternatesFile(PhysicalFileSystem fileSystem, out string e return true; } - private bool TrySetRequiredGitConfigSettings() { - string expectedHooksPath = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Hooks.Root); + string expectedHooksPath = Path.Combine(this.enlistment.DotGitRoot, GVFSConstants.DotGit.Hooks.RootName); expectedHooksPath = Paths.ConvertPathToGitFormat(expectedHooksPath); string gitStatusCachePath = null; @@ -1278,7 +1361,8 @@ private bool TrySetRequiredGitConfigSettings() GitCoreGVFSFlags.MissingOk | GitCoreGVFSFlags.NoDeleteOutsideSparseCheckout | GitCoreGVFSFlags.FetchSkipReachabilityAndUploadPack | - GitCoreGVFSFlags.BlockFiltersAndEolConversions) + GitCoreGVFSFlags.BlockFiltersAndEolConversions | + GitCoreGVFSFlags.SupportsWorktrees) .ToString(); Dictionary requiredSettings = new Dictionary @@ -1298,7 +1382,8 @@ private bool TrySetRequiredGitConfigSettings() { "core.bare", "false" }, { "core.logallrefupdates", "true" }, { GitConfigSetting.CoreVirtualizeObjectsName, "true" }, - { GitConfigSetting.CoreVirtualFileSystemName, Paths.ConvertPathToGitFormat(GVFSConstants.DotGit.Hooks.VirtualFileSystemPath) }, + { GitConfigSetting.CoreVirtualFileSystemName, Paths.ConvertPathToGitFormat( + Path.Combine(this.enlistment.DotGitRoot, GVFSConstants.DotGit.Hooks.RootName, GVFSConstants.DotGit.Hooks.VirtualFileSystemName)) }, { "core.hookspath", expectedHooksPath }, { GitConfigSetting.CredentialUseHttpPath, "true" }, { "credential.validate", "false" }, diff --git a/GVFS/GVFS.Mount/InProcessMountVerb.cs b/GVFS/GVFS.Mount/InProcessMountVerb.cs index 17d373b7c..0cc43d960 100644 --- a/GVFS/GVFS.Mount/InProcessMountVerb.cs +++ b/GVFS/GVFS.Mount/InProcessMountVerb.cs @@ -57,11 +57,11 @@ public InProcessMountVerb() HelpText = "Service initiated mount.")] public string StartedByService { get; set; } - [Option( - 'b', - GVFSConstants.VerbParameters.Mount.StartedByVerb, - Default = false, - Required = false, + [Option( + 'b', + GVFSConstants.VerbParameters.Mount.StartedByVerb, + Default = false, + Required = false, HelpText = "Verb initiated mount.")] public bool StartedByVerb { get; set; } diff --git a/GVFS/GVFS.Service/GVFSMountProcess.cs b/GVFS/GVFS.Service/GVFSMountProcess.cs index 6d11f9ce2..cbdef9a0e 100644 --- a/GVFS/GVFS.Service/GVFSMountProcess.cs +++ b/GVFS/GVFS.Service/GVFSMountProcess.cs @@ -35,7 +35,19 @@ public bool MountRepository(string repoRoot, int sessionId) } string errorMessage; - if (!GVFSEnlistment.WaitUntilMounted(this.tracer, repoRoot, false, out errorMessage)) + string pipeName = GVFSPlatform.Instance.GetNamedPipeName(repoRoot); + GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(repoRoot); + if (wtInfo?.SharedGitDir != null) + { + string srcDir = System.IO.Path.GetDirectoryName(wtInfo.SharedGitDir); + string enlistmentRoot = srcDir != null ? System.IO.Path.GetDirectoryName(srcDir) : null; + if (enlistmentRoot != null) + { + pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot) + wtInfo.PipeSuffix; + } + } + + if (!GVFSEnlistment.WaitUntilMounted(this.tracer, pipeName, repoRoot, false, out errorMessage)) { this.tracer.RelatedError(errorMessage); return false; diff --git a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs index 8a50f030a..e6db82a9f 100644 --- a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs +++ b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs @@ -116,7 +116,7 @@ public FileSystemCallbacks( // This lets us from having to add null checks to callsites into GitStatusCache. this.gitStatusCache = gitStatusCache ?? new GitStatusCache(context, TimeSpan.Zero); - this.logsHeadPath = Path.Combine(this.context.Enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Logs.Head); + this.logsHeadPath = Path.Combine(this.context.Enlistment.DotGitRoot, GVFSConstants.DotGit.Logs.HeadRelativePath); EventMetadata metadata = new EventMetadata(); metadata.Add("placeholders.Count", this.placeholderDatabase.GetCount()); diff --git a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs index 10fd7b573..8e7c4b210 100644 --- a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs +++ b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs @@ -109,7 +109,7 @@ public GitIndexProjection( this.projectionParseComplete = new ManualResetEventSlim(initialState: false); this.wakeUpIndexParsingThread = new AutoResetEvent(initialState: false); this.projectionIndexBackupPath = Path.Combine(this.context.Enlistment.DotGVFSRoot, ProjectionIndexBackupName); - this.indexPath = Path.Combine(this.context.Enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Index); + this.indexPath = this.context.Enlistment.GitIndexPath; this.placeholderDatabase = placeholderDatabase; this.sparseCollection = sparseCollection; this.modifiedPaths = modifiedPaths; diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs index c2a4060d1..b885008ff 100644 --- a/GVFS/GVFS/CommandLine/GVFSVerb.cs +++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs @@ -36,7 +36,6 @@ public GVFSVerb(bool validateOrigin = true) this.InitializeDefaultParameterValues(); } - public abstract string EnlistmentRootPathParameter { get; set; } [Option( @@ -104,7 +103,8 @@ public string ServicePipeName public static bool TrySetRequiredGitConfigSettings(Enlistment enlistment) { - string expectedHooksPath = Path.Combine(enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Hooks.Root); + // Use DotGitRoot (shared .git dir for worktrees) for absolute hook paths. + string expectedHooksPath = Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.Hooks.RootName); expectedHooksPath = Paths.ConvertPathToGitFormat(expectedHooksPath); string gitStatusCachePath = null; @@ -124,7 +124,8 @@ public static bool TrySetRequiredGitConfigSettings(Enlistment enlistment) GitCoreGVFSFlags.MissingOk | GitCoreGVFSFlags.NoDeleteOutsideSparseCheckout | GitCoreGVFSFlags.FetchSkipReachabilityAndUploadPack | - GitCoreGVFSFlags.BlockFiltersAndEolConversions) + GitCoreGVFSFlags.BlockFiltersAndEolConversions | + GitCoreGVFSFlags.SupportsWorktrees) .ToString(); // These settings are required for normal GVFS functionality. @@ -183,8 +184,10 @@ public static bool TrySetRequiredGitConfigSettings(Enlistment enlistment) // Git to download objects on demand. { GitConfigSetting.CoreVirtualizeObjectsName, "true" }, - // Configure hook that git calls to get the paths git needs to consider for changes or untracked files - { GitConfigSetting.CoreVirtualFileSystemName, Paths.ConvertPathToGitFormat(GVFSConstants.DotGit.Hooks.VirtualFileSystemPath) }, + // Configure hook that git calls to get the paths git needs to consider for changes or untracked files. + // Use absolute path so worktrees (where .git is a file) can find the hook. + { GitConfigSetting.CoreVirtualFileSystemName, Paths.ConvertPathToGitFormat( + Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.Hooks.RootName, GVFSConstants.DotGit.Hooks.VirtualFileSystemName)) }, // Ensure hooks path is configured correctly. { "core.hookspath", expectedHooksPath }, @@ -826,7 +829,9 @@ private static bool TrySetConfig(Enlistment enlistment, Dictionary { return this.Unmount(root, out errorMessage); }, + () => { return this.Unmount(pipeName, out errorMessage); }, "Unmounting")) { this.ReportErrorAndExit(errorMessage); @@ -60,7 +91,7 @@ public override void Execute() if (!this.Unattended && !this.SkipUnregister) { if (!this.ShowStatusWhileRunning( - () => { return this.UnregisterRepo(root, out errorMessage); }, + () => { return this.UnregisterRepo(registrationPath, out errorMessage); }, "Unregistering automount")) { this.Output.WriteLine(" WARNING: " + errorMessage); @@ -68,11 +99,9 @@ public override void Execute() } } - private bool Unmount(string enlistmentRoot, out string errorMessage) + private bool Unmount(string pipeName, out string errorMessage) { errorMessage = string.Empty; - - string pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot); string rawGetStatusResponse = string.Empty; try @@ -197,9 +226,8 @@ private bool UnregisterRepo(string rootPath, out string errorMessage) } } - private void AcquireLock(string enlistmentRoot) + private void AcquireLock(string pipeName) { - string pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot); using (NamedPipeClient pipeClient = new NamedPipeClient(pipeName)) { try @@ -220,7 +248,7 @@ private void AcquireLock(string enlistmentRoot) GVFSPlatform.Instance.IsElevated(), isConsoleOutputRedirectedToFile: GVFSPlatform.Instance.IsConsoleOutputRedirectedToFile(), checkAvailabilityOnly: false, - gvfsEnlistmentRoot: enlistmentRoot, + gvfsEnlistmentRoot: null, gitCommandSessionId: string.Empty, result: out result)) { From 729aa7bc9a6de93babc5d8dec717647346cd8d9f Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 11 Mar 2026 14:56:37 -0700 Subject: [PATCH 04/17] hooks: auto-mount/unmount worktrees via git hooks In the managed pre/post-command hooks, intercept git worktree subcommands to transparently manage GVFS mounts: add: Post-command runs 'git checkout -f' to create the index, then 'gvfs mount' to start ProjFS projection. remove: Pre-command checks for uncommitted changes while ProjFS is alive, writes skip-clean-check marker, unmounts. Post-command remounts if removal failed (dir + .git exist). move: Pre-command unmounts old path, post-command mounts new. prune: Post-command cleans stale worktree metadata. Add WorktreeCommandParser reference to GVFS.Hooks.csproj. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/WorktreeCommandParser.cs | 94 +++++++ GVFS/GVFS.Hooks/GVFS.Hooks.csproj | 3 + GVFS/GVFS.Hooks/Program.Worktree.cs | 287 ++++++++++++++++++++++ GVFS/GVFS.Hooks/Program.cs | 38 ++- 4 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 GVFS/GVFS.Common/WorktreeCommandParser.cs create mode 100644 GVFS/GVFS.Hooks/Program.Worktree.cs diff --git a/GVFS/GVFS.Common/WorktreeCommandParser.cs b/GVFS/GVFS.Common/WorktreeCommandParser.cs new file mode 100644 index 000000000..ae0dc415b --- /dev/null +++ b/GVFS/GVFS.Common/WorktreeCommandParser.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; + +namespace GVFS.Common +{ + /// + /// Parses git worktree command arguments from hook args arrays. + /// Hook args format: [hooktype, "worktree", subcommand, options..., positional args..., --git-pid=N, --exit_code=N] + /// + public static class WorktreeCommandParser + { + /// + /// Gets the worktree subcommand (add, remove, move, list, etc.) from hook args. + /// + public static string GetSubcommand(string[] args) + { + // args[0] = hook type, args[1] = "worktree", args[2+] = subcommand and its args + for (int i = 2; i < args.Length; i++) + { + if (!args[i].StartsWith("--")) + { + return args[i].ToLowerInvariant(); + } + } + + return null; + } + + /// + /// Gets a positional argument from git worktree subcommand args. + /// For 'add': git worktree add [options] <path> [<commit-ish>] + /// For 'remove': git worktree remove [options] <worktree> + /// For 'move': git worktree move [options] <worktree> <new-path> + /// + /// Full hook args array (hooktype, command, subcommand, ...) + /// 0-based index of the positional arg after the subcommand + public static string GetPositionalArg(string[] args, int positionalIndex) + { + var optionsWithValue = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "-b", "-B", "--reason" + }; + + int found = -1; + bool pastSubcommand = false; + bool pastSeparator = false; + for (int i = 2; i < args.Length; i++) + { + if (args[i].StartsWith("--git-pid=") || args[i].StartsWith("--exit_code=")) + { + continue; + } + + if (args[i] == "--") + { + pastSeparator = true; + continue; + } + + if (!pastSeparator && args[i].StartsWith("-")) + { + if (optionsWithValue.Contains(args[i]) && i + 1 < args.Length) + { + i++; + } + + continue; + } + + if (!pastSubcommand) + { + pastSubcommand = true; + continue; + } + + found++; + if (found == positionalIndex) + { + return args[i]; + } + } + + return null; + } + + /// + /// Gets the first positional argument (worktree path) from git worktree args. + /// + public static string GetPathArg(string[] args) + { + return GetPositionalArg(args, 0); + } + } +} diff --git a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj index 9c0956b8b..e5c634a94 100644 --- a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj +++ b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj @@ -70,6 +70,9 @@ Common\ProcessResult.cs + + Common\WorktreeCommandParser.cs + Common\SHA1Util.cs diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs new file mode 100644 index 000000000..5699232e8 --- /dev/null +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -0,0 +1,287 @@ +using GVFS.Common; +using GVFS.Common.NamedPipes; +using GVFS.Hooks.HooksPlatform; +using System; +using System.IO; +using System.Linq; + +namespace GVFS.Hooks +{ + public partial class Program + { + private static string GetWorktreeSubcommand(string[] args) + { + return WorktreeCommandParser.GetSubcommand(args); + } + + /// + /// Gets a positional argument from git worktree subcommand args. + /// For 'add': git worktree add [options] <path> [<commit-ish>] + /// For 'remove': git worktree remove [options] <worktree> + /// For 'move': git worktree move [options] <worktree> <new-path> + /// + private static string GetWorktreePositionalArg(string[] args, int positionalIndex) + { + return WorktreeCommandParser.GetPositionalArg(args, positionalIndex); + } + + private static string GetWorktreePathArg(string[] args) + { + return WorktreeCommandParser.GetPathArg(args); + } + + private static void RunWorktreePreCommand(string[] args) + { + string subcommand = GetWorktreeSubcommand(args); + switch (subcommand) + { + case "remove": + HandleWorktreeRemove(args); + break; + case "move": + // Unmount at old location before git moves the directory + UnmountWorktreeByArg(args); + break; + } + } + + private static void RunWorktreePostCommand(string[] args) + { + string subcommand = GetWorktreeSubcommand(args); + switch (subcommand) + { + case "add": + MountNewWorktree(args); + break; + case "remove": + RemountWorktreeIfRemoveFailed(args); + CleanupSkipCleanCheckMarker(args); + break; + case "move": + // Mount at the new location after git moved the directory + MountMovedWorktree(args); + break; + } + } + + private static void UnmountWorktreeByArg(string[] args) + { + string worktreePath = GetWorktreePathArg(args); + if (string.IsNullOrEmpty(worktreePath)) + { + return; + } + + string fullPath = ResolvePath(worktreePath); + UnmountWorktree(fullPath); + } + + /// + /// If the worktree directory and its .git file both still exist after + /// git worktree remove, the removal failed completely. Remount ProjFS + /// so the worktree remains usable. If the remove partially succeeded + /// (e.g., .git file or gitdir removed), don't attempt recovery. + /// + private static void RemountWorktreeIfRemoveFailed(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 + /// worktree gitdir was not deleted). + /// + private static void CleanupSkipCleanCheckMarker(string[] args) + { + string worktreePath = GetWorktreePathArg(args); + if (string.IsNullOrEmpty(worktreePath)) + { + return; + } + + string fullPath = ResolvePath(worktreePath); + GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath); + if (wtInfo != null) + { + string markerPath = Path.Combine(wtInfo.WorktreeGitDir, "skip-clean-check"); + if (File.Exists(markerPath)) + { + File.Delete(markerPath); + } + } + } + + private static void HandleWorktreeRemove(string[] args) + { + string worktreePath = GetWorktreePathArg(args); + if (string.IsNullOrEmpty(worktreePath)) + { + return; + } + + string fullPath = ResolvePath(worktreePath); + GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath); + if (wtInfo == null) + { + return; + } + + bool hasForce = args.Any(a => + a.Equals("--force", StringComparison.OrdinalIgnoreCase) || + a.Equals("-f", StringComparison.OrdinalIgnoreCase)); + + if (!hasForce) + { + // Check for uncommitted changes while ProjFS is still mounted. + ProcessResult statusResult = ProcessHelper.Run( + "git", + $"-C \"{fullPath}\" status --porcelain", + redirectOutput: true); + + if (!string.IsNullOrWhiteSpace(statusResult.Output)) + { + Console.Error.WriteLine( + $"error: worktree '{fullPath}' has uncommitted changes.\n" + + $"Use 'git worktree remove --force' to remove it anyway."); + Environment.Exit(1); + } + } + + // Write a marker in the worktree gitdir that tells git.exe + // to skip the cleanliness check during worktree remove. + // We already did our own check above while ProjFS was alive. + string skipCleanCheck = Path.Combine(wtInfo.WorktreeGitDir, "skip-clean-check"); + File.WriteAllText(skipCleanCheck, "1"); + + // Unmount ProjFS before git deletes the worktree directory. + UnmountWorktree(fullPath); + } + + private static void UnmountWorktree(string fullPath) + { + GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath); + if (wtInfo == null) + { + return; + } + + ProcessHelper.Run("gvfs", $"unmount \"{fullPath}\"", redirectOutput: false); + + // Wait for the GVFS.Mount process to fully exit by polling + // the named pipe. Once the pipe is gone, the mount process + // has released all file handles. + string pipeName = GVFSHooksPlatform.GetNamedPipeName(enlistmentRoot) + wtInfo.PipeSuffix; + for (int i = 0; i < 10; i++) + { + using (NamedPipeClient pipeClient = new NamedPipeClient(pipeName)) + { + if (!pipeClient.Connect(100)) + { + return; + } + } + + System.Threading.Thread.Sleep(100); + } + } + + private static void MountNewWorktree(string[] args) + { + string worktreePath = GetWorktreePathArg(args); + if (string.IsNullOrEmpty(worktreePath)) + { + return; + } + + string fullPath = ResolvePath(worktreePath); + + // Verify worktree was created (check for .git file) + string dotGitFile = Path.Combine(fullPath, ".git"); + if (File.Exists(dotGitFile)) + { + GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath); + + // Copy the primary's index to the worktree before checkout. + // The primary index has all entries with correct skip-worktree + // bits. If the worktree targets the same commit, checkout is + // a no-op. If a different commit, git does an incremental + // update — much faster than building 2.5M entries from scratch. + if (wtInfo?.SharedGitDir != null) + { + string primaryIndex = Path.Combine(wtInfo.SharedGitDir, "index"); + string worktreeIndex = Path.Combine(wtInfo.WorktreeGitDir, "index"); + if (File.Exists(primaryIndex) && !File.Exists(worktreeIndex)) + { + File.Copy(primaryIndex, worktreeIndex); + } + } + + // Run checkout to reconcile the index with the worktree's HEAD. + // With a pre-populated index this is fast (incremental diff). + // Override core.virtualfilesystem with an empty script that + // returns .gitattributes so it gets materialized while all + // other entries keep skip-worktree set. + // + // 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. + string emptyVfsHook = Path.Combine(fullPath, ".vfs-empty-hook"); + File.WriteAllText(emptyVfsHook, "#!/bin/sh\nprintf \".gitattributes\\n\"\n"); + string emptyVfsHookGitPath = emptyVfsHook.Replace('\\', '/'); + + ProcessHelper.Run( + "git", + $"-C \"{fullPath}\" -c core.virtualfilesystem=\"{emptyVfsHookGitPath}\" -c core.hookspath= checkout -f HEAD", + redirectOutput: false); + + File.Delete(emptyVfsHook); + + // Hydrate .gitattributes — copy from the primary enlistment. + if (wtInfo?.SharedGitDir != null) + { + string primarySrc = Path.GetDirectoryName(wtInfo.SharedGitDir); + string primaryGitattributes = Path.Combine(primarySrc, ".gitattributes"); + string worktreeGitattributes = Path.Combine(fullPath, ".gitattributes"); + if (File.Exists(primaryGitattributes) && !File.Exists(worktreeGitattributes)) + { + File.Copy(primaryGitattributes, worktreeGitattributes); + } + } + + // Now mount GVFS — the index exists for GitIndexProjection + ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false); + } + } + + private static void MountMovedWorktree(string[] args) + { + // git worktree move + // After move, the worktree is at + string newPath = GetWorktreePositionalArg(args, 1); + if (string.IsNullOrEmpty(newPath)) + { + return; + } + + string fullPath = ResolvePath(newPath); + + string dotGitFile = Path.Combine(fullPath, ".git"); + if (File.Exists(dotGitFile)) + { + ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false); + } + } + } +} diff --git a/GVFS/GVFS.Hooks/Program.cs b/GVFS/GVFS.Hooks/Program.cs index 366f9985d..9fe0e6679 100644 --- a/GVFS/GVFS.Hooks/Program.cs +++ b/GVFS/GVFS.Hooks/Program.cs @@ -1,15 +1,16 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.Common.Git; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; using GVFS.Hooks.HooksPlatform; using System; using System.Collections.Generic; +using System.IO; using System.Linq; namespace GVFS.Hooks { - public class Program + public partial class Program { private const string PreCommandHook = "pre-command"; private const string PostCommandHook = "post-command"; @@ -52,6 +53,15 @@ public static void Main(string[] args) enlistmentPipename = GVFSHooksPlatform.GetNamedPipeName(enlistmentRoot); + // If running inside a worktree, append a worktree-specific + // suffix to the pipe name so hooks communicate with the + // correct GVFS mount instance. + string worktreeSuffix = GVFSEnlistment.GetWorktreePipeSuffix(normalizedCurrentDirectory); + if (worktreeSuffix != null) + { + enlistmentPipename += worktreeSuffix; + } + switch (GetHookType(args)) { case PreCommandHook: @@ -67,6 +77,8 @@ public static void Main(string[] args) { RunLockRequest(args, unattended, ReleaseGVFSLock); } + + RunPostCommands(args); break; default: @@ -98,6 +110,9 @@ private static void RunPreCommands(string[] args) ProcessHelper.Run("gvfs", "health --status", redirectOutput: false); } break; + case "worktree": + RunWorktreePreCommand(args); + break; } } @@ -110,6 +125,25 @@ private static bool ArgsBlockHydrationStatus(string[] args) || HasShortFlag(arg, "s")); } + private static void RunPostCommands(string[] args) + { + string command = GetGitCommand(args); + switch (command) + { + case "worktree": + RunWorktreePostCommand(args); + break; + } + } + + private static string ResolvePath(string path) + { + return Path.GetFullPath( + Path.IsPathRooted(path) + ? path + : Path.Combine(normalizedCurrentDirectory, path)); + } + private static bool HasShortFlag(string arg, string flag) { return arg.StartsWith("-") && !arg.StartsWith("--") && arg.Substring(1).Contains(flag); From 9a1b98bd50f1ba500e6e15829614614959576a7a Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 11 Mar 2026 14:56:48 -0700 Subject: [PATCH 05/17] tests: add worktree unit and functional tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests: WorktreeInfoTests — TryGetWorktreeInfo detection, pipe suffix WorktreeEnlistmentTests — CreateForWorktree path mappings WorktreeCommandParserTests — subcommand and arg extraction Functional tests: WorktreeTests — end-to-end add/list/remove with live GVFS mount GitBlockCommandsTests — update existing test for conditional block Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .../GitBlockCommandsTests.cs | 2 +- .../EnlistmentPerFixture/WorktreeTests.cs | 163 ++++++++++++++++++ .../Common/WorktreeCommandParserTests.cs | 144 ++++++++++++++++ .../Common/WorktreeEnlistmentTests.cs | 158 +++++++++++++++++ .../Common/WorktreeInfoTests.cs | 149 ++++++++++++++++ 5 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs create mode 100644 GVFS/GVFS.UnitTests/Common/WorktreeCommandParserTests.cs create mode 100644 GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs create mode 100644 GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitBlockCommandsTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitBlockCommandsTests.cs index 774f9be0b..d0660205c 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitBlockCommandsTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitBlockCommandsTests.cs @@ -23,7 +23,7 @@ public void GitBlockCommands() this.CommandBlocked("update-index --skip-worktree"); this.CommandBlocked("update-index --no-skip-worktree"); this.CommandBlocked("update-index --split-index"); - this.CommandBlocked("worktree list"); + this.CommandNotBlocked("worktree list"); } private void CommandBlocked(string command) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs new file mode 100644 index 000000000..fc94de2a2 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -0,0 +1,163 @@ +using GVFS.FunctionalTests.Tools; +using GVFS.Tests.Should; +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.IO; + +namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture +{ + [TestFixture] + [Category(Categories.GitCommands)] + public class WorktreeTests : TestsWithEnlistmentPerFixture + { + private const string WorktreeBranch = "worktree-test-branch"; + + [TestCase] + public void WorktreeAddRemoveCycle() + { + string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, "test-wt-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + + try + { + // 1. Create worktree + ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {WorktreeBranch} \"{worktreePath}\""); + addResult.ExitCode.ShouldEqual(0, $"worktree add failed: {addResult.Errors}"); + + // 2. Verify directory exists with projected files + Directory.Exists(worktreePath).ShouldBeTrue("Worktree directory should exist"); + File.Exists(Path.Combine(worktreePath, "Readme.md")).ShouldBeTrue("Readme.md should be projected"); + + string readmeContent = File.ReadAllText(Path.Combine(worktreePath, "Readme.md")); + readmeContent.ShouldContain( + expectedSubstrings: new[] { "GVFS" }); + + // 3. Verify git status is clean + ProcessResult statusResult = GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePath, + "status --porcelain"); + statusResult.ExitCode.ShouldEqual(0, $"git status failed: {statusResult.Errors}"); + statusResult.Output.Trim().ShouldBeEmpty("Worktree should have clean status"); + + // 4. Verify worktree list shows both + ProcessResult listResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + "worktree list"); + listResult.ExitCode.ShouldEqual(0, $"worktree list failed: {listResult.Errors}"); + string listOutput = listResult.Output; + string repoRootGitFormat = this.Enlistment.RepoRoot.Replace('\\', '/'); + string worktreePathGitFormat = worktreePath.Replace('\\', '/'); + Assert.IsTrue( + listOutput.Contains(repoRootGitFormat), + $"worktree list should contain repo root. Output: {listOutput}"); + Assert.IsTrue( + listOutput.Contains(worktreePathGitFormat), + $"worktree list should contain worktree path. Output: {listOutput}"); + + // 5. Make a change in the worktree, commit on the branch + string testFile = Path.Combine(worktreePath, "worktree-test.txt"); + File.WriteAllText(testFile, "created in worktree"); + + ProcessResult addFile = GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePath, "add worktree-test.txt"); + addFile.ExitCode.ShouldEqual(0, $"git add failed: {addFile.Errors}"); + + ProcessResult commit = GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePath, "commit -m \"test commit from worktree\""); + commit.ExitCode.ShouldEqual(0, $"git commit failed: {commit.Errors}"); + + // 6. Remove without --force should fail with helpful message + ProcessResult removeNoForce = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree remove \"{worktreePath}\""); + removeNoForce.ExitCode.ShouldNotEqual(0, "worktree remove without --force should fail"); + removeNoForce.Errors.ShouldContain( + expectedSubstrings: new[] { "--force" }); + + // Worktree should still be intact after failed remove + File.Exists(Path.Combine(worktreePath, "Readme.md")).ShouldBeTrue("Files should still be projected after failed remove"); + + // 6. Remove with --force should succeed + ProcessResult removeResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree remove --force \"{worktreePath}\""); + removeResult.ExitCode.ShouldEqual(0, $"worktree remove --force failed: {removeResult.Errors}"); + + // 7. Verify cleanup + Directory.Exists(worktreePath).ShouldBeFalse("Worktree directory should be deleted"); + + ProcessResult listAfter = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + "worktree list"); + listAfter.Output.ShouldNotContain( + ignoreCase: false, + unexpectedSubstrings: new[] { worktreePathGitFormat }); + + // 8. Verify commit from worktree is accessible from main enlistment + ProcessResult logFromMain = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"log -1 --format=%s {WorktreeBranch}"); + logFromMain.ExitCode.ShouldEqual(0, $"git log from main failed: {logFromMain.Errors}"); + logFromMain.Output.ShouldContain( + expectedSubstrings: new[] { "test commit from worktree" }); + } + finally + { + this.ForceCleanupWorktree(worktreePath); + } + } + + private void ForceCleanupWorktree(string worktreePath) + { + // Best-effort cleanup for test failure cases + try + { + GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree remove --force \"{worktreePath}\""); + } + catch + { + } + + if (Directory.Exists(worktreePath)) + { + try + { + // Kill any stuck GVFS.Mount for this worktree + foreach (Process p in Process.GetProcessesByName("GVFS.Mount")) + { + try + { + if (p.StartInfo.Arguments?.Contains(worktreePath) == true) + { + p.Kill(); + } + } + catch + { + } + } + + Directory.Delete(worktreePath, recursive: true); + } + catch + { + } + } + + // Clean up branch + try + { + GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"branch -D {WorktreeBranch}"); + } + catch + { + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeCommandParserTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeCommandParserTests.cs new file mode 100644 index 000000000..d9eda1da3 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/WorktreeCommandParserTests.cs @@ -0,0 +1,144 @@ +using GVFS.Common; +using GVFS.Tests.Should; +using NUnit.Framework; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class WorktreeCommandParserTests + { + [TestCase] + public void GetSubcommandReturnsAdd() + { + string[] args = { "post-command", "worktree", "add", "-b", "branch", @"C:\wt" }; + WorktreeCommandParser.GetSubcommand(args).ShouldEqual("add"); + } + + [TestCase] + public void GetSubcommandReturnsRemove() + { + string[] args = { "pre-command", "worktree", "remove", @"C:\wt" }; + WorktreeCommandParser.GetSubcommand(args).ShouldEqual("remove"); + } + + [TestCase] + public void GetSubcommandSkipsLeadingDoubleHyphenArgs() + { + string[] args = { "post-command", "worktree", "--git-pid=1234", "add", @"C:\wt" }; + WorktreeCommandParser.GetSubcommand(args).ShouldEqual("add"); + } + + [TestCase] + public void GetSubcommandReturnsNullWhenNoSubcommand() + { + string[] args = { "post-command", "worktree" }; + WorktreeCommandParser.GetSubcommand(args).ShouldBeNull(); + } + + [TestCase] + public void GetSubcommandNormalizesToLowercase() + { + string[] args = { "post-command", "worktree", "Add" }; + WorktreeCommandParser.GetSubcommand(args).ShouldEqual("add"); + } + + [TestCase] + public void GetPathArgExtractsPathFromAddWithBranch() + { + // git worktree add -b branch C:\worktree + string[] args = { "post-command", "worktree", "add", "-b", "my-branch", @"C:\repos\wt", "--git-pid=123", "--exit_code=0" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } + + [TestCase] + public void GetPathArgExtractsPathFromAddWithoutBranch() + { + // git worktree add C:\worktree + string[] args = { "post-command", "worktree", "add", @"C:\repos\wt", "--git-pid=123", "--exit_code=0" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } + + [TestCase] + public void GetPathArgExtractsPathFromRemove() + { + string[] args = { "pre-command", "worktree", "remove", @"C:\repos\wt", "--git-pid=456" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } + + [TestCase] + public void GetPathArgExtractsPathFromRemoveWithForce() + { + string[] args = { "pre-command", "worktree", "remove", "--force", @"C:\repos\wt" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } + + [TestCase] + public void GetPathArgSkipsBranchNameAfterDashB() + { + // -b takes a value — the path is the arg AFTER the branch name + string[] args = { "post-command", "worktree", "add", "-b", "feature", @"C:\repos\feature" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\feature"); + } + + [TestCase] + public void GetPathArgSkipsBranchNameAfterDashCapitalB() + { + string[] args = { "post-command", "worktree", "add", "-B", "feature", @"C:\repos\feature" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\feature"); + } + + [TestCase] + public void GetPathArgSkipsAllOptionFlags() + { + // -f, -d, -q, --detach, --checkout, --lock, --no-checkout + string[] args = { "post-command", "worktree", "add", "-f", "--no-checkout", "--lock", "--reason", "testing", @"C:\repos\wt" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } + + [TestCase] + public void GetPathArgHandlesSeparator() + { + // After --, everything is positional + string[] args = { "post-command", "worktree", "add", "--", @"C:\repos\wt" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } + + [TestCase] + public void GetPathArgSkipsGitPidAndExitCode() + { + string[] args = { "post-command", "worktree", "add", @"C:\wt", "--git-pid=99", "--exit_code=0" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\wt"); + } + + [TestCase] + public void GetPathArgReturnsNullWhenNoPath() + { + string[] args = { "post-command", "worktree", "list" }; + WorktreeCommandParser.GetPathArg(args).ShouldBeNull(); + } + + [TestCase] + public void GetPositionalArgReturnsSecondPositional() + { + // git worktree move + string[] args = { "post-command", "worktree", "move", @"C:\old", @"C:\new" }; + WorktreeCommandParser.GetPositionalArg(args, 0).ShouldEqual(@"C:\old"); + WorktreeCommandParser.GetPositionalArg(args, 1).ShouldEqual(@"C:\new"); + } + + [TestCase] + public void GetPositionalArgReturnsNullForOutOfRangeIndex() + { + string[] args = { "post-command", "worktree", "remove", @"C:\wt" }; + WorktreeCommandParser.GetPositionalArg(args, 1).ShouldBeNull(); + } + + [TestCase] + public void GetPathArgHandlesShortArgs() + { + // Ensure single-char flags without values are skipped + string[] args = { "post-command", "worktree", "add", "-f", "-q", @"C:\repos\wt" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs new file mode 100644 index 000000000..2541e56ac --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs @@ -0,0 +1,158 @@ +using GVFS.Common; +using GVFS.Tests.Should; +using NUnit.Framework; +using System.IO; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class WorktreeEnlistmentTests + { + private string testRoot; + private string primaryRoot; + private string sharedGitDir; + private string worktreePath; + private string worktreeGitDir; + + [SetUp] + public void SetUp() + { + this.testRoot = Path.Combine(Path.GetTempPath(), "GVFSWTEnlTests_" + Path.GetRandomFileName()); + this.primaryRoot = Path.Combine(this.testRoot, "enlistment"); + string primarySrc = Path.Combine(this.primaryRoot, "src"); + this.sharedGitDir = Path.Combine(primarySrc, ".git"); + this.worktreePath = Path.Combine(this.testRoot, "agent-wt-1"); + this.worktreeGitDir = Path.Combine(this.sharedGitDir, "worktrees", "agent-wt-1"); + + Directory.CreateDirectory(this.sharedGitDir); + Directory.CreateDirectory(this.worktreeGitDir); + Directory.CreateDirectory(this.worktreePath); + Directory.CreateDirectory(Path.Combine(this.primaryRoot, ".gvfs")); + + File.WriteAllText( + Path.Combine(this.sharedGitDir, "config"), + "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = https://mock/repo\n"); + File.WriteAllText( + Path.Combine(this.sharedGitDir, "HEAD"), + "ref: refs/heads/main\n"); + File.WriteAllText( + Path.Combine(this.worktreePath, ".git"), + "gitdir: " + this.worktreeGitDir); + File.WriteAllText( + Path.Combine(this.worktreeGitDir, "commondir"), + "../.."); + File.WriteAllText( + Path.Combine(this.worktreeGitDir, "HEAD"), + "ref: refs/heads/agent-wt-1\n"); + File.WriteAllText( + Path.Combine(this.worktreeGitDir, "gitdir"), + Path.Combine(this.worktreePath, ".git")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(this.testRoot)) + { + Directory.Delete(this.testRoot, recursive: true); + } + } + + private GVFSEnlistment CreateWorktreeEnlistment() + { + string gitBinPath = GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath() + ?? @"C:\Program Files\Git\cmd\git.exe"; + return GVFSEnlistment.CreateForWorktree( + this.primaryRoot, gitBinPath, authentication: null, + GVFSEnlistment.TryGetWorktreeInfo(this.worktreePath), + repoUrl: "https://mock/repo"); + } + + [TestCase] + public void IsWorktreeReturnsTrueForWorktreeEnlistment() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + enlistment.IsWorktree.ShouldBeTrue(); + } + + [TestCase] + public void WorktreeInfoIsPopulated() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + enlistment.Worktree.ShouldNotBeNull(); + enlistment.Worktree.Name.ShouldEqual("agent-wt-1"); + enlistment.Worktree.WorktreePath.ShouldEqual(this.worktreePath); + } + + [TestCase] + public void DotGitRootPointsToSharedGitDir() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + enlistment.DotGitRoot.ShouldEqual(this.sharedGitDir); + } + + [TestCase] + public void WorkingDirectoryRootIsWorktreePath() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + enlistment.WorkingDirectoryRoot.ShouldEqual(this.worktreePath); + } + + [TestCase] + public void LocalObjectsRootIsSharedGitObjects() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + enlistment.LocalObjectsRoot.ShouldEqual( + Path.Combine(this.sharedGitDir, "objects")); + } + + [TestCase] + public void LocalObjectsRootDoesNotDoubleGitPath() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + Assert.IsFalse( + enlistment.LocalObjectsRoot.Contains(Path.Combine(".git", ".git")), + "LocalObjectsRoot should not have doubled .git path"); + } + + [TestCase] + public void GitIndexPathUsesWorktreeGitDir() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + enlistment.GitIndexPath.ShouldEqual( + Path.Combine(this.worktreeGitDir, "index")); + } + + [TestCase] + public void NamedPipeNameIncludesWorktreeSuffix() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + Assert.IsTrue( + enlistment.NamedPipeName.Contains("_WT_AGENT-WT-1"), + "NamedPipeName should contain worktree suffix"); + } + + [TestCase] + public void DotGVFSRootIsInWorktreeGitDir() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + Assert.IsTrue( + enlistment.DotGVFSRoot.Contains(this.worktreeGitDir), + "DotGVFSRoot should be inside worktree git dir"); + } + + [TestCase] + public void EnlistmentRootIsPrimaryRoot() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + enlistment.EnlistmentRoot.ShouldEqual(this.primaryRoot); + } + + [TestCase] + public void RepoUrlIsReadFromSharedConfig() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + enlistment.RepoUrl.ShouldEqual("https://mock/repo"); + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs new file mode 100644 index 000000000..515b9b608 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs @@ -0,0 +1,149 @@ +using GVFS.Common; +using GVFS.Tests.Should; +using NUnit.Framework; +using System.IO; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class WorktreeInfoTests + { + private string testRoot; + + [SetUp] + public void SetUp() + { + this.testRoot = Path.Combine(Path.GetTempPath(), "GVFSWorktreeTests_" + Path.GetRandomFileName()); + Directory.CreateDirectory(this.testRoot); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(this.testRoot)) + { + Directory.Delete(this.testRoot, recursive: true); + } + } + + [TestCase] + public void ReturnsNullForNonWorktreeDirectory() + { + // A directory without a .git file is not a worktree + GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(this.testRoot); + info.ShouldBeNull(); + } + + [TestCase] + public void ReturnsNullWhenDotGitIsDirectory() + { + // A .git directory (not file) means primary enlistment, not a worktree + Directory.CreateDirectory(Path.Combine(this.testRoot, ".git")); + GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(this.testRoot); + info.ShouldBeNull(); + } + + [TestCase] + public void ReturnsNullWhenDotGitFileHasNoGitdirPrefix() + { + File.WriteAllText(Path.Combine(this.testRoot, ".git"), "not a gitdir line"); + GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(this.testRoot); + info.ShouldBeNull(); + } + + [TestCase] + public void DetectsWorktreeFromAbsoluteGitdir() + { + // Simulate a worktree: .git file pointing to .git/worktrees/ + string primaryGitDir = Path.Combine(this.testRoot, "primary", ".git"); + string worktreeGitDir = Path.Combine(primaryGitDir, "worktrees", "agent-1"); + Directory.CreateDirectory(worktreeGitDir); + + // Create commondir file pointing back to shared .git + File.WriteAllText(Path.Combine(worktreeGitDir, "commondir"), "../.."); + + // Create the worktree directory with a .git file + string worktreeDir = Path.Combine(this.testRoot, "wt"); + Directory.CreateDirectory(worktreeDir); + File.WriteAllText(Path.Combine(worktreeDir, ".git"), "gitdir: " + worktreeGitDir); + + GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(worktreeDir); + info.ShouldNotBeNull(); + info.Name.ShouldEqual("agent-1"); + info.WorktreePath.ShouldEqual(worktreeDir); + info.WorktreeGitDir.ShouldEqual(worktreeGitDir); + info.SharedGitDir.ShouldEqual(primaryGitDir); + info.PipeSuffix.ShouldEqual("_WT_AGENT-1"); + } + + [TestCase] + public void DetectsWorktreeFromRelativeGitdir() + { + // Simulate worktree with relative gitdir path + string primaryGitDir = Path.Combine(this.testRoot, "primary", ".git"); + string worktreeGitDir = Path.Combine(primaryGitDir, "worktrees", "feature-branch"); + Directory.CreateDirectory(worktreeGitDir); + + File.WriteAllText(Path.Combine(worktreeGitDir, "commondir"), "../.."); + + // Worktree as sibling of primary + string worktreeDir = Path.Combine(this.testRoot, "feature-branch"); + Directory.CreateDirectory(worktreeDir); + + // Use a relative path: ../primary/.git/worktrees/feature-branch + string relativePath = "../primary/.git/worktrees/feature-branch"; + File.WriteAllText(Path.Combine(worktreeDir, ".git"), "gitdir: " + relativePath); + + GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(worktreeDir); + info.ShouldNotBeNull(); + info.Name.ShouldEqual("feature-branch"); + info.PipeSuffix.ShouldEqual("_WT_FEATURE-BRANCH"); + } + + [TestCase] + public void WorksWithoutCommondirFile() + { + // Worktree git dir without a commondir file + string worktreeGitDir = Path.Combine(this.testRoot, "primary", ".git", "worktrees", "no-common"); + Directory.CreateDirectory(worktreeGitDir); + + string worktreeDir = Path.Combine(this.testRoot, "no-common"); + Directory.CreateDirectory(worktreeDir); + File.WriteAllText(Path.Combine(worktreeDir, ".git"), "gitdir: " + worktreeGitDir); + + GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(worktreeDir); + info.ShouldNotBeNull(); + info.Name.ShouldEqual("no-common"); + info.SharedGitDir.ShouldBeNull(); + } + + [TestCase] + public void PipeSuffixReturnsNullForNonWorktree() + { + string suffix = GVFSEnlistment.GetWorktreePipeSuffix(this.testRoot); + suffix.ShouldBeNull(); + } + + [TestCase] + public void PipeSuffixReturnsCorrectValueForWorktree() + { + string worktreeGitDir = Path.Combine(this.testRoot, "primary", ".git", "worktrees", "my-wt"); + Directory.CreateDirectory(worktreeGitDir); + + string worktreeDir = Path.Combine(this.testRoot, "my-wt"); + Directory.CreateDirectory(worktreeDir); + File.WriteAllText(Path.Combine(worktreeDir, ".git"), "gitdir: " + worktreeGitDir); + + string suffix = GVFSEnlistment.GetWorktreePipeSuffix(worktreeDir); + suffix.ShouldEqual("_WT_MY-WT"); + } + + [TestCase] + public void ReturnsNullForNonexistentDirectory() + { + string nonexistent = Path.Combine(this.testRoot, "does-not-exist"); + GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(nonexistent); + info.ShouldBeNull(); + } + } +} From 47073cc741b4da91be9b3fbcc0ef6b032ce85909 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 12 Mar 2026 13:41:01 -0700 Subject: [PATCH 06/17] hooks: block worktree creation inside VFS working directory ProjFS cannot handle nested virtualization roots. Add a pre-command check that blocks 'git worktree add' when the target path is inside the primary enlistment's working directory. Add IsPathInsideDirectory() utility to GVFSEnlistment.Shared.cs with unit tests for path matching (case-insensitive, sibling paths allowed). Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 10 ++++ GVFS/GVFS.Hooks/Program.Worktree.cs | 27 +++++++++ .../Common/WorktreeNestedPathTests.cs | 58 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index ae63fff61..7808ca448 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -27,6 +27,16 @@ public static bool IsUnattended(ITracer tracer) } } + /// + /// Returns true if is equal to or a subdirectory of + /// (case-insensitive). + /// + public static bool IsPathInsideDirectory(string path, string directory) + { + return path.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + path.Equals(directory, StringComparison.OrdinalIgnoreCase); + } + /// /// Detects if the given directory is a git worktree by checking for /// a .git file (not directory) containing "gitdir: path/.git/worktrees/name". diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs index 5699232e8..1244e2653 100644 --- a/GVFS/GVFS.Hooks/Program.Worktree.cs +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -35,6 +35,9 @@ private static void RunWorktreePreCommand(string[] args) string subcommand = GetWorktreeSubcommand(args); switch (subcommand) { + case "add": + BlockNestedWorktreeAdd(args); + break; case "remove": HandleWorktreeRemove(args); break; @@ -123,6 +126,30 @@ private static void CleanupSkipCleanCheckMarker(string[] args) } } + /// + /// Block creating a worktree inside the primary VFS working directory. + /// ProjFS cannot handle nested virtualization roots. + /// + private static void BlockNestedWorktreeAdd(string[] args) + { + string worktreePath = GetWorktreePathArg(args); + if (string.IsNullOrEmpty(worktreePath)) + { + return; + } + + string fullPath = ResolvePath(worktreePath); + string primaryWorkingDir = Path.Combine(enlistmentRoot, "src"); + + if (GVFSEnlistment.IsPathInsideDirectory(fullPath, primaryWorkingDir)) + { + Console.Error.WriteLine( + $"error: cannot create worktree inside the VFS working directory.\n" + + $"Create the worktree outside of '{primaryWorkingDir}'."); + Environment.Exit(1); + } + } + private static void HandleWorktreeRemove(string[] args) { string worktreePath = GetWorktreePathArg(args); diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs new file mode 100644 index 000000000..8cc7e941d --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs @@ -0,0 +1,58 @@ +using GVFS.Common; +using GVFS.Tests.Should; +using NUnit.Framework; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class WorktreeNestedPathTests + { + [TestCase] + public void PathInsidePrimaryIsBlocked() + { + GVFSEnlistment.IsPathInsideDirectory( + @"C:\repo\src\subfolder", + @"C:\repo\src").ShouldBeTrue(); + } + + [TestCase] + public void PathEqualToPrimaryIsBlocked() + { + GVFSEnlistment.IsPathInsideDirectory( + @"C:\repo\src", + @"C:\repo\src").ShouldBeTrue(); + } + + [TestCase] + public void PathOutsidePrimaryIsAllowed() + { + GVFSEnlistment.IsPathInsideDirectory( + @"C:\repo\src.worktrees\wt1", + @"C:\repo\src").ShouldBeFalse(); + } + + [TestCase] + public void SiblingPathIsAllowed() + { + GVFSEnlistment.IsPathInsideDirectory( + @"C:\repo\src2", + @"C:\repo\src").ShouldBeFalse(); + } + + [TestCase] + public void PathWithDifferentCaseIsBlocked() + { + GVFSEnlistment.IsPathInsideDirectory( + @"C:\Repo\SRC\subfolder", + @"C:\repo\src").ShouldBeTrue(); + } + + [TestCase] + public void DeeplyNestedPathIsBlocked() + { + GVFSEnlistment.IsPathInsideDirectory( + @"C:\repo\src\a\b\c\d", + @"C:\repo\src").ShouldBeTrue(); + } + } +} From b5e5ca54f49756c686d5bfb9c8780420568715d9 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 12 Mar 2026 13:43:58 -0700 Subject: [PATCH 07/17] hooks: check mount status before worktree remove Before removing a worktree, probe the named pipe to verify the GVFS mount is running. If not mounted: - Without --force: error with guidance to mount or use --force - With --force: skip unmount and let git proceed Refactor UnmountWorktree to accept a pre-resolved WorktreeInfo to avoid redundant TryGetWorktreeInfo calls. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Hooks/Program.Worktree.cs | 35 ++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs index 1244e2653..e88d268b0 100644 --- a/GVFS/GVFS.Hooks/Program.Worktree.cs +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -160,17 +160,32 @@ private static void HandleWorktreeRemove(string[] args) string fullPath = ResolvePath(worktreePath); GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath); - if (wtInfo == null) - { - return; - } bool hasForce = args.Any(a => a.Equals("--force", StringComparison.OrdinalIgnoreCase) || a.Equals("-f", StringComparison.OrdinalIgnoreCase)); + // Check if the worktree's GVFS mount is running by probing the pipe. + bool isMounted = false; + if (wtInfo != null) + { + string pipeName = GVFSHooksPlatform.GetNamedPipeName(enlistmentRoot) + wtInfo.PipeSuffix; + using (NamedPipeClient pipeClient = new NamedPipeClient(pipeName)) + { + isMounted = pipeClient.Connect(500); + } + } + if (!hasForce) { + if (!isMounted) + { + Console.Error.WriteLine( + $"error: worktree '{fullPath}' is not mounted.\n" + + $"Mount it with 'gvfs mount \"{fullPath}\"' or use 'git worktree remove --force'."); + Environment.Exit(1); + } + // Check for uncommitted changes while ProjFS is still mounted. ProcessResult statusResult = ProcessHelper.Run( "git", @@ -185,6 +200,11 @@ private static void HandleWorktreeRemove(string[] args) Environment.Exit(1); } } + else if (!isMounted) + { + // Force remove of unmounted worktree — nothing to unmount. + return; + } // Write a marker in the worktree gitdir that tells git.exe // to skip the cleanliness check during worktree remove. @@ -193,7 +213,7 @@ private static void HandleWorktreeRemove(string[] args) File.WriteAllText(skipCleanCheck, "1"); // Unmount ProjFS before git deletes the worktree directory. - UnmountWorktree(fullPath); + UnmountWorktree(fullPath, wtInfo); } private static void UnmountWorktree(string fullPath) @@ -204,6 +224,11 @@ private static void UnmountWorktree(string fullPath) return; } + UnmountWorktree(fullPath, wtInfo); + } + + private static void UnmountWorktree(string fullPath, GVFSEnlistment.WorktreeInfo wtInfo) + { ProcessHelper.Run("gvfs", $"unmount \"{fullPath}\"", redirectOutput: false); // Wait for the GVFS.Mount process to fully exit by polling From 820d04e9b57f51018345cd60c7443950800ebd6d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Mon, 16 Mar 2026 15:10:51 -0700 Subject: [PATCH 08/17] hooks: harden block of nested worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize paths in IsPathInsideDirectory using Path.GetFullPath to prevent traversal attacks with segments like '/../'. Add GetKnownWorktreePaths to enumerate existing worktrees from the .git/worktrees directory, and block creating a worktree inside any existing worktree — not just the primary VFS working directory. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 54 +++++++- GVFS/GVFS.Hooks/Program.Worktree.cs | 15 ++- .../Common/WorktreeNestedPathTests.cs | 120 ++++++++++++++---- 3 files changed, 159 insertions(+), 30 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index 7808ca448..2de88099f 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -1,5 +1,6 @@ using GVFS.Common.Tracing; using System; +using System.Collections.Generic; using System.IO; using System.Security; @@ -29,12 +30,19 @@ public static bool IsUnattended(ITracer tracer) /// /// Returns true if is equal to or a subdirectory of - /// (case-insensitive). + /// (case-insensitive). Both paths are + /// canonicalized with to resolve + /// relative segments (e.g. "/../") before comparison. /// public static bool IsPathInsideDirectory(string path, string directory) { - return path.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || - path.Equals(directory, StringComparison.OrdinalIgnoreCase); + string normalizedPath = Path.GetFullPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string normalizedDirectory = Path.GetFullPath(directory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + return normalizedPath.StartsWith(normalizedDirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + normalizedPath.Equals(normalizedDirectory, StringComparison.OrdinalIgnoreCase); } /// @@ -110,6 +118,46 @@ public static WorktreeInfo TryGetWorktreeInfo(string directory) } } + /// + /// Returns the working directory paths of all worktrees registered + /// under /worktrees by reading each entry's + /// gitdir file. The primary worktree is not included. + /// + public static string[] GetKnownWorktreePaths(string gitDir) + { + string worktreesDir = Path.Combine(gitDir, "worktrees"); + if (!Directory.Exists(worktreesDir)) + { + return new string[0]; + } + + List paths = new List(); + foreach (string entry in Directory.GetDirectories(worktreesDir)) + { + string gitdirFile = Path.Combine(entry, "gitdir"); + if (!File.Exists(gitdirFile)) + { + continue; + } + + try + { + string gitdirContent = File.ReadAllText(gitdirFile).Trim(); + gitdirContent = gitdirContent.Replace('/', Path.DirectorySeparatorChar); + string worktreeDir = Path.GetDirectoryName(gitdirContent); + if (!string.IsNullOrEmpty(worktreeDir)) + { + paths.Add(Path.GetFullPath(worktreeDir)); + } + } + catch + { + } + } + + return paths.ToArray(); + } + public class WorktreeInfo { public string Name { get; set; } diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs index e88d268b0..8849438a1 100644 --- a/GVFS/GVFS.Hooks/Program.Worktree.cs +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -127,7 +127,8 @@ private static void CleanupSkipCleanCheckMarker(string[] args) } /// - /// Block creating a worktree inside the primary VFS working directory. + /// Block creating a worktree inside the primary VFS working directory + /// or inside any other existing worktree. /// ProjFS cannot handle nested virtualization roots. /// private static void BlockNestedWorktreeAdd(string[] args) @@ -148,6 +149,18 @@ private static void BlockNestedWorktreeAdd(string[] args) $"Create the worktree outside of '{primaryWorkingDir}'."); Environment.Exit(1); } + + string gitDir = Path.Combine(primaryWorkingDir, ".git"); + foreach (string existingWorktreePath in GVFSEnlistment.GetKnownWorktreePaths(gitDir)) + { + if (GVFSEnlistment.IsPathInsideDirectory(fullPath, existingWorktreePath)) + { + Console.Error.WriteLine( + $"error: cannot create worktree inside an existing worktree.\n" + + $"'{fullPath}' is inside worktree '{existingWorktreePath}'."); + Environment.Exit(1); + } + } } private static void HandleWorktreeRemove(string[] args) diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs index 8cc7e941d..76d10a3b8 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs @@ -1,58 +1,126 @@ using GVFS.Common; -using GVFS.Tests.Should; using NUnit.Framework; +using System.IO; namespace GVFS.UnitTests.Common { [TestFixture] public class WorktreeNestedPathTests { - [TestCase] - public void PathInsidePrimaryIsBlocked() + // Basic containment + [TestCase(@"C:\repo\src\subfolder", @"C:\repo\src", true, Description = "Child path is inside directory")] + [TestCase(@"C:\repo\src", @"C:\repo\src", true, Description = "Equal path is inside directory")] + [TestCase(@"C:\repo\src\a\b\c\d", @"C:\repo\src", true, Description = "Deeply nested path is inside")] + [TestCase(@"C:\repo\src.worktrees\wt1", @"C:\repo\src", false, Description = "Path with prefix overlap is outside")] + [TestCase(@"C:\repo\src2", @"C:\repo\src", false, Description = "Sibling path is outside")] + + // Path traversal normalization + [TestCase(@"C:\repo\src\..\..\..\evil", @"C:\repo\src", false, Description = "Traversal escaping directory is outside")] + [TestCase(@"C:\repo\src\..", @"C:\repo\src", false, Description = "Traversal to parent is outside")] + [TestCase(@"C:\repo\src\..\other", @"C:\repo\src", false, Description = "Traversal to sibling is outside")] + [TestCase(@"C:\repo\src\sub\..\other", @"C:\repo\src", true, Description = "Traversal staying inside directory")] + [TestCase(@"C:\repo\src\.\subfolder", @"C:\repo\src", true, Description = "Dot segment resolves to same path")] + [TestCase(@"C:\repo\src\subfolder", @"C:\repo\.\src", true, Description = "Dot segment in directory")] + + // Trailing separators + [TestCase(@"C:\repo\src\subfolder", @"C:\repo\src\", true, Description = "Trailing slash on directory")] + [TestCase(@"C:\repo\src\subfolder\", @"C:\repo\src", true, Description = "Trailing slash on path")] + + // Case sensitivity + [TestCase(@"C:\Repo\SRC\subfolder", @"C:\repo\src", true, Description = "Case-insensitive child path")] + [TestCase(@"C:\REPO\SRC", @"C:\repo\src", true, Description = "Case-insensitive equal path")] + [TestCase(@"c:\repo\src\subfolder", @"C:\REPO\SRC", true, Description = "Lower drive letter vs upper")] + [TestCase(@"C:\Repo\Src2", @"C:\repo\src", false, Description = "Case-insensitive sibling is outside")] + + // Mixed forward and backward slashes + [TestCase(@"C:\repo\src/subfolder", @"C:\repo\src", true, Description = "Forward slash in child path")] + [TestCase("C:/repo/src/subfolder", @"C:\repo\src", true, Description = "All forward slashes in path")] + [TestCase(@"C:\repo\src\subfolder", "C:/repo/src", true, Description = "All forward slashes in directory")] + [TestCase("C:/repo/src", "C:/repo/src", true, Description = "Both paths with forward slashes")] + [TestCase("C:/repo/src/../other", @"C:\repo\src", false, Description = "Forward slashes with traversal")] + public void IsPathInsideDirectory(string path, string directory, bool expected) { - GVFSEnlistment.IsPathInsideDirectory( - @"C:\repo\src\subfolder", - @"C:\repo\src").ShouldBeTrue(); + Assert.AreEqual(expected, GVFSEnlistment.IsPathInsideDirectory(path, directory)); + } + + private string testDir; + + [SetUp] + public void SetUp() + { + this.testDir = Path.Combine(Path.GetTempPath(), "WorktreeNestedPathTests_" + Path.GetRandomFileName()); + Directory.CreateDirectory(this.testDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(this.testDir)) + { + Directory.Delete(this.testDir, recursive: true); + } } [TestCase] - public void PathEqualToPrimaryIsBlocked() + public void GetKnownWorktreePathsReturnsEmptyWhenNoWorktreesDir() { - GVFSEnlistment.IsPathInsideDirectory( - @"C:\repo\src", - @"C:\repo\src").ShouldBeTrue(); + string[] paths = GVFSEnlistment.GetKnownWorktreePaths(this.testDir); + Assert.AreEqual(0, paths.Length); } [TestCase] - public void PathOutsidePrimaryIsAllowed() + public void GetKnownWorktreePathsReturnsEmptyWhenWorktreesDirIsEmpty() { - GVFSEnlistment.IsPathInsideDirectory( - @"C:\repo\src.worktrees\wt1", - @"C:\repo\src").ShouldBeFalse(); + Directory.CreateDirectory(Path.Combine(this.testDir, "worktrees")); + + string[] paths = GVFSEnlistment.GetKnownWorktreePaths(this.testDir); + Assert.AreEqual(0, paths.Length); } [TestCase] - public void SiblingPathIsAllowed() + public void GetKnownWorktreePathsReadsGitdirFiles() { - GVFSEnlistment.IsPathInsideDirectory( - @"C:\repo\src2", - @"C:\repo\src").ShouldBeFalse(); + string wt1Dir = Path.Combine(this.testDir, "worktrees", "wt1"); + string wt2Dir = Path.Combine(this.testDir, "worktrees", "wt2"); + Directory.CreateDirectory(wt1Dir); + Directory.CreateDirectory(wt2Dir); + + File.WriteAllText(Path.Combine(wt1Dir, "gitdir"), @"C:\worktrees\wt1\.git" + "\n"); + File.WriteAllText(Path.Combine(wt2Dir, "gitdir"), @"C:\worktrees\wt2\.git" + "\n"); + + string[] paths = GVFSEnlistment.GetKnownWorktreePaths(this.testDir); + Assert.AreEqual(2, paths.Length); + Assert.That(paths, Has.Member(@"C:\worktrees\wt1")); + Assert.That(paths, Has.Member(@"C:\worktrees\wt2")); } [TestCase] - public void PathWithDifferentCaseIsBlocked() + public void GetKnownWorktreePathsSkipsEntriesWithoutGitdirFile() { - GVFSEnlistment.IsPathInsideDirectory( - @"C:\Repo\SRC\subfolder", - @"C:\repo\src").ShouldBeTrue(); + string wt1Dir = Path.Combine(this.testDir, "worktrees", "wt1"); + string wt2Dir = Path.Combine(this.testDir, "worktrees", "wt2"); + Directory.CreateDirectory(wt1Dir); + Directory.CreateDirectory(wt2Dir); + + File.WriteAllText(Path.Combine(wt1Dir, "gitdir"), @"C:\worktrees\wt1\.git" + "\n"); + // wt2 has no gitdir file + + string[] paths = GVFSEnlistment.GetKnownWorktreePaths(this.testDir); + Assert.AreEqual(1, paths.Length); + Assert.AreEqual(@"C:\worktrees\wt1", paths[0]); } [TestCase] - public void DeeplyNestedPathIsBlocked() + public void GetKnownWorktreePathsNormalizesForwardSlashes() { - GVFSEnlistment.IsPathInsideDirectory( - @"C:\repo\src\a\b\c\d", - @"C:\repo\src").ShouldBeTrue(); + string wtDir = Path.Combine(this.testDir, "worktrees", "wt1"); + Directory.CreateDirectory(wtDir); + + File.WriteAllText(Path.Combine(wtDir, "gitdir"), "C:/worktrees/wt1/.git\n"); + + string[] paths = GVFSEnlistment.GetKnownWorktreePaths(this.testDir); + Assert.AreEqual(1, paths.Length); + Assert.AreEqual(@"C:\worktrees\wt1", paths[0]); } } } From 706454846d609257f45ef9aff90da07816f37eae Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 17 Mar 2026 09:14:29 -0700 Subject: [PATCH 09/17] hooks: fix index copy race and vfs-hook script cleanup Copy the primary index to a temp file first, then rename atomically into the worktree's index path. A direct File.Copy on a live index risks a torn read on large indexes. Clean up the temp file on failure. Wrap the .vfs-empty-hook script creation and deletion in try/finally so the file is always cleaned up even if git checkout crashes. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Hooks/Program.Worktree.cs | 37 ++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs index 8849438a1..48d50d2f2 100644 --- a/GVFS/GVFS.Hooks/Program.Worktree.cs +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -289,7 +289,21 @@ private static void MountNewWorktree(string[] args) string worktreeIndex = Path.Combine(wtInfo.WorktreeGitDir, "index"); if (File.Exists(primaryIndex) && !File.Exists(worktreeIndex)) { - File.Copy(primaryIndex, worktreeIndex); + // Copy to a temp file first, then rename atomically. + // The primary index may be updated concurrently by the + // running mount; a direct copy risks a torn read on + // large indexes (200MB+ in some large repos). + string tempIndex = worktreeIndex + ".tmp"; + try + { + File.Copy(primaryIndex, tempIndex, overwrite: true); + File.Move(tempIndex, worktreeIndex); + } + catch + { + try { File.Delete(tempIndex); } catch { } + throw; + } } } @@ -303,15 +317,20 @@ private static void MountNewWorktree(string[] args) // doesn't exist yet, so post-index-change would fail trying // to connect to a pipe that hasn't been created. string emptyVfsHook = Path.Combine(fullPath, ".vfs-empty-hook"); - File.WriteAllText(emptyVfsHook, "#!/bin/sh\nprintf \".gitattributes\\n\"\n"); - string emptyVfsHookGitPath = emptyVfsHook.Replace('\\', '/'); - - ProcessHelper.Run( - "git", - $"-C \"{fullPath}\" -c core.virtualfilesystem=\"{emptyVfsHookGitPath}\" -c core.hookspath= checkout -f HEAD", - redirectOutput: false); + try + { + File.WriteAllText(emptyVfsHook, "#!/bin/sh\nprintf \".gitattributes\\n\"\n"); + string emptyVfsHookGitPath = emptyVfsHook.Replace('\\', '/'); - File.Delete(emptyVfsHook); + ProcessHelper.Run( + "git", + $"-C \"{fullPath}\" -c core.virtualfilesystem=\"{emptyVfsHookGitPath}\" -c core.hookspath= checkout -f HEAD", + redirectOutput: false); + } + finally + { + File.Delete(emptyVfsHook); + } // Hydrate .gitattributes — copy from the primary enlistment. if (wtInfo?.SharedGitDir != null) From 7379c1ac05e7345c333b0f9e5a74212ff7fd26c1 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 17 Mar 2026 09:28:40 -0700 Subject: [PATCH 10/17] mount: wrap RepoMetadata init/shutdown in try/finally Guarantee RepoMetadata.Shutdown() is called even if an unexpected exception occurs between TryInitialize and Shutdown. Without this, the process-global singleton could be left pointing at the wrong metadata directory. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Mount/InProcessMount.cs | 49 ++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index d5af1b4fa..09ec4b0e2 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -323,34 +323,43 @@ private void InitializeWorktreeMetadata() } } - // Bootstrap RepoMetadata from the primary enlistment's metadata + // Bootstrap RepoMetadata from the primary enlistment's metadata. + // 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 error; + string gitObjectsRoot; + string localCacheRoot; + string blobSizesRoot; + if (!RepoMetadata.TryInitialize(this.tracer, primaryDotGVFS, out error)) { this.FailMountAndExit("Failed to read primary enlistment metadata: " + error); } - string gitObjectsRoot; - if (!RepoMetadata.Instance.TryGetGitObjectsRoot(out gitObjectsRoot, out error)) + try { - this.FailMountAndExit("Failed to read git objects root from primary metadata: " + error); - } + if (!RepoMetadata.Instance.TryGetGitObjectsRoot(out gitObjectsRoot, out error)) + { + this.FailMountAndExit("Failed to read git objects root from primary metadata: " + error); + } - string localCacheRoot; - if (!RepoMetadata.Instance.TryGetLocalCacheRoot(out localCacheRoot, out error)) - { - this.FailMountAndExit("Failed to read local cache root from primary metadata: " + error); - } + if (!RepoMetadata.Instance.TryGetLocalCacheRoot(out localCacheRoot, out error)) + { + this.FailMountAndExit("Failed to read local cache root from primary metadata: " + error); + } - string blobSizesRoot; - if (!RepoMetadata.Instance.TryGetBlobSizesRoot(out blobSizesRoot, out error)) + if (!RepoMetadata.Instance.TryGetBlobSizesRoot(out blobSizesRoot, out error)) + { + this.FailMountAndExit("Failed to read blob sizes root from primary metadata: " + error); + } + } + finally { - this.FailMountAndExit("Failed to read blob sizes root from primary metadata: " + error); + RepoMetadata.Shutdown(); } - RepoMetadata.Shutdown(); - // Initialize cache paths on the enlistment so SaveCloneMetadata // can persist them into the worktree's metadata this.enlistment.InitializeCachePaths(localCacheRoot, gitObjectsRoot, blobSizesRoot); @@ -362,8 +371,14 @@ private void InitializeWorktreeMetadata() this.FailMountAndExit("Failed to initialize worktree metadata: " + error); } - RepoMetadata.Instance.SaveCloneMetadata(this.tracer, this.enlistment); - RepoMetadata.Shutdown(); + try + { + RepoMetadata.Instance.SaveCloneMetadata(this.tracer, this.enlistment); + } + finally + { + RepoMetadata.Shutdown(); + } } private NamedPipeServer StartNamedPipe() From 768e8e1b242fc2b5497e81efd098fc743b299327 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 17 Mar 2026 09:43:22 -0700 Subject: [PATCH 11/17] hooks: fix MAX_PATH overflow and simplify unmount wait Replace MAX_PATH fixed buffers in GetWorktreePipeSuffix() with std::wstring/std::string to handle long worktree paths safely. Use dynamic MultiByteToWideChar sizing instead of fixed buffer. Replace the 10-iteration pipe polling loop after gvfs unmount with a simple sleep. The unmount command already blocks until the mount process exits; the sleep allows remaining ProjFS handles to close. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Hooks/Program.Worktree.cs | 20 +++---------- .../common.windows.cpp | 29 +++++++++++-------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs index 48d50d2f2..45ca5a956 100644 --- a/GVFS/GVFS.Hooks/Program.Worktree.cs +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -244,22 +244,10 @@ private static void UnmountWorktree(string fullPath, GVFSEnlistment.WorktreeInfo { ProcessHelper.Run("gvfs", $"unmount \"{fullPath}\"", redirectOutput: false); - // Wait for the GVFS.Mount process to fully exit by polling - // the named pipe. Once the pipe is gone, the mount process - // has released all file handles. - string pipeName = GVFSHooksPlatform.GetNamedPipeName(enlistmentRoot) + wtInfo.PipeSuffix; - for (int i = 0; i < 10; i++) - { - using (NamedPipeClient pipeClient = new NamedPipeClient(pipeName)) - { - if (!pipeClient.Connect(100)) - { - return; - } - } - - System.Threading.Thread.Sleep(100); - } + // After gvfs unmount exits, ProjFS handles may still be closing. + // Wait briefly to allow the OS to release all handles before git + // attempts to delete the worktree directory. + System.Threading.Thread.Sleep(200); } private static void MountNewWorktree(string[] args) diff --git a/GVFS/GVFS.NativeHooks.Common/common.windows.cpp b/GVFS/GVFS.NativeHooks.Common/common.windows.cpp index c973ec67f..35c7db8d4 100644 --- a/GVFS/GVFS.NativeHooks.Common/common.windows.cpp +++ b/GVFS/GVFS.NativeHooks.Common/common.windows.cpp @@ -58,14 +58,12 @@ PATH_STRING GetFinalPathName(const PATH_STRING& path) // Returns an empty string if not in a worktree. PATH_STRING GetWorktreePipeSuffix(const wchar_t* directory) { - wchar_t dotGitPath[MAX_PATH]; - wcscpy_s(dotGitPath, directory); - size_t checkLen = wcslen(dotGitPath); - if (checkLen > 0 && dotGitPath[checkLen - 1] != L'\\') - wcscat_s(dotGitPath, L"\\"); - wcscat_s(dotGitPath, L".git"); - - DWORD dotGitAttrs = GetFileAttributesW(dotGitPath); + PATH_STRING dotGitPath(directory); + if (!dotGitPath.empty() && dotGitPath.back() != L'\\') + dotGitPath += L'\\'; + dotGitPath += L".git"; + + DWORD dotGitAttrs = GetFileAttributesW(dotGitPath.c_str()); if (dotGitAttrs == INVALID_FILE_ATTRIBUTES || (dotGitAttrs & FILE_ATTRIBUTE_DIRECTORY)) { @@ -75,11 +73,11 @@ PATH_STRING GetWorktreePipeSuffix(const wchar_t* directory) // .git is a file — this is a worktree. Read it to find the // worktree git directory (format: "gitdir: ") FILE* gitFile = NULL; - errno_t fopenResult = _wfopen_s(&gitFile, dotGitPath, L"r"); + errno_t fopenResult = _wfopen_s(&gitFile, dotGitPath.c_str(), L"r"); if (fopenResult != 0 || gitFile == NULL) return PATH_STRING(); - char gitdirLine[MAX_PATH * 2]; + char gitdirLine[4096]; if (fgets(gitdirLine, sizeof(gitdirLine), gitFile) == NULL) { fclose(gitFile); @@ -107,8 +105,15 @@ PATH_STRING GetWorktreePipeSuffix(const wchar_t* directory) if (lastSep == NULL) return PATH_STRING(); - wchar_t wtName[MAX_PATH]; - MultiByteToWideChar(CP_UTF8, 0, lastSep + 1, -1, wtName, MAX_PATH); + std::string nameUtf8(lastSep + 1); + int wideLen = MultiByteToWideChar(CP_UTF8, 0, nameUtf8.c_str(), -1, NULL, 0); + if (wideLen <= 0) + return PATH_STRING(); + + std::wstring wtName(wideLen, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, nameUtf8.c_str(), -1, &wtName[0], wideLen); + wtName.resize(wideLen - 1); // remove null terminator from string + PATH_STRING suffix = L"_WT_"; suffix += wtName; return suffix; From fcaae15d4b0874731a5a97e691f31d2b1d160265 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 17 Mar 2026 10:10:20 -0700 Subject: [PATCH 12/17] common: store enlistment root in worktree gitdir, remove path assumptions Write the primary enlistment root to a marker file in the worktree gitdir during creation (gvfs-enlistment-root). WorktreeInfo.GetEnlistmentRoot() reads this marker, falling back to the GetDirectoryName chain for worktrees created before this change. Replace all GetDirectoryName(GetDirectoryName(SharedGitDir)) chains in MountVerb, UnmountVerb, GVFSMountProcess, and GVFSEnlistment with the new GetEnlistmentRoot() method. Replace hardcoded "src" with GVFSConstants.WorkingDirectoryRootName in the nested worktree path check. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 32 +++++++++++++++++++++++ GVFS/GVFS.Common/GVFSEnlistment.cs | 20 +++++++------- GVFS/GVFS.Hooks/Program.Worktree.cs | 12 ++++++++- GVFS/GVFS.Service/GVFSMountProcess.cs | 3 +-- GVFS/GVFS/CommandLine/MountVerb.cs | 3 +-- GVFS/GVFS/CommandLine/UnmountVerb.cs | 3 +-- 6 files changed, 56 insertions(+), 17 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index 2de88099f..e28e2fe89 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -160,11 +160,43 @@ public static string[] GetKnownWorktreePaths(string gitDir) public class WorktreeInfo { + public const string EnlistmentRootFileName = "gvfs-enlistment-root"; + public string Name { get; set; } public string WorktreePath { get; set; } public string WorktreeGitDir { get; set; } public string SharedGitDir { get; set; } public string PipeSuffix { get; set; } + + /// + /// Returns the primary enlistment root, either from a stored + /// marker file or by deriving it from SharedGitDir. + /// + public string GetEnlistmentRoot() + { + // Prefer the explicit marker written during worktree creation + string markerPath = Path.Combine(this.WorktreeGitDir, EnlistmentRootFileName); + if (File.Exists(markerPath)) + { + string root = File.ReadAllText(markerPath).Trim(); + if (!string.IsNullOrEmpty(root)) + { + return root; + } + } + + // Fallback: derive from SharedGitDir (assumes /src/.git) + if (this.SharedGitDir != null) + { + string srcDir = Path.GetDirectoryName(this.SharedGitDir); + if (srcDir != null) + { + return Path.GetDirectoryName(srcDir); + } + } + + return null; + } } } } diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index 6b2767ac3..51b2348b8 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -141,22 +141,22 @@ public static GVFSEnlistment CreateFromDirectory( WorktreeInfo wtInfo = TryGetWorktreeInfo(directory); if (wtInfo?.SharedGitDir != null) { - string srcDir = Path.GetDirectoryName(wtInfo.SharedGitDir); - if (srcDir != null) + string primaryRoot = wtInfo.GetEnlistmentRoot(); + if (primaryRoot != null) { - string primaryRoot = Path.GetDirectoryName(srcDir); - if (primaryRoot != null) + // Read origin URL via the shared .git dir (not the worktree's + // .git file) because the base Enlistment constructor runs + // git config before we can override DotGitRoot. + string srcDir = Path.GetDirectoryName(wtInfo.SharedGitDir); + string repoUrl = null; + if (srcDir != null) { - // Read origin URL via the shared .git dir (not the worktree's - // .git file) because the base Enlistment constructor runs - // git config before we can override DotGitRoot. - string repoUrl = null; GitProcess git = new GitProcess(gitBinRoot, srcDir); GitProcess.ConfigResult urlResult = git.GetOriginUrl(); urlResult.TryParseAsString(out repoUrl, out _); - - return CreateForWorktree(primaryRoot, gitBinRoot, authentication, wtInfo, repoUrl?.Trim()); } + + return CreateForWorktree(primaryRoot, gitBinRoot, authentication, wtInfo, repoUrl?.Trim()); } } diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs index 45ca5a956..3e00572f2 100644 --- a/GVFS/GVFS.Hooks/Program.Worktree.cs +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -140,7 +140,7 @@ private static void BlockNestedWorktreeAdd(string[] args) } string fullPath = ResolvePath(worktreePath); - string primaryWorkingDir = Path.Combine(enlistmentRoot, "src"); + string primaryWorkingDir = Path.Combine(enlistmentRoot, GVFSConstants.WorkingDirectoryRootName); if (GVFSEnlistment.IsPathInsideDirectory(fullPath, primaryWorkingDir)) { @@ -266,6 +266,16 @@ private static void MountNewWorktree(string[] args) { GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath); + // Store the primary enlistment root so mount/unmount can find + // it without deriving from path structure assumptions. + if (wtInfo?.WorktreeGitDir != null) + { + string markerPath = Path.Combine( + wtInfo.WorktreeGitDir, + GVFSEnlistment.WorktreeInfo.EnlistmentRootFileName); + File.WriteAllText(markerPath, enlistmentRoot); + } + // Copy the primary's index to the worktree before checkout. // The primary index has all entries with correct skip-worktree // bits. If the worktree targets the same commit, checkout is diff --git a/GVFS/GVFS.Service/GVFSMountProcess.cs b/GVFS/GVFS.Service/GVFSMountProcess.cs index cbdef9a0e..582c58a1d 100644 --- a/GVFS/GVFS.Service/GVFSMountProcess.cs +++ b/GVFS/GVFS.Service/GVFSMountProcess.cs @@ -39,8 +39,7 @@ public bool MountRepository(string repoRoot, int sessionId) GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(repoRoot); if (wtInfo?.SharedGitDir != null) { - string srcDir = System.IO.Path.GetDirectoryName(wtInfo.SharedGitDir); - string enlistmentRoot = srcDir != null ? System.IO.Path.GetDirectoryName(srcDir) : null; + string enlistmentRoot = wtInfo.GetEnlistmentRoot(); if (enlistmentRoot != null) { pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot) + wtInfo.PipeSuffix; diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index ff8aae9a6..5e90c6c82 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -65,8 +65,7 @@ protected override void PreCreateEnlistment() if (wtInfo?.SharedGitDir != null) { // This is a worktree mount request. Find the primary enlistment root. - string srcDir = Path.GetDirectoryName(wtInfo.SharedGitDir); - enlistmentRoot = srcDir != null ? Path.GetDirectoryName(srcDir) : null; + enlistmentRoot = wtInfo.GetEnlistmentRoot(); if (enlistmentRoot == null) { diff --git a/GVFS/GVFS/CommandLine/UnmountVerb.cs b/GVFS/GVFS/CommandLine/UnmountVerb.cs index f75787c02..17e5f5b8b 100644 --- a/GVFS/GVFS/CommandLine/UnmountVerb.cs +++ b/GVFS/GVFS/CommandLine/UnmountVerb.cs @@ -50,8 +50,7 @@ public override void Execute() GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(pathToCheck); if (wtInfo?.SharedGitDir != null) { - string srcDir = System.IO.Path.GetDirectoryName(wtInfo.SharedGitDir); - root = srcDir != null ? System.IO.Path.GetDirectoryName(srcDir) : null; + root = wtInfo.GetEnlistmentRoot(); if (root == null) { this.ReportErrorAndExit("Error: could not determine enlistment root for worktree '{0}'", pathToCheck); From 3a243c1e7ce2f729ca1a1a85f77c062d26263a8b Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 17 Mar 2026 10:36:21 -0700 Subject: [PATCH 13/17] misc: restore unmount log path, API compat overload, narrow catch Restore enlistmentRoot parameter in UnmountVerb.AcquireLock so the "Run gvfs log" message appears on lock acquisition failure. Add backward-compatible WaitUntilMounted(tracer, enlistmentRoot, unattended, out error) overload for out-of-tree callers. Narrow bare catch in TryGetWorktreeInfo to IOException and UnauthorizedAccessException to avoid swallowing unexpected errors. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 6 +++++- GVFS/GVFS.Common/GVFSEnlistment.cs | 6 ++++++ GVFS/GVFS/CommandLine/UnmountVerb.cs | 6 +++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index e28e2fe89..7bb08e502 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -112,7 +112,11 @@ public static WorktreeInfo TryGetWorktreeInfo(string directory) PipeSuffix = "_WT_" + worktreeName.ToUpper(), }; } - catch + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) { return null; } diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index 51b2348b8..cbe9657b4 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -206,6 +206,12 @@ public static string GetNewGVFSLogFileName( fileSystem: fileSystem); } + public static bool WaitUntilMounted(ITracer tracer, string enlistmentRoot, bool unattended, out string errorMessage) + { + string pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot); + return WaitUntilMounted(tracer, pipeName, enlistmentRoot, unattended, out errorMessage); + } + public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enlistmentRoot, bool unattended, out string errorMessage) { tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Creating NamedPipeClient for pipe '{pipeName}'"); diff --git a/GVFS/GVFS/CommandLine/UnmountVerb.cs b/GVFS/GVFS/CommandLine/UnmountVerb.cs index 17e5f5b8b..311badb87 100644 --- a/GVFS/GVFS/CommandLine/UnmountVerb.cs +++ b/GVFS/GVFS/CommandLine/UnmountVerb.cs @@ -77,7 +77,7 @@ public override void Execute() if (!this.SkipLock) { - this.AcquireLock(pipeName); + this.AcquireLock(pipeName, root); } if (!this.ShowStatusWhileRunning( @@ -225,7 +225,7 @@ private bool UnregisterRepo(string rootPath, out string errorMessage) } } - private void AcquireLock(string pipeName) + private void AcquireLock(string pipeName, string enlistmentRoot) { using (NamedPipeClient pipeClient = new NamedPipeClient(pipeName)) { @@ -247,7 +247,7 @@ private void AcquireLock(string pipeName) GVFSPlatform.Instance.IsElevated(), isConsoleOutputRedirectedToFile: GVFSPlatform.Instance.IsConsoleOutputRedirectedToFile(), checkAvailabilityOnly: false, - gvfsEnlistmentRoot: null, + gvfsEnlistmentRoot: enlistmentRoot, gitCommandSessionId: string.Empty, result: out result)) { From 47fd82776d84ef6a4247a57b37ef8723aea2fd00 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 17 Mar 2026 10:51:34 -0700 Subject: [PATCH 14/17] tests: fix worktree cleanup to use gvfs unmount Process.StartInfo.Arguments is empty for externally-launched processes, so the old code that matched GVFS.Mount by arguments would never find or kill stuck mounts. Replace with gvfs unmount which uses the named pipe to cleanly shut down the mount process. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .../EnlistmentPerFixture/WorktreeTests.cs | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index fc94de2a2..3738ed7fb 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -126,21 +126,16 @@ private void ForceCleanupWorktree(string worktreePath) { try { - // Kill any stuck GVFS.Mount for this worktree - foreach (Process p in Process.GetProcessesByName("GVFS.Mount")) - { - try - { - if (p.StartInfo.Arguments?.Contains(worktreePath) == true) - { - p.Kill(); - } - } - catch - { - } - } + // Unmount any running GVFS mount for this worktree + Process unmount = Process.Start("gvfs", $"unmount \"{worktreePath}\""); + unmount?.WaitForExit(30000); + } + catch + { + } + try + { Directory.Delete(worktreePath, recursive: true); } catch From 6d69ca93ef0c681728c2c4b1999b7f282eb8fa01 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 17 Mar 2026 13:39:22 -0700 Subject: [PATCH 15/17] tests: concurrent worktree creation, commit, and removal Replace the single-worktree functional test with a concurrent test that creates two worktrees in parallel, verifies both have projected files and clean status, commits in both, verifies cross-visibility of commits (shared objects), and removes both in parallel. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .../EnlistmentPerFixture/WorktreeTests.cs | 171 +++++++++--------- 1 file changed, 86 insertions(+), 85 deletions(-) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 3738ed7fb..376796350 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -11,105 +11,106 @@ namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture [Category(Categories.GitCommands)] public class WorktreeTests : TestsWithEnlistmentPerFixture { - private const string WorktreeBranch = "worktree-test-branch"; + private const string WorktreeBranchA = "worktree-test-branch-a"; + private const string WorktreeBranchB = "worktree-test-branch-b"; [TestCase] - public void WorktreeAddRemoveCycle() + public void ConcurrentWorktreeAddCommitRemove() { - string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, "test-wt-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + 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)); try { - // 1. Create worktree - ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree add -b {WorktreeBranch} \"{worktreePath}\""); - addResult.ExitCode.ShouldEqual(0, $"worktree add failed: {addResult.Errors}"); - - // 2. Verify directory exists with projected files - Directory.Exists(worktreePath).ShouldBeTrue("Worktree directory should exist"); - File.Exists(Path.Combine(worktreePath, "Readme.md")).ShouldBeTrue("Readme.md should be projected"); - - string readmeContent = File.ReadAllText(Path.Combine(worktreePath, "Readme.md")); - readmeContent.ShouldContain( - expectedSubstrings: new[] { "GVFS" }); - - // 3. Verify git status is clean - ProcessResult statusResult = GitHelpers.InvokeGitAgainstGVFSRepo( - worktreePath, - "status --porcelain"); - statusResult.ExitCode.ShouldEqual(0, $"git status failed: {statusResult.Errors}"); - statusResult.Output.Trim().ShouldBeEmpty("Worktree should have clean status"); - - // 4. Verify worktree list shows both + // 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 ProcessResult listResult = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - "worktree list"); + this.Enlistment.RepoRoot, "worktree list"); listResult.ExitCode.ShouldEqual(0, $"worktree list failed: {listResult.Errors}"); string listOutput = listResult.Output; - string repoRootGitFormat = this.Enlistment.RepoRoot.Replace('\\', '/'); - string worktreePathGitFormat = worktreePath.Replace('\\', '/'); - Assert.IsTrue( - listOutput.Contains(repoRootGitFormat), - $"worktree list should contain repo root. Output: {listOutput}"); - Assert.IsTrue( - listOutput.Contains(worktreePathGitFormat), - $"worktree list should contain worktree path. Output: {listOutput}"); - - // 5. Make a change in the worktree, commit on the branch - string testFile = Path.Combine(worktreePath, "worktree-test.txt"); - File.WriteAllText(testFile, "created in worktree"); - - ProcessResult addFile = GitHelpers.InvokeGitAgainstGVFSRepo( - worktreePath, "add worktree-test.txt"); - addFile.ExitCode.ShouldEqual(0, $"git add failed: {addFile.Errors}"); - - ProcessResult commit = GitHelpers.InvokeGitAgainstGVFSRepo( - worktreePath, "commit -m \"test commit from worktree\""); - commit.ExitCode.ShouldEqual(0, $"git commit failed: {commit.Errors}"); - - // 6. Remove without --force should fail with helpful message - ProcessResult removeNoForce = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree remove \"{worktreePath}\""); - removeNoForce.ExitCode.ShouldNotEqual(0, "worktree remove without --force should fail"); - removeNoForce.Errors.ShouldContain( - expectedSubstrings: new[] { "--force" }); - - // Worktree should still be intact after failed remove - File.Exists(Path.Combine(worktreePath, "Readme.md")).ShouldBeTrue("Files should still be projected after failed remove"); - - // 6. Remove with --force should succeed - ProcessResult removeResult = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree remove --force \"{worktreePath}\""); - removeResult.ExitCode.ShouldEqual(0, $"worktree remove --force failed: {removeResult.Errors}"); - - // 7. Verify cleanup - Directory.Exists(worktreePath).ShouldBeFalse("Worktree directory should be deleted"); - - ProcessResult listAfter = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - "worktree list"); - listAfter.Output.ShouldNotContain( - ignoreCase: false, - unexpectedSubstrings: new[] { worktreePathGitFormat }); - - // 8. Verify commit from worktree is accessible from main enlistment - ProcessResult logFromMain = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"log -1 --format=%s {WorktreeBranch}"); - logFromMain.ExitCode.ShouldEqual(0, $"git log from main failed: {logFromMain.Errors}"); - logFromMain.Output.ShouldContain( - expectedSubstrings: new[] { "test commit from worktree" }); + 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"); } finally { - this.ForceCleanupWorktree(worktreePath); + this.ForceCleanupWorktree(worktreePathA, WorktreeBranchA); + this.ForceCleanupWorktree(worktreePathB, WorktreeBranchB); } } - private void ForceCleanupWorktree(string worktreePath) + private void ForceCleanupWorktree(string worktreePath, string branchName) { // Best-effort cleanup for test failure cases try @@ -148,7 +149,7 @@ private void ForceCleanupWorktree(string worktreePath) { GitHelpers.InvokeGitAgainstGVFSRepo( this.Enlistment.RepoRoot, - $"branch -D {WorktreeBranch}"); + $"branch -D {branchName}"); } catch { From d933faecdfb8793b08a9b5cb16f0749bff779158 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 27 Mar 2026 16:39:41 -0700 Subject: [PATCH 16/17] Address PR feedback from mjcheetham and KeithIsSleeping TryGetWorktreeInfo: - Walk up from subdirectories to find worktree root - Canonicalize directory to absolute path - Require commondir file for valid worktree (return null if missing) - Add out string error overload; callers fail or warn on IO errors - Add GitDirPrefix, CommonDirName, SkipCleanCheckName constants WorktreeCommandParser: - Handle combined short flags (e.g., -fd, -fb branch, -bfd) - Separate long/short option handling - Handle --git-pid/--exit_code as separate-value options - Document assumptions and note Mono.Options as future improvement Hooks: - Write empty marker file instead of "1" for skip-clean-check - Check unmount exit code; block git on failure unless --force - Reference PhysicalFileSystem.TryCopyToTempFileAndRename in comment Other: - Revert whitespace-only changes in InProcessMountVerb.cs - New unit tests for subdirectory detection, combined flags, baked-in values Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSConstants.cs | 3 + GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 86 +++++++++++++++---- GVFS/GVFS.Common/GVFSEnlistment.cs | 8 +- GVFS/GVFS.Common/WorktreeCommandParser.cs | 60 +++++++++++-- GVFS/GVFS.Hooks/Program.Worktree.cs | 43 +++++++--- GVFS/GVFS.Mount/InProcessMountVerb.cs | 10 +-- GVFS/GVFS.Service/GVFSMountProcess.cs | 9 +- .../Common/WorktreeCommandParserTests.cs | 32 +++++++ .../Common/WorktreeInfoTests.cs | 48 +++++++++-- GVFS/GVFS/CommandLine/MountVerb.cs | 8 +- GVFS/GVFS/CommandLine/UnmountVerb.cs | 8 +- 11 files changed, 266 insertions(+), 49 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSConstants.cs b/GVFS/GVFS.Common/GVFSConstants.cs index 87a363ae5..ff717ff11 100644 --- a/GVFS/GVFS.Common/GVFSConstants.cs +++ b/GVFS/GVFS.Common/GVFSConstants.cs @@ -146,6 +146,9 @@ public static class DotGit { public const string Root = ".git"; public const string HeadName = "HEAD"; + public const string GitDirPrefix = "gitdir: "; + public const string CommonDirName = "commondir"; + public const string SkipCleanCheckName = "skip-clean-check"; public const string IndexName = "index"; public const string PackedRefsName = "packed-refs"; public const string LockExtension = ".lock"; diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index 7bb08e502..26e2de306 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -57,34 +57,81 @@ public static string GetWorktreePipeSuffix(string directory) } /// - /// Detects if the given directory is a git worktree. If so, returns - /// a WorktreeInfo with the worktree name, git dir path, and shared - /// git dir path. Returns null if not a worktree. + /// Detects if the given directory (or any ancestor) is a git worktree. + /// Walks up from looking for a .git + /// file (not directory) containing a gitdir: pointer. Returns + /// null if not inside a worktree. /// public static WorktreeInfo TryGetWorktreeInfo(string directory) { - string dotGitPath = Path.Combine(directory, ".git"); + return TryGetWorktreeInfo(directory, out _); + } - if (!File.Exists(dotGitPath) || Directory.Exists(dotGitPath)) + /// + /// Detects if the given directory (or any ancestor) is a git worktree. + /// Walks up from looking for a .git + /// file (not directory) containing a gitdir: pointer. Returns + /// null if not inside a worktree, with an error message if an I/O + /// error prevented detection. + /// + public static WorktreeInfo TryGetWorktreeInfo(string directory, out string error) + { + error = null; + + if (string.IsNullOrEmpty(directory)) { return null; } + // Canonicalize to an absolute path so walk-up and Path.Combine + // behave consistently regardless of the caller's CWD. + string current = Path.GetFullPath(directory); + while (current != null) + { + string dotGitPath = Path.Combine(current, ".git"); + + if (Directory.Exists(dotGitPath)) + { + // Found a real .git directory — this is a primary worktree, not a linked worktree + return null; + } + + if (File.Exists(dotGitPath)) + { + return TryParseWorktreeGitFile(current, dotGitPath, out error); + } + + string parent = Path.GetDirectoryName(current); + if (parent == current) + { + break; + } + + current = parent; + } + + return null; + } + + private static WorktreeInfo TryParseWorktreeGitFile(string worktreeRoot, string dotGitPath, out string error) + { + error = null; + try { string gitdirLine = File.ReadAllText(dotGitPath).Trim(); - if (!gitdirLine.StartsWith("gitdir: ")) + if (!gitdirLine.StartsWith(GVFSConstants.DotGit.GitDirPrefix)) { return null; } - string gitdirPath = gitdirLine.Substring("gitdir: ".Length).Trim(); + string gitdirPath = gitdirLine.Substring(GVFSConstants.DotGit.GitDirPrefix.Length).Trim(); gitdirPath = gitdirPath.Replace('/', Path.DirectorySeparatorChar); // Resolve relative paths against the worktree directory if (!Path.IsPathRooted(gitdirPath)) { - gitdirPath = Path.GetFullPath(Path.Combine(directory, gitdirPath)); + gitdirPath = Path.GetFullPath(Path.Combine(worktreeRoot, gitdirPath)); } string worktreeName = Path.GetFileName(gitdirPath); @@ -93,31 +140,34 @@ public static WorktreeInfo TryGetWorktreeInfo(string directory) return null; } - // Read commondir to find the shared .git/ directory - // commondir file contains a relative path like "../../.." - string commondirFile = Path.Combine(gitdirPath, "commondir"); - string sharedGitDir = null; - if (File.Exists(commondirFile)) + // Read commondir to find the shared .git/ directory. + // All valid worktrees must have a commondir file. + string commondirFile = Path.Combine(gitdirPath, GVFSConstants.DotGit.CommonDirName); + if (!File.Exists(commondirFile)) { - string commondirContent = File.ReadAllText(commondirFile).Trim(); - sharedGitDir = Path.GetFullPath(Path.Combine(gitdirPath, commondirContent)); + return null; } + string commondirContent = File.ReadAllText(commondirFile).Trim(); + string sharedGitDir = Path.GetFullPath(Path.Combine(gitdirPath, commondirContent)); + return new WorktreeInfo { Name = worktreeName, - WorktreePath = directory, + WorktreePath = worktreeRoot, WorktreeGitDir = gitdirPath, SharedGitDir = sharedGitDir, PipeSuffix = "_WT_" + worktreeName.ToUpper(), }; } - catch (IOException) + catch (IOException e) { + error = e.Message; return null; } - catch (UnauthorizedAccessException) + catch (UnauthorizedAccessException e) { + error = e.Message; return null; } } diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index cbe9657b4..eb407c175 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -138,7 +138,13 @@ public static GVFSEnlistment CreateFromDirectory( // Always check for worktree first. A worktree directory may // be under the enlistment tree, so TryGetGVFSEnlistmentRoot // can succeed by walking up — but we need a worktree enlistment. - WorktreeInfo wtInfo = TryGetWorktreeInfo(directory); + string worktreeError; + WorktreeInfo wtInfo = TryGetWorktreeInfo(directory, out worktreeError); + if (worktreeError != null) + { + throw new InvalidRepoException($"Failed to check worktree status for '{directory}': {worktreeError}"); + } + if (wtInfo?.SharedGitDir != null) { string primaryRoot = wtInfo.GetEnlistmentRoot(); diff --git a/GVFS/GVFS.Common/WorktreeCommandParser.cs b/GVFS/GVFS.Common/WorktreeCommandParser.cs index ae0dc415b..df98cc750 100644 --- a/GVFS/GVFS.Common/WorktreeCommandParser.cs +++ b/GVFS/GVFS.Common/WorktreeCommandParser.cs @@ -6,9 +6,20 @@ namespace GVFS.Common /// /// Parses git worktree command arguments from hook args arrays. /// Hook args format: [hooktype, "worktree", subcommand, options..., positional args..., --git-pid=N, --exit_code=N] + /// + /// Assumptions: + /// - Args are passed by git exactly as the user typed them (no normalization). + /// - --git-pid and --exit_code are always appended by git in =value form. + /// - Single-letter flags may be combined (e.g., -fd for --force --detach). + /// - -b/-B always consume the next arg as a branch name, even when combined (e.g., -fb branch). + /// + /// Future improvement: consider replacing with a POSIX-compatible arg parser + /// library (e.g., Mono.Options, MIT license) to handle edge cases more robustly. /// public static class WorktreeCommandParser { + private static readonly HashSet ShortOptionsWithValue = new HashSet { 'b', 'B' }; + /// /// Gets the worktree subcommand (add, remove, move, list, etc.) from hook args. /// @@ -17,7 +28,7 @@ public static string GetSubcommand(string[] args) // args[0] = hook type, args[1] = "worktree", args[2+] = subcommand and its args for (int i = 2; i < args.Length; i++) { - if (!args[i].StartsWith("--")) + if (!args[i].StartsWith("-")) { return args[i].ToLowerInvariant(); } @@ -36,9 +47,9 @@ public static string GetSubcommand(string[] args) /// 0-based index of the positional arg after the subcommand public static string GetPositionalArg(string[] args, int positionalIndex) { - var optionsWithValue = new HashSet(StringComparer.OrdinalIgnoreCase) + var longOptionsWithValue = new HashSet(StringComparer.OrdinalIgnoreCase) { - "-b", "-B", "--reason" + "--reason" }; int found = -1; @@ -46,8 +57,14 @@ public static string GetPositionalArg(string[] args, int positionalIndex) bool pastSeparator = false; for (int i = 2; i < args.Length; i++) { - if (args[i].StartsWith("--git-pid=") || args[i].StartsWith("--exit_code=")) + if (args[i].StartsWith("--git-pid") || args[i].StartsWith("--exit_code")) { + // Always =value form, but skip either way + if (!args[i].Contains("=") && i + 1 < args.Length) + { + i++; + } + continue; } @@ -57,9 +74,40 @@ public static string GetPositionalArg(string[] args, int positionalIndex) continue; } - if (!pastSeparator && args[i].StartsWith("-")) + if (!pastSeparator && args[i].StartsWith("--")) + { + // Long option — check if it takes a separate value + if (longOptionsWithValue.Contains(args[i]) && i + 1 < args.Length) + { + i++; + } + + continue; + } + + if (!pastSeparator && args[i].StartsWith("-") && args[i].Length > 1) { - if (optionsWithValue.Contains(args[i]) && i + 1 < args.Length) + // Short option(s), possibly combined (e.g., -fd, -fb branch). + // A value-taking letter consumes the rest of the arg as its value. + // Only consume the next arg if the first value-taking letter is + // the last character (no baked-in value). + // e.g., -bfd → b="fd" (baked), -fdb val → f,d booleans, b="val" + // -Bb → B="b" (baked), -fBb → f boolean, B="b" (baked) + string flags = args[i].Substring(1); + bool consumesNextArg = false; + for (int j = 0; j < flags.Length; j++) + { + if (ShortOptionsWithValue.Contains(flags[j])) + { + // This letter takes a value. If it's the last letter, + // the value is the next arg. Otherwise the value is the + // remaining characters (baked in) and we're done. + consumesNextArg = (j == flags.Length - 1); + break; + } + } + + if (consumesNextArg && i + 1 < args.Length) { i++; } diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs index 3e00572f2..200c01e7e 100644 --- a/GVFS/GVFS.Hooks/Program.Worktree.cs +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -76,7 +76,12 @@ private static void UnmountWorktreeByArg(string[] args) } string fullPath = ResolvePath(worktreePath); - UnmountWorktree(fullPath); + if (!UnmountWorktree(fullPath)) + { + Console.Error.WriteLine( + $"error: failed to unmount worktree '{fullPath}'. Cannot proceed with move."); + Environment.Exit(1); + } } /// @@ -118,7 +123,7 @@ private static void CleanupSkipCleanCheckMarker(string[] args) GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath); if (wtInfo != null) { - string markerPath = Path.Combine(wtInfo.WorktreeGitDir, "skip-clean-check"); + string markerPath = Path.Combine(wtInfo.WorktreeGitDir, GVFSConstants.DotGit.SkipCleanCheckName); if (File.Exists(markerPath)) { File.Delete(markerPath); @@ -222,32 +227,40 @@ private static void HandleWorktreeRemove(string[] args) // Write a marker in the worktree gitdir that tells git.exe // to skip the cleanliness check during worktree remove. // We already did our own check above while ProjFS was alive. - string skipCleanCheck = Path.Combine(wtInfo.WorktreeGitDir, "skip-clean-check"); - File.WriteAllText(skipCleanCheck, "1"); + string skipCleanCheck = Path.Combine(wtInfo.WorktreeGitDir, GVFSConstants.DotGit.SkipCleanCheckName); + File.WriteAllText(skipCleanCheck, string.Empty); // Unmount ProjFS before git deletes the worktree directory. - UnmountWorktree(fullPath, wtInfo); + if (!UnmountWorktree(fullPath, wtInfo) && !hasForce) + { + Console.Error.WriteLine( + $"error: failed to unmount worktree '{fullPath}'.\n" + + $"Use 'git worktree remove --force' to attempt removal anyway."); + Environment.Exit(1); + } } - private static void UnmountWorktree(string fullPath) + private static bool UnmountWorktree(string fullPath) { GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath); if (wtInfo == null) { - return; + return false; } - UnmountWorktree(fullPath, wtInfo); + return UnmountWorktree(fullPath, wtInfo); } - private static void UnmountWorktree(string fullPath, GVFSEnlistment.WorktreeInfo wtInfo) + private static bool UnmountWorktree(string fullPath, GVFSEnlistment.WorktreeInfo wtInfo) { - ProcessHelper.Run("gvfs", $"unmount \"{fullPath}\"", redirectOutput: false); + ProcessResult result = ProcessHelper.Run("gvfs", $"unmount \"{fullPath}\"", redirectOutput: false); // After gvfs unmount exits, ProjFS handles may still be closing. // Wait briefly to allow the OS to release all handles before git // attempts to delete the worktree directory. System.Threading.Thread.Sleep(200); + + return result.ExitCode == 0; } private static void MountNewWorktree(string[] args) @@ -264,7 +277,12 @@ private static void MountNewWorktree(string[] args) string dotGitFile = Path.Combine(fullPath, ".git"); if (File.Exists(dotGitFile)) { - GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath); + string worktreeError; + GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath, out worktreeError); + if (worktreeError != null) + { + Console.Error.WriteLine($"warning: failed to read worktree info for '{fullPath}': {worktreeError}"); + } // Store the primary enlistment root so mount/unmount can find // it without deriving from path structure assumptions. @@ -291,6 +309,9 @@ private static void MountNewWorktree(string[] args) // The primary index may be updated concurrently by the // running mount; a direct copy risks a torn read on // large indexes (200MB+ in some large repos). + // Note: mirrors PhysicalFileSystem.TryCopyToTempFileAndRename + // but that method requires GVFSPlatform which is not + // available in the hooks process. string tempIndex = worktreeIndex + ".tmp"; try { diff --git a/GVFS/GVFS.Mount/InProcessMountVerb.cs b/GVFS/GVFS.Mount/InProcessMountVerb.cs index 0cc43d960..17d373b7c 100644 --- a/GVFS/GVFS.Mount/InProcessMountVerb.cs +++ b/GVFS/GVFS.Mount/InProcessMountVerb.cs @@ -57,11 +57,11 @@ public InProcessMountVerb() HelpText = "Service initiated mount.")] public string StartedByService { get; set; } - [Option( - 'b', - GVFSConstants.VerbParameters.Mount.StartedByVerb, - Default = false, - Required = false, + [Option( + 'b', + GVFSConstants.VerbParameters.Mount.StartedByVerb, + Default = false, + Required = false, HelpText = "Verb initiated mount.")] public bool StartedByVerb { get; set; } diff --git a/GVFS/GVFS.Service/GVFSMountProcess.cs b/GVFS/GVFS.Service/GVFSMountProcess.cs index 582c58a1d..4e65e6c62 100644 --- a/GVFS/GVFS.Service/GVFSMountProcess.cs +++ b/GVFS/GVFS.Service/GVFSMountProcess.cs @@ -36,7 +36,14 @@ public bool MountRepository(string repoRoot, int sessionId) string errorMessage; string pipeName = GVFSPlatform.Instance.GetNamedPipeName(repoRoot); - GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(repoRoot); + string worktreeError; + GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(repoRoot, out worktreeError); + if (worktreeError != null) + { + this.tracer.RelatedError($"Failed to check worktree status for '{repoRoot}': {worktreeError}"); + return false; + } + if (wtInfo?.SharedGitDir != null) { string enlistmentRoot = wtInfo.GetEnlistmentRoot(); diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeCommandParserTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeCommandParserTests.cs index d9eda1da3..ccfa0c0a1 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeCommandParserTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeCommandParserTests.cs @@ -140,5 +140,37 @@ public void GetPathArgHandlesShortArgs() string[] args = { "post-command", "worktree", "add", "-f", "-q", @"C:\repos\wt" }; WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); } + + [TestCase] + public void GetPathArgHandlesCombinedShortFlags() + { + // -fd = --force --detach combined into one arg + string[] args = { "post-command", "worktree", "add", "-fd", @"C:\repos\wt", "HEAD" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } + + [TestCase] + public void GetPathArgHandlesCombinedFlagWithBranch() + { + // -fb = --force + -b, next arg is the branch name + string[] args = { "post-command", "worktree", "add", "-fb", "my-branch", @"C:\repos\wt" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } + + [TestCase] + public void GetPathArgHandlesBranchValueBakedIn() + { + // -bfd = -b with value "fd" baked in, no next-arg consumption + string[] args = { "post-command", "worktree", "add", "-bfd", @"C:\repos\wt" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } + + [TestCase] + public void GetPathArgHandlesTwoValueOptionsFirstConsumes() + { + // -Bb = -B with value "b" baked in, no next-arg consumption + string[] args = { "post-command", "worktree", "add", "-Bb", @"C:\repos\wt" }; + WorktreeCommandParser.GetPathArg(args).ShouldEqual(@"C:\repos\wt"); + } } } diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs index 515b9b608..9ebe56963 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs @@ -101,9 +101,9 @@ public void DetectsWorktreeFromRelativeGitdir() } [TestCase] - public void WorksWithoutCommondirFile() + public void ReturnsNullWithoutCommondirFile() { - // Worktree git dir without a commondir file + // Worktree git dir without a commondir file is invalid string worktreeGitDir = Path.Combine(this.testRoot, "primary", ".git", "worktrees", "no-common"); Directory.CreateDirectory(worktreeGitDir); @@ -112,9 +112,7 @@ public void WorksWithoutCommondirFile() File.WriteAllText(Path.Combine(worktreeDir, ".git"), "gitdir: " + worktreeGitDir); GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(worktreeDir); - info.ShouldNotBeNull(); - info.Name.ShouldEqual("no-common"); - info.SharedGitDir.ShouldBeNull(); + info.ShouldBeNull(); } [TestCase] @@ -129,6 +127,7 @@ public void PipeSuffixReturnsCorrectValueForWorktree() { string worktreeGitDir = Path.Combine(this.testRoot, "primary", ".git", "worktrees", "my-wt"); Directory.CreateDirectory(worktreeGitDir); + File.WriteAllText(Path.Combine(worktreeGitDir, "commondir"), "../.."); string worktreeDir = Path.Combine(this.testRoot, "my-wt"); Directory.CreateDirectory(worktreeDir); @@ -145,5 +144,44 @@ public void ReturnsNullForNonexistentDirectory() GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(nonexistent); info.ShouldBeNull(); } + + [TestCase] + public void DetectsWorktreeFromSubdirectory() + { + // Set up a worktree at testRoot/wt-sub with .git file + string primaryGitDir = Path.Combine(this.testRoot, "primary", ".git"); + string worktreeGitDir = Path.Combine(primaryGitDir, "worktrees", "wt-sub"); + Directory.CreateDirectory(worktreeGitDir); + File.WriteAllText(Path.Combine(worktreeGitDir, "commondir"), "../.."); + + string worktreeDir = Path.Combine(this.testRoot, "wt-sub"); + Directory.CreateDirectory(worktreeDir); + File.WriteAllText(Path.Combine(worktreeDir, ".git"), "gitdir: " + worktreeGitDir); + + // Create a subdirectory inside the worktree + string subDir = Path.Combine(worktreeDir, "a", "b", "c"); + Directory.CreateDirectory(subDir); + + // TryGetWorktreeInfo should walk up and find the worktree root + GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(subDir); + info.ShouldNotBeNull(); + info.Name.ShouldEqual("wt-sub"); + info.WorktreePath.ShouldEqual(worktreeDir); + } + + [TestCase] + public void ReturnsNullForPrimaryFromSubdirectory() + { + // Set up a primary repo with a real .git directory + string primaryDir = Path.Combine(this.testRoot, "primary-repo"); + Directory.CreateDirectory(Path.Combine(primaryDir, ".git")); + + // Walking up from a subdirectory should find the .git dir and return null + string subDir = Path.Combine(primaryDir, "src", "folder"); + Directory.CreateDirectory(subDir); + + GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(subDir); + info.ShouldBeNull(); + } } } diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index 5e90c6c82..2fa730a8e 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -61,7 +61,13 @@ protected override void PreCreateEnlistment() ? Environment.CurrentDirectory : this.EnlistmentRootPathParameter; - GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(pathToCheck); + string worktreeError; + GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(pathToCheck, out worktreeError); + if (worktreeError != null) + { + this.ReportErrorAndExit("Error: failed to check worktree status for '{0}': {1}", pathToCheck, worktreeError); + } + if (wtInfo?.SharedGitDir != null) { // This is a worktree mount request. Find the primary enlistment root. diff --git a/GVFS/GVFS/CommandLine/UnmountVerb.cs b/GVFS/GVFS/CommandLine/UnmountVerb.cs index 311badb87..eebb4a3b1 100644 --- a/GVFS/GVFS/CommandLine/UnmountVerb.cs +++ b/GVFS/GVFS/CommandLine/UnmountVerb.cs @@ -47,7 +47,13 @@ public override void Execute() : this.EnlistmentRootPathParameter; string registrationPath; - GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(pathToCheck); + string worktreeError; + GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(pathToCheck, out worktreeError); + if (worktreeError != null) + { + this.ReportErrorAndExit("Error: failed to check worktree status for '{0}': {1}", pathToCheck, worktreeError); + } + if (wtInfo?.SharedGitDir != null) { root = wtInfo.GetEnlistmentRoot(); From d4b4bbc859839f488f9070fd49b05cd380d2f428 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 7 Apr 2026 16:01:37 -0700 Subject: [PATCH 17/17] common: single-quote core.virtualfilesystem for paths with spaces git executes core.virtualfilesystem via the shell (use_shell=1 in virtualfilesystem.c). When the enlistment path contains spaces, the unquoted absolute path is split by bash and the hook fails with 'No such file or directory'. Wrap the path in single quotes. Git's config file parser strips double quotes but preserves single quotes, and bash treats single-quoted strings as a single token. Apply the same fix to the -c override in the worktree checkout code path. Signed-off-by: Tyrie Vella Assisted-by: Claude Opus 4.6 --- GVFS/GVFS.Common/Git/RequiredGitConfig.cs | 9 +++++++-- GVFS/GVFS.Hooks/Program.Worktree.cs | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/GVFS/GVFS.Common/Git/RequiredGitConfig.cs b/GVFS/GVFS.Common/Git/RequiredGitConfig.cs index f41aaff43..2220a795b 100644 --- a/GVFS/GVFS.Common/Git/RequiredGitConfig.cs +++ b/GVFS/GVFS.Common/Git/RequiredGitConfig.cs @@ -19,8 +19,13 @@ public static Dictionary GetRequiredSettings(Enlistment enlistme string expectedHooksPath = Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.Hooks.RootName); expectedHooksPath = Paths.ConvertPathToGitFormat(expectedHooksPath); - string virtualFileSystemPath = Paths.ConvertPathToGitFormat( - Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.Hooks.RootName, GVFSConstants.DotGit.Hooks.VirtualFileSystemName)); + // Single-quote the path: git executes core.virtualfilesystem via the + // shell (use_shell=1 in virtualfilesystem.c), so spaces in an absolute + // path would split the command. Git's config parser strips double quotes + // but preserves single quotes, and bash treats single-quoted strings as + // a single token. + string virtualFileSystemPath = "'" + Paths.ConvertPathToGitFormat( + Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.Hooks.RootName, GVFSConstants.DotGit.Hooks.VirtualFileSystemName)) + "'"; string gitStatusCachePath = null; if (!GVFSEnlistment.IsUnattended(tracer: null) && GVFSPlatform.Instance.IsGitStatusCacheSupported()) diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs index 200c01e7e..325532a37 100644 --- a/GVFS/GVFS.Hooks/Program.Worktree.cs +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -343,7 +343,7 @@ private static void MountNewWorktree(string[] args) ProcessHelper.Run( "git", - $"-C \"{fullPath}\" -c core.virtualfilesystem=\"{emptyVfsHookGitPath}\" -c core.hookspath= checkout -f HEAD", + $"-C \"{fullPath}\" -c core.virtualfilesystem=\"'{emptyVfsHookGitPath}'\" -c core.hookspath= checkout -f HEAD", redirectOutput: false); } finally