diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 76290b7e0d..ad2c53e7aa 100644 --- a/plugins/disk-hygiene/.claude-plugin/plugin.json +++ b/plugins/disk-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "disk-hygiene", - "version": "0.34.3", + "version": "0.34.4", "description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.", "author": { "name": "Melodic Software", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index 0dc9dd9814..30c3751b16 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.34.4] - 2026-09-30 + +### Fixed + +- **`scan --sizes-only` asks the large-scan question and keeps no per-path entries** + ([#4009](https://github.com/melodic-software/claude-code-plugins/issues/4009)). `--sizes-only` + no longer skips the `--confirmed-large-scan` gate: a large root without `--max-depth` or the flag + returns `large-target-confirmation-required`, as an ordinary unbounded walk does, on both the + plain-target and `--root-children` paths. The walk still enters VCS and protected directories + (exact totals need it) and now sums sizes straight into the per-child rollup and the target total + without retaining one entry per path; the empty-directory count stays exact and the + `empty_directory_paths` sample stays capped and sorted. The payload, `inventory_mode: sizes-only` and + `rollup_precision` markers are unchanged. The skill, `scan-flags.md`, `safety-model.md`, the README + and the fan-out worker brief state the gated behavior, superseding the earlier lines below that say + `--sizes-only` skips the question. + ## [0.34.3] - 2026-09-30 ### Fixed diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index 41f9c4bdbd..ed6f455f46 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -236,9 +236,9 @@ walking the rest of the home. Every volume root, OS-managed or a Windows Dev Dri strict child ladder described under Volume-root coverage; only a target that is not a volume root gets the relaxed directory listing. -`--sizes-only` writes per-child byte totals and no entries. As implemented it skips the -large-scan confirmation, sums through VCS and protected directories read-only, and has no entry -cap. +`--sizes-only` writes per-child byte totals and no entries. It goes through the same large-scan +confirmation as an unbounded walk, sums through VCS and protected directories read-only, and has no +entry cap. The skill stores snapshots, plans, and reports under `${CLAUDE_PLUGIN_DATA}`. It never writes generated state into the installed plugin directory or the audited target. diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index 6d48d4adba..c3767b1cf9 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -63,7 +63,7 @@ address an OS-managed volume root (for example `C:\` or `/`): it never walks tha recursively. With explicit `--root-child ` flags, after the human clears the confirmation gate's root-children row, it audits only those admitted children into one snapshot; without names the engine returns `root-children-selection-required`. A general "clean everything" is not -selection. `--sizes-only` skips the large-scan question. What each of the three flags does +selection. `--sizes-only` goes through the same large-scan gate as an unbounded walk. What each of the three flags does exactly, including the admission ladder, is in [scan-flags.md](reference/scan-flags.md). With no target, ask once. Reject an OS-managed root (unless `--root-children` on the volume root itself), a non-root mount target, a protected shell-folder root or descendant (the refusal carries a `hint`: a child of a shell folder is refused too, so name a directory whose path holds no protected name), a virtual-disk image file by name (`*.vhd`, `*.vhdx`, `*.avhd`, `*.avhdx`, `*.vmdk`, `*.vdi`, `*.qcow2`, `*.img`, which includes WSL's `ext4.vhdx`), a missing directory, a symlink, @@ -158,9 +158,9 @@ naming what the question never presented cannot be met. | Root-children selection (`--root-children`, §1) | one or more admitted immediate children just listed (directories, or regular files on a volume root), never "everything" or the scan target itself | | Removal approval (§5) and manual handoff (§6) | exactly the one tier and the exact path list just shown | -**`--sizes-only`** does not ask the large-scan question, so a known-large root walks without -`--max-depth` or `--confirmed-large-scan`; it sums through VCS and protected directories, read-only, -and has no entry cap. Detail: +**`--sizes-only`** goes through the same large-scan question as an ordinary unbounded walk, so a +known-large root needs `--max-depth` or `--confirmed-large-scan`; it sums through VCS and protected +directories, read-only, keeps no per-path entries, and has no entry cap. Detail: [scan-flags.md](reference/scan-flags.md#--sizes-only). ## 1. Create a read-only snapshot @@ -178,7 +178,7 @@ or `${CLAUDE_PLUGIN_ROOT}`. Run: ``` For exact per-child byte totals without paying for a per-entry inventory (or the entry cap), add -`--sizes-only`. The snapshot carries `inventory_mode: sizes-only` and `rollup_precision: exact` +`--sizes-only` (a known-large target still needs `--confirmed-large-scan` or `--max-depth`). The snapshot carries `inventory_mode: sizes-only` and `rollup_precision: exact` when every subtree was walked; a depth cut, a directory that failed to scan, or a mount-state error marks `rollup_precision: partial`. Entry-cap error and next steps: [scan-flags.md](reference/scan-flags.md). Pasteable fan-out worker instructions: [fan-out-worker-brief.md](reference/fan-out-worker-brief.md). diff --git a/plugins/disk-hygiene/skills/clean/reference/fan-out-worker-brief.md b/plugins/disk-hygiene/skills/clean/reference/fan-out-worker-brief.md index 3ded6418e4..d94148b2bd 100644 --- a/plugins/disk-hygiene/skills/clean/reference/fan-out-worker-brief.md +++ b/plugins/disk-hygiene/skills/clean/reference/fan-out-worker-brief.md @@ -42,9 +42,12 @@ mutates the target. Do not wrap the engine in compound shells (`;`, `&&`, `|`). "" "" scan \ --target "" --output "/sizes.json" \ --data-root "" [--project-dir ""] \ - --sizes-only + --sizes-only [--confirmed-large-scan] ``` +Add `--confirmed-large-scan` only when the parent confirmed a large target; without it a +known-large root returns `large-target-confirmation-required`. + Read `inventory_mode: sizes-only` and `rollup_precision` on stdout. `partial` means a subtree was cut or failed to scan. `children_rollup` rows with `walked: true` are exact totals, not depth-cut floors. diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index 8ff25a5ad4..41aaccf3d7 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -704,9 +704,9 @@ unbounded traversal, so an unauthenticated whole-volume walk cannot begin by omi scan-cost gating (time and resources), distinct from the hard rejection of an OS-managed root as an invalid target. -`--sizes-only`, as implemented, bypasses that gate. It does not ask the large-scan question, does -not stop at VCS or protected directories (it sums through them, read-only, and emits no entries), -and has no entry cap. Its snapshot is refused by disposition. +`--sizes-only` goes through that same gate: a known-large root needs `--max-depth` or +`--confirmed-large-scan`. It does not stop at VCS or protected directories (it sums through them, +read-only, for exact totals), keeps no per-path entries, and has no entry cap. Its snapshot is refused by disposition. Managed state is engine-ineligible. Even current native dry-run evidence is recorded only as a report-only handoff because this engine cannot independently authenticate the owning product's state diff --git a/plugins/disk-hygiene/skills/clean/reference/scan-flags.md b/plugins/disk-hygiene/skills/clean/reference/scan-flags.md index 80f4cc10f8..6a64750558 100644 --- a/plugins/disk-hygiene/skills/clean/reference/scan-flags.md +++ b/plugins/disk-hygiene/skills/clean/reference/scan-flags.md @@ -35,9 +35,10 @@ everything" is not selection. ## `--sizes-only` -`--sizes-only` as implemented: it does not ask the large-scan question, so a known-large root walks -without `--max-depth` or `--confirmed-large-scan`. It does not stop at VCS or protected -directories: it sums through them, read-only, and writes no entries. It has no entry cap. +`--sizes-only` goes through the same large-scan gate as an ordinary unbounded walk: a known-large +root returns `large-target-confirmation-required` without `--max-depth` or `--confirmed-large-scan`. +It does not stop at VCS or protected directories: it sums through them, read-only, for exact totals, +and keeps no per-path entries. It has no entry cap. When an inventory scan hits the entry cap, the error lists the top five top-level children by entry count so far. The child still being walked is a lower bound, and children not yet reached are not diff --git a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py index fc99422389..c3cd1005a7 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py @@ -1607,6 +1607,51 @@ def children_rollup( unknown with no more specific cause falls back to the bare ``not-walked``, the same qualifier the flat entry carries. """ + totals: dict[str, dict[str, Any]] = {} + for entry in entries: + accumulate_child_rollup(totals, entry) + return child_rollup_rows(totals, unknown_paths, unwalked_reasons) + + +def accumulate_child_rollup( + totals: dict[str, dict[str, Any]], entry: dict[str, Any] +) -> None: + """Fold one inventory record into its immediate child's accumulator. + + Keeps only the child's own kind and logical size, never the record, so a + caller that feeds records one at a time need not retain them. + """ + relative = entry.get("path") + # `.` is the target itself, never one of its children: `scan_tree` keeps + # the target's record out of the inventory, and the same skip in the gap + # loop keeps a target-level truncation from inventing a `.` child row. + if not isinstance(relative, str) or not relative or relative == ".": + return + name = child_rollup_name(relative) + bucket = totals.setdefault(name, empty_child_rollup_bucket()) + if relative == name: + bucket["self"] = { + "kind": entry.get("kind"), + "logical_size": entry.get("logical_size"), + } + else: + bucket["descendants"] = bucket["descendants"] + 1 + mtime = entry.get("mtime_ns") + if isinstance(mtime, int): + newest = bucket["newest"] + bucket["newest"] = mtime if newest is None else max(newest, mtime) + bucket["qualifiers"].update(entry.get("size_qualifiers") or ()) + local = entry_reclaimable_local_bytes(entry) + if local is not None: + bucket["reclaimable"] = bucket["reclaimable"] + local + + +def child_rollup_rows( + totals: dict[str, dict[str, Any]], + unknown_paths: Iterable[str] = (), + unwalked_reasons: dict[str, str] | None = None, +) -> list[dict[str, Any]]: + """The per-child rows ``children_rollup`` documents, from accumulated totals.""" reasons = unwalked_reasons or {} gaps: dict[str, set[str]] = {} for path in unknown_paths: @@ -1616,28 +1661,6 @@ def children_rollup( gaps.setdefault(name, set()).add( reasons.get(path, "not-walked") if path == name else "descendant-not-walked" ) - totals: dict[str, dict[str, Any]] = {} - for entry in entries: - relative = entry.get("path") - # `.` is the target itself, never one of its children: `scan_tree` keeps - # the target's record out of `entries`, and the same skip in the gap loop - # above keeps a target-level truncation from inventing a `.` child row. - if not isinstance(relative, str) or not relative or relative == ".": - continue - name = child_rollup_name(relative) - bucket = totals.setdefault(name, empty_child_rollup_bucket()) - if relative == name: - bucket["self"] = entry - else: - bucket["descendants"] = bucket["descendants"] + 1 - mtime = entry.get("mtime_ns") - if isinstance(mtime, int): - newest = bucket["newest"] - bucket["newest"] = mtime if newest is None else max(newest, mtime) - bucket["qualifiers"].update(entry.get("size_qualifiers") or ()) - local = entry_reclaimable_local_bytes(entry) - if local is not None: - bucket["reclaimable"] = bucket["reclaimable"] + local rows: list[dict[str, Any]] = [] for name in sorted(set(totals) | set(gaps)): bucket = totals.get(name) or empty_child_rollup_bucket() @@ -2384,6 +2407,17 @@ def scan_tree( sizes_only: bool = False, ) -> dict[str, Any]: entries: list[dict[str, Any]] = [] + # A sizes-only walk keeps no per-path record: each path folds straight into + # these running totals, so memory follows the immediate children rather than + # the tree. + child_totals: dict[str, dict[str, Any]] = {} + # Only the count and a bounded sorted sample of empty directories are kept. + empty_directory_total = 0 + empty_directory_sample: list[str] = [] + inventoried = 0 + reclaimable_total = 0 + empty_files = 0 + not_walked_paths: set[str] = set() errors: list[dict[str, str]] = [] truncated: list[str] = [] # Why each uninventoried path is uninventoried, so the per-child roll-up can @@ -2403,6 +2437,7 @@ def scan_tree( allowed_root_children = {name.casefold() for name in root_children} def visit(directory: Path, depth: int = 1) -> int | None: + nonlocal inventoried, reclaimable_total, empty_files, empty_directory_total total = 0 try: with os.scandir(directory) as iterator: @@ -2436,6 +2471,7 @@ def visit(directory: Path, depth: int = 1) -> int | None: ) if consumer_matches: protections.append("consumer-protected-path") + descendants = 0 try: if is_linkish(path): kind = "link" @@ -2476,7 +2512,9 @@ def visit(directory: Path, depth: int = 1) -> int | None: else: subtotal = 0 else: + before = inventoried subtotal = visit(path, depth + 1) + descendants = inventoried - before if subtotal is None: # scandir failed inside this child: unknown, not empty. walked = False @@ -2512,8 +2550,26 @@ def visit(directory: Path, depth: int = 1) -> int | None: "entry cap), then rerun with --root-children --root-child " "on bounded children or with --max-depth" ) + inventoried += 1 + if "not-walked" in data["size_qualifiers"]: + not_walked_paths.add(relative) if sizes_only: - entries.append({"path": relative, **data}) + record = {"path": relative, **data} + accumulate_child_rollup(child_totals, record) + reclaimable_total += entry_reclaimable_local_bytes(record) or 0 + if kind == "file" and data["logical_size"] == 0: + empty_files += 1 + if ( + descendants == 0 + and kind == "directory" + and entry_is_empty_directory(record, parents_with_children=set()) + ): + empty_directory_total += 1 + empty_directory_sample.append(relative) + if len(empty_directory_sample) >= 2 * MAX_EMPTY_DIRECTORY_PATHS: + empty_directory_sample[:] = sorted(empty_directory_sample)[ + :MAX_EMPTY_DIRECTORY_PATHS + ] else: entries.append( { @@ -2524,8 +2580,8 @@ def visit(directory: Path, depth: int = 1) -> int | None: **protection_matches_field(consumer_matches), } ) - if len(entries) % 25_000 == 0: - print(f"scanned {len(entries)} entries...", file=sys.stderr) + if inventoried % 25_000 == 0: + print(f"scanned {inventoried} entries...", file=sys.stderr) return total total_size = visit(target) @@ -2547,7 +2603,6 @@ def visit(directory: Path, depth: int = 1) -> int | None: }, ) stdlib_shadowing = annotate_stdlib_shadowing(entries, target) - reclaimable = reclaimable_local_bytes(entries) target_identity = metadata(target, "directory", total_size) # The target itself was walked, but any truncated child means the target's # byte roll-up is incomplete. Keep the known walked sum in logical_size and @@ -2564,16 +2619,22 @@ def visit(directory: Path, depth: int = 1) -> int | None: # Everything the walk could not fully account for, from all three places it # can be recorded: an explicit truncation, a scan error (which never adds a # truncation and can leave no entry at all), and a `not-walked` record. - unknown_paths = ( - set(truncated) - | error_paths - | { - entry["path"] - for entry in entries - if "not-walked" in (entry.get("size_qualifiers") or []) - } - ) - empty_directories = empty_directory_paths(entries, error_paths=error_paths) + unknown_paths = set(truncated) | error_paths | not_walked_paths + if sizes_only: + reclaimable = reclaimable_total + empty_directories_total = empty_directory_total + empty_directories = sorted(empty_directory_sample)[:MAX_EMPTY_DIRECTORY_PATHS] + empty_files_total = empty_files + rollup_rows = child_rollup_rows(child_totals, unknown_paths, unwalked_reasons) + else: + reclaimable = reclaimable_local_bytes(entries) + all_empty_directories = empty_directory_paths(entries, error_paths=error_paths) + empty_directories_total = len(all_empty_directories) + empty_directories = all_empty_directories[:MAX_EMPTY_DIRECTORY_PATHS] + empty_files_total = empty_file_count(entries) + rollup_rows = children_rollup( + entries, unknown_paths=unknown_paths, unwalked_reasons=unwalked_reasons + ) payload: dict[str, Any] = { "schema_version": SCHEMA_VERSION, "engine": "disk-hygiene-python-1", @@ -2584,11 +2645,11 @@ def visit(directory: Path, depth: int = 1) -> int | None: "target_identity": target_identity, "target_logical_bytes": total_size, "target_reclaimable_local_bytes": reclaimable, - "empty_directory_count": len(empty_directories), - "empty_directory_paths": empty_directories[:MAX_EMPTY_DIRECTORY_PATHS], - "empty_directory_paths_truncated": len(empty_directories) + "empty_directory_count": empty_directories_total, + "empty_directory_paths": empty_directories, + "empty_directory_paths_truncated": empty_directories_total > MAX_EMPTY_DIRECTORY_PATHS, - "empty_file_count": empty_file_count(entries), + "empty_file_count": empty_files_total, "policy": policy, "repositories": [str(repo) for repo in repositories], "repository_errors": repo_errors, @@ -2603,14 +2664,8 @@ def visit(directory: Path, depth: int = 1) -> int | None: truncated or unwalked_reasons or root_children is not None ), "stdlib_shadowing": stdlib_shadowing, - "children_rollup": children_rollup( - entries, - unknown_paths=unknown_paths, - unwalked_reasons=unwalked_reasons, - ), - "entries": [] - if sizes_only - else sorted(entries, key=lambda entry: entry["path"]), + "children_rollup": rollup_rows, + "entries": sorted(entries, key=lambda entry: entry["path"]), } if sizes_only: payload["inventory_mode"] = "sizes-only" @@ -4948,7 +5003,6 @@ def main(argv: list[str] | None = None) -> int: child_large_reasons and args.max_depth is None and not args.confirmed_large_scan - and not sizes_only ): return emit( { @@ -5023,7 +5077,6 @@ def main(argv: list[str] | None = None) -> int: large_reasons and args.max_depth is None and not args.confirmed_large_scan - and not sizes_only ): immediate_entries, probe_error = top_level_entry_count(target) return emit( diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index 27835e2b90..69c4e6b88b 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -3171,6 +3171,66 @@ def test_root_children_large_selected_child_is_a_next_step_not_a_failure( self.assertEqual(["builds:user-home"], payload["large_target_reasons"]) self.assertFalse((data_root / "snapshot.json").exists()) + def test_sizes_only_plain_target_goes_through_the_large_scan_gate(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + base = Path(temporary) + target = base / "dev-drive" + data_root = base / "plugin-data" + target.mkdir() + (target / "scratch.tmp").write_text("x", encoding="utf-8") + data_root.mkdir() + code, payload = self._scan_target( + target, + data_root, + self._non_os_volume_root_patches(), + extra_args=["--sizes-only"], + ) + self.assertEqual(0, code) + self.assertEqual("large-target-confirmation-required", payload["status"]) + self.assertFalse((data_root / "snapshot.json").exists()) + code, payload = self._scan_target( + target, + data_root, + [], + extra_args=["--sizes-only", "--confirmed-large-scan"], + ) + self.assertEqual(0, code) + self.assertEqual("scan-complete", payload["status"]) + self.assertEqual("sizes-only", payload["inventory_mode"]) + self.assertTrue((data_root / "snapshot.json").exists()) + + def test_sizes_only_root_children_goes_through_the_large_scan_gate(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + base = Path(temporary) + target = base / "os-root" + data_root = base / "plugin-data" + target.mkdir() + data_root.mkdir() + (target / "builds").mkdir() + (target / "builds" / "out.bin").write_text("x", encoding="utf-8") + patches = [ + *self._os_managed_volume_root_patches(target), + mock.patch.object( + hygiene, + "large_scan_reasons", + side_effect=lambda path: ( + ["user-home"] if Path(path).name == "builds" else [] + ), + ), + ] + args = ["--sizes-only", "--root-children", "--root-child", "builds"] + code, payload = self._scan_target(target, data_root, patches, args) + self.assertEqual(0, code) + self.assertEqual("large-target-confirmation-required", payload["status"]) + self.assertFalse((data_root / "snapshot.json").exists()) + code, payload = self._scan_target( + target, data_root, [], [*args, "--confirmed-large-scan"] + ) + self.assertEqual(0, code) + self.assertEqual("scan-complete", payload["status"]) + self.assertEqual("sizes-only", payload["inventory_mode"]) + self.assertTrue((data_root / "snapshot.json").exists()) + def test_linux_volume_root_os_owned_includes_conventional_roots(self) -> None: owned = hygiene.volume_root_os_owned_names("linux") for name in ( @@ -4412,6 +4472,72 @@ def test_sizes_only_bypasses_inventory_entry_cap(self) -> None: ) self.assertTrue(child["walked"]) + def test_sizes_only_totals_match_an_ordinary_scan_without_retaining_entries( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "target" + (root / "tree" / "nested" / "hollow").mkdir(parents=True) + (root / "tree" / "nested" / "data.bin").write_bytes(b"x" * 7) + (root / "tree" / "zero.txt").write_bytes(b"") + (root / "tree" / "linked-a.txt").write_bytes(b"abc") + os.link(root / "tree" / "linked-a.txt", root / "tree" / "linked-b.txt") + (root / "empty").mkdir() + (root / "top.txt").write_bytes(b"yy") + target = root.resolve() + policy = hygiene.load_policy(None) + with mock.patch.object( + hygiene, "linux_mount_points", return_value=(set(), None) + ): + ordinary = hygiene.scan_tree(target, policy) + # Each of these consumes a retained entry list, so none may run + # on the sizes-only walk. + for name in ( + "matching_hints", + "annotate_tracked", + "children_rollup", + "reclaimable_local_bytes", + "empty_directory_paths", + "empty_file_count", + ): + self.enterContext( + mock.patch.object(hygiene, name, side_effect=AssertionError) + ) + sizes = hygiene.scan_tree(target, policy, sizes_only=True) + self.assertNotEqual([], ordinary["entries"]) + self.assertEqual([], sizes["entries"]) + self.assertEqual("exact", sizes["rollup_precision"]) + for field in ( + "children_rollup", + "target_logical_bytes", + "target_reclaimable_local_bytes", + "empty_directory_count", + "empty_directory_paths", + "empty_directory_paths_truncated", + "empty_file_count", + ): + self.assertEqual(ordinary[field], sizes[field], field) + self.assertEqual(7 + 3 + 3 + 2, sizes["target_logical_bytes"]) + self.assertEqual(2, sizes["empty_directory_count"]) + self.assertEqual(1, sizes["empty_file_count"]) + + def test_sizes_only_keeps_a_bounded_sample_of_empty_directories(self) -> None: + many = 2 * hygiene.MAX_EMPTY_DIRECTORY_PATHS + 5 + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "target" + root.mkdir() + for i in range(many): + (root / f"d{i:04d}").mkdir() + target = root.resolve() + policy = hygiene.load_policy(None) + ordinary = hygiene.scan_tree(target, policy) + sizes = hygiene.scan_tree(target, policy, sizes_only=True) + self.assertEqual(many, sizes["empty_directory_count"]) + self.assertTrue(sizes["empty_directory_paths_truncated"]) + self.assertEqual( + ordinary["empty_directory_paths"], sizes["empty_directory_paths"] + ) + def test_sizes_only_depth_cut_marks_partial_rollup(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) / "target"