Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ and `cargo build --release` directly; CI runs the gate on both systems.
folders and depth; see `mft.rs`. That path counts every stream's
allocation, alternate data streams included since they go when the file
goes, so for a file with alternate streams it can exceed the walk's
number.
2. **Totals are derived, never tracked.** A leaf's `bytes` is the only
input: `tree::aggregate` computes every directory's `bytes`, `files` and
`dirs` from its children, and `own_bytes()`/`own_files()` read the direct
number. An APFS clone family is charged once, like a hardlinked file:
every copy's `st_blocks` counts the blocks they share; see `macos.rs`.
2. **Totals are derived, never tracked.** A leaf's `bytes` and `unshared`
are the only inputs: `tree::aggregate` computes every directory's
`bytes`, `unshared`, `files` and `dirs` from its children, and `own_bytes()`/`own_files()` read the direct
children when asked. Hardlink de-duplication zeroes a duplicate leaf's
`bytes` while that pass runs; anything that patches a directory's `bytes`
directly will be overwritten. `Node` is paid for tens of millions of
Expand All @@ -102,7 +103,9 @@ and `cargo build --release` directly; CI runs the gate on both systems.
8. **The view transform is the only thing zoom changes.** Layout runs in
base-space pixels and is cached; `screen = (base - origin) * scale`.
9. **The status bar never claims a saving it cannot measure.** Projections come
from marked bytes *on the volume the meter measures*, which is the scan's own
from marked *unshared* bytes — an APFS clone's blocks stay while another
copy does, so they are never projected; see `Node::unshared` —
*on the volume the meter measures*, which is the scan's own
notion of a volume — the mount *source* — so a mark on a disk mounted inside
the tree, which `-X` and the media exception allow, is reported and never
added to this volume's free space; see `space::attribution`. The final number
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,12 @@ What is different from Linux:

