diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index a86b6131a..11822b5cd 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -509,6 +509,138 @@ public Result StatusPorcelain() return this.InvokeGitInWorkingDirectoryRoot(command, useReadObjectHook: false); } + /// + /// 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. + /// + /// Inline pathspecs to scope the diff, or null for all. + /// + /// Path to a file containing additional pathspecs (one per line), forwarded + /// as --pathspec-from-file to git. Null if not used. + /// + /// + /// 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) + { + string command = "diff --cached --name-status -z --no-renames"; + + if (pathspecFromFile != null) + { + command += " --pathspec-from-file=" + QuoteGitPath(pathspecFromFile); + if (pathspecFileNul) + { + command += " --pathspec-file-nul"; + } + } + + if (pathspecs != null && pathspecs.Length > 0) + { + command += " -- " + string.Join(" ", pathspecs.Select(p => QuoteGitPath(p))); + } + + return this.InvokeGitInWorkingDirectoryRoot(command, useReadObjectHook: false); + } + + /// + /// Writes the staged (index) version of the specified files to the working + /// tree with correct line endings and attributes. Batches multiple paths into + /// a single git process invocation where possible, respecting the Windows + /// command line length limit. + /// + public List CheckoutIndexForFiles(IEnumerable paths) + { + // Windows command line limit is 32,767 characters. Leave headroom for + // the base command and other arguments. + const int MaxCommandLength = 30000; + const string BaseCommand = "-c core.hookspath= checkout-index --force --"; + + List results = new List(); + StringBuilder command = new StringBuilder(BaseCommand); + foreach (string path in paths) + { + string quotedPath = " " + QuoteGitPath(path); + + if (command.Length + quotedPath.Length > MaxCommandLength && command.Length > BaseCommand.Length) + { + // Flush current batch + results.Add(this.InvokeGitInWorkingDirectoryRoot(command.ToString(), useReadObjectHook: false)); + command.Clear(); + command.Append(BaseCommand); + } + + command.Append(quotedPath); + } + + // Flush remaining paths + if (command.Length > BaseCommand.Length) + { + results.Add(this.InvokeGitInWorkingDirectoryRoot(command.ToString(), useReadObjectHook: false)); + } + + return results; + } + + /// + /// Wraps a path in double quotes for use as a git command argument, + /// escaping any embedded double quotes and any backslashes that + /// immediately precede a double quote (to prevent them from being + /// interpreted as escape characters by the Windows C runtime argument + /// parser). Lone backslashes used as path separators are left as-is. + /// + public static string QuoteGitPath(string path) + { + StringBuilder sb = new StringBuilder(path.Length + 4); + sb.Append('"'); + + for (int i = 0; i < path.Length; i++) + { + if (path[i] == '"') + { + sb.Append('\\'); + sb.Append('"'); + } + else if (path[i] == '\\') + { + // Count consecutive backslashes + int backslashCount = 0; + while (i < path.Length && path[i] == '\\') + { + backslashCount++; + i++; + } + + if (i < path.Length && path[i] == '"') + { + // Backslashes before a quote: double them all, then escape the quote + sb.Append('\\', backslashCount * 2); + sb.Append('\\'); + sb.Append('"'); + } + else if (i == path.Length) + { + // Backslashes at end of string (before closing quote): double them + sb.Append('\\', backslashCount * 2); + } + else + { + // Backslashes not before a quote: keep as-is (path separators) + sb.Append('\\', backslashCount); + i--; // Re-process current non-backslash char + } + } + else + { + sb.Append(path[i]); + } + } + + sb.Append('"'); + return sb.ToString(); + } + public Result SerializeStatus(bool allowObjectDownloads, string serializePath) { // specify ignored=matching and --untracked-files=complete diff --git a/GVFS/GVFS.Common/NamedPipes/UnstageNamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/UnstageNamedPipeMessages.cs new file mode 100644 index 000000000..ee3e32c27 --- /dev/null +++ b/GVFS/GVFS.Common/NamedPipes/UnstageNamedPipeMessages.cs @@ -0,0 +1,27 @@ +namespace GVFS.Common.NamedPipes +{ + public static partial class NamedPipeMessages + { + public static class PrepareForUnstage + { + public const string Request = "PreUnstage"; + public const string SuccessResult = "S"; + public const string FailureResult = "F"; + + public class Response + { + public Response(string result) + { + this.Result = result; + } + + public string Result { get; } + + public Message CreateMessage() + { + return new Message(this.Result, null); + } + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/CorruptionReproTests.cs b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/CorruptionReproTests.cs new file mode 100644 index 000000000..5204d08f1 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/CorruptionReproTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using GVFS.Common; +using GVFS.FunctionalTests.Properties; +using GVFS.FunctionalTests.Tests.EnlistmentPerTestCase; +using NUnit.Framework; + +namespace GVFS.FunctionalTests.Tests.GitCommands +{ + /// + /// This class is used to reproduce corruption scenarios in the GVFS virtual projection. + /// + [Category(Categories.GitCommands)] + [TestFixtureSource(typeof(GitRepoTests), nameof(GitRepoTests.ValidateWorkingTree))] + public class CorruptionReproTests : GitRepoTests + { + public CorruptionReproTests(Settings.ValidateWorkingTreeMode validateWorkingTree) + : base(enlistmentPerTest: true, validateWorkingTree: validateWorkingTree) + { + } + + [TestCase] + public void ReproCherryPickRestoreCorruption() + { + // Reproduces a corruption scenario where git commands (like cherry-pick -n) + // stage changes directly, bypassing the filesystem. In VFS mode, these staged + // files have skip-worktree set and are not in the ModifiedPaths database. + // Without the fix, a subsequent "restore --staged" would fail to properly + // unstage them, leaving the index and projection in an inconsistent state. + // + // See https://github.com/microsoft/VFSForGit/issues/1855 + + // Based on FunctionalTests/20170206_Conflict_Source + const string CherryPickCommit = "51d15f7584e81d59d44c1511ce17d7c493903390"; + const string StartingCommit = "db95d631e379d366d26d899523f8136a77441914"; + + this.ControlGitRepo.Fetch(StartingCommit); + this.ControlGitRepo.Fetch(CherryPickCommit); + + this.ValidateGitCommand($"checkout -b FunctionalTests/CherryPickRestoreCorruptionRepro {StartingCommit}"); + + // Cherry-pick stages adds, deletes, and modifications without committing. + // In VFS mode, these changes are made directly by git in the index — they + // are not in ModifiedPaths, so all affected files still have skip-worktree set. + this.ValidateGitCommand($"cherry-pick -n {CherryPickCommit}"); + + // Restore --staged for a single file first. This verifies that only the + // targeted file is added to ModifiedPaths, not all staged files (important + // for performance when there are many staged files, e.g. during merge + // conflict resolution). + // + // Before the fix: added files with skip-worktree would be skipped by + // restore --staged, remaining stuck as staged in the index. + this.ValidateGitCommand("restore --staged Test_ConflictTests/AddedFiles/AddedBySource.txt"); + + // Restore --staged for everything remaining. Before the fix: + // - Modified files: restored in the index but invisible to git status + // because skip-worktree was set and the file wasn't in ModifiedPaths, + // so git never checked the working tree against the index. + // - Deleted files: same issue — deletions became invisible. + // - Added files: remained stuck as staged because restore --staged + // skipped them (skip-worktree set), and their ProjFS placeholders + // would later vanish when the projection reverted to HEAD. + this.ValidateGitCommand("restore --staged ."); + + // Restore the working directory. Before the fix, this step would + // silently succeed but leave corrupted state: modified/deleted files + // had stale projected content that didn't match HEAD, and added files + // (as ProjFS placeholders) would vanish entirely since they're not in + // HEAD's tree. + this.ValidateGitCommand("restore -- ."); + this.FilesShouldMatchCheckoutOfSourceBranch(); + } + } +} diff --git a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj index e79065bc5..929d6089f 100644 --- a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj +++ b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj @@ -49,6 +49,9 @@ Common\NamedPipes\LockNamedPipeMessages.cs + + Common\NamedPipes\UnstageNamedPipeMessages.cs + Common\NamedPipes\NamedPipeClient.cs diff --git a/GVFS/GVFS.Hooks/Program.Unstage.cs b/GVFS/GVFS.Hooks/Program.Unstage.cs new file mode 100644 index 000000000..bebe9b9a9 --- /dev/null +++ b/GVFS/GVFS.Hooks/Program.Unstage.cs @@ -0,0 +1,106 @@ +using GVFS.Common.NamedPipes; +using System; + +namespace GVFS.Hooks +{ + /// + /// Partial class for unstage-related pre-command handling. + /// Detects "restore --staged" and "checkout HEAD --" operations and sends + /// a PrepareForUnstage message to the GVFS mount process so it can add + /// staged files to ModifiedPaths before git clears skip-worktree. + /// + public partial class Program + { + /// + /// Sends a PrepareForUnstage message to the GVFS mount process, which will + /// add staged files matching the pathspec to ModifiedPaths so that git will + /// clear skip-worktree and process them. + /// + private static void SendPrepareForUnstageMessage(string command, string[] args) + { + UnstageCommandParser.PathspecResult pathspecResult = UnstageCommandParser.GetRestorePathspec(command, args); + + if (pathspecResult.Failed) + { + ExitWithError( + "VFS for Git was unable to determine the pathspecs for this unstage operation.", + "This can happen when --pathspec-from-file=- (stdin) is used.", + "", + "Instead, pass the paths directly on the command line:", + " git restore --staged ..."); + return; + } + + // Build the message body. Format: + // null/empty → all staged files (no pathspec) + // "path1\0path2" → inline pathspecs (null-separated) + // "\nF\n" → --pathspec-from-file (mount forwards to git) + // "\nFZ\n" → --pathspec-from-file with --pathspec-file-nul + // The leading \n distinguishes file-reference bodies from inline pathspecs. + string body; + if (pathspecResult.PathspecFromFile != null) + { + string prefix = pathspecResult.PathspecFileNul ? "\nFZ\n" : "\nF\n"; + body = prefix + pathspecResult.PathspecFromFile; + + // If there are also inline pathspecs, append them after another \n + if (!string.IsNullOrEmpty(pathspecResult.InlinePathspecs)) + { + body += "\n" + pathspecResult.InlinePathspecs; + } + } + else + { + body = pathspecResult.InlinePathspecs; + } + + string message = string.IsNullOrEmpty(body) + ? NamedPipeMessages.PrepareForUnstage.Request + : NamedPipeMessages.PrepareForUnstage.Request + "|" + body; + + bool succeeded = false; + string failureMessage = null; + + try + { + using (NamedPipeClient pipeClient = new NamedPipeClient(enlistmentPipename)) + { + if (pipeClient.Connect()) + { + pipeClient.SendRequest(message); + string rawResponse = pipeClient.ReadRawResponse(); + if (rawResponse != null && rawResponse.StartsWith(NamedPipeMessages.PrepareForUnstage.SuccessResult)) + { + succeeded = true; + } + else + { + failureMessage = "GVFS mount process returned failure for PrepareForUnstage."; + } + } + else + { + failureMessage = "Unable to connect to GVFS mount process."; + } + } + } + catch (Exception e) + { + failureMessage = "Exception communicating with GVFS: " + e.Message; + } + + if (!succeeded && failureMessage != null) + { + ExitWithError( + failureMessage, + "The unstage operation cannot safely proceed because GVFS was unable to", + "prepare the staged files. This could lead to index corruption.", + "", + "To resolve:", + " 1. Run 'gvfs unmount' and 'gvfs mount' to reset the GVFS state", + " 2. Retry the restore --staged command", + "If the problem persists, run 'gvfs repair' or re-clone the enlistment."); + } + } + } +} diff --git a/GVFS/GVFS.Hooks/Program.cs b/GVFS/GVFS.Hooks/Program.cs index 6b95f556f..04a9d130a 100644 --- a/GVFS/GVFS.Hooks/Program.cs +++ b/GVFS/GVFS.Hooks/Program.cs @@ -10,7 +10,7 @@ namespace GVFS.Hooks { - public class Program + public partial class Program { private const string PreCommandHook = "pre-command"; private const string PostCommandHook = "post-command"; @@ -100,6 +100,13 @@ private static void RunPreCommands(string[] args) ProcessHelper.Run("gvfs", "health --status", redirectOutput: false); } break; + case "restore": + case "checkout": + if (UnstageCommandParser.IsUnstageOperation(command, args)) + { + SendPrepareForUnstageMessage(command, args); + } + break; } } diff --git a/GVFS/GVFS.Hooks/UnstageCommandParser.cs b/GVFS/GVFS.Hooks/UnstageCommandParser.cs new file mode 100644 index 000000000..d03761821 --- /dev/null +++ b/GVFS/GVFS.Hooks/UnstageCommandParser.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GVFS.Hooks +{ + /// + /// Pure parsing logic for detecting and extracting pathspecs from + /// git unstage commands. Separated from Program.Unstage.cs so it + /// can be linked into the unit test project without pulling in the + /// rest of the Hooks assembly. + /// + public static class UnstageCommandParser + { + /// + /// Result of parsing pathspec arguments from a git unstage command. + /// + public class PathspecResult + { + /// Null-separated inline pathspecs, or empty for all staged files. + public string InlinePathspecs { get; set; } + + /// Path to a --pathspec-from-file, or null if not specified. + public string PathspecFromFile { get; set; } + + /// Whether --pathspec-file-nul was specified. + public bool PathspecFileNul { get; set; } + + /// True if parsing failed and the command should be blocked. + public bool Failed { get; set; } + } + + /// + /// Detects whether the git command is an unstage operation that may need + /// special handling for VFS projections. + /// Matches: "restore --staged", "restore -S", "checkout HEAD --" + /// + public static bool IsUnstageOperation(string command, string[] args) + { + if (command == "restore") + { + return args.Any(arg => + arg.Equals("--staged", StringComparison.OrdinalIgnoreCase) || + // -S is --staged; char overload of IndexOf is case-sensitive, + // which is required because lowercase -s means --source + (arg.StartsWith("-") && !arg.StartsWith("--") && arg.IndexOf('S') >= 0)); + } + + if (command == "checkout") + { + // "checkout HEAD -- " is an unstage+restore operation. + // TODO: investigate whether "checkout -- " also + // needs PrepareForUnstage protection. It re-stages files (sets index to + // a different tree-ish) and could hit the same skip-worktree interference + // if the target files were staged by cherry-pick -n / merge and aren't in + // ModifiedPaths. Currently scoped to HEAD only as the common unstage case. + bool hasHead = args.Any(arg => arg.Equals("HEAD", StringComparison.OrdinalIgnoreCase)); + bool hasDashDash = args.Any(arg => arg == "--"); + return hasHead && hasDashDash; + } + + return false; + } + + /// + /// Extracts pathspec arguments from a restore/checkout unstage command. + /// Returns a containing either inline pathspecs, + /// a --pathspec-from-file reference, or a failure indicator. + /// + /// When --pathspec-from-file is specified, the file path is returned so the + /// caller can forward it through IPC to the mount process, which passes it + /// to git diff --cached --pathspec-from-file. + /// + public static PathspecResult GetRestorePathspec(string command, string[] args) + { + // args[0] = hook type, args[1] = git command, rest are arguments + List paths = new List(); + bool pastDashDash = false; + bool skipNext = false; + bool isCheckout = command == "checkout"; + + // For checkout, the first non-option arg before -- is the tree-ish (e.g. HEAD), + // not a pathspec. Track whether we've consumed it. + bool treeishConsumed = false; + + // --pathspec-from-file support: collect the file path and nul flag + string pathspecFromFile = null; + bool pathspecFileNul = false; + bool captureNextAsPathspecFile = false; + + for (int i = 2; i < args.Length; i++) + { + string arg = args[i]; + + if (captureNextAsPathspecFile) + { + pathspecFromFile = arg; + captureNextAsPathspecFile = false; + continue; + } + + if (skipNext) + { + skipNext = false; + continue; + } + + if (arg.StartsWith("--git-pid=")) + continue; + + // Capture --pathspec-from-file value + if (arg.StartsWith("--pathspec-from-file=")) + { + pathspecFromFile = arg.Substring("--pathspec-from-file=".Length); + continue; + } + + if (arg == "--pathspec-from-file") + { + captureNextAsPathspecFile = true; + continue; + } + + if (arg == "--pathspec-file-nul") + { + pathspecFileNul = true; + continue; + } + + if (arg == "--") + { + pastDashDash = true; + continue; + } + + if (!pastDashDash && arg.StartsWith("-")) + { + // For restore: --source and -s take a following argument + if (!isCheckout && + (arg == "--source" || arg == "-s")) + { + skipNext = true; + } + + continue; + } + + // For checkout, the first positional arg before -- is the tree-ish + if (isCheckout && !pastDashDash && !treeishConsumed) + { + treeishConsumed = true; + continue; + } + + paths.Add(arg); + } + + // stdin ("-") is not supported in hook context — the hook's stdin + // is not connected to the user's terminal + if (pathspecFromFile == "-") + { + return new PathspecResult { Failed = true }; + } + + return new PathspecResult + { + InlinePathspecs = paths.Count > 0 ? string.Join("\0", paths) : "", + PathspecFromFile = pathspecFromFile, + PathspecFileNul = pathspecFileNul, + }; + } + } +} diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 6e0cc0ead..ef3003071 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -369,6 +369,10 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne this.HandlePostIndexChangedRequest(message, connection); break; + case NamedPipeMessages.PrepareForUnstage.Request: + this.HandlePrepareForUnstageRequest(message, connection); + break; + case NamedPipeMessages.RunPostFetchJob.PostFetchJob: this.HandlePostFetchJobRequest(message, connection); break; @@ -608,6 +612,53 @@ private void HandlePostIndexChangedRequest(NamedPipeMessages.Message message, Na connection.TrySendResponse(response.CreateMessage()); } + /// + /// Handles a request to prepare for an unstage operation (e.g., restore --staged). + /// Finds index entries that are staged (not in HEAD) with skip-worktree set and adds + /// them to ModifiedPaths so that git will clear skip-worktree and process them. + /// Also forces a projection update to fix stale placeholders for modified/deleted files. + /// + private void HandlePrepareForUnstageRequest(NamedPipeMessages.Message message, NamedPipeServer.Connection connection) + { + NamedPipeMessages.PrepareForUnstage.Response response; + + if (this.currentState != MountState.Ready) + { + response = new NamedPipeMessages.PrepareForUnstage.Response(NamedPipeMessages.MountNotReadyResult); + } + else + { + try + { + string pathspec = message.Body; + bool success = this.fileSystemCallbacks.AddStagedFilesToModifiedPaths(pathspec, out int addedCount); + + EventMetadata metadata = new EventMetadata(); + metadata.Add("addedToModifiedPaths", addedCount); + metadata.Add("pathspec", pathspec ?? "(all)"); + metadata.Add("success", success); + this.tracer.RelatedEvent( + EventLevel.Informational, + nameof(this.HandlePrepareForUnstageRequest), + metadata); + + response = new NamedPipeMessages.PrepareForUnstage.Response( + success + ? NamedPipeMessages.PrepareForUnstage.SuccessResult + : NamedPipeMessages.PrepareForUnstage.FailureResult); + } + catch (Exception e) + { + EventMetadata metadata = new EventMetadata(); + metadata.Add("Exception", e.ToString()); + this.tracer.RelatedError(metadata, nameof(this.HandlePrepareForUnstageRequest) + " failed"); + response = new NamedPipeMessages.PrepareForUnstage.Response(NamedPipeMessages.PrepareForUnstage.FailureResult); + } + } + + connection.TrySendResponse(response.CreateMessage()); + } + private void HandleModifiedPathsListRequest(NamedPipeMessages.Message message, NamedPipeServer.Connection connection) { NamedPipeMessages.ModifiedPaths.Response response; diff --git a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj index 890714857..8c3669baa 100644 --- a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj +++ b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj @@ -37,6 +37,12 @@ + + + Hooks\UnstageCommandParser.cs + + + Always diff --git a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs index 2ef875245..74ddde8ee 100644 --- a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs @@ -2,6 +2,7 @@ using GVFS.Tests.Should; using GVFS.UnitTests.Mock.Common; using NUnit.Framework; +using System.Diagnostics; namespace GVFS.UnitTests.Git { @@ -253,5 +254,71 @@ public void ConfigResult_TryParseAsInt_ParsesWhenOutputIncludesWhitespace() result.TryParseAsInt(1, -1, out int value, out string error).ShouldBeTrue(); value.ShouldEqual(32); } + + [TestCase("dir/file.txt", "\"dir/file.txt\"")] + [TestCase("my dir/my file.txt", "\"my dir/my file.txt\"")] + [TestCase("dir/file\"name.txt", "\"dir/file\\\"name.txt\"")] + [TestCase("\"quoted\"", "\"\\\"quoted\\\"\"")] + [TestCase("dir\\subdir\\file.txt", "\"dir\\subdir\\file.txt\"")] // Backslashes as path separators left as-is + [TestCase("", "\"\"")] + [TestCase("dir\\\"file.txt", "\"dir\\\\\\\"file.txt\"")] // Backslash before quote: doubled, then quote escaped + [TestCase("dir\\subdir\\", "\"dir\\subdir\\\\\"")] // Trailing backslash doubled + public void QuoteGitPath(string input, string expected) + { + GitProcess.QuoteGitPath(input).ShouldEqual(expected); + } + + [TestCase] + [Description("Integration test: verify QuoteGitPath produces arguments that git actually receives correctly")] + public void QuoteGitPath_RoundTripThroughProcess() + { + // Test that paths with special characters survive the + // ProcessStartInfo.Arguments → Windows CRT argument parsing → git round-trip. + // We use "git rev-parse --sq-quote " which echoes the path back + // in shell-quoted form, proving git received it correctly. + string[] testPaths = new[] + { + "simple/path.txt", + "path with spaces/file name.txt", + "path\\with\\backslashes\\file.txt", + }; + + string gitPath = "C:\\Program Files\\Git\\cmd\\git.exe"; + if (!System.IO.File.Exists(gitPath)) + { + Assert.Ignore("Git not found at expected path — skipping integration test"); + } + + foreach (string testPath in testPaths) + { + string quoted = GitProcess.QuoteGitPath(testPath); + ProcessStartInfo psi = new ProcessStartInfo(gitPath) + { + Arguments = "rev-parse --sq-quote " + quoted, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + using (Process proc = Process.Start(psi)) + { + string output = proc.StandardOutput.ReadToEnd().Trim(); + proc.WaitForExit(); + + // git sq-quote wraps in single quotes and escapes single quotes + // For a simple path "foo/bar.txt" → output is "'foo/bar.txt'" + // Strip the outer single quotes to get the raw path back + if (output.StartsWith("'") && output.EndsWith("'")) + { + output = output.Substring(1, output.Length - 2); + } + + output.ShouldEqual( + testPath, + $"Path round-trip failed for: {testPath} (quoted as: {quoted})"); + } + } + } } } diff --git a/GVFS/GVFS.UnitTests/Hooks/UnstageTests.cs b/GVFS/GVFS.UnitTests/Hooks/UnstageTests.cs new file mode 100644 index 000000000..2341c15be --- /dev/null +++ b/GVFS/GVFS.UnitTests/Hooks/UnstageTests.cs @@ -0,0 +1,286 @@ +using GVFS.Hooks; +using GVFS.Tests.Should; +using NUnit.Framework; + +namespace GVFS.UnitTests.Hooks +{ + [TestFixture] + public class UnstageTests + { + // ── IsUnstageOperation ────────────────────────────────────────── + + [TestCase] + public void IsUnstageOperation_RestoreStaged() + { + UnstageCommandParser.IsUnstageOperation( + "restore", + new[] { "pre-command", "restore", "--staged", "." }) + .ShouldBeTrue(); + } + + [TestCase] + public void IsUnstageOperation_RestoreShortFlag() + { + UnstageCommandParser.IsUnstageOperation( + "restore", + new[] { "pre-command", "restore", "-S", "file.txt" }) + .ShouldBeTrue(); + } + + [TestCase] + public void IsUnstageOperation_RestoreCombinedShortFlags() + { + // -WS means --worktree --staged + UnstageCommandParser.IsUnstageOperation( + "restore", + new[] { "pre-command", "restore", "-WS", "file.txt" }) + .ShouldBeTrue(); + } + + [TestCase] + public void IsUnstageOperation_RestoreLowerS_NotStaged() + { + // -s means --source, not --staged + UnstageCommandParser.IsUnstageOperation( + "restore", + new[] { "pre-command", "restore", "-s", "HEAD~1", "file.txt" }) + .ShouldBeFalse(); + } + + [TestCase] + public void IsUnstageOperation_RestoreWithoutStaged() + { + UnstageCommandParser.IsUnstageOperation( + "restore", + new[] { "pre-command", "restore", "file.txt" }) + .ShouldBeFalse(); + } + + [TestCase] + public void IsUnstageOperation_CheckoutHeadDashDash() + { + UnstageCommandParser.IsUnstageOperation( + "checkout", + new[] { "pre-command", "checkout", "HEAD", "--", "file.txt" }) + .ShouldBeTrue(); + } + + [TestCase] + public void IsUnstageOperation_CheckoutNoDashDash() + { + UnstageCommandParser.IsUnstageOperation( + "checkout", + new[] { "pre-command", "checkout", "HEAD", "file.txt" }) + .ShouldBeFalse(); + } + + [TestCase] + public void IsUnstageOperation_CheckoutBranchName() + { + UnstageCommandParser.IsUnstageOperation( + "checkout", + new[] { "pre-command", "checkout", "my-branch" }) + .ShouldBeFalse(); + } + + [TestCase] + public void IsUnstageOperation_OtherCommand() + { + UnstageCommandParser.IsUnstageOperation( + "status", + new[] { "pre-command", "status" }) + .ShouldBeFalse(); + } + + // ── GetRestorePathspec: inline pathspecs ──────────────────────── + + [TestCase] + public void GetRestorePathspec_RestoreStagedAllFiles() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "." }); + result.Failed.ShouldBeFalse(); + result.InlinePathspecs.ShouldEqual("."); + result.PathspecFromFile.ShouldBeNull(); + } + + [TestCase] + public void GetRestorePathspec_RestoreStagedSpecificFiles() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "a.txt", "b.txt" }); + result.Failed.ShouldBeFalse(); + result.InlinePathspecs.ShouldEqual("a.txt\0b.txt"); + } + + [TestCase] + public void GetRestorePathspec_RestoreStagedNoPathspec() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged" }); + result.Failed.ShouldBeFalse(); + result.InlinePathspecs.ShouldEqual(string.Empty); + result.PathspecFromFile.ShouldBeNull(); + } + + [TestCase] + public void GetRestorePathspec_RestoreSkipsSourceFlag() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "--source", "HEAD~1", "file.txt" }); + result.InlinePathspecs.ShouldEqual("file.txt"); + } + + [TestCase] + public void GetRestorePathspec_RestoreSkipsSourceEqualsFlag() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "--source=HEAD~1", "file.txt" }); + result.InlinePathspecs.ShouldEqual("file.txt"); + } + + [TestCase] + public void GetRestorePathspec_RestoreSkipsShortSourceFlag() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "-s", "HEAD~1", "file.txt" }); + result.InlinePathspecs.ShouldEqual("file.txt"); + } + + [TestCase] + public void GetRestorePathspec_RestorePathsAfterDashDash() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "--", "a.txt", "b.txt" }); + result.InlinePathspecs.ShouldEqual("a.txt\0b.txt"); + } + + [TestCase] + public void GetRestorePathspec_RestoreSkipsGitPid() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "--git-pid=1234", "file.txt" }); + result.InlinePathspecs.ShouldEqual("file.txt"); + } + + // ── Checkout tree-ish stripping ──────────────────────────────── + + [TestCase] + public void GetRestorePathspec_CheckoutStripsTreeish() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "checkout", + new[] { "pre-command", "checkout", "HEAD", "--", "foo.txt" }); + result.InlinePathspecs.ShouldEqual("foo.txt"); + } + + [TestCase] + public void GetRestorePathspec_CheckoutStripsTreeishMultiplePaths() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "checkout", + new[] { "pre-command", "checkout", "HEAD", "--", "a.txt", "b.txt" }); + result.InlinePathspecs.ShouldEqual("a.txt\0b.txt"); + } + + [TestCase] + public void GetRestorePathspec_CheckoutNoPaths() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "checkout", + new[] { "pre-command", "checkout", "HEAD", "--" }); + result.InlinePathspecs.ShouldEqual(string.Empty); + } + + [TestCase] + public void GetRestorePathspec_CheckoutTreeishNotIncludedAsPaths() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "checkout", + new[] { "pre-command", "checkout", "HEAD", "--", "file.txt" }); + result.InlinePathspecs.ShouldNotContain(false, "HEAD"); + } + + // ── --pathspec-from-file forwarding ─────────────────────────── + + [TestCase] + public void GetRestorePathspec_PathspecFromFileEqualsForm() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "--pathspec-from-file=list.txt" }); + result.Failed.ShouldBeFalse(); + result.PathspecFromFile.ShouldEqual("list.txt"); + result.PathspecFileNul.ShouldBeFalse(); + } + + [TestCase] + public void GetRestorePathspec_PathspecFromFileSeparateArg() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "--pathspec-from-file", "list.txt" }); + result.Failed.ShouldBeFalse(); + result.PathspecFromFile.ShouldEqual("list.txt"); + } + + [TestCase] + public void GetRestorePathspec_PathspecFileNulSetsFlag() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "--pathspec-from-file=list.txt", "--pathspec-file-nul" }); + result.Failed.ShouldBeFalse(); + result.PathspecFromFile.ShouldEqual("list.txt"); + result.PathspecFileNul.ShouldBeTrue(); + } + + [TestCase] + public void GetRestorePathspec_PathspecFromFileStdinFails() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "--pathspec-from-file=-" }); + result.Failed.ShouldBeTrue(); + } + + [TestCase] + public void GetRestorePathspec_CheckoutPathspecFromFile() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "checkout", + new[] { "pre-command", "checkout", "HEAD", "--pathspec-from-file=list.txt", "--" }); + result.Failed.ShouldBeFalse(); + result.PathspecFromFile.ShouldEqual("list.txt"); + } + + [TestCase] + public void GetRestorePathspec_PathspecFileNulAloneIsIgnored() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "--pathspec-file-nul", "file.txt" }); + result.InlinePathspecs.ShouldEqual("file.txt"); + result.PathspecFromFile.ShouldBeNull(); + } + + [TestCase] + public void GetRestorePathspec_PathspecFromFileWithInlinePaths() + { + UnstageCommandParser.PathspecResult result = UnstageCommandParser.GetRestorePathspec( + "restore", + new[] { "pre-command", "restore", "--staged", "--pathspec-from-file=list.txt", "extra.txt" }); + result.Failed.ShouldBeFalse(); + result.PathspecFromFile.ShouldEqual("list.txt"); + result.InlinePathspecs.ShouldEqual("extra.txt"); + } + } +} diff --git a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs index 8a50f030a..e47db84d6 100644 --- a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs +++ b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs @@ -365,6 +365,158 @@ public IEnumerable GetAllModifiedPaths() return this.modifiedPaths.GetAllModifiedPaths(); } + /// + /// Finds index entries that are staged (differ from HEAD) matching the given + /// pathspec, and adds them to ModifiedPaths. This prepares for an unstage operation + /// (e.g., restore --staged) by ensuring git will clear skip-worktree for these + /// entries so it can detect their working tree state correctly. + /// Files that were added (not in HEAD) are also written to disk from the git object + /// store as full files, so they persist after projection changes. + /// + /// + /// IPC message body. Formats: + /// null/empty — all staged files + /// "path1\0path2" — inline pathspecs (null-separated) + /// "\nF\n{filepath}" — --pathspec-from-file (forwarded to git) + /// "\nFZ\n{filepath}" — --pathspec-from-file with --pathspec-file-nul + /// File-reference bodies may include inline pathspecs after a 4th \n field. + /// + /// Number of paths added to ModifiedPaths. + /// True if all operations succeeded, false if any failed. + public bool AddStagedFilesToModifiedPaths(string messageBody, out int addedCount) + { + addedCount = 0; + bool success = true; + + // Use a dedicated GitProcess instance to avoid serialization with other + // concurrent pipe message handlers that may also be running git commands. + GitProcess gitProcess = new GitProcess(this.context.Enlistment); + + // Parse message body to extract pathspec arguments for git diff --cached + string[] pathspecs = null; + string pathspecFromFile = null; + bool pathspecFileNul = false; + + if (!string.IsNullOrEmpty(messageBody)) + { + if (messageBody.StartsWith("\n")) + { + // File-reference format: "\n{F|FZ}\n[\n]" + string[] fields = messageBody.Split(new[] { '\n' }, 4, StringSplitOptions.None); + if (fields.Length >= 3) + { + pathspecFileNul = fields[1] == "FZ"; + pathspecFromFile = fields[2]; + + if (fields.Length >= 4 && !string.IsNullOrEmpty(fields[3])) + { + pathspecs = fields[3].Split('\0'); + } + } + } + else + { + pathspecs = messageBody.Split('\0'); + } + } + + // 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.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) + { + string status = parts[i]; + string gitPath = parts[i + 1]; + + if (string.IsNullOrEmpty(gitPath)) + { + continue; + } + + string platformPath = gitPath.Replace(GVFSConstants.GitPathSeparator, Path.DirectorySeparatorChar); + if (this.modifiedPaths.TryAdd(platformPath, isFolder: false, isRetryable: out _)) + { + addedCount++; + } + + // Added files (in index but not in HEAD) are ProjFS placeholders that + // would vanish when the projection reverts to HEAD. Collect them for + // hydration below. + if (status.StartsWith("A")) + { + addedFilePaths.Add(gitPath); + } + } + + // 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. + if (addedFilePaths.Count > 0) + { + if (!this.WriteStagedFilesToWorkingDirectory(gitProcess, addedFilePaths)) + { + success = false; + } + } + } + else if (!result.ExitCodeIsSuccess) + { + EventMetadata metadata = new EventMetadata(); + metadata.Add("ExitCode", result.ExitCode); + metadata.Add("Errors", result.Errors ?? string.Empty); + this.context.Tracer.RelatedError( + metadata, + nameof(this.AddStagedFilesToModifiedPaths) + ": git diff --cached failed"); + success = false; + } + + return success; + } + + /// + /// Writes the staged (index) versions of files to the working directory as + /// full files, bypassing ProjFS. Uses "git checkout-index --force" with + /// batched paths to minimize process invocations. + /// Returns true if all batches succeeded, false if any failed. + /// + private bool WriteStagedFilesToWorkingDirectory(GitProcess gitProcess, List gitPaths) + { + bool allSucceeded = true; + try + { + List results = gitProcess.CheckoutIndexForFiles(gitPaths); + foreach (GitProcess.Result result in results) + { + if (!result.ExitCodeIsSuccess) + { + allSucceeded = false; + EventMetadata metadata = new EventMetadata(); + metadata.Add("pathCount", gitPaths.Count); + metadata.Add("error", result.Errors); + this.context.Tracer.RelatedWarning( + metadata, + nameof(this.WriteStagedFilesToWorkingDirectory) + ": git checkout-index failed"); + } + } + } + catch (Exception e) + { + allSucceeded = false; + EventMetadata metadata = new EventMetadata(); + metadata.Add("pathCount", gitPaths.Count); + metadata.Add("Exception", e.ToString()); + this.context.Tracer.RelatedWarning(metadata, nameof(this.WriteStagedFilesToWorkingDirectory) + ": Failed to write files"); + } + + return allSucceeded; + } + public virtual void OnIndexFileChange() { string lockedGitCommand = this.context.Repository.GVFSLock.GetLockedGitCommand();