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