Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **A launch that attaches to a workspace devpod already has now says how far
behind its checkout is.** `dl owner/repo@branch` against a running workspace
runs no git and never has -- that is the decision that makes a hot attach hot --
so the checkout inside the container is whatever you left there, and nothing
said so at the moment you were about to conclude something from it. One session
went the whole way to "this devcontainer boots correctly" against a container
built before the repository had a `.devcontainer/` at all.

The line is computed from `dl`'s own clone and reaches no network:

```
Workspace devlaunch-main-3j1t: its checkout is 37 commits behind origin/main as this
workspace last fetched it, and a launch of a workspace devpod already has fetches
nothing. Run 'git fetch' inside it, or 'dl <workspace> rm' and launch again, if you
meant to run against the branch as it is now.
```

**A fetch on attach was the other candidate and would have been the wrong
trade.** The claim here is deliberately narrow: how the checkout stands against
the `origin/<branch>` in that clone, of whatever age the last fetch left it. It
is one `rev-list` against a local repository, single-digit milliseconds beside
the 0.43s to 0.74s one `devpod status` already costs on the same path, and it
cannot be wrong about a remote it never asked. Being behind a week-old ref is
already the whole of what points you at `git fetch` or `rm`.

Commits of your own are named as yours rather than counted as staleness, and
four shapes stay silent: a checkout that agrees with its ref, one that is only
ahead, a workspace whose clone is gone, and `dl <workspace-id>` by bare name,
which carries no branch to be behind.

- **Every `devpod up` says whether it forwarded dotfiles**, naming the repository
and script it passed or saying that devpod's context options name none. `dl`
reads `DOTFILES_URL` from `devpod context options` and from nowhere else -- not
from the process environment, and not from `~/.devpod/config.yaml`, which it only
ever stats -- and it used to forward or omit the flags in silence either way.
That silence is what turned one report of "the dotfiles never landed" into a
fortnight of three plausible causes and no observation to cut between them. An
attach that runs no `up` prints neither line, because it asked devpod for
nothing.

- **`docs/cli.md` now names the cause of `inject agent … exit status 126`.** devpod
picks its agent binary by globbing `uname -a` for `arm`, and `uname -a` carries
the container's hostname, so a workspace whose branch contains `alarm`, `warm`,
`charm`, `swarm`, `harm` or `armature` gets the arm64 agent on an x86 host and a
launch that dies saying only "not executable". `dl` writes the workspace id into
that hostname itself, so it is one of the ways the name gets there. The entry
carries the one-line check, the way to unblock a container that is already in
that state, and the reason a recreate undoes it. The match itself is devpod's and
is not fixed here.

### Changed

- **`dl <ws> reset` stopped describing itself as a clean slate.** Its help line and
the README table said "Clean slate: remove everything, recreate", which reads as
a promise about the checkout that `reset` does not keep: it passes devpod's
`--reset`, and the source removal that flag additionally performs applies only to
a workspace devpod cloned itself, where `dl` hands devpod a local folder. Both
now say what it does -- recreate the container and its volumes -- and
`docs/cli.md` and `docs/workspaces.md` gained the sentence that `rm` is the only
verb which refreshes git state.
## [0.31.0] - 2026-09-04

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ there to answer. [docs/cli.md](docs/cli.md) has the rest.
| `dl <ws> code` | Open it in VS Code |
| `dl <ws> restart` | Stop and start, no rebuild |
| `dl <ws> recreate` | Recreate the container |
| `dl <ws> reset` | Clean slate: remove everything, recreate |
| `dl <ws> reset` | Recreate the container and its volumes from scratch (not the checkout) |
| `dl <ws> dotfiles` | Refresh dotfiles (`chezmoi update`) |
| `dl <ws> -- <cmd>` | Run one command inside it |
| `dl <ws> --rm` | Open it, and delete it when the session ends |
Expand Down
55 changes: 50 additions & 5 deletions docs/cli.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# The command line, in full

[README](../README.md) has the commands you need. This page is the rest: how the
selector decides what you picked, which commands get a terminal, what `--rm`
promises and where it stops, which exits fire it, the spellings that were retired
and what they say now, what `aid`'s Remote Control default starts and how to turn
it off, what `kill` does to a workspace that will not answer, and what happens
when devpod is missing or will not answer.
selector decides what you picked, which verb refreshes git state and which only
touch the container, which commands get a terminal, what `--rm` promises and where
it stops, which exits fire it, the spellings that were retired and what they say
now, what `aid`'s Remote Control default starts and how to turn it off, what
`kill` does to a workspace that will not answer, and what happens when devpod is
missing, will not answer, or injects the wrong agent binary.

