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