Repository navigation
Description
What's wrong
CollapseSameFile reduces each set of paths sharing a file identity to one representative:
FileDeduplicator/FileDeduplicator/Deduplicator.cs
Lines 78 to 83 in 8de035e
| private static IEnumerable<AbsoluteFilePath> CollapseSameFile(IEnumerable<AbsoluteFilePath> paths) | |
| { | |
| HashSet<FileIdentity> seen = []; | |
| return OrderByPreference(paths) | |
| .Where(path => !FileIdentity.TryGet(path.WeakString, out FileIdentity identity) || seen.Add(identity)); |
That is correct for paths that lead to the keeper. A duplicate that is not the keeper can also have several hardlinks, though. Its other names are then dropped from the group, so DeleteDuplicates deletes only the representative's name. It still adds the full file size to bytesReclaimed:
FileDeduplicator/FileDeduplicator/Deduplicator.cs
Lines 182 to 186 in 8de035e
| if (TryDelete(file, errors, out long fileSize)) | |
| { | |
| deletedCount++; | |
| bytesReclaimed += fileSize; | |
| } |
DryRun's Space to reclaim makes the same claim:
| bytesReclaimable += group.FileSize; |
Removing one link out of two unlinks no data blocks.
Reproduction (Linux, built from HEAD)
mkdir -p t/sub
head -c 100000 /dev/urandom > t/k.bin
cp t/k.bin t/sub/copy-long-name.bin
ln t/sub/copy-long-name.bin t/sub/copy-long-name-2.bin
echo y | FileDeduplicator Deduplicate -p t
FileDeduplicator Scan -p tDeduplicate lists KEEP: t/k.bin and DELETE: t/sub/copy-long-name.bin, then prints Deleted 1 file(s). and Reclaimed 97.7 KB of disk space.. Afterwards t/sub/copy-long-name-2.bin still holds the same 100,000 bytes, so no space was freed. The following Scan reports the group again (k.bin [KEEP], copy-long-name-2.bin [DELETE], Total wasted space: 97.7 KB).
Why it matters
- The reclaimed-space figure is wrong in the hardlink-heavy trees Hardlinked files are deduplicated like independent copies, risking silent data loss in snapshot/backup trees #132 was about (rsnapshot,
cp -al,rsync --link-dest). - The user is told deduplication finished while the duplicate data is still on disk. It takes one more run per remaining link to actually free the space.
- In a snapshot tree this is what Hardlinked files are deduplicated like independent copies, risking silent data loss in snapshot/backup trees #132 set out to prevent: a name is removed from one snapshot while the data stays referenced from another. It is the representative's name only because
CollapseSameFilehappened to prefer it.
Suggested fix
Treat a non-keeper duplicate as all of its paths rather than one. Pick one consistent policy:
- Fail closed (in keeping with the triage on Hardlinked files are deduplicated like independent copies, risking silent data loss in snapshot/backup trees #132): when a non-keeper copy has other names, skip deleting it and record a
SkippedFilesuch as "has N other hardlinks; deleting this name would free nothing". Leave it out of DryRun'sFiles to delete/Space to reclaim. - Delete every name: keep the collapsed-away paths alongside their representative in
DuplicateGroup, delete all of them, and count the size once. List each name underDELETE:so the preview shows them.
Either way, bytesReclaimed and bytesReclaimable should only count a file's size when its last name is removed.
Acceptance criteria
- In the reproduction above, Deduplicate does not report 97.7 KB reclaimed unless the data is actually freed.
- After Deduplicate, Scan finds no duplicates, or Deduplicate's output explains why the remaining link was kept.
- DryRun's totals match what Deduplicate then does.
- A test in
FileDeduplicator.Test/SameFileTests.cscovering a non-keeper copy with two hardlinks.
Activity
matt-edmondson commentedon Oct 19, 2026
Triage
Deduplicator.CollapseSameFile/DeleteDuplicatesandDuplicateReport(DryRun totals). Owner: @matt-edmondsonCollapseSameFilewithout carrying the extra names forward, so one change to how a group represents a file's names could fix both. No open PR in FileDeduplicator covers either.SkippedFilereason, matches the Hardlinked files are deduplicated like independent copies, risking silent data loss in snapshot/backup trees #132 triage. "Delete every name" is the alternative. Either way, count bytes only when the last name goes, keep DryRun and Deduplicate in agreement, and add the two-hardlink non-keeper test toSameFileTests.cs.Generated by Claude Code
matt-edmondson commentedon Oct 19, 2026
Triage
Depends on PR #191 (the fix for #189). Not actionable until that lands.
I started on this and stopped because of how it interacts with #191:
Duplicate filescome from the collapsed groups (Sum(g => g.Files.Count - 1)), and its test pins Stats == DryRunFiles to deletefor a copy plus a hardlink pair (k.bin,sub/copy-long-name.binand its link). Today that count is 1.Files to deletedrops to 0 for that same tree, because the non-keeper has another name. Stats then has to make the same exclusion, or the two disagree again. That change belongs on top of Count a hardlinked file once in Stats [patch] #191's Stats code. Doing it on a separate branch frommainwould either stack on Count a hardlinked file once in Stats [patch] #191 or conflict with it.Plan once #191 is merged:
FindDuplicatesrecords, per representative, how many other scanned paths share itsFileIdentity. The collapse already computes this and then throws it away.DeleteDuplicatesskips a non-keeper that has other names, with aSkippedFilereason ("has N other hardlink(s); deleting this name would free nothing"), and adds nothing tobytesReclaimed.DuplicateReport.PlanDeletionslists that copy as kept with the same reason, and leaves it out ofFiles to delete/Space to reclaim. Stats'Duplicate filesuses the same rule.SameFileTests.cs, and update Count a hardlinked file once in Stats [patch] #191's copy-plus-hardlink-pair assertion to the new count.One limit to record: only links inside the scanned tree can be detected this way. The runtime's
SystemNative_Statstructure has no link count, so a link outside the tree (an rsnapshot sibling, say) still needs a nativest_nlinkread on Unix.NumberOfLinksis already inByHandleFileInformationon Windows. Decide whether that's in scope.Generated by Claude Code