devlaunch 0.0.25__tar.gz → 0.0.27__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.27/PKG-INFO +1587 -0
- devlaunch-0.0.27/README.md +1565 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/completions/dl.bash +1 -1
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/devpod_provider.py +22 -5
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/disk_usage.py +32 -1
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/dl.py +2273 -152
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/gh_auth.py +16 -11
- devlaunch-0.0.27/devlaunch/timing.py +410 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/tools.py +477 -58
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/workspace_id.py +3 -2
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/workspace_state.py +5 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/worktree/branch_manager.py +32 -9
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/worktree/config.py +1 -4
- devlaunch-0.0.27/devlaunch/worktree/git_errors.py +35 -0
- devlaunch-0.0.27/devlaunch/worktree/locks.py +142 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/worktree/migration.py +60 -19
- devlaunch-0.0.27/devlaunch/worktree/repo_manager.py +640 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/worktree/storage.py +10 -3
- devlaunch-0.0.27/devlaunch/worktree/workspace_clone.py +808 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/pyproject.toml +17 -1
- devlaunch-0.0.25/PKG-INFO +0 -786
- devlaunch-0.0.25/README.md +0 -764
- devlaunch-0.0.25/devlaunch/worktree/locks.py +0 -65
- devlaunch-0.0.25/devlaunch/worktree/repo_manager.py +0 -351
- devlaunch-0.0.25/devlaunch/worktree/workspace_clone.py +0 -484
- {devlaunch-0.0.25 → devlaunch-0.0.27}/.gitignore +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/LICENSE +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.25 → devlaunch-0.0.27}/devlaunch/xdg.py +0 -0
|
@@ -0,0 +1,1587 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: devlaunch
|
|
3
|
+
Version: 0.0.27
|
|
4
|
+
Summary: DevLaunch - A streamlined CLI for devpod workspaces
|
|
5
|
+
Project-URL: Source, https://github.com/blooop/devlaunch
|
|
6
|
+
Project-URL: Home, https://github.com/blooop/devlaunch
|
|
7
|
+
Author-email: Austin Gregg-Smith <blooop@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: iterfzf>=1.0.0
|
|
11
|
+
Requires-Dist: tomli>=2.0.0
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: coverage<=7.14.1,>=7.5.4; extra == 'test'
|
|
14
|
+
Requires-Dist: hypothesis<=6.155.1,>=6.104.2; extra == 'test'
|
|
15
|
+
Requires-Dist: prek<0.5.0,>=0.2.28; extra == 'test'
|
|
16
|
+
Requires-Dist: pylint<=4.0.5,>=3.2.5; extra == 'test'
|
|
17
|
+
Requires-Dist: pytest-cov<=7.1.0,>=4.1; extra == 'test'
|
|
18
|
+
Requires-Dist: pytest<=9.0.3,>=7.4; extra == 'test'
|
|
19
|
+
Requires-Dist: ruff<=0.15.15,>=0.5.0; extra == 'test'
|
|
20
|
+
Requires-Dist: ty<=0.0.19,>=0.0.12; extra == 'test'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# devlaunch
|
|
24
|
+
|
|
25
|
+
A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete and fzf fuzzy selection.
|
|
26
|
+
|
|
27
|
+
## Continuous Integration Status
|
|
28
|
+
|
|
29
|
+
[](https://github.com/blooop/devlaunch/actions/workflows/ci.yml?query=branch%3Amain)
|
|
30
|
+
[](https://codecov.io/gh/blooop/devlaunch)
|
|
31
|
+
[](https://GitHub.com/blooop/devlaunch/issues/)
|
|
32
|
+
[](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
|
|
33
|
+
[](https://GitHub.com/blooop/devlaunch/releases/)
|
|
34
|
+
[](https://pypi.org/project/devlaunch/)
|
|
35
|
+
[](https://prefix.dev/channels/blooop/packages/devlaunch)
|
|
36
|
+
[](https://opensource.org/license/mit/)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](https://pixi.sh)
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
### Pixi (Recommended)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pixi global install --channel conda-forge --channel https://prefix.dev/blooop devlaunch
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This installs `devlaunch` along with `devpod` and all dependencies automatically.
|
|
49
|
+
|
|
50
|
+
### Pip
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install devlaunch
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Note: When using pip, you must install [devpod](https://devpod.sh/docs/getting-started/install) separately.
|
|
57
|
+
If `devpod` is not on `PATH`, every command that needs it prints a single install hint on stderr and exits `127`
|
|
58
|
+
(the shell's "command not found" code). `dl --help` and `dl --version` keep working without it.
|
|
59
|
+
|
|
60
|
+
A `devpod` that is installed but cannot answer is a different failure and gets a different exit code. If
|
|
61
|
+
`devpod list` exits non-zero, or prints something that is not a `--output json` workspace listing, `dl` quotes
|
|
62
|
+
what devpod said on stderr and exits `1` rather than reporting that you have no workspaces — so `dl --purge`
|
|
63
|
+
stops instead of deleting caches it never checked. Shell completion is the deliberate exception: `dl --install`,
|
|
64
|
+
`dl --refresh` and `dl --completion-data` log the failure and carry on with the repos and branches they can
|
|
65
|
+
still discover on local disk, so an unreachable devpod costs you workspace-name completion and nothing more.
|
|
66
|
+
|
|
67
|
+
### Shell Completions
|
|
68
|
+
|
|
69
|
+
After installation, set up shell completions for `dl` and `aid`:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
dl --install
|
|
73
|
+
source ~/.bashrc # or restart your terminal
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Usage
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
dl # Interactive workspace selector (fzf)
|
|
80
|
+
dl <user/repo> # Start workspace and attach shell
|
|
81
|
+
dl <user/repo> <cmd> # Run workspace command (stop, code, etc.)
|
|
82
|
+
dl <user/repo> -- <command> # Run shell command in workspace
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Commands that need a terminal
|
|
86
|
+
|
|
87
|
+
`dl <ws> -- <command>` gives the command a terminal whenever `dl` itself has one,
|
|
88
|
+
so interactive programs — a coding agent, `htop`, `git rebase -i`, a REPL — start
|
|
89
|
+
and stay up instead of exiting immediately. Redirect the output and the terminal
|
|
90
|
+
goes away again, so `dl <ws> -- ls > files.txt` stays free of escape sequences.
|
|
91
|
+
|
|
92
|
+
This needs the ssh host alias `devpod up` writes to `~/.ssh/config`. If a
|
|
93
|
+
workspace has none, `dl` says so and falls back to the plain `devpod ssh`
|
|
94
|
+
transport, which has no terminal; `dl <ws> restart` republishes the alias. Set
|
|
95
|
+
`DEVLAUNCH_NO_TTY=1` to force the fallback everywhere.
|
|
96
|
+
|
|
97
|
+
## aid: start a coding agent in a workspace
|
|
98
|
+
|
|
99
|
+
`aid` is `dl` with a coding agent started for you:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
aid <user/repo>[@branch] [prompt...] # Open the workspace, start the agent
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
It is a shortcut, not a second launcher. `aid` rewrites its command line into a
|
|
106
|
+
`dl` one and hands it to `dl` itself, so
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
aid blooop/devlaunch@fix/42 fix the flaky test
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
is exactly
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
dl blooop/devlaunch@fix/42 -- IS_SANDBOX=1 claude --dangerously-skip-permissions 'fix the flaky test'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
That means an `aid` workspace *is* the `dl` workspace: same clone, same workspace
|
|
119
|
+
id, same container — started if stopped, attached to if already running, and never
|
|
120
|
+
rebuilt just because `aid` asked for it. Anything `dl` learns, `aid` gets.
|
|
121
|
+
|
|
122
|
+
`claude` is started with `--dangerously-skip-permissions`. The agent is already
|
|
123
|
+
inside a disposable container holding only this repo, so the per-tool prompts it
|
|
124
|
+
would ask on the host protect nothing here and would stall an unattended run.
|
|
125
|
+
`IS_SANDBOX=1` rides along because `claude` otherwise refuses that flag outright
|
|
126
|
+
under `uid 0`, and devcontainers that run as root are ordinary. The variable is
|
|
127
|
+
scoped to the agent process, not exported into your shell.
|
|
128
|
+
|
|
129
|
+
The trade is worth stating plainly: an agent started this way edits, runs and
|
|
130
|
+
deletes inside the container without asking. It cannot reach your host, but it can
|
|
131
|
+
rewrite the checkout it is in, so review an `aid` workspace before pushing rather
|
|
132
|
+
than treating it as a sandbox that will stop it for you. `--codex` and `--gemini`
|
|
133
|
+
are unaffected, and `dl <ws> -- claude` still runs exactly what you typed.
|
|
134
|
+
|
|
135
|
+
| Option | Description |
|
|
136
|
+
|--------|-------------|
|
|
137
|
+
| `--claude`, `--codex`, `--gemini` | Pick the agent (default: `claude`) |
|
|
138
|
+
| `--devcontainer <variant\|path>` | Passed through to `dl` |
|
|
139
|
+
| `DEVLAUNCH_AID_AGENT=<agent>` | Change the default agent |
|
|
140
|
+
|
|
141
|
+
Everything after the workspace is the prompt, flags and all, so it never needs
|
|
142
|
+
quoting to survive `aid`'s own parsing. Managing workspaces — listing, stopping,
|
|
143
|
+
deleting, VS Code — stays with `dl`.
|
|
144
|
+
|
|
145
|
+
The agent's CLI has to be installed in the container; `aid` runs it there, it does
|
|
146
|
+
not install it.
|
|
147
|
+
|
|
148
|
+
## Workspace Sources
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
dl myproject # Existing workspace by name
|
|
152
|
+
dl user/repo # Create from GitHub repo
|
|
153
|
+
dl user/repo@branch # Create from specific branch
|
|
154
|
+
dl ./path # Create from local path
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Workspace IDs
|
|
158
|
+
|
|
159
|
+
`dl user/repo@branch` derives one id that names both the devpod workspace (what you
|
|
160
|
+
see in `dl --ls`) and the clone directory under `~/.cache/devlaunch/repos/`:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
<repo-slug>-<branch-slug>-<syllables> at most 38 characters
|
|
164
|
+
|
|
165
|
+
blooop/devlaunch@main -> devlaunch-main-zovomobo
|
|
166
|
+
blooop/devlaunch@feature/auth -> devlaunch-feature-auth-poliseno
|
|
167
|
+
blooop/devlaunch@feature-auth -> devlaunch-feature-auth-nesatabe
|
|
168
|
+
blooop/test_renv@nb4 -> test-renv-nb4-polenita
|
|
169
|
+
kinisi-robotics/kinisi_ros@ags-devcontainer-tooling-support
|
|
170
|
+
-> kinisi-ros-ags-devcontainer-t-lenevere
|
|
171
|
+
blooop/devlaunch@dependabot/github_actions/codecov/codecov-action-6
|
|
172
|
+
-> devlaunch-dependabot-codecov-sifivasa
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The eight-character syllable suffix is a hash of the full `(owner, repo, branch)` triple.
|
|
176
|
+
It is what makes the id unique: the readable part is shortened to fit the length limit,
|
|
177
|
+
and shortening it does not affect whether two branches share an id. Long branch names
|
|
178
|
+
drop whole `/`-separated middle segments before losing characters, so the part that
|
|
179
|
+
identifies the branch survives. Note the third and fourth lines above: `feature/auth` and
|
|
180
|
+
`feature-auth` read the same once slugged but are different branches, and they get
|
|
181
|
+
different ids.
|
|
182
|
+
|
|
183
|
+
Owner and repo are matched case-insensitively, the way GitHub treats them, so
|
|
184
|
+
`dl NVIDIA/cuda-samples@main` and `dl nvidia/cuda-samples@main` are the same workspace.
|
|
185
|
+
Branch names are case-sensitive, because git refs are.
|
|
186
|
+
|
|
187
|
+
URL specs (`dl github.com/owner/repo`) get an id in the same shape, with the suffix
|
|
188
|
+
hashed over the URL.
|
|
189
|
+
|
|
190
|
+
The id is also the container hostname, so it stays well inside the 38-character budget
|
|
191
|
+
to leave room for tools that add their own prefixes.
|
|
192
|
+
|
|
193
|
+
Branch names must be safe as both git refs and directory names — a name with a space or
|
|
194
|
+
a leading dash is rejected rather than quietly rewritten.
|
|
195
|
+
|
|
196
|
+
### Upgrading from an older devlaunch
|
|
197
|
+
|
|
198
|
+
This id format is new, and the directories and containers on your machine were named by
|
|
199
|
+
the previous scheme. The first `dl user/repo…` command after upgrading migrates the cache
|
|
200
|
+
once and prints what it did. `dl --help`, `dl --version`, `dl --ls` and opening an existing
|
|
201
|
+
workspace by name do not trigger it.
|
|
202
|
+
|
|
203
|
+
**Your clone directories are renamed.** What was
|
|
204
|
+
`~/.cache/devlaunch/repos/blooop/devlaunch/main` becomes
|
|
205
|
+
`~/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo`. A workspace is a git
|
|
206
|
+
clone whose `origin` points at the `.bare` cache next to it, and `.bare` does not move, so
|
|
207
|
+
this is a plain rename: branches, history and **uncommitted changes all survive** — only
|
|
208
|
+
the folder name changes. `metadata.json` is updated in the same pass, so nothing is left
|
|
209
|
+
pointing at the old name.
|
|
210
|
+
|
|
211
|
+
**Your existing devpod containers keep their old ids and are orphaned — and they can
|
|
212
|
+
often be repaired rather than replaced.** An orphaned container is sourced at the path this
|
|
213
|
+
migration just renamed, with the real clone sitting next to it under the new name, which is
|
|
214
|
+
precisely what [`dl --reconcile`](#reconciling-records-that-disagree) is for: it re-points
|
|
215
|
+
devpod's record at the renamed clone, and `dl <workspace> recreate` finishes the repair.
|
|
216
|
+
That gives you back the clone association and the workspace's identity — not state that
|
|
217
|
+
lived only inside the old container, which nothing can bring back. The repair is
|
|
218
|
+
order-dependent: relaunching the branch claims the renamed clone for a fresh container,
|
|
219
|
+
and reconcile never re-points a clone a live container holds — so reconcile first, then
|
|
220
|
+
relaunch. Left alone, the next `dl user/repo@branch` simply builds a fresh container
|
|
221
|
+
under the new id, and deleting the old one is all that remains for it.
|
|
222
|
+
|
|
223
|
+
dl does not delete containers for you — deleting by id is how a running sidecar got
|
|
224
|
+
destroyed the last time something tried ([kinisi_ros#9766](https://github.com/kinisi-robotics/kinisi_ros/pull/9766)) —
|
|
225
|
+
so it prints a one-line notice with the count and writes the old ids to
|
|
226
|
+
`~/.cache/devlaunch/orphaned-workspaces.txt`. For the workspaces you are finished with,
|
|
227
|
+
the disposal command reads from that listing:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
xargs -r -n1 devpod delete < ~/.cache/devlaunch/orphaned-workspaces.txt
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**A clone directory with no metadata record is left alone.** Nothing records which branch
|
|
234
|
+
it was cloned for, and the old directory name cannot be turned back into one — `feature/auth`
|
|
235
|
+
and `feature-auth` both became `feature-auth` — so a guessed name would be worse than no
|
|
236
|
+
rename. Those directories stay exactly where they are and are listed in
|
|
237
|
+
`~/.cache/devlaunch/unmigrated-clones.txt`.
|
|
238
|
+
|
|
239
|
+
Running dl again changes nothing: the migration is keyed on the `version` field in
|
|
240
|
+
`metadata.json`, not on directory names, so a branch that happens to look like a new-scheme
|
|
241
|
+
id is never mistaken for one. If a migration is interrupted, the next run finishes it — the
|
|
242
|
+
version is written last, in the same atomic save as the new paths, so it never claims more
|
|
243
|
+
than the filesystem has actually done. A rename the filesystem refuses (a read-only mount,
|
|
244
|
+
tightened permissions) is treated the same way: the version stays put and every later run
|
|
245
|
+
retries the refused directories and repeats the notice until the underlying refusal is
|
|
246
|
+
fixed by hand.
|
|
247
|
+
|
|
248
|
+
## Workspace Commands
|
|
249
|
+
|
|
250
|
+
| Command | Description |
|
|
251
|
+
|---------|-------------|
|
|
252
|
+
| `dl <user/repo> up` | Start (or create) the workspace without attaching — for prewarming a container before a session wants it |
|
|
253
|
+
| `dl <user/repo> stop` | Stop the workspace |
|
|
254
|
+
| `dl <user/repo> rm, prune` | Delete the workspace |
|
|
255
|
+
| `dl <user/repo> code` | Open in VS Code |
|
|
256
|
+
| `dl <user/repo> restart` | Stop and start (no rebuild) |
|
|
257
|
+
| `dl <user/repo> recreate` | Recreate container |
|
|
258
|
+
| `dl <user/repo> reset` | Clean slate (remove all, recreate) |
|
|
259
|
+
| `dl <user/repo> dotfiles` | Refresh dotfiles in the running workspace (`chezmoi update`) |
|
|
260
|
+
| `dl <user/repo> -- <command>` | Run shell command in workspace (with a terminal, when `dl` has one) |
|
|
261
|
+
|
|
262
|
+
## Options
|
|
263
|
+
|
|
264
|
+
| Option | Description |
|
|
265
|
+
|--------|-------------|
|
|
266
|
+
| `--devcontainer <variant\|path>` | Use a non-default `devcontainer.json`. A bare name means `.devcontainer/<name>/devcontainer.json`. Stored with the workspace, so pass it once. |
|
|
267
|
+
| `DEVLAUNCH_NO_TTY=1` | Never give a workspace command a terminal; always use the plain `devpod ssh` transport. |
|
|
268
|
+
| `DEVLAUNCH_DOTFILES_ON_ATTACH=1` | Refresh dotfiles before handing over an interactive shell. Off by default; see below. |
|
|
269
|
+
|
|
270
|
+
Projects with demanding devcontainers — several variants, compose sidecars, or a
|
|
271
|
+
host-side `initializeCommand` that has to tell branch workspaces apart — are
|
|
272
|
+
covered in [docs/devcontainer-projects.md](docs/devcontainer-projects.md).
|
|
273
|
+
|
|
274
|
+
### Refreshing dotfiles on attach
|
|
275
|
+
|
|
276
|
+
devpod applies dotfiles when it *provisions* a workspace, so a workspace that has
|
|
277
|
+
been up for a fortnight still has the dotfiles it was born with. `dl <ws>
|
|
278
|
+
dotfiles` fixes that when you think of it; `DEVLAUNCH_DOTFILES_ON_ATTACH=1` makes
|
|
279
|
+
`dl` think of it for you, running the same refresh just before it hands you the
|
|
280
|
+
shell.
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
DEVLAUNCH_DOTFILES_ON_ATTACH=1 dl someone/repo
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
It is off unless you set it, and that is the point rather than caution. The
|
|
287
|
+
refresh is a `devpod ssh` round-trip — measured at ~1.7s, almost all of it
|
|
288
|
+
connection setup — with a `git pull` behind it, and it would otherwise be charged
|
|
289
|
+
to every attach on every machine to close a gap most people do not have.
|
|
290
|
+
|
|
291
|
+
Two things it deliberately does not do:
|
|
292
|
+
|
|
293
|
+
- **It never runs for `dl <ws> -- <command>`.** A one-shot command renders no
|
|
294
|
+
prompt and sources no interactive shell, so refreshing in front of it would
|
|
295
|
+
buy that command nothing and cost it the round-trip. That path is the one
|
|
296
|
+
agent launchers use, and it stays exactly as fast as it was.
|
|
297
|
+
- **It never holds the shell hostage.** The refresh gets 60 seconds; an
|
|
298
|
+
unreachable dotfiles remote, or one that wants a password nobody is there to
|
|
299
|
+
type, means a pause and then your shell, not a hang. Failure is a warning —
|
|
300
|
+
you get the workspace either way.
|
|
301
|
+
|
|
302
|
+
Refreshes run every time you attach, with no cooldown, because you asked for
|
|
303
|
+
them. If that is too often, unset the variable and use `dl <ws> dotfiles`.
|
|
304
|
+
|
|
305
|
+
## GitHub Authentication
|
|
306
|
+
|
|
307
|
+
Every workspace `dl` opens inherits the host's GitHub login, so `gh` is already
|
|
308
|
+
authenticated inside the container and the devcontainer.json does not have to
|
|
309
|
+
arrange anything for it. devpod forwards the ssh agent and git credentials on its
|
|
310
|
+
own, but nothing else carries `gh`.
|
|
311
|
+
|
|
312
|
+
devlaunch takes the token from `GH_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`,
|
|
313
|
+
whichever answers first, and hands it to the container as `GH_TOKEN`. That reaches
|
|
314
|
+
any image and any container user, unlike a bind-mount of `~/.config/gh`, and it
|
|
315
|
+
works whether the host keeps its token in `hosts.yml` or in a keyring. The token
|
|
316
|
+
is passed to devpod through a private file and through devpod's own environment,
|
|
317
|
+
never on a command line, so it does not appear in `ps`. `dl` installs `gh` itself
|
|
318
|
+
(see [Tools in every workspace](#tools-in-every-workspace)), so the login has
|
|
319
|
+
something to be spent on whatever the image ships. Check a workspace with:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
dl <workspace> -- gh auth status
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
If no token can be found, `dl` warns on stderr and opens the workspace anyway rather
|
|
326
|
+
than failing — and the warning names the config directory `gh` consulted, because the
|
|
327
|
+
usual cause is a shell that scoped `XDG_CONFIG_HOME` somewhere `gh` has no login,
|
|
328
|
+
not a host that is actually logged out.
|
|
329
|
+
|
|
330
|
+
### Who gets the token
|
|
331
|
+
|
|
332
|
+
Everything running in the container does — including a `postCreateCommand` from a
|
|
333
|
+
repo you did not write. `dl someone/repo` builds and runs that project's
|
|
334
|
+
devcontainer with your GitHub token in its environment, and a `gh auth login` token
|
|
335
|
+
usually carries `repo`, `workflow`, `gist` and `read:org` scopes. devpod already
|
|
336
|
+
forwards the ssh agent to every workspace, so this is not a new trust boundary, but
|
|
337
|
+
it is a wider one. Skip it for a repo you have not read:
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
DEVLAUNCH_NO_GH_TOKEN=1 dl someone/repo
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
| Variable | Description |
|
|
344
|
+
|----------|-------------|
|
|
345
|
+
| `DEVLAUNCH_NO_GH_TOKEN=1` | Do not forward the host's GitHub login into workspaces |
|
|
346
|
+
|
|
347
|
+
### When the token changes
|
|
348
|
+
|
|
349
|
+
`dl` refreshes the token on every start, so rotating it on the host is enough for
|
|
350
|
+
any workspace that gets started or restarted afterwards. Attaching to a workspace
|
|
351
|
+
that is *already running* skips that step, and the token it was given at startup
|
|
352
|
+
stays in place — including one it was given before you set
|
|
353
|
+
`DEVLAUNCH_NO_GH_TOKEN`. Run `dl <workspace> restart` to replace it.
|
|
354
|
+
|
|
355
|
+
## Tools in every workspace
|
|
356
|
+
|
|
357
|
+
`gh` and `claude` are available in every workspace `dl` opens, in every kind of
|
|
358
|
+
session — an interactive `dl <workspace>`, a one-shot `dl <workspace> -- <command>`,
|
|
359
|
+
and `aid`. The repo's `devcontainer.json` does not have to provide them, and most
|
|
360
|
+
do not: `dl` launches arbitrary repos, so a guarantee that depended on the image
|
|
361
|
+
would not be a guarantee.
|
|
362
|
+
|
|
363
|
+
### How they get there
|
|
364
|
+
|
|
365
|
+
On `devpod up`, at most three round trips, each one earning the next.
|
|
366
|
+
|
|
367
|
+
**1. The setup pass — the only trip a ready workspace ever pays.** One trip
|
|
368
|
+
carries everything the host wants done on the way into a running container: the
|
|
369
|
+
stages first, then the probe. Naming the container — the hostname your shell
|
|
370
|
+
prompt shows — is the one stage today, and it costs nothing extra because the
|
|
371
|
+
probe was paying for the trip anyway. Each stage reports `ok`, `failed` with its
|
|
372
|
+
exit status, or *not reached*; one that fails stops neither the stages behind it
|
|
373
|
+
nor the probe, and `dl` says which one it was.
|
|
374
|
+
|
|
375
|
+
The probe is the tail of that trip. The container reports
|
|
376
|
+
what only it can know: whether both tools answer at all, where its `claude`
|
|
377
|
+
resolves to, and where `~/.local/share/claude/versions` in its own home resolves
|
|
378
|
+
to. It reports those and names no verdict; the host reads them, so "a real
|
|
379
|
+
`claude`" is defined in exactly one place. The reading is one of three:
|
|
380
|
+
|
|
381
|
+
- **provisioned** — `gh` answers on the login PATH and `claude` resolves to a
|
|
382
|
+
binary the official installer put in the versions directory. Nothing else
|
|
383
|
+
happens.
|
|
384
|
+
- **lendable** — both names answer, but that `claude` is a shim or a wrapper.
|
|
385
|
+
- **absent** — a tool is genuinely missing.
|
|
386
|
+
|
|
387
|
+
**2. A lend, for *lendable* and *absent*.** `dl` streams its own `gh` and `claude`
|
|
388
|
+
into the container as a tar over the `devpod ssh` channel it already holds — a
|
|
389
|
+
local pipe, no network and no download. Nothing lands outside a staging directory
|
|
390
|
+
until both binaries have been run there once, so a container that cannot execute
|
|
391
|
+
them (a different libc, a different architecture) is left exactly as it was.
|
|
392
|
+
|
|
393
|
+
**3. The network install, for *absent* only.** When the host had nothing to lend,
|
|
394
|
+
or the lend was refused, `pixi global` installs both tools — and `pixi` itself
|
|
395
|
+
first if the image has none. A *lendable* container never reaches this trip: it
|
|
396
|
+
stops after the lend, or — when the host had nothing to lend — after the probe
|
|
397
|
+
itself. A `claude` already answers there, and this install decides what to do
|
|
398
|
+
with the same `command -v` that a shim satisfies, so the trip would install
|
|
399
|
+
nothing.
|
|
400
|
+
|
|
401
|
+
Tools reach the PATH of a login shell through whichever of `~/.bash_profile`,
|
|
402
|
+
`~/.bash_login` or `~/.profile` bash actually reads — it sources only the first of
|
|
403
|
+
those that exists, so an image shipping a `~/.bash_profile` never reads
|
|
404
|
+
`~/.profile`.
|
|
405
|
+
|
|
406
|
+
An install that fails costs the workspace its tools, not its launch: `dl` logs a
|
|
407
|
+
warning and hands you the session anyway.
|
|
408
|
+
|
|
409
|
+
### What to bake so a launch does no work at all
|
|
410
|
+
|
|
411
|
+
To make every `dl` launch of an image stop at trip 1. The probe asks a **login**
|
|
412
|
+
shell to resolve each name, so every bullet here is about what a login shell can
|
|
413
|
+
find:
|
|
414
|
+
|
|
415
|
+
- **`gh`** anywhere on the login PATH.
|
|
416
|
+
- **`claude`** in the layout its official installer creates — the binary at
|
|
417
|
+
`~/.local/share/claude/versions/<version>`, a **direct child** of that
|
|
418
|
+
directory named for the version, with `~/.local/bin/claude` symlinked to it.
|
|
419
|
+
Nested any deeper — `versions/<version>/bin/claude`, the shape a downloader
|
|
420
|
+
parked there would take — is read as somebody else's tree that merely starts
|
|
421
|
+
with the official path, and does not count.
|
|
422
|
+
- **`~/.local/bin` on the login PATH**. The symlink above is how `claude`
|
|
423
|
+
answers at all; a login shell that cannot find that directory reads the image
|
|
424
|
+
as *absent* however carefully the rest was baked, and it pays the full lend.
|
|
425
|
+
Ubuntu's stock `~/.profile` prepends `~/.local/bin` itself — but an image
|
|
426
|
+
shipping a `~/.bash_profile` never reads `~/.profile` (above), and then
|
|
427
|
+
nothing does.
|
|
428
|
+
|
|
429
|
+
Nothing else counts as a `claude`, and that is the point. A *shim* — a small
|
|
430
|
+
launcher that downloads the real binary the first time it is called — answers
|
|
431
|
+
`command -v claude` exactly as the real thing does, while the workspace still
|
|
432
|
+
owes a multi-hundred-megabyte download at the least convenient moment. So `dl`
|
|
433
|
+
resolves the name rather than running it (running a shim *is* the download), reads
|
|
434
|
+
a shim as *lendable*, and sends the host's real binary. The lend prepends
|
|
435
|
+
`~/.local/bin` to the login PATH, which is what puts the lent binary in front of
|
|
436
|
+
the shim from then on — intended, and the reason the next launch probes
|
|
437
|
+
*provisioned* and the transfer is paid once rather than forever.
|
|
438
|
+
|
|
439
|
+
**This repo's own devcontainer feature bakes a shim.**
|
|
440
|
+
`.devcontainer/claude-code/install.sh` installs `claude-shim`, so an image built
|
|
441
|
+
from it does *not* meet the contract by itself: its first `dl` launch is lent a
|
|
442
|
+
real `claude`, and only launches after that do nothing. Build the official layout
|
|
443
|
+
into the image if you want the first launch free too.
|
|
444
|
+
|
|
445
|
+
### What this deliberately does not do
|
|
446
|
+
|
|
447
|
+
- **No per-tool transfer.** The lend is all-or-nothing — an image with a real `gh`
|
|
448
|
+
but a shimmed `claude` is sent both. Splitting the payload would save part of
|
|
449
|
+
one transfer, paid once per workspace, in exchange for a matrix of half-lent
|
|
450
|
+
states every later step would have to reason about. (The *network* install is
|
|
451
|
+
already per tool: each install guards itself with its own `command -v`.)
|
|
452
|
+
- **No version sync.** A real `claude` already in the container is left alone
|
|
453
|
+
whatever its version. `dl` lends what is missing; it is not a package manager,
|
|
454
|
+
and keeping versions in step would mean deciding what to do when the container
|
|
455
|
+
is the newer one. The official binary self-updates in a long-lived workspace,
|
|
456
|
+
and rebuilding one re-provisions it from scratch. The single upgrade `dl` does
|
|
457
|
+
perform is replacing a shim with a real binary.
|
|
458
|
+
|
|
459
|
+
### Turning it off
|
|
460
|
+
|
|
461
|
+
```bash
|
|
462
|
+
DEVLAUNCH_NO_TOOLS=1 dl someone/repo
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
| Variable | Description |
|
|
466
|
+
|----------|-------------|
|
|
467
|
+
| `DEVLAUNCH_NO_TOOLS=1` | Do not install `gh` or `claude` into workspaces. The setup pass still runs — one trip per `up`, which still names the container; only the installing is skipped |
|
|
468
|
+
|
|
469
|
+
Attaching to a workspace that is *already running* skips `devpod up`, and so skips
|
|
470
|
+
this too. A workspace started by something other than `dl` — or created before this
|
|
471
|
+
existed — picks the tools up on its next `dl <workspace> restart`.
|
|
472
|
+
|
|
473
|
+
## A terminal beside the agent
|
|
474
|
+
|
|
475
|
+
Every workspace `dl` opens also has [zellij](https://zellij.dev) on `PATH`, which
|
|
476
|
+
buys one thing the other tools do not: an agent running in a container can open a
|
|
477
|
+
**second terminal next to itself**, in the same container, and you can attach to it
|
|
478
|
+
from anywhere to watch or to type.
|
|
479
|
+
|
|
480
|
+
Nothing has to cooperate for this. It does not come from your dotfiles, it does not
|
|
481
|
+
need an edit to any repo's `devcontainer.json`, and it works in images `dl` has never
|
|
482
|
+
seen — the same argument the rest of "Tools in every workspace" makes, for the same
|
|
483
|
+
reason: `dl` launches arbitrary repos.
|
|
484
|
+
|
|
485
|
+
### Opening a pane from inside a session
|
|
486
|
+
|
|
487
|
+
From anywhere inside the container — including from a completely non-interactive
|
|
488
|
+
command, with no terminal attached to anything:
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
zellij -s devlaunch action new-pane -- htop
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
`-s <name>` is the form to use and the only one worth depending on. Bare
|
|
495
|
+
`zellij action new-pane` happens to work by falling back to the single running
|
|
496
|
+
session, which stops being a single session the moment there are two of them.
|
|
497
|
+
|
|
498
|
+
`devlaunch` is the session name `dl` creates and the one to name here.
|
|
499
|
+
|
|
500
|
+
### Switching the wrap on
|
|
501
|
+
|
|
502
|
+
The session an agent opens panes into has to exist first, and creating it is
|
|
503
|
+
**off by default**:
|
|
504
|
+
|
|
505
|
+
```bash
|
|
506
|
+
DEVLAUNCH_ZELLIJ=1 dl someone/repo -- claude -p "do the thing"
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
| Variable | Description |
|
|
510
|
+
|----------|-------------|
|
|
511
|
+
| `DEVLAUNCH_ZELLIJ=1` | Before running `dl <spec> -- <command>`, make sure a zellij session named `devlaunch` exists in the container, so the command can open panes into it |
|
|
512
|
+
|
|
513
|
+
With it off, no invocation changes meaning at all — that is what off means here, and
|
|
514
|
+
it is why the switch exists rather than the behaviour simply being on.
|
|
515
|
+
|
|
516
|
+
**The command runs beside the session, not inside a pane of it.** That is deliberate.
|
|
517
|
+
Putting the command in a pane would hand its stdin, stdout and exit status to zellij,
|
|
518
|
+
and all three are things `dl` promises to leave alone: `dl <ws> -- cmd > file` has to
|
|
519
|
+
put the command's own output in the file, and a failing command has to come back with
|
|
520
|
+
its own status. Since `zellij -s <name> action new-pane` works perfectly well from a
|
|
521
|
+
command that is in no session at all, running beside the session costs nothing and
|
|
522
|
+
delivers the same pane.
|
|
523
|
+
|
|
524
|
+
**The interactive session of a bare `dl <workspace>` is untouched, switched on or
|
|
525
|
+
off.** An interactive attach sends no command for the wrap to attach to — that is
|
|
526
|
+
exactly what gets it a terminal from devpod — and giving it one would cost either the
|
|
527
|
+
terminal or a round trip in front of every shell. You land in an ordinary login shell
|
|
528
|
+
with `zellij` on `PATH`, so `zellij attach -c devlaunch` gets you the session, and any
|
|
529
|
+
panes an agent has opened in it, whenever you want them.
|
|
530
|
+
|
|
531
|
+
There is one exception, and it is a pleasant one: if you also run with
|
|
532
|
+
`DEVLAUNCH_DOTFILES_ON_ATTACH=1`, that refresh is a command, so it gets wrapped like
|
|
533
|
+
any other and the session is already there when the shell arrives.
|
|
534
|
+
|
|
535
|
+
### Existing workspaces
|
|
536
|
+
|
|
537
|
+
zellij arrives on the setup pass, which runs on every `devpod up`. So a workspace
|
|
538
|
+
that predates this picks it up on its next **`dl <workspace> restart`** — a full
|
|
539
|
+
`dl <workspace> recreate` also works but is not needed, because nothing here is a
|
|
540
|
+
bind mount and mounts are the thing that only lands at container creation.
|
|
541
|
+
|
|
542
|
+
Attaching to a workspace that is *already running* skips `devpod up` and so skips
|
|
543
|
+
this too, which is what makes the restart necessary rather than automatic.
|
|
544
|
+
|
|
545
|
+
### What it costs
|
|
546
|
+
|
|
547
|
+
Almost nothing, and that was measured rather than assumed. zellij is a conda-forge
|
|
548
|
+
package installed by pixi into the container, so it lands in the shared package cache
|
|
549
|
+
above and every container after the first extracts rather than downloads:
|
|
550
|
+
|
|
551
|
+
| | |
|
|
552
|
+
|---|---|
|
|
553
|
+
| **Warm install** (shared cache populated) | 0.56s / 0.23s / 0.23s over three fresh containers |
|
|
554
|
+
| **Cold install** (empty cache) | 3.0s, filling 167MB of shared cache |
|
|
555
|
+
| **Every launch after the first** | one `command -v`; the whole setup pass measured at 50ms |
|
|
556
|
+
|
|
557
|
+
**It can never fail a launch.** Provisioning zellij is a stage of the setup pass, so a
|
|
558
|
+
container with no network, no pixi and no way to get either reports the stage as
|
|
559
|
+
failed, by name, and then opens exactly as it would have. A container that ends up
|
|
560
|
+
without zellij still works; with the wrap on, the command still runs, because the
|
|
561
|
+
session setup is allowed to fail and the command runs regardless.
|
|
562
|
+
|
|
563
|
+
`DEVLAUNCH_NO_TOOLS=1` turns this off along with the rest of tool provisioning —
|
|
564
|
+
installing zellij is tool provisioning, where naming a container is not.
|
|
565
|
+
|
|
566
|
+
## The shared pixi package cache
|
|
567
|
+
|
|
568
|
+
Every container `dl` creates gets one host directory bound into it, and
|
|
569
|
+
`PIXI_CACHE_DIR` pointed at it, so that dotfiles which provision their tools with
|
|
570
|
+
`pixi global sync` download each package once per machine instead of once per
|
|
571
|
+
container:
|
|
572
|
+
|
|
573
|
+
| | |
|
|
574
|
+
|---|---|
|
|
575
|
+
| **On the host** | `$XDG_CACHE_HOME`, or `~/.cache`, then `devlaunch/pixi` |
|
|
576
|
+
| **In the container** | `/var/tmp/devlaunch-pixi` |
|
|
577
|
+
|
|
578
|
+
Measured on the profile this was built for — 23 pixi-global environments — a
|
|
579
|
+
container with a cold cache spends 62–113 s and downloads 1.2 GB; one that finds
|
|
580
|
+
the packages already there finishes in 18–28 s and fetches nothing. Two containers
|
|
581
|
+
syncing against it at the same time is fine: the downloads are content-addressed
|
|
582
|
+
and rattler takes a lock per package.
|
|
583
|
+
|
|
584
|
+
**Deleting it is always safe, at any moment, including while containers are
|
|
585
|
+
running.** It holds nothing but downloaded package archives — every one of them
|
|
586
|
+
re-fetchable from the network, and none of them referenced by a path anything
|
|
587
|
+
inside a container has stored. The worst a deletion costs is the next container's
|
|
588
|
+
download.
|
|
589
|
+
|
|
590
|
+
```bash
|
|
591
|
+
rm -rf ~/.cache/devlaunch/pixi
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
`dl --purge` takes it away with the rest of `~/.cache/devlaunch/`, for the same
|
|
595
|
+
reason.
|
|
596
|
+
|
|
597
|
+
Two things it deliberately is not. It is **not the host's own**
|
|
598
|
+
`~/.cache/rattler/cache`: containers write into it as their own remote user,
|
|
599
|
+
whose uid only happens to match yours, and a `pixi clean cache` you run for your
|
|
600
|
+
own reasons must not be able to pull packages out from under a running container.
|
|
601
|
+
And it is **not a shared `PIXI_HOME`** — the installed environments and their
|
|
602
|
+
trampolines are baked with absolute paths, and two containers sharing one
|
|
603
|
+
environment tree is [pixi#5476](https://github.com/prefix-dev/pixi/issues/5476).
|
|
604
|
+
Only the download cache is shared, which is the part that is safe to share.
|
|
605
|
+
|
|
606
|
+
If the directory cannot be created, or is not there when the launch reaches it —
|
|
607
|
+
a full disk, a read-only cache home, a cache swept between the two — the launch
|
|
608
|
+
goes ahead without the mount and the container downloads its own packages,
|
|
609
|
+
exactly as it did before this existed.
|
|
610
|
+
|
|
611
|
+
**Sharing requires the container's user to be able to write the directory**,
|
|
612
|
+
which in practice means its uid matches yours or it is root. The mount carries
|
|
613
|
+
host ownership through unchanged, and pixi does not degrade to reading a cache
|
|
614
|
+
it cannot write: pointing `PIXI_CACHE_DIR` at a directory owned by another uid
|
|
615
|
+
fails the install outright (`Permission denied` on the repodata, exit 1) even
|
|
616
|
+
when every package it wants is already in there. So an image whose remote user
|
|
617
|
+
is neither root nor your uid does not merely lose the sharing — its `pixi global
|
|
618
|
+
sync` fails, and its tools do not get provisioned.
|
|
619
|
+
|
|
620
|
+
`dl` cannot see the container's uid before it launches, so it cannot decide this
|
|
621
|
+
for you. In practice the common case is safe: every mainstream base declares a
|
|
622
|
+
remote user at uid 1000, which is the first human user on a Linux host. If you
|
|
623
|
+
hit the failure, the fixes available to you are to run that image as your own
|
|
624
|
+
uid, or to take the cache out of play for it (`rm -rf ~/.cache/devlaunch/pixi`
|
|
625
|
+
recovers a directory an earlier container left owned by someone else).
|
|
626
|
+
|
|
627
|
+
### Existing containers, and what a recreate is for
|
|
628
|
+
|
|
629
|
+
**A mount lands only when a container is created.** devpod re-applies
|
|
630
|
+
`--workspace-env` on every `up`, but it will not add a bind mount to a container
|
|
631
|
+
that already exists — passing `--mount` there is a silent no-op. So a container
|
|
632
|
+
built before this feature, or before a change to where the mount lands, keeps
|
|
633
|
+
whatever it was created with until `dl <workspace> recreate`, and only then
|
|
634
|
+
picks the current arrangement up.
|
|
635
|
+
|
|
636
|
+
In between, `PIXI_CACHE_DIR` points at `/var/tmp/devlaunch-pixi` with nothing
|
|
637
|
+
mounted on it. That is a working private cache, not a failure — `/var/tmp` is
|
|
638
|
+
world-writable in every image, so pixi creates the directory and fills it. The
|
|
639
|
+
container re-warms itself and simply never shares, abandoning whatever pixi had
|
|
640
|
+
already warmed in its default location. **This is the reason the container-side
|
|
641
|
+
path is under `/var/tmp` rather than somewhere tidier like `/var/cache`:** a
|
|
642
|
+
target whose parent is root-owned is a hard `pixi global sync` failure on every
|
|
643
|
+
container that predates it, not a lost optimisation.
|
|
644
|
+
|
|
645
|
+
One older breakage needs the recreate rather than a restart. Devlaunch briefly
|
|
646
|
+
mounted this cache inside `~/.cache`, which left that directory root-owned in
|
|
647
|
+
any image that ships no `~/.cache` of its own. `$HOME` lives on the container's
|
|
648
|
+
own layer, so `dl <workspace> stop` and a fresh `up` keep the root-owned
|
|
649
|
+
directory; `dl <workspace> recreate` gets a new layer where `~/.cache` is the
|
|
650
|
+
user's own again.
|
|
651
|
+
|
|
652
|
+
## Global Commands
|
|
653
|
+
|
|
654
|
+
| Command | Description |
|
|
655
|
+
|---------|-------------|
|
|
656
|
+
| `dl --ls` | List all workspaces |
|
|
657
|
+
| `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 |
|
|
658
|
+
| `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 |
|
|
659
|
+
| `dl --install` | Install shell completions |
|
|
660
|
+
| `dl --prune [-y] [--force]` | Remove [the clone directories no workspace opens any more](#pruning-the-clones-nothing-opens) — and nothing else |
|
|
661
|
+
| `dl --reconcile [-y]` | Re-point [devpod workspaces whose recorded source folder no longer holds a checkout](#reconciling-records-that-disagree) at the clone that does |
|
|
662
|
+
| `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
|
|
663
|
+
| `dl --refresh` | Refresh completion cache |
|
|
664
|
+
| `dl --help, -h` | Show this help |
|
|
665
|
+
| `dl --version` | Show version (an editable install also names the tree it runs from) |
|
|
666
|
+
|
|
667
|
+
A released install prints the version and nothing else. An install made in
|
|
668
|
+
editable mode says so and names the checkout it resolves to, so two builds of
|
|
669
|
+
the same version are told apart at a glance:
|
|
670
|
+
|
|
671
|
+
```bash
|
|
672
|
+
$ dl --version
|
|
673
|
+
dl 0.0.9
|
|
674
|
+
|
|
675
|
+
$ dl-next --version # editable install of a working tree
|
|
676
|
+
dl 0.0.9 (dev, editable from /path/to/your/devlaunch)
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
`aid --version` reports the same thing under its own name. The provenance comes
|
|
680
|
+
from the installed package's own PEP 610 metadata; an install that records none
|
|
681
|
+
just prints the bare version.
|
|
682
|
+
|
|
683
|
+
### What purge deletes
|
|
684
|
+
|
|
685
|
+
devpod's workspace list is shared. A workspace you made with `devpod up`, or that
|
|
686
|
+
another tool made, sits in the same list as the ones `dl` made, and `dl --purge`
|
|
687
|
+
has no business destroying it. So it deletes only the workspaces devlaunch
|
|
688
|
+
created — the clones it made under its own cache directory (`$XDG_CACHE_HOME` or
|
|
689
|
+
`~/.cache`, then `devlaunch/repos/<owner>/<repo>/<id>`), which is exactly the
|
|
690
|
+
directory the purge is about to remove anyway. Everything else keeps working
|
|
691
|
+
afterwards, because nothing a purge touches backs it.
|
|
692
|
+
|
|
693
|
+
Anything it is leaving is named before it asks:
|
|
694
|
+
|
|
695
|
+
```
|
|
696
|
+
$ dl --purge
|
|
697
|
+
This will remove all devlaunch data:
|
|
698
|
+
- 4 DevPod workspace(s)
|
|
699
|
+
- /home/you/.cache/devlaunch/ (workspace clones, repo caches, the shared pixi cache, completions)
|
|
700
|
+
|
|
701
|
+
Leaving 2 workspace(s) devlaunch did not create:
|
|
702
|
+
- pythontemplate
|
|
703
|
+
- my-hand-made-workspace
|
|
704
|
+
|
|
705
|
+
Are you sure? [y/N]
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
Three things `dl` does create are in that second list rather than the first.
|
|
709
|
+
`dl ./some/path` and `dl <git-url>` open a source `dl` did not clone, so it
|
|
710
|
+
cannot tell them from a workspace you made by hand — and a `config.toml` that
|
|
711
|
+
points `repos_dir` outside the cache puts the clones somewhere `--purge` does not
|
|
712
|
+
remove either, so those are left too. Delete any of them with `dl <workspace> rm`.
|
|
713
|
+
Erring this way is deliberate — a purge that skips one of your own workspaces
|
|
714
|
+
costs you a command, and the other kind of mistake costs you work you cannot get
|
|
715
|
+
back.
|
|
716
|
+
|
|
717
|
+
#### When part of the cache will not go
|
|
718
|
+
|
|
719
|
+
A container writes into its clone as its own user — `vscode`, uid 1000, in the
|
|
720
|
+
standard devcontainer base image. Where your host user is uid 1000 too, nothing
|
|
721
|
+
here comes up. Where it is not — CI, a shared machine, a container running as
|
|
722
|
+
root, or devlaunch developed inside its own devcontainer — the directories the
|
|
723
|
+
container made cannot be emptied by you, and the purge cannot remove them.
|
|
724
|
+
|
|
725
|
+
It removes everything else anyway, and names what is left:
|
|
726
|
+
|
|
727
|
+
```
|
|
728
|
+
$ dl --purge -y
|
|
729
|
+
Removed what was permitted under /home/you/.cache/devlaunch. These refused:
|
|
730
|
+
- /home/you/.cache/devlaunch/repos/blooop/bencher/bencher-main-kivagede: Permission denied
|
|
731
|
+
|
|
732
|
+
Usually this means a container wrote them as a different user, and:
|
|
733
|
+
sudo rm -rf '/home/you/.cache/devlaunch'
|
|
734
|
+
clears them. Check the reasons above first -- it does not fix all of them.
|
|
735
|
+
devlaunch does not manage Docker images or volumes: the containers these workspaces used may still hold disk, and `docker system df` shows what Docker is holding.
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
That last line ends every purge, including one that found nothing to purge and
|
|
739
|
+
one you answered `n` to — `dl --prune` ends on the same one, in the same words.
|
|
740
|
+
See [the disk neither command frees](#the-disk-neither-command-frees).
|
|
741
|
+
|
|
742
|
+
Exit status is `1`, because a clone you were told would go is still on disk. It
|
|
743
|
+
used to be `1` with the *whole* cache still standing: the first refusal stopped
|
|
744
|
+
the purge, so the completion caches, `metadata.json` and every other clone
|
|
745
|
+
survived on account of one directory.
|
|
746
|
+
|
|
747
|
+
When **none** of it goes — nothing under the cache came away at all, which is
|
|
748
|
+
what a symlinked cache root gives you, or one that cannot even be looked at, or
|
|
749
|
+
one whose every entry refused — the headline says that instead of claiming a
|
|
750
|
+
partial success:
|
|
751
|
+
|
|
752
|
+
```
|
|
753
|
+
$ dl --purge -y
|
|
754
|
+
Removed nothing under /home/you/.cache/devlaunch. These refused:
|
|
755
|
+
- /home/you/.cache/devlaunch: Permission denied
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
The report underneath is the same one, and so is the exit status: `0` means the
|
|
759
|
+
cache is gone and nothing else does, which is the only distinction a script can
|
|
760
|
+
act on. Removed everything, removed what it was permitted to and removed nothing
|
|
761
|
+
are three outcomes rather than two, and the sentence is where the third one
|
|
762
|
+
lives — because it is the one that decides whether you still have clones to go
|
|
763
|
+
and look for.
|
|
764
|
+
|
|
765
|
+
What is listed is the directory, once — not the hundreds of files inside it.
|
|
766
|
+
Unlinking needs write permission on the directory rather than on the file, so
|
|
767
|
+
every entry in that clone refuses separately and they are all the same fact.
|
|
768
|
+
Two *separately* unwritable directories on one path are two lines, though,
|
|
769
|
+
because clearing the inner one would leave the outer one just as stuck.
|
|
770
|
+
|
|
771
|
+
Each line carries what the system actually said. A container running as another
|
|
772
|
+
user is the common cause, but a read-only mount, `chattr +i` and a busy
|
|
773
|
+
mountpoint all land here too — and `sudo rm -rf` does not fix those, which is
|
|
774
|
+
why the report offers the cause rather than asserting it.
|
|
775
|
+
|
|
776
|
+
If you have **moved your cache** by making `~/.cache/devlaunch` a symlink, a
|
|
777
|
+
purge refuses it and names the target rather than following it. Remove the real
|
|
778
|
+
directory yourself if you meant to: following the link would empty a directory
|
|
779
|
+
you never named, and removing just the link would report a clean sweep while
|
|
780
|
+
your clones sat on the other volume.
|
|
781
|
+
|
|
782
|
+
### Pruning the clones nothing opens
|
|
783
|
+
|
|
784
|
+
A workspace per branch means clone directories accumulate under the cache, and
|
|
785
|
+
until now nothing removed them: measured on one host, **52 clone directories for
|
|
786
|
+
17 live devpod workspaces — 37 of them attached to nothing, 4.00 GB, against
|
|
787
|
+
7.86 GB still in use.** `--purge` is the wrong tool for that, being
|
|
788
|
+
all-or-nothing: the only way to get the 4 GB back was to destroy the 7.86 GB
|
|
789
|
+
too, and every bare cache with it.
|
|
790
|
+
|
|
791
|
+
`dl --prune` removes exactly the clone directories no live workspace opens. It
|
|
792
|
+
never deletes a devpod workspace, a container, an image or a volume, never
|
|
793
|
+
touches a repo's `.bare` cache (0.08 GB for seven repos, and it is what makes
|
|
794
|
+
the next clone of a repo fast), and never looks outside
|
|
795
|
+
`<cache>/devlaunch/repos`. Every directory it finds is one of three things:
|
|
796
|
+
|
|
797
|
+
- **a live workspace opens it** — kept, and named with the workspace that has
|
|
798
|
+
it. "Opens" means at *or under*: a workspace opened on a subdirectory of a
|
|
799
|
+
clone still needs the clone;
|
|
800
|
+
- **nothing opens it** — removed, unless it holds work that exists nowhere else,
|
|
801
|
+
or `git` would not say what it holds. A clone a container wrote as another
|
|
802
|
+
user is unreadable rather than empty, and "cannot tell" is kept, not removed;
|
|
803
|
+
- **`dl`'s records and devpod's disagree about it** — kept, always. This is
|
|
804
|
+
[#88](https://github.com/blooop/devlaunch/issues/88)'s shape. On that ticket's
|
|
805
|
+
host, 36 devpod workspaces out of 39 recorded a source folder that was gone or
|
|
806
|
+
was a config-only stub, while the real checkout sat beside it under a newer
|
|
807
|
+
naming scheme — so a perfectly healthy clone was opened by nobody, and the
|
|
808
|
+
stub was the only thing anything pointed at. `--prune` will not guess which
|
|
809
|
+
clone such a workspace needs: it keeps every clone of that repository and
|
|
810
|
+
names the record to go and fix. `--force` does not move any of them.
|
|
811
|
+
[`dl --reconcile`](#reconciling-records-that-disagree) is what fixes them.
|
|
812
|
+
|
|
813
|
+
Note that *every* directory two levels under `<cache>/devlaunch/repos` is a
|
|
814
|
+
candidate — a stray directory somebody left there is looked at like any other.
|
|
815
|
+
The cache is `dl`'s to manage; things that are not clones do not belong in it.
|
|
816
|
+
But `git` cannot say what a directory that is not a repository holds, and
|
|
817
|
+
"cannot say" is kept rather than removed, so clearing junk out of the cache
|
|
818
|
+
takes `--force`. That is the same refusal a clone with a half-written `.git`
|
|
819
|
+
gets, and deliberately so: telling the two apart would mean `--prune` forming
|
|
820
|
+
its own opinion about a directory `dl <workspace> rm` already refuses on.
|
|
821
|
+
|
|
822
|
+
```
|
|
823
|
+
$ dl --prune
|
|
824
|
+
Clone directories under /home/you/.cache/devlaunch/repos:
|
|
825
|
+
|
|
826
|
+
Removing 2 that nothing references -- 1.4 GiB:
|
|
827
|
+
- /home/you/.cache/devlaunch/repos/blooop/bencher/bencher-test1-pipagito (1.1 GiB)
|
|
828
|
+
- /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-t1-vebilote (317.0 MiB)
|
|
829
|
+
|
|
830
|
+
Leaving 3:
|
|
831
|
+
- /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo: workspace devlaunch-main-zovomobo still opens it
|
|
832
|
+
- /home/you/.cache/devlaunch/repos/blooop/wayfinder/wayfinder-devlaunch-kilarabo: holds 2 unpushed commit(s) -- add --force to remove it anyway
|
|
833
|
+
- /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
|
|
834
|
+
|
|
835
|
+
Dropping 12 record(s) of directories already gone.
|
|
836
|
+
|
|
837
|
+
Are you sure? [y/N]
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
`-y` skips the question. **A clone holding uncommitted or unpushed work is kept
|
|
841
|
+
and named**, in the same words [`dl <workspace> rm`](#cleaning-up-workspaces)
|
|
842
|
+
refuses in — 13 of those 37 stale clones did, two of them with real unpushed
|
|
843
|
+
commits, so this is load-bearing rather than a formality. `--force` promotes
|
|
844
|
+
that one case and nothing else. Erring this way costs you a flag; erring the
|
|
845
|
+
other way costs work that cannot be recovered.
|
|
846
|
+
|
|
847
|
+
The sizes are the same *exclusive* bytes `dl --ls --size` reports, and they mean
|
|
848
|
+
[the same thing](#how-much-disk-a-workspace-costs): what removing that directory
|
|
849
|
+
would actually free, not what `du` would print. Where a walk could not read
|
|
850
|
+
something the figure reads `≥` and so does the total, because a floor printed as
|
|
851
|
+
a total is a cleanup tool telling you a directory is small when it is not.
|
|
852
|
+
|
|
853
|
+
Directories that will not come away are named the same way [a purge names
|
|
854
|
+
them](#when-part-of-the-cache-will-not-go), the rest still go, and the exit
|
|
855
|
+
status is `1`.
|
|
856
|
+
|
|
857
|
+
**Nothing here runs on its own.** A full scan measured 1017 ms on that host —
|
|
858
|
+
about two warm launches — and it gets slower exactly as the cache gets fuller,
|
|
859
|
+
so it is never on a launch path and never folded into `dl --ls`. Answering `n`
|
|
860
|
+
*is* the read-only view; there is no separate flag for it. It costs one
|
|
861
|
+
`devpod list` to build the plan and no `devpod status` at all, because whether a
|
|
862
|
+
workspace is running has no bearing on whether a directory is opened by one. A
|
|
863
|
+
run you say yes to pays a second `devpod list` before it removes anything, and
|
|
864
|
+
classifies every directory again: a launch that finishes while the report is on
|
|
865
|
+
screen registers a workspace for one of the directories in the plan, and that is
|
|
866
|
+
the one thing the plan cannot be re-checked against from disk. The set you
|
|
867
|
+
approved can shrink between the report and the act. It can never grow.
|
|
868
|
+
|
|
869
|
+
It also drops the `metadata.json` records of directories that are already gone.
|
|
870
|
+
That file was append-only in practice — 49 records for 17 live workspaces on the
|
|
871
|
+
same host — and this is the first thing that prunes it.
|
|
872
|
+
|
|
873
|
+
#### The disk neither command frees
|
|
874
|
+
|
|
875
|
+
Both commands end on the same line, in the same words:
|
|
876
|
+
|
|
877
|
+
```
|
|
878
|
+
$ dl --prune -y
|
|
879
|
+
...
|
|
880
|
+
Removed 2 clone director(ies) -- 1.4 GiB.
|
|
881
|
+
devlaunch does not manage Docker images or volumes: the containers these workspaces used may still hold disk, and `docker system df` shows what Docker is holding.
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
The gigabytes a cleanup reports are usually not the ones you are looking for. On
|
|
885
|
+
the host this was measured, `--prune` had 4.00 GB of stale clones to give back
|
|
886
|
+
while `docker system df` read **86.5 GB of reclaimable images, 43.18 GB of
|
|
887
|
+
volumes and 13.88 GB of build cache** — an order of magnitude more, sitting
|
|
888
|
+
behind a command that had just said "Removed". Saying nothing is what makes a
|
|
889
|
+
freed figure read as *all* of it, so both commands say this instead, whether they
|
|
890
|
+
removed 40 clones, found nothing to remove, or were answered `n` at the
|
|
891
|
+
confirmation. The report you get for saying `n` is a reason to print it, not an
|
|
892
|
+
exception: that is where somebody is deciding what is worth deleting.
|
|
893
|
+
|
|
894
|
+
**It is a sentence, not a measurement.** `dl` runs no `docker` command to print
|
|
895
|
+
it, so there is nothing to be slow and nothing to fail where Docker is absent,
|
|
896
|
+
stopped, or reachable only as another user. The figures above are this README's,
|
|
897
|
+
from the host it was measured on, not from your machine — `docker system df` is
|
|
898
|
+
where yours are.
|
|
899
|
+
|
|
900
|
+
**And it points rather than offers.** There is deliberately no `dl` flag that
|
|
901
|
+
removes an image, and no list of image ids here to paste into `docker image rm`.
|
|
902
|
+
Images devpod builds carry no devlaunch or devpod label, so any list `dl` printed
|
|
903
|
+
would be a guess at which of them belong to these workspaces, and `docker image
|
|
904
|
+
prune -a` is not scoped to devlaunch at all — it would take images built by
|
|
905
|
+
everything else on the machine. Deleting them is a decision with your own
|
|
906
|
+
containers on the other side of it, and `docker system df` is the tool that shows
|
|
907
|
+
you what it costs.
|
|
908
|
+
|
|
909
|
+
### Reconciling records that disagree
|
|
910
|
+
|
|
911
|
+
`dl` keeps its own record of every workspace, and devpod keeps one too. They
|
|
912
|
+
agree until the naming that connects them moves — and it did move once, when
|
|
913
|
+
workspace ids and clone-directory names gained a hashed suffix. `dl`'s records
|
|
914
|
+
were migrated to the new naming; devpod's were not, because nothing knew to
|
|
915
|
+
touch them. On the host that reported it, **36 of 39 devpod workspaces recorded
|
|
916
|
+
a source folder that was missing, or was a stub with no `.git` in it**, while the
|
|
917
|
+
real checkout sat next to it under the new name. Nothing was deleted and nothing
|
|
918
|
+
was corrupted: `dl` was simply asking devpod about workspaces devpod had never
|
|
919
|
+
been given, and devpod was answering correctly that there were none.
|
|
920
|
+
|
|
921
|
+
Two things fix that, and they are different jobs. `dl` now **writes the devpod
|
|
922
|
+
workspace id down** when it creates a workspace, so the naming can move again
|
|
923
|
+
without taking anything with it — that is automatic and needs no command. It
|
|
924
|
+
does nothing for the records that already disagree, because they were written
|
|
925
|
+
before there was a field to write it in. `dl --reconcile` is for those:
|
|
926
|
+
|
|
927
|
+
```
|
|
928
|
+
$ dl --reconcile
|
|
929
|
+
devpod workspaces sourced under /home/you/.cache/devlaunch/repos at something that is not a clone:
|
|
930
|
+
|
|
931
|
+
Re-pointing 2:
|
|
932
|
+
- devlaunch-main: .../blooop/devlaunch/main -> .../blooop/devlaunch/devlaunch-main-zovomobo
|
|
933
|
+
- bencher-test1: .../blooop/bencher/test1 -> .../blooop/bencher/bencher-test1-pipagito
|
|
934
|
+
|
|
935
|
+
Each of these needs `dl <workspace> recreate` afterwards: the container
|
|
936
|
+
still has the old source bind-mounted, and no record change moves a mount.
|
|
937
|
+
|
|
938
|
+
Leaving 1, which dl will not guess at:
|
|
939
|
+
- rockerc-main (.../blooop/rockerc/main): no clone of that repository answers to this name
|
|
940
|
+
|
|
941
|
+
Nothing here is deleted. `dl <workspace> rm` is how one goes, if it should.
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
It matches the two sides **by path, never by id** — the id is the thing that
|
|
945
|
+
changed, so it connects nothing, while the source folder devpod kept still names
|
|
946
|
+
the owner and the repository exactly, and its last component still names the
|
|
947
|
+
branch in one of the three ways `dl` has named a clone directory. Where that
|
|
948
|
+
match is not unique it is refused rather than guessed: a clone a live workspace
|
|
949
|
+
already opens — at it, or anywhere under it — is never taken from it, a clone two
|
|
950
|
+
dead records both match is claimed by neither, and a name that two clones answer
|
|
951
|
+
to (the old flattened spelling turned `feature/auth`, `feature auth` and
|
|
952
|
+
`feature:auth` all into `feature-auth`) adopts neither of them. If a live
|
|
953
|
+
workspace's source cannot be followed at all, the whole command stops the way
|
|
954
|
+
`dl --prune` does, because such a workspace could be holding any of the clones on
|
|
955
|
+
offer. **Nothing is ever deleted.** A workspace `dl` cannot match
|
|
956
|
+
is named and left exactly where it is, because whether a workspace is finished
|
|
957
|
+
with is not something `dl` can know, and the two mistakes are not the same size.
|
|
958
|
+
|
|
959
|
+
Run it as often as you like — a repaired workspace is no longer sourced at a
|
|
960
|
+
non-checkout, so a second run finds nothing to do.
|
|
961
|
+
|
|
962
|
+
**A re-pointed workspace still needs rebuilding.** Its container was built with
|
|
963
|
+
the dead path bind-mounted into it, and changing a record does not move a mount.
|
|
964
|
+
`dl <workspace> recreate` is what finishes the repair, and it is the step that
|
|
965
|
+
needs Docker.
|
|
966
|
+
|
|
967
|
+
**Do not point an old `dl` at a reconciled cache.** A `dl` from before the naming
|
|
968
|
+
changed derives the old directory name, does not find it, and treats the launch
|
|
969
|
+
as a cold one: it clones a second directory under the old name, registers a
|
|
970
|
+
second devpod workspace, and rewrites that branch's record with the old naming
|
|
971
|
+
and an empty workspace id — undoing the repair for that one workspace, and
|
|
972
|
+
leaving you two clones of the branch. It is not destructive and the next
|
|
973
|
+
`dl --reconcile` sorts it out, but a machine that runs both builds against one
|
|
974
|
+
cache will keep re-breaking. Upgrade the old one, or give it its own
|
|
975
|
+
`XDG_CACHE_HOME`.
|
|
976
|
+
|
|
977
|
+
### Cleaning up workspaces
|
|
978
|
+
|
|
979
|
+
One workspace per branch means workspaces accumulate, and `--purge` is the wrong
|
|
980
|
+
tool for tidying: it is all-or-nothing and takes the caches with it.
|
|
981
|
+
|
|
982
|
+
**devlaunch does not decide which workspaces are finished.** Whether a piece of
|
|
983
|
+
work is over is a fact about a ticket, a review, or somebody's intent, and `dl`
|
|
984
|
+
knows about clones and containers. Inferring it from the branch — merged into
|
|
985
|
+
the default, or deleted from the remote — was tried and dropped: it reads like a
|
|
986
|
+
git fact but is a guess at intent, and it cannot tell a squash-merged branch
|
|
987
|
+
from an abandoned one. So `dl` supplies the two halves a tool that *does* know
|
|
988
|
+
needs, and that tool drives the cleanup:
|
|
989
|
+
|
|
990
|
+
```bash
|
|
991
|
+
dl --ls --json # what exists, and what each workspace holds
|
|
992
|
+
dl --ls --json --size # ...and what removing each one would free
|
|
993
|
+
dl <workspace> rm # remove one
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
The JSON reports, per workspace: `id`, `devlaunch` (did `dl` create it),
|
|
997
|
+
`repo`, `branch` (what the workspace was made for), `checkedOut` (what its clone
|
|
998
|
+
is on now, which can differ), `path`, `state`, `lastUsed`, and — the field a
|
|
999
|
+
cleanup tool must not ignore — `unsaved`:
|
|
1000
|
+
|
|
1001
|
+
```json
|
|
1002
|
+
{
|
|
1003
|
+
"id": "devlaunch-wayfinder-devlaunch-80-ladepomi",
|
|
1004
|
+
"devlaunch": true,
|
|
1005
|
+
"repo": "blooop/devlaunch",
|
|
1006
|
+
"branch": "wayfinder/devlaunch-80",
|
|
1007
|
+
"state": "Stopped",
|
|
1008
|
+
"unsaved": {
|
|
1009
|
+
"wouldLose": "2 uncommitted change(s) (pixi.lock, notes.md) and 1 unpushed commit(s)"
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
`unsaved` is an object with exactly one key, and the key says which of three
|
|
1015
|
+
answers it is:
|
|
1016
|
+
|
|
1017
|
+
| `unsaved` | Meaning |
|
|
1018
|
+
| --- | --- |
|
|
1019
|
+
| `{"nothingToLose": true}` | Everything in the clone exists on a remote too. Deleting it costs nothing. |
|
|
1020
|
+
| `{"wouldLose": "<what>"}` | Uncommitted changes (untracked files included), commits no remote has, or both. |
|
|
1021
|
+
| `{"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. |
|
|
1022
|
+
|
|
1023
|
+
The changed paths are named, not just counted, and that matters more than it
|
|
1024
|
+
looks: a devcontainer that runs a package install in its `postCreateCommand` can
|
|
1025
|
+
leave a tracked lockfile modified in *every* workspace it builds — this repo's
|
|
1026
|
+
own does — and as a bare count that is indistinguishable from an hour of unsaved
|
|
1027
|
+
work. A cleanup tool believing the count would then never clean anything. Named,
|
|
1028
|
+
it is judgeable. A workspace `dl` did not create reports `devlaunch: false` and
|
|
1029
|
+
no `unsaved` — `unsaved` is `null` exactly where `devlaunch` is `false`, and
|
|
1030
|
+
nowhere else: there is no clone of `dl`'s to protect, and it has no business
|
|
1031
|
+
inspecting your checkout. (`repo` and `branch` are a weaker test and not the
|
|
1032
|
+
same set: they come from `dl`'s metadata record, and a clone `dl` owns can have
|
|
1033
|
+
lost its record while the clone and the work in it are still on disk. That clone
|
|
1034
|
+
is inspected and reported like any other.)
|
|
1035
|
+
|
|
1036
|
+
**`dl <workspace> rm` refuses to delete a clone it would lose work from — when
|
|
1037
|
+
the recorded clone holds unsaved work, and when it cannot tell what that clone
|
|
1038
|
+
holds**, so a caller that forgets to read the field is still caught. (Recorded,
|
|
1039
|
+
because that is the directory the guard reads; the case with no record is
|
|
1040
|
+
neither, and is described below.)
|
|
1041
|
+
|
|
1042
|
+
```
|
|
1043
|
+
$ dl blooop/repo@feature rm
|
|
1044
|
+
error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
|
|
1045
|
+
Push or commit it, or run: dl blooop/repo@feature rm --force
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
```
|
|
1049
|
+
$ dl blooop/repo@feature rm
|
|
1050
|
+
error: devlaunch-repo-feature-xyz: git could not read /home/…/repo/feature:
|
|
1051
|
+
fatal: not a git repository. devlaunch will not delete a clone it cannot
|
|
1052
|
+
check. Look at it, or run: dl blooop/repo@feature rm --force
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
That refusal is the only judgement `dl` makes here, and it is not about finished
|
|
1056
|
+
work — it is `dl` declining to destroy the only copy of something, including
|
|
1057
|
+
when it cannot prove there is another copy. Say `--force` if you mean it.
|
|
1058
|
+
|
|
1059
|
+
`--force` changes one more answer: an already-absent workspace counts as
|
|
1060
|
+
deleted, like `rm -f`. Unforced, `rm` reports devpod's refusal to delete a
|
|
1061
|
+
workspace it does not have; forced, the contract is the state afterwards, not
|
|
1062
|
+
that a delete happened — which is what lets the [cold benchmark's per-run
|
|
1063
|
+
reset](#measuring-launch-time) run before the first launch, when there is
|
|
1064
|
+
nothing to remove yet.
|
|
1065
|
+
|
|
1066
|
+
The guard reads `dl`'s metadata record, so the recorded directory is the one it
|
|
1067
|
+
asks about. (The delete does not always remove that same directory: when the
|
|
1068
|
+
recorded path is not on disk it falls back to a derived one. That divergence is
|
|
1069
|
+
older than this guard and is tracked as devlaunch#174.) One case is therefore
|
|
1070
|
+
neither a refusal nor a delete: a clone under `dl`'s cache that has **no** record — a metadata write
|
|
1071
|
+
that failed, a record pruned, a cache restored without one. The listing still
|
|
1072
|
+
reports what that clone holds, so `unsaved` is the field to read; but `rm`
|
|
1073
|
+
removes the devpod workspace, exits `0` without asking for `--force`, and leaves
|
|
1074
|
+
the clone on disk, because there is no recorded directory for it to remove
|
|
1075
|
+
either. Nothing is destroyed, and nothing then points at the clone: it is yours
|
|
1076
|
+
to keep or to `rm -rf` by hand.
|
|
1077
|
+
|
|
1078
|
+
[`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
|
|
1079
|
+
names its branches after its tickets, so it knows which workspaces belong to
|
|
1080
|
+
finished work and removes those.
|
|
1081
|
+
|
|
1082
|
+
### How much disk a workspace costs
|
|
1083
|
+
|
|
1084
|
+
`dl --ls --size` adds a `SIZE` column, and `dl --ls --json --size` adds a `disk`
|
|
1085
|
+
object beside the other per-workspace facts:
|
|
1086
|
+
|
|
1087
|
+
```
|
|
1088
|
+
$ dl --ls --size
|
|
1089
|
+
WORKSPACE TYPE SOURCE SIZE LAST USED
|
|
1090
|
+
kinisi-ros-main-lubadaha local /home/…/repos/kinisi-robotics/kinisi_ros/main 64.9 MiB 2026-08-08 11:43:27
|
|
1091
|
+
my-own-checkout local /home/…/projects/scratch - 2026-08-01 09:12:04
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
**The number is what deleting that workspace would give back, not what `du`
|
|
1095
|
+
prints.** Those differ, and the gap is the point of the design. A repo is cloned
|
|
1096
|
+
once into a bare cache and every workspace clone hardlinks its git objects out
|
|
1097
|
+
of that one copy, so the objects exist once on disk however many workspaces
|
|
1098
|
+
share them. A size that walked each workspace on its own — which is what `du`
|
|
1099
|
+
does when you point it at one directory, counting the blocks every file in it
|
|
1100
|
+
occupies — bills each workspace for the whole shared pool.
|
|
1101
|
+
|
|
1102
|
+
The measurement the row above comes from, taken with the shipped code on one
|
|
1103
|
+
machine (Ubuntu 24.04, ext4, warm page cache) on a real clone of that repo made
|
|
1104
|
+
by `git clone` from the bare in `dl`'s own cache:
|
|
1105
|
+
|
|
1106
|
+
| | bytes |
|
|
1107
|
+
| --- | --- |
|
|
1108
|
+
| `du -s --block-size=1` on the clone alone | 353,230,848 |
|
|
1109
|
+
| what `dl --ls --size` reports for it | 68,050,944 |
|
|
1110
|
+
| what `dl --ls --size` reports for the bare it clones from | 651,264 |
|
|
1111
|
+
| `du -sc --block-size=1` over both together | 353,882,112 |
|
|
1112
|
+
|
|
1113
|
+
`du` bills that workspace **5.2x** what deleting it would actually free. The
|
|
1114
|
+
difference is a single 270,823,424-byte pack file with one link in the clone and
|
|
1115
|
+
one in the bare, so removing either end frees none of it.
|
|
1116
|
+
|
|
1117
|
+
**That sharing is a promise, not a coincidence, and a test holds it to that.**
|
|
1118
|
+
`git clone <path> <path>` hardlinks pack files by default, and the default is
|
|
1119
|
+
all that was ever keeping it true — a `file://` URL, an intermediate copy, or an
|
|
1120
|
+
explicit `--no-hardlinks` would each forfeit it with nothing failing and no
|
|
1121
|
+
warning printed. Measured on this repo — `du -sc` over the cache and each
|
|
1122
|
+
clone's `.git`, ext4, git 2.55.0 — that is 2400 KB for the cache plus one
|
|
1123
|
+
workspace against 4472 KB unshared, and 196 KB rather than 2268 KB of `.git` for
|
|
1124
|
+
every workspace after the first. So an integration test asserts the pack files are
|
|
1125
|
+
the cache's — same inode, more than one link — and that assertion goes red on
|
|
1126
|
+
all three. No clone flag is used to guard it: `--local` is already the default
|
|
1127
|
+
and does not even reject a `file://` source, and `--shared`/`--reference` were
|
|
1128
|
+
measured to leave a workspace that fails `git fsck` once the cache has fetched
|
|
1129
|
+
and gc'd, for a 2 KB saving.
|
|
1130
|
+
|
|
1131
|
+
Sharing does erode, in one measured way that is a safety property rather than a
|
|
1132
|
+
fault: when the cache repacks, an existing workspace's pack loses its second
|
|
1133
|
+
link and becomes that workspace's own complete copy, still passing `git fsck`.
|
|
1134
|
+
The workspace stops being cheap and never stops being valid — which is the trade
|
|
1135
|
+
`--shared` and `--reference` get wrong, and the reason they are not used.
|
|
1136
|
+
|
|
1137
|
+
**Large files are shared the same way, but nothing about `git clone` does it for
|
|
1138
|
+
you.** git-lfs objects are not git objects: the clone does not carry them at
|
|
1139
|
+
all, so a workspace of an LFS repo used to download the entire payload from the
|
|
1140
|
+
forge and keep a private copy of it in `.git/lfs/objects` — every workspace,
|
|
1141
|
+
every time, on top of the worktree copy. `dl` now makes the bare cache the
|
|
1142
|
+
repo's LFS store as well: the payload is fetched once into `<repo>/.bare/lfs`
|
|
1143
|
+
for the branch being launched, and each workspace materializes out of *that*,
|
|
1144
|
+
which git-lfs does by hardlinking. Measured with git-lfs 3.7.1 on ext4: the
|
|
1145
|
+
workspace's object file is the same `(st_dev, st_ino)` as the cache's, so its
|
|
1146
|
+
store costs nothing, and the materialization succeeds with the remote deleted
|
|
1147
|
+
from disk — the second workspace of an LFS repo touches the network for its
|
|
1148
|
+
large files not at all. What remains per workspace is the worktree copy, which
|
|
1149
|
+
is real bytes and cannot be shared: a container build has to be able to read
|
|
1150
|
+
them. If the cache cannot supply an object — a first launch offline, a payload
|
|
1151
|
+
the branch alone introduces — the old download from `origin` still runs, and a
|
|
1152
|
+
workspace left holding pointer files is retried on the next launch rather than
|
|
1153
|
+
written off.
|
|
1154
|
+
|
|
1155
|
+
Nothing about that is written into the workspace's `.git/config`, and that
|
|
1156
|
+
restraint is load-bearing rather than tidy: `dl` bind-mounts the *clone*
|
|
1157
|
+
directory into the devcontainer and `.bare` is a sibling that is not mounted, so
|
|
1158
|
+
an `lfs.storage` entry or an added remote naming a host path would break every
|
|
1159
|
+
`git checkout` of an LFS repo inside the container while working perfectly on
|
|
1160
|
+
the host. A test asserts the clone keeps exactly one remote, still pointing at
|
|
1161
|
+
the forge, and no `lfs.storage` at all.
|
|
1162
|
+
|
|
1163
|
+
So `dl` counts a file only when every one of its hardlinks lies inside the
|
|
1164
|
+
workspace being measured. Two consequences, both deliberate:
|
|
1165
|
+
|
|
1166
|
+
- **The sizes do not add up to the size of the cache.** Bytes shared between
|
|
1167
|
+
workspaces belong to none of them, because deleting any one frees none of
|
|
1168
|
+
them. They become the last workspace's the moment it is the last one — which
|
|
1169
|
+
is exactly when deleting it *would* free them. In the table above that is the
|
|
1170
|
+
last two rows read against each other: 68,702,208 reported bytes against
|
|
1171
|
+
353,882,112 held.
|
|
1172
|
+
- **A workspace's size can change without the workspace changing**, when a
|
|
1173
|
+
sibling that was sharing with it goes away. That is the truth about shared
|
|
1174
|
+
storage.
|
|
1175
|
+
|
|
1176
|
+
A workspace `dl` did not create reads `-` (`null` in JSON): there is no clone of
|
|
1177
|
+
`dl`'s there to measure, and walking your own project directory is not `dl`'s to
|
|
1178
|
+
do. The table and the JSON decide that from the same rule — is the clone one
|
|
1179
|
+
`dl` put in its own cache, the same question `--purge` deletes by — so the two
|
|
1180
|
+
always name the same set of workspaces as measurable. Where a walk hits a
|
|
1181
|
+
directory it cannot read — a container writes into its
|
|
1182
|
+
clone as its own user, so this happens — the answer is a floor rather than a
|
|
1183
|
+
total: `≥2.0 MiB` in the table, and `{"atLeastBytes": …, "unreadable": 1}` in
|
|
1184
|
+
JSON instead of `{"exclusiveBytes": …}`. A partial measurement never comes back
|
|
1185
|
+
looking like a complete one.
|
|
1186
|
+
|
|
1187
|
+
**It is opt-in because it walks the whole clone.** Plain `dl --ls` is one devpod
|
|
1188
|
+
round-trip and no filesystem work at all, and the walk is O(files) with no
|
|
1189
|
+
ceiling. Measured with the shipped code on one machine — Ubuntu 24.04, ext4,
|
|
1190
|
+
warm page cache, five runs after a warm-up, the machine otherwise busy — a real
|
|
1191
|
+
8,309-entry clone walked in 24–28 ms, this repo's own tree with its built
|
|
1192
|
+
environment inside it (9,124 entries) in 17–21 ms, and a 114,817-entry tree in
|
|
1193
|
+
232–239 ms. No cold-cache figure is quoted because none was taken: dropping the
|
|
1194
|
+
page cache needs root on that machine. Those are one machine's numbers on warm
|
|
1195
|
+
cache and yours will differ, but the shape is the point — it grows with the file
|
|
1196
|
+
count, and a devcontainer that builds its environment *inside* the clone (this
|
|
1197
|
+
repo's own does) is most of that count. That is not a bill a listing should
|
|
1198
|
+
present unasked.
|
|
1199
|
+
|
|
1200
|
+
Docker images and named volumes are not counted: `dl` did not create the layer
|
|
1201
|
+
store and does not manage volumes. `docker system df` is the tool that knows —
|
|
1202
|
+
the same boundary [`--prune` and `--purge` name](#the-disk-neither-command-frees)
|
|
1203
|
+
when they finish.
|
|
1204
|
+
|
|
1205
|
+
## Examples
|
|
1206
|
+
|
|
1207
|
+
```bash
|
|
1208
|
+
dl # Select workspace with fzf
|
|
1209
|
+
dl devpod # Open existing workspace
|
|
1210
|
+
dl loft-sh/devpod # Create from GitHub
|
|
1211
|
+
dl blooop/devlaunch@main # Create from specific branch
|
|
1212
|
+
dl ./my-project # Create from local folder
|
|
1213
|
+
dl blooop/devlaunch code # Open in VS Code
|
|
1214
|
+
dl blooop/devlaunch -- make test # Run command in workspace
|
|
1215
|
+
dl blooop/devlaunch stop # Stop workspace
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
## Features
|
|
1219
|
+
|
|
1220
|
+
- **Fuzzy Selection**: When called without arguments, uses fzf for interactive workspace selection
|
|
1221
|
+
- **Smart Completion**: Tab completion for workspaces, GitHub repos (owner/repo format), and paths
|
|
1222
|
+
- **GitHub Shorthand**: Use `owner/repo` instead of full URLs - automatically expands to `github.com/owner/repo`
|
|
1223
|
+
- **Branch Support**: Specify branches with `owner/repo@branch` syntax
|
|
1224
|
+
- **Fast Autocomplete**: Completion cache for ~3ms response time (vs ~700ms without cache)
|
|
1225
|
+
- **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 everything a container needs on the way in (naming it, then the tools probe) rides one setup pass, so an interactive `dl <ws>` and a one-shot `dl <ws> -- <cmd>` cost the same trips
|
|
1226
|
+
|
|
1227
|
+
## Measuring launch time
|
|
1228
|
+
|
|
1229
|
+
Set `DEVLAUNCH_TIMING=1` and a `dl` command ends with one summary on stderr,
|
|
1230
|
+
naming each subprocess round trip and the total. Unset (or `0`) records nothing
|
|
1231
|
+
and prints nothing.
|
|
1232
|
+
|
|
1233
|
+
Captured from a real warm launch (the launch's own output elided):
|
|
1234
|
+
|
|
1235
|
+
```bash
|
|
1236
|
+
$ DEVLAUNCH_TIMING=1 dl-next blooop/mcp-devtasks -- true
|
|
1237
|
+
...
|
|
1238
|
+
dl-timing: devpod status 0.454s
|
|
1239
|
+
dl-timing: gh auth token 0.036s
|
|
1240
|
+
dl-timing: devpod ssh 1.952s
|
|
1241
|
+
dl-timing: total 2.444s (in-process, excluding interpreter startup)
|
|
1242
|
+
```
|
|
1243
|
+
|
|
1244
|
+
### The same launch, machine-readable
|
|
1245
|
+
|
|
1246
|
+
`DEVLAUNCH_TIMING=json` swaps that prose for one document on a single
|
|
1247
|
+
`dl-timing-json:` line, so a trend job can read a launch without scraping
|
|
1248
|
+
prose. It decomposes the launch into five **ownership-boundary stages** — one
|
|
1249
|
+
per party that could actually make it faster — with the round trips nested
|
|
1250
|
+
inside the stage that paid for them:
|
|
1251
|
+
|
|
1252
|
+
| stage | what it owns |
|
|
1253
|
+
|---|---|
|
|
1254
|
+
| `handoff` | the gap between the keystroke that resolved to this exec and dl starting (see the stamps below) |
|
|
1255
|
+
| `host-prep` | the host's own git work — the bare clone and its fetches, the lock waits, the LFS probe and, for an LFS repo, the cache's LFS fetch and the workspace's materialization out of it — and the `gh auth token` trip, wherever on the launch it falls |
|
|
1256
|
+
| `devpod-up` | the arm that gets a container running: the existence probe and, when it is not running, the `up` itself. On a warm launch that arm is the probe alone |
|
|
1257
|
+
| `tools` | the probe trip and the conditional lend, including staging the payload tar |
|
|
1258
|
+
| `attach` | the last trip, into the running command |
|
|
1259
|
+
|
|
1260
|
+
Two rules are worth knowing before reading one:
|
|
1261
|
+
|
|
1262
|
+
- **A stage that never ran is absent, not zero.** A warm launch reports no
|
|
1263
|
+
`host-prep` at all, because it did none. A stage that failed is present,
|
|
1264
|
+
timed up to the failure, and marked `failed`.
|
|
1265
|
+
- **A stage totals over its whole arm**, not just over its round trips, so the
|
|
1266
|
+
host-side work between two spawns is attributed rather than lost. Stages
|
|
1267
|
+
never double-count each other: `tools` runs inside the launch `devpod-up`
|
|
1268
|
+
brackets, and those seconds are charged to `tools` alone. The in-process
|
|
1269
|
+
stages therefore add up to the total — measured on a real cold launch below,
|
|
1270
|
+
they came to 20.834s against a 20.834s total.
|
|
1271
|
+
|
|
1272
|
+
`handoff` is the exception to that sum, and the only one: it ends where
|
|
1273
|
+
`total` begins, so it is time the process could not have measured from inside
|
|
1274
|
+
itself. A consumer adding stages up against the total leaves it out.
|
|
1275
|
+
|
|
1276
|
+
Two optional environment variables let whatever launches `dl` — a shell
|
|
1277
|
+
function, an agent front-end — close the loop on the time before dl existed.
|
|
1278
|
+
Both are Unix epoch seconds, which is what `date +%s.%N` prints:
|
|
1279
|
+
|
|
1280
|
+
| variable | meaning |
|
|
1281
|
+
|---|---|
|
|
1282
|
+
| `DEVLAUNCH_HANDOFF_T0` | the keystroke that resolved to this exec. Becomes the `handoff` stage — the only measurement of exec plus interpreter startup there is, since `total` begins after both |
|
|
1283
|
+
| `DEVLAUNCH_PREWARM_FIRED_AT` | when a prewarm (`dl <ws> up`) was fired for this workspace, if one was |
|
|
1284
|
+
|
|
1285
|
+
With the prewarm stamp set, the document also reports what that prewarm was
|
|
1286
|
+
worth: the head start it bought, and which shape the launch then took — `hit`
|
|
1287
|
+
(the workspace was already up), `partial` (this launch queued behind a prewarm
|
|
1288
|
+
still running) or `miss` (this launch ran the `up` itself). dl decides that,
|
|
1289
|
+
not the firer: a prewarm is fired and forgotten, so only the launch that
|
|
1290
|
+
followed can see whether it helped. **A stamp that is missing, unreadable, or
|
|
1291
|
+
ahead of this clock reports nothing** rather than a zero — an absent handoff
|
|
1292
|
+
and an instantaneous one are different facts, and a trend cannot tell them
|
|
1293
|
+
apart once one is written as the other.
|
|
1294
|
+
|
|
1295
|
+
Captured from a real warm launch with both stamps set (one line, wrapped and
|
|
1296
|
+
elided here for reading):
|
|
1297
|
+
|
|
1298
|
+
```bash
|
|
1299
|
+
$ DEVLAUNCH_TIMING=json DEVLAUNCH_HANDOFF_T0=$(date +%s.%N) \
|
|
1300
|
+
DEVLAUNCH_PREWARM_FIRED_AT=... dl-next blooop/mcp-devtasks -- true
|
|
1301
|
+
...
|
|
1302
|
+
dl-timing-json: {"total": 2.210768, "total_epoch": "in-process, excluding interpreter startup",
|
|
1303
|
+
"stages": [{"stage": "handoff", "seconds": 0.130542, "outcome": "ok", "spans": []},
|
|
1304
|
+
{"stage": "host-prep", "seconds": 0.027799, "outcome": "ok",
|
|
1305
|
+
"spans": [{"label": "gh auth token", "seconds": 0.02747}]},
|
|
1306
|
+
{"stage": "devpod-up", "seconds": 0.455188, "outcome": "ok",
|
|
1307
|
+
"spans": [{"label": "devpod status", "seconds": 0.455158}]},
|
|
1308
|
+
{"stage": "attach", "seconds": 1.726961, "outcome": "ok",
|
|
1309
|
+
"spans": [{"label": "devpod ssh", "seconds": 1.72661}]}],
|
|
1310
|
+
"prewarm": {"head_start_seconds": 42.489243, "shape": "hit"}}
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
Stages appear in the order the launch first entered them, and only the ones it
|
|
1314
|
+
reached appear at all — this warm launch built nothing and lent nothing, so
|
|
1315
|
+
there is no `tools` stage and no `devpod up` inside `devpod-up`. That
|
|
1316
|
+
`handoff: 0.131s` is the exec and the interpreter start, which nothing else
|
|
1317
|
+
measures.
|
|
1318
|
+
|
|
1319
|
+
The cold launch of the same repo, same host and session, decomposed as:
|
|
1320
|
+
`host-prep` 2.257s (`git clone --bare` 1.602 + `git fetch` 0.427 + workspace
|
|
1321
|
+
`git clone` 0.065 + LFS probe 0.002 + token 0.034), `devpod-up` 5.848s (`devpod
|
|
1322
|
+
up` 5.113 of it), `tools` 10.224s (probe trip 1.584 + `tools tar` 0.111 +
|
|
1323
|
+
transfer 8.445), `attach` 2.505s — 20.834s of stages against a 20.834s total.
|
|
1324
|
+
|
|
1325
|
+
For before/after numbers, `scripts/bench_launch.py` runs a command N times and
|
|
1326
|
+
reports the median — one command per side of a change:
|
|
1327
|
+
|
|
1328
|
+
```bash
|
|
1329
|
+
python3 scripts/bench_launch.py -n 5 -- dl-next owner/repo -- true # warm launch
|
|
1330
|
+
```
|
|
1331
|
+
|
|
1332
|
+
(`pixi run bench -n 5 -- ...` in the devcontainer.) It reports no median if any
|
|
1333
|
+
run fails, so a broken launch cannot pass as a fast one. See `bench_launch.py
|
|
1334
|
+
--help` for `--before` — the per-run reset that makes a *cold* median cold and
|
|
1335
|
+
whose `rm --force` also succeeds on the first run, when there is nothing to
|
|
1336
|
+
remove yet — and for why its wall clock and `dl-timing: total` are not the
|
|
1337
|
+
same quantity. For scale, on the host the session above was captured on, the
|
|
1338
|
+
warm median over 5 runs was 2.176s. Running the cold recipe exactly as the
|
|
1339
|
+
epilog writes it — `-n 5`, container recreated per run — gave a median of
|
|
1340
|
+
15.899s (runs: 15.9, 20.0, 15.2, 15.7, 17.8). Read that as the cost of
|
|
1341
|
+
recreating a container, not of a first-ever launch: the reset removes the
|
|
1342
|
+
workspace but leaves the docker image layers and the bare clone cache, so
|
|
1343
|
+
every run after the first starts from both. A machine that must also pull or
|
|
1344
|
+
build the image pays more, by an amount this recipe does not measure — but the
|
|
1345
|
+
gap is large: an earlier 3-run median on this same host, reported as its first
|
|
1346
|
+
real launch, was 33.204s.
|
|
1347
|
+
|
|
1348
|
+
Every number in the two paragraphs above was copied into this prose by hand.
|
|
1349
|
+
`--record` is how that stops: it writes the same invocation as one JSON object
|
|
1350
|
+
a trend job can upload without anyone reading it.
|
|
1351
|
+
|
|
1352
|
+
```bash
|
|
1353
|
+
python3 scripts/bench_launch.py -n 5 --record warm.json --shape warm \
|
|
1354
|
+
-- dl-next owner/repo -- true
|
|
1355
|
+
```
|
|
1356
|
+
|
|
1357
|
+
The record holds the command, the run count, each run's wall time and
|
|
1358
|
+
per-stage seconds, and the medians of those — and nothing else, because a CI
|
|
1359
|
+
job stamps its own commit, clock and host better than this script can. Four
|
|
1360
|
+
things about it are load-bearing:
|
|
1361
|
+
|
|
1362
|
+
- **The median is the point, the runs are its evidence.** A trend compares a
|
|
1363
|
+
point against the immediately previous one, so N runs published as N points
|
|
1364
|
+
would read ordinary spread as a regression.
|
|
1365
|
+
- **A stage no run reported is absent, not zero.** A warm launch legitimately
|
|
1366
|
+
has no cold-path stages; a zero would claim the work happened instantly
|
|
1367
|
+
rather than not at all. A stage only *some* runs reported is a median over
|
|
1368
|
+
those runs, carrying a count of how many — a median of two and a median of
|
|
1369
|
+
five are not the same claim.
|
|
1370
|
+
- **Recording asks the launch for its stages** (`DEVLAUNCH_TIMING=json`), so a
|
|
1371
|
+
run that reports no timing document is an error and no record — same
|
|
1372
|
+
discipline as no median over a failed run.
|
|
1373
|
+
- **`--shape` labels the trend line** (`warm`, `cold-recreate`). It is the
|
|
1374
|
+
caller's to say: the same command benches either shape depending on
|
|
1375
|
+
`--before`, and a wrong label is worse in a trend than a missing one.
|
|
1376
|
+
|
|
1377
|
+
### The trend on main
|
|
1378
|
+
|
|
1379
|
+
Every push to `main` runs `.github/workflows/bench.yml`, which benches both
|
|
1380
|
+
shapes on the runner and publishes one point per stage to
|
|
1381
|
+
<https://blooop.github.io/devlaunch/dev/bench/>. It can also be dispatched by
|
|
1382
|
+
hand. Reading it needs nothing but the chart; what follows is for changing it.
|
|
1383
|
+
|
|
1384
|
+
`scripts/bench_points.py` is the step between the two formats — bench records
|
|
1385
|
+
in, one flat array of trend cases out:
|
|
1386
|
+
|
|
1387
|
+
```bash
|
|
1388
|
+
python3 scripts/bench_points.py warm.json cold-recreate.json --out bench.json \
|
|
1389
|
+
--require-stages-on cold-recreate
|
|
1390
|
+
```
|
|
1391
|
+
|
|
1392
|
+
(`pixi run bench-points ...` in the devcontainer.) One case per stage the shape
|
|
1393
|
+
reported, plus that shape's own total: `warm / host-prep`,
|
|
1394
|
+
`cold-recreate / devpod-up`, `warm / total`. Six properties of it are
|
|
1395
|
+
load-bearing:
|
|
1396
|
+
|
|
1397
|
+
- **The published value is the median, and the case name is a key.** The trend
|
|
1398
|
+
compares a point against the immediately previous point and nothing older, so
|
|
1399
|
+
the de-noising has to have happened before publishing. Renaming a stage
|
|
1400
|
+
starts a new, empty series beside a frozen old one.
|
|
1401
|
+
- **The spread rides along as the point's error bar**, and the outside
|
|
1402
|
+
stopwatch (`wall=`) as part of its `extra`. Evidence beside the number,
|
|
1403
|
+
rather than a second trend line for the same launch that disagrees with the
|
|
1404
|
+
first by a constant.
|
|
1405
|
+
- **An absent stage is absent.** A warm launch lends nothing, so there is no
|
|
1406
|
+
`warm / tools` case at all — never a zero, which would claim an instantaneous
|
|
1407
|
+
lend and would drag the line down exactly where a regression should show.
|
|
1408
|
+
- **`--require-stages-on` fails the job rather than the trend.** The way this
|
|
1409
|
+
decomposition is expected to break is an absence, not a wrong number: a stage
|
|
1410
|
+
stops being emitted and the total keeps working. So the cold-recreate shape,
|
|
1411
|
+
where every stage is known to be present, asserts them all, and a run that
|
|
1412
|
+
lost one publishes nothing and goes red. Naming a shape that was not benched
|
|
1413
|
+
fails too — an assertion that covers nothing reads exactly like one that
|
|
1414
|
+
passed.
|
|
1415
|
+
- **A record that is missing or unreadable refuses the same way.** The step
|
|
1416
|
+
that writes the records can exit 0 without having written one, so the absence
|
|
1417
|
+
arrives here — and it prints `bench_points: <reason>` naming the file and
|
|
1418
|
+
writes nothing, like every other refusal, rather than a traceback.
|
|
1419
|
+
- **A regression alerts; it never gates.** The workflow is deliberately not a
|
|
1420
|
+
job in `ci.yml`: a job there would join the CI gate's `needs` by house
|
|
1421
|
+
convention and turn a noisy wall-clock measurement into a merge gate. A point
|
|
1422
|
+
above the threshold leaves a commit comment and a red mark on the chart, and
|
|
1423
|
+
the build stays green.
|
|
1424
|
+
|
|
1425
|
+
## Worktree Backend
|
|
1426
|
+
|
|
1427
|
+
For git repositories, devlaunch uses an efficient worktree backend by default:
|
|
1428
|
+
|
|
1429
|
+
- **Efficient Storage**: Repos are cloned once to `~/.cache/devlaunch/repos/owner/repo/`, then git worktrees are created for each branch
|
|
1430
|
+
- **Shared Git Objects**: All branches share git objects, saving disk space
|
|
1431
|
+
- **Targeted Fetch**: A launch fetches only the one branch it is launching, so no launch waits on a repo-wide refresh
|
|
1432
|
+
|
|
1433
|
+
### What you get when you push and immediately launch
|
|
1434
|
+
|
|
1435
|
+
- **Attaching to a workspace devpod already knows**: no git at all. The workspace
|
|
1436
|
+
is exactly as you left it; freshness inside it is your own `git pull`.
|
|
1437
|
+
- **A cold launch** (first time this branch is launched on this machine, or a
|
|
1438
|
+
clone devpod has forgotten): one targeted fetch of that branch, every time.
|
|
1439
|
+
Push upstream and immediately `dl` the branch and you get the pushed tip.
|
|
1440
|
+
- **A branch that does not exist yet**: created from the default branch's freshly
|
|
1441
|
+
fetched tip.
|
|
1442
|
+
- **Offline**: a warning, and the launch proceeds from whatever the cache holds.
|
|
1443
|
+
It only fails when there is nothing cached to launch from.
|
|
1444
|
+
- **Everything else** (other branches, tags, prunes) is refreshed by the
|
|
1445
|
+
background updater within the configured interval (default: 1 hour), which
|
|
1446
|
+
never blocks a launch.
|
|
1447
|
+
|
|
1448
|
+
### Container Sharing Mode
|
|
1449
|
+
|
|
1450
|
+
Use `--shared` to share a single container across multiple branches of the same repo:
|
|
1451
|
+
|
|
1452
|
+
```bash
|
|
1453
|
+
dl --shared owner/repo@branch1 # Creates container "owner-repo"
|
|
1454
|
+
dl --shared owner/repo@branch2 # Reuses "owner-repo" container
|
|
1455
|
+
```
|
|
1456
|
+
|
|
1457
|
+
### Pre-warming
|
|
1458
|
+
|
|
1459
|
+
Use `--warm` to prepare a workspace without attaching a shell:
|
|
1460
|
+
|
|
1461
|
+
```bash
|
|
1462
|
+
dl --warm owner/repo@branch # Creates container in background
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
## Shell Completion
|
|
1466
|
+
|
|
1467
|
+
After running `dl --install`, you get intelligent tab completion:
|
|
1468
|
+
|
|
1469
|
+
- Workspace names from your devpod list
|
|
1470
|
+
- Known GitHub owners and repositories from your workspaces
|
|
1471
|
+
- File/directory paths when starting with `./`, `/`, or `~`
|
|
1472
|
+
- All global flags (`--ls`, `--install`, etc.) and workspace commands
|
|
1473
|
+
|
|
1474
|
+
### How the completion cache stays current
|
|
1475
|
+
|
|
1476
|
+
The data behind completions lives in `~/.cache/devlaunch/completions.json`, and
|
|
1477
|
+
building it means a `git ls-remote` per known repo — seconds of work. So it is
|
|
1478
|
+
rebuilt in the background at most once an hour (the same interval the worktree
|
|
1479
|
+
backend's background fetch sweep uses), and at most once per `dl` invocation. Commands
|
|
1480
|
+
that change your workspaces (starting, stopping or deleting one) rebuild it as
|
|
1481
|
+
soon as they finish, regardless of when it was last built. Commands with no use
|
|
1482
|
+
for it — `dl --help`, `dl --version` — do not touch it at all.
|
|
1483
|
+
|
|
1484
|
+
A branch created on a remote in the last hour may therefore not be offered yet.
|
|
1485
|
+
`dl --refresh` rebuilds the cache immediately and ignores the interval.
|
|
1486
|
+
|
|
1487
|
+
## Development
|
|
1488
|
+
|
|
1489
|
+
This project uses [pixi](https://pixi.sh) for environment management.
|
|
1490
|
+
|
|
1491
|
+
```bash
|
|
1492
|
+
# Run tests
|
|
1493
|
+
pixi run test
|
|
1494
|
+
|
|
1495
|
+
# Run the e2e suite: real devpod, real containers
|
|
1496
|
+
pixi run test-e2e
|
|
1497
|
+
|
|
1498
|
+
# Run full CI suite
|
|
1499
|
+
pixi run ci
|
|
1500
|
+
|
|
1501
|
+
# Format and lint
|
|
1502
|
+
pixi run style
|
|
1503
|
+
```
|
|
1504
|
+
|
|
1505
|
+
`pixi run test` skips the e2e tests, which need devpod and a Docker daemon and
|
|
1506
|
+
build real containers. CI runs `pixi run test-e2e` in a job of its own, outside
|
|
1507
|
+
the Python matrix, on a throwaway runner — on every push to `main` and on every
|
|
1508
|
+
pull request, whatever branch that pull request targets. Stacked chains, where
|
|
1509
|
+
each link targets its predecessor rather than `main`, get the same CI as anything
|
|
1510
|
+
else.
|
|
1511
|
+
|
|
1512
|
+
Alongside the matrix and e2e there is a `gate` job that does nothing but fail
|
|
1513
|
+
unless every other job in that workflow succeeded. It exists so that a branch
|
|
1514
|
+
ruleset has one stable name to require rather than a list: requiring the jobs one
|
|
1515
|
+
by one means literal strings in a repository setting, which nobody reviews and
|
|
1516
|
+
which goes stale the moment a job is added or renamed — and a required check that
|
|
1517
|
+
no longer exists does not turn a merge red, it stops gating it. Adding a job
|
|
1518
|
+
means adding it to `gate`'s `needs`, in the same pull request, where it can be
|
|
1519
|
+
seen. It reaches only as far as its own workflow file, so the `prek` lint job is
|
|
1520
|
+
not behind it and has to be required alongside it.
|
|
1521
|
+
|
|
1522
|
+
Running it yourself is a different proposition. This repo's devcontainer carries
|
|
1523
|
+
a Docker daemon of its own, through the `docker-in-docker` feature, and pins the
|
|
1524
|
+
same devpod a host installs, so `pixi run test-e2e` from inside it builds its
|
|
1525
|
+
containers in there rather than on your Docker. You can also run it on a machine
|
|
1526
|
+
you do not mind it writing to — an ephemeral CI runner, say. It is skipped by
|
|
1527
|
+
default rather than gated on a container, because what it needs is a daemon, not
|
|
1528
|
+
nesting. Either way the suite exercises `dl --purge`, so it gives itself a
|
|
1529
|
+
private devpod namespace before collection begins — but the containers it builds
|
|
1530
|
+
are real ones, and it wants several minutes and a 1.25 GB image pull the first
|
|
1531
|
+
time.
|
|
1532
|
+
|
|
1533
|
+
Its skips mean one thing only. A test that opts out does so through
|
|
1534
|
+
`fixtures.e2e_guard.opt_out`, and any other skip is reported as a failure,
|
|
1535
|
+
because a run that could not reach a registry used to be indistinguishable from
|
|
1536
|
+
a healthy one. Every run also prints what it actually built:
|
|
1537
|
+
|
|
1538
|
+
```
|
|
1539
|
+
--------------------------------- e2e session ---------------------------------
|
|
1540
|
+
22 e2e tests attempted, 5 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-purge-devlaunchs, e2e-purge-hand-made
|
|
1541
|
+
```
|
|
1542
|
+
|
|
1543
|
+
A run whose workspace-building tests built nothing does not pass: the shortfall
|
|
1544
|
+
is counted into the last line of the run, so `4 passed, 18 skipped` becomes
|
|
1545
|
+
`1 failed, 4 passed, 18 skipped`. A run with no workspace-building tests in it —
|
|
1546
|
+
`pytest -m e2e test/e2e/test_interactive_session.py`, say — has nothing to
|
|
1547
|
+
answer for and says so instead.
|
|
1548
|
+
|
|
1549
|
+
`DEVLAUNCH_E2E_WORKSPACE=<id>` opts in to the interactive-session tests, which
|
|
1550
|
+
attach to a workspace you already have running rather than building one.
|
|
1551
|
+
|
|
1552
|
+
The nested daemon is also why the devcontainer does not join the host's network
|
|
1553
|
+
namespace: a nested daemon needs a namespace of its own, or it co-manages the
|
|
1554
|
+
host's `docker0` bridge and writes its NAT rules into the host's netfilter
|
|
1555
|
+
tables.
|
|
1556
|
+
|
|
1557
|
+
### Disk cost of the dev container
|
|
1558
|
+
|
|
1559
|
+
Opening a devcontainer for a branch costs about **2 GB on the host before you do
|
|
1560
|
+
anything in it**: ~600 MB of image layers unique to this image, a ~680 MB container
|
|
1561
|
+
writable layer, and a ~520 MB `<workspace>-pixi` volume.
|
|
1562
|
+
|
|
1563
|
+
The container carries its own Docker daemon, and that daemon's `/var/lib/docker`
|
|
1564
|
+
lives on a second named volume. One `pixi run test-e2e` plus a couple of nested
|
|
1565
|
+
workspaces puts **~2.3 GB** in there, and nothing garbage-collects it — the inner
|
|
1566
|
+
daemon reports ~45% of its images reclaimable with no reclaimer. Nested daemons
|
|
1567
|
+
share no layers with the host or with each other, so this is paid once per branch.
|
|
1568
|
+
|
|
1569
|
+
**Budget ~4 GB per branch you are actively developing and e2e-testing — about 12 GB
|
|
1570
|
+
for three concurrent branches.**
|
|
1571
|
+
|
|
1572
|
+
The time cost is cold pulls in a fresh nested daemon: the first `devpod up` inside a
|
|
1573
|
+
new container takes ~25s, ~16s of which is pulling a base image the host already has.
|
|
1574
|
+
Workspaces after that reuse it and take ~8s.
|
|
1575
|
+
|
|
1576
|
+
**These volumes are not reclaimed automatically.** `devpod delete` removes the
|
|
1577
|
+
container with `docker rm` and never touches volumes, and Docker never
|
|
1578
|
+
garbage-collects a *named* volume — so `<workspace>-pixi` and
|
|
1579
|
+
`dind-var-lib-docker-*` outlive the workspace that created them. To see what has
|
|
1580
|
+
piled up:
|
|
1581
|
+
|
|
1582
|
+
```bash
|
|
1583
|
+
docker system df -v # under Local Volumes, LINKS 0 means no container uses it
|
|
1584
|
+
```
|
|
1585
|
+
|
|
1586
|
+
Cross-check a name against `devpod list` before removing it with `docker volume rm`:
|
|
1587
|
+
a volume belonging to a live workspace shows `LINKS 1`.
|