- **Free space** is what `df` reports. Finder's figure is larger: it counts
purgeable space (caches and local snapshots macOS will clear on its own).
- **Cloned files** (copies APFS shares blocks between, as Finder's Duplicate
makes) are each counted in full, so a total can exceed what deleting them
frees.
- **Cloned files** (copies APFS shares blocks between, as `cp -c` and
Finder's Duplicate make) are counted once per family, like hardlinks. Each
copy holds none of those blocks alone, so marking one projects no saving:
deleting it frees nothing while another copy stays. A copy edited after
cloning still shares the rest of its blocks; it projects only what it
holds alone, or nothing if it is 64 KiB or smaller.
- **Time Machine's local snapshots** are not files and do not appear; they
are part of the gap between the scan and the disk's used space.
- **Cloud-only folders** (iCloud Drive, Dropbox and the like, evicted to the
Expand Down Expand Up @@ -402,7 +405,8 @@ escaped in the prompt, so it cannot pass for another path.
- **Disk usage** by default: `st_blocks × 512`, the number `du` reports and the
space that actually comes back when a file is deleted. Apparent size (what
`ls -l` shows) is one toggle away.
- **Hardlinks once.** Two names for one inode cost one file.
- **Hardlinks once.** Two names for one inode cost one file. On macOS an
APFS clone family counts once too.
- **Hidden entries included**, because `~/.cache` is often the biggest thing in
a home directory. Symlinks are not followed.
- **The projection counts one volume.** The meter shows the scanned volume's
Expand All @@ -412,7 +416,8 @@ escaped in the prompt, so it cannot pass for another path.
subvolume is left out of the projection — removing it gives its space back
somewhere else, or, in a snapshot, shares its blocks with the live files —
and the panel and the review screen say what was left out. A mark whose
volume cannot be told is not claimed either.
volume cannot be told is not claimed either, and neither are blocks a
marked APFS clone shares with copies that stay.

The scan follows [dust](https://github.com/bootandy/dust)'s approach: one rayon
scope per root, a completion counter per directory so no directory is built
Expand Down
10 changes: 9 additions & 1 deletion crates/disktree-app/src/marks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ impl Marks {
match find(root_path, root, &item.path) {
Some(node) => resolved.push(Target {
bytes: node.bytes,
unshared: node.unshared,
is_dir: node.is_dir(),
hidden: is_hidden(&item.path),
path: item.path.clone(),
}),
None => resolved.push(Target {
bytes: 0,
unshared: 0,
..item.clone()
}),
}
Expand Down Expand Up @@ -133,7 +135,10 @@ mod tests {
fn tree() -> Node {
let mut root = Node::directory("home");
let mut cache = Node::directory(".cache");
cache.children.push(file("blob.bin", 900));
// A clone, mostly its family's: only 300 bytes are its own.
let mut blob = file("blob.bin", 900);
blob.unshared = 300;
cache.children.push(blob);
root.children.push(cache);
root.children.push(file("notes.bin", 100));
aggregate(&mut root, Metric::Bytes);
Expand All @@ -144,6 +149,7 @@ mod tests {
Target {
path: PathBuf::from(path),
bytes,
unshared: bytes,
is_dir: false,
hidden: false,
}
Expand Down Expand Up @@ -178,9 +184,11 @@ mod tests {

marks.refresh(root_path, &root, Metric::Bytes);
assert_eq!(marks.items()[0].bytes, 900);
assert_eq!(marks.items()[0].unshared, 300, "only what it frees");
assert!(marks.items()[0].is_dir);
assert!(marks.items()[0].hidden, "the .cache mark is hidden");
assert_eq!(marks.items()[1].bytes, 0, "a path that no longer exists");
assert_eq!(marks.items()[1].unshared, 0);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/disktree-app/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2174,6 +2174,7 @@ impl Disktree {
hidden: is_hidden(&path),
path,
bytes: node.bytes,
unshared: node.unshared,
is_dir: node.is_dir(),
})
}
Expand Down
63 changes: 63 additions & 0 deletions crates/disktree-app/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,7 @@ fn the_review_screen_switches_removal_mode(cx: &mut TestAppContext) {
app.marks.toggle(disktree_core::removal::Target {
path: temp.path().join("junk"),
bytes: 300_000,
unshared: 300_000,
is_dir: true,
hidden: false,
});
Expand Down Expand Up @@ -1234,6 +1235,7 @@ fn the_review_screen_copies_the_list_as_an_agent_prompt(
app.marks.toggle(disktree_core::removal::Target {
path: junk.clone(),
bytes: 300_000,
unshared: 300_000,
is_dir: true,
hidden: false,
});
Expand Down Expand Up @@ -1276,6 +1278,7 @@ fn remote_review_copies_host_guarded_script_and_prompt(
app.marks.toggle(disktree_core::removal::Target {
path: temp.path().join("junk"),
bytes: 300_000,
unshared: 300_000,
is_dir: true,
hidden: false,
});
Expand Down Expand Up @@ -1316,6 +1319,7 @@ fn escape_in_the_delete_dialog_cancels(cx: &mut TestAppContext) {
app.marks.toggle(disktree_core::removal::Target {
path: temp.path().join("junk"),
bytes: 300_000,
unshared: 300_000,
is_dir: true,
hidden: false,
});
Expand Down Expand Up @@ -1864,6 +1868,65 @@ fn names_at(names: &[String], index: usize) -> String {
names.get(index).cloned().unwrap_or_default()
}

/// An APFS clone shares every block with its original, so the scan charges
/// the pair once, and marking it projects only what deleting it frees: the
/// meter never promises the shared blocks, and the review and the delete
/// dialog say where they went.
#[cfg(target_os = "macos")]
#[gpui_kit::test]
fn a_marked_apfs_clone_projects_only_what_it_holds_alone(
cx: &mut TestAppContext,
) {
cx.update(gpui_omarchy::init);
let temp = fixture();
let copied = std::process::Command::new("/bin/cp")
.arg("-c")
.arg(temp.path().join("junk/blob.bin"))
.arg(temp.path().join("keep/copy.bin"))
.status()
.expect("cp");
if !copied.success() {
eprintln!("skipped: no clones on {}", temp.path().display());
return;
}
let (view, cx) = view_over(temp.path(), cx);
draw(cx);
let (keep, junk) = read(&view, cx, |app| {
let at = |rel: &str| app.crumbs_for_path(&temp.path().join(rel));
(at("keep").expect("keep"), at("junk").expect("junk"))
});
let tree_bytes = read(&view, cx, |app| app.tree().expect("tree").bytes);
assert_eq!(tree_bytes, 1_000 + 200_000 + 100_000 + 300_000, "once");

update(&view, cx, |app, cx| {
app.toggle_mark(&keep, cx);
app.toggle_mark(&junk, cx);
});
let plan = read(&view, cx, Disktree::plan);
assert_eq!(plan.bytes(), 1_000 + 200_000 + 100_000);
assert_eq!(plan.shared(), 200_000, "the family's blocks");
assert_eq!(plan.frees(), 1_000 + 100_000);

update(&view, cx, |app, cx| {
app.screen = Screen::Review;
cx.notify();
});
draw(cx);
assert_eq!(read(&view, cx, |app| app.screen), Screen::Review);
let said = crate::views::not_counted(&plan).expect("the shared bytes");
assert!(said.contains("shared with clones"), "{said}");
update(&view, cx, |app, cx| {
app.removal_mode = RemovalMode::Permanent;
app.commit(cx);
});
draw(cx);
assert!(
read(&view, cx, |app| app.confirm_open),
"the dialog is drawn"
);
assert!(temp.path().join("keep/copy.bin").exists(), "only asked");
}

#[gpui_kit::test]
fn marking_a_directory_marks_everything_inside_it(cx: &mut TestAppContext) {
cx.update(gpui_omarchy::init);
Expand Down
27 changes: 21 additions & 6 deletions crates/disktree-app/src/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,19 @@ fn delete_dialog(
),
targets => format!("Delete {} items permanently?", targets.len()),
};
// Only what is sure to come back: a clone's blocks stay with its
// family, so deleting it frees what it alone holds.
let shared = if plan.shared() > 0 {
format!(
"; {} more is shared with clones and stays",
human_bytes(plan.shared())
)
} else {
String::new()
};
let body = format!(
"This frees {}. Deleted files can\u{2019}t be recovered; move them to the trash if you might need them again.",
human_bytes(plan.bytes())
"This frees {}{shared}. Deleted files can\u{2019}t be recovered; move them to the trash if you might need them again.",
human_bytes(plan.frees())
);
let confirm = cx.entity().downgrade();
let cancel = confirm.clone();
Expand Down Expand Up @@ -2288,11 +2298,16 @@ fn disk_section(
}

/// What the volume meter leaves out, and why, when it leaves anything out:
/// bytes marked on another volume come back there, and bytes no volume can
/// be found for are not claimed at all. `None` when every marked byte is
/// this volume's, which is the usual case.
fn not_counted(plan: &Plan) -> Option<String> {
/// bytes shared with clones stay on the disk, bytes marked on another
/// volume come back there, and bytes no volume can be found for are not
/// claimed at all. `None` when every marked byte is this volume's own,
/// which is the usual case.
pub fn not_counted(plan: &Plan) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if plan.shared() > 0 {
parts
.push(format!("{} shared with clones", human_bytes(plan.shared())));
}
if plan.foreign() > 0 {
parts.push(format!("{} on other volumes", human_bytes(plan.foreign())));
}
Expand Down
2 changes: 2 additions & 0 deletions crates/disktree-core/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ mod tests {
Target {
path: PathBuf::from(path),
bytes,
unshared: bytes,
is_dir,
hidden: false,
}
Expand Down Expand Up @@ -514,6 +515,7 @@ mod tests {
let targets = [Target {
path: PathBuf::from("/tmp").join(name),
bytes: 1,
unshared: 1,
is_dir: false,
hidden: false,
}];
Expand Down
42 changes: 37 additions & 5 deletions crates/disktree-core/src/insights.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ pub enum Finding {
pub struct Candidate {
/// Where it is, from the scanned root.
pub crumbs: Vec<usize>,
/// What clearing it frees. For stale experiments, only the stale ones.
/// What clearing it is sure to free: its unshared bytes, so a folder of
/// APFS clones is not offered for space its copies elsewhere keep. For
/// stale experiments, only the stale ones.
pub bytes: u64,
pub finding: Finding,
}
Expand Down Expand Up @@ -62,14 +64,14 @@ fn visit(
) {
// Nothing beneath a small directory can reach `MIN_BYTES` either, so a
// scan of millions of files looks at a few thousand directories.
if !node.is_dir() || node.bytes < MIN_BYTES {
if !node.is_dir() || node.unshared < MIN_BYTES {
return;
}
// Topmost only: everything beneath a reclaimable directory goes with it.
if let Some(reason) = node.reclaim {
found.push(Candidate {
crumbs: crumbs.clone(),
bytes: node.bytes,
bytes: node.unshared,
finding: Finding::Reclaimable(reason),
});
return;
Expand All @@ -91,7 +93,7 @@ fn visit(
.unwrap_or(now);
found.push(Candidate {
crumbs: crumbs.clone(),
bytes: node.bytes,
bytes: node.unshared,
finding: Finding::Worktrees {
count: trees.len(),
oldest_days: (now - oldest).max(0) / DAY,
Expand All @@ -111,7 +113,7 @@ fn visit(
&& now - child.modified > STALE_DAYS * DAY;
if is_stale {
stale.0 += 1;
stale.1 += child.bytes;
stale.1 += child.unshared;
// A stale experiment is judged whole; its caches go with it.
continue;
}
Expand Down Expand Up @@ -215,6 +217,36 @@ mod tests {
assert_eq!(found[2].bytes, 4 * GIB, "only the stale experiment counts");
}

/// Worktrees cloned with `cp -c` share nearly every block with the
/// checkout they came from: offered for what deleting them frees, not
/// for what each copy reports.
#[test]
fn a_finding_offers_only_what_clearing_it_frees() {
let mut copy = file("x", 3 * GIB, 41);
copy.unshared = GIB / 2;
let mut twin = file("y", 3 * GIB, 41);
twin.unshared = 0;
let root = scan(dir(
"tobi",
vec![dir(
".codex",
vec![dir(
"worktrees",
vec![dir("a1", vec![copy]), dir("b2", vec![twin])],
)],
)],
));
let found = worth_a_look(&root, NOW, 10);
assert_eq!(found.len(), 1);
assert_eq!(found[0].bytes, GIB / 2);

// All of it shared: nothing to offer at all.
let mut alone = file("blob", 5 * GIB, 1);
alone.unshared = 0;
let cache = scan(dir("tobi", vec![dir(".cache", vec![alone])]));
assert!(worth_a_look(&cache, NOW, 10).is_empty());
}

#[test]
fn documents_are_never_suggested() {
let found = worth_a_look(&home(), NOW, 10);
Expand Down
Loading