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());
}