From 0979438b9e25ba8dbecadc7eb5d9ac417fc5a61d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:25:28 +0000 Subject: [PATCH] Count a hardlinked file once in Stats [patch] Stats took its headline numbers from the uncollapsed hash results, so a file and its hardlink showed as one duplicate file that Scan, DryRun and Deduplicate never act on, and the file's size was counted once per path. Duplicate files now comes from the collapsed groups, as the extension breakdown already did, and total size counts each file once. The extra paths are reported on their own line. Fixes ktsu-dev/FileDeduplicator#189 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0198HLiVUinCNr1rdcUTMQee --- FileDeduplicator.Test/SameFileTests.cs | 49 ++++++++++++++++++++++++++ FileDeduplicator/Deduplicator.cs | 2 +- FileDeduplicator/Verbs/Stats.cs | 15 ++++++-- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/FileDeduplicator.Test/SameFileTests.cs b/FileDeduplicator.Test/SameFileTests.cs index 3d4553b..de4aab9 100644 --- a/FileDeduplicator.Test/SameFileTests.cs +++ b/FileDeduplicator.Test/SameFileTests.cs @@ -117,6 +117,55 @@ public void AHardlinkIsCollapsedIntoItsFileAlongsideAGenuineCopy() CollectionAssert.AreEquivalent(new[] { original, copy }, duplicates[0].Files); } + /// + /// Stats counts a file and its hardlink as one file: no duplicate, and its size once. + /// + [TestMethod] + public void StatsCountsAFileAndItsHardlinkAsOneFile() + { + // Arrange + using TempTree tree = new(); + AbsoluteFilePath original = tree.Write("a.txt", "hello\n"); + _ = MakeHardLink(tree, original, "b.txt"); + _ = tree.Write("c.txt", "world-unique-123\n"); + + // Act + string output = ConsoleCapture.Normalize(ConsoleCapture.Run(new Stats { PathString = tree.Root.WeakString })); + + // Assert -- 6 B for a.txt once, plus 17 B for c.txt + Assert.Contains("Total files: 3", output); + Assert.Contains("Total size: 23 B", output); + Assert.Contains("Unique files: 2", output); + Assert.Contains("Duplicate files: 0", output); + Assert.Contains("Duplicate groups: 0", output); + Assert.Contains("Extra paths to the same file (counted once): 1", output); + } + + /// + /// With a genuine copy and a hardlink pair, Stats' duplicate count agrees with its own extension + /// breakdown and with what DryRun would delete. + /// + [TestMethod] + public void StatsAgreesWithDryRunForACopyAndAHardlinkPair() + { + // Arrange + using TempTree tree = new(); + _ = tree.Write("k.bin", "duplicate content"); + AbsoluteFilePath copy = tree.Write("sub/copy-long-name.bin", "duplicate content"); + _ = MakeHardLink(tree, copy, "sub/copy-long-name-2.bin"); + + // Act + string stats = ConsoleCapture.Normalize(ConsoleCapture.Run(new Stats { PathString = tree.Root.WeakString })); + string dryRun = ConsoleCapture.Normalize(ConsoleCapture.Run(new DryRun { PathString = tree.Root.WeakString })); + + // Assert -- two 17-byte files on disk, one of them redundant + Assert.Contains("Total size: 34 B", stats); + Assert.Contains("Duplicate files: 1", stats); + Assert.Contains("Duplicate groups: 1", stats); + Assert.Contains(".bin: 1 file(s)", stats); + Assert.Contains("Files to delete: 1", dryRun); + } + /// /// The deletion loop refuses a path that leads to the keeper's own file, whatever grouping it is /// handed, and reports it instead. diff --git a/FileDeduplicator/Deduplicator.cs b/FileDeduplicator/Deduplicator.cs index a3320f2..3af2feb 100644 --- a/FileDeduplicator/Deduplicator.cs +++ b/FileDeduplicator/Deduplicator.cs @@ -75,7 +75,7 @@ internal static IReadOnlyList FindDuplicates(Dictionary /// still stands between it and the keeper. /// - private static IEnumerable CollapseSameFile(IEnumerable paths) + internal static IEnumerable CollapseSameFile(IEnumerable paths) { HashSet seen = []; diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs index 14d7cae..ac11972 100644 --- a/FileDeduplicator/Verbs/Stats.cs +++ b/FileDeduplicator/Verbs/Stats.cs @@ -54,9 +54,15 @@ internal override void Run(Stats options) // size is unique and so are unique files without being read. HashFiles drops a file it cannot // read, so counting against the scanned list would report each one as a duplicate of nothing, // and sizing it would throw if it had vanished since the scan. - long totalSize = Deduplicator.TotalSize(scan.FileHashes.Keys.Concat(scan.UniqueSize)); + // + // Paths that lead to one file (a hardlink, a bind mount) are counted and sized once, as the + // other verbs do through FindDuplicates. Counting them per path reported a hardlink as a + // duplicate that DryRun and Deduplicate would never delete, and its size twice. + AbsoluteFilePath[] hashedFiles = [.. scan.HashGroups.Values.SelectMany(Deduplicator.CollapseSameFile)]; + long totalSize = Deduplicator.TotalSize(hashedFiles.Concat(scan.UniqueSize)); int uniqueFiles = scan.HashGroups.Count + scan.UniqueSize.Count; - int duplicateFiles = scan.FileHashes.Count - scan.HashGroups.Count - emptyCopies; + int duplicateFiles = duplicates.Sum(g => g.Files.Count - 1); + int sameFilePaths = scan.FileHashes.Count - hashedFiles.Length; int unreadableFiles = scan.Files.Count - scan.FileHashes.Count - scan.UniqueSize.Count; Console.WriteLine("=== FileDeduplicator Statistics ==="); @@ -76,6 +82,11 @@ internal override void Run(Stats options) Console.WriteLine($"Empty duplicates (never deleted): {emptyCopies}"); } + if (sameFilePaths > 0) + { + Console.WriteLine($"Extra paths to the same file (counted once): {sameFilePaths}"); + } + if (duplicates.Length > 0) { long wastedSpace = duplicates.Sum(g => g.FileSize * (g.Files.Count - 1));