devlaunch 0.0.22__tar.gz → 0.0.24__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.22 → devlaunch-0.0.24}/PKG-INFO +175 -21
  2. {devlaunch-0.0.22 → devlaunch-0.0.24}/README.md +174 -20
  3. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/completions/dl.bash +1 -1
  4. devlaunch-0.0.24/devlaunch/disk_usage.py +244 -0
  5. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/dl.py +750 -117
  6. devlaunch-0.0.24/devlaunch/tools.py +693 -0
  7. devlaunch-0.0.24/devlaunch/workspace_state.py +441 -0
  8. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/locks.py +12 -2
  9. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/workspace_clone.py +82 -11
  10. {devlaunch-0.0.22 → devlaunch-0.0.24}/pyproject.toml +2 -1
  11. devlaunch-0.0.22/devlaunch/tools.py +0 -219
  12. devlaunch-0.0.22/devlaunch/workspace_state.py +0 -150
  13. {devlaunch-0.0.22 → devlaunch-0.0.24}/.gitignore +0 -0
  14. {devlaunch-0.0.22 → devlaunch-0.0.24}/LICENSE +0 -0
  15. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/__init__.py +0 -0
  16. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/aid.py +0 -0
  17. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/completion.py +0 -0
  18. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/completion_loader.py +0 -0
  19. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/completions/__init__.py +0 -0
  20. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/devpod_provider.py +0 -0
  21. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/devpod_ssh.py +0 -0
  22. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/gh_auth.py +0 -0
  23. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/tty_session.py +0 -0
  24. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/workspace_id.py +0 -0
  25. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/__init__.py +0 -0
  26. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/branch_manager.py +0 -0
  27. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/config.py +0 -0
  28. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/migration.py +0 -0
  29. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/models.py +0 -0
  30. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/repo_manager.py +0 -0
  31. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/storage.py +0 -0
  32. {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/xdg.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlaunch
3
- Version: 0.0.22
3
+ Version: 0.0.24
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) |
@@ -397,6 +399,48 @@ Erring this way is deliberate — a purge that skips one of your own workspaces
397
399
  costs you a command, and the other kind of mistake costs you work you cannot get
398
400
  back.
399
401
 
402
+ #### When part of the cache will not go
403
+
404
+ A container writes into its clone as its own user — `vscode`, uid 1000, in the
405
+ standard devcontainer base image. Where your host user is uid 1000 too, nothing
406
+ here comes up. Where it is not — CI, a shared machine, a container running as
407
+ root, or devlaunch developed inside its own devcontainer — the directories the
408
+ container made cannot be emptied by you, and the purge cannot remove them.
409
+
410
+ It removes everything else anyway, and names what is left:
411
+
412
+ ```
413
+ $ dl --purge -y
414
+ Removed what was permitted under /home/you/.cache/devlaunch. These refused:
415
+ - /home/you/.cache/devlaunch/repos/blooop/bencher/bencher-main-kivagede: Permission denied
416
+
417
+ Usually this means a container wrote them as a different user, and:
418
+ sudo rm -rf '/home/you/.cache/devlaunch'
419
+ clears them. Check the reasons above first -- it does not fix all of them.
420
+ ```
421
+
422
+ Exit status is `1`, because a clone you were told would go is still on disk. It
423
+ used to be `1` with the *whole* cache still standing: the first refusal stopped
424
+ the purge, so the completion caches, `metadata.json` and every other clone
425
+ survived on account of one directory.
426
+
427
+ What is listed is the directory, once — not the hundreds of files inside it.
428
+ Unlinking needs write permission on the directory rather than on the file, so
429
+ every entry in that clone refuses separately and they are all the same fact.
430
+ Two *separately* unwritable directories on one path are two lines, though,
431
+ because clearing the inner one would leave the outer one just as stuck.
432
+
433
+ Each line carries what the system actually said. A container running as another
434
+ user is the common cause, but a read-only mount, `chattr +i` and a busy
435
+ mountpoint all land here too — and `sudo rm -rf` does not fix those, which is
436
+ why the report offers the cause rather than asserting it.
437
+
438
+ If you have **moved your cache** by making `~/.cache/devlaunch` a symlink, a
439
+ purge refuses it and names the target rather than following it. Remove the real
440
+ directory yourself if you meant to: following the link would empty a directory
441
+ you never named, and removing just the link would report a clean sweep while
442
+ your clones sat on the other volume.
443
+
400
444
  ### Cleaning up workspaces
