devlaunch 0.0.25__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.
- {devlaunch-0.0.25 → devlaunch-0.0.26}/PKG-INFO +206 -9
- {devlaunch-0.0.25 → devlaunch-0.0.26}/README.md +204 -7
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/completions/dl.bash +1 -1
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/disk_usage.py +32 -1
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/dl.py +1184 -54
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/gh_auth.py +13 -11
- devlaunch-0.0.26/devlaunch/timing.py +91 -0
- devlaunch-0.0.26/devlaunch/worktree/locks.py +126 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/worktree/migration.py +20 -9
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/worktree/repo_manager.py +41 -9
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/worktree/workspace_clone.py +87 -5
- {devlaunch-0.0.25 → devlaunch-0.0.26}/pyproject.toml +4 -1
- devlaunch-0.0.25/devlaunch/worktree/locks.py +0 -65
- {devlaunch-0.0.25 → devlaunch-0.0.26}/.gitignore +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/LICENSE +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/devpod_provider.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/tools.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/workspace_state.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/worktree/storage.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.26}/devlaunch/xdg.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: devlaunch
|
|
3
|
-
Version: 0.0.
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|