From 5631c15c1cbc20b43aad35f43e432dec54f0e663 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 11 Mar 2026 14:55:51 -0700 Subject: [PATCH 1/8] 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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 3f061257fb..5dcfd9e548 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 cf52e1fbbd..2d73c1dbba 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 a7e84ba33a..ae63fff61e 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 731f1b3559..6b2767ac31 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 411c5bc3c2..551be80b28 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 600ba91c59..7c1785ca15 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 d2a47377be6ecc712d42708a174e9f1c1d38c08d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 11 Mar 2026 14:56:06 -0700 Subject: [PATCH 2/8] 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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../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 d062d57586..c973ec67f4 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 289ef9aaac86fe0b41e5dc57300e93303a83858d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 11 Mar 2026 14:56:23 -0700 Subject: [PATCH 3/8] 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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 c566277032..d5af1b4fa6 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 17d373b7c2..0cc43d9608 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 6d11f9ce20..cbdef9a0e4 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 8a50f030aa..e6db82a9f8 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 10fd7b5730..8e7c4b2103 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 c2a4060d1f..b885008ffb 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 9d5c8257975dc2093c8a3f6012580eb090d278b8 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 11 Mar 2026 14:56:37 -0700 Subject: [PATCH 4/8] 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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 0000000000..ae0dc415b9 --- /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 9c0956b8bb..e5c634a943 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 0000000000..5699232e8e --- /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 366f9985d9..9fe0e66798 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 e4314b31884f94e2d5ed48b1c848f8e010bb87e8 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 11 Mar 2026 14:56:48 -0700 Subject: [PATCH 5/8] 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 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../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 774f9be0b0..d0660205cb 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 0000000000..fc94de2a26 --- /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 0000000000..d9eda1da3b --- /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 0000000000..2541e56acb --- /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 0000000000..515b9b6088 --- /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 d600b80c21070ce6e8840a30d578aed85e1027ef Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 12 Mar 2026 13:41:01 -0700 Subject: [PATCH 6/8] 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). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 ae63fff61e..7808ca4482 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 5699232e8e..1244e26532 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 0000000000..8cc7e941d0 --- /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 cbf6a8a5d87e97d41f4cd3ed2aeef24926f074c1 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 12 Mar 2026 13:43:58 -0700 Subject: [PATCH 7/8] 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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 1244e26532..e88d268b00 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 3d9bb0858e156021f7c31ad75def915fc40cb1d9 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Mon, 16 Mar 2026 15:10:51 -0700 Subject: [PATCH 8/8] 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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 7808ca4482..2de88099f5 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 e88d268b00..8849438a1b 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 8cc7e941d0..76d10a3b81 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]); } } }