Skip to content

Show the files Deduplicate will delete before asking to confirm - #123

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-c7yklx
Sep 21, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-c7yklx

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #114

What was wrong

Deduplicate.Run printed only a count and a policy sentence — Found N group(s) of duplicate files. / Keeping the copy with the shortest filename in each group. — before asking Proceed with deletion? (y/N):. The paths only appeared afterwards, via Deleted: {file}, one per file that was already gone.

Scan and DryRun both print the full per-group KEEP/DELETE listing. The one verb where seeing it matters was the one that omitted it, so the confirmation gate asked the user to approve an outcome they could not see. "Keep the shortest filename" makes that concrete: two identical files report-final-DO-NOT-DELETE.pdf and r.pdf keep r.pdf, and nothing on screen said so until it was irreversible.

What changed

  • New DuplicateReport.PlanDeletions builds the per-group KEEP/DELETE listing plus its totals, computing each group's keeper with the same Deduplicator.SelectFileToKeep the delete path uses — so the preview and the deletion cannot drift apart.
  • Deduplicate.Run prints that listing, plus Files to delete: / Space to reclaim:, before the y/N prompt.
  • DryRun.Run now renders from the same helper instead of its own copy of the loop.
  • FormatBytes was duplicated in four verbs; it moves to DuplicateReport alongside the listing.
  • README's Deduplicate section now says the listing comes before the prompt.

DryRun and Scan output is unchanged byte for byte — verified by running both verbs before and after, and now pinned by tests.

The listing is deliberately not capped or paged, which the triage comment raised as an option: the acceptance criterion is that every path about to be deleted is on screen before the question is asked, and a cap would silently reintroduce the defect for exactly the large runs where it is most costly.

Deduplicate, after

Found 2 group(s) of duplicate files.
Keeping the copy with the shortest filename in each group.

  Hash: 10f488626e3f... (13 B, 3 copies)
    KEEP:   /tmp/dedupe-demo/r.pdf
    DELETE: /tmp/dedupe-demo/archive/backup.pdf
    DELETE: /tmp/dedupe-demo/report-final-DO-NOT-DELETE.pdf

  Hash: d9298a10d1b0... (5 B, 2 copies)
    KEEP:   /tmp/dedupe-demo/x.txt
    DELETE: /tmp/dedupe-demo/xx.txt

Files to delete: 3
Space to reclaim: 31 B

Proceed with deletion? (y/N):

Tests

The suite goes from 27 tests to 45. ConsoleCapture runs a verb with the console redirected, which is the only surface these verbs have — they return nothing, and what they do is what they print.

DeduplicateConfirmationTests drives the verb through BaseVerb.Run() and asserts every doomed path appears before the prompt text. Answering n is what makes that unambiguous: nothing is deleted, so a path appearing ahead of the prompt can only have come from the new listing.

  • EveryPathToBeDeletedIsNamedBeforeTheConfirmationPrompt — the blunt-policy case (r.pdf vs report-final-DO-NOT-DELETE.pdf), also checking the kept copy is named
  • EveryDuplicateGroupAppearsInTheListing — three groups, all covered
  • TheTotalsMatchTheListingShownAboveThem
  • DecliningTheConfirmationLeavesEveryFileOnDisk — the listing did not turn the preview into the deletion
  • ConfirmingDeletesExactlyTheCopiesTheListingNamed — what the listing named is gone, what it did not name survives

VerbOutputTests covers Scan, DryRun and Stats, which had no tests at all before this PR although it rewrote a call site in each — including that the two read-only verbs leave every file on disk, and that DryRun still reports exactly what it used to now that it renders from the shared helper.

DuplicateReportTests pins the helper's counts, bytes and listing shape, plus a FormatBytes row per unit.

