diff --git a/GVFS/FastFetch/CheckoutStage.cs b/GVFS/FastFetch/CheckoutStage.cs
index 6874eff43..1012b29ff 100644
--- a/GVFS/FastFetch/CheckoutStage.cs
+++ b/GVFS/FastFetch/CheckoutStage.cs
@@ -173,13 +173,25 @@ private void HandleAllDirectoryOperations()
case DiffTreeResult.Operations.Add:
try
{
- Directory.CreateDirectory(absoluteTargetPath);
+ if (treeOp.SourcePath != null)
+ {
+ this.ApplyCaseOnlyDirectoryRename(treeOp, absoluteTargetPath);
+ }
+ else
+ {
+ Directory.CreateDirectory(absoluteTargetPath);
+ }
}
catch (Exception ex)
{
EventMetadata metadata = new EventMetadata();
- metadata.Add("Operation", "CreateDirectory");
+ metadata.Add("Operation", treeOp.SourcePath != null ? "RenameDirectory" : "CreateDirectory");
metadata.Add(nameof(treeOp.TargetPath), absoluteTargetPath);
+ if (treeOp.SourcePath != null)
+ {
+ metadata.Add(nameof(treeOp.SourcePath), treeOp.SourcePath);
+ }
+
this.tracer.RelatedError(metadata, ex.Message);
this.HasFailures = true;
}
@@ -222,6 +234,62 @@ private void HandleAllDirectoryOperations()
}
}
+ ///
+ /// Apply a case-only directory rename produced by DiffHelper, where
+ /// .SourcePath carries the old casing and
+ /// is the new (post-rename) absolute path.
+ ///
+ /// Directory.Move throws IOException for case-only renames on Windows, so the
+ /// rename is performed in two steps through a temporary name. If the second
+ /// move fails the directory is moved back to the original casing so a retry
+ /// sees a consistent working tree.
+ ///
+ /// If the source directory is missing it usually means an outer parent rename
+ /// has already moved the children into place (Windows preserves child casing
+ /// through a parent rename when the children's tree SHAs were unchanged); the
+ /// fallback creates the target directory so the operation is idempotent.
+ /// Exceptions propagate to the caller's existing error handler.
+ ///
+ private void ApplyCaseOnlyDirectoryRename(DiffTreeResult treeOp, string absoluteTargetPath)
+ {
+ string absoluteSourcePath = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, treeOp.SourcePath);
+ if (!Directory.Exists(absoluteSourcePath))
+ {
+ Directory.CreateDirectory(absoluteTargetPath);
+ return;
+ }
+
+ string trimmedSourcePath = absoluteSourcePath.TrimEnd(Path.DirectorySeparatorChar);
+ string trimmedTargetPath = absoluteTargetPath.TrimEnd(Path.DirectorySeparatorChar);
+ string tempPath = trimmedTargetPath + "_caseRename_" + Guid.NewGuid().ToString("N");
+
+ Directory.Move(trimmedSourcePath, tempPath);
+ try
+ {
+ Directory.Move(tempPath, trimmedTargetPath);
+ }
+ catch
+ {
+ // The first move succeeded but the second failed. Try to restore the
+ // original casing so a retry starts from a consistent state; if
+ // restoration also fails, the outer catch will log the original
+ // exception and the temp directory will be left behind for manual
+ // recovery.
+ if (Directory.Exists(tempPath) && !Directory.Exists(trimmedSourcePath))
+ {
+ try
+ {
+ Directory.Move(tempPath, trimmedSourcePath);
+ }
+ catch
+ {
+ }
+ }
+
+ throw;
+ }
+ }
+
private void HandleAllFileDeleteOperations()
{
string path;
diff --git a/GVFS/GVFS.Common/Git/DiffTreeResult.cs b/GVFS/GVFS.Common/Git/DiffTreeResult.cs
index 85eaaf559..abafa4597 100644
--- a/GVFS/GVFS.Common/Git/DiffTreeResult.cs
+++ b/GVFS/GVFS.Common/Git/DiffTreeResult.cs
@@ -38,6 +38,18 @@ public enum Operations
public ushort SourceMode { get; set; }
public ushort TargetMode { get; set; }
+ ///
+ /// Old-cased path of a case-only directory rename, set by DiffHelper when
+ /// collapsing a Delete+Add pair under the case-insensitive comparer. When
+ /// non-null the operation represents a rename from SourcePath to TargetPath
+ /// and consumers (currently CheckoutStage) must rename the directory on
+ /// disk instead of treating the operation as a plain Add. Always null for
+ /// file operations, Modify, Delete, and non-rename Add entries. The setter
+ /// is intentionally restricted to the assembly so only the parser can
+ /// produce this annotation.
+ ///
+ public string SourcePath { get; internal set; }
+
public static DiffTreeResult ParseFromDiffTreeLine(string line)
{
if (string.IsNullOrEmpty(line))
diff --git a/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs b/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs
index f55d47d5f..386e4c214 100644
--- a/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs
+++ b/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs
@@ -16,13 +16,28 @@ public class DiffHelper
private HashSet exactFileList;
private List patternList;
private List folderList;
- private HashSet filesAdded = new HashSet(GVFSPlatform.Instance.Constants.PathComparer);
-
- private HashSet stagedDirectoryOperations = new HashSet(new DiffTreeByNameComparer());
- private HashSet stagedFileDeletes = new HashSet(GVFSPlatform.Instance.Constants.PathComparer);
+ // The staged collections are keyed by the case-insensitive PathComparer on
+ // case-insensitive platforms so that two paths differing only in case map to
+ // the same entry. The dictionary value stores the original casing of the
+ // first path inserted, which case-rename detection compares against the
+ // incoming path to decide whether the collision is a rename or a true
+ // duplicate. Dictionary lookups keep this O(1); a HashSet would force a
+ // linear scan to recover the stored casing.
+ private Dictionary filesAdded = new Dictionary(GVFSPlatform.Instance.Constants.PathComparer);
+
+ private Dictionary stagedDirectoryOperations = new Dictionary(GVFSPlatform.Instance.Constants.PathComparer);
+ private Dictionary stagedFileDeletes = new Dictionary(GVFSPlatform.Instance.Constants.PathComparer);
+
+ // Holds the old-cased paths of directories whose Delete was collapsed into an
+ // Add via case-only rename detection. FlushStagedQueues consults this set to
+ // suppress child operations (file deletes and child directory Adds) whose
+ // parent was case-renamed — those children are carried by the parent rename
+ // on disk and do not need separate operations.
+ private HashSet directoriesReplacedByCaseRename = new HashSet(GVFSPlatform.Instance.Constants.PathComparer);
private Enlistment enlistment;
private GitProcess git;
+ private bool diffPerformed;
public DiffHelper(ITracer tracer, Enlistment enlistment, IEnumerable fileList, IEnumerable folderList, bool includeSymLinks)
: this(tracer, enlistment, new GitProcess(enlistment), fileList, folderList, includeSymLinks)
@@ -93,6 +108,7 @@ public void PerformDiff(string targetCommitSha)
public void PerformDiff(string sourceTreeSha, string targetTreeSha)
{
+ this.EnsureSingleUse();
EventMetadata metadata = new EventMetadata();
metadata.Add("TargetTreeSha", targetTreeSha);
metadata.Add("HeadTreeSha", sourceTreeSha);
@@ -150,6 +166,7 @@ public void PerformDiff(string sourceTreeSha, string targetTreeSha)
public void ParseDiffFile(string filename)
{
+ this.EnsureSingleUse();
using (ITracer activity = this.tracer.StartActivity("PerformDiff", EventLevel.Informational))
{
using (StreamReader file = new StreamReader(File.OpenRead(filename)))
@@ -170,22 +187,33 @@ private void FlushStagedQueues()
{
HashSet deletedDirectories =
new HashSet(
- this.stagedDirectoryOperations
+ this.stagedDirectoryOperations.Values
.Where(d => d.Operation == DiffTreeResult.Operations.Delete)
.Select(d => d.TargetPath.TrimEnd(Path.DirectorySeparatorChar)),
GVFSPlatform.Instance.Constants.PathComparer);
- foreach (DiffTreeResult result in this.stagedDirectoryOperations)
+ // Also include directories that were deleted as part of case-only renames.
+ // These were replaced by Adds in stagedDirectoryOperations but their children's
+ // file deletes should still be filtered out (the parent rename handles them).
+ deletedDirectories.UnionWith(this.directoriesReplacedByCaseRename);
+
+ foreach (DiffTreeResult result in this.stagedDirectoryOperations.Values)
{
string parentPath = Path.GetDirectoryName(result.TargetPath.TrimEnd(Path.DirectorySeparatorChar));
if (deletedDirectories.Contains(parentPath))
{
if (result.Operation != DiffTreeResult.Operations.Delete)
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(result.TargetPath), result.TargetPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, "An operation is intended to go inside of a deleted folder");
- activity.RelatedError("InvalidOperation", metadata);
+ // For case renames, child directory Adds inside a case-renamed parent
+ // are expected. The parent rename will handle moving the children.
+ // Only warn if the parent is truly deleted (not case-renamed).
+ if (!this.directoriesReplacedByCaseRename.Contains(parentPath))
+ {
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add(nameof(result.TargetPath), result.TargetPath);
+ metadata.Add(TracingConstants.MessageKey.WarningMessage, "An operation is intended to go inside of a deleted folder");
+ activity.RelatedError("InvalidOperation", metadata);
+ }
}
}
else
@@ -194,7 +222,7 @@ private void FlushStagedQueues()
}
}
- foreach (string filePath in this.stagedFileDeletes)
+ foreach (string filePath in this.stagedFileDeletes.Values)
{
string parentPath = Path.GetDirectoryName(filePath);
if (!deletedDirectories.Contains(parentPath))
@@ -222,16 +250,16 @@ private void EnqueueOperationsFromLsTreeLine(ITracer activity, string line)
if (result.TargetIsDirectory)
{
- if (!this.stagedDirectoryOperations.Add(result))
+ if (!this.stagedDirectoryOperations.TryAdd(result.TargetPath, result))
{
EventMetadata metadata = new EventMetadata();
metadata.Add(nameof(result.TargetPath), result.TargetPath);
metadata.Add(TracingConstants.MessageKey.WarningMessage, "File exists in tree with two different cases. Taking the last one.");
this.tracer.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata);
- // Since we match only on filename, re-adding is the easiest way to update the set.
- this.stagedDirectoryOperations.Remove(result);
- this.stagedDirectoryOperations.Add(result);
+ // Two entries in the same tree differ only in case. Keep the
+ // last one parsed, matching the historical HashSet behavior.
+ this.stagedDirectoryOperations[result.TargetPath] = result;
}
}
else
@@ -274,27 +302,52 @@ private void EnqueueOperationsFromDiffTreeLine(ITracer activity, string line)
switch (result.Operation)
{
case DiffTreeResult.Operations.Delete:
- if (!this.stagedDirectoryOperations.Add(result))
+ if (!this.stagedDirectoryOperations.TryAdd(result.TargetPath, result))
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(result.TargetPath), result.TargetPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory.");
- activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata);
+ // A directory with the same (case-insensitive) path was already
+ // staged as an Add. This is a case-only rename where diff-tree
+ // emitted the Add before the Delete. Either emit order is possible
+ // because git diff-tree compares tree entries by byte order, so
+ // whichever casing sorts lower appears first.
+ //
+ // Annotate the staged Add with the old-cased path so CheckoutStage
+ // can perform the rename. Keep the Add — never the Delete — to
+ // avoid deleting a folder out from under ourselves.
+ DiffTreeResult existingOp = this.stagedDirectoryOperations[result.TargetPath];
+ if (!existingOp.TargetPath.Equals(result.TargetPath, StringComparison.Ordinal))
+ {
+ existingOp.SourcePath = result.TargetPath;
+ this.directoriesReplacedByCaseRename.Add(result.TargetPath.TrimEnd(Path.DirectorySeparatorChar));
+ TraceCaseRename(activity, "Directory", oldPath: result.TargetPath, newPath: existingOp.TargetPath);
+ }
+ else
+ {
+ TraceDuplicate(activity, "Directory", "Delete", result.TargetPath);
+ }
}
break;
case DiffTreeResult.Operations.Add:
case DiffTreeResult.Operations.Modify:
- if (!this.stagedDirectoryOperations.Add(result))
+ if (!this.stagedDirectoryOperations.TryAdd(result.TargetPath, result))
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(result.TargetPath), result.TargetPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory.");
- activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata);
+ // A directory with the same path (case-insensitive) was already staged.
+ // This is a case-only rename: the Delete was staged first, now the Add arrives.
+ DiffTreeResult existingOp = this.stagedDirectoryOperations[result.TargetPath];
+ if (!existingOp.TargetPath.Equals(result.TargetPath, StringComparison.Ordinal))
+ {
+ // Case-only rename: store the old-cased path so CheckoutStage can rename the directory
+ result.SourcePath = existingOp.TargetPath;
+ this.directoriesReplacedByCaseRename.Add(existingOp.TargetPath.TrimEnd(Path.DirectorySeparatorChar));
+ TraceCaseRename(activity, "Directory", oldPath: existingOp.TargetPath, newPath: result.TargetPath);
+ }
+ else
+ {
+ TraceDuplicate(activity, "Directory", result.Operation.ToString(), result.TargetPath);
+ }
// Replace the delete with the add to make sure we don't delete a folder from under ourselves
- this.stagedDirectoryOperations.Remove(result);
- this.stagedDirectoryOperations.Add(result);
+ this.stagedDirectoryOperations[result.TargetPath] = result;
}
break;
@@ -357,17 +410,24 @@ private bool ShouldIncludeResult(DiffTreeResult blobAdd)
private void EnqueueFileDeleteOperation(ITracer activity, string targetPath)
{
- if (this.filesAdded.Contains(targetPath))
+ // Use case-sensitive check: if the exact same path (same casing) was already added,
+ // this is a true duplicate, not a case rename. Skip it.
+ // But if it matches case-insensitively only, this is a case rename — allow the delete through
+ // so the old-cased file is removed before the new-cased file is written.
+ if (this.filesAdded.TryGetValue(targetPath, out string existingAddedPath))
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(targetPath), targetPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory.");
- activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata);
+ if (existingAddedPath.Equals(targetPath, StringComparison.Ordinal))
+ {
+ TraceDuplicate(activity, "File", "Delete", targetPath);
+ return;
+ }
- return;
+ TraceCaseRename(activity, "File", oldPath: targetPath, newPath: existingAddedPath);
}
- this.stagedFileDeletes.Add(targetPath);
+ // Either no prior add, or a case-only difference: allow the delete to be
+ // staged so the old casing is removed from disk before the new add lands.
+ this.stagedFileDeletes.TryAdd(targetPath, targetPath);
}
///
@@ -377,7 +437,7 @@ private void EnqueueFileAddOperation(ITracer activity, DiffTreeResult operation)
{
// Each filepath should be unique according to GVFSPlatform.Instance.Constants.PathComparer.
// If there are duplicates, only the last parsed one should remain.
- if (!this.filesAdded.Add(operation.TargetPath))
+ if (!this.filesAdded.TryAdd(operation.TargetPath, operation.TargetPath))
{
foreach (KeyValuePair> kvp in this.FileAddOperations)
{
@@ -389,12 +449,21 @@ private void EnqueueFileAddOperation(ITracer activity, DiffTreeResult operation)
}
}
- if (this.stagedFileDeletes.Remove(operation.TargetPath))
+ // If a delete is already staged for the same path under the case-insensitive
+ // comparer, decide whether this is a true duplicate (same casing → drop the
+ // delete) or a case-only rename (different casing → keep the delete so the
+ // old casing is removed from disk before the new add lands).
+ if (this.stagedFileDeletes.TryGetValue(operation.TargetPath, out string existingDeletePath))
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(operation.TargetPath), operation.TargetPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory.");
- activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata);
+ if (existingDeletePath.Equals(operation.TargetPath, StringComparison.Ordinal))
+ {
+ TraceDuplicate(activity, "File", "Add", operation.TargetPath);
+ this.stagedFileDeletes.Remove(operation.TargetPath);
+ }
+ else
+ {
+ TraceCaseRename(activity, "File", oldPath: existingDeletePath, newPath: operation.TargetPath);
+ }
}
this.FileAddOperations.AddOrUpdate(
@@ -409,31 +478,42 @@ private void EnqueueFileAddOperation(ITracer activity, DiffTreeResult operation)
this.RequiredBlobs.Add(operation.TargetSha);
}
- private class DiffTreeByNameComparer : IEqualityComparer
+ private static void TraceCaseRename(ITracer activity, string kind, string oldPath, string newPath)
{
- public bool Equals(DiffTreeResult x, DiffTreeResult y)
- {
- if (x.TargetPath != null)
- {
- if (y.TargetPath != null)
- {
- return x.TargetPath.Equals(y.TargetPath, GVFSPlatform.Instance.Constants.PathComparison);
- }
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add("Kind", kind);
+ metadata.Add("OldPath", oldPath);
+ metadata.Add("NewPath", newPath);
+ activity.RelatedEvent(EventLevel.Informational, "CaseRename", metadata);
+ }
- return false;
- }
- else
- {
- // both null means they're equal
- return y.TargetPath == null;
- }
- }
+ private static void TraceDuplicate(ITracer activity, string kind, string operation, string targetPath)
+ {
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add("Kind", kind);
+ metadata.Add("Operation", operation);
+ metadata.Add(nameof(targetPath), targetPath);
+ metadata.Add(TracingConstants.MessageKey.WarningMessage, "Duplicate diff entry for the same path; later occurrence collapsed into earlier.");
+ activity.RelatedEvent(EventLevel.Warning, "DuplicateDiffEntry", metadata);
+ }
- public int GetHashCode(DiffTreeResult obj)
+ private void EnsureSingleUse()
+ {
+ // The output collections — DirectoryOperations, FileDeleteOperations,
+ // FileAddOperations, RequiredBlobs — are populated incrementally and
+ // RequiredBlobs.CompleteAdding() is called at the end of FlushStagedQueues.
+ // A second call would attempt to add to a completed BlockingCollection
+ // and throw deep in the parsing path, leaving partial output. The class
+ // is therefore intended to be single-use; instantiate a new DiffHelper
+ // for each diff.
+ if (this.diffPerformed)
{
- return obj.TargetPath != null ?
- GVFSPlatform.Instance.Constants.PathComparer.GetHashCode(obj.TargetPath) : 0;
+ throw new InvalidOperationException(
+ $"{nameof(DiffHelper)} has already produced a diff and cannot be reused. Construct a new instance.");
}
+
+ this.diffPerformed = true;
}
+
}
}
diff --git a/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs b/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs
index 2a0989327..ad8db56cb 100644
--- a/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs
+++ b/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs
@@ -320,9 +320,9 @@ public void SuccessfullyChecksOutCaseChanges()
try
{
- // Ignore case differences on case-insensitive filesystems
+ // Verify FastFetch produces correct casing matching git checkout, including on case-insensitive filesystems
this.fastFetchRepoRoot.ShouldBeADirectory(FileSystemRunner.DefaultRunner)
- .WithDeepStructure(FileSystemRunner.DefaultRunner, this.fastFetchControlRoot, ignoreCase: !FileSystemHelpers.CaseSensitiveFileSystem);
+ .WithDeepStructure(FileSystemRunner.DefaultRunner, this.fastFetchControlRoot, ignoreCase: false);
}
finally
{
diff --git a/GVFS/GVFS.UnitTests/Data/caseChangeAddFirst.txt b/GVFS/GVFS.UnitTests/Data/caseChangeAddFirst.txt
new file mode 100644
index 000000000..a78c15071
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Data/caseChangeAddFirst.txt
@@ -0,0 +1,10 @@
+:000000 040000 0000000000000000000000000000000000000000 d813c8227132c3bf73c013f8913f207b4876b2bf A GVFLT_MultiThreadTest
+:000000 040000 0000000000000000000000000000000000000000 1260ecb71f2be8eb92ea904c6dffa3e40eaaf1bf A GVFLT_MultiThreadTest/OpenForReadsSameTime
+:000000 100644 0000000000000000000000000000000000000000 eabe8d5ec569cc7e199e77411ad935f101414032 A GVFLT_MultiThreadTest/OpenForReadsSameTime/test
+:000000 040000 0000000000000000000000000000000000000000 1260ecb71f2be8eb92ea904c6dffa3e40eaaf1bf A GVFLT_MultiThreadTest/OpenForWritesSameTime
+:000000 100644 0000000000000000000000000000000000000000 eabe8d5ec569cc7e199e77411ad935f101414032 A GVFLT_MultiThreadTest/OpenForWritesSameTime/test
+:040000 000000 d813c8227132c3bf73c013f8913f207b4876b2bf 0000000000000000000000000000000000000000 D GVFlt_MultiThreadTest
+:040000 000000 1260ecb71f2be8eb92ea904c6dffa3e40eaaf1bf 0000000000000000000000000000000000000000 D GVFlt_MultiThreadTest/OpenForReadsSameTime
+:100644 000000 eabe8d5ec569cc7e199e77411ad935f101414032 0000000000000000000000000000000000000000 D GVFlt_MultiThreadTest/OpenForReadsSameTime/test
+:040000 000000 1260ecb71f2be8eb92ea904c6dffa3e40eaaf1bf 0000000000000000000000000000000000000000 D GVFlt_MultiThreadTest/OpenForWritesSameTime
+:100644 000000 eabe8d5ec569cc7e199e77411ad935f101414032 0000000000000000000000000000000000000000 D GVFlt_MultiThreadTest/OpenForWritesSameTime/test
diff --git a/GVFS/GVFS.UnitTests/Data/fileCaseChange.txt b/GVFS/GVFS.UnitTests/Data/fileCaseChange.txt
new file mode 100644
index 000000000..e4b68d1dd
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Data/fileCaseChange.txt
@@ -0,0 +1,2 @@
+:100644 000000 eabe8d5ec569cc7e199e77411ad935f101414032 0000000000000000000000000000000000000000 D foo.txt
+:000000 100644 0000000000000000000000000000000000000000 b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0 A FOO.txt
diff --git a/GVFS/GVFS.UnitTests/Data/nestedCaseChange.txt b/GVFS/GVFS.UnitTests/Data/nestedCaseChange.txt
new file mode 100644
index 000000000..751384645
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Data/nestedCaseChange.txt
@@ -0,0 +1,6 @@
+:040000 000000 d813c8227132c3bf73c013f8913f207b4876b2bf 0000000000000000000000000000000000000000 D Outer
+:040000 000000 1260ecb71f2be8eb92ea904c6dffa3e40eaaf1bf 0000000000000000000000000000000000000000 D Outer/Inner
+:100644 000000 eabe8d5ec569cc7e199e77411ad935f101414032 0000000000000000000000000000000000000000 D Outer/Inner/test
+:000000 040000 0000000000000000000000000000000000000000 d813c8227132c3bf73c013f8913f207b4876b2bf A outer
+:000000 040000 0000000000000000000000000000000000000000 1260ecb71f2be8eb92ea904c6dffa3e40eaaf1bf A outer/inner
+:000000 100644 0000000000000000000000000000000000000000 eabe8d5ec569cc7e199e77411ad935f101414032 A outer/inner/test
diff --git a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj
index 0df5f3263..0517e82f1 100644
--- a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj
+++ b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj
@@ -39,6 +39,15 @@
Always
+
+ Always
+
+
+ Always
+
+
+ Always
+
Always
diff --git a/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs b/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs
index 0799ad11b..03954c1ad 100644
--- a/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs
+++ b/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs
@@ -16,41 +16,41 @@ namespace GVFS.UnitTests.Prefetch
{
[TestFixtureSource(typeof(DataSources), nameof(DataSources.AllBools))]
public class DiffHelperTests
- {
+ {
public DiffHelperTests(bool symLinkSupport)
{
this.IncludeSymLinks = symLinkSupport;
}
-
+
public bool IncludeSymLinks { get; set; }
-
- // Make two commits. The first should look like this:
- // recursiveDelete
- // recursiveDelete/subfolder
- // recursiveDelete/subfolder/childFile.txt
- // fileToBecomeFolder
- // fileToDelete.txt
- // fileToEdit.txt
- // fileToRename.txt
- // fileToRenameEdit.txt
- // folderToBeFile
- // folderToBeFile/childFile.txt
- // folderToDelete
- // folderToDelete/childFile.txt
- // folderToEdit
- // folderToEdit/childFile.txt
- // folderToRename
- // folderToRename/childFile.txt
- // symLinkToBeCreated.txt
- //
- // The second should follow the action indicated by the file/folder name:
- // eg. recursiveDelete should run "rmdir /s/q recursiveDelete"
- // eg. folderToBeFile should be deleted and replaced with a file of the same name
- // Note that each childFile.txt should have unique contents, but is only a placeholder to force git to add a folder.
- //
- // Then to generate the diffs, run:
- // git diff-tree -r -t Head~1 Head > forward.txt
- // git diff-tree -r -t Head Head ~1 > backward.txt
+
+ // Make two commits. The first should look like this:
+ // recursiveDelete
+ // recursiveDelete/subfolder
+ // recursiveDelete/subfolder/childFile.txt
+ // fileToBecomeFolder
+ // fileToDelete.txt
+ // fileToEdit.txt
+ // fileToRename.txt
+ // fileToRenameEdit.txt
+ // folderToBeFile
+ // folderToBeFile/childFile.txt
+ // folderToDelete
+ // folderToDelete/childFile.txt
+ // folderToEdit
+ // folderToEdit/childFile.txt
+ // folderToRename
+ // folderToRename/childFile.txt
+ // symLinkToBeCreated.txt
+ //
+ // The second should follow the action indicated by the file/folder name:
+ // eg. recursiveDelete should run "rmdir /s/q recursiveDelete"
+ // eg. folderToBeFile should be deleted and replaced with a file of the same name
+ // Note that each childFile.txt should have unique contents, but is only a placeholder to force git to add a folder.
+ //
+ // Then to generate the diffs, run:
+ // git diff-tree -r -t Head~1 Head > forward.txt
+ // git diff-tree -r -t Head Head ~1 > backward.txt
[TestCase]
public void CanParseDiffForwards()
{
@@ -114,13 +114,131 @@ public void ParsesCaseChangesAsAdds()
diffBackwards.RequiredBlobs.Count.ShouldEqual(2);
diffBackwards.FileAddOperations.Sum(list => list.Value.Count).ShouldEqual(2);
+ // File deletes inside case-renamed directories are filtered out by FlushStagedQueues
+ diffBackwards.FileDeleteOperations.Count.ShouldEqual(0);
+
+ // File deletes are now staged (not suppressed) so FlushStagedQueues can filter them properly
+ diffBackwards.TotalFileDeletes.ShouldEqual(2);
+
+ diffBackwards.DirectoryOperations.ShouldNotContain(entry => entry.Operation == DiffTreeResult.Operations.Delete);
+
+ // Only the top-level directory rename is enqueued; children are filtered because
+ // the parent rename moves them automatically
+ diffBackwards.DirectoryOperations.Count.ShouldEqual(1);
+
+ // The enqueued directory operation should carry the old-cased path for the rename
+ DiffTreeResult dirOp = diffBackwards.DirectoryOperations.First();
+ dirOp.SourcePath.ShouldNotBeNull();
+ Assert.AreEqual("GVFLT_MultiThreadTest" + Path.DirectorySeparatorChar, dirOp.SourcePath);
+ Assert.AreEqual("GVFlt_MultiThreadTest" + Path.DirectorySeparatorChar, dirOp.TargetPath);
+
+ diffBackwards.TotalDirectoryOperations.ShouldEqual(3);
+ }
+
+ // Mirror of ParsesCaseChangesAsAdds for the opposite emit order: the Add
+ // lines appear in the diff before the Delete lines. This happens when the
+ // new (target) casing sorts byte-wise before the old (source) casing, e.g.
+ // "GVFlt_*" -> "GVFLT_*" (uppercase 'L' < lowercase 'l').
+ //
+ // The parser must produce the same staged state regardless of which
+ // ordering the diff-tree output uses.
+ [TestCase]
+ [Category(CategoryConstants.CaseInsensitiveFileSystemOnly)]
+ public void ParsesCaseChangesWhenAddPrecedesDelete()
+ {
+ MockTracer tracer = new MockTracer();
+ DiffHelper diffBackwards = new DiffHelper(tracer, new Mock.Common.MockGVFSEnlistment(), new List(), new List(), includeSymLinks: this.IncludeSymLinks);
+ diffBackwards.ParseDiffFile(GetDataPath("caseChangeAddFirst.txt"));
+
+ diffBackwards.RequiredBlobs.Count.ShouldEqual(2);
+ diffBackwards.FileAddOperations.Sum(list => list.Value.Count).ShouldEqual(2);
+
+ // File deletes inside case-renamed directories are filtered out by FlushStagedQueues
diffBackwards.FileDeleteOperations.Count.ShouldEqual(0);
- diffBackwards.TotalFileDeletes.ShouldEqual(0);
+
+ // File deletes are staged (not suppressed) so FlushStagedQueues can filter them properly
+ diffBackwards.TotalFileDeletes.ShouldEqual(2);
diffBackwards.DirectoryOperations.ShouldNotContain(entry => entry.Operation == DiffTreeResult.Operations.Delete);
+
+ // Only the top-level directory rename is enqueued; children are filtered because
+ // the parent rename moves them automatically
+ diffBackwards.DirectoryOperations.Count.ShouldEqual(1);
+
+ // The enqueued directory operation should carry the old-cased path for the rename
+ // even though the Add was staged first and the Delete arrived second.
+ DiffTreeResult dirOp = diffBackwards.DirectoryOperations.First();
+ dirOp.SourcePath.ShouldNotBeNull();
+ Assert.AreEqual("GVFlt_MultiThreadTest" + Path.DirectorySeparatorChar, dirOp.SourcePath);
+ Assert.AreEqual("GVFLT_MultiThreadTest" + Path.DirectorySeparatorChar, dirOp.TargetPath);
+
diffBackwards.TotalDirectoryOperations.ShouldEqual(3);
}
+ // File-level case rename ("foo.txt" -> "FOO.txt") with no directory case
+ // changes. The fixture emits a Delete for the old casing followed by an
+ // Add for the new casing; DiffHelper should stage both — the delete
+ // removes the old-cased file from disk, the add writes the new-cased
+ // file — so FlushStagedQueues hands both to the checkout stage.
+ [TestCase]
+ [Category(CategoryConstants.CaseInsensitiveFileSystemOnly)]
+ public void ParsesFileOnlyCaseRename()
+ {
+ MockTracer tracer = new MockTracer();
+ DiffHelper diff = new DiffHelper(tracer, new Mock.Common.MockGVFSEnlistment(), new List(), new List(), includeSymLinks: this.IncludeSymLinks);
+ diff.ParseDiffFile(GetDataPath("fileCaseChange.txt"));
+
+ diff.RequiredBlobs.Count.ShouldEqual(1);
+ diff.FileAddOperations.Sum(list => list.Value.Count).ShouldEqual(1);
+ diff.FileDeleteOperations.Count.ShouldEqual(1);
+ diff.TotalFileDeletes.ShouldEqual(1);
+ diff.TotalDirectoryOperations.ShouldEqual(0);
+ diff.DirectoryOperations.Count.ShouldEqual(0);
+
+ // The delete keeps the old casing; the add carries the new casing.
+ string deletedPath = diff.FileDeleteOperations.ToArray()[0];
+ Assert.AreEqual("foo.txt", deletedPath);
+
+ string addedPath = diff.FileAddOperations.First().Value.First().Path;
+ Assert.AreEqual("FOO.txt", addedPath);
+ }
+
+ // Nested case rename: both an outer directory ("Outer" -> "outer") and
+ // an inner directory inside it ("Outer/Inner" -> "outer/inner") change
+ // casing in the same diff. Only the outermost rename should be enqueued
+ // for the checkout stage — the inner rename's parent path is in
+ // directoriesReplacedByCaseRename, so FlushStagedQueues suppresses it
+ // (the outer rename moves the whole subtree on disk and Windows
+ // preserves child casing through the move). The file inside the inner
+ // directory is similarly suppressed at the file-delete stage.
+ [TestCase]
+ [Category(CategoryConstants.CaseInsensitiveFileSystemOnly)]
+ public void ParsesNestedCaseChanges()
+ {
+ MockTracer tracer = new MockTracer();
+ DiffHelper diff = new DiffHelper(tracer, new Mock.Common.MockGVFSEnlistment(), new List(), new List(), includeSymLinks: this.IncludeSymLinks);
+ diff.ParseDiffFile(GetDataPath("nestedCaseChange.txt"));
+
+ diff.RequiredBlobs.Count.ShouldEqual(1);
+ diff.FileAddOperations.Sum(list => list.Value.Count).ShouldEqual(1);
+
+ // File delete inside the case-renamed parent is filtered out.
+ diff.FileDeleteOperations.Count.ShouldEqual(0);
+ diff.TotalFileDeletes.ShouldEqual(1);
+
+ // Two directory case-renames were collapsed into Adds in the
+ // staging dictionary; only the outermost survives the parent-path
+ // filter in FlushStagedQueues.
+ diff.TotalDirectoryOperations.ShouldEqual(2);
+ diff.DirectoryOperations.Count.ShouldEqual(1);
+ diff.DirectoryOperations.ShouldNotContain(entry => entry.Operation == DiffTreeResult.Operations.Delete);
+
+ DiffTreeResult outerOp = diff.DirectoryOperations.First();
+ outerOp.SourcePath.ShouldNotBeNull();
+ Assert.AreEqual("Outer" + Path.DirectorySeparatorChar, outerOp.SourcePath);
+ Assert.AreEqual("outer" + Path.DirectorySeparatorChar, outerOp.TargetPath);
+ }
+
// Delete a folder with two sub folders each with a single file
// Readd it with a different casing and same contents
[TestCase]
@@ -142,6 +260,16 @@ public void ParsesCaseChangesAsRenames()
diffBackwards.TotalDirectoryOperations.ShouldEqual(6);
}
+ [TestCase]
+ public void DiffHelperThrowsOnReuse()
+ {
+ MockTracer tracer = new MockTracer();
+ DiffHelper diff = new DiffHelper(tracer, new Mock.Common.MockGVFSEnlistment(), new List(), new List(), includeSymLinks: this.IncludeSymLinks);
+ diff.ParseDiffFile(GetDataPath("forward.txt"));
+
+ Assert.Throws(() => diff.ParseDiffFile(GetDataPath("forward.txt")));
+ }
+
[TestCase]
public void DetectsFailuresInDiffTree()
{
@@ -172,4 +300,4 @@ private static string GetDataPath(string fileName)
return Path.Combine(workingDirectory, "Data", fileName);
}
}
-}
+}