Skip to content

Deduplicate removes one name of a hardlinked duplicate and reports the space as reclaimed, but frees nothing and the next Scan finds the same duplicate again #190

Open

Description

@matt-edmondson

What's wrong

CollapseSameFile reduces each set of paths sharing a file identity to one representative:

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:

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 t

Deduplicate 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

Suggested fix

Treat a non-keeper duplicate as all of its paths rather than one. Pick one consistent policy:

  1. 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 SkippedFile such as "has N other hardlinks; deleting this name would free nothing". Leave it out of DryRun's Files to delete / Space to reclaim.
  2. 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 under DELETE: 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.cs covering a non-keeper copy with two hardlinks.

Activity

  1. matt-edmondson commented on Oct 19, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

  2. matt-edmondson commented on Oct 19, 2026

    @matt-edmondson
    ContributorAuthor

    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:

    Plan once #191 is merged:

    1. FindDuplicates records, per representative, how many other scanned paths share its FileIdentity. The collapse already computes this and then throws it away.
    2. DeleteDuplicates skips a non-keeper that has other names, with a SkippedFile reason ("has N other hardlink(s); deleting this name would free nothing"), and adds nothing to bytesReclaimed.
    3. DuplicateReport.PlanDeletions lists that copy as kept with the same reason, and leaves it out of Files to delete / Space to reclaim. Stats' Duplicate files uses the same rule.
    4. Add the two-hardlink non-keeper test to 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_Stat structure has no link count, so a link outside the tree (an rsnapshot sibling, say) still needs a native st_nlink read on Unix. NumberOfLinks is already in ByHandleFileInformation on Windows. Decide whether that's in scope.


    Generated by Claude Code

  3. removed their assignment
    on Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions