devlaunch 0.0.23__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.
- {devlaunch-0.0.23 → devlaunch-0.0.24}/PKG-INFO +133 -21
- {devlaunch-0.0.23 → devlaunch-0.0.24}/README.md +132 -20
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/completions/dl.bash +1 -1
- devlaunch-0.0.24/devlaunch/disk_usage.py +244 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/dl.py +525 -108
- devlaunch-0.0.24/devlaunch/tools.py +693 -0
- devlaunch-0.0.24/devlaunch/workspace_state.py +441 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/worktree/locks.py +12 -2
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/worktree/workspace_clone.py +82 -11
- {devlaunch-0.0.23 → devlaunch-0.0.24}/pyproject.toml +2 -1
- devlaunch-0.0.23/devlaunch/tools.py +0 -219
- devlaunch-0.0.23/devlaunch/workspace_state.py +0 -150
- {devlaunch-0.0.23 → devlaunch-0.0.24}/.gitignore +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/LICENSE +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/devpod_provider.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.23 → devlaunch-0.0.24}/devlaunch/worktree/storage.py +0 -0
- {devlaunch-0.0.23 → 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.
|
|
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
|
[](https://pypi.org/project/devlaunch/)
|
|
35
35
|
[](https://prefix.dev/channels/blooop/packages/devlaunch)
|
|
36
36
|
[](https://opensource.org/license/mit/)
|
|
37
|
-
[](https://www.python.org/downloads/)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
38
|
[](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
|
|
457
|
-
dl
|
|
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":
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
|
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
|
[](https://pypi.org/project/devlaunch/)
|
|
13
13
|
[](https://prefix.dev/channels/blooop/packages/devlaunch)
|
|
14
14
|
[](https://opensource.org/license/mit/)
|
|
15
|
-
[](https://www.python.org/downloads/)
|
|
15
|
+
[](https://www.python.org/downloads/)
|
|
16
16
|
[](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
|
|
435
|
-
dl
|
|
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":
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
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"
|