Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions FileDeduplicator.Test/SameFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,55 @@ public void AHardlinkIsCollapsedIntoItsFileAlongsideAGenuineCopy()
CollectionAssert.AreEquivalent(new[] { original, copy }, duplicates[0].Files);
}

/// <summary>
/// Stats counts a file and its hardlink as one file: no duplicate, and its size once.
/// </summary>
[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);
}

/// <summary>
/// With a genuine copy and a hardlink pair, Stats' duplicate count agrees with its own extension
/// breakdown and with what DryRun would delete.
/// </summary>
[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);
}

/// <summary>
/// The deletion loop refuses a path that leads to the keeper's own file, whatever grouping it is
/// handed, and reports it instead.
Expand Down
2 changes: 1 addition & 1 deletion FileDeduplicator/Deduplicator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ internal static IReadOnlyList<DuplicateGroup> FindDuplicates(Dictionary<string,
/// drops, or it cannot be inspected, and the deletion guard in <see cref="DeleteDuplicates"/>
/// still stands between it and the keeper.
/// </remarks>
private static IEnumerable<AbsoluteFilePath> CollapseSameFile(IEnumerable<AbsoluteFilePath> paths)
internal static IEnumerable<AbsoluteFilePath> CollapseSameFile(IEnumerable<AbsoluteFilePath> paths)
{
HashSet<FileIdentity> seen = [];

Expand Down
15 changes: 13 additions & 2 deletions FileDeduplicator/Verbs/Stats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,15 @@
// 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 ===");
Expand All @@ -76,6 +82,11 @@
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));
Expand Down Expand Up @@ -117,7 +128,7 @@
private static Dictionary<string, int> CountRedundantCopiesByExtension(IReadOnlyList<DuplicateGroup> duplicates)
{
Dictionary<string, int> extensionCounts = [];
foreach (DuplicateGroup group in duplicates)

Check warning on line 131 in FileDeduplicator/Verbs/Stats.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(group => group.Files)

Check warning on line 131 in FileDeduplicator/Verbs/Stats.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(group => group.Files)
{
AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files);
IEnumerable<string> extensions = group.Files
Expand Down
Loading