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
177 changes: 177 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,180 @@ 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;
}

// 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 (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
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 const string EnlistmentRootFileName = "gvfs-enlistment-root";

public string Name { get; set; }
public string WorktreePath { get; set; }
public string WorktreeGitDir { get; set; }
public string SharedGitDir { get; set; }
public string PipeSuffix { get; set; }

/// <summary>
/// Returns the primary enlistment root, either from a stored
/// marker file or by deriving it from SharedGitDir.
/// </summary>
public string GetEnlistmentRoot()
{
// Prefer the explicit marker written during worktree creation
string markerPath = Path.Combine(this.WorktreeGitDir, EnlistmentRootFileName);
if (File.Exists(markerPath))
{
string root = File.ReadAllText(markerPath).Trim();
if (!string.IsNullOrEmpty(root))
{
return root;
}
}

// Fallback: derive from SharedGitDir (assumes <root>/src/.git)
if (this.SharedGitDir != null)
{
string srcDir = Path.GetDirectoryName(this.SharedGitDir);
if (srcDir != null)
{
return Path.GetDirectoryName(srcDir);
}
}

return null;
}
}
}
}
92 changes: 92 additions & 0 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 primaryRoot = wtInfo.GetEnlistmentRoot();
if (primaryRoot != null)
{
// Read origin URL via the shared .git dir (not the worktree's
// .git file) because the base Enlistment constructor runs
// git config before we can override DotGitRoot.
string srcDir = Path.GetDirectoryName(wtInfo.SharedGitDir);
string repoUrl = null;
if (srcDir != null)
{
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");
}

/// <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 @@ -122,6 +209,11 @@ public static string GetNewGVFSLogFileName(
public static bool WaitUntilMounted(ITracer tracer, string enlistmentRoot, bool unattended, out string errorMessage)
{
string pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot);
return WaitUntilMounted(tracer, pipeName, enlistmentRoot, unattended, out errorMessage);
}

public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enlistmentRoot, bool unattended, out string errorMessage)
{
tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Creating NamedPipeClient for pipe '{pipeName}'");

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