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.
- {devlaunch-0.0.22 → devlaunch-0.0.24}/PKG-INFO +175 -21
- {devlaunch-0.0.22 → devlaunch-0.0.24}/README.md +174 -20
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/completions/dl.bash +1 -1
- devlaunch-0.0.24/devlaunch/disk_usage.py +244 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/dl.py +750 -117
- devlaunch-0.0.24/devlaunch/tools.py +693 -0
- devlaunch-0.0.24/devlaunch/workspace_state.py +441 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/locks.py +12 -2
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/workspace_clone.py +82 -11
- {devlaunch-0.0.22 → devlaunch-0.0.24}/pyproject.toml +2 -1
- devlaunch-0.0.22/devlaunch/tools.py +0 -219
- devlaunch-0.0.22/devlaunch/workspace_state.py +0 -150
- {devlaunch-0.0.22 → devlaunch-0.0.24}/.gitignore +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/LICENSE +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/devpod_provider.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.24}/devlaunch/worktree/storage.py +0 -0
- {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.
|
|
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) |
|
|
@@ -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
|
|
415
|
-
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
|
|
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":
|
|
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
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
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
|
[](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) |
|
|
@@ -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
|
|
393
|
-
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
|
|
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":
|
|
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
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
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"
|