diff --git a/FileDeduplicator.Test/DeduplicateConfirmationTests.cs b/FileDeduplicator.Test/DeduplicateConfirmationTests.cs index 25a9bde..e3a4dd7 100644 --- a/FileDeduplicator.Test/DeduplicateConfirmationTests.cs +++ b/FileDeduplicator.Test/DeduplicateConfirmationTests.cs @@ -166,4 +166,29 @@ public void ConfirmingDeletesExactlyTheCopiesTheListingNamed() Assert.Contains("Deleted 1 file(s).", output); Assert.Contains("Reclaimed 5 B of disk space.", output); } + + /// + /// Empty files are never deleted, so a tree whose only duplicates are empty marker files has + /// nothing to approve: the verb says so, reads no answer, and succeeds. + /// + [TestMethod] + public void OnlyEmptyDuplicatesSkipTheConfirmationAndSucceed() + { + // Arrange -- the routine Python/git case: two empty __init__.py files and one unique file + using TempTree tree = new(); + AbsoluteFilePath first = tree.Write("a/__init__.py", string.Empty); + AbsoluteFilePath second = tree.Write("b/__init__.py", string.Empty); + _ = tree.Write("u.txt", "unique"); + + // Act -- stdin is empty, as it is for a script with nothing piped in + string output = ConsoleCapture.Normalize(ConsoleCapture.Run(new Deduplicate { PathString = tree.Root.WeakString }, string.Empty, out int exitCode)); + + // Assert + Assert.AreEqual(0, exitCode, $"A run with nothing to delete reported failure. Output was:\n{output}"); + Assert.Contains("Nothing to delete", output); + Assert.DoesNotContain("Proceed with deletion?", output, $"The verb asked to approve deleting nothing. Output was:\n{output}"); + Assert.DoesNotContain("Aborted.", output); + Assert.IsTrue(TempTree.Exists(first), $"{first} is empty and should never have been touched."); + Assert.IsTrue(TempTree.Exists(second), $"{second} is empty and should never have been touched."); + } } diff --git a/FileDeduplicator.Test/VerbOutputTests.cs b/FileDeduplicator.Test/VerbOutputTests.cs index 610eea7..48fa86f 100644 --- a/FileDeduplicator.Test/VerbOutputTests.cs +++ b/FileDeduplicator.Test/VerbOutputTests.cs @@ -439,4 +439,24 @@ private static (string Output, int ExitCode) RunWithExitCode(BaseVerb verb, stri string output = ConsoleCapture.Run(verb, stdin, out int exitCode); return (output, exitCode); } + + /// + /// When every duplicate is empty, and so kept, Scan does not point at a Deduplicate run that + /// would have nothing to do. + /// + [TestMethod] + public void ScanDoesNotSuggestDeduplicateWhenOnlyEmptyDuplicatesExist() + { + // Arrange + using TempTree tree = new(); + AbsoluteFilePath first = tree.Write("a/.gitkeep", string.Empty); + _ = tree.Write("b/.gitkeep", string.Empty); + + // Act + string output = ConsoleCapture.Normalize(ConsoleCapture.Run(new Scan { PathString = tree.Root.WeakString })); + + // Assert + Assert.Contains($"{first} [KEEP, empty]", output); + Assert.DoesNotContain("Run the 'Deduplicate' command", output, $"Scan suggested a Deduplicate run with nothing to delete. Output was:\n{output}"); + } } diff --git a/FileDeduplicator/Verbs/Deduplicate.cs b/FileDeduplicator/Verbs/Deduplicate.cs index 2ee0f0a..6cd50ba 100644 --- a/FileDeduplicator/Verbs/Deduplicate.cs +++ b/FileDeduplicator/Verbs/Deduplicate.cs @@ -52,6 +52,15 @@ internal override void Run(Deduplicate options) // and DryRun already print this; the verb that actually deletes is the one that needs it. DeletionPlan plan = DuplicateReport.PlanDeletions(duplicates); + // Empty files are never deleted, so a tree whose only duplicates are empty marker files + // (__init__.py, .gitkeep) has nothing to do. Asking to approve deleting zero files would + // read stdin for nothing, and declining it would report failure to a script. + if (plan.FileCount == 0) + { + Console.WriteLine("Nothing to delete (only empty-file duplicates found)."); + return; + } + Console.WriteLine($"Found {duplicates.Count} group(s) of duplicate files."); Console.WriteLine("Keeping the copy with the shortest filename in each group."); Console.WriteLine(); diff --git a/FileDeduplicator/Verbs/Scan.cs b/FileDeduplicator/Verbs/Scan.cs index 4b6fb15..9766cf7 100644 --- a/FileDeduplicator/Verbs/Scan.cs +++ b/FileDeduplicator/Verbs/Scan.cs @@ -50,6 +50,7 @@ internal override void Run(Scan options) // Step 4: Display results long totalWastedBytes = 0; + bool anyDeletable = false; Console.WriteLine($"Found {duplicates.Count} group(s) of duplicate files:"); Console.WriteLine(); @@ -57,6 +58,7 @@ internal override void Run(Scan options) { AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); bool deletable = Deduplicator.IsDeletable(group); + anyDeletable |= deletable; long wastedBytes = group.FileSize * (group.Files.Count - 1); totalWastedBytes += wastedBytes; @@ -78,7 +80,13 @@ internal override void Run(Scan options) Console.WriteLine($"Total duplicate groups: {duplicates.Count}"); Console.WriteLine($"Total wasted space: {DuplicateReport.FormatBytes(totalWastedBytes)}"); - Console.WriteLine(); - Console.WriteLine("Run the 'Deduplicate' command to remove duplicates."); + + // Empty duplicates are kept, so pointing at Deduplicate when they are all there is would + // send the user to a verb with nothing to do. + if (anyDeletable) + { + Console.WriteLine(); + Console.WriteLine("Run the 'Deduplicate' command to remove duplicates."); + } } }