diff --git a/GVFS/GVFS.Common/GVFSConstants.cs b/GVFS/GVFS.Common/GVFSConstants.cs index 78819737c..594db7bea 100644 --- a/GVFS/GVFS.Common/GVFSConstants.cs +++ b/GVFS/GVFS.Common/GVFSConstants.cs @@ -133,7 +133,6 @@ public static class GitStatusCache { public const string Name = "gitStatusCache"; public static readonly string CachePath = Path.Combine(Name, "GitStatusCache.dat"); - public static readonly string TreeCount = Path.Combine(Name, "TreeCountCache.dat"); } public static class HydrationStatus diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index 731f1b355..86c986b31 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -54,6 +54,11 @@ private GVFSEnlistment(string enlistmentRoot, string gitBinPath, GitAuthenticati public string GVFSLogsRoot { get; } + /// + /// Path to the git index file. + /// + public string GitIndexPath => Path.Combine(this.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Index); + public string LocalCacheRoot { get; private set; } public string BlobSizesRoot { get; private set; } diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index a86b6131a..6b31c4f50 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -682,11 +682,6 @@ public Result MultiPackIndexRepack(string gitObjectDirectory, string batchSize) return this.InvokeGitAgainstDotGitFolder($"-c pack.threads=1 -c repack.packKeptObjects=true multi-pack-index repack --object-dir=\"{gitObjectDirectory}\" --batch-size={batchSize} --no-progress"); } - public Result GetHeadTreeId() - { - return this.InvokeGitAgainstDotGitFolder("rev-parse \"HEAD^{tree}\"", usePreCommandHook: false); - } - public Process GetGitProcess(string command, string workingDirectory, string dotGitDirectory, bool useReadObjectHook, bool redirectStandardError, string gitObjectsDirectory, bool usePreCommandHook) { ProcessStartInfo processInfo = new ProcessStartInfo(this.gitBinPath); diff --git a/GVFS/GVFS.Common/GitStatusCache.cs b/GVFS/GVFS.Common/GitStatusCache.cs index 2a3e397fc..21bf09fce 100644 --- a/GVFS/GVFS.Common/GitStatusCache.cs +++ b/GVFS/GVFS.Common/GitStatusCache.cs @@ -50,6 +50,8 @@ public class GitStatusCache : IDisposable private CancellationTokenSource shutdownTokenSource; private Task activeHydrationTask; + private Func projectedFolderCountProvider; + private volatile CacheState cacheState = CacheState.Dirty; private object cacheFileLock = new object(); @@ -72,6 +74,23 @@ public GitStatusCache(GVFSContext context, TimeSpan backoffTime) this.wakeUpThread = new AutoResetEvent(false); } + /// + /// Sets the provider used to get the total projected folder count for hydration + /// summary computation. Must be called before for + /// hydration summary to function. + /// + /// + /// This is set post-construction because of a circular dependency: + /// InProcessMount creates GitStatusCache before FileSystemCallbacks, + /// but the provider requires GitIndexProjection, which is created + /// inside FileSystemCallbacks. FileSystemCallbacks calls this method + /// after GitIndexProjection is available. + /// + public void SetProjectedFolderCountProvider(Func provider) + { + this.projectedFolderCountProvider = provider; + } + public virtual void Initialize() { this.isInitialized = true; @@ -183,17 +202,16 @@ public virtual void Dispose() // Wait for the hydration task to complete before disposing the // token source it may still be using. - if (this.activeHydrationTask != null) + Task hydrationTask = Interlocked.Exchange(ref this.activeHydrationTask, null); + if (hydrationTask != null) { try { - this.activeHydrationTask.Wait(); + hydrationTask.Wait(TimeSpan.FromSeconds(5)); } catch (AggregateException) { } - - this.activeHydrationTask = null; } if (this.shutdownTokenSource != null) @@ -346,11 +364,12 @@ private void RebuildStatusCacheIfNeeded(bool ignoreBackoff) // Run hydration summary in parallel with git status — they are independent // operations and neither should delay the other. Task hydrationTask = Task.Run(() => this.UpdateHydrationSummary()); - this.activeHydrationTask = hydrationTask; + Interlocked.Exchange(ref this.activeHydrationTask, hydrationTask); bool rebuildStatusCacheSucceeded = this.TryRebuildStatusCache(); // Wait for hydration to complete before logging final stats. + // Exceptions are observed here to avoid unobserved task exceptions. try { hydrationTask.Wait(); @@ -385,6 +404,11 @@ private void RebuildStatusCacheIfNeeded(bool ignoreBackoff) private void UpdateHydrationSummary() { + if (this.projectedFolderCountProvider == null) + { + return; + } + bool enabled = TEST_EnableHydrationSummaryOverride ?? this.context.Repository.LibGit2RepoInvoker.GetConfigBoolOrDefault(GVFSConstants.GitConfig.ShowHydrationStatus, GVFSConstants.GitConfig.ShowHydrationStatusDefault); if (!enabled) @@ -409,7 +433,7 @@ private void UpdateHydrationSummary() * and this is also a convenient place to log telemetry for it. */ EnlistmentHydrationSummary hydrationSummary = - EnlistmentHydrationSummary.CreateSummary(this.context.Enlistment, this.context.FileSystem, this.context.Tracer, cancellationToken: this.shutdownTokenSource.Token); + EnlistmentHydrationSummary.CreateSummary(this.context.Enlistment, this.context.FileSystem, this.context.Tracer, this.projectedFolderCountProvider, this.shutdownTokenSource.Token); EventMetadata metadata = new EventMetadata(); metadata.Add("Area", EtwArea); if (hydrationSummary.IsValid) diff --git a/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs b/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs index 10f72a555..5e97fabcd 100644 --- a/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs +++ b/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs @@ -1,10 +1,8 @@ using GVFS.Common.FileSystem; -using GVFS.Common.Git; using GVFS.Common.Tracing; using System; using System.Diagnostics; using System.IO; -using System.Linq; using System.Threading; namespace GVFS.Common @@ -45,6 +43,7 @@ public static EnlistmentHydrationSummary CreateSummary( GVFSEnlistment enlistment, PhysicalFileSystem fileSystem, ITracer tracer, + Func projectedFolderCountProvider, CancellationToken cancellationToken = default) { Stopwatch totalStopwatch = Stopwatch.StartNew(); @@ -57,7 +56,6 @@ public static EnlistmentHydrationSummary CreateSummary( phaseStopwatch.Restart(); int totalFileCount = GetIndexFileCount(enlistment, fileSystem); long indexReadMs = phaseStopwatch.ElapsedMilliseconds; - cancellationToken.ThrowIfCancellationRequested(); EnlistmentPathData pathData = new EnlistmentPathData(); @@ -66,15 +64,13 @@ public static EnlistmentHydrationSummary CreateSummary( phaseStopwatch.Restart(); pathData.LoadPlaceholdersFromDatabase(enlistment); long placeholderLoadMs = phaseStopwatch.ElapsedMilliseconds; - cancellationToken.ThrowIfCancellationRequested(); phaseStopwatch.Restart(); pathData.LoadModifiedPaths(enlistment, tracer); long modifiedPathsLoadMs = phaseStopwatch.ElapsedMilliseconds; + cancellationToken.ThrowIfCancellationRequested(); - cancellationToken.ThrowIfCancellationRequested(); - int hydratedFileCount = pathData.ModifiedFilePaths.Count + pathData.PlaceholderFilePaths.Count; int hydratedFolderCount = pathData.ModifiedFolderPaths.Count + pathData.PlaceholderFolderPaths.Count; @@ -100,11 +96,12 @@ public static EnlistmentHydrationSummary CreateSummary( return soFar; } - /* Getting all the directories is also slow, but not as slow as reading the entire index, - * GetTotalPathCount caches the count so this is only slow occasionally, - * and the GitStatusCache manager also calls this to ensure it is updated frequently. */ + /* Get the total folder count from the caller-provided function. + * In the mount process, this comes from the in-memory projection (essentially free). + * In gvfs health --status fallback, this parses the git index via GitIndexProjection. */ + cancellationToken.ThrowIfCancellationRequested(); phaseStopwatch.Restart(); - int totalFolderCount = GetHeadTreeCount(enlistment, fileSystem, tracer); + int totalFolderCount = projectedFolderCountProvider(); long treeCountMs = phaseStopwatch.ElapsedMilliseconds; EmitDurationTelemetry(tracer, totalStopwatch.ElapsedMilliseconds, indexReadMs, placeholderLoadMs, modifiedPathsLoadMs, treeCountMs, earlyExit: false); @@ -169,7 +166,7 @@ private static void EmitDurationTelemetry( /// 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) @@ -193,77 +190,5 @@ internal static int GetIndexFileCount(GVFSEnlistment enlistment, PhysicalFileSys } } - /// - /// Get the total number of trees in the repo at HEAD. - /// - /// - /// This is used as the denominator in displaying percentage of hydrated - /// directories as part of git status pre-command hook. - /// It can take several seconds to calculate, so we cache it near the git status cache. - /// - /// - /// The number of subtrees at HEAD, which may be 0. - /// Will return 0 if unsuccessful. - /// - internal static int GetHeadTreeCount(GVFSEnlistment enlistment, PhysicalFileSystem fileSystem, ITracer tracer) - { - var gitProcess = enlistment.CreateGitProcess(); - var headResult = gitProcess.GetHeadTreeId(); - if (headResult.ExitCodeIsFailure) - { - tracer.RelatedError($"Failed to get HEAD tree ID: \nOutput: {headResult.Output}\n\nError:{headResult.Errors}"); - return 0; - } - var headSha = headResult.Output.Trim(); - var cacheFile = Path.Combine( - enlistment.DotGVFSRoot, - GVFSConstants.DotGVFS.GitStatusCache.TreeCount); - - // Load from cache if cache matches current HEAD. - if (fileSystem.FileExists(cacheFile)) - { - try - { - var lines = fileSystem.ReadLines(cacheFile).ToArray(); - if (lines.Length == 2 - && lines[0] == headSha - && int.TryParse(lines[1], out int cachedCount)) - { - return cachedCount; - } - } - catch (Exception ex) - { - tracer.RelatedWarning($"Failed to read tree count cache file at {cacheFile}: {ex}"); - // Ignore errors reading the cache - } - } - - int totalPathCount = 0; - GitProcess.Result folderResult = gitProcess.LsTree( - GVFSConstants.DotGit.HeadName, - line => totalPathCount++, - recursive: true, - showDirectories: true); - - if (GitProcess.Result.SuccessCode != folderResult.ExitCode) - { - tracer.RelatedError($"Failed to get tree count from HEAD: \nOutput: {folderResult.Output}\n\nError:{folderResult.Errors}"); - return 0; - } - - try - { - fileSystem.CreateDirectory(Path.GetDirectoryName(cacheFile)); - fileSystem.WriteAllText(cacheFile, $"{headSha}\n{totalPathCount}"); - } - catch (Exception ex) - { - // Ignore errors writing the cache - tracer.RelatedWarning($"Failed to write tree count cache file at {cacheFile}: {ex}"); - } - - return totalPathCount; - } } } diff --git a/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs b/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs index f826c4359..3920ab135 100644 --- a/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs +++ b/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs @@ -1,59 +1,266 @@ using GVFS.Common; using GVFS.Common.Git; -using GVFS.Tests.Should; +using GVFS.Common.Tracing; using GVFS.UnitTests.Mock.Common; using GVFS.UnitTests.Mock.FileSystem; using GVFS.UnitTests.Mock.Git; +using GVFS.Virtualization.Projection; using NUnit.Framework; -using System.Collections.Generic; +using System; using System.IO; -using System.Linq; +using System.Text; +using System.Threading; +using static GVFS.Virtualization.Projection.GitIndexProjection.GitIndexParser; namespace GVFS.UnitTests.Common { [TestFixture] public class EnlistmentHydrationSummaryTests { - private MockFileSystem fileSystem; - private MockGitProcess gitProcess; - private GVFSContext context; - private string gitParentPath; - private string gvfsMetadataPath; - private MockDirectory enlistmentDirectory; + [TestCase] + public void CountIndexFolders_FlatDirectories() + { + int count = CountFoldersInIndex(new[] { "src/file1.cs", "test/file2.cs" }); + Assert.AreEqual(2, count); // "src", "test" + } + + [TestCase] + public void CountIndexFolders_NestedDirectories() + { + int count = CountFoldersInIndex(new[] { "a/b/c/file1.cs", "a/b/file2.cs", "x/file3.cs" }); + Assert.AreEqual(4, count); // "a", "a/b", "a/b/c", "x" + } + + [TestCase] + public void CountIndexFolders_RootFilesOnly() + { + int count = CountFoldersInIndex(new[] { "README.md", ".gitignore" }); + Assert.AreEqual(0, count); + } + + [TestCase] + public void CountIndexFolders_EmptyIndex() + { + int count = CountFoldersInIndex(new string[0]); + Assert.AreEqual(0, count); + } + + [TestCase] + public void CountIndexFolders_DeepNesting() + { + int count = CountFoldersInIndex(new[] { "a/b/c/d/e/file.txt" }); + Assert.AreEqual(5, count); // "a", "a/b", "a/b/c", "a/b/c/d", "a/b/c/d/e" + } + + [TestCase] + public void CountIndexFolders_ExcludesNonSkipWorktree() + { + // Entries without skip-worktree and with NoConflicts merge state are not + // projected, so their directories should not be counted. + IndexEntryInfo[] entries = new[] + { + new IndexEntryInfo("src/file1.cs", skipWorktree: true), + new IndexEntryInfo("vendor/lib/file2.cs", skipWorktree: false), + }; + + int count = CountFoldersInIndex(entries); + Assert.AreEqual(1, count); // only "src" + } - private const string HeadTreeId = "0123456789012345678901234567890123456789"; - private const int HeadPathCount = 42; + [TestCase] + public void CountIndexFolders_ExcludesCommonAncestor() + { + // CommonAncestor entries are excluded even when skip-worktree is set. + IndexEntryInfo[] entries = new[] + { + new IndexEntryInfo("src/file1.cs", skipWorktree: true), + new IndexEntryInfo("conflict/file2.cs", skipWorktree: true, mergeState: MergeStage.CommonAncestor), + }; + + int count = CountFoldersInIndex(entries); + Assert.AreEqual(1, count); // only "src" + } + + [TestCase] + public void CountIndexFolders_IncludesYoursMergeState() + { + // Yours merge-state entries are projected even without skip-worktree. + IndexEntryInfo[] entries = new[] + { + new IndexEntryInfo("src/file1.cs", skipWorktree: true), + new IndexEntryInfo("merge/file2.cs", skipWorktree: false, mergeState: MergeStage.Yours), + }; + + int count = CountFoldersInIndex(entries); + Assert.AreEqual(2, count); // "src" and "merge" + } - public static IEnumerable<(string CachePrecontents, string ExpectedCachePostContents)> HeadTreeCountCacheContents + private static int CountFoldersInIndex(string[] paths) { - get + byte[] indexBytes = CreateV4Index(paths); + using (MemoryStream stream = new MemoryStream(indexBytes)) { - yield return (null, $"{HeadTreeId}\n{HeadPathCount}"); - yield return ($"{HeadTreeId}\n{HeadPathCount}", $"{HeadTreeId}\n{HeadPathCount}"); - yield return ($"{HeadTreeId}\n{HeadPathCount - 1}", $"{HeadTreeId}\n{HeadPathCount - 1}"); - yield return ($"{HeadTreeId.Replace("1", "a")}\n{HeadPathCount - 1}", $"{HeadTreeId}\n{HeadPathCount}"); - yield return ($"{HeadTreeId}\nabc", $"{HeadTreeId}\n{HeadPathCount}"); - yield return ($"{HeadTreeId}\nabc", $"{HeadTreeId}\n{HeadPathCount}"); - yield return ($"\n", $"{HeadTreeId}\n{HeadPathCount}"); - yield return ($"\nabc", $"{HeadTreeId}\n{HeadPathCount}"); + return GitIndexProjection.CountIndexFolders(new MockTracer(), stream); } } + private static int CountFoldersInIndex(IndexEntryInfo[] entries) + { + byte[] indexBytes = CreateV4Index(entries); + using (MemoryStream stream = new MemoryStream(indexBytes)) + { + return GitIndexProjection.CountIndexFolders(new MockTracer(), stream); + } + } + + /// + /// Create a minimal git index v4 binary matching the format GitIndexGenerator produces. + /// Uses prefix-compression for paths (v4 format). + /// + private static byte[] CreateV4Index(string[] paths) + { + IndexEntryInfo[] entries = new IndexEntryInfo[paths.Length]; + for (int i = 0; i < paths.Length; i++) + { + entries[i] = new IndexEntryInfo(paths[i], skipWorktree: true); + } + + return CreateV4Index(entries); + } + + private static byte[] CreateV4Index(IndexEntryInfo[] entries) + { + // Stat entry header matching GitIndexGenerator.EntryHeader: + // 40 bytes with file mode 0x81A4 (regular file, 644) at offset 24-27 + byte[] entryHeader = new byte[40]; + entryHeader[26] = 0x81; + entryHeader[27] = 0xA4; + + using (MemoryStream ms = new MemoryStream()) + using (BinaryWriter bw = new BinaryWriter(ms)) + { + // Header + bw.Write(new byte[] { (byte)'D', (byte)'I', (byte)'R', (byte)'C' }); + WriteBigEndian32(bw, 4); // version 4 + WriteBigEndian32(bw, (uint)entries.Length); + + string previousPath = string.Empty; + foreach (IndexEntryInfo entry in entries) + { + // 40-byte stat entry header with valid file mode + bw.Write(entryHeader); + // 20 bytes SHA-1 (zeros) + bw.Write(new byte[20]); + // Flags: path length in low 12 bits, merge state in bits 12-13, extended bit 14 + byte[] pathBytes = Encoding.UTF8.GetBytes(entry.Path); + ushort flags = (ushort)(Math.Min(pathBytes.Length, 0xFFF) | 0x4000 | ((ushort)entry.MergeState << 12)); + WriteBigEndian16(bw, flags); + // Extended flags: skip-worktree bit + ushort extendedFlags = entry.SkipWorktree ? (ushort)0x4000 : (ushort)0; + WriteBigEndian16(bw, extendedFlags); + + // V4 prefix compression: compute common prefix with previous path + int commonLen = 0; + int maxCommon = Math.Min(previousPath.Length, entry.Path.Length); + while (commonLen < maxCommon && previousPath[commonLen] == entry.Path[commonLen]) + { + commonLen++; + } + + int replaceLen = previousPath.Length - commonLen; + string suffix = entry.Path.Substring(commonLen); + + // Write replace length as varint + WriteVarint(bw, replaceLen); + // Write suffix + null terminator + bw.Write(Encoding.UTF8.GetBytes(suffix)); + bw.Write((byte)0); + + previousPath = entry.Path; + } + + return ms.ToArray(); + } + } + + private struct IndexEntryInfo + { + public string Path; + public bool SkipWorktree; + public MergeStage MergeState; + + public IndexEntryInfo(string path, bool skipWorktree, MergeStage mergeState = MergeStage.NoConflicts) + { + this.Path = path; + this.SkipWorktree = skipWorktree; + this.MergeState = mergeState; + } + } + + private static void WriteBigEndian32(BinaryWriter bw, uint value) + { + bw.Write((byte)((value >> 24) & 0xFF)); + bw.Write((byte)((value >> 16) & 0xFF)); + bw.Write((byte)((value >> 8) & 0xFF)); + bw.Write((byte)(value & 0xFF)); + } + + private static void WriteBigEndian16(BinaryWriter bw, ushort value) + { + bw.Write((byte)((value >> 8) & 0xFF)); + bw.Write((byte)(value & 0xFF)); + } + + private static void WriteVarint(BinaryWriter bw, int value) + { + // Git index v4 varint encoding (same as ReadReplaceLength in GitIndexParser) + if (value < 0x80) + { + bw.Write((byte)value); + return; + } + + byte[] bytes = new byte[5]; + int pos = 4; + bytes[pos] = (byte)(value & 0x7F); + value = (value >> 7) - 1; + while (value >= 0) + { + pos--; + bytes[pos] = (byte)(0x80 | (value & 0x7F)); + value = (value >> 7) - 1; + } + + bw.Write(bytes, pos, 5 - pos); + } + } + + /// + /// Tests for EnlistmentHydrationSummary that require the full mock filesystem/context. + /// + [TestFixture] + public class EnlistmentHydrationSummaryContextTests + { + private MockFileSystem fileSystem; + private MockTracer tracer; + private GVFSContext context; + private string gitParentPath; + private MockDirectory enlistmentDirectory; + [SetUp] public void Setup() { - MockTracer tracer = new MockTracer(); + this.tracer = new MockTracer(); string enlistmentRoot = Path.Combine("mock:", "GVFS", "UnitTests", "Repo"); string statusCachePath = Path.Combine("mock:", "GVFS", "UnitTests", "Repo", GVFSPlatform.Instance.Constants.DotGVFSRoot, "gitStatusCache"); - this.gitProcess = new MockGitProcess(); - this.gitProcess.SetExpectedCommandResult($"--no-optional-locks status \"--serialize={statusCachePath}", () => new GitProcess.Result(string.Empty, string.Empty, 0), true); - MockGVFSEnlistment enlistment = new MockGVFSEnlistment(enlistmentRoot, "fake://repoUrl", "fake://gitBinPath", this.gitProcess); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult($"--no-optional-locks status \"--serialize={statusCachePath}", () => new GitProcess.Result(string.Empty, string.Empty, 0), true); + MockGVFSEnlistment enlistment = new MockGVFSEnlistment(enlistmentRoot, "fake://repoUrl", "fake://gitBinPath", gitProcess); enlistment.InitializeCachePathsFromKey("fake:\\gvfsSharedCache", "fakeCacheKey"); this.gitParentPath = enlistment.WorkingDirectoryBackingRoot; - this.gvfsMetadataPath = enlistment.DotGVFSRoot; this.enlistmentDirectory = new MockDirectory( enlistmentRoot, @@ -74,52 +281,45 @@ public void Setup() this.fileSystem.DeleteNonExistentFileThrowsException = false; this.context = new GVFSContext( - tracer, + this.tracer, this.fileSystem, - new MockGitRepo(tracer, enlistment, this.fileSystem), + new MockGitRepo(this.tracer, enlistment, this.fileSystem), enlistment); } - [TearDown] - public void TearDown() + [TestCase] + public void GetIndexFileCount_IndexTooSmall_ReturnsNegativeOne() { - this.fileSystem = null; - this.gitProcess = null; - this.context = null; - this.gitParentPath = null; - this.gvfsMetadataPath = null; - this.enlistmentDirectory = null; + string indexPath = Path.Combine(this.gitParentPath, ".git", "index"); + this.enlistmentDirectory.CreateFile(indexPath, "short", createDirectories: true); + + int result = EnlistmentHydrationSummary.GetIndexFileCount( + this.context.Enlistment, this.context.FileSystem); + + Assert.AreEqual(-1, result); } - [TestCaseSource("HeadTreeCountCacheContents")] - public void HeadTreeCountCacheTests((string CachePrecontents, string ExpectedCachePostContents) args) + [TestCase] + public void CreateSummary_CancelledToken_ReturnsInvalidSummary() { - string totalPathCountPath = Path.Combine(this.gvfsMetadataPath, GVFSConstants.DotGVFS.GitStatusCache.TreeCount); - if (args.CachePrecontents != null) - { - this.enlistmentDirectory.CreateFile(totalPathCountPath, args.CachePrecontents, createDirectories: true); - } + // Set up a valid index file so CreateSummary gets past GetIndexFileCount + // before hitting the first cancellation check. + string indexPath = Path.Combine(this.gitParentPath, ".git", "index"); + byte[] indexBytes = new byte[12]; + indexBytes[11] = 100; // file count = 100 (big-endian) + MockFile indexFile = new MockFile(indexPath, indexBytes); + MockDirectory gitDir = this.enlistmentDirectory.FindDirectory(Path.Combine(this.gitParentPath, ".git")); + gitDir.Files.Add(indexFile.FullName, indexFile); + + CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + + Func dummyProvider = () => 0; + EnlistmentHydrationSummary result = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.context.FileSystem, this.context.Tracer, dummyProvider, cts.Token); - this.gitProcess.SetExpectedCommandResult("rev-parse \"HEAD^{tree}\"", - () => new GitProcess.Result(HeadTreeId, "", 0)); - this.gitProcess.SetExpectedCommandResult("ls-tree -r -d HEAD", - () => new GitProcess.Result( - string.Join("\n", Enumerable.Range(0, HeadPathCount) - .Select(x => x.ToString())), - "", 0)); - - Assert.AreEqual( - args.CachePrecontents != null, - this.fileSystem.FileExists(totalPathCountPath)); - - int result = EnlistmentHydrationSummary.GetHeadTreeCount(this.context.Enlistment, this.context.FileSystem, this.context.Tracer); - - this.fileSystem.FileExists(totalPathCountPath).ShouldBeTrue(); - var postContents = this.fileSystem.ReadAllText(totalPathCountPath); - Assert.AreEqual( - args.ExpectedCachePostContents, - postContents); - Assert.AreEqual(postContents.Split('\n')[1], result.ToString()); + Assert.IsFalse(result.IsValid); + Assert.IsNull(result.Error); } } } diff --git a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs index 8a50f030a..802065953 100644 --- a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs +++ b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs @@ -115,6 +115,8 @@ public FileSystemCallbacks( // If the status cache is not enabled, create a dummy GitStatusCache that will never be initialized // This lets us from having to add null checks to callsites into GitStatusCache. this.gitStatusCache = gitStatusCache ?? new GitStatusCache(context, TimeSpan.Zero); + this.gitStatusCache.SetProjectedFolderCountProvider( + () => this.GitIndexProjection.GetProjectedFolderCount()); this.logsHeadPath = Path.Combine(this.context.Enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Logs.Head); @@ -397,6 +399,11 @@ public void InvalidateGitStatusCache() } } + public int GetProjectedFolderCount() + { + return this.GitIndexProjection.GetProjectedFolderCount(); + } + public virtual void OnLogsHeadChange() { // Don't open the .git\logs\HEAD file here to check its attributes as we're in a callback for the .git folder diff --git a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.FolderData.cs b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.FolderData.cs index 4777cabbc..bf56444a8 100644 --- a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.FolderData.cs +++ b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.FolderData.cs @@ -21,6 +21,29 @@ internal class FolderData : FolderEntryData public bool ChildrenHaveSizes { get; private set; } public bool IsIncluded { get; set; } = true; + public int GetRecursiveFolderCount() + { + int count = 0; + Stack stack = new Stack(); + stack.Push(this); + + while (stack.Count > 0) + { + FolderData current = stack.Pop(); + for (int i = 0; i < current.ChildEntries.Count; i++) + { + FolderData childFolder = current.ChildEntries[i] as FolderData; + if (childFolder != null) + { + count++; + stack.Push(childFolder); + } + } + } + + return count; + } + public void ResetData(LazyUTF8String name, bool isIncluded) { this.Name = name; diff --git a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.GitIndexParser.cs b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.GitIndexParser.cs index c05348e2b..382a05945 100644 --- a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.GitIndexParser.cs +++ b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.GitIndexParser.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; namespace GVFS.Virtualization.Projection { @@ -60,6 +61,53 @@ public static void ValidateIndex(ITracer tracer, Stream indexStream) } } + /// + /// Count unique directories in the index by scanning entry paths for separators. + /// Uses the existing index parser to read entries, avoiding a custom index parser. + /// + public static int CountIndexFolders(ITracer tracer, Stream indexStream) + { + HashSet dirs = new HashSet(StringComparer.OrdinalIgnoreCase); + GitIndexParser indexParser = new GitIndexParser(null); + + FileSystemTaskResult result = indexParser.ParseIndex( + tracer, + indexStream, + indexParser.resuableProjectionBuildingIndexEntry, + entry => + { + // Match the same filter as AddIndexEntryToProjection so the + // fallback folder count agrees with the mounted projection. + if (!((entry.MergeState != MergeStage.CommonAncestor && entry.SkipWorktree) || entry.MergeState == MergeStage.Yours)) + { + return FileSystemTaskResult.Success; + } + + // Extract unique parent directories from the raw path buffer + string path = Encoding.UTF8.GetString(entry.PathBuffer, 0, entry.PathLength); + int lastSlash = path.LastIndexOf('/'); + while (lastSlash > 0) + { + string dir = path.Substring(0, lastSlash); + if (!dirs.Add(dir)) + { + break; + } + + lastSlash = dir.LastIndexOf('/'); + } + + return FileSystemTaskResult.Success; + }); + + if (result != FileSystemTaskResult.Success) + { + throw new InvalidOperationException($"{nameof(CountIndexFolders)} failed: {result}"); + } + + return dirs.Count; + } + public void RebuildProjection(ITracer tracer, Stream indexStream) { if (this.projection == null) diff --git a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs index 10fd7b573..d0f96c4fa 100644 --- a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs +++ b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs @@ -309,6 +309,45 @@ public virtual bool IsProjectionParseComplete() return this.projectionParseComplete.IsSet; } + /// + /// Get the total number of directories in the projection. + /// This is computed from the in-memory tree built during index parsing, + /// so it is essentially free (no I/O, no process spawn). + /// + public virtual int GetProjectedFolderCount() + { + this.projectionReadWriteLock.EnterReadLock(); + try + { + return this.rootFolderData.GetRecursiveFolderCount(); + } + finally + { + this.projectionReadWriteLock.ExitReadLock(); + } + } + + /// + /// Count unique directories by parsing the index file directly. + /// This is a fallback for when the in-memory projection is not available + /// (e.g., when running gvfs health --status without a mount process). + /// + public static int CountIndexFolders(ITracer tracer, string indexPath) + { + using (FileStream indexStream = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + return CountIndexFolders(tracer, indexStream); + } + } + + /// + /// Count unique directories by parsing an index stream. + /// + public static int CountIndexFolders(ITracer tracer, Stream indexStream) + { + return GitIndexParser.CountIndexFolders(tracer, indexStream); + } + public virtual void InvalidateProjection() { this.context.Tracer.RelatedEvent(EventLevel.Informational, "InvalidateProjection", null); diff --git a/GVFS/GVFS/CommandLine/HealthVerb.cs b/GVFS/GVFS/CommandLine/HealthVerb.cs index 897bc23d7..74e69ad2c 100644 --- a/GVFS/GVFS/CommandLine/HealthVerb.cs +++ b/GVFS/GVFS/CommandLine/HealthVerb.cs @@ -88,7 +88,10 @@ protected override void Execute(GVFSEnlistment enlistment) private void OutputHydrationPercent(GVFSEnlistment enlistment, ITracer tracer) { - EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary(enlistment, this.FileSystem, tracer); + Func folderCountProvider = () => + GVFS.Virtualization.Projection.GitIndexProjection.CountIndexFolders(tracer, enlistment.GitIndexPath); + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + enlistment, this.FileSystem, tracer, folderCountProvider); this.Output.WriteLine(summary.ToMessage()); }