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`