Skip to content

Report per-workspace disk size in dl --ls --size - #165

Merged
blooop merged 4 commits into
mainfrom
perf/153-ls-disk-size
Aug 9, 2026
Merged

blooop merged 4 commits into
mainfrom
perf/153-ls-disk-size

Conversation

@blooop

@blooop blooop commented Aug 9, 2026 •

Copy link
Copy Markdown
Owner

Closes #153.

dl --ls --size and dl --ls --json --size report what deleting each workspace's clone would free. This is the measurement primitive the disk work on map #139 needs before anything can be called "reduced", and #159 dl --prune imports it rather than growing its own walk, exactly as #155 required.

The decision: exclusive bytes, not apparent size

#154 settled that .bare is the repo's object cache and every workspace hardlinks out of it. An apparent size (du -s per workspace) bills each workspace for the whole shared pool, so it reports away the saving the sharing exists for — and it gets worse as the design gets better, since #154 extends the same hardlinking to LFS.

Measured on one machine — Ubuntu 24.04, ext4, 32 cores, warm page cache, five
runs after a warm-up, machine otherwise busy — with the shipped code, on a real
clone of kinisi_ros made by git from the bare cache this branch's own tooling
maintains:

   64.9 MiB    24.2-  27.7 ms     8309 entries  the clone (shipped exclusive_usage)
  636.0 KiB     0.3-   0.4 ms       87 entries  the bare it hardlinks out of
   59.2 MiB    16.6-  21.1 ms     9124 entries  this repo's own tree, built environment inside it
  699.7 MiB  232.0- 239.3 ms   114817 entries  ~/.pixi

du -s --block-size=1 on that same clone says 353,230,848 B; the shipped
number says 68,050,944 B (64.9 MiB) is what deleting it would free. The gap
is one 270 MB pack file with two links — one in the clone, one in the bare — so
removing either frees neither. du -sc over the clone and the bare together
says the disk really holds 353,882,112 B, and 273 MB of that is the pack
neither tree can free on its own.

Those four rows are what the shipped docs now argue from, in README.md and in
the disk_usage module docstring, with the conditions attached. The figures the
earlier revision shipped — 2.48 GiB / 1.70 GiB / 755.0 MiB / 488.8 MiB, from a
cache since emptied of its workspace clones — are gone from every shipped
file
, including the sample table where 488.8 MiB was the example value. They
were unverifiable rather than merely unverified, and a hedge that lives only in
a PR body does not ship.

The claim they supported is independently verified three times over regardless:
this measurement's 5.2x, the first review's rebuilt-from-scratch ~6.8x, and the
second review's own real-git rebuild at 2.03x per workspace and 15.9x on the
bare.

The honest cost of the choice, stated in the docs rather than hidden: exclusive bytes do not sum to total disk. A pool shared by three workspaces is billed to none of them, because deleting any one frees none of it — it becomes the last holder's the moment it is the last holder. And a workspace's size can change when a sibling is deleted. Both are properties of shared storage; the alternative is a total that over-counts — 5.2x on the clone measured above, 46% on the whole cache in the historical figures.

Documented in --help and in a new README section, per the ticket.

Opt-in, and why

