Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions GVFS/GVFS.Common/Git/GitProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,18 @@ public Result DiffTree(string sourceTreeish, string targetTreeish, Action<string
return this.InvokeGitAgainstDotGitFolder("diff-tree -r -t " + sourceTreeish + " " + targetTreeish, null, onResult);
}

/// <summary>
/// Runs "git diff-tree --name-only -r -z" between two tree-ish references,
/// returning only the file paths that differ. The output is null-byte separated
/// for safe parsing of paths containing special characters.
/// </summary>
public Result DiffTreeNameOnly(string sourceTreeish, string targetTreeish)
{
return this.InvokeGitInWorkingDirectoryRoot(
"diff-tree --name-only -r -z " + sourceTreeish + " " + targetTreeish,
useReadObjectHook: false);
}

public Result CreateBranchWithUpstream(string branchToCreate, string upstreamBranch)
{
return this.InvokeGitAgainstDotGitFolder("branch " + branchToCreate + " --track " + upstreamBranch);
Expand Down
27 changes: 27 additions & 0 deletions GVFS/GVFS.Common/NamedPipes/ResetNamedPipeMessages.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace GVFS.Common.NamedPipes
{
public static partial class NamedPipeMessages
{
public static class PrepareForReset
{
public const string Request = "PreReset";
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);
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,47 @@ public void ReproCherryPickRestoreCorruption()
this.FilesShouldMatchCheckoutOfSourceBranch();
}

/// <summary>
/// Reproduces a bug where "git reset --mixed" fails to report hydrated files
/// as modified when skip-worktree hides the working-tree vs index mismatch.
///
/// After a mixed reset, the index is updated to the target commit's tree, but
/// the working tree is left untouched. For non-hydrated placeholders this is
/// invisible (ProjFS serves the new content transparently). But for hydrated
/// files — files that have been read and materialized on disk — the on-disk
/// content still matches the OLD HEAD. Because the file was never modified
/// (just read), it is not in ModifiedPaths, so skip-worktree remains set.
/// This causes "git status" to skip the file entirely, hiding the mismatch
/// between the stale on-disk content and the new index entry.
///
/// On the FunctionalTests/20201014 branch, Readme.md is the only file that
/// differs between HEAD and HEAD~1, making it a clean single-file repro.
///
/// Expected: reset output includes "M Readme.md", status shows it as modified.
/// Actual (bug): reset output is empty, status reports clean.
/// </summary>
[TestCase]
public void ReproResetMixedSkipWorktree()
{
// Hydrate Readme.md by reading it via blame. In GVFS, this triggers a
// ProjFS callback that materializes the file from the object store. The
// file is now a full file on disk, but NOT in ModifiedPaths (read-only
// access doesn't modify it), so skip-worktree stays set.
this.ValidateGitCommand("blame Readme.md");

this.ValidateGitCommand("checkout -b tests/functional/ReproResetMixedSkipWorktree");

// Mixed reset to HEAD~1. Readme.md's blob differs between HEAD and HEAD~1
// on this branch, so the index entry is updated. But the on-disk content
// still has HEAD's version (mixed reset doesn't touch the working tree).
//
// Control repo: reports "M Readme.md" in reset output and status.
// GVFS (bug): skip-worktree is set and file isn't in ModifiedPaths, so
// git skips the working-tree check. Reset output omits Readme.md, and
// status incorrectly reports clean.
this.ValidateGitCommand("reset HEAD~1");
}

/// <summary>
/// Reproduction of a reported issue:
/// Restoring a file after its parent directory was deleted fails with
Expand Down
3 changes: 3 additions & 0 deletions GVFS/GVFS.Hooks/GVFS.Hooks.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
<Compile Include="..\GVFS.Common\NamedPipes\UnstageNamedPipeMessages.cs">
<Link>Common\NamedPipes\UnstageNamedPipeMessages.cs</Link>
</Compile>
<Compile Include="..\GVFS.Common\NamedPipes\ResetNamedPipeMessages.cs">
<Link>Common\NamedPipes\ResetNamedPipeMessages.cs</Link>
</Compile>
<Compile Include="..\GVFS.Common\NamedPipes\HydrationStatusNamedPipeMessages.cs">
<Link>Common\NamedPipes\HydrationStatusNamedPipeMessages.cs</Link>
</Compile>
Expand Down
83 changes: 83 additions & 0 deletions GVFS/GVFS.Hooks/Program.Reset.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using GVFS.Common.NamedPipes;
using System;

namespace GVFS.Hooks
{
/// <summary>
/// Partial class for reset-related pre-command handling.
/// Detects "git reset --mixed" operations and sends a PrepareForReset
/// message to the GVFS mount process so it can add files that differ
/// between HEAD and the target commit to ModifiedPaths before git
/// clears skip-worktree.
/// </summary>
public partial class Program
{
/// <summary>
/// Sends a PrepareForReset message to the GVFS mount process, which will
/// diff HEAD against the target commit and add changed files to ModifiedPaths
/// so that git will clear skip-worktree and process them during the reset.
/// </summary>
private static void SendPrepareForResetMessage(string[] args)
{
ResetCommandParser.ResetParseResult parseResult = ResetCommandParser.Parse(args);

if (!parseResult.IsMixedReset)
{
return;
}

// Message body is the target commit (or empty for HEAD).
// For path-based resets (git reset HEAD -- path), we still send the
// target to let the mount process diff the right trees.
string body = parseResult.TargetCommit ?? string.Empty;

string message = string.IsNullOrEmpty(body)
? NamedPipeMessages.PrepareForReset.Request
: NamedPipeMessages.PrepareForReset.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.PrepareForReset.SuccessResult))
{
succeeded = true;
}
else
{
failureMessage = "GVFS mount process returned failure for PrepareForReset.";
}
}
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 reset operation cannot safely proceed because GVFS was unable to",
"prepare the index entries. This could lead to stale index state where",
"skip-worktree files retain incorrect blob SHAs after the reset.",
"",
"To resolve:",
" 1. Run 'gvfs unmount' and 'gvfs mount' to reset the GVFS state",
" 2. Retry the reset command");
}
}
}
}
3 changes: 3 additions & 0 deletions GVFS/GVFS.Hooks/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ private static void RunPreCommands(string[] args)
SendPrepareForUnstageMessage(command, args);
}
break;
case "reset":
SendPrepareForResetMessage(args);
break;
case "worktree":
RunWorktreePreCommand(args);
break;
Expand Down
134 changes: 134 additions & 0 deletions GVFS/GVFS.Hooks/ResetCommandParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using System;

