devlaunch 0.0.24__tar.gz → 0.0.26__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.24 → devlaunch-0.0.26}/PKG-INFO +206 -9
  2. {devlaunch-0.0.24 → devlaunch-0.0.26}/README.md +204 -7
  3. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/completions/dl.bash +1 -1
  4. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/disk_usage.py +32 -1
  5. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/dl.py +1184 -54
  6. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/gh_auth.py +13 -11
  7. devlaunch-0.0.26/devlaunch/timing.py +91 -0
  8. devlaunch-0.0.26/devlaunch/worktree/locks.py +126 -0
  9. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/worktree/migration.py +20 -9
  10. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/worktree/repo_manager.py +41 -9
  11. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/worktree/workspace_clone.py +87 -5
  12. {devlaunch-0.0.24 → devlaunch-0.0.26}/pyproject.toml +4 -1
  13. devlaunch-0.0.24/devlaunch/worktree/locks.py +0 -65
  14. {devlaunch-0.0.24 → devlaunch-0.0.26}/.gitignore +0 -0
  15. {devlaunch-0.0.24 → devlaunch-0.0.26}/LICENSE +0 -0
  16. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/__init__.py +0 -0
  17. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/aid.py +0 -0
  18. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/completion.py +0 -0
  19. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/completion_loader.py +0 -0
  20. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/completions/__init__.py +0 -0
  21. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/devpod_provider.py +0 -0
  22. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/devpod_ssh.py +0 -0
  23. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/tools.py +0 -0
  24. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/tty_session.py +0 -0
  25. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/workspace_id.py +0 -0
  26. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/workspace_state.py +0 -0
  27. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/worktree/__init__.py +0 -0
  28. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/worktree/branch_manager.py +0 -0
  29. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/worktree/config.py +0 -0
  30. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/worktree/models.py +0 -0
  31. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/worktree/storage.py +0 -0
  32. {devlaunch-0.0.24 → devlaunch-0.0.26}/devlaunch/xdg.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: devlaunch
3
- Version: 0.0.24
3
+ Version: 0.0.26
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
@@ -242,6 +242,7 @@ than the filesystem has actually done.
242
242
  | `dl <user/repo> restart` | Stop and start (no rebuild) |
243
243
  | `dl <user/repo> recreate` | Recreate container |
244
244
  | `dl <user/repo> reset` | Clean slate (remove all, recreate) |
245
+ | `dl <user/repo> dotfiles` | Refresh dotfiles in the running workspace (`chezmoi update`) |
245
246
  | `dl <user/repo> -- <command>` | Run shell command in workspace (with a terminal, when `dl` has one) |
246
247
 
247
248
  ## Options
@@ -313,16 +314,96 @@ and `aid`. The repo's `devcontainer.json` does not have to provide them, and mos
313
314
  do not: `dl` launches arbitrary repos, so a guarantee that depended on the image
314
315
  would not be a guarantee.
315
316
 
316
- They are installed with `pixi global` on `devpod up`, and put on the PATH of a
317
- login shell through whichever of `~/.bash_profile`, `~/.bash_login` or `~/.profile`
318
- bash actually reads it sources only the first of those that exists, so an image
319
- shipping a `~/.bash_profile` never reads `~/.profile`. A workspace that already has both is left alone —
320
- the check runs first, so the cost after the first launch is one round-trip and no
321
- network. If `pixi` is missing from the image, `dl` installs that too.
317
+ ### How they get there
318
+
319
+ On `devpod up`, at most three round trips, each one earning the next.
320
+
321
+ **1. A probe the only trip a ready workspace ever pays.** The container reports
322
+ what only it can know: whether both tools answer at all, where its `claude`
323
+ resolves to, and where `~/.local/share/claude/versions` in its own home resolves
324
+ to. It reports those and names no verdict; the host reads them, so "a real
325
+ `claude`" is defined in exactly one place. The reading is one of three:
326
+
327
+ - **provisioned** — `gh` answers on the login PATH and `claude` resolves to a
328
+ binary the official installer put in the versions directory. Nothing else
329
+ happens.
330
+ - **lendable** — both names answer, but that `claude` is a shim or a wrapper.
331
+ - **absent** — a tool is genuinely missing.
332
+
333
+ **2. A lend, for *lendable* and *absent*.** `dl` streams its own `gh` and `claude`
334
+ into the container as a tar over the `devpod ssh` channel it already holds — a
335
+ local pipe, no network and no download. Nothing lands outside a staging directory
336
+ until both binaries have been run there once, so a container that cannot execute
337
+ them (a different libc, a different architecture) is left exactly as it was.
338
+
339
+ **3. The network install, for *absent* only.** When the host had nothing to lend,
340
+ or the lend was refused, `pixi global` installs both tools — and `pixi` itself
341
+ first if the image has none. A *lendable* container never reaches this trip: it
342
+ stops after the lend, or — when the host had nothing to lend — after the probe
343
+ itself. A `claude` already answers there, and this install decides what to do
344
+ with the same `command -v` that a shim satisfies, so the trip would install
345
+ nothing.
346
+
347
+ Tools reach the PATH of a login shell through whichever of `~/.bash_profile`,
348
+ `~/.bash_login` or `~/.profile` bash actually reads — it sources only the first of
349
+ those that exists, so an image shipping a `~/.bash_profile` never reads
350
+ `~/.profile`.
322
351
 