Measured above: 17–28 ms for a ~9,000-entry clone, warm page cache — under the ticket's ~100 ms trigger for these trees. It is opt-in anyway, and the reasoning is measured rather than assumed:

  • the walk is O(files) with no ceiling, and the ordinary devlaunch workspace is not a bare checkout — a devcontainer that builds its environment inside the clone (this repo's own postCreateCommand runs pixi install) is most of the file count, and a 114,817-entry tree measured 232–239 ms warm on the same machine;
  • plain dl --ls is today one devpod round-trip and zero filesystem work, pinned in test_devpod_spawn_counts.py, and it stays that way;
  • Reclaim orphaned disk: stale clone directories and left-behind images #155 already ruled that --ls is not where a third unrelated question goes.

--ls --size adds no subprocess at all — sizes come from lstat, never du, never docker. That is pinned by a case in test_devpod_spawn_counts.py whose workspace is genuinely measurable, and the pin is mutation-checked below.

No zero for "could not tell"

Principle 3, taken literally. exclusive_usage returns a two-armed sum type: Measured(exclusive_bytes) or PartlyUnreadable(at_least_bytes, unreadable). A container writes into its clone as its own user, so a directory this process cannot read is the ordinary case here — and it reports ≥2.0 MiB in the table and {"atLeastBytes": …, "unreadable": 1} in JSON, never a total. "The directory is not there" is Measured(0), which is a real answer and matches what workspace_state.read_clone already says about a clone removed by hand.

JSON shape: disk is absent unless --size was passed (so "nobody asked" cannot read as "nothing on disk"), null for a workspace devlaunch did not create (the same way repo and branch already say "not mine"), and otherwise an object with exactly one of exclusiveBytes / atLeastBytes. A caller cannot arrive at a floor by ignoring a field.

Only devlaunch's own clones are walked — dl ./path opens somebody's project directory and walking it would be both unbounded and none of devlaunch's business.

Out of scope, held

Docker named volumes (#88) and image sizes: untouched, per the map's ruling and #155's. The README says so in one line and points at docker system df; the --purge/--prune boundary line itself is #160's.

What the review changed

The spawn-count guard was hollow and now bites. Its fixture workspace's
source sat outside the patched cache directory, so _measurable_clone returned
None, the cell printed -, and the walk never ran — a devpod du per workspace
passed it under every selection. The fixture now puts the clone under the
patched cache with a payload in it, and the rendered size is asserted before the
spawn counts, so a - in that cell fails the test instead of emptying it.
Mutation-checked, full-suite selection (pixi run -e py310 pytest test/ -q),
same tree, injection then revert:

E       Left contains one more item: ['du', '/tmp/.../devlaunch/repos/myws']
FAILED test/test_devpod_spawn_counts.py::TestHotCommandSpawnCounts::test_ls_with_sizes_spawns_nothing_extra
1 failed, 1196 passed, 23 deselected in 75.15s
1197 passed, 23 deselected in 98.91s

The table and the JSON now measure the same workspaces. "May we measure
this" was answered twice — by the source directory for the table, by the
metadata record for the JSON — and the two already disagreed: a clone under the
cache with no record printed a size and reported "disk": null, which the
README defines as "not dl's to measure". Both now ask _measurable_clone, the
same predicate --purge deletes by, and a record is no longer part of the
question: it can be missing from a cache that is very much on disk. Pinned by
test_the_two_renderings_measure_the_same_workspaces; reverting the fix fails
it (TypeError: 'NoneType' object is not subscriptable) and nothing else.

The 39–46 ms figure is gone. It matched no measurement — not this PR's, not
the reviewer's. Everything the docs now quote is in the table above, with the
machine and conditions named, and no cold-cache number is quoted because none
was taken (dropping the page cache needs root here). The README also no longer
attributes a du figure to "file lengths": du counts blocks, which is what
the code measures.

Also folded in: known_bytes exports "the bytes, whichever arm" so #159 can
order usages without hand-rolling an else; a file or directory that vanishes
between being named and being weighed is now worth nothing rather than unknown,
matching what the root of a walk already answers for a directory that is not
there; and _size_cell no longer means both "column suppressed" and "not
measurable" with one string.

Tests

New coverage: test/unit/test_disk_usage.py (25 cases) — 100% of
devlaunch/disk_usage.py; the --ls/--ls --json cases in
test/test_workspace_state.py; the spawn-count pin.

The exclusive-vs-apparent choice is still mutation-checked. Replacing the
exclusivity condition with plain per-tree dedup — an apparent size — under
full-suite selection (pixi run -e py310 pytest test/ -q):

>       assert MIB <= got < 2 * MIB
E       assert 5246976 < (2 * 1048576)
FAILED test/unit/test_disk_usage.py::TestSharingIsNotBilledTwice::test_a_file_hardlinked_from_outside_is_not_billed_to_the_tree
1 failed, 1196 passed, 23 deselected in 41.11s

Local run, pixi run -e py310 ci end to end as a single task (the docs tidy
adds one rendering case, so 1198):

✨ Pixi task (format in py310): ruff format .
✨ Pixi task (ruff-lint in py310): ruff check . --fix
All checks passed!
✨ Pixi task (pylint in py310): pylint --version && echo 'running pylint...' && pylint $(git ls-files '*.py')
Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)
✨ Pixi task (ty in py310): ty check --respect-ignore-files .
All checks passed!
✨ Pixi task (coverage in py310): coverage run -m pytest && coverage xml -o coverage.xml
===================== 1198 passed, 23 deselected in 33.42s =====================
✨ Pixi task (coverage-report in py310): coverage report -m
devlaunch/disk_usage.py                    83      0   100%

Timing numbers above are warm page cache on one machine, named with them; no cold-cache measurement was taken, because dropping the page cache needs root on that machine.

🤖 Generated with Claude Code

Adds the measurement primitive the disk work on map #139 needs before
anything can be called "reduced": what deleting a workspace's clone
would actually free.

The number is exclusive bytes, not apparent size. A repo is cloned once
into a bare cache and every workspace hardlinks its git objects out of
that one copy, so an apparent size bills each workspace for the whole
shared pool -- reporting away the saving the sharing exists for.
Measured on a bare plus two clones of this repo, `du` says each
workspace's .git costs 2,539,520 B where 258,048 B is what removing it
frees. The choice is pinned by test: swapping in an apparent size fails
the hardlink tests and nothing else.

Opt-in behind --size, because the walk is O(files) with no ceiling
while plain --ls is one devpod round-trip and no filesystem work at
all. Measured on a real cache, a workspace clone is 27.6-32.2 ms warm.

A measurement that could not be completed is never reported as a
number: the result is a two-armed sum type, and a walk that hit an
unreadable directory reports a floor the caller cannot mistake for a
total.

devlaunch/disk_usage.py is deliberately free of devpod, git and
metadata so #159's --prune imports it rather than growing its own walk.

Closes #153

@sourcery-ai sourcery-ai Bot 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.

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements opt-in per-workspace disk usage reporting for dl --ls --size (and JSON variant), using a new exclusive-bytes disk walking module, integrating it into CLI, JSON output, help, README, and tests including spawn-count guarantees and detailed disk-usage behavior.

Sequence diagram for dl --ls --json --size disk usage reporting

sequenceDiagram
    actor User
    participant dl_cli as dl_cli
    participant disk_usage as disk_usage

    User->>dl_cli: _run_cli(["--ls", "--json", "--size"])
    dl_cli->>dl_cli: workspaces_as_json(with_size=True)
    loop per workspace
        dl_cli->>dl_cli: _disk_report(clone_path)
        alt clone_path is None
            dl_cli-->>dl_cli: return None (disk=null)
        else clone_path exists
            dl_cli->>disk_usage: exclusive_usage(clone_path)
            disk_usage-->>dl_cli: DiskUsage (Measured|PartlyUnreadable)
            dl_cli->>disk_usage: usage_as_json(usage)
            disk_usage-->>dl_cli: {exclusiveBytes}|{atLeastBytes, unreadable}
            dl_cli-->>dl_cli: row["disk"] = json object
        end
    end
    dl_cli-->>User: JSON list with optional disk field
Loading

File-Level Changes

Change Details Files
Add exclusive-bytes disk usage computation module and human/JSON renderers.
  • Introduce devlaunch.disk_usage module to compute disk usage based on allocated blocks and hardlink exclusivity.
  • Implement Measured and PartlyUnreadable sum types to represent complete and partial measurements.
  • Walk directory trees with lstat/scandir, counting directories always and files only when all links are inside the tree.
  • Provide describe_usage for human-readable sizes and usage_as_json for structured JSON responses.
devlaunch/disk_usage.py
test/unit/test_disk_usage.py
Extend dl --ls CLI to optionally report per-workspace disk usage in table and JSON outputs.
  • Wire disk_usage into dl.py, importing the new module.
  • Add workspaces_as_json(with_size) and _disk_report() to optionally include nested disk object in JSON when --size is passed, with null for non-devlaunch clones.
  • Add print_workspaces(with_size) and _size_cell() to add a SIZE column with exclusive usage or '-' for foreign workspaces, only when requested.
  • Add _measurable_clone() to restrict disk walks to devlaunch-owned clones and avoid arbitrary project directories.
  • Update CLI argument parsing so dl --ls --size and dl --ls --json --size route to the new logic.
  • Update help text to document --ls --size semantics, exclusivity, non-summing behavior, and JSON disk field.
devlaunch/dl.py
Document the new disk usage reporting behavior and integrate it into README examples.
  • Add dl --ls --size to README command summary table with link to detailed section.
  • Introduce a new "How much disk a workspace costs" section explaining exclusive bytes, non-additive totals, foreign workspace handling, partial measurements, performance considerations, and docker scope.
  • Adjust cleanup example commands to include dl --ls --json --size in the workflow.
README.md
Add tests covering disk usage behavior, JSON/table output, and devpod spawn counts for the new feature.
  • Add unit tests for disk_usage: exclusive counting, hardlink behavior (inside/outside tree), unreadable directories yielding floors, and human/JSON formatting.
  • Add integration-style tests for dl --ls --json --size and dl --ls --size to verify disk field presence/absence, value ranges, foreign workspace handling, unreadable directory floors, and table formatting.
  • Extend devpod spawn-count tests to assert that dl --ls --size introduces no additional devpod or external subprocesses beyond the existing list call.
test/unit/test_disk_usage.py
test/test_workspace_state.py
test/test_devpod_spawn_counts.py

Assessment against linked issues

Issue Objective Addressed Explanation
#153 Add per-workspace disk size reporting to dl --ls and dl --ls --json, measuring the workspace clone directory with hardlink-aware semantics (exclusive vs apparent size), and expose this via a new JSON field consistent with existing output. ✅
#153 Ensure the disk-size measurement is scoped only to devlaunch-owned workspace clones, does not involve Docker volumes or image sizes, does not add extra subprocesses to dl --ls, and is opt-in if it could materially slow listing. ✅
#153 Add tests and documentation for the disk-size reporting feature, including correctness on known-content trees and hardlinked files, JSON schema updates for the new field, and explanation of the exclusive vs apparent size choice in --help and README. ✅

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.14530% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 93.49%. Comparing base (f03158b) to head (f349e79).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
devlaunch/dl.py 97.05% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #165      +/-   ##
==========================================
+ Coverage   93.03%   93.49%   +0.45%     
==========================================
  Files          20       21       +1     
  Lines        2354     2505     +151     
==========================================
+ Hits         2190     2342     +152     
+ Misses        164      163       -1     
Files with missing lines Coverage Δ
devlaunch/disk_usage.py 100.00% <100.00%> (ø)
devlaunch/dl.py 93.09% <97.05%> (+0.18%) ⬆️

... and 1 file with indirect coverage changes

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The SIZE column was sized from its cells only, so a listing whose sizes
are all "-" -- every workspace opened from a path devlaunch did not
clone -- printed a heading wider than the column it heads and shifted
every LAST USED after it. Pinned by a test that fails without the fix.

The refusal arms of the walk are now exercised rather than argued for:
a tree behind an untraversable parent, entries a directory will name
but not stat, and a file handed in where a directory was expected. The
exhaustiveness sentinel joins its sibling on coverage's exclude list,
for the reason already recorded there -- ty rejects any reachable call
to it, so in a build that type-checks the line cannot run.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Two-axis review of dcf157d against merge-base f03158b (git diff f03158b...HEAD), spec = ticket #153. Preflight: 3 workflow runs exist on the head SHA (prek success, CI success, Auto-publish skipped) and every check reports pass, including ci (py310). The axes ran as independent subagents in a fresh worktree and their findings are reported below unmerged and unreranked — where they overlap, they arrived there separately.

Posted as --comment: GitHub refuses --request-changes on a same-account PR. The written verdict at the bottom is the gate, not the GitHub review state.

Standards

  • blocking — test/test_devpod_spawn_counts.py:165 (test_ls_with_sizes_spawns_nothing_extra): the guard is hollow. The spawns fixture lists one workspace whose source is /cache/myws, which is not under the real _get_cache_dir(), so _measurable_clone returns None, the cell prints -, and exclusive_usage is never called. I wrapped devlaunch.disk_usage.exclusive_usage with a counter: 0 calls. I then injected the exact regression the test claims to catch (a devpod du subprocess per measured workspace) and it stayed green under all three selections, including the full suite. This file is the repo's primary round-trip guard; the fix is a fixture workspace whose localFolder sits under the patched cache dir.
  • non-blocking — devlaunch/dl.py:489 vs devlaunch/dl.py:1210: "which directory may we measure" is answered twice by two mechanisms — JSON via clone_mgr.storage.get_worktree_by_workspace_id(...).local_path, table via _measurable_clone → source.path. Duplicated Code / Divergent Change, and they already disagree: with a workspace under the cache but no storage record, JSON reports disk: null while the table reports 2.0 MiB (verified, output below). _measurable_clone's docstring claim that "this is where that boundary is kept" is true for only one of the two outputs; #159 adding a case must edit both, and no test covers the mismatch.
  • non-blocking — devlaunch/disk_usage.py:80: the module is clean of devpod/git coupling (confirmed by reading — imports are os, stat, dataclasses, pathlib, typing only), but _unhandled_usage is private and there is no exported accessor for "the bytes, whatever arm". A downstream --prune gets the sum type without the exhaustiveness helper, so it will hand-roll an else.
  • nit — devlaunch/dl.py:1642: _size_cell returns "" for "column suppressed" and "-" for "not measurable" — one string doing two jobs, next door to a module that built a proper sum type for exactly this.
  • Docs (README "How much disk a workspace costs", --help) state both hazards plainly — non-summing and sibling-deletion. No finding.

Evidence

Verified by execution (pixi run -e py310, review worktree):

  • pytest test/unit/test_disk_usage.py -q → 20 passed in 0.34s
  • pytest test/test_devpod_spawn_counts.py test/test_workspace_state.py -q → 52 passed in 3.52s
  • pytest (full, py310) → 1191 passed, 23 deselected in 54.00s
  • Probe plugin, single test: PROBE exclusive_usage calls: []; full file: same, 24 passed
  • Regression plugin (subprocess injected into size walk): single test 1 passed; full file 24 passed; full suite 1191 passed, 23 deselected in 43.22s
  • Divergence scratch test: JSON devlaunch= True disk= None / table row ... 2.0 MiB 2026-08-08 11:43:27
  • pylint → 10.00/10, exit 0; ruff check . → All checks passed!; ty → All checks passed!
  • git diff <base>...HEAD | grep '^+.*/home/' → only README's elided /home/…/ sample output; no hardcoded user path.

Not run / not verified: full pixi run ci (ran its test, pylint, ruff-lint, ty components individually, not format/coverage); e2e suite; ./dev.sh / dl-next (no real devpod exercised); the README's quoted timings (39–46 ms, 224/707 ms) and byte figures — not reproduced.

Spec

  1. non-blocking — shipped docs cite a walk cost the PR never measured. Spec: "if it costs more than ~100ms per workspace, make it opt-in, e.g. dl --ls --size, and say so." README.md:528 says "one workspace clone costs 39–46 ms to walk with a warm page cache" (repeated at devlaunch/dl.py:1601 and test/test_workspace_state.py:210). The PR body's only measurement says "27.6–32.2 ms per real workspace clone". Same subject, same stated conditions, different number; 39–46 appears in no measurement anywhere. My own run of the shipped walk on a 9,124-entry tree was 17.6–21.0 ms. Doesn't change the conclusion (all under 100 ms) but it is a documented number contradicting its own evidence.
  2. non-blocking — table and JSON disagree on which workspaces are measurable. Spec: "JSON field name and human rendering consistent with the existing table." JSON gates on a storage record (_disk_report(clone_path), dl.py:517) and walks record.local_path; the table gates on source-under-cache (_measurable_clone, dl.py:1210) and walks source.path. Verified by execution: for a clone under the cache with no metadata record, JSON emits "devlaunch": true, "disk": null while the table prints 2.0 MiB. README states "A workspace dl did not create reads - (null in JSON)" — so null here means something the README doesn't cover, and #159's --prune reading JSON would see "not mine" for a tree the table sizes.
  3. nit — README.md:522 attributes the 2.48 GiB figure to "a size that added up file lengths"; that number came from du, which counts blocks, not lengths. The code uses st_blocks (disk_usage.py:93) and only the module docstring says so.
  4. nit — a file vanishing mid-walk (FileNotFoundError from entry.stat) is filed as unreadable, so a race downgrades a total to a floor, while the same error at the root is Measured(0).

Nothing blocking on this axis. Constructive modeling holds: Measured/PartlyUnreadable is genuinely total, no path builds Measured from a partial walk, and absent / null / value are three distinct JSON states.

Opt-in deviation: correct call. The ticket's ~100 ms is a floor for requiring opt-in, not a ceiling forbidding it; it even names --ls --size as the form. O(files) with no ceiling, plus #155's ruling that --ls isn't where extra questions go, justifies it. README says it is opt-in but not that measured cost was under the ticket's trigger — the PR body says so; the docs should.

Evidence

Verified by execution (review worktree, py310 env):

  • Hardlink experiment, own bare + two git clones in scratch. stat confirms shared pack inodes (8270119, nlink 7). Per-tree du -s --block-size=1, separate invocations: bare 3133440, ws1/.git 3203072, ws2/.git 3203072. Shipped code: Measured(exclusive_bytes=401408) / 471040 / 471040. du -sc over all three in one invocation: 4075520. Direction of the PR's claim reproduced (per-tree du over-bills a workspace ~6.8x here) and so is the honest cost — exclusive sums to 1,343,488 against 4,075,520 actually held.
  • Mutation check, full suite (pytest test/ -q -p no:randomly on a git archive copy with links_here >= links removed): 1 failed, 1190 passed, 23 deselected in 60.48s, failing exactly TestSharingIsNotBilledTwice::test_a_file_hardlinked_from_outside_is_not_billed_to_the_tree — assert 5246976 < (2 * 1048576). The pin is CI-effective, not isolation-only; baseline 1191 confirmed.
  • Selection test/unit/test_disk_usage.py test/test_workspace_state.py test/test_devpod_spawn_counts.py: 72 passed in 9.07s.
  • Divergence in finding 2: scratch pytest with an empty record store; verbatim table row r-feature-aaa local … 2.0 MiB 2026-08-08 11:43:27 against JSON devlaunch= True disk= None.
  • Timing, warm, 3 runs after a warm-up, worktree root (9,124 entries): 20.7ms 21.0ms 20.3ms; .pixi (8,888 entries): 18.8ms 17.6ms 17.9ms.
  • Cold-cache disclosure is present in the PR body: "no cold-cache measurement was taken (dropping the page cache needs root on this host), which is stated rather than estimated."

Not run / not verified: the PR's own host numbers (2.48 GiB / 1,824,649,216 B, 8,956 entries / 45.5 ms, 114,611-entry 224 ms / 707 ms) — different machine and cache, not reproducible there; pixi run -e py310 ci not run by this axis; no cold-cache measurement taken.

Verdict

Request changes.

The design decision at the centre of this PR is sound and, unusually, verified: the Spec axis rebuilt the hardlink experiment from scratch with real git and reproduced the direction and the honest cost of exclusive-vs-apparent independently, and it confirmed by full-suite mutation that the exclusivity pin is CI-effective rather than isolation-only. Constructive modeling holds. The opt-in deviation from the ticket's literal ~100 ms trigger is judged correct — the threshold is a floor for requiring opt-in, not a ceiling forbidding it.

One blocking finding:

  1. test_ls_with_sizes_spawns_nothing_extra does not guard anything (test/test_devpod_spawn_counts.py:165). The fixture's workspace source (/cache/myws) is not under the patched _get_cache_dir(), so _measurable_clone returns None and exclusive_usage is called 0 times under that test. The Standards axis injected the exact regression the test claims to catch — a subprocess per measured workspace on the size path — and the suite stayed green under single-test, whole-file, and full-suite selection. The PR body advertises this case as the pin for "--ls --size adds no subprocess at all"; it currently pins a code path that never executes. Fix: give the fixture a workspace whose localFolder sits under the patched cache dir, and confirm the guard goes red on the injected regression before it goes green.

Two non-blocking findings are worth folding in while the branch is open, both raised independently by each axis:

  • Table and JSON gate measurability by two different mechanisms and already disagree — a clone under the cache with no storage record renders 2.0 MiB in the table and "disk": null in JSON. That is a documented-behaviour mismatch (null is README-defined as "a workspace dl did not create") and it is exactly the surface #159's --prune will read.
  • README.md:528 states 39–46 ms where the PR's only measurement says 27.6–32.2 ms, and a third-party run measured 17.6–21.0 ms. On a ticket whose entire product is numbers, a shipped doc figure that matches no measurement should be corrected or sourced.

Neither of those blocks on its own; the hollow guard does.

The spawn-count pin for `--ls --size` was hollow: its fixture workspace
sat outside the patched cache directory, so nothing was measurable, the
walk never ran, and a `devpod du` per workspace passed it unnoticed. The
fixture now puts the clone under the cache with a payload in it and
asserts the rendered size before the spawn counts, so a `-` in that cell
fails the test rather than emptying it.

"May we measure this workspace" was answered twice, by the source
directory for the table and by the metadata record for the JSON, and the
two already disagreed: a clone under the cache with no record printed a
size and reported `disk: null`. Both now ask _measurable_clone, which is
the predicate `--purge` deletes by, and a test pins the agreement.

A file that vanishes between being named and being weighed is worth
nothing rather than unknown -- a live cache repacks and writes, and only
a door that will not open should make the answer a floor. known_bytes
exports "the bytes, whichever arm", so a caller ordering usages does not
hand-roll an else.

The walk timings are re-measured and the conditions named; the figures
that matched no measurement are gone, and no cold-cache number is quoted
because none was taken.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Re-verdict after the retry. Two-axis review of 6e7e213 against merge-base f03158b (git diff f03158b...HEAD, +869/−34, 7 files), spec = ticket #153. I am a fresh reviewer; I wrote neither the code nor the previous review.

Preflight confirmed independently: 3 workflow runs exist on the head SHA (CI success, prek success, Auto-publish skipped), MERGEABLE/CLEAN, and every check passes including codecov/patch and ci (py310).

The blocking finding is CLOSED. Re-derived by me, not accepted on the builder's word — see the first evidence block.

Posted as --comment: GitHub refuses --approve on a same-account PR. The written verdict at the bottom is the gate, not the GitHub review state.

What I re-derived vs. what I accepted as previously covered

Re-derived from scratch: every mutation result below (12 distinct mutations, all under full-suite selection); the exclusive-vs-apparent design crux, rebuilt with my own git clone from a bare; the agreement between _measurable_clone, --purge and devlaunch: true, read out of the source; the disappearance of the 39–46 ms / 45.5 ms / 707 ms / 224 ms figures; the absence of hardcoded /home/<user>.

Accepted as previously covered, not re-derived: the opt-in deviation from the ticket's literal ~100 ms trigger (upheld — the threshold is a floor for requiring opt-in, not a ceiling forbidding it, and the ticket names --ls --size itself), and the constructive-modeling crux (Measured/PartlyUnreadable genuinely total, no path builds Measured from a partial walk, absent / null / value three distinct JSON states). I re-read both and found nothing to reopen.


⚠️ Reviewer-side disclosure: worktree contamination, and what was done about it

I gave both axis subagents the same worktree and both mutated source in it concurrently. The Standards axis caught this via ps, found a mutation in its tree that it had not written, and traced a false green of its own to the other axis's in-flight edit. It redid its results in an isolated git archive tree. This is my error, not the builder's, and it means some axis-reported mutation results were produced under contaminated conditions.

Remediation: after both agents finished I asserted the worktree byte-identical to 6e7e213 (git show 6e7e213:<file> compared to disk) and re-ran the four load-bearing mutations myself, serially, with a pristine-check before each and a mutation-still-present check after each. Those four re-derived results are the ones the verdict rests on. Where an axis result was not re-derived, it is marked as such below and carries no weight in the verdict.


Standards

  • non-blocking — known_bytes() is Speculative Generality. devlaunch/disk_usage.py:194. No production caller; the only references are in test/unit/test_disk_usage.py. It is exported for #159, which is not in this PR. On the misuse question: the only thing stopping a consumer printing this number and turning a floor into a total is 6 lines of docstring plus a test-class preamble — prose, not type — and a caller who wants "the bytes" reaches for the function literally named "the bytes". Under principle 2, land it in #159 alongside the ordering caller that makes it real and testable end-to-end. The exhaustiveness argument for the function living in this module is sound; it just needs something to call it.
  • non-blocking — one explanation copied five times, one measurement set three times. The exclusive-vs-du rationale lives at disk_usage.py:9-31, README.md:492-509, test/unit/test_disk_usage.py:3-14, dl.py:519-533 and dl.py:2160-2171; the figures in three of those, the timings in three more. Shotgun Surgery: re-measure on new hardware and six places drift. One canonical home (the module docstring) with the rest pointing at it.
  • non-blocking — a documented term of the sum is unpinned. disk_usage.py:159, whose behaviour :106-108 explicitly claims ("Directories are always this tree's… their own blocks count"). Replacing it with total += 0 leaves the full suite green — test_a_tree_of_known_content_reports_what_it_holds's 4*MIB <= got < 5*MIB window is a whole MiB wide, so nothing notices. Given this PR's history of a hollow guard, worth one tight assertion.
  • nit — sizes / zip(strict=True) is machinery without a job. dl.py:1622-1634. sizes is constructed by the two preceding lines, so strict=True guards an invariant that cannot fail; and with_size: bool and cache_dir: Optional[Path] are two representations of one fact. One comprehension says it. On the _size_cell item proper: print_workspaces has not grown a second job — column layout was always its job and sized() makes one column optional cleanly. Only the list-then-zip step is surplus.
  • nit — _size_cell/_disk_report continue dl.py's accretion (1010 statements). Both are pure rendering adapters over disk_usage. _measurable_clone genuinely belongs in dl.py; these two need not. Pre-existing Divergent Change, not this PR's to fix.
  • Upheld, no finding — the measurability root cause. Gating on is_devlaunch_clone rather than the metadata record is the right root cause, not a consistency patch.
  • Clean on the repo rules. No scope creep (README.md:546-547 rules Docker images/volumes out in two sentences; no code touches them). No hardcoded /home/<user> — README's samples use the elided /home/…/. The new _unhandled_usage\( coverage exclusion (pyproject.toml:165) is legitimate: it mirrors the adjacent _unhandled_source\( under the same ty-proves-unreachable justification, and disk_usage.py is 83/83 otherwise.

Spec

  1. blocking (as labelled by this axis) — un-verified figures shipped un-hedged, in the present tense. Spec: "report the workspace's exclusive bytes, or clearly label the number as apparent size — decide during build, document the choice in --help and README." The docs justify that choice with figures the author states are unreproducible. README.md:505-507 — "On a real cache, three workspaces of one repo read as 2.48 GiB that way, against the 1.70 GiB du says the disk actually holds…"; disk_usage.py:16-18 — "Measured on a real cache: one ROS workspace reads 755.0 MiB… against 488.8 MiB exclusive". The relabelling ("not reproducible… recorded here as history, not re-verified") exists only in the PR body, which does not ship; 488.8 MiB is also the sample value in README's example table (README.md:493). Meanwhile the live replacement the builder already measured — du -s 353,230,848 B vs 68,050,944 B exclusive, one 270 MB pack with two links — appears nowhere in any .py or .md (grep, exit 1). Same paragraph, two figures: the timings carry OS/fs/cache/run-count/load, the exclusive-vs-apparent figure carries nothing.
  2. non-blocking — the measurability predicate is correct; one note for #159. _measurable_clone (dl.py:1221-1240) gates on is_devlaunch_clone, and workspaces_as_json computes devlaunch from the same call (dl.py:489), so "devlaunch": true ⟺ disk != null by construction, and it is exactly what --purge deletes by (purge_all_data → workspace_ownership → is_devlaunch_clone). The isinstance(source, LocalFolder) conjunct is redundant but harmless (it is needed for type narrowing). Note for #159: is_devlaunch_clone compares PurePath lexically with no resolve() — precisely the symlinked-cache trap #159 calls "a total-loss bug". Benign here (a symlinked cache merely understates to -); it must not be reused as-is for Referenced.
  3. non-blocking — the removed-clone edge is documented but unpinned. disk_usage.py:76-78 asserts that a directory that is not there is Measured(0), "the same answer workspace_state gives for a clone that has already been removed by hand" — so a devlaunch workspace whose clone was deleted by hand reads 0 B / {"exclusiveBytes": 0}, not -/null. Making _size_cell/_disk_report return -/None for a non-existent clone leaves the full suite green (re-derived by me, below). Nothing at the dl.py boundary holds "measured zero" apart from "not measurable", and #159 classifies exactly those directories. 0 B is the right answer; it deserves a pin.
  4. non-blocking — the vanishing-entry downgrade: the argument holds. The two situations are distinguished at the right seam — FileNotFoundError (the bytes no longer exist and free nothing) versus any other OSError (the bytes exist behind a door). ENOENT cannot mask EACCES, so no uncertainty is laundered. A rename mid-walk can under-count, never over-count, which is the safe direction for a --prune that must not over-claim reclaimable bytes.
  5. nit — timings supported, upper bound not conservative. Entry counts reproduce exactly (~/.pixi = 114,817). This axis's warm five-run range on ~/.pixi was 240.5–274.5 ms against README's quoted 232–239 ms. README hedges ("one machine's numbers… yours will differ"), so this is a nit.
  6. nit — three names for one concept. Spec: "JSON field name and human rendering consistent with the existing table." Flag --size, column SIZE, JSON key disk, values exclusiveBytes/atLeastBytes. Defensible (disk is an object, not a size) but not literally consistent.
  7. nit — unknown flags after --ls are silently ignored (dl.py:2313-2318): dl --ls --siz prints the plain table with no complaint. Pre-existing pattern.

Acceptance criteria met. "a workspace tree of known content reports the expected bytes" → test_a_tree_of_known_content_reports_what_it_holds. "JSON schema gains the field" → disk. "hardlinked pack files are not double-counted" → pinned and mutation-verified. "document the choice in --help and README" → dl.py:2160-2171 + README §"How much disk a workspace costs" (presence satisfied; content disputed by finding 1). "Docker named volumes and image sizes are explicitly out" → zero docker references in the devlaunch/ diff. "must not slow --ls unacceptably" → opt-in plus the spawn-count pin.


Evidence

Re-derived by me, serially, after both subagents finished

Selection for every line in this block: pixi run -e py310 pytest test/ -q (full suite) in a worktree at 6e7e213. Before each mutation, devlaunch/dl.py and devlaunch/disk_usage.py were compared byte-for-byte against git show 6e7e213:<file> and the check printed PRISTINE; after each run, git diff --stat confirmed the mutation was still present.

Baseline: 1197 passed, 23 deselected in 86.09s

The blocking finding — one devpod du per measured workspace injected into _size_cell:

E       AssertionError: assert [['list', '--.../repos/myws']] == [['list', '--output', 'json']]
E         Left contains one more item: ['du', '/tmp/pytest-of-ags/pytest-413/test_ls_with_sizes_spawns_noth0/devlaunch/repos/myws']
FAILED test/test_devpod_spawn_counts.py::TestHotCommandSpawnCounts::test_ls_with_sizes_spawns_nothing_extra
1 failed, 1196 passed, 23 deselected in 73.80s

The guard now bites the exact regression that sailed past it before. Closed.

Divergence pin — JSON measurability reverted to the metadata record (_disk_report(clone_path)):

E       TypeError: 'NoneType' object is not subscriptable
FAILED test/test_workspace_state.py::TestReportingWhatAWorkspaceCostsOnDisk::test_the_two_renderings_measure_the_same_workspaces
1 failed, 1196 passed, 23 deselected in 64.80s

Exclusivity pin — links_here >= links → plain per-tree dedup (an apparent size):

E       assert 5246976 < (2 * 1048576)
FAILED test/unit/test_disk_usage.py::TestSharingIsNotBilledTwice::test_a_file_hardlinked_from_outside_is_not_billed_to_the_tree
1 failed, 1196 passed, 23 deselected in 50.68s

Spec finding 3 — _size_cell/_disk_report return -/None for a clone that is not on disk:

1197 passed, 23 deselected in 56.67s

Green: the documented Measured(0) answer for a hand-removed clone is unguarded at the dl.py boundary.

Also run by me, before the subagents were spawned (same worktree, no other writer)

Selection for all eight: pixi run -e py310 pytest test/ -q (full suite); baseline 1197 passed, 23 deselected in 36.17s; tree confirmed clean before and after. Four of these eight are the ones re-derived above and reproduced identically. The other four: vanishing entry re-filed as unreadable → 2 failed, 1195 passed (both TestWhatVanishesMidWalk cases); disk always emitted → 1 failed (test_the_listing_says_nothing_about_disk_unless_asked); size_width heading term removed → 1 failed (test_the_size_column_lines_up_with_its_heading, assert 103 == 106); floor rendered as exclusiveBytes → 2 failed (both floor tests); follow_symlinks=True → 1 failed (test_a_symlink_is_worth_its_own_size_not_its_targets). 12 of 12 mutations bite. I found no hollow pin.

The design crux, rebuilt independently

My own bare + two real git clones (not the builder's cache, not the previous reviewer's). One pack inode 30153421, nlink 3, shared by all three trees:

du -s per tree:   bare 1,953,792   ws2 3,600,384   ws3 3,600,384
du -sc all three: 5,492,736
exclusive_usage:  bare 122,880     ws2 1,769,472   ws3 1,769,472   (sum 3,661,824)

Per-tree du over-bills a workspace 2.03x and the bare 15.9x; the 1,830,912 B pack is billed to nobody, and exclusive sums to less than the disk holds — both stated consequences reproduced. uptime at the time: load average: 3.35, 2.70, 3.44 (machine under load from sibling agents).

Verified by execution (axis subagents)

  • Standards, isolated git archive tree, full suite: baseline 1197 passed; defeating the ownership predicate → 2 failed, 1195 passed; disk_usage.py:159 → total += 0 → 1197 passed (the unpinned term, finding 3).
  • Standards, shared worktree: pixi run -e py310 ci exit 0 — 1197 passed, 23 deselected in 35.09s, devlaunch/disk_usage.py 83 0 100%, TOTAL 2460 164 93%. pixi run -e py310 lint exit 0 — ruff All checks passed!, ty All checks passed!, pylint 10.00/10.
  • Spec, shared worktree, full suite: root FileNotFoundError → PartlyUnreadable → 1 failed; describe_usage floor without ≥ → 2 failed. Timings taken with the shipped walk, warm, five runs after a warm-up, load average: 4.24, 2.77, 3.49: ~/.pixi entries=114,817 → 274.5, 273.7, 261.0, 243.8, 240.5 ms; repo tree entries=9,479 → 18.8, 17.9, 17.4, 18.4, 18.4 ms.
  • Grep (reported as grep, not as execution): 39–46, 27.6, 45.5, 224 ms, 707 ms appear in no .py or .md; the live 353,230,848 / 68,050,944 figures appear in none either (exit 1); no hardcoded /home/<user> in the diff's added lines.

Not run / not verified

  • ./dev.sh / dl-next --ls --size against a real devpod and a real cache — not run by anyone. Still the builder's own stated gap.
  • The kinisi_ros row (8,309 entries, 24.2–27.7 ms) — not run; no such cache exists on this machine. Its entry count is unverified. The other two timing rows reproduced.
  • The 2.48 GiB / 1.70 GiB / 755.0 MiB / 488.8 MiB figures — not verified and not verifiable; the cache they came from has been emptied. That is the substance of Spec finding 1.
  • Cold-cache timings — not taken (dropping the page cache needs root here), matching the PR's own disclosure.
  • Two Spec-axis mutation results were produced in the contaminated window and are not re-derived; they support only nits and carry no weight in the verdict.
  • Interaction with #147's fresh_clone_manager fixture: not investigated beyond noting both touch per-test cache state. No semantic conflict found; none looked for in depth. PR is MERGEABLE/CLEAN.

Verdict

Approve.

The blocking finding is closed. test_ls_with_sizes_spawns_nothing_extra now guards what it advertises: I injected the exact regression that previously sailed past it under every selection — one devpod du per measured workspace — and it failed, under full-suite selection, in a tree verified byte-identical to 6e7e213 immediately beforehand. The fix is the right one: the fixture's clone sits under the patched cache dir with a real payload, and the rendered 2.0 MiB is asserted before the spawn counts, so a - in that cell fails the test rather than silently emptying it. I did not take the builder's hollowness re-audit on trust — I ran 12 mutations of my own choosing across the walk, the renderings, the JSON gating, the column layout and the ownership predicate, all under full-suite selection, and all 12 bite. I found no hollow pin.

Rulings on the three judgement items:

  • The measurability predicate is right, not merely consistent. "Source under the cache" is the correct definition of "devlaunch owns this" because it is the same predicate --purge destroys by and the same call that produces devlaunch: true — so "devlaunch": true ⟺ disk != null holds by construction rather than by two functions agreeing. Gating on the metadata record was the genuine root cause of the divergence: the record can be absent from a cache that is very much on disk, and it is append-mostly, so it drifts from disk in both directions. #159 inherits a boundary that already matches deletion. One caveat handed forward: the containment test is lexical with no resolve(), which is benign here but must not be reused as-is for #159's Referenced.
  • The vanishing-file downgrade holds. A vanished file and an unreadable one are different, and the code now distinguishes them at exactly the right seam: ENOENT means the bytes are gone and free nothing; any other OSError means the bytes exist behind a door and the answer is a floor. ENOENT cannot mask EACCES, so no uncertainty is laundered — and a mid-walk rename can only under-count, which is the safe direction for a consumer deciding what to reclaim. Making ≥ the routine answer on a live cache would have degraded the one signal PartlyUnreadable exists to carry.
  • The retained relabelled figure should go the way of the other two. Retaining it un-hedged in shipped docs is not acceptable. The hedge the builder wrote lives only in the PR body, which no user reads, while README.md:505-507 and disk_usage.py:16-18 state the number in the present tense beside timings that carry full conditions — and 488.8 MiB is the sample value in README's own example table. The builder already measured a live, reproducible replacement (du -s 353,230,848 B vs 68,050,944 B exclusive on one clone, one 270 MB pack with two links) and shipped it nowhere. This is the one thing that should not reach main as it stands, and the fix is to move a number that is already written.

I considered blocking on that, and the Spec axis did label it blocking. I am not blocking, for three reasons, and I am recording the override rather than quietly reranking: the claim the figure supports is now independently verified three separate times (my own real-git rebuild at 2.03x/15.9x, the previous review's at ~6.8x, the builder's live 5.2x), so no reader is misled about the decision itself; the previous review graded a strictly worse instance — a figure matching no measurement anywhere — as non-blocking, and blocking a milder one now would be reviewer ratcheting; and the code that merges is correct and, for the first time on this PR, genuinely pinned. Treat it as a pre-merge docs tidy — replace the four stale figures with the live measurement, or hedge them in-doc as the PR body already does — rather than a re-review cycle.

Worth folding in while the branch is open, none of them blocking: known_bytes() has no production caller and its only defence against a consumer printing a floor as a total is a docstring, so it belongs in #159 with the caller that makes it real; the Measured(0) answer for a hand-removed clone is documented but unpinned (a mutation removing it leaves the full suite green) and #159 classifies exactly those directories; disk_usage.py:159's directory-blocks term is likewise unpinned behind a MiB-wide assertion window; and one explanation now exists in five places with the figures in three, which is where the next re-measurement will drift.

The exclusive-vs-apparent case was argued in the shipped docs with four
numbers from a cache that has since been emptied -- unverifiable now, and
stated in the present tense beside timings that carry their full
conditions, so nothing told a reader which was which. One of them was
also the sample value in README's example table. The hedge existed only
in the pull request body, which nobody installs.

They are replaced by the measurement actually taken on this branch, with
its conditions: one real clone against `du`, the bare it hardlinks out
of, and the two together. It makes the same point with a stronger one --
a 5.2x over-bill traced to a single named pack file with two links --
and the non-summing consequence is now readable off the same four rows
instead of asserted.

The explanation had accumulated in five places and the figures in three,
which is where the next re-measurement drifts. The module docstring is
now the one home for why-and-how-much; the rendering adapters and the
test modules point at it, and the timings live once, in README.

Docs only: no executable line changed.
@blooop
blooop merged commit ebe2cb8 into main Aug 9, 2026
11 checks passed
@blooop
blooop deleted the perf/153-ls-disk-size branch August 9, 2026 14:17
blooop added a commit that referenced this pull request Aug 10, 2026
Four review items on the #171 fix, none of them in the fix itself.

`dl --ls --json` gated `unsaved` on the metadata record while `disk` gated
on the clone directory, so a clone under the cache with no record printed
`devlaunch: true`, a measured `disk`, and `unsaved: null` -- and `null` is
documented as "no clone of dl's here". #171's own sentinel, one layer out,
and the divergence PR #165 already closed for `disk`. `unsaved`, `path` and
`checkedOut` now fall back to `_measurable_clone`, so `unsaved: null` and
`devlaunch: false` are the same set by construction.

`--work-tree` was load-bearing and unguarded: dropping it left the suite
green, but `core.worktree` pointing elsewhere makes `git status --porcelain`
print nothing at rc 0 on a clone holding an untracked file. Test added that
goes red without the flag.

`read_clone` asked `Path.is_dir()`, which cannot express "I was not allowed
to look" and answers differently per Python: <=3.12 raised PermissionError
out of the listing, 3.13+ returns False and reads as nothing-to-lose. The
errno is read directly now.

Two docstrings claimed more than was measured: `_git` said all five broken
shapes answered about the ancestor under plain `cwd=` (four did; a truncated
gitfile is a hard error in git and was never part of the bug), and
`CloneState` asserted an invariant its own new test disproves.
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.

Report per-workspace disk size in dl --ls

1 participant