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..2de88099f 100644
--- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs
+++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs
@@ -1,5 +1,7 @@
using GVFS.Common.Tracing;
using System;
+using System.Collections.Generic;
+using System.IO;
using System.Security;
namespace GVFS.Common
@@ -25,5 +27,144 @@ public static bool IsUnattended(ITracer tracer)
return false;
}
}
+
+ ///
+ /// Returns true if is equal to or a subdirectory of
+ /// (case-insensitive). Both paths are
+ /// canonicalized with to resolve
+ /// relative segments (e.g. "/../") before comparison.
+ ///
+ public static bool IsPathInsideDirectory(string path, string directory)
+ {
+ 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);
+ }
+
+ ///
+ /// 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;
+ }
+ }
+
+ ///
+ /// 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; }
+ 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)
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.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.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..8849438a1
--- /dev/null
+++ b/GVFS/GVFS.Hooks/Program.Worktree.cs
@@ -0,0 +1,352 @@
+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 "add":
+ BlockNestedWorktreeAdd(args);
+ break;
+ 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);
+ }
+ }
+ }
+
+ ///
+ /// 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)
+ {
+ 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);
+ }
+
+ 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)
+ {
+ string worktreePath = GetWorktreePathArg(args);
+ if (string.IsNullOrEmpty(worktreePath))
+ {
+ return;
+ }
+
+ string fullPath = ResolvePath(worktreePath);
+ GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath);
+
+ 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",
+ $"-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);
+ }
+ }
+ 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.
+ // 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, wtInfo);
+ }
+
+ private static void UnmountWorktree(string fullPath)
+ {
+ GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath);
+ if (wtInfo == null)
+ {
+ 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
+ // 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);
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.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)
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.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();
+ }
+ }
+}
diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs
new file mode 100644
index 000000000..76d10a3b8
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Common/WorktreeNestedPathTests.cs
@@ -0,0 +1,126 @@
+using GVFS.Common;
+using NUnit.Framework;
+using System.IO;
+
+namespace GVFS.UnitTests.Common
+{
+ [TestFixture]
+ public class WorktreeNestedPathTests
+ {
+ // 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)
+ {
+ 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 GetKnownWorktreePathsReturnsEmptyWhenNoWorktreesDir()
+ {
+ string[] paths = GVFSEnlistment.GetKnownWorktreePaths(this.testDir);
+ Assert.AreEqual(0, paths.Length);
+ }
+
+ [TestCase]
+ public void GetKnownWorktreePathsReturnsEmptyWhenWorktreesDirIsEmpty()
+ {
+ Directory.CreateDirectory(Path.Combine(this.testDir, "worktrees"));
+
+ string[] paths = GVFSEnlistment.GetKnownWorktreePaths(this.testDir);
+ Assert.AreEqual(0, paths.Length);
+ }
+
+ [TestCase]
+ public void GetKnownWorktreePathsReadsGitdirFiles()
+ {
+ 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 GetKnownWorktreePathsSkipsEntriesWithoutGitdirFile()
+ {
+ 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 GetKnownWorktreePathsNormalizesForwardSlashes()
+ {
+ 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]);
+ }
+ }
+}
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))
{