devlaunch 0.0.23__tar.gz → 0.0.25__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {devlaunch-0.0.23 → devlaunch-0.0.25}/PKG-INFO +133 -21
  2. {devlaunch-0.0.23 → devlaunch-0.0.25}/README.md +132 -20
  3. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/completions/dl.bash +1 -1
  4. devlaunch-0.0.25/devlaunch/disk_usage.py +244 -0
  5. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/dl.py +525 -108
  6. devlaunch-0.0.25/devlaunch/tools.py +693 -0
  7. devlaunch-0.0.25/devlaunch/workspace_state.py +441 -0
  8. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/worktree/locks.py +12 -2
  9. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/worktree/workspace_clone.py +82 -11
  10. {devlaunch-0.0.23 → devlaunch-0.0.25}/pyproject.toml +2 -1
  11. devlaunch-0.0.23/devlaunch/tools.py +0 -219
  12. devlaunch-0.0.23/devlaunch/workspace_state.py +0 -150
  13. {devlaunch-0.0.23 → devlaunch-0.0.25}/.gitignore +0 -0
  14. {devlaunch-0.0.23 → devlaunch-0.0.25}/LICENSE +0 -0
  15. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/__init__.py +0 -0
  16. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/aid.py +0 -0
  17. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/completion.py +0 -0
  18. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/completion_loader.py +0 -0
  19. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/completions/__init__.py +0 -0
  20. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/devpod_provider.py +0 -0
  21. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/devpod_ssh.py +0 -0
  22. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/gh_auth.py +0 -0
  23. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/tty_session.py +0 -0
  24. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/workspace_id.py +0 -0
  25. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/worktree/__init__.py +0 -0
  26. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/worktree/branch_manager.py +0 -0
  27. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/worktree/config.py +0 -0
  28. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/worktree/migration.py +0 -0
  29. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/worktree/models.py +0 -0
  30. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/worktree/repo_manager.py +0 -0
  31. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/worktree/storage.py +0 -0
  32. {devlaunch-0.0.23 → devlaunch-0.0.25}/devlaunch/xdg.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlaunch
3
- Version: 0.0.23
3
+ Version: 0.0.25
4
4
  Summary: DevLaunch - A streamlined CLI for devpod workspaces
5
5
  Project-URL: Source, https://github.com/blooop/devlaunch
6
6
  Project-URL: Home, https://github.com/blooop/devlaunch
