From 1a2dc137fb1febd1326df1440ae0f2fbe0b68817 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 9 Jul 2026 14:36:39 -0700 Subject: [PATCH] Stream large -z git output instead of buffering it against the cap Building on the git-output bounding change, this removes the truncation exposure for the two commands whose full result is correctness-critical by streaming their output instead of buffering it. DiffCachedNameStatus (diff --cached --name-status -z) feeds every staged path into ModifiedPaths; StatusPorcelain (status --porcelain -z) drives the sparse dirty check. Both use -z (NUL-delimited), so line-based streaming cannot chunk them - git delivers the whole blob as a single line. The prior change bounded these at a large stdout cap and made the callers fail safe if the cap was ever exceeded; this change makes truncation impossible instead. Add a NUL-delimited streaming mode to InvokeGitImpl: a new parseStdOutToken callback reads stdout synchronously and splits on NUL, invoking the callback once per record as it arrives. stderr stays async (BeginErrorReadLine), so the synchronous stdout read cannot deadlock. Only one record is held in memory, so an arbitrarily large result streams without buffering, truncation, or OOM. The mode keeps git's robust -z format (no path-unquoting logic) and is guarded to an infinite timeout (a synchronous read cannot honor a finite one) and is mutually exclusive with line streaming. DiffCachedNameStatus and StatusPorcelain now stream tokens; their callers drive small status/path state machines. This removes the interim fail-safe valves the callers had (there is no longer a partial result to guard against) and the dead -z string parsers (GetPathsNotCoveredBySparseFolders, GetNextGitPath). Result.OutputTruncated/ErrorsTruncated remain for the still-buffered commands (stderr is still bounded), but the two converted commands can no longer trip OutputTruncated. Tests: - ReadStdOutTokens: NUL splitting, empty input, embedded empty records, a trailing record without a NUL, and a record spanning the 8KB read buffer. - DiffCachedNameStatus/StatusPorcelain stream records through the mock. - Replaces the removed GetNextGitPath test; PathCoveredBySparseFolders tests are unchanged. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Git/GitProcess.cs | 134 ++++++++++++++---- GVFS/GVFS.UnitTests/Git/GitProcessTests.cs | 88 ++++++++++++ .../GVFS.UnitTests/Mock/Git/MockGitProcess.cs | 16 ++- .../Windows/CommandLine/SparseVerbTests.cs | 29 ---- .../FileSystemCallbacks.cs | 59 ++++---- GVFS/GVFS/CommandLine/SparseVerb.cs | 88 +++++------- 6 files changed, 275 insertions(+), 139 deletions(-) diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index 03cc27b417..6308f0be43 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -568,17 +568,24 @@ public Result Status(bool allowObjectDownloads, bool useStatusCache, bool showUn return this.InvokeGitInWorkingDirectoryRoot(command, useReadObjectHook: allowObjectDownloads); } - public Result StatusPorcelain() + /// + /// Streams "git status" output in porcelain -z form, delivering each NUL-terminated record to + /// as it is read. This avoids buffering the entire status + /// output, which can be large in a big working tree. + /// + public Result StatusPorcelain(Action parseStdOutToken) { string command = "status -uall --porcelain -z"; - return this.InvokeGitInWorkingDirectoryRoot(command, useReadObjectHook: false); + return this.InvokeGitInWorkingDirectoryRoot(command, useReadObjectHook: false, parseStdOutToken: parseStdOutToken); } /// - /// Returns staged file changes (index vs HEAD) as null-separated pairs of - /// status and path: "A\0path1\0M\0path2\0D\0path3\0". - /// Status codes: A=added, M=modified, D=deleted, R=renamed, C=copied. + /// Streams staged file changes (index vs HEAD) as NUL-separated records: each change is emitted + /// as two records, a status token ("A", "M", "D", ...) followed by a path token. The records are + /// delivered to as they are read, so an arbitrarily large + /// staged set is processed without buffering the whole list. /// + /// Receives each NUL-terminated record (status, path, status, path, ...). /// Inline pathspecs to scope the diff, or null for all. /// /// Path to a file containing additional pathspecs (one per line), forwarded @@ -588,7 +595,7 @@ public Result StatusPorcelain() /// When true and pathspecFromFile is set, pathspec entries in the file are /// separated by NUL instead of newline (--pathspec-file-nul). /// - public Result DiffCachedNameStatus(string[] pathspecs = null, string pathspecFromFile = null, bool pathspecFileNul = false) + public Result DiffCachedNameStatus(Action parseStdOutToken, string[] pathspecs = null, string pathspecFromFile = null, bool pathspecFileNul = false) { string command = "diff --cached --name-status -z --no-renames"; @@ -606,7 +613,7 @@ public Result DiffCachedNameStatus(string[] pathspecs = null, string pathspecFro command += " -- " + string.Join(" ", pathspecs.Select(p => QuoteGitPath(p))); } - return this.InvokeGitInWorkingDirectoryRoot(command, useReadObjectHook: false); + return this.InvokeGitInWorkingDirectoryRoot(command, useReadObjectHook: false, parseStdOutToken: parseStdOutToken); } /// @@ -975,13 +982,30 @@ protected virtual Result InvokeGitImpl( Action parseStdOutLine, int timeoutMs, string gitObjectsDirectory = null, - bool usePreCommandHook = true) + bool usePreCommandHook = true, + Action parseStdOutToken = null) { if (failedToSetEncoding && writeStdIn != null) { return new Result(string.Empty, "Attempting to use to stdin, but the process does not have the right input encodings set.", Result.GenericFailureCode); } + // NUL-delimited streaming reads stdout synchronously on this thread, so it cannot honor a + // finite timeout (the timeout is only enforced once stdout reaches EOF). It also cannot be + // combined with line streaming. Callers that use it always pass an infinite timeout. + if (parseStdOutToken != null) + { + if (parseStdOutLine != null) + { + throw new InvalidOperationException($"{nameof(parseStdOutToken)} cannot be combined with {nameof(parseStdOutLine)}."); + } + + if (timeoutMs != -1) + { + throw new InvalidOperationException($"{nameof(parseStdOutToken)} requires an infinite timeout."); + } + } + try { // From https://msdn.microsoft.com/en-us/library/system.diagnostics.process.standardoutput.aspx @@ -1004,20 +1028,26 @@ protected virtual Result InvokeGitImpl( errors.AppendLine(args.Data); } }; - this.executingProcess.OutputDataReceived += (sender, args) => + + // In NUL-delimited streaming mode we read stdout ourselves (below) rather than using + // the line-based async reader, so we do not subscribe OutputDataReceived. + if (parseStdOutToken == null) { - if (args.Data != null) + this.executingProcess.OutputDataReceived += (sender, args) => { - if (parseStdOutLine != null) - { - parseStdOutLine(args.Data); - } - else + if (args.Data != null) { - output.AppendLine(args.Data); + if (parseStdOutLine != null) + { + parseStdOutLine(args.Data); + } + else + { + output.AppendLine(args.Data); + } } - } - }; + }; + } lock (this.executionLock) { @@ -1046,14 +1076,32 @@ protected virtual Result InvokeGitImpl( writeStdIn?.Invoke(this.executingProcess.StandardInput); this.executingProcess.StandardInput.Close(); - this.executingProcess.BeginOutputReadLine(); + // Always drain stderr asynchronously so the child can never block writing to it. this.executingProcess.BeginErrorReadLine(); - if (!this.executingProcess.WaitForExit(timeoutMs)) + if (parseStdOutToken != null) { - this.executingProcess.Kill(); + // Read stdout synchronously, splitting on NUL and handing each record to the + // callback as it arrives. Because stderr is drained asynchronously above, a + // synchronous stdout read cannot deadlock. Only a single record is held in + // memory at a time, so an arbitrarily large result (e.g. every staged file in + // a monorepo) is processed without buffering the whole thing. + ReadStdOutTokens(this.executingProcess.StandardOutput, parseStdOutToken); + + // stdout is at EOF; block until the process fully exits so the async stderr + // reads complete before we read ExitCode/Errors. + this.executingProcess.WaitForExit(); + } + else + { + this.executingProcess.BeginOutputReadLine(); + + if (!this.executingProcess.WaitForExit(timeoutMs)) + { + this.executingProcess.Kill(); - return new Result(output.ToString(), "Operation timed out: " + errors.ToString(), Result.GenericFailureCode, output.Truncated, errors.Truncated); + return new Result(output.ToString(), "Operation timed out: " + errors.ToString(), Result.GenericFailureCode, output.Truncated, errors.Truncated); + } } } @@ -1075,6 +1123,42 @@ private static string GenerateCredentialVerbCommand(string verb) return $"-c {GitConfigSetting.CredentialUseHttpPath}=true credential {verb}"; } + /// + /// Reads a redirected stdout stream that is NUL-delimited (git's "-z" machine-readable format), + /// invoking once per NUL-terminated record as it is read. + /// Only a single record is accumulated at a time, so an arbitrarily large result is processed + /// without buffering the entire stream. + /// + internal static void ReadStdOutTokens(StreamReader reader, Action parseStdOutToken) + { + StringBuilder token = new StringBuilder(); + char[] buffer = new char[8192]; + int read; + + while ((read = reader.Read(buffer, 0, buffer.Length)) > 0) + { + for (int i = 0; i < read; i++) + { + if (buffer[i] == '\0') + { + parseStdOutToken(token.ToString()); + token.Clear(); + } + else + { + token.Append(buffer[i]); + } + } + } + + // git's -z output always terminates the final record with a NUL, so there should be nothing + // left here. Flush any trailing partial record defensively rather than dropping it. + if (token.Length > 0) + { + parseStdOutToken(token.ToString()); + } + } + private static string ParseValue(string contents, string prefix) { int startIndex = contents.IndexOf(prefix) + prefix.Length; @@ -1128,7 +1212,8 @@ private Result InvokeGitInWorkingDirectoryRoot( string command, bool useReadObjectHook, Action writeStdIn = null, - Action parseStdOutLine = null) + Action parseStdOutLine = null, + Action parseStdOutToken = null) { return this.InvokeGitImpl( command, @@ -1137,7 +1222,8 @@ private Result InvokeGitInWorkingDirectoryRoot( useReadObjectHook: useReadObjectHook, writeStdIn: writeStdIn, parseStdOutLine: parseStdOutLine, - timeoutMs: -1); + timeoutMs: -1, + parseStdOutToken: parseStdOutToken); } /// diff --git a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs index 8182f8dfb5..41032c6480 100644 --- a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs @@ -1,14 +1,102 @@ using GVFS.Common.Git; using GVFS.Tests.Should; using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Mock.Git; using NUnit.Framework; +using System.Collections.Generic; using System.Diagnostics; +using System.IO; +using System.Text; namespace GVFS.UnitTests.Git { [TestFixture] public class GitProcessTests { + [TestCase] + public void ReadStdOutTokens_SplitsOnNul() + { + List tokens = ReadTokens("a.txt\0d/b.txt\0d/c.txt\0"); + tokens.ShouldMatchInOrder("a.txt", "d/b.txt", "d/c.txt"); + } + + [TestCase] + public void ReadStdOutTokens_EmptyInputYieldsNoTokens() + { + ReadTokens(string.Empty).Count.ShouldEqual(0); + } + + [TestCase] + public void ReadStdOutTokens_PreservesEmptyRecords() + { + // diff --name-status -z emits status and path as separate records; an empty record must + // still be delivered so a caller's status/path state machine stays aligned. + List tokens = ReadTokens("A\0path\0\0after-empty\0"); + tokens.ShouldMatchInOrder("A", "path", string.Empty, "after-empty"); + } + + [TestCase] + public void ReadStdOutTokens_FlushesTrailingRecordWithoutNul() + { + List tokens = ReadTokens("a.txt\0trailing"); + tokens.ShouldMatchInOrder("a.txt", "trailing"); + } + + [TestCase] + public void ReadStdOutTokens_ReassemblesRecordSpanningReadBoundary() + { + // A single record longer than the internal 8192-char read buffer must be reassembled across + // multiple reads rather than split. + string longPath = new string('x', 20000); + List tokens = ReadTokens("short\0" + longPath + "\0"); + + tokens.Count.ShouldEqual(2); + tokens[0].ShouldEqual("short"); + tokens[1].ShouldEqual(longPath); + } + + [TestCase] + public void DiffCachedNameStatus_StreamsRecordsAsTokens() + { + MockGitProcess git = new MockGitProcess(); + git.SetExpectedCommandResult( + "diff --cached --name-status -z --no-renames", + () => new GitProcess.Result("A\0added.txt\0M\0modified.txt\0", string.Empty, GitProcess.Result.SuccessCode)); + + List tokens = new List(); + GitProcess.Result result = git.DiffCachedNameStatus(t => tokens.Add(t)); + + result.ExitCodeIsSuccess.ShouldBeTrue(); + tokens.ShouldMatchInOrder("A", "added.txt", "M", "modified.txt"); + } + + [TestCase] + public void StatusPorcelain_StreamsRecordsAsTokens() + { + MockGitProcess git = new MockGitProcess(); + git.SetExpectedCommandResult( + "status -uall --porcelain -z", + () => new GitProcess.Result("A added.txt\0 M modified.txt\0", string.Empty, GitProcess.Result.SuccessCode)); + + List tokens = new List(); + GitProcess.Result result = git.StatusPorcelain(t => tokens.Add(t)); + + result.ExitCodeIsSuccess.ShouldBeTrue(); + tokens.ShouldMatchInOrder("A added.txt", " M modified.txt"); + } + + private static List ReadTokens(string content) + { + List tokens = new List(); + using (MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(content))) + using (StreamReader reader = new StreamReader(stream, Encoding.UTF8)) + { + GitProcess.ReadStdOutTokens(reader, token => tokens.Add(token)); + } + + return tokens; + } + [TestCase] public void BoundedGitOutputBuffer_KeepsShortOutput() { diff --git a/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs b/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs index c9095cc2c8..91623acd1c 100644 --- a/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs +++ b/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs @@ -84,7 +84,8 @@ protected override Result InvokeGitImpl( Action parseStdOutLine, int timeoutMs, string gitObjectsDirectory = null, - bool usePrecommandHook = true) + bool usePrecommandHook = true, + Action parseStdOutToken = null) { this.CommandsRun.Add(command); @@ -122,6 +123,19 @@ protected override Result InvokeGitImpl( } /* Future: result.Output should be set to null in this case */ } + + if (parseStdOutToken != null && !string.IsNullOrEmpty(result.Output)) + { + // Mirror production ReadStdOutTokens: deliver every NUL-terminated record, including + // empty ones, and drop only the trailing empty element produced by the final NUL. + string[] tokens = result.Output.Split('\0'); + int count = (tokens.Length > 0 && tokens[tokens.Length - 1].Length == 0) ? tokens.Length - 1 : tokens.Length; + for (int i = 0; i < count; i++) + { + parseStdOutToken(tokens[i]); + } + } + return result; } diff --git a/GVFS/GVFS.UnitTests/Windows/CommandLine/SparseVerbTests.cs b/GVFS/GVFS.UnitTests/Windows/CommandLine/SparseVerbTests.cs index e4ef7b2db7..e1d6b10fc1 100644 --- a/GVFS/GVFS.UnitTests/Windows/CommandLine/SparseVerbTests.cs +++ b/GVFS/GVFS.UnitTests/Windows/CommandLine/SparseVerbTests.cs @@ -12,23 +12,8 @@ namespace GVFS.UnitTests.Windows.Windows.CommandLine [TestFixture] public class SparseVerbTests { - private const char StatusPathSeparatorToken = '\0'; - private static readonly HashSet EmptySparseSet = new HashSet(); - [TestCase] - public void GetNextGitPathGetsPaths() - { - string testStatusOutput = $"a.txt{StatusPathSeparatorToken}"; - ConfirmGitPathsParsed(testStatusOutput, new List() { "a.txt" }); - - testStatusOutput = $"a.txt{StatusPathSeparatorToken}b.txt{StatusPathSeparatorToken}c.txt{StatusPathSeparatorToken}"; - ConfirmGitPathsParsed(testStatusOutput, new List() { "a.txt", "b.txt", "c.txt" }); - - testStatusOutput = $"a.txt{StatusPathSeparatorToken}d/b.txt{StatusPathSeparatorToken}d/c.txt{StatusPathSeparatorToken}"; - ConfirmGitPathsParsed(testStatusOutput, new List() { "a.txt", "d/b.txt", "d/c.txt" }); - } - [TestCase] public void PathCoveredBySparseFolders_RootPaths() { @@ -126,20 +111,6 @@ private static void ConfirmAllPathsNotCovered(List paths, HashSet expectedPaths) - { - int index = 0; - int listIndex = 0; - while (index < paths.Length - 1) - { - int nextSeparatorIndex = paths.IndexOf(StatusPathSeparatorToken, index); - string expectedGitPath = expectedPaths[listIndex]; - SparseVerb.GetNextGitPath(ref index, paths).ShouldEqual(expectedGitPath); - index.ShouldEqual(nextSeparatorIndex + 1); - ++listIndex; - } - } - private static void CheckIfPathsCovered(List paths, HashSet sparseSet, bool shouldBeCovered) { foreach (string path in paths) diff --git a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs index f2a45623cb..689a55d7c8 100644 --- a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs +++ b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs @@ -433,43 +433,35 @@ public bool AddStagedFilesToModifiedPaths(string messageBody, out int addedCount } } - // Query all staged files in one call using --name-status -z. - // Output format: "A\0path1\0M\0path2\0D\0path3\0" - GitProcess.Result result = gitProcess.DiffCachedNameStatus(pathspecs, pathspecFromFile, pathspecFileNul); - - if (result.OutputTruncated) - { - // The staged-file list exceeded the capture buffer. Acting on a partial list would leave - // some staged files out of ModifiedPaths (skip-worktree not cleared, stale placeholders), - // which is worse than failing. Fail safe and let the caller retry. - EventMetadata metadata = new EventMetadata(); - metadata.Add("ExitCode", result.ExitCode); - this.context.Tracer.RelatedError( - metadata, - nameof(this.AddStagedFilesToModifiedPaths) + ": git diff --cached output was truncated; refusing to update ModifiedPaths from a partial staged-file list"); - return false; - } - - if (result.ExitCodeIsSuccess && !string.IsNullOrEmpty(result.Output)) - { - string[] parts = result.Output.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries); - List addedFilePaths = new List(); - - // Parts alternate: status, path, status, path, ... - for (int i = 0; i + 1 < parts.Length; i += 2) + // Query all staged files in one call using --name-status -z, streaming the records so we + // never buffer the entire (potentially huge) staged file list in memory. Records arrive in + // pairs: a status token ("A", "M", "D", ...) followed by a path token. + List addedFilePaths = new List(); + int added = 0; + string pendingStatus = null; + + GitProcess.Result result = gitProcess.DiffCachedNameStatus( + token => { - string status = parts[i]; - string gitPath = parts[i + 1]; + if (pendingStatus == null) + { + pendingStatus = token; + return; + } + + string status = pendingStatus; + pendingStatus = null; + string gitPath = token; if (string.IsNullOrEmpty(gitPath)) { - continue; + return; } string platformPath = gitPath.Replace(GVFSConstants.GitPathSeparator, Path.DirectorySeparatorChar); if (this.modifiedPaths.TryAdd(platformPath, isFolder: false, isRetryable: out _)) { - addedCount++; + added++; } // Added files (in index but not in HEAD) are ProjFS placeholders that @@ -479,8 +471,15 @@ public bool AddStagedFilesToModifiedPaths(string messageBody, out int addedCount { addedFilePaths.Add(gitPath); } - } + }, + pathspecs, + pathspecFromFile, + pathspecFileNul); + + addedCount = added; + if (result.ExitCodeIsSuccess) + { // Write added files from the git object store to disk as full files // so they persist across projection changes. Batched into as few git // process invocations as possible. @@ -492,7 +491,7 @@ public bool AddStagedFilesToModifiedPaths(string messageBody, out int addedCount } } } - else if (!result.ExitCodeIsSuccess) + else { EventMetadata metadata = new EventMetadata(); metadata.Add("ExitCode", result.ExitCode); diff --git a/GVFS/GVFS/CommandLine/SparseVerb.cs b/GVFS/GVFS/CommandLine/SparseVerb.cs index 334e4d1ca4..cec3783b08 100644 --- a/GVFS/GVFS/CommandLine/SparseVerb.cs +++ b/GVFS/GVFS/CommandLine/SparseVerb.cs @@ -18,7 +18,6 @@ public class SparseVerb : GVFSVerb.ForExistingEnlistment { private const string SparseVerbName = "sparse"; private const string FolderListSeparator = ";"; - private const char StatusPathSeparatorToken = '\0'; private const char StatusRenameToken = 'R'; private const string PruneOptionName = "prune"; @@ -107,14 +106,6 @@ public static System.CommandLine.Command CreateCommand() protected override string VerbName => SparseVerbName; - internal static string GetNextGitPath(ref int index, string statusOutput) - { - int endOfPathIndex = statusOutput.IndexOf(StatusPathSeparatorToken, index); - string gitPath = statusOutput.Substring(index, endOfPathIndex - index); - index = endOfPathIndex + 1; - return gitPath; - } - internal static bool PathCoveredBySparseFolders(string gitPath, HashSet sparseFolders) { string filePath = gitPath.Replace(GVFSConstants.GitPathSeparator, Path.DirectorySeparatorChar); @@ -628,29 +619,48 @@ private void ForceProjectionChange(ITracer tracer, GVFSEnlistment enlistment) private void CheckGitStatus(ITracer tracer, GVFSEnlistment enlistment, HashSet sparseFolders) { GitProcess.Result statusResult = null; - HashSet dirtyPathsNotInSparseSet = null; + HashSet dirtyPathsNotInSparseSet = new HashSet(); if (!this.ShowStatusWhileRunning( () => { + dirtyPathsNotInSparseSet.Clear(); GitProcess git = new GitProcess(enlistment); - statusResult = git.StatusPorcelain(); - if (statusResult.ExitCodeIsFailure) - { - return false; - } - if (statusResult.OutputTruncated) + // Stream porcelain -z records so we never buffer the whole status output. Each entry + // is a primary "XY " token; a rename adds a second token for the original path. + bool expectingRenameOrigin = false; + statusResult = git.StatusPorcelain( + token => + { + string gitPath; + if (expectingRenameOrigin) + { + expectingRenameOrigin = false; + gitPath = token; + } + else + { + if (token.Length < 3) + { + return; + } + + // Two status chars (XY) then a space, then the path. + expectingRenameOrigin = token[0] == StatusRenameToken || token[1] == StatusRenameToken; + gitPath = token.Substring(3); + } + + if (!PathCoveredBySparseFolders(gitPath, sparseFolders)) + { + dirtyPathsNotInSparseSet.Add(gitPath); + } + }); + + if (statusResult.ExitCodeIsFailure) { - // git status output exceeded the capture buffer. A partial status could omit - // dirty paths and let sparse proceed over uncommitted changes (data loss), so - // treat truncation as "cannot verify clean" and abort. - tracer.RelatedError( - new EventMetadata(), - "git status output was truncated; aborting sparse to avoid acting on an incomplete status"); return false; } - dirtyPathsNotInSparseSet = this.GetPathsNotCoveredBySparseFolders(statusResult.Output, sparseFolders); return dirtyPathsNotInSparseSet.Count == 0; }, "Running git status", @@ -662,10 +672,6 @@ private void CheckGitStatus(ITracer tracer, GVFSEnlistment enlistment, HashSet GetPathsNotCoveredBySparseFolders(string statusOutput, HashSet sparseFolders) - { - HashSet uncoveredPaths = new HashSet(); - int index = 0; - while (index < statusOutput.Length - 1) - { - bool isRename = statusOutput[index] == StatusRenameToken || statusOutput[index + 1] == StatusRenameToken; - index = index + 3; - - string gitPath = GetNextGitPath(ref index, statusOutput); - if (!PathCoveredBySparseFolders(gitPath, sparseFolders)) - { - uncoveredPaths.Add(gitPath); - } - - if (isRename) - { - gitPath = GetNextGitPath(ref index, statusOutput); - if (!PathCoveredBySparseFolders(gitPath, sparseFolders)) - { - uncoveredPaths.Add(gitPath); - } - } - } - - return uncoveredPaths; - } - private void WriteMessage(ITracer tracer, string message) { this.Output.WriteLine(message);