401
445
 
402
446
  One workspace per branch means workspaces accumulate, and `--purge` is the wrong
@@ -411,8 +455,9 @@ from an abandoned one. So `dl` supplies the two halves a tool that *does* know
411
455
  needs, and that tool drives the cleanup:
412
456
 
413
457
  ```bash
414
- dl --ls --json # what exists, and what each workspace holds
415
- 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
416
461
  ```
417
462
 
418
463
  The JSON reports, per workspace: `id`, `devlaunch` (did `dl` create it),
@@ -427,24 +472,39 @@ cleanup tool must not ignore — `unsaved`:
427
472
  "repo": "blooop/devlaunch",
428
473
  "branch": "wayfinder/devlaunch-80",
429
474
  "state": "Stopped",
430
- "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
+ }
431
478
  }
432
479
  ```
433
480
 
434
- `unsaved` is a description of what deleting would destroy, or `null` when the
435
- clone holds nothing that does not also exist on a remote — uncommitted changes
436
- (untracked files included) and commits no remote has. The changed paths are
437
- named, not just counted, and that matters more than it looks: a devcontainer
438
- that runs a package install in its `postCreateCommand` can leave a tracked
439
- lockfile modified in *every* workspace it builds this repo's own does and as
440
- a bare count that is indistinguishable from an hour of unsaved work. A cleanup
441
- tool believing the count would then never clean anything. Named, it is
442
- judgeable. A workspace `dl` did not
443
- create reports `devlaunch: false` with no repo, branch or `unsaved`: there is no
444
- clone of `dl`'s to protect, and it has no business inspecting your checkout.
445
-
446
- **`dl <workspace> rm` refuses when the clone holds unsaved work**, so a caller
447
- 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.)
448
508
 
449
509
  ```
450
510
  $ dl blooop/repo@feature rm
@@ -452,14 +512,108 @@ error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
452
512
  Push or commit it, or run: dl blooop/repo@feature rm --force
453
513
  ```
454
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
+
455
522
  That refusal is the only judgement `dl` makes here, and it is not about finished
456
- work — it is `dl` declining to destroy the only copy of something. Say `--force`
457
- 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.
458
537
 
459
538
  [`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
460
539
  names its branches after its tickets, so it knows which workspaces belong to
461
540
  finished work and removes those.
462
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
+
463
617
  ## Examples
464
618
 
465
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) |
@@ -375,6 +377,48 @@ Erring this way is deliberate — a purge that skips one of your own workspaces
375
377
  costs you a command, and the other kind of mistake costs you work you cannot get
376
378
  back.
377
379
 
