-
Notifications
You must be signed in to change notification settings - Fork 461
git worktree support for VFSForGit
#1911
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
5631c15
d2a4737
289ef9a
9d5c825
e4314b3
d600b80
cbf6a8a
3d9bb08
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
@@ -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; | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MINOR: Bare
Recommendation: At minimum, catch |
||
|
|
||
| // 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; } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; } | ||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HIGH: Fragile enlistment root derivation via parent directory traversal
If symlinks/junctions are involved, or if Recommendation: Store the primary enlistment root explicitly during worktree creation (e.g., write it to a file in |
||
| 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"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM: Breaking API change to The signature changed from 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; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.