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); } } -} +}