namespace GVFS.Hooks
{
/// <summary>
/// Pure parsing logic for detecting mixed resets and extracting the
/// target commit. Separated from Program.Reset.cs for testability.
/// </summary>
public static class ResetCommandParser
{
/// <summary>
/// Result of parsing a git reset command.
/// </summary>
public class ResetParseResult
{
/// <summary>Whether this is a mixed reset that needs PrepareForReset handling.</summary>
public bool IsMixedReset { get; set; }

/// <summary>
/// The target commit (ref, SHA, HEAD~N, etc.), or null if resetting to HEAD.
/// For path-based resets (git reset HEAD -- path), this is the tree-ish.
/// </summary>
public string TargetCommit { get; set; }

/// <summary>Whether this is a path-based reset (git reset [commit] -- paths).</summary>
public bool HasPaths { get; set; }
}

/// <summary>
/// Determines whether the git reset command is a mixed reset that may be
/// affected by skip-worktree, and extracts the target commit.
///
/// Mixed resets include:
/// git reset HEAD~1 (implicit --mixed)
/// git reset --mixed HEAD~1 (explicit --mixed)
/// git reset HEAD -- path (path-based, also affected)
///
/// Not affected:
/// git reset --soft HEAD~1 (doesn't touch index)
/// git reset --hard HEAD~1 (overwrites working tree, handles skip-worktree)
/// </summary>
public static ResetParseResult Parse(string[] args)
{
// args[0] = hook type, args[1] = "reset", rest are arguments
bool isSoft = false;
bool isHard = false;
bool isMerge = false;
bool isKeep = false;
bool pastDashDash = false;
string targetCommit = null;
bool hasPositionalAfterDashDash = false;

for (int i = 2; i < args.Length; i++)
{
string arg = args[i];

if (arg.StartsWith("--git-pid="))
{
continue;
}

if (arg == "--")
{
pastDashDash = true;
continue;
}

if (pastDashDash)
{
hasPositionalAfterDashDash = true;
continue;
}

if (arg.Equals("--soft", StringComparison.OrdinalIgnoreCase))
{
isSoft = true;
continue;
}

if (arg.Equals("--hard", StringComparison.OrdinalIgnoreCase))
{
isHard = true;
continue;
}

if (arg.Equals("--mixed", StringComparison.OrdinalIgnoreCase))
{
// Explicit --mixed, no-op since mixed is the default
continue;
}

if (arg.Equals("--merge", StringComparison.OrdinalIgnoreCase))
{
isMerge = true;
continue;
}

if (arg.Equals("--keep", StringComparison.OrdinalIgnoreCase))
{
isKeep = true;
continue;
}

// Skip flags that don't affect mode
if (arg.StartsWith("-"))
{
continue;
}

// First positional argument before -- is the target commit
if (targetCommit == null)
{
targetCommit = arg;
}
else
{
// Second positional argument = path (git reset <commit> <paths>)
hasPositionalAfterDashDash = true;
}
}

// Only handle mixed resets. Soft doesn't touch index, hard and
// merge/keep have their own working tree handling.
bool isMixedReset = !isSoft && !isHard && !isMerge && !isKeep;

return new ResetParseResult
{
IsMixedReset = isMixedReset,
TargetCommit = targetCommit,
HasPaths = hasPositionalAfterDashDash,
};
}
}
}
52 changes: 52 additions & 0 deletions GVFS/GVFS.Mount/InProcessMount.cs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,10 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne
this.HandlePrepareForUnstageRequest(message, connection);
break;