## The selector

Expand Down Expand Up @@ -82,6 +83,22 @@ is read one at a time down the terminal, so the branch is spelled in full, slash
all. A tab is a handful of characters read at a glance next to a dozen others, so it
takes the truncated slug and drops the suffix. Same workspace, two jobs.

## What a verb does not refresh

Every verb on this page acts on a container. **`rm` is the only one that refreshes
git state**, because it deletes the clone along with the workspace and the launch
after it is a cold one. `restart`, `recreate` and `reset` all reach a workspace
devpod already knows, and that path runs no git at all: no fetch, no ref update,
no checkout. So a container can be rebuilt repeatedly from a checkout that never
moves, and `reset` in particular is a clean slate for the container and its
volumes rather than for the code in it.

`dl` reports the part of that you cannot see. Launch `owner/repo@branch` against a
workspace devpod already has and, when the checkout is behind the `origin/<branch>`
that clone last fetched, the attach says how far behind before it hands over the
shell. [How fresh a launch is](workspaces.md#how-fresh-a-launch-is) is the whole
of the freshness rules, and the section under it names which verb moves what.

## Commands that need a terminal

`dl <ws> -- <command>` gives the command a terminal whenever `dl` itself has one,
Expand Down Expand Up @@ -648,3 +665,31 @@ Shell completion is the deliberate exception. `dl --install`, `dl --refresh` and
`dl --completion-data` log the failure and carry on with the repos and branches
they can still discover on local disk, so an unreachable devpod costs you
workspace-name completion and nothing more.

### An `up` that dies with `inject agent … exit status 126`

The message is devpod's and the cause is a substring match. devpod decides which
agent binary to inject by globbing `uname -a` for `arm`, and `uname -a` includes
the container's hostname, so a container whose hostname contains `arm` reads as an
ARM machine on an x86 host. devpod downloads the arm64 agent, the version check
cannot execute it, and 126 is the shell's "not executable". Every workspace whose
branch contains `alarm`, `warm`, `charm`, `swarm`, `harm`, `farm` or `armature` is
a candidate, and `dl`'s own setup pass puts the workspace id into the hostname, so
`dl` is one of the ways the name gets there.

```bash
# the mechanism, in any running container
docker exec <container> sh -c 'case "$(uname -a)" in *arm*) echo ARM;; *) echo NOT;; esac'
# what got injected
docker exec <container> file /usr/local/bin/devpod
```

To unblock a container already in this state without rebuilding it, copy the
right binary in and reconnect:

```bash
docker cp "$(command -v devpod)" <container>:/usr/local/bin/devpod
```

Not `restart` or `recreate`: a recreate wipes it. Renaming the branch is the only
fix that lasts until the upstream match is anchored on `uname -m`.
2 changes: 2 additions & 0 deletions docs/rust-rewrite-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ Nothing diverges silently.
| 31 | `prune` is retired as a workspace verb. `dl <ws> prune` / `dl prune <ws>` / `dl prune` were Python's second spelling of `rm` and now refuse with `'prune' is no longer a workspace verb. Use 'dl <workspace> rm' to delete a workspace, or 'dl --prune' to remove the clone directories no workspace opens any more.` (exit 1). The word stays in a `RETIRED` table rather than being dropped, so it is still never read as a workspace name: `dl prune <ws> --force --rm` removes `<ws>` (row 30's suffix form, with `prune` absorbed as the same verb the flag asks for), and a live verb beside it still wins from either position, so `dl stop prune` stops a workspace named `prune` as before. `dl --prune` is unchanged. Row 15's parenthetical about the `Ignoring --devcontainer` diagnostic naming `rm` for the `prune` spelling is now unreachable. | **Superseded in part by row 32**, which retires row 30's suffix form: `dl prune <ws> --rm` is now this row's refusal rather than a removal of `<ws>`, and the word is still in `RETIRED` and still never read as a workspace name. One word meant two unrelated commands, separated only by two dashes: `dl <ws> prune` deleted a workspace and `dl --prune` deletes clone directories and no workspace. The two failure modes were losing a workspace meant to be kept, and being refused by `--prune takes no workspace` for a reason that sentence cannot explain. `rm` has no such twin, and the retirement is a refusal that names both meanings rather than a silent removal. Pinned by the `prune_is_refused_as_a_verb_from_either_position`, `a_retired_word_is_never_read_as_the_workspace` and `a_recalled_prune_line_is_the_retirement_and_the_pair_is_named_ahead_of_it` tests in `dl/src/cli.rs`. |