323
352
  An install that fails costs the workspace its tools, not its launch: `dl` logs a
324
353
  warning and hands you the session anyway.
325
354
 
355
+ ### What to bake so a launch does no work at all
356
+
357
+ To make every `dl` launch of an image stop at trip 1. The probe asks a **login**
358
+ shell to resolve each name, so every bullet here is about what a login shell can
359
+ find:
360
+
361
+ - **`gh`** anywhere on the login PATH.
362
+ - **`claude`** in the layout its official installer creates — the binary at
363
+ `~/.local/share/claude/versions/<version>`, a **direct child** of that
364
+ directory named for the version, with `~/.local/bin/claude` symlinked to it.
365
+ Nested any deeper — `versions/<version>/bin/claude`, the shape a downloader
366
+ parked there would take — is read as somebody else's tree that merely starts
367
+ with the official path, and does not count.
368
+ - **`~/.local/bin` on the login PATH**. The symlink above is how `claude`
369
+ answers at all; a login shell that cannot find that directory reads the image
370
+ as *absent* however carefully the rest was baked, and it pays the full lend.
371
+ Ubuntu's stock `~/.profile` prepends `~/.local/bin` itself — but an image
372
+ shipping a `~/.bash_profile` never reads `~/.profile` (above), and then
373
+ nothing does.
374
+
375
+ Nothing else counts as a `claude`, and that is the point. A *shim* — a small
376
+ launcher that downloads the real binary the first time it is called — answers
377
+ `command -v claude` exactly as the real thing does, while the workspace still
378
+ owes a multi-hundred-megabyte download at the least convenient moment. So `dl`
379
+ resolves the name rather than running it (running a shim *is* the download), reads
380
+ a shim as *lendable*, and sends the host's real binary. The lend prepends
381
+ `~/.local/bin` to the login PATH, which is what puts the lent binary in front of
382
+ the shim from then on — intended, and the reason the next launch probes
383
+ *provisioned* and the transfer is paid once rather than forever.
384
+
385
+ **This repo's own devcontainer feature bakes a shim.**
386
+ `.devcontainer/claude-code/install.sh` installs `claude-shim`, so an image built
387
+ from it does *not* meet the contract by itself: its first `dl` launch is lent a
388
+ real `claude`, and only launches after that do nothing. Build the official layout
389
+ into the image if you want the first launch free too.
390
+
391
+ ### What this deliberately does not do
392
+
393
+ - **No per-tool transfer.** The lend is all-or-nothing — an image with a real `gh`
394
+ but a shimmed `claude` is sent both. Splitting the payload would save part of
395
+ one transfer, paid once per workspace, in exchange for a matrix of half-lent
396
+ states every later step would have to reason about. (The *network* install is
397
+ already per tool: each install guards itself with its own `command -v`.)
398
+ - **No version sync.** A real `claude` already in the container is left alone
399
+ whatever its version. `dl` lends what is missing; it is not a package manager,
400
+ and keeping versions in step would mean deciding what to do when the container
401
+ is the newer one. The official binary self-updates in a long-lived workspace,
402
+ and rebuilding one re-provisions it from scratch. The single upgrade `dl` does
403
+ perform is replacing a shim with a real binary.
404
+
405
+ ### Turning it off
406
+
326
407
  ```bash
327
408
  DEVLAUNCH_NO_TOOLS=1 dl someone/repo
328
409
  ```
@@ -343,8 +424,8 @@ existed — picks the tools up on its next `dl <workspace> restart`.
343
424
  | `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
425
  | `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 |