case NamedPipeMessages.PrepareForReset.Request:
this.HandlePrepareForResetRequest(message, connection);
break;

case NamedPipeMessages.RunPostFetchJob.PostFetchJob:
this.HandlePostFetchJobRequest(message, connection);
break;
Expand Down Expand Up @@ -826,6 +830,54 @@ private void HandlePrepareForUnstageRequest(NamedPipeMessages.Message message, N
connection.TrySendResponse(response.CreateMessage());
}

/// <summary>
/// Handles a request to prepare for a mixed reset operation.
/// Diffs HEAD against the target commit and adds changed files to ModifiedPaths
/// so that git will clear skip-worktree for them. Without this, hydrated files
/// (read but not modified) retain skip-worktree after the reset, hiding the
/// working-tree vs index mismatch from git status.
/// </summary>
private void HandlePrepareForResetRequest(NamedPipeMessages.Message message, NamedPipeServer.Connection connection)
{
NamedPipeMessages.PrepareForReset.Response response;

if (this.currentState != MountState.Ready)
{
response = new NamedPipeMessages.PrepareForReset.Response(NamedPipeMessages.MountNotReadyResult);
}
else
{
try
{
string targetCommit = message.Body;
bool success = this.fileSystemCallbacks.AddResetDiffToModifiedPaths(targetCommit, out int addedCount);

EventMetadata metadata = new EventMetadata();
metadata.Add("addedToModifiedPaths", addedCount);
metadata.Add("targetCommit", targetCommit ?? "(HEAD)");
metadata.Add("success", success);
this.tracer.RelatedEvent(
EventLevel.Informational,
nameof(this.HandlePrepareForResetRequest),
metadata);

response = new NamedPipeMessages.PrepareForReset.Response(
success
? NamedPipeMessages.PrepareForReset.SuccessResult
: NamedPipeMessages.PrepareForReset.FailureResult);
}
catch (Exception e)
{
EventMetadata metadata = new EventMetadata();
metadata.Add("Exception", e.ToString());
this.tracer.RelatedError(metadata, nameof(this.HandlePrepareForResetRequest) + " failed");
response = new NamedPipeMessages.PrepareForReset.Response(NamedPipeMessages.PrepareForReset.FailureResult);
}
}

connection.TrySendResponse(response.CreateMessage());
}

private void HandleModifiedPathsListRequest(NamedPipeMessages.Message message, NamedPipeServer.Connection connection)
{
NamedPipeMessages.ModifiedPaths.Response response;
Expand Down
Loading
Loading