Skip to content

fix(scan): count APFS clones once and project only unshared bytes - #117

Open
amilajack wants to merge 1 commit into
tobi:mainfrom
amilajack:fix/apfs-clones
Open

amilajack wants to merge 1 commit into
tobi:mainfrom
amilajack:fix/apfs-clones

Conversation

@amilajack

Copy link
Copy Markdown

Problem

An APFS clone (cp -c, Finder's Duplicate) reports every block it shares with its family in st_blocks. So:

  • Totals were inflated: three 200 MB copies read as 600 MB while the disk spends 200 MB. On a real, clone-heavy ~/Library (3.1M files) the scan reported 309.2 GB; the correct figure is 294.3 GB.
  • Projections over-promised: marking a clone projected its full size, but deleting it frees nothing while another copy stays. That breaks invariant 9 ("never claims a saving it cannot measure").

Fix

  • macOS listing (macos.rs): getattrlistbulk now also asks for ATTR_CMNEXT_CLONEID, ATTR_CMNEXT_CLONE_REFCNT and ATTR_CMNEXT_EXT_FLAGS. Every member of a clone family is charged under one identity (the clone id, tagged so it can't collide with an inode number), so the existing hardlink de-duplication counts the family once.
  • Node::unshared: the part of a subtree no other file shares, derived bottom-up like bytes. Whole clones hold 0 of their own. The review projection, the delete dialog ("This frees …") and Worth a look now count only unshared bytes. The shared remainder is shown as "N shared with clones" under Not counted here.
  • Edited clones (copies changed after cloning, still sharing the rest) are the only files that need ATTR_CMNEXT_PRIVATESIZE, which costs an extent walk per file. It is fetched per file, only for those over 64 KiB.
  • Older kernels (deployment target is macOS 11) that reject the newer attributes with EINVAL are asked again with fewer attributes; the fallback is remembered. A file reached through a followed symlink is charged as its clone family too.

Cost

  • Node grows from 88 to 96 bytes (a_node_stays_small updated): about 80 MB more for a 10M-node scan.
  • Scan time on the clone-heavy ~/Library, 8 workers, 6 alternating runs: 14.1 s → 16.0 s (≈+13%). A disk with few clones pays close to nothing.
  • Asking PRIVATESIZE of every file instead doubled scan time (15 s → 31 s), which is why it is fetched per file.

Known limits (documented)

  • Marking every copy of a clone still projects 0, although deleting them all frees the space. Only the volume's extent map could say so in advance, because an edited copy keeps sharing blocks with its family. The measured statvfs result after removal shows the real number.
  • Edited clones of 64 KiB or less claim no saving (on the test ~/Library: 195k such files, 1.4 GB allocated between them).
  • Time Machine local snapshot sharing is not detected.

Tests

  • macos.rs: a clone family shares one identity and holds nothing of its own; an edited copy leaves the family and holds only what was written; a single-path stat reads the same as the listing (including through symlinks); a refused attribute request is retried with fewer attributes, and other errors are not.
  • scan.rs: a family is charged once, once per copy with de-duplication off, and once again through a followed link.
  • tree.rs, removal.rs, insights.rs, marks.rs: unshared is derived and charged once; the plan projects only unshared bytes and reports the shared part.
  • Window harness: a real cp -c clone is scanned and marked, the projection and the shared figure are checked, and the review screen and delete dialog are drawn.
  • Mutation-checked: disabling the symlink path, the per-file private-size lookup or the EINVAL fallback each fails a test.

cargo xtask lint passes. cargo xtask test passes except enter_in_the_focused_volume_picker_scans_the_selected_root, which fails identically on main on macOS (/private/var vs /var paths). disktree-core clippy is also clean when cross-checked for Linux and for Windows (the Windows library; its test target has two dead-code errors in remote.rs that main has too).

An APFS clone reports every block it shares with its family in
st_blocks, so three copies of 200 MB read as 600 MB and marking one
projected a saving that deleting it never gives back.

- The macOS listing asks getattrlistbulk for each file's clone id,
  clone count and sharing flags. A clone family is charged once, keyed
  apart from inode numbers, so totals match the disk.
- Node gains `unshared` (88 -> 96 bytes): the part of a subtree no
  other file shares. Projections, the delete dialog and "Worth a look"
  count only that; the shared remainder is said, not claimed.
- Private size is read per file only for copies edited away from their
  family and larger than 64 KiB: asking it of every file doubled scan
  time on a 3.1 million file ~/Library.
- Older kernels that refuse the newer attributes are asked again with
  less, and a followed symlink is charged as its family too.

On a clone-heavy ~/Library the total drops from 309.2 GB to 294.3 GB;
the scan takes about 13% longer (14.1 s to 16.0 s, 8 workers).
Copilot AI balanced review requested due to automatic review settings October 26, 2026 01:48
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-26T01:49:33.498744Z
We couldn’t complete this review. Comment @codex review to try again.
ae83c1a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

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.

2 participants