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
28 changes: 28 additions & 0 deletions FileDeduplicator.Test/DeduplicatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,34 @@ public void DeleteDuplicatesRemovesEveryCopyExceptTheKeeper()
}
}

/// <summary>
/// Empty files group together, but deleting one frees nothing and an empty <c>__init__.py</c>
/// or <c>.gitkeep</c> matters because it exists, so only the real duplicate is deleted.
/// </summary>
[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<DuplicateGroup> 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);
}

/// <summary>
/// The reclaimed byte count must reflect what was actually removed.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion FileDeduplicator.Test/DryRunEquivalenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ private static HashSet<AbsoluteFilePath> PredictedDeletions(IReadOnlyList<Duplic
{
HashSet<AbsoluteFilePath> 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))
Expand Down
23 changes: 23 additions & 0 deletions FileDeduplicator.Test/DuplicateReportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ public void PlanCountsEveryCopyExceptEachGroupsKeeper()
Assert.AreEqual((2 * 5L) + 4L, plan.BytesReclaimable);
}

/// <summary>
/// Empty files are never deleted, so the plan keeps all of them and counts none, matching what
/// Deduplicate then does.
/// </summary>
[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)));
}

/// <summary>
/// The listing names one keeper and every other copy in each group, so the two sets together
/// account for every file the group holds.
Expand Down
14 changes: 13 additions & 1 deletion FileDeduplicator/Deduplicator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,26 @@ private static bool TryGetSize(AbsoluteFilePath file, out long size)
internal static AbsoluteFilePath SelectFileToKeep(List<AbsoluteFilePath> duplicates) =>
duplicates.OrderBy(f => f.FileName.WeakString.Length).ThenBy(f => f.WeakString, StringComparer.Ordinal).First();

/// <summary>
/// Reports whether deduplication may delete copies from a group.
/// </summary>
/// <remarks>
/// Every zero-length file hashes the same, so an empty <c>__init__.py</c>, a <c>.gitkeep</c> and a
/// <c>py.typed</c> 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.
/// </remarks>
/// <param name="group">The group to check.</param>
/// <returns><see langword="true"/> unless the group's files are empty.</returns>
internal static bool IsDeletable(DuplicateGroup group) => group.FileSize > 0;

internal static DeduplicationResult DeleteDuplicates(IReadOnlyList<DuplicateGroup> duplicateGroups)
{
int deletedCount = 0;
long bytesReclaimed = 0;
List<string> errors = [];
List<SkippedFile> skipped = [];

foreach (DuplicateGroup group in duplicateGroups)
foreach (DuplicateGroup group in duplicateGroups.Where(IsDeletable))
{
AbsoluteFilePath keeper = SelectFileToKeep(group.Files);

Expand Down
16 changes: 14 additions & 2 deletions FileDeduplicator/DuplicateReport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,21 @@ internal static DeletionPlan PlanDeletions(IReadOnlyList<DuplicateGroup> 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)
Expand Down
8 changes: 7 additions & 1 deletion FileDeduplicator/Verbs/Scan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,20 @@ 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;

Console.WriteLine($" Hash: {group.Hash[..12]}... ({DuplicateReport.FormatBytes(group.FileSize)}, {group.Files.Count} copies)");

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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading