Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion GVFS/GVFS.Common/Enlistment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

/// <summary>
/// Path to the git index file. Override for worktree-specific paths.
/// </summary>
public virtual string GitIndexPath
{
get { return Path.Combine(this.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Index); }
}
public string RepoUrl { get; }
public bool FlushFileBuffersForPacks { get; }

Expand Down
12 changes: 10 additions & 2 deletions GVFS/GVFS.Common/GVFSConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>Path relative to the git directory (e.g., "logs/HEAD").</summary>
public static readonly string HeadRelativePath = Path.Combine(RootName, HeadName);
}

public static class Hooks
Expand All @@ -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);
Expand Down Expand Up @@ -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");

/// <summary>Path relative to the git directory (e.g., "objects/info/alternates").</summary>
public static readonly string AlternatesRelativePath = Path.Combine("objects", "info", "alternates");
}

public static class Pack
Expand Down
141 changes: 141 additions & 0 deletions GVFS/GVFS.Common/GVFSEnlistment.Shared.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using GVFS.Common.Tracing;
using System;
using System.Collections.Generic;
using System.IO;
using System.Security;

namespace GVFS.Common
Expand All @@ -25,5 +27,144 @@ public static bool IsUnattended(ITracer tracer)
return false;
}
}

/// <summary>
/// Returns true if <paramref name="path"/> is equal to or a subdirectory of
/// <paramref name="directory"/> (case-insensitive). Both paths are
/// canonicalized with <see cref="Path.GetFullPath(string)"/> to resolve
/// relative segments (e.g. "/../") before comparison.
/// </summary>
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);
}

/// <summary>
/// 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.
/// </summary>
public static string GetWorktreePipeSuffix(string directory)
{
WorktreeInfo info = TryGetWorktreeInfo(directory);
return info?.PipeSuffix;
}

/// <summary>
/// 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.
/// </summary>
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;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: Bare catch swallows all exceptions silently

TryGetWorktreeInfo catches all exceptions and returns null. This means file permission errors, disk I/O errors, and even OutOfMemoryException are silently swallowed. During mount operations, this could mask the real cause of a failure — the mount would fail with a confusing "not a worktree" error instead of the actual I/O error.

Recommendation: At minimum, catch IOException and UnauthorizedAccessException specifically. Consider logging the exception even if you still 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;
}
}

/// <summary>
/// Returns the working directory paths of all worktrees registered
/// under <paramref name="gitDir"/>/worktrees by reading each entry's
/// gitdir file. The primary worktree is not included.
/// </summary>
public static string[] GetKnownWorktreePaths(string gitDir)
{
string worktreesDir = Path.Combine(gitDir, "worktrees");
if (!Directory.Exists(worktreesDir))
{
return new string[0];
}

List<string> paths = new List<string>();
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; }
}
}
}
90 changes: 88 additions & 2 deletions GVFS/GVFS.Common/GVFSEnlistment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Path to the git index file. For worktrees this is in the
/// per-worktree git dir, not in the working directory.
/// </summary>
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; }
Expand Down Expand Up @@ -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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH: Fragile enlistment root derivation via parent directory traversal

Path.GetDirectoryName(Path.GetDirectoryName(wtInfo.SharedGitDir)) assumes the structure is always <enlistmentRoot>/src/.git. This same pattern is repeated in MountVerb.cs, UnmountVerb.cs, and GVFSMountProcess.cs (4+ locations).

If symlinks/junctions are involved, or if SharedGitDir has trailing separators, GetDirectoryName can return unexpected results.

Recommendation: Store the primary enlistment root explicitly during worktree creation (e.g., write it to a file in .git/worktrees/<name>/gvfs-primary-root). This eliminates the path structure assumption entirely and is resilient to junctions/symlinks.

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))
Expand All @@ -106,6 +178,21 @@ public static GVFSEnlistment CreateFromDirectory(
throw new InvalidRepoException($"Directory '{directory}' does not exist");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: No limit on concurrent worktree mounts

Each worktree creates a ProjFS instance (kernel callbacks, filter driver state), a GVFS.Mount process (thread pool, prefetch), index parsing threads, and a status cache. On the OS repo, each index is ~200MB. 10 concurrent agent worktrees would consume ~2GB for indexes alone, plus significant kernel resources for 10 ProjFS providers.

The ProjFS minifilter handles per-provider callback thread pools — multiple providers on the same volume competing for disk I/O with anti-malware filters creates a multiplier effect on system performance.

Recommendation: Add a configurable max-worktrees limit (default 4-8) enforced during worktree creation.

}

/// <summary>
/// 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.
/// </summary>
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,
Expand All @@ -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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: Breaking API change to WaitUntilMounted

The signature changed from (ITracer, string enlistmentRoot, bool, out string) to (ITracer, string pipeName, string enlistmentRoot, bool, out string). This is a public static method — any out-of-tree callers (OS build system tools, deployment scripts, other mount orchestrators) will break at compile time.

Recommendation: Consider adding the new overload alongside the old one (calling into shared logic) to maintain backward compatibility, or verify there are no external callers.

{
string pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot);
tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Creating NamedPipeClient for pipe '{pipeName}'");

errorMessage = null;
Expand Down
5 changes: 5 additions & 0 deletions GVFS/GVFS.Common/Git/GitCoreGVFSFlags.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public static EnlistmentHydrationSummary CreateSummary(
/// </summary>
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)
Expand Down
Loading
Loading