380
+ #### When part of the cache will not go
381
+
382
+ A container writes into its clone as its own user — `vscode`, uid 1000, in the
383
+ standard devcontainer base image. Where your host user is uid 1000 too, nothing
384
+ here comes up. Where it is not — CI, a shared machine, a container running as
385
+ root, or devlaunch developed inside its own devcontainer — the directories the
386
+ container made cannot be emptied by you, and the purge cannot remove them.
387
+
388
+ It removes everything else anyway, and names what is left:
389
+
390
+ ```
391
+ $ dl --purge -y
392
+ Removed what was permitted under /home/you/.cache/devlaunch. These refused:
393
+ - /home/you/.cache/devlaunch/repos/blooop/bencher/bencher-main-kivagede: Permission denied
394
+
395
+ Usually this means a container wrote them as a different user, and:
396
+ sudo rm -rf '/home/you/.cache/devlaunch'
397
+ clears them. Check the reasons above first -- it does not fix all of them.
398
+ ```
399
+
400
+ Exit status is `1`, because a clone you were told would go is still on disk. It
401
+ used to be `1` with the *whole* cache still standing: the first refusal stopped
402
+ the purge, so the completion caches, `metadata.json` and every other clone
403
+ survived on account of one directory.
404
+
405
+ What is listed is the directory, once — not the hundreds of files inside it.
406
+ Unlinking needs write permission on the directory rather than on the file, so
407
+ every entry in that clone refuses separately and they are all the same fact.
408
+ Two *separately* unwritable directories on one path are two lines, though,
409
+ because clearing the inner one would leave the outer one just as stuck.
410
+
411
+ Each line carries what the system actually said. A container running as another
412
+ user is the common cause, but a read-only mount, `chattr +i` and a busy
413
+ mountpoint all land here too — and `sudo rm -rf` does not fix those, which is
414
+ why the report offers the cause rather than asserting it.
415
+
416
+ If you have **moved your cache** by making `~/.cache/devlaunch` a symlink, a
417
+ purge refuses it and names the target rather than following it. Remove the real
418
+ directory yourself if you meant to: following the link would empty a directory
419
+ you never named, and removing just the link would report a clean sweep while
420
+ your clones sat on the other volume.
421
+
378
422
  ### Cleaning up workspaces
379
423
 
380
424
  One workspace per branch means workspaces accumulate, and `--purge` is the wrong
@@ -389,8 +433,9 @@ from an abandoned one. So `dl` supplies the two halves a tool that *does* know
389
433
  needs, and that tool drives the cleanup:
390
434
 
391
435
  ```bash
392
- dl --ls --json # what exists, and what each workspace holds
393
- 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
394
439
  ```
395
440
 
396
441
  The JSON reports, per workspace: `id`, `devlaunch` (did `dl` create it),
@@ -405,24 +450,39 @@ cleanup tool must not ignore — `unsaved`:
405
450
  "repo": "blooop/devlaunch",
406
451
  "branch": "wayfinder/devlaunch-80",
407
452
  "state": "Stopped",
408
- "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
+ }
409
456
  }
410
457
  ```
411
458
 
412
- `unsaved` is a description of what deleting would destroy, or `null` when the
413
- clone holds nothing that does not also exist on a remote — uncommitted changes
414
- (untracked files included) and commits no remote has. The changed paths are
415
- named, not just counted, and that matters more than it looks: a devcontainer
416
- that runs a package install in its `postCreateCommand` can leave a tracked
417
- lockfile modified in *every* workspace it builds this repo's own does and as
418
- a bare count that is indistinguishable from an hour of unsaved work. A cleanup
419
- tool believing the count would then never clean anything. Named, it is
420
- judgeable. A workspace `dl` did not
421
- create reports `devlaunch: false` with no repo, branch or `unsaved`: there is no
422
- clone of `dl`'s to protect, and it has no business inspecting your checkout.
423
-
424
- **`dl <workspace> rm` refuses when the clone holds unsaved work**, so a caller
425
- 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.)
426
486
 
427
487
  ```
428
488
  $ dl blooop/repo@feature rm
@@ -430,14 +490,108 @@ error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
430
490
  Push or commit it, or run: dl blooop/repo@feature rm --force
431
491
  ```
432
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
+
433
500
  That refusal is the only judgement `dl` makes here, and it is not about finished
434
- work — it is `dl` declining to destroy the only copy of something. Say `--force`
435
- 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.
436
515
 
437
516
  [`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
438
517
  names its branches after its tickets, so it knows which workspaces belong to
439
518
  finished work and removes those.
440
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
+
441
595
  ## Examples
442
596
 
443
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"