@@ -34,7 +34,7 @@ A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete an
34
34
  [![PyPI](https://img.shields.io/pypi/v/devlaunch)](https://pypi.org/project/devlaunch/)
35
35
  [![Conda](https://img.shields.io/badge/conda-v0.0.9-brightgreen?logo=anaconda)](https://prefix.dev/channels/blooop/packages/devlaunch)
36
36
  [![License](https://img.shields.io/github/license/blooop/devlaunch)](https://opensource.org/license/mit/)
37
- [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/downloads/)
37
+ [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://www.python.org/downloads/)
38
38
  [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh)
39
39
 
40
40
  ## Installation
@@ -235,6 +235,7 @@ than the filesystem has actually done.
235
235
 
236
236
  | Command | Description |
237
237
  |---------|-------------|
238
+ | `dl <user/repo> up` | Start (or create) the workspace without attaching — for prewarming a container before a session wants it |
238
239
  | `dl <user/repo> stop` | Stop the workspace |
239
240
  | `dl <user/repo> rm, prune` | Delete the workspace |
240
241
  | `dl <user/repo> code` | Open in VS Code |
@@ -340,6 +341,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
340
341
  |---------|-------------|
341
342
  | `dl --ls` | List all workspaces |
342
343
  | `dl --ls --json` | The same list as JSON, with each workspace's repo, branch, state and [unsaved work](#cleaning-up-workspaces) — for tools that decide what to clean up |
344
+ | `dl --ls --size` | Add [what deleting each workspace would free](#how-much-disk-a-workspace-costs). Opt-in: it walks every file in the clone |
343
345
  | `dl --install` | Install shell completions |
344
346
  | `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
345
347
  | `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
@@ -453,8 +455,9 @@ from an abandoned one. So `dl` supplies the two halves a tool that *does* know
453
455
  needs, and that tool drives the cleanup:
454
456
 
455
457
  ```bash
456
- dl --ls --json # what exists, and what each workspace holds
457
- dl <workspace> rm # remove one
458
+ dl --ls --json # what exists, and what each workspace holds
459
+ dl --ls --json --size # ...and what removing each one would free
460
+ dl <workspace> rm # remove one
458
461
  ```
459
462
 
460
463
  The JSON reports, per workspace: `id`, `devlaunch` (did `dl` create it),
@@ -469,24 +472,39 @@ cleanup tool must not ignore — `unsaved`:
469
472
  "repo": "blooop/devlaunch",
470
473
  "branch": "wayfinder/devlaunch-80",
471
474
  "state": "Stopped",
472
- "unsaved": "2 uncommitted change(s) (pixi.lock, notes.md) and 1 unpushed commit(s)"
475
+ "unsaved": {
476
+ "wouldLose": "2 uncommitted change(s) (pixi.lock, notes.md) and 1 unpushed commit(s)"
477
+ }
473
478
  }
474
479
  ```
475
480
 
476
- `unsaved` is a description of what deleting would destroy, or `null` when the
477
- clone holds nothing that does not also exist on a remote — uncommitted changes
478
- (untracked files included) and commits no remote has. The changed paths are
479
- named, not just counted, and that matters more than it looks: a devcontainer
480
- that runs a package install in its `postCreateCommand` can leave a tracked
481
- lockfile modified in *every* workspace it builds this repo's own does and as
482
- a bare count that is indistinguishable from an hour of unsaved work. A cleanup
483
- tool believing the count would then never clean anything. Named, it is
484
- judgeable. A workspace `dl` did not
485
- create reports `devlaunch: false` with no repo, branch or `unsaved`: there is no
486
- clone of `dl`'s to protect, and it has no business inspecting your checkout.
487
-
488
- **`dl <workspace> rm` refuses when the clone holds unsaved work**, so a caller
489
- that forgets to read the field is still caught:
481
+ `unsaved` is an object with exactly one key, and the key says which of three
482
+ answers it is:
483
+
484
+ | `unsaved` | Meaning |
485
+ | --- | --- |
486
+ | `{"nothingToLose": true}` | Everything in the clone exists on a remote too. Deleting it costs nothing. |
487
+ | `{"wouldLose": "<what>"}` | Uncommitted changes (untracked files included), commits no remote has, or both. |
488
+ | `{"couldNotTell": "<why>"}` | `git` could not read the clone as a repository a half-removed `.git`, an interrupted delete. The files are still there and nothing has established that they exist anywhere else. |
489
+
490
+ The changed paths are named, not just counted, and that matters more than it
491
+ looks: a devcontainer that runs a package install in its `postCreateCommand` can
492
+ leave a tracked lockfile modified in *every* workspace it builds — this repo's
493
+ own does and as a bare count that is indistinguishable from an hour of unsaved
494
+ work. A cleanup tool believing the count would then never clean anything. Named,
495
+ it is judgeable. A workspace `dl` did not create reports `devlaunch: false` and
496
+ no `unsaved` — `unsaved` is `null` exactly where `devlaunch` is `false`, and
497
+ nowhere else: there is no clone of `dl`'s to protect, and it has no business
498
+ inspecting your checkout. (`repo` and `branch` are a weaker test and not the
499
+ same set: they come from `dl`'s metadata record, and a clone `dl` owns can have
500
+ lost its record while the clone and the work in it are still on disk. That clone
501
+ is inspected and reported like any other.)
502
+
503
+ **`dl <workspace> rm` refuses to delete a clone it would lose work from — when
504
+ the recorded clone holds unsaved work, and when it cannot tell what that clone
505
+ holds**, so a caller that forgets to read the field is still caught. (Recorded,
506
+ because that is the directory the guard reads; the case with no record is
507
+ neither, and is described below.)
490
508
 
491
509
  ```
492
510
  $ dl blooop/repo@feature rm
@@ -494,14 +512,108 @@ error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
494
512
  Push or commit it, or run: dl blooop/repo@feature rm --force
495
513
  ```
496
514
 
515
+ ```
516
+ $ dl blooop/repo@feature rm
517
+ error: devlaunch-repo-feature-xyz: git could not read /home/…/repo/feature:
518
+ fatal: not a git repository. devlaunch will not delete a clone it cannot
519
+ check. Look at it, or run: dl blooop/repo@feature rm --force
520
+ ```
521
+
497
522
  That refusal is the only judgement `dl` makes here, and it is not about finished
498
- work — it is `dl` declining to destroy the only copy of something. Say `--force`
499
- if you mean it.
523
+ work — it is `dl` declining to destroy the only copy of something, including
524
+ when it cannot prove there is another copy. Say `--force` if you mean it.
525
+
526
+ The guard reads `dl`'s metadata record, so the recorded directory is the one it
527
+ asks about. (The delete does not always remove that same directory: when the
528
+ recorded path is not on disk it falls back to a derived one. That divergence is
529
+ older than this guard and is tracked as devlaunch#174.) One case is therefore
530
+ neither a refusal nor a delete: a clone under `dl`'s cache that has **no** record — a metadata write
531
+ that failed, a record pruned, a cache restored without one. The listing still
532
+ reports what that clone holds, so `unsaved` is the field to read; but `rm`
533
+ removes the devpod workspace, exits `0` without asking for `--force`, and leaves
534
+ the clone on disk, because there is no recorded directory for it to remove
535
+ either. Nothing is destroyed, and nothing then points at the clone: it is yours
536
+ to keep or to `rm -rf` by hand.
500
537
 
501
538
  [`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
502
539
  names its branches after its tickets, so it knows which workspaces belong to
503
540
  finished work and removes those.
504
541
 
542
+ ### How much disk a workspace costs
543
+
544
+ `dl --ls --size` adds a `SIZE` column, and `dl --ls --json --size` adds a `disk`
545
+ object beside the other per-workspace facts:
546
+
547
+ ```
548
+ $ dl --ls --size
549
+ WORKSPACE TYPE SOURCE SIZE LAST USED
550
+ kinisi-ros-main-lubadaha local /home/…/repos/kinisi-robotics/kinisi_ros/main 64.9 MiB 2026-08-08 11:43:27
551
+ my-own-checkout local /home/…/projects/scratch - 2026-08-01 09:12:04
552
+ ```
553
+
554
+ **The number is what deleting that workspace would give back, not what `du`
555
+ prints.** Those differ, and the gap is the point of the design. A repo is cloned
556
+ once into a bare cache and every workspace clone hardlinks its git objects out
557
+ of that one copy, so the objects exist once on disk however many workspaces
558
+ share them. A size that walked each workspace on its own — which is what `du`
559
+ does when you point it at one directory, counting the blocks every file in it
560
+ occupies — bills each workspace for the whole shared pool.
561
+
562
+ The measurement the row above comes from, taken with the shipped code on one
563
+ machine (Ubuntu 24.04, ext4, warm page cache) on a real clone of that repo made
564
+ by `git clone` from the bare in `dl`'s own cache:
565
+
566
+ | | bytes |
567
+ | --- | --- |
568
+ | `du -s --block-size=1` on the clone alone | 353,230,848 |
569
+ | what `dl --ls --size` reports for it | 68,050,944 |
570
+ | what `dl --ls --size` reports for the bare it clones from | 651,264 |
571
+ | `du -sc --block-size=1` over both together | 353,882,112 |
572
+
573
+ `du` bills that workspace **5.2x** what deleting it would actually free. The
574
+ difference is a single 270,823,424-byte pack file with one link in the clone and
575
+ one in the bare, so removing either end frees none of it.
576
+
577
+ So `dl` counts a file only when every one of its hardlinks lies inside the
578
+ workspace being measured. Two consequences, both deliberate:
579
+
580
+ - **The sizes do not add up to the size of the cache.** Bytes shared between
581
+ workspaces belong to none of them, because deleting any one frees none of
582
+ them. They become the last workspace's the moment it is the last one — which
583
+ is exactly when deleting it *would* free them. In the table above that is the
584
+ last two rows read against each other: 68,702,208 reported bytes against
585
+ 353,882,112 held.
586
+ - **A workspace's size can change without the workspace changing**, when a
587
+ sibling that was sharing with it goes away. That is the truth about shared
588
+ storage.
589
+
590
+ A workspace `dl` did not create reads `-` (`null` in JSON): there is no clone of
591
+ `dl`'s there to measure, and walking your own project directory is not `dl`'s to
592
+ do. The table and the JSON decide that from the same rule — is the clone one
593
+ `dl` put in its own cache, the same question `--purge` deletes by — so the two
594
+ always name the same set of workspaces as measurable. Where a walk hits a
595
+ directory it cannot read — a container writes into its
596
+ clone as its own user, so this happens — the answer is a floor rather than a
597
+ total: `≥2.0 MiB` in the table, and `{"atLeastBytes": …, "unreadable": 1}` in
598
+ JSON instead of `{"exclusiveBytes": …}`. A partial measurement never comes back
599
+ looking like a complete one.
600
+
601
+ **It is opt-in because it walks the whole clone.** Plain `dl --ls` is one devpod
602
+ round-trip and no filesystem work at all, and the walk is O(files) with no
603
+ ceiling. Measured with the shipped code on one machine — Ubuntu 24.04, ext4,
604
+ warm page cache, five runs after a warm-up, the machine otherwise busy — a real
605
+ 8,309-entry clone walked in 24–28 ms, this repo's own tree with its built
606
+ environment inside it (9,124 entries) in 17–21 ms, and a 114,817-entry tree in
607
+ 232–239 ms. No cold-cache figure is quoted because none was taken: dropping the
608
+ page cache needs root on that machine. Those are one machine's numbers on warm
609
+ cache and yours will differ, but the shape is the point — it grows with the file
610
+ count, and a devcontainer that builds its environment *inside* the clone (this
611
+ repo's own does) is most of that count. That is not a bill a listing should
612
+ present unasked.
613
+
614
+ Docker images and named volumes are not counted: `dl` did not create the layer
615
+ store and does not manage volumes. `docker system df` is the tool that knows.
616
+
505
617
  ## Examples
506
618
 
507
619
  ```bash
@@ -12,7 +12,7 @@ A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete an
12
12
  [![PyPI](https://img.shields.io/pypi/v/devlaunch)](https://pypi.org/project/devlaunch/)
13
13
  [![Conda](https://img.shields.io/badge/conda-v0.0.9-brightgreen?logo=anaconda)](https://prefix.dev/channels/blooop/packages/devlaunch)
14
14
  [![License](https://img.shields.io/github/license/blooop/devlaunch)](https://opensource.org/license/mit/)
15
- [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/downloads/)
15
+ [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://www.python.org/downloads/)
16
16
  [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh)
17
17
 
18
18
  ## Installation
@@ -213,6 +213,7 @@ than the filesystem has actually done.
213
213
 
214
214
  | Command | Description |
215
215
  |---------|-------------|
216
+ | `dl <user/repo> up` | Start (or create) the workspace without attaching — for prewarming a container before a session wants it |
216
217
  | `dl <user/repo> stop` | Stop the workspace |
217
218
  | `dl <user/repo> rm, prune` | Delete the workspace |
218
219
  | `dl <user/repo> code` | Open in VS Code |
@@ -318,6 +319,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
318
319
  |---------|-------------|
319
320
  | `dl --ls` | List all workspaces |
320
321
  | `dl --ls --json` | The same list as JSON, with each workspace's repo, branch, state and [unsaved work](#cleaning-up-workspaces) — for tools that decide what to clean up |
322
+ | `dl --ls --size` | Add [what deleting each workspace would free](#how-much-disk-a-workspace-costs). Opt-in: it walks every file in the clone |
321
323
  | `dl --install` | Install shell completions |
322
324
  | `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
323
325
  | `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
@@ -431,8 +433,9 @@ from an abandoned one. So `dl` supplies the two halves a tool that *does* know
431
433
  needs, and that tool drives the cleanup:
432
434
 
433
435
  ```bash
434
- dl --ls --json # what exists, and what each workspace holds
435
- dl <workspace> rm # remove one
436
+ dl --ls --json # what exists, and what each workspace holds
437
+ dl --ls --json --size # ...and what removing each one would free
438
+ dl <workspace> rm # remove one
436
439
  ```
437
440
 
438
441
  The JSON reports, per workspace: `id`, `devlaunch` (did `dl` create it),
@@ -447,24 +450,39 @@ cleanup tool must not ignore — `unsaved`:
447
450
  "repo": "blooop/devlaunch",
448
451
  "branch": "wayfinder/devlaunch-80",
449
452
  "state": "Stopped",
450
- "unsaved": "2 uncommitted change(s) (pixi.lock, notes.md) and 1 unpushed commit(s)"
453
+ "unsaved": {
454
+ "wouldLose": "2 uncommitted change(s) (pixi.lock, notes.md) and 1 unpushed commit(s)"
455
+ }
451
456
  }
452
457
  ```
453
458
 
454
- `unsaved` is a description of what deleting would destroy, or `null` when the
455
- clone holds nothing that does not also exist on a remote — uncommitted changes
456
- (untracked files included) and commits no remote has. The changed paths are
457
- named, not just counted, and that matters more than it looks: a devcontainer
458
- that runs a package install in its `postCreateCommand` can leave a tracked
459
- lockfile modified in *every* workspace it builds this repo's own does and as
460
- a bare count that is indistinguishable from an hour of unsaved work. A cleanup
461
- tool believing the count would then never clean anything. Named, it is
462
- judgeable. A workspace `dl` did not
463
- create reports `devlaunch: false` with no repo, branch or `unsaved`: there is no
464
- clone of `dl`'s to protect, and it has no business inspecting your checkout.
465
-
466
- **`dl <workspace> rm` refuses when the clone holds unsaved work**, so a caller
467
- that forgets to read the field is still caught:
459
+ `unsaved` is an object with exactly one key, and the key says which of three
460
+ answers it is:
461
+
462
+ | `unsaved` | Meaning |
463
+ | --- | --- |
464
+ | `{"nothingToLose": true}` | Everything in the clone exists on a remote too. Deleting it costs nothing. |
465
+ | `{"wouldLose": "<what>"}` | Uncommitted changes (untracked files included), commits no remote has, or both. |
466
+ | `{"couldNotTell": "<why>"}` | `git` could not read the clone as a repository a half-removed `.git`, an interrupted delete. The files are still there and nothing has established that they exist anywhere else. |
467
+
468
+ The changed paths are named, not just counted, and that matters more than it
469
+ looks: a devcontainer that runs a package install in its `postCreateCommand` can
470
+ leave a tracked lockfile modified in *every* workspace it builds — this repo's
471
+ own does and as a bare count that is indistinguishable from an hour of unsaved
472
+ work. A cleanup tool believing the count would then never clean anything. Named,
473
+ it is judgeable. A workspace `dl` did not create reports `devlaunch: false` and
474
+ no `unsaved` — `unsaved` is `null` exactly where `devlaunch` is `false`, and
475
+ nowhere else: there is no clone of `dl`'s to protect, and it has no business
476
+ inspecting your checkout. (`repo` and `branch` are a weaker test and not the
477
+ same set: they come from `dl`'s metadata record, and a clone `dl` owns can have
478
+ lost its record while the clone and the work in it are still on disk. That clone
479
+ is inspected and reported like any other.)
480
+
481
+ **`dl <workspace> rm` refuses to delete a clone it would lose work from — when
482
+ the recorded clone holds unsaved work, and when it cannot tell what that clone
483
+ holds**, so a caller that forgets to read the field is still caught. (Recorded,
484
+ because that is the directory the guard reads; the case with no record is
485
+ neither, and is described below.)
468
486
 
469
487
  ```
470
488
  $ dl blooop/repo@feature rm
@@ -472,14 +490,108 @@ error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
472
490
  Push or commit it, or run: dl blooop/repo@feature rm --force
473
491
  ```
474
492
 
493
+ ```
494
+ $ dl blooop/repo@feature rm
495
+ error: devlaunch-repo-feature-xyz: git could not read /home/…/repo/feature:
496
+ fatal: not a git repository. devlaunch will not delete a clone it cannot
497
+ check. Look at it, or run: dl blooop/repo@feature rm --force
498
+ ```
499
+
475
500
  That refusal is the only judgement `dl` makes here, and it is not about finished
476
- work — it is `dl` declining to destroy the only copy of something. Say `--force`
477
- if you mean it.
501
+ work — it is `dl` declining to destroy the only copy of something, including
502
+ when it cannot prove there is another copy. Say `--force` if you mean it.
503
+
504
+ The guard reads `dl`'s metadata record, so the recorded directory is the one it
505
+ asks about. (The delete does not always remove that same directory: when the
506
+ recorded path is not on disk it falls back to a derived one. That divergence is
507
+ older than this guard and is tracked as devlaunch#174.) One case is therefore
508
+ neither a refusal nor a delete: a clone under `dl`'s cache that has **no** record — a metadata write
509
+ that failed, a record pruned, a cache restored without one. The listing still
510
+ reports what that clone holds, so `unsaved` is the field to read; but `rm`
511
+ removes the devpod workspace, exits `0` without asking for `--force`, and leaves
512
+ the clone on disk, because there is no recorded directory for it to remove
513
+ either. Nothing is destroyed, and nothing then points at the clone: it is yours
514
+ to keep or to `rm -rf` by hand.
478
515
 
479
516
  [`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
480
517
  names its branches after its tickets, so it knows which workspaces belong to
481
518
  finished work and removes those.
482
519
 
520
+ ### How much disk a workspace costs
521
+
522
+ `dl --ls --size` adds a `SIZE` column, and `dl --ls --json --size` adds a `disk`
523
+ object beside the other per-workspace facts:
524
+
525
+ ```
526
+ $ dl --ls --size
527
+ WORKSPACE TYPE SOURCE SIZE LAST USED
528
+ kinisi-ros-main-lubadaha local /home/…/repos/kinisi-robotics/kinisi_ros/main 64.9 MiB 2026-08-08 11:43:27
529
+ my-own-checkout local /home/…/projects/scratch - 2026-08-01 09:12:04
530
+ ```
531
+
532
+ **The number is what deleting that workspace would give back, not what `du`
533
+ prints.** Those differ, and the gap is the point of the design. A repo is cloned
534
+ once into a bare cache and every workspace clone hardlinks its git objects out
535
+ of that one copy, so the objects exist once on disk however many workspaces
536
+ share them. A size that walked each workspace on its own — which is what `du`
537
+ does when you point it at one directory, counting the blocks every file in it
538
+ occupies — bills each workspace for the whole shared pool.
539
+
540
+ The measurement the row above comes from, taken with the shipped code on one
541
+ machine (Ubuntu 24.04, ext4, warm page cache) on a real clone of that repo made
542
+ by `git clone` from the bare in `dl`'s own cache:
543
+
544
+ | | bytes |
545
+ | --- | --- |
546
+ | `du -s --block-size=1` on the clone alone | 353,230,848 |
547
+ | what `dl --ls --size` reports for it | 68,050,944 |
548
+ | what `dl --ls --size` reports for the bare it clones from | 651,264 |
549
+ | `du -sc --block-size=1` over both together | 353,882,112 |
550
+
551
+ `du` bills that workspace **5.2x** what deleting it would actually free. The
552
+ difference is a single 270,823,424-byte pack file with one link in the clone and
553
+ one in the bare, so removing either end frees none of it.
554
+
555
+ So `dl` counts a file only when every one of its hardlinks lies inside the
556
+ workspace being measured. Two consequences, both deliberate:
557
+
558
+ - **The sizes do not add up to the size of the cache.** Bytes shared between
559
+ workspaces belong to none of them, because deleting any one frees none of
560
+ them. They become the last workspace's the moment it is the last one — which
561
+ is exactly when deleting it *would* free them. In the table above that is the
562
+ last two rows read against each other: 68,702,208 reported bytes against
563
+ 353,882,112 held.
564
+ - **A workspace's size can change without the workspace changing**, when a
565
+ sibling that was sharing with it goes away. That is the truth about shared
566
+ storage.
567
+
568
+ A workspace `dl` did not create reads `-` (`null` in JSON): there is no clone of
569
+ `dl`'s there to measure, and walking your own project directory is not `dl`'s to
570
+ do. The table and the JSON decide that from the same rule — is the clone one
571
+ `dl` put in its own cache, the same question `--purge` deletes by — so the two
572
+ always name the same set of workspaces as measurable. Where a walk hits a
573
+ directory it cannot read — a container writes into its
574
+ clone as its own user, so this happens — the answer is a floor rather than a
575
+ total: `≥2.0 MiB` in the table, and `{"atLeastBytes": …, "unreadable": 1}` in
576
+ JSON instead of `{"exclusiveBytes": …}`. A partial measurement never comes back
577
+ looking like a complete one.
578
+
579
+ **It is opt-in because it walks the whole clone.** Plain `dl --ls` is one devpod
580
+ round-trip and no filesystem work at all, and the walk is O(files) with no
581
+ ceiling. Measured with the shipped code on one machine — Ubuntu 24.04, ext4,
582
+ warm page cache, five runs after a warm-up, the machine otherwise busy — a real
583
+ 8,309-entry clone walked in 24–28 ms, this repo's own tree with its built
584
+ environment inside it (9,124 entries) in 17–21 ms, and a 114,817-entry tree in
585
+ 232–239 ms. No cold-cache figure is quoted because none was taken: dropping the
586
+ page cache needs root on that machine. Those are one machine's numbers on warm
587
+ cache and yours will differ, but the shape is the point — it grows with the file
588
+ count, and a devcontainer that builds its environment *inside* the clone (this
589
+ repo's own does) is most of that count. That is not a bill a listing should
590
+ present unasked.
591
+
592
+ Docker images and named volumes are not counted: `dl` did not create the layer
593
+ store and does not manage volumes. `docker system df` is the tool that knows.
594
+
483
595
  ## Examples
484
596
 
485
597
  ```bash
@@ -61,7 +61,7 @@ _dl_completion() {
61
61
  fi
62
62
 
63
63
  # Workspace subcommands
64
- local ws_cmds="stop rm code restart recreate reset --"
64
+ local ws_cmds="up stop rm code restart recreate reset --"
65
65
 
66
66
  # Options that take a value; a variant name or a path follows them.
67
67
  local value_opts="--devcontainer"