| 32 | **`--rm` is docker's `--rm`, and the flag-spelled verbs are retired.** `dl <ws> --rm` and `dl <ws> --rm -- <cmd>` now hand over a session and delete the workspace once it ends — what row 30's `--autorm` did, under the name docker gives it. The word `rm` is unchanged and is the only way to delete one *now*, so `docker rm` / `docker run --rm` is the whole of the grammar and no spelling has to be read twice. Three withdrawals pay for it, each refused at exit 1 rather than reinterpreted: `--autorm` (`--autorm is now spelled --rm: …`); `--stop`, whose only reason to exist was being a flag (rows 15 and 30) and which cannot stay a *cancelling* suffix beside a `--rm` that runs the line; and row 30's suffix override itself, so `aid <ws> 'review this pr' --rm` now runs the review and deletes afterwards where it used to delete instead, and `dl prune <ws> --rm` is row 31's refusal rather than a removal (with `--force` also on the line it is the `--force`-beside-`--rm` refusal instead, named first because the pair is the more confused half). `--force` still does not compose with `--rm` — docker keeps `-f` on `rm` too — and `dl <ws> rm --rm` is refused as the two requests it is. Retired flags are answered *before* anything else the line got wrong, since both moved on account of `--rm`'s new meaning. aid peels the retired spellings so dl refuses them by name instead of joining them into a prompt, and builds no agent command for such a line, so nothing is booted on the way to exit 1. `Overridden`, `pick_target` and the `--rm overrode the rest of the line` notice are gone with the override. | Row 30 bought "and now delete it" as a suffix and paid for it with a flag whose meaning was unguessable from its spelling: `--rm` cancelled the line, `--autorm` ran it, and the two looked like a pair. docker had already split the same problem the other way — a verb for now, a `run` flag for after — and taking that split makes the common line (`aid <ws> <prompt> --rm`: send the agent in, get the disk back) the one the short spelling names, at the cost of the rarer one, which `dl rm` and a pick does in fewer keystrokes than recalling a long prompt to append to it. The withdrawals are recognised rather than deleted, for row 31's reason: a flag dropped from `Cli` is clap's `unexpected argument` at exit 2, naming the spelling and not the replacement — and the spelling is exactly what cannot explain a line that stopped working because a *different* flag changed meaning. Pinned by the `retired_flag` / `rm_` tests in `dl/src/cli.rs`, `the_retired_flag_spellings_name_the_words_that_replaced_them` and `autorm_is_refused_with_the_spelling_that_replaced_it` in `dl/tests/lifecycle.rs`, the `rm_on_exit_` tests in `dl/tests/launch.rs`, and `a_retired_spelling_starts_no_agent_and_is_handed_to_dl_to_refuse` in `aid/src/rewrite.rs`. |
| 33 | **A warm launch of a triple says how far behind its checkout is.** `dl owner/repo@branch` against a workspace devpod already knows prints one line before the attach banner when the clone's `HEAD` is behind the `refs/remotes/origin/<branch>` that clone last fetched, naming the count both ways (`its checkout is 37 commits behind origin/main … (and 3 of its own it has not pushed)`). Python printed nothing, and neither did earlier Rust. The launch still runs no fetch: the report is one `rev-list` against a local repository, so it says how the checkout stands against a ref of whatever age and never claims to know the remote. Silent when the counts agree, when the checkout is only ahead, when there is no clone on disk, for a bare workspace name (no triple, so no branch to name and no clone path to derive), and for a warm resolution addressed by an id `metadata.json` recorded rather than the one the triple derives (devlaunch#88's arm, where the derived clone path is not this container's source). `reset`'s help line and the README table stopped saying "Clean slate: remove everything, recreate" in the same change, because that promise reads as one about the checkout and `reset` cannot keep it. | The warm arm makes no git call and never will (devlaunch#144, built by #149 and #150), and a launch that looks like it verified new work when it verified neither the commit nor the image invalidates whatever was concluded inside the container. A fetch on attach was the tempting fix and is the wrong trade; the fact was available locally the whole time. Pinned by the `checkout`/`warm_triple` tests in `flows::launch` and by `a_warm_triple_whose_checkout_is_behind_says_how_far_and_still_runs_no_git_fetch` plus `a_bare_workspace_name_reports_no_checkout_however_stale_it_is` in `dl/tests/launch.rs`, whose world is `launch_scenario.py`'s `--stale-checkout`. blooop/devlaunch#560 §1. |
| 34 | **Every `devpod up` says whether it forwarded dotfiles.** One line per `up`: the repository and script it passed, or that devpod's context options name none and where to set one. Python read `devpod context options`, silently forwarded `--dotfiles`/`--dotfiles-script` or silently omitted them, and left no way from the terminal to tell which had happened. An attach that runs no `up` prints neither line. | Three plausible causes and no observation to cut between them is what turned "the dotfiles never landed" into a fortnight: `DOTFILES_URL` is read out of `devpod context options` and out of nothing else, not the process environment and not `~/.devpod/config.yaml`, and `context-options.json` is dl's *cache* of that answer rather than an input. The line costs nothing on a path that is already spawning a container build, and it makes a silent policy checkable. Pinned by `an_up_says_which_dotfiles_it_asked_devpod_for_and_the_argv_agrees` in `dl/tests/launch.rs`, which asserts the sentence and the argv together so the two cannot drift. blooop/devlaunch#560 §2. |

Additions require a PR that updates this table; the row number is cited by any
per-binary harness branch.
Expand Down
27 changes: 27 additions & 0 deletions docs/workspace-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -1410,6 +1410,33 @@ own layer, so `dl <workspace> stop` and a fresh `up` keep the root-owned
directory; `dl <workspace> recreate` gets a new layer where `~/.cache` is the
user's own again.

### Where the dotfiles setting comes from, and how to see it

`dl` forwards `--dotfiles` and `--dotfiles-script` to `devpod up` from
`devpod context options` and from nowhere else. Not from the process environment:
exporting `DOTFILES_URL` in your shell sets nothing. Not from
`~/.devpod/config.yaml` either, which `dl` only ever stats, to decide when its own
copy of those options has gone stale. And `~/.cache/devlaunch/context-options.json`
is that copy rather than an input, so hand-writing devpod's nested shape into it
sets nothing and reports nothing: the read fails silently and `dl` asks devpod,
which is the only answer that ever counted.

```bash
devpod context set-options DOTFILES_URL=https://github.com/you/dotfiles
devpod context options --output json # the one question dl asks
```

Every `devpod up` `dl` runs now says which of the two it did, so the flag and the
line cannot disagree:

```
dotfiles: https://github.com/you/dotfiles (devpod context options), passed to devpod up; devpod installs them when it creates the container.
dotfiles: none set in devpod context options, so this up asked for none. 'devpod context set-options DOTFILES_URL=<repo>' is the only place dl reads it from.
```

An attach that runs no `up` prints neither, because it asked devpod for nothing
either way.

### Refreshing dotfiles on attach

Under chezmoi, the refresh is `chezmoi update`. If that fails **and** the
Expand Down
31 changes: 30 additions & 1 deletion docs/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,14 @@ on a repo-wide refresh.
### What you get when you push and immediately launch

- **Attaching to a workspace devpod already knows**: no git at all. The workspace
is exactly as you left it; freshness inside it is your own `git pull`.
is exactly as you left it; freshness inside it is your own `git pull`. `dl` now
says so when it matters: launch `owner/repo@branch` and, if the checkout in
`dl`'s clone is behind the `origin/<branch>` that clone last fetched, the attach
reports how far behind before handing over the shell. That report is read out of
the clone and costs no network call, so it says how the checkout stands against
a ref of whatever age and never claims to know the remote now. You get it for a
spec that names a branch, since that is the shape which implies a claim about
one; `dl <workspace-id>` carries no branch and stays silent.
- **A cold launch** (first time this branch is launched on this machine, or a
clone devpod has forgotten): one targeted fetch of that branch, every time.
Push upstream and immediately `dl` the branch and you get the pushed tip.
Expand All @@ -177,6 +184,28 @@ on a repo-wide refresh.
background updater within the configured interval (default: 1 hour), which
never blocks a launch.

### Which verb moves the checkout, and it is only one

`rm` is the only verb that refreshes git state. Everything else acts on the
container and leaves the clone standing:

- `restart`, `recreate` and `reset` all reach a workspace devpod already knows,
so they take the attach path above and run no git. `reset` is the one worth
spelling out, because "clean slate" reads like a promise about the checkout: it
passes devpod's `--reset`, which recreates the container and removes its
volumes, and additionally removes the *source* only for a workspace devpod
cloned itself. `dl` hands devpod a local folder (its own clone), never a git
URL, so there is no devpod-managed source to remove and the clone is untouched.
- `dl <workspace> rm` deletes the workspace and the clone with it, so the next
launch is a cold one: a fetch, a fresh clone, and the branch reset to the
fetched ref.
- Inside the container, `git fetch` and `git pull` are yours and always were.

A clone that is already on disk is never fast-forwarded for you, even on a cold
launch: an existing directory gets a plain `git checkout <branch>` so that
uncommitted work survives, and only a directory `dl` has just created is reset to
the fetched ref.

### Preparing a workspace without attaching

`dl <workspace> up` prepares one, and running it repeatedly is
Expand Down
Loading
Loading