diff --git a/FileDeduplicator.Test/DeduplicatorTests.cs b/FileDeduplicator.Test/DeduplicatorTests.cs index f1a6318..a8e93b9 100644 --- a/FileDeduplicator.Test/DeduplicatorTests.cs +++ b/FileDeduplicator.Test/DeduplicatorTests.cs @@ -207,6 +207,34 @@ public void DeleteDuplicatesRemovesEveryCopyExceptTheKeeper() } } + /// + /// Empty files group together, but deleting one frees nothing and an empty __init__.py + /// or .gitkeep matters because it exists, so only the real duplicate is deleted. + /// + [TestMethod] + public void EmptyFilesAreNeverDeleted() + { + // Arrange + using TempTree tree = new(); + AbsoluteFilePath initA = tree.Write("a/__init__.py", string.Empty); + AbsoluteFilePath initB = tree.Write("b/__init__.py", string.Empty); + AbsoluteFilePath copy = tree.Write("copy.txt", "shared"); + AbsoluteFilePath keeper = tree.Write("k.txt", "shared"); + IReadOnlyList duplicates = Duplicates(FileHasher.HashFiles([initA, initB, copy, keeper])); + + // Act + DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates); + + // Assert + Assert.HasCount(2, duplicates, "The empty files are still reported as a group."); + Assert.AreEqual(1, result.DeletedCount); + Assert.IsTrue(TempTree.Exists(initA), "An empty file must survive."); + Assert.IsTrue(TempTree.Exists(initB), "An empty file must survive."); + Assert.IsTrue(TempTree.Exists(keeper), "The keeper must survive."); + Assert.IsFalse(TempTree.Exists(copy), "The real duplicate should have been deleted."); + Assert.IsEmpty(result.SkippedFiles); + } + /// /// The reclaimed byte count must reflect what was actually removed. /// diff --git a/FileDeduplicator.Test/DryRunEquivalenceTests.cs b/FileDeduplicator.Test/DryRunEquivalenceTests.cs index ebf26e0..4b06079 100644 --- a/FileDeduplicator.Test/DryRunEquivalenceTests.cs +++ b/FileDeduplicator.Test/DryRunEquivalenceTests.cs @@ -31,7 +31,7 @@ private static HashSet PredictedDeletions(IReadOnlyList predicted = []; - foreach (DuplicateGroup group in duplicates) + foreach (DuplicateGroup group in duplicates.Where(Deduplicator.IsDeletable)) { AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); foreach (AbsoluteFilePath file in group.Files.Where(f => f != keeper)) diff --git a/FileDeduplicator.Test/DuplicateReportTests.cs b/FileDeduplicator.Test/DuplicateReportTests.cs index 8d8c3e9..8a17e62 100644 --- a/FileDeduplicator.Test/DuplicateReportTests.cs +++ b/FileDeduplicator.Test/DuplicateReportTests.cs @@ -43,6 +43,29 @@ public void PlanCountsEveryCopyExceptEachGroupsKeeper() Assert.AreEqual((2 * 5L) + 4L, plan.BytesReclaimable); } + /// + /// Empty files are never deleted, so the plan keeps all of them and counts none, matching what + /// Deduplicate then does. + /// + [TestMethod] + public void PlanKeepsEveryEmptyFile() + { + // Arrange + using TempTree tree = new(); + _ = tree.Write("a/__init__.py", string.Empty); + _ = tree.Write("b/__init__.py", string.Empty); + _ = tree.Write("logs/.gitkeep", string.Empty); + + // Act + DeletionPlan plan = DuplicateReport.PlanDeletions(DuplicatesIn(tree)); + + // Assert + Assert.AreEqual(0, plan.FileCount); + Assert.AreEqual(0, plan.BytesReclaimable); + Assert.DoesNotContain(line => line.Contains("DELETE:", StringComparison.Ordinal), plan.Listing, "No empty file may be listed for deletion."); + Assert.AreEqual(3, plan.Listing.Count(line => line.Contains("KEEP:", StringComparison.Ordinal))); + } + /// /// The listing names one keeper and every other copy in each group, so the two sets together /// account for every file the group holds. diff --git a/FileDeduplicator/Deduplicator.cs b/FileDeduplicator/Deduplicator.cs index f7b8591..a00a7ea 100644 --- a/FileDeduplicator/Deduplicator.cs +++ b/FileDeduplicator/Deduplicator.cs @@ -97,6 +97,18 @@ private static bool TryGetSize(AbsoluteFilePath file, out long size) internal static AbsoluteFilePath SelectFileToKeep(List duplicates) => duplicates.OrderBy(f => f.FileName.WeakString.Length).ThenBy(f => f.WeakString, StringComparer.Ordinal).First(); + /// + /// Reports whether deduplication may delete copies from a group. + /// + /// + /// Every zero-length file hashes the same, so an empty __init__.py, a .gitkeep and a + /// py.typed marker land in one group. Such files matter because they exist, not for what + /// they hold, and deleting them frees nothing, so the group is still reported but never deleted. + /// + /// The group to check. + /// unless the group's files are empty. + internal static bool IsDeletable(DuplicateGroup group) => group.FileSize > 0; + internal static DeduplicationResult DeleteDuplicates(IReadOnlyList duplicateGroups) { int deletedCount = 0; @@ -104,7 +116,7 @@ internal static DeduplicationResult DeleteDuplicates(IReadOnlyList errors = []; List skipped = []; - foreach (DuplicateGroup group in duplicateGroups) + foreach (DuplicateGroup group in duplicateGroups.Where(IsDeletable)) { AbsoluteFilePath keeper = SelectFileToKeep(group.Files); diff --git a/FileDeduplicator/DuplicateReport.cs b/FileDeduplicator/DuplicateReport.cs index f14d4b9..d255a2b 100644 --- a/FileDeduplicator/DuplicateReport.cs +++ b/FileDeduplicator/DuplicateReport.cs @@ -31,9 +31,21 @@ internal static DeletionPlan PlanDeletions(IReadOnlyList duplica foreach (DuplicateGroup group in duplicates) { - AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); - listing.Add($" Hash: {group.Hash[..12]}... ({FormatBytes(group.FileSize)}, {group.Files.Count} copies)"); + + if (!Deduplicator.IsDeletable(group)) + { + listing.Add(" Empty files are never deleted:"); + foreach (AbsoluteFilePath file in group.Files) + { + listing.Add($" KEEP: {file}"); + } + + listing.Add(string.Empty); + continue; + } + + AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); listing.Add($" KEEP: {keeper}"); foreach (AbsoluteFilePath file in group.Files) diff --git a/FileDeduplicator/Verbs/Scan.cs b/FileDeduplicator/Verbs/Scan.cs index 952ea1e..a519d91 100644 --- a/FileDeduplicator/Verbs/Scan.cs +++ b/FileDeduplicator/Verbs/Scan.cs @@ -68,6 +68,7 @@ internal override void Run(Scan options) foreach (DuplicateGroup group in duplicates) { AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); + bool deletable = Deduplicator.IsDeletable(group); long wastedBytes = group.FileSize * (group.Files.Count - 1); totalWastedBytes += wastedBytes; @@ -75,7 +76,12 @@ internal override void Run(Scan options) foreach (AbsoluteFilePath file in group.Files) { - string marker = file == keeper ? " [KEEP]" : " [DELETE]"; + string marker = (deletable, file == keeper) switch + { + (false, _) => " [KEEP, empty]", + (true, true) => " [KEEP]", + (true, false) => " [DELETE]", + }; Console.WriteLine($" {file}{marker}"); } diff --git a/README.md b/README.md index 1523afe..5168972 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ FileDeduplicator Stats -p "C:\path\to\directory" When duplicates are found, the file with the **shortest filename** is kept. If two files have filenames of equal length, the one with the lexicographically first full path is kept. +Empty files are reported as a group but never deleted. Deleting one frees nothing, and an empty file such as `__init__.py`, `.gitkeep` or `py.typed` usually matters because it exists. + For example, given these duplicates: - `C:\photos\vacation\IMG_20240101_123456.jpg` - `C:\photos\beach.jpg`