Skip to content

Commit 1cf5fea

Browse files
authored
Merge pull request microsoft#1856 from tyrielv/restore-repro-test
Fix corruption when unstaging changes made by git commands
2 parents 1ef345c + 2bee9a3 commit 1cf5fea

12 files changed

Lines changed: 1090 additions & 1 deletion

File tree

GVFS/GVFS.Common/Git/GitProcess.cs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,138 @@ public Result StatusPorcelain()
509509
return this.InvokeGitInWorkingDirectoryRoot(command, useReadObjectHook: false);
510510
}
511511

512+
/// <summary>
513+
/// Returns staged file changes (index vs HEAD) as null-separated pairs of
514+
/// status and path: "A\0path1\0M\0path2\0D\0path3\0".
515+
/// Status codes: A=added, M=modified, D=deleted, R=renamed, C=copied.
516+
/// </summary>
517+
/// <param name="pathspecs">Inline pathspecs to scope the diff, or null for all.</param>
518+
/// <param name="pathspecFromFile">
519+
/// Path to a file containing additional pathspecs (one per line), forwarded
520+
/// as --pathspec-from-file to git. Null if not used.
521+
/// </param>
522+
/// <param name="pathspecFileNul">
523+
/// When true and pathspecFromFile is set, pathspec entries in the file are
524+
/// separated by NUL instead of newline (--pathspec-file-nul).
525+
/// </param>
526+
public Result DiffCachedNameStatus(string[] pathspecs = null, string pathspecFromFile = null, bool pathspecFileNul = false)
527+
{
528+
string command = "diff --cached --name-status -z --no-renames";
529+
530+
if (pathspecFromFile != null)
531+
{
532+
command += " --pathspec-from-file=" + QuoteGitPath(pathspecFromFile);
533+
if (pathspecFileNul)
534+
{
535+
command += " --pathspec-file-nul";
536+
}
537+
}
538+
539+
if (pathspecs != null && pathspecs.Length > 0)
540+
{
541+
command += " -- " + string.Join(" ", pathspecs.Select(p => QuoteGitPath(p)));
542+
}
543+
544+
return this.InvokeGitInWorkingDirectoryRoot(command, useReadObjectHook: false);
545+
}
546+
547+
/// <summary>
548+
/// Writes the staged (index) version of the specified files to the working
549+
/// tree with correct line endings and attributes. Batches multiple paths into
550+
/// a single git process invocation where possible, respecting the Windows
551+
/// command line length limit.
552+
/// </summary>
553+
public List<Result> CheckoutIndexForFiles(IEnumerable<string> paths)
554+
{
555+
// Windows command line limit is 32,767 characters. Leave headroom for
556+
// the base command and other arguments.
557+
const int MaxCommandLength = 30000;
558+
const string BaseCommand = "-c core.hookspath= checkout-index --force --";
559+
560+
List<Result> results = new List<Result>();
561+
StringBuilder command = new StringBuilder(BaseCommand);
562+
foreach (string path in paths)
563+
{
564+
string quotedPath = " " + QuoteGitPath(path);
565+
566+
if (command.Length + quotedPath.Length > MaxCommandLength && command.Length > BaseCommand.Length)
567+
{
568+
// Flush current batch
569+
results.Add(this.InvokeGitInWorkingDirectoryRoot(command.ToString(), useReadObjectHook: false));
570+
command.Clear();
571+
command.Append(BaseCommand);
572+
}
573+
574+
command.Append(quotedPath);
575+
}
576+
577+
// Flush remaining paths
578+
if (command.Length > BaseCommand.Length)
579+
{
580+
results.Add(this.InvokeGitInWorkingDirectoryRoot(command.ToString(), useReadObjectHook: false));
581+
}
582+
583+
return results;
584+
}
585+
586+
/// <summary>
587+
/// Wraps a path in double quotes for use as a git command argument,
588+
/// escaping any embedded double quotes and any backslashes that
589+
/// immediately precede a double quote (to prevent them from being
590+
/// interpreted as escape characters by the Windows C runtime argument
591+
/// parser). Lone backslashes used as path separators are left as-is.
592+
/// </summary>
593+
public static string QuoteGitPath(string path)
594+
{
595+
StringBuilder sb = new StringBuilder(path.Length + 4);
596+
sb.Append('"');
597+
598+
for (int i = 0; i < path.Length; i++)
599+
{
600+
if (path[i] == '"')
601+
{
602+
sb.Append('\\');
603+
sb.Append('"');
604+
}
605+
else if (path[i] == '\\')
606+
{
607+
// Count consecutive backslashes
608+
int backslashCount = 0;
609+
while (i < path.Length && path[i] == '\\')
610+
{
611+
backslashCount++;
612+
i++;
613+
}
614+
615+
if (i < path.Length && path[i] == '"')
616+
{
617+
// Backslashes before a quote: double them all, then escape the quote
618+
sb.Append('\\', backslashCount * 2);
619+
sb.Append('\\');
620+
sb.Append('"');
621+
}
622+
else if (i == path.Length)
623+
{
624+
// Backslashes at end of string (before closing quote): double them
625+
sb.Append('\\', backslashCount * 2);
626+
}
627+
else
628+
{
629+
// Backslashes not before a quote: keep as-is (path separators)
630+
sb.Append('\\', backslashCount);
631+
i--; // Re-process current non-backslash char
632+
}
633+
}
634+
else
635+
{
636+
sb.Append(path[i]);
637+
}
638+
}
639+
640+
sb.Append('"');
641+
return sb.ToString();
642+
}
643+
512644
public Result SerializeStatus(bool allowObjectDownloads, string serializePath)
513645
{
514646
// specify ignored=matching and --untracked-files=complete
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace GVFS.Common.NamedPipes
2+
{
3+
public static partial class NamedPipeMessages
4+
{
5+
public static class PrepareForUnstage
6+
{
7+
public const string Request = "PreUnstage";
8+
public const string SuccessResult = "S";
9+
public const string FailureResult = "F";
10+
11+
public class Response
12+
{
13+
public Response(string result)
14+
{
15+
this.Result = result;
16+
}
17+
18+
public string Result { get; }
19+
20+
public Message CreateMessage()
21+
{
22+
return new Message(this.Result, null);
23+
}
24+
}
25+
}
26+
}
27+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using System.Linq;
5+
using System.Text;
6+
using System.Threading.Tasks;
7+
using GVFS.Common;
8+
using GVFS.FunctionalTests.Properties;
9+
using GVFS.FunctionalTests.Tests.EnlistmentPerTestCase;
10+
using NUnit.Framework;
11+
12+
namespace GVFS.FunctionalTests.Tests.GitCommands
13+
{
14+
/// <summary>
15+
/// This class is used to reproduce corruption scenarios in the GVFS virtual projection.
16+
/// </summary>
17+
[Category(Categories.GitCommands)]
18+
[TestFixtureSource(typeof(GitRepoTests), nameof(GitRepoTests.ValidateWorkingTree))]
19+
public class CorruptionReproTests : GitRepoTests
20+
{
21+
public CorruptionReproTests(Settings.ValidateWorkingTreeMode validateWorkingTree)
22+
: base(enlistmentPerTest: true, validateWorkingTree: validateWorkingTree)
23+
{
24+
}
25+
26+
[TestCase]
27+
public void ReproCherryPickRestoreCorruption()
28+
{
29+
// Reproduces a corruption scenario where git commands (like cherry-pick -n)
30+
// stage changes directly, bypassing the filesystem. In VFS mode, these staged
31+
// files have skip-worktree set and are not in the ModifiedPaths database.
32+
// Without the fix, a subsequent "restore --staged" would fail to properly
33+
// unstage them, leaving the index and projection in an inconsistent state.
34+
//
35+
// See https://github.com/microsoft/VFSForGit/issues/1855
36+
37+
// Based on FunctionalTests/20170206_Conflict_Source
38+
const string CherryPickCommit = "51d15f7584e81d59d44c1511ce17d7c493903390";
39+
const string StartingCommit = "db95d631e379d366d26d899523f8136a77441914";
40+
41+
this.ControlGitRepo.Fetch(StartingCommit);
42+
this.ControlGitRepo.Fetch(CherryPickCommit);
43+
44+
this.ValidateGitCommand($"checkout -b FunctionalTests/CherryPickRestoreCorruptionRepro {StartingCommit}");
45+
46+
// Cherry-pick stages adds, deletes, and modifications without committing.
47+
// In VFS mode, these changes are made directly by git in the index — they
48+
// are not in ModifiedPaths, so all affected files still have skip-worktree set.
49+
this.ValidateGitCommand($"cherry-pick -n {CherryPickCommit}");
50+
51+
// Restore --staged for a single file first. This verifies that only the
52+
// targeted file is added to ModifiedPaths, not all staged files (important
53+
// for performance when there are many staged files, e.g. during merge
54+
// conflict resolution).
55+
//
56+
// Before the fix: added files with skip-worktree would be skipped by
57+
// restore --staged, remaining stuck as staged in the index.
58+
this.ValidateGitCommand("restore --staged Test_ConflictTests/AddedFiles/AddedBySource.txt");
59+
60+
// Restore --staged for everything remaining. Before the fix:
61+
// - Modified files: restored in the index but invisible to git status
62+
// because skip-worktree was set and the file wasn't in ModifiedPaths,
63+
// so git never checked the working tree against the index.
64+
// - Deleted files: same issue — deletions became invisible.
65+
// - Added files: remained stuck as staged because restore --staged
66+
// skipped them (skip-worktree set), and their ProjFS placeholders
67+
// would later vanish when the projection reverted to HEAD.
68+
this.ValidateGitCommand("restore --staged .");
69+
70+
// Restore the working directory. Before the fix, this step would
71+
// silently succeed but leave corrupted state: modified/deleted files
72+
// had stale projected content that didn't match HEAD, and added files
73+
// (as ProjFS placeholders) would vanish entirely since they're not in
74+
// HEAD's tree.
75+
this.ValidateGitCommand("restore -- .");
76+
this.FilesShouldMatchCheckoutOfSourceBranch();
77+
}
78+
}
79+
}

GVFS/GVFS.Hooks/GVFS.Hooks.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
<Compile Include="..\GVFS.Common\NamedPipes\LockNamedPipeMessages.cs">
5050
<Link>Common\NamedPipes\LockNamedPipeMessages.cs</Link>
5151
</Compile>
52+
<Compile Include="..\GVFS.Common\NamedPipes\UnstageNamedPipeMessages.cs">
53+
<Link>Common\NamedPipes\UnstageNamedPipeMessages.cs</Link>
54+
</Compile>
5255
<Compile Include="..\GVFS.Common\NamedPipes\NamedPipeClient.cs">
5356
<Link>Common\NamedPipes\NamedPipeClient.cs</Link>
5457
</Compile>

GVFS/GVFS.Hooks/Program.Unstage.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using GVFS.Common.NamedPipes;
2+
using System;
3+
4+
namespace GVFS.Hooks
5+
{
6+
/// <summary>
7+
/// Partial class for unstage-related pre-command handling.
8+
/// Detects "restore --staged" and "checkout HEAD --" operations and sends
9+
/// a PrepareForUnstage message to the GVFS mount process so it can add
10+
/// staged files to ModifiedPaths before git clears skip-worktree.
11+
/// </summary>
12+
public partial class Program
13+
{
14+
/// <summary>
15+
/// Sends a PrepareForUnstage message to the GVFS mount process, which will
16+
/// add staged files matching the pathspec to ModifiedPaths so that git will
17+
/// clear skip-worktree and process them.
18+
/// </summary>
19+
private static void SendPrepareForUnstageMessage(string command, string[] args)
20+
{
21+
UnstageCommandParser.PathspecResult pathspecResult = UnstageCommandParser.GetRestorePathspec(command, args);
22+
23+
if (pathspecResult.Failed)
24+
{
25+
ExitWithError(
26+
"VFS for Git was unable to determine the pathspecs for this unstage operation.",
27+
"This can happen when --pathspec-from-file=- (stdin) is used.",
28+
"",
29+
"Instead, pass the paths directly on the command line:",
30+
" git restore --staged <path1> <path2> ...");
31+
return;
32+
}
33+
34+
// Build the message body. Format:
35+
// null/empty → all staged files (no pathspec)
36+
// "path1\0path2" → inline pathspecs (null-separated)
37+
// "\nF\n<filepath>" → --pathspec-from-file (mount forwards to git)
38+
// "\nFZ\n<filepath>" → --pathspec-from-file with --pathspec-file-nul
39+
// The leading \n distinguishes file-reference bodies from inline pathspecs.
40+
string body;
41+
if (pathspecResult.PathspecFromFile != null)
42+
{
43+
string prefix = pathspecResult.PathspecFileNul ? "\nFZ\n" : "\nF\n";
44+
body = prefix + pathspecResult.PathspecFromFile;
45+
46+
// If there are also inline pathspecs, append them after another \n
47+
if (!string.IsNullOrEmpty(pathspecResult.InlinePathspecs))
48+
{
49+
body += "\n" + pathspecResult.InlinePathspecs;
50+
}
51+
}
52+
else
53+
{
54+
body = pathspecResult.InlinePathspecs;
55+
}
56+
57+
string message = string.IsNullOrEmpty(body)
58+
? NamedPipeMessages.PrepareForUnstage.Request
59+
: NamedPipeMessages.PrepareForUnstage.Request + "|" + body;
60+
61+
bool succeeded = false;
62+
string failureMessage = null;
63+
64+
try
65+
{
66+
using (NamedPipeClient pipeClient = new NamedPipeClient(enlistmentPipename))
67+
{
68+
if (pipeClient.Connect())
69+
{
70+
pipeClient.SendRequest(message);
71+
string rawResponse = pipeClient.ReadRawResponse();
72+
if (rawResponse != null && rawResponse.StartsWith(NamedPipeMessages.PrepareForUnstage.SuccessResult))
73+
{
74+
succeeded = true;
75+
}
76+
else
77+
{
78+
failureMessage = "GVFS mount process returned failure for PrepareForUnstage.";
79+
}
80+
}
81+
else
82+
{
83+
failureMessage = "Unable to connect to GVFS mount process.";
84+
}
85+
}
86+
}
87+
catch (Exception e)
88+
{
89+
failureMessage = "Exception communicating with GVFS: " + e.Message;
90+
}
91+
92+
if (!succeeded && failureMessage != null)
93+
{
94+
ExitWithError(
95+
failureMessage,
96+
"The unstage operation cannot safely proceed because GVFS was unable to",
97+
"prepare the staged files. This could lead to index corruption.",
98+
"",
99+
"To resolve:",
100+
" 1. Run 'gvfs unmount' and 'gvfs mount' to reset the GVFS state",
101+
" 2. Retry the restore --staged command",
102+
"If the problem persists, run 'gvfs repair' or re-clone the enlistment.");
103+
}
104+
}
105+
}
106+
}

GVFS/GVFS.Hooks/Program.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
namespace GVFS.Hooks
1212
{
13-
public class Program
13+
public partial class Program
1414
{
1515
private const string PreCommandHook = "pre-command";
1616
private const string PostCommandHook = "post-command";
@@ -100,6 +100,13 @@ private static void RunPreCommands(string[] args)
100100
ProcessHelper.Run("gvfs", "health --status", redirectOutput: false);
101101
}
102102
break;
103+
case "restore":
104+
case "checkout":
105+
if (UnstageCommandParser.IsUnstageOperation(command, args))
106+
{
107+
SendPrepareForUnstageMessage(command, args);
108+
}
109+
break;
103110
}
104111
}
105112

0 commit comments

Comments
 (0)