From 241d559398896e606e649da9b29dbb0450078cd1 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 22 Aug 2026 10:23:41 +1000 Subject: [PATCH] Fold tracked keys only where the file system does TrackedKeys lower-cased the path unconditionally. InlineKey folds only on Windows and macOS, and carries the reason: on Linux two paths differing only in case are two files, and giving them one key means the second takes over the first's entry. Tracked moves and deletes ride the same listing and had the opposite rule. On Linux two received files differing only in case - value=a and value=A from one parameterised test - shared a move key, and ViewerSession.EnqueueTracked keeps one entry per key, so the earlier pending file was dropped without a word. Both now go through InlineKey.FoldPath, so there is one answer to "when are two paths one file" rather than two that disagree. The tests assert the two agree rather than asserting a platform's answer, so they hold wherever they run - but they can only fail on Linux, where the two rules differ. The Ubuntu job is what enforces this; it passes on Windows either way. --- src/DiffEngine.Tests/TrackedKeyCaseTests.cs | 37 +++++++++++++++++++++ src/DiffEngine/Inline/InlineKey.cs | 9 ++++- src/DiffEngine/Protocol/TrackedKeys.cs | 14 +++++--- 3 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 src/DiffEngine.Tests/TrackedKeyCaseTests.cs diff --git a/src/DiffEngine.Tests/TrackedKeyCaseTests.cs b/src/DiffEngine.Tests/TrackedKeyCaseTests.cs new file mode 100644 index 00000000..ab42e2cf --- /dev/null +++ b/src/DiffEngine.Tests/TrackedKeyCaseTests.cs @@ -0,0 +1,37 @@ +/// +/// Tracked keys fold case exactly where inline keys do. +/// +/// TrackedKeys lower-cased unconditionally while InlineKey folds only on Windows and macOS, and +/// InlineKey says why: on Linux two paths differing only in case are two files, and giving them +/// one key means the second takes over the first's entry. For a tracked move that shows up as +/// ViewerSession.EnqueueTracked dropping one of them - a parameterised test with value=a and +/// value=A silently loses a pending file. +/// +/// +/// Asserted as agreement between the two rather than as a platform's answer, so it holds wherever +/// it runs. +/// +/// +public class TrackedKeyCaseTests +{ + const string upper = "/repo/tests/Value.txt"; + const string lower = "/repo/tests/value.txt"; + + [Test] + public async Task MoveKeysFoldWithInlineKeys() + { + var inlineFolds = InlineKey.For(upper, 1) == InlineKey.For(lower, 1); + + await Assert.That(TrackedKeys.ForMove(upper) == TrackedKeys.ForMove(lower)) + .IsEqualTo(inlineFolds); + } + + [Test] + public async Task DeleteKeysFoldWithInlineKeys() + { + var inlineFolds = InlineKey.For(upper, 1) == InlineKey.For(lower, 1); + + await Assert.That(TrackedKeys.ForDelete(upper) == TrackedKeys.ForDelete(lower)) + .IsEqualTo(inlineFolds); + } +} diff --git a/src/DiffEngine/Inline/InlineKey.cs b/src/DiffEngine/Inline/InlineKey.cs index 465d3e5c..28e49481 100644 --- a/src/DiffEngine/Inline/InlineKey.cs +++ b/src/DiffEngine/Inline/InlineKey.cs @@ -22,7 +22,14 @@ public static class InlineKey /// /// public static string For(string sourceFile, int line) => - $"{(caseInsensitivePaths ? sourceFile.ToLowerInvariant() : sourceFile)}|{line}"; + $"{FoldPath(sourceFile)}|{line}"; + + /// + /// A path folded the way this machine's file system folds it, so anything else keyed by path + /// agrees with about when two paths are one file. + /// + internal static string FoldPath(string path) => + caseInsensitivePaths ? path.ToLowerInvariant() : path; static readonly bool caseInsensitivePaths = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || diff --git a/src/DiffEngine/Protocol/TrackedKeys.cs b/src/DiffEngine/Protocol/TrackedKeys.cs index ce156c93..cfab20c9 100644 --- a/src/DiffEngine/Protocol/TrackedKeys.cs +++ b/src/DiffEngine/Protocol/TrackedKeys.cs @@ -2,9 +2,15 @@ namespace DiffEngine; /// /// Keys for the tray's tracked moves and deletes when they ride the inline listing. Prefixed so -/// they cannot collide with an inline key — a lower-cased path plus "|" plus a line number, where -/// a Windows path cannot put a colon at index four — and so only the tray has to know which +/// they cannot collide with an inline key — a folded path plus "|" plus a line number, where a +/// Windows path cannot put a colon at index four — and so only the tray has to know which /// collection a key belongs to. Every other process echoes keys back opaquely. +/// +/// Folded through , which folds only where the file system does. +/// Lower-casing unconditionally made two Linux files differing only in case - which is two files, +/// not one - share a key, and the viewer's enqueue drops the earlier of two entries with the same +/// key. A parameterised test producing value=a and value=A lost one of them. +/// /// static class TrackedKeys { @@ -13,10 +19,10 @@ static class TrackedKeys public const string DeletePrefix = "delete:"; public static string ForMove(string temp) => - MovePrefix + temp.ToLowerInvariant(); + MovePrefix + InlineKey.FoldPath(temp); public static string ForDelete(string file) => - DeletePrefix + file.ToLowerInvariant(); + DeletePrefix + InlineKey.FoldPath(file); public static bool IsTracked(string key) => key.StartsWith(MovePrefix, StringComparison.Ordinal) ||