345
426
  | `dl --install` | Install shell completions |
427
+ | `dl --prune [-y] [--force]` | Remove [the clone directories no workspace opens any more](#pruning-the-clones-nothing-opens) — and nothing else |
346
428
  | `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
347
- | `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
348
429
  | `dl --refresh` | Refresh completion cache |
349
430
  | `dl --help, -h` | Show this help |
350
431
  | `dl --version` | Show version (an editable install also names the tree it runs from) |
@@ -441,6 +522,96 @@ directory yourself if you meant to: following the link would empty a directory
441
522
  you never named, and removing just the link would report a clean sweep while
442
523
  your clones sat on the other volume.
443
524
 
525
+ ### Pruning the clones nothing opens
526
+
527
+ A workspace per branch means clone directories accumulate under the cache, and
528
+ until now nothing removed them: measured on one host, **52 clone directories for
529
+ 17 live devpod workspaces — 37 of them attached to nothing, 4.00 GB, against
530
+ 7.86 GB still in use.** `--purge` is the wrong tool for that, being
531
+ all-or-nothing: the only way to get the 4 GB back was to destroy the 7.86 GB
532
+ too, and every bare cache with it.
533
+
534
+ `dl --prune` removes exactly the clone directories no live workspace opens. It
535
+ never deletes a devpod workspace, a container, an image or a volume, never
536
+ touches a repo's `.bare` cache (0.08 GB for seven repos, and it is what makes
537
+ the next clone of a repo fast), and never looks outside
538
+ `<cache>/devlaunch/repos`. Every directory it finds is one of three things:
539
+
540
+ - **a live workspace opens it** — kept, and named with the workspace that has
541
+ it. "Opens" means at *or under*: a workspace opened on a subdirectory of a
542
+ clone still needs the clone;
543
+ - **nothing opens it** — removed, unless it holds work that exists nowhere else,
544
+ or `git` would not say what it holds. A clone a container wrote as another
545
+ user is unreadable rather than empty, and "cannot tell" is kept, not removed;
546
+ - **`dl`'s records and devpod's disagree about it** — kept, always. This is
547
+ [#88](https://github.com/blooop/devlaunch/issues/88)'s shape. On that ticket's
548
+ host, 36 devpod workspaces out of 39 recorded a source folder that was gone or
549
+ was a config-only stub, while the real checkout sat beside it under a newer
550
+ naming scheme — so a perfectly healthy clone was opened by nobody, and the
551
+ stub was the only thing anything pointed at. `--prune` will not guess which
552
+ clone such a workspace needs: it keeps every clone of that repository and
553
+ names the record to go and fix. `--force` does not move any of them.
554
+
555
+ Note that *every* directory two levels under `<cache>/devlaunch/repos` is a
556
+ candidate — a stray directory somebody left there is looked at like any other.
557
+ The cache is `dl`'s to manage; things that are not clones do not belong in it.
558
+ But `git` cannot say what a directory that is not a repository holds, and
559
+ "cannot say" is kept rather than removed, so clearing junk out of the cache
560
+ takes `--force`. That is the same refusal a clone with a half-written `.git`
561
+ gets, and deliberately so: telling the two apart would mean `--prune` forming
562
+ its own opinion about a directory `dl <workspace> rm` already refuses on.
563
+
564
+ ```
565
+ $ dl --prune
566
+ Clone directories under /home/you/.cache/devlaunch/repos:
567
+
568
+ Removing 2 that nothing references -- 1.4 GiB:
569
+ - /home/you/.cache/devlaunch/repos/blooop/bencher/bencher-test1-pipagito (1.1 GiB)
570
+ - /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-t1-vebilote (317.0 MiB)
571
+
572
+ Leaving 3:
573
+ - /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo: workspace devlaunch-main-zovomobo still opens it
574
+ - /home/you/.cache/devlaunch/repos/blooop/wayfinder/wayfinder-devlaunch-kilarabo: holds 2 unpushed commit(s) -- add --force to remove it anyway
575
+ - /home/you/.cache/devlaunch/repos/blooop/rockerc/rockerc-main-ludomane: devpod lists workspace rockerc-main-ludomane and sources it at /home/you/.cache/devlaunch/repos/blooop/rockerc/main; see devlaunch#88
576
+
577
+ Dropping 12 record(s) of directories already gone.
578
+
579
+ Are you sure? [y/N]
580
+ ```
581
+
582
+ `-y` skips the question. **A clone holding uncommitted or unpushed work is kept
583
+ and named**, in the same words [`dl <workspace> rm`](#cleaning-up-workspaces)
584
+ refuses in — 13 of those 37 stale clones did, two of them with real unpushed
585
+ commits, so this is load-bearing rather than a formality. `--force` promotes
586
+ that one case and nothing else. Erring this way costs you a flag; erring the
587
+ other way costs work that cannot be recovered.
588
+
589
+ The sizes are the same *exclusive* bytes `dl --ls --size` reports, and they mean
590
+ [the same thing](#how-much-disk-a-workspace-costs): what removing that directory
591
+ would actually free, not what `du` would print. Where a walk could not read
592
+ something the figure reads `≥` and so does the total, because a floor printed as
593
+ a total is a cleanup tool telling you a directory is small when it is not.
594
+
595
+ Directories that will not come away are named the same way [a purge names
596
+ them](#when-part-of-the-cache-will-not-go), the rest still go, and the exit
597
+ status is `1`.
598
+
599
+ **Nothing here runs on its own.** A full scan measured 1017 ms on that host —
600
+ about two warm launches — and it gets slower exactly as the cache gets fuller,
601
+ so it is never on a launch path and never folded into `dl --ls`. Answering `n`
602
+ *is* the read-only view; there is no separate flag for it. It costs one
603
+ `devpod list` to build the plan and no `devpod status` at all, because whether a
604
+ workspace is running has no bearing on whether a directory is opened by one. A
605
+ run you say yes to pays a second `devpod list` before it removes anything, and
606
+ classifies every directory again: a launch that finishes while the report is on
607
+ screen registers a workspace for one of the directories in the plan, and that is
608
+ the one thing the plan cannot be re-checked against from disk. The set you
609
+ approved can shrink between the report and the act. It can never grow.
610
+
611
+ It also drops the `metadata.json` records of directories that are already gone.
612
+ That file was append-only in practice — 49 records for 17 live workspaces on the
613
+ same host — and this is the first thing that prunes it.
614
+
444
615
  ### Cleaning up workspaces
445
616
 
446
617
  One workspace per branch means workspaces accumulate, and `--purge` is the wrong
@@ -636,6 +807,32 @@ dl blooop/devlaunch stop # Stop workspace
636
807
  - **Fast Autocomplete**: Completion cache for ~3ms response time (vs ~700ms without cache)
637
808
  - **One Round-Trip Per Question**: every `devpod` call costs ~0.45s, far more than `dl` itself, so a command reads the workspace list at most once — and `dl <ws> -- <cmd>` skips the extra round-trip that names an interactive prompt, since a one-shot command has none
638
809
 
810
+ ## Measuring launch time
811
+
812
+ Set `DEVLAUNCH_TIMING=1` and a `dl` command ends with one summary on stderr,
813
+ naming each subprocess round trip and the total. Unset (or `0`) records nothing
814
+ and prints nothing.
815
+
816
+ ```bash
817
+ $ DEVLAUNCH_TIMING=1 dl myws -- true
818
+ dl-timing: devpod status 0.412s
819
+ dl-timing: devpod ssh 0.583s
820
+ dl-timing: devpod ssh 1.102s
821
+ dl-timing: total 2.201s (in-process, excluding interpreter startup)
822
+ ```
823
+
824
+ For before/after numbers, `scripts/bench_launch.py` runs a command N times and
825
+ reports the median — one command per side of a change:
826
+
827
+ ```bash
828
+ python scripts/bench_launch.py -n 5 -- dl-next owner/repo -- true # warm launch
829
+ ```
830
+
831
+ (`pixi run bench -n 5 -- ...` in the devcontainer.) It reports no median if any
832
+ run fails, so a broken launch cannot pass as a fast one. See `bench_launch.py
833
+ --help` for `--before` — the per-run reset that makes a *cold* median cold —
834
+ and for why its wall clock and `dl-timing: total` are not the same quantity.
835
+
639
836
  ## Worktree Backend
640
837
 
641
838
  For git repositories, devlaunch uses an efficient worktree backend by default:
@@ -220,6 +220,7 @@ than the filesystem has actually done.
220
220
  | `dl <user/repo> restart` | Stop and start (no rebuild) |
221
221
  | `dl <user/repo> recreate` | Recreate container |
222
222
  | `dl <user/repo> reset` | Clean slate (remove all, recreate) |
223
+ | `dl <user/repo> dotfiles` | Refresh dotfiles in the running workspace (`chezmoi update`) |
223
224
  | `dl <user/repo> -- <command>` | Run shell command in workspace (with a terminal, when `dl` has one) |
224
225
 
225
226
  ## Options
@@ -291,16 +292,96 @@ and `aid`. The repo's `devcontainer.json` does not have to provide them, and mos
291
292
  do not: `dl` launches arbitrary repos, so a guarantee that depended on the image
292
293
  would not be a guarantee.
293
294
 
294
- They are installed with `pixi global` on `devpod up`, and put on the PATH of a
295
- login shell through whichever of `~/.bash_profile`, `~/.bash_login` or `~/.profile`
296
- bash actually reads it sources only the first of those that exists, so an image
297
- shipping a `~/.bash_profile` never reads `~/.profile`. A workspace that already has both is left alone —
298
- the check runs first, so the cost after the first launch is one round-trip and no
299
- network. If `pixi` is missing from the image, `dl` installs that too.
295
+ ### How they get there
296
+
297
+ On `devpod up`, at most three round trips, each one earning the next.
298
+
299
+ **1. A probe the only trip a ready workspace ever pays.** The container reports
300
+ what only it can know: whether both tools answer at all, where its `claude`
301
+ resolves to, and where `~/.local/share/claude/versions` in its own home resolves
302
+ to. It reports those and names no verdict; the host reads them, so "a real
303
+ `claude`" is defined in exactly one place. The reading is one of three:
304
+
305
+ - **provisioned** — `gh` answers on the login PATH and `claude` resolves to a
306
+ binary the official installer put in the versions directory. Nothing else
307
+ happens.
308
+ - **lendable** — both names answer, but that `claude` is a shim or a wrapper.
309
+ - **absent** — a tool is genuinely missing.
310
+
311
+ **2. A lend, for *lendable* and *absent*.** `dl` streams its own `gh` and `claude`
312
+ into the container as a tar over the `devpod ssh` channel it already holds — a
313
+ local pipe, no network and no download. Nothing lands outside a staging directory
314
+ until both binaries have been run there once, so a container that cannot execute
315
+ them (a different libc, a different architecture) is left exactly as it was.
316
+
317
+ **3. The network install, for *absent* only.** When the host had nothing to lend,
318
+ or the lend was refused, `pixi global` installs both tools — and `pixi` itself
319
+ first if the image has none. A *lendable* container never reaches this trip: it
320
+ stops after the lend, or — when the host had nothing to lend — after the probe
321
+ itself. A `claude` already answers there, and this install decides what to do
322
+ with the same `command -v` that a shim satisfies, so the trip would install
323
+ nothing.
324
+
325
+ Tools reach the PATH of a login shell through whichever of `~/.bash_profile`,
326
+ `~/.bash_login` or `~/.profile` bash actually reads — it sources only the first of
327
+ those that exists, so an image shipping a `~/.bash_profile` never reads
328
+ `~/.profile`.
300
329
 
301
330
  An install that fails costs the workspace its tools, not its launch: `dl` logs a
302
331
  warning and hands you the session anyway.
303
332
 
333
+ ### What to bake so a launch does no work at all
334
+
335
+ To make every `dl` launch of an image stop at trip 1. The probe asks a **login**
336
+ shell to resolve each name, so every bullet here is about what a login shell can
337
+ find:
338
+
339
+ - **`gh`** anywhere on the login PATH.
340
+ - **`claude`** in the layout its official installer creates — the binary at
341
+ `~/.local/share/claude/versions/<version>`, a **direct child** of that
342
+ directory named for the version, with `~/.local/bin/claude` symlinked to it.
343
+ Nested any deeper — `versions/<version>/bin/claude`, the shape a downloader
344
+ parked there would take — is read as somebody else's tree that merely starts
345
+ with the official path, and does not count.
346
+ - **`~/.local/bin` on the login PATH**. The symlink above is how `claude`
347
+ answers at all; a login shell that cannot find that directory reads the image
348
+ as *absent* however carefully the rest was baked, and it pays the full lend.
349
+ Ubuntu's stock `~/.profile` prepends `~/.local/bin` itself — but an image
350
+ shipping a `~/.bash_profile` never reads `~/.profile` (above), and then
351
+ nothing does.
352
+
353
+ Nothing else counts as a `claude`, and that is the point. A *shim* — a small
354
+ launcher that downloads the real binary the first time it is called — answers
355
+ `command -v claude` exactly as the real thing does, while the workspace still
356
+ owes a multi-hundred-megabyte download at the least convenient moment. So `dl`
357
+ resolves the name rather than running it (running a shim *is* the download), reads
358
+ a shim as *lendable*, and sends the host's real binary. The lend prepends
359
+ `~/.local/bin` to the login PATH, which is what puts the lent binary in front of
360
+ the shim from then on — intended, and the reason the next launch probes
361
+ *provisioned* and the transfer is paid once rather than forever.
362
+
363
+ **This repo's own devcontainer feature bakes a shim.**
364
+ `.devcontainer/claude-code/install.sh` installs `claude-shim`, so an image built
365
+ from it does *not* meet the contract by itself: its first `dl` launch is lent a
366
+ real `claude`, and only launches after that do nothing. Build the official layout
367
+ into the image if you want the first launch free too.
368
+
369
+ ### What this deliberately does not do
370
+
371
+ - **No per-tool transfer.** The lend is all-or-nothing — an image with a real `gh`
372
+ but a shimmed `claude` is sent both. Splitting the payload would save part of
373
+ one transfer, paid once per workspace, in exchange for a matrix of half-lent
374
+ states every later step would have to reason about. (The *network* install is
375
+ already per tool: each install guards itself with its own `command -v`.)
376
+ - **No version sync.** A real `claude` already in the container is left alone
377
+ whatever its version. `dl` lends what is missing; it is not a package manager,
378
+ and keeping versions in step would mean deciding what to do when the container
379
+ is the newer one. The official binary self-updates in a long-lived workspace,
380
+ and rebuilding one re-provisions it from scratch. The single upgrade `dl` does
381
+ perform is replacing a shim with a real binary.
382
+
383
+ ### Turning it off
384
+
304
385
  ```bash
305
386
  DEVLAUNCH_NO_TOOLS=1 dl someone/repo
306
387
  ```
@@ -321,8 +402,8 @@ existed — picks the tools up on its next `dl <workspace> restart`.
321
402
  | `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
403
  | `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 |
323
404
  | `dl --install` | Install shell completions |
405
+ | `dl --prune [-y] [--force]` | Remove [the clone directories no workspace opens any more](#pruning-the-clones-nothing-opens) — and nothing else |
324
406
  | `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
325
- | `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
326
407
  | `dl --refresh` | Refresh completion cache |
327
408
  | `dl --help, -h` | Show this help |
328
409
  | `dl --version` | Show version (an editable install also names the tree it runs from) |
@@ -419,6 +500,96 @@ directory yourself if you meant to: following the link would empty a directory
419
500
  you never named, and removing just the link would report a clean sweep while
420
501
  your clones sat on the other volume.
421
502
 
503
+ ### Pruning the clones nothing opens
504
+
505
+ A workspace per branch means clone directories accumulate under the cache, and
506
+ until now nothing removed them: measured on one host, **52 clone directories for
507
+ 17 live devpod workspaces — 37 of them attached to nothing, 4.00 GB, against
508
+ 7.86 GB still in use.** `--purge` is the wrong tool for that, being
509
+ all-or-nothing: the only way to get the 4 GB back was to destroy the 7.86 GB
510
+ too, and every bare cache with it.
511
+
512
+ `dl --prune` removes exactly the clone directories no live workspace opens. It
513
+ never deletes a devpod workspace, a container, an image or a volume, never
514
+ touches a repo's `.bare` cache (0.08 GB for seven repos, and it is what makes
515
+ the next clone of a repo fast), and never looks outside
516
+ `<cache>/devlaunch/repos`. Every directory it finds is one of three things:
517
+
518
+ - **a live workspace opens it** — kept, and named with the workspace that has
519
+ it. "Opens" means at *or under*: a workspace opened on a subdirectory of a
520
+ clone still needs the clone;
521
+ - **nothing opens it** — removed, unless it holds work that exists nowhere else,
522
+ or `git` would not say what it holds. A clone a container wrote as another
523
+ user is unreadable rather than empty, and "cannot tell" is kept, not removed;
524
+ - **`dl`'s records and devpod's disagree about it** — kept, always. This is
525
+ [#88](https://github.com/blooop/devlaunch/issues/88)'s shape. On that ticket's
526
+ host, 36 devpod workspaces out of 39 recorded a source folder that was gone or
527
+ was a config-only stub, while the real checkout sat beside it under a newer
528
+ naming scheme — so a perfectly healthy clone was opened by nobody, and the
529
+ stub was the only thing anything pointed at. `--prune` will not guess which
530
+ clone such a workspace needs: it keeps every clone of that repository and
531
+ names the record to go and fix. `--force` does not move any of them.
532
+
533
+ Note that *every* directory two levels under `<cache>/devlaunch/repos` is a
534
+ candidate — a stray directory somebody left there is looked at like any other.
535
+ The cache is `dl`'s to manage; things that are not clones do not belong in it.
536
+ But `git` cannot say what a directory that is not a repository holds, and
537
+ "cannot say" is kept rather than removed, so clearing junk out of the cache
538
+ takes `--force`. That is the same refusal a clone with a half-written `.git`
539
+ gets, and deliberately so: telling the two apart would mean `--prune` forming
540
+ its own opinion about a directory `dl <workspace> rm` already refuses on.
541
+
542
+ ```
543
+ $ dl --prune
544
+ Clone directories under /home/you/.cache/devlaunch/repos:
545
+
546
+ Removing 2 that nothing references -- 1.4 GiB:
547
+ - /home/you/.cache/devlaunch/repos/blooop/bencher/bencher-test1-pipagito (1.1 GiB)
548
+ - /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-t1-vebilote (317.0 MiB)
549
+
550
+ Leaving 3:
551
+ - /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo: workspace devlaunch-main-zovomobo still opens it
552
+ - /home/you/.cache/devlaunch/repos/blooop/wayfinder/wayfinder-devlaunch-kilarabo: holds 2 unpushed commit(s) -- add --force to remove it anyway
553
+ - /home/you/.cache/devlaunch/repos/blooop/rockerc/rockerc-main-ludomane: devpod lists workspace rockerc-main-ludomane and sources it at /home/you/.cache/devlaunch/repos/blooop/rockerc/main; see devlaunch#88
554
+
555
+ Dropping 12 record(s) of directories already gone.
556
+
557
+ Are you sure? [y/N]
558
+ ```
559
+
560
+ `-y` skips the question. **A clone holding uncommitted or unpushed work is kept
561
+ and named**, in the same words [`dl <workspace> rm`](#cleaning-up-workspaces)
562
+ refuses in — 13 of those 37 stale clones did, two of them with real unpushed
563
+ commits, so this is load-bearing rather than a formality. `--force` promotes
564
+ that one case and nothing else. Erring this way costs you a flag; erring the
565
+ other way costs work that cannot be recovered.
566
+
567
+ The sizes are the same *exclusive* bytes `dl --ls --size` reports, and they mean
568
+ [the same thing](#how-much-disk-a-workspace-costs): what removing that directory
569
+ would actually free, not what `du` would print. Where a walk could not read
570
+ something the figure reads `≥` and so does the total, because a floor printed as
571
+ a total is a cleanup tool telling you a directory is small when it is not.
572
+
573
+ Directories that will not come away are named the same way [a purge names
574
+ them](#when-part-of-the-cache-will-not-go), the rest still go, and the exit
575
+ status is `1`.
576
+
577
+ **Nothing here runs on its own.** A full scan measured 1017 ms on that host —
578
+ about two warm launches — and it gets slower exactly as the cache gets fuller,
579
+ so it is never on a launch path and never folded into `dl --ls`. Answering `n`
580
+ *is* the read-only view; there is no separate flag for it. It costs one
581
+ `devpod list` to build the plan and no `devpod status` at all, because whether a
582
+ workspace is running has no bearing on whether a directory is opened by one. A
583
+ run you say yes to pays a second `devpod list` before it removes anything, and
584
+ classifies every directory again: a launch that finishes while the report is on
585
+ screen registers a workspace for one of the directories in the plan, and that is
586
+ the one thing the plan cannot be re-checked against from disk. The set you
587
+ approved can shrink between the report and the act. It can never grow.
588
+
589
+ It also drops the `metadata.json` records of directories that are already gone.
590
+ That file was append-only in practice — 49 records for 17 live workspaces on the
591
+ same host — and this is the first thing that prunes it.
592
+
422
593
  ### Cleaning up workspaces
423
594
 
424
595
  One workspace per branch means workspaces accumulate, and `--purge` is the wrong
@@ -614,6 +785,32 @@ dl blooop/devlaunch stop # Stop workspace
614
785
  - **Fast Autocomplete**: Completion cache for ~3ms response time (vs ~700ms without cache)
615
786
  - **One Round-Trip Per Question**: every `devpod` call costs ~0.45s, far more than `dl` itself, so a command reads the workspace list at most once — and `dl <ws> -- <cmd>` skips the extra round-trip that names an interactive prompt, since a one-shot command has none
616
787
 
788
+ ## Measuring launch time
789
+
790
+ Set `DEVLAUNCH_TIMING=1` and a `dl` command ends with one summary on stderr,
791
+ naming each subprocess round trip and the total. Unset (or `0`) records nothing
792
+ and prints nothing.
793
+
794
+ ```bash
795
+ $ DEVLAUNCH_TIMING=1 dl myws -- true
796
+ dl-timing: devpod status 0.412s
797
+ dl-timing: devpod ssh 0.583s
798
+ dl-timing: devpod ssh 1.102s
799
+ dl-timing: total 2.201s (in-process, excluding interpreter startup)
800
+ ```
801
+
802
+ For before/after numbers, `scripts/bench_launch.py` runs a command N times and
803
+ reports the median — one command per side of a change:
804
+
805
+ ```bash
806
+ python scripts/bench_launch.py -n 5 -- dl-next owner/repo -- true # warm launch
807
+ ```
808
+
809
+ (`pixi run bench -n 5 -- ...` in the devcontainer.) It reports no median if any
810
+ run fails, so a broken launch cannot pass as a fast one. See `bench_launch.py
811
+ --help` for `--before` — the per-run reset that makes a *cold* median cold —
812
+ and for why its wall clock and `dl-timing: total` are not the same quantity.
813
+
617
814
  ## Worktree Backend
618
815
 
619
816
  For git repositories, devlaunch uses an efficient worktree backend by default:
@@ -61,7 +61,7 @@ _dl_completion() {
61
61
  fi
62
62
 
63
63
  # Workspace subcommands
64
- local ws_cmds="up stop rm code restart recreate reset --"
64
+ local ws_cmds="up stop rm code restart recreate reset dotfiles --"
65
65
 
66
66
  # Options that take a value; a variant name or a path follows them.
67
67
  local value_opts="--devcontainer"
@@ -58,7 +58,7 @@ import os
58
58
  import stat
59
59
  from dataclasses import dataclass
60
60
  from pathlib import Path
61
- from typing import Any, Dict, List, NoReturn, Tuple, Union
61
+ from typing import Any, Dict, Iterable, List, NoReturn, Tuple, Union
62
62
 
63
63
  # st_blocks is counted in 512-byte units by POSIX, whatever the filesystem's own
64
64
  # block size is.
@@ -230,6 +230,37 @@ def known_bytes(usage: DiskUsage) -> int:
230
230
  _unhandled_usage(usage)
231
231
 
232
232
 
233
+ def _unreadable_in(usage: DiskUsage) -> Tuple[Path, ...]:
234
+ """The doors this usage could not open -- none, for a complete walk."""
235
+ if isinstance(usage, Measured):
236
+ return ()
237
+ if isinstance(usage, PartlyUnreadable):
238
+ return usage.unreadable
239
+ _unhandled_usage(usage)
240
+
241
+
242
+ def total_usage(usages: Iterable[DiskUsage]) -> DiskUsage:
243
+ """What removing all of *usages*' trees together would free.
244
+
245
+ A sum with one floor in it is a floor, and this returns the arm that says
246
+ so. That is the whole reason a total lives here rather than in the caller:
247
+ adding :func:`known_bytes` up gives an integer that has lost which kind of
248
+ answer it is, and an integer printed as a size is a floor read as a total --
249
+ the one mistake every other function in this module is shaped to prevent.
250
+
251
+ Which bytes these are, and why they do not add up to the disk a cache holds,
252
+ is the module docstring's business and is not restated here.
253
+ """
254
+ known = 0
255
+ unreadable: List[Path] = []
256
+ for usage in usages:
257
+ known += known_bytes(usage)
258
+ unreadable.extend(_unreadable_in(usage))
259
+ if unreadable:
260
+ return PartlyUnreadable(known, tuple(unreadable))
261
+ return Measured(known)
262
+
263
+
233
264
  def usage_as_json(usage: DiskUsage) -> Dict[str, Any]:
234
265
  """How a usage reads to a tool: one key, and the key says which kind it is.
235
266