Verification

  • Reverting just the listing call and re-running makes EveryPathToBeDeletedIsNamedBeforeTheConfirmationPrompt and EveryDuplicateGroupAppearsInTheListing fail, and they pass again with it restored.
  • Local coverage run (dotnet test -- --coverage) cross-referenced against the diff: 54 of 54 added or changed lines executed. SonarCloud agrees at 100.0% coverage on new code.
  • Full suite green on ubuntu, macOS and Windows. Locally: 44 passed, 0 failed, 1 skipped — ACopyThatCannotBeDeletedIsReportedAndTheRunCarriesOn self-skips when the tests run as root, which was already true before this change.

One red check that is not this PR's

github-advanced-security fails with statusCode: 402, errorType: 'quota' — "You have exceeded your monthly quota" — at analysis-session creation, before it reads the diff. Details and the blocked re-run are in this comment.

🤖 Generated with Claude Code

https://claude.ai/code/session_01By4ipNgZKvDwoS2HUmjupA

Deduplicate printed only a count and its keep-the-shortest-name policy
before the "Proceed with deletion? (y/N)" prompt; the paths appeared
afterwards, one per file already deleted. Scan and DryRun both print the
full per-group KEEP/DELETE listing, so the one verb where seeing it
matters was the one that omitted it, and the confirmation gate asked the
user to approve an outcome they could not see.

Extract that listing into DuplicateReport.PlanDeletions, which computes
the keeper with the same Deduplicator.SelectFileToKeep the delete path
uses, and call it from Deduplicate before the prompt and from DryRun in
place of its own copy. FormatBytes was duplicated in four verbs and moves
there too. DryRun's and Scan's output is unchanged byte for byte.

The listing is not capped or paged: the point is that every path about to
be deleted is on screen before the question is asked.

Fixes #114

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01By4ipNgZKvDwoS2HUmjupA

Copy link
Copy Markdown
Contributor Author

CI: github-advanced-security failed, and it is not this PR's

The job died before it analyzed anything:

Built prompt with 5963 tokens
Creating copilot-sdk session with model: claude-opus-5 ... clientName: github/code-scanning
Error creating PR review request: SessionModelError: You have exceeded your monthly quota
  ... "statusCode":402, "errorCode":"quota"
##[error]Process completed with exit code 1

That is an account-level Copilot quota (HTTP 402) refusing to open the analysis session. It fails at session creation, before the diff is looked at, so no change to this branch can affect it — there is no fix to port, and nothing here to root-cause. It needs the monthly quota to reset or to be raised by an org owner.

I could not spend the one re-run the situation allows: the run belongs to the github-advanced-security app's dynamic workflow, and rerun-failed-jobs is refused with 403 This workflow run cannot be retried.

Everything this PR does control is green on 355cca5:

Check Result
Test on ubuntu-latest ✅ success
Test on macos-latest ✅ success
Test on windows-latest ⏳ in progress
Analyze (csharp) ×2, Analyze (actions), CodeQL ✅ success
Discover Test Projects ✅ success
github-advanced-security ❌ quota (402), see above

I'm still watching the PR and will report on the Windows leg when it finishes.


Generated by Claude Code

SonarCloud's quality gate failed the PR at 68.3% coverage on new code
against a required 80%. The uncovered lines were real gaps, not noise:
Scan, DryRun and Stats had no test at all, so the FormatBytes call sites
this change rewrote in each of them were never executed, and the
Deduplicate tests all declined at the prompt, so the delete path below it
was never reached either.

Add VerbOutputTests, which runs Scan, DryRun and Stats through the console
and pins what each reports, including that the two read-only verbs leave
every file on disk. Add the confirming case to DeduplicateConfirmationTests
-- what the listing named is deleted, what it did not name is not -- plus
the totals shown above the prompt, and a FormatBytes row per unit. Console
redirection moves to a shared ConsoleCapture helper.

Every line this PR adds or changes is now executed by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01By4ipNgZKvDwoS2HUmjupA
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 06de957 into main Sep 21, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deduplicate never shows which specific files will be kept vs. deleted before the confirmation prompt

2 participants