From 790e6bb9ba2c45c54bec944d7a6ee427ecd56440 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Thu, 20 Aug 2026 15:11:14 +0000 Subject: [PATCH 1/4] Order the README for human readers first The options table, the workspace id derivation and the ~600 lines of purge/prune/reconcile and disk accounting came before anything a reader types, and `## Examples` sat at line 1209 of 1620. The top half is now what someone needs on the way in: features, install, usage with the examples folded in beside the command shapes, workspace commands, the `dl --ls` global table, `aid`, the zellij pane, auth, tools, completion. A rule and one line mark where reference begins. Two structural moves beyond the reordering: - `## Global Commands` is split. Its table stays up top; the five deep subsections below it become `## Cleaning up: purge, prune, reconcile`, so `dl --ls` is no longer documented 600 lines down. - `## Workspace Sources` was a duplicate of the `## Examples` block. It is folded into the one canonical list, now `### Examples` under Usage. The header gains a short orientation paragraph and a two-row index. The badge wall keeps its place but loses the `## Continuous Integration Status` heading, so the first section is about the tool. No heading text was renamed: all 13 internal anchors still resolve, and nothing describing a command's behaviour changed. --- README.md | 795 +++++++++++++++++++++++++++--------------------------- 1 file changed, 403 insertions(+), 392 deletions(-) diff --git a/README.md b/README.md index d3fe21bf..faadd794 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete and a built-in fuzzy selector. -## Continuous Integration Status +`dl owner/repo@branch` is the whole interface: it clones the repo if it has to, builds or starts the +devcontainer, and drops you into a shell inside it. Every branch gets its own container, and +launching one again attaches to what is already there. [![Ci](https://github.com/blooop/devlaunch/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/blooop/devlaunch/actions/workflows/ci.yml?query=branch%3Amain) [![Codecov](https://codecov.io/gh/blooop/devlaunch/branch/main/graph/badge.svg?token=Y212GW1PG6)](https://codecov.io/gh/blooop/devlaunch) @@ -15,6 +17,19 @@ A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete an [![Platform](https://img.shields.io/badge/platform-linux--64-blue)](https://github.com/blooop/devlaunch/releases) [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh) +**Start here:** [Features](#features) · [Installation](#installation) · [Usage](#usage) · [Workspace Commands](#workspace-commands) · [Global Commands](#global-commands) · [aid](#aid-start-a-coding-agent-in-a-workspace) · [A terminal beside the agent](#a-terminal-beside-the-agent) · [GitHub auth](#github-authentication) · [Tools in every workspace](#tools-in-every-workspace) · [Shell completion](#shell-completion) + +**Reference:** [Options](#options) · [Workspace IDs](#workspace-ids) · [Cleaning up](#cleaning-up-purge-prune-reconcile) · [pixi cache](#the-shared-pixi-package-cache) · [Worktree backend](#worktree-backend) · [Launch timing](#measuring-launch-time) · [Development](#development) + +## Features + +- **Fuzzy Selection**: When called without arguments — or with a verb and no workspace — opens a built-in fuzzy selector; nothing to install, and no `fzf` on `PATH` to find +- **Smart Completion**: Tab completion for workspaces, GitHub repos (owner/repo format), and paths +- **GitHub Shorthand**: Use `owner/repo` instead of full URLs - automatically expands to `github.com/owner/repo` +- **Branch Support**: Specify branches with `owner/repo@branch` syntax +- **Fast Autocomplete**: Completion cache for ~3ms response time (vs ~700ms without cache) +- **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 ` and a one-shot `dl -- ` cost the same trips + ## Installation ### Pixi (Recommended) @@ -55,22 +70,6 @@ dl --install source ~/.bashrc # or restart your terminal ``` -### Going back to the Python build - -`0.1.0` is the Rust rewrite: same commands, same cache, same `metadata.json`, different -implementation (the whole of it is in [docs/rust-rewrite-plan.md](docs/rust-rewrite-plan.md), whose -divergence table is the complete list of what changed on purpose). The last Python release is -`0.0.29`, and nothing in the cache stops you going back to it: - -```bash -pixi global install --channel conda-forge --channel https://prefix.dev/blooop "devlaunch<0.1" -pip install "devlaunch<0.1" -``` - -Both builds read and write the same `metadata.json` at schema 2 under the same locks, so a downgrade -keeps every workspace you already have and needs no cleanup. Please open an issue for whatever sent -you back. - ## Usage ```bash @@ -87,6 +86,21 @@ install for it and why `dl` with its input redirected away from a terminal simpl one. A reserved verb wins over a workspace name of the same spelling: `dl stop` opens the selector to stop something, it does not look for a workspace called `stop`. +### Examples + +```bash +dl # Select a workspace interactively +dl myproject # Open an existing workspace by name +dl loft-sh/devpod # Create from a GitHub repo +dl blooop/devlaunch@main # Create from a specific branch +dl ./my-project # Create from a local path +dl blooop/devlaunch code # Open in VS Code +dl blooop/devlaunch -- make test # Run a command in the workspace +dl blooop/devlaunch stop # Stop the workspace +``` + +A name that is not `owner/repo`, a URL or a path is looked up among the workspaces you already have. + ### Commands that need a terminal `dl -- ` gives the command a terminal whenever `dl` itself has one, @@ -99,6 +113,50 @@ workspace has none, `dl` says so and falls back to the plain `devpod ssh` transport, which has no terminal; `dl restart` republishes the alias. Set `DEVLAUNCH_NO_TTY=1` to force the fallback everywhere. +## Workspace Commands + +| Command | Description | +|---------|-------------| +| `dl up` | Start (or create) the workspace without attaching — for prewarming a container before a session wants it | +| `dl stop` | Stop the workspace | +| `dl rm, prune` | Delete the workspace | +| `dl code` | Open in VS Code | +| `dl restart` | Stop and start (no rebuild) | +| `dl recreate` | Recreate container | +| `dl reset` | Clean slate (remove all, recreate) | +| `dl dotfiles` | Refresh dotfiles in the running workspace (`chezmoi update`) | +| `dl -- ` | Run shell command in workspace (with a terminal, when `dl` has one) | + +Every verb in that table also takes the workspace second — `dl stop ` — and with no +workspace at all it opens the selector and applies itself to what you pick. `stop` and `rm` answer to +`--stop` and `--rm` as well, since the flag spellings were documented long before they worked. + +## Global Commands + +| Command | Description | +|---------|-------------| +| `dl --ls` | List all workspaces | +| `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 | +| `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 | +| `dl --install` | Install shell completions | +| `dl --prune [-y] [--force]` | Remove [the clone directories no workspace opens any more](#pruning-the-clones-nothing-opens) — and nothing else | +| `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 | +| `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches | +| `dl --refresh` | Refresh completion cache | +| `dl --help, -h` | Show this help | +| `dl --version` | Show version | + +```bash +$ dl --version +dl 0.1.0 +``` + +The version and nothing else. `aid --version` reports the same version under its +own name — the two binaries are built from one cargo package and cannot disagree +about it. (Through 0.0.29 an editable install appended `(dev, editable from +)`, read out of the installed package's PEP 610 metadata. A compiled binary +has no such metadata and no editable installs, so there is nothing left to say.) + ## aid: start a coding agent in a workspace `aid` is `dl` with a coding agent started for you: @@ -150,166 +208,98 @@ deleting, VS Code — stays with `dl`. The agent's CLI has to be installed in the container; `aid` runs it there, it does not install it. -## Workspace Sources +## A terminal beside the agent -```bash -dl myproject # Existing workspace by name -dl user/repo # Create from GitHub repo -dl user/repo@branch # Create from specific branch -dl ./path # Create from local path -``` +Every workspace `dl` opens also has [zellij](https://zellij.dev) on `PATH`, which +buys one thing the other tools do not: an agent running in a container can open a +**second terminal next to itself**, in the same container, and you can attach to it +from anywhere to watch or to type. -## Workspace IDs +Nothing has to cooperate for this. It does not come from your dotfiles, it does not +need an edit to any repo's `devcontainer.json`, and it works in images `dl` has never +seen — the same argument the rest of "Tools in every workspace" makes, for the same +reason: `dl` launches arbitrary repos. -`dl user/repo@branch` derives one id that names both the devpod workspace (what you -see in `dl --ls`) and the clone directory under `~/.cache/devlaunch/repos/`: +### Opening a pane from inside a session -``` --- at most 38 characters +From anywhere inside the container — including from a completely non-interactive +command, with no terminal attached to anything: -blooop/devlaunch@main -> devlaunch-main-zovomobo -blooop/devlaunch@feature/auth -> devlaunch-feature-auth-poliseno -blooop/devlaunch@feature-auth -> devlaunch-feature-auth-nesatabe -blooop/test_renv@nb4 -> test-renv-nb4-polenita -kinisi-robotics/kinisi_ros@ags-devcontainer-tooling-support - -> kinisi-ros-ags-devcontainer-t-lenevere -blooop/devlaunch@dependabot/github_actions/codecov/codecov-action-6 - -> devlaunch-dependabot-codecov-sifivasa +```bash +zellij -s devlaunch action new-pane -- htop ``` -The eight-character syllable suffix is a hash of the full `(owner, repo, branch)` triple. -It is what makes the id unique: the readable part is shortened to fit the length limit, -and shortening it does not affect whether two branches share an id. Long branch names -drop whole `/`-separated middle segments before losing characters, so the part that -identifies the branch survives. Note the third and fourth lines above: `feature/auth` and -`feature-auth` read the same once slugged but are different branches, and they get -different ids. - -Owner and repo are matched case-insensitively, the way GitHub treats them, so -`dl NVIDIA/cuda-samples@main` and `dl nvidia/cuda-samples@main` are the same workspace. -Branch names are case-sensitive, because git refs are. - -URL specs (`dl github.com/owner/repo`) get an id in the same shape, with the suffix -hashed over the URL. - -The id is also the container hostname, so it stays well inside the 38-character budget -to leave room for tools that add their own prefixes. - -Branch names must be safe as both git refs and directory names — a name with a space or -a leading dash is rejected rather than quietly rewritten. - -### Upgrading from an older devlaunch - -This id format is new, and the directories and containers on your machine were named by -the previous scheme. The first `dl user/repo…` command after upgrading migrates the cache -once, and leaves what it did behind in the cache directory (the two listings named below). -`dl --help`, `dl --version`, `dl --ls` and opening an existing workspace by name do not trigger it. +`-s ` is the form to use and the only one worth depending on. Bare +`zellij action new-pane` happens to work by falling back to the single running +session, which stops being a single session the moment there are two of them. -**Your clone directories are renamed.** What was -`~/.cache/devlaunch/repos/blooop/devlaunch/main` becomes -`~/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo`. A workspace is a git -clone whose `origin` points at the `.bare` cache next to it, and `.bare` does not move, so -this is a plain rename: branches, history and **uncommitted changes all survive** — only -the folder name changes. `metadata.json` is updated in the same pass, so nothing is left -pointing at the old name. +`devlaunch` is the session name `dl` creates and the one to name here. -**Your existing devpod containers keep their old ids and are orphaned — and they can -often be repaired rather than replaced.** An orphaned container is sourced at the path this -migration just renamed, with the real clone sitting next to it under the new name, which is -precisely what [`dl --reconcile`](#reconciling-records-that-disagree) is for: it re-points -devpod's record at the renamed clone, and `dl recreate` finishes the repair. -That gives you back the clone association and the workspace's identity — not state that -lived only inside the old container, which nothing can bring back. The repair is -order-dependent: relaunching the branch claims the renamed clone for a fresh container, -and reconcile never re-points a clone a live container holds — so reconcile first, then -relaunch. Left alone, the next `dl user/repo@branch` simply builds a fresh container -under the new id, and deleting the old one is all that remains for it. +### Switching the wrap on -dl does not delete containers for you — deleting by id is how a running sidecar got -destroyed the last time something tried ([kinisi_ros#9766](https://github.com/kinisi-robotics/kinisi_ros/pull/9766)) — -so it writes the old ids to -`~/.cache/devlaunch/orphaned-workspaces.txt`. For the workspaces you are finished with, -the disposal command reads from that listing: +The session an agent opens panes into has to exist first, and creating it is +**off by default**: ```bash -xargs -r -n1 devpod delete < ~/.cache/devlaunch/orphaned-workspaces.txt +DEVLAUNCH_ZELLIJ=1 dl someone/repo -- claude -p "do the thing" ``` -**A clone directory with no metadata record is left alone.** Nothing records which branch -it was cloned for, and the old directory name cannot be turned back into one — `feature/auth` -and `feature-auth` both became `feature-auth` — so a guessed name would be worse than no -rename. Those directories stay exactly where they are and are listed in -`~/.cache/devlaunch/unmigrated-clones.txt`. - -Running dl again changes nothing: the migration is keyed on the `version` field in -`metadata.json`, not on directory names, so a branch that happens to look like a new-scheme -id is never mistaken for one. If a migration is interrupted, the next run finishes it — the -version is written last, in the same atomic save as the new paths, so it never claims more -than the filesystem has actually done. A rename the filesystem refuses (a read-only mount, -tightened permissions) is treated the same way: the version stays put and every later run -retries the refused directories and repeats the notice until the underlying refusal is -fixed by hand. - -## Workspace Commands +| Variable | Description | +|----------|-------------| +| `DEVLAUNCH_ZELLIJ=1` | Before running `dl -- `, make sure a zellij session named `devlaunch` exists in the container, so the command can open panes into it | -| Command | Description | -|---------|-------------| -| `dl up` | Start (or create) the workspace without attaching — for prewarming a container before a session wants it | -| `dl stop` | Stop the workspace | -| `dl rm, prune` | Delete the workspace | -| `dl code` | Open in VS Code | -| `dl restart` | Stop and start (no rebuild) | -| `dl recreate` | Recreate container | -| `dl reset` | Clean slate (remove all, recreate) | -| `dl dotfiles` | Refresh dotfiles in the running workspace (`chezmoi update`) | -| `dl -- ` | Run shell command in workspace (with a terminal, when `dl` has one) | +With it off, no invocation changes meaning at all — that is what off means here, and +it is why the switch exists rather than the behaviour simply being on. -Every verb in that table also takes the workspace second — `dl stop ` — and with no -workspace at all it opens the selector and applies itself to what you pick. `stop` and `rm` answer to -`--stop` and `--rm` as well, since the flag spellings were documented long before they worked. +**The command runs beside the session, not inside a pane of it.** That is deliberate. +Putting the command in a pane would hand its stdin, stdout and exit status to zellij, +and all three are things `dl` promises to leave alone: `dl -- cmd > file` has to +put the command's own output in the file, and a failing command has to come back with +its own status. Since `zellij -s action new-pane` works perfectly well from a +command that is in no session at all, running beside the session costs nothing and +delivers the same pane. -## Options +**The interactive session of a bare `dl ` is untouched, switched on or +off.** An interactive attach sends no command for the wrap to attach to — that is +exactly what gets it a terminal from devpod — and giving it one would cost either the +terminal or a round trip in front of every shell. You land in an ordinary login shell +with `zellij` on `PATH`, so `zellij attach -c devlaunch` gets you the session, and any +panes an agent has opened in it, whenever you want them. -| Option | Description | -|--------|-------------| -| `--devcontainer ` | Use a non-default `devcontainer.json`. A bare name means `.devcontainer//devcontainer.json`. Stored with the workspace, so pass it once. | -| `DEVLAUNCH_NO_TTY=1` | Never give a workspace command a terminal; always use the plain `devpod ssh` transport. | -| `DEVLAUNCH_DOTFILES_ON_ATTACH=1` | Refresh dotfiles before handing over an interactive shell. Off by default; see below. | +There is one exception, and it is a pleasant one: if you also run with +`DEVLAUNCH_DOTFILES_ON_ATTACH=1`, that refresh is a command, so it gets wrapped like +any other and the session is already there when the shell arrives. -Projects with demanding devcontainers — several variants, compose sidecars, or a -host-side `initializeCommand` that has to tell branch workspaces apart — are -covered in [docs/devcontainer-projects.md](docs/devcontainer-projects.md). +### Existing workspaces -### Refreshing dotfiles on attach +zellij arrives on the setup pass, which runs on every `devpod up`. So a workspace +that predates this picks it up on its next **`dl restart`** — a full +`dl recreate` also works but is not needed, because nothing here is a +bind mount and mounts are the thing that only lands at container creation. -devpod applies dotfiles when it *provisions* a workspace, so a workspace that has -been up for a fortnight still has the dotfiles it was born with. `dl -dotfiles` fixes that when you think of it; `DEVLAUNCH_DOTFILES_ON_ATTACH=1` makes -`dl` think of it for you, running the same refresh just before it hands you the -shell. +Attaching to a workspace that is *already running* skips `devpod up` and so skips +this too, which is what makes the restart necessary rather than automatic. -```bash -DEVLAUNCH_DOTFILES_ON_ATTACH=1 dl someone/repo -``` +### What it costs -It is off unless you set it, and that is the point rather than caution. The -refresh is a `devpod ssh` round-trip — measured at ~1.7s, almost all of it -connection setup — with a `git pull` behind it, and it would otherwise be charged -to every attach on every machine to close a gap most people do not have. +Almost nothing, and that was measured rather than assumed. zellij is a conda-forge +package installed by pixi into the container, so it lands in the shared package cache +above and every container after the first extracts rather than downloads: -Two things it deliberately does not do: +| | | +|---|---| +| **Warm install** (shared cache populated) | 0.56s / 0.23s / 0.23s over three fresh containers | +| **Cold install** (empty cache) | 3.0s, filling 167MB of shared cache | +| **Every launch after the first** | one `command -v`; the whole setup pass measured at 50ms | -- **It never runs for `dl -- `.** A one-shot command renders no - prompt and sources no interactive shell, so refreshing in front of it would - buy that command nothing and cost it the round-trip. That path is the one - agent launchers use, and it stays exactly as fast as it was. -- **It never holds the shell hostage.** The refresh gets 60 seconds; an - unreachable dotfiles remote, or one that wants a password nobody is there to - type, means a pause and then your shell, not a hang. Failure is a warning — - you get the workspace either way. +**It can never fail a launch.** Provisioning zellij is a stage of the setup pass, so a +container with no network, no pixi and no way to get either reports the stage as +failed, by name, and then opens exactly as it would have. A container that ends up +without zellij still works; with the wrap on, the command still runs, because the +session setup is allowed to fail and the command runs regardless. -Refreshes run every time you attach, with no cooldown, because you asked for -them. If that is too often, unset the variable and use `dl dotfiles`. +`DEVLAUNCH_NO_TOOLS=1` turns this off along with the rest of tool provisioning — +installing zellij is tool provisioning, where naming a container is not. ## GitHub Authentication @@ -479,210 +469,173 @@ Attaching to a workspace that is *already running* skips `devpod up`, and so ski this too. A workspace started by something other than `dl` — or created before this existed — picks the tools up on its next `dl restart`. -## A terminal beside the agent - -Every workspace `dl` opens also has [zellij](https://zellij.dev) on `PATH`, which -buys one thing the other tools do not: an agent running in a container can open a -**second terminal next to itself**, in the same container, and you can attach to it -from anywhere to watch or to type. - -Nothing has to cooperate for this. It does not come from your dotfiles, it does not -need an edit to any repo's `devcontainer.json`, and it works in images `dl` has never -seen — the same argument the rest of "Tools in every workspace" makes, for the same -reason: `dl` launches arbitrary repos. - -### Opening a pane from inside a session - -From anywhere inside the container — including from a completely non-interactive -command, with no terminal attached to anything: - -```bash -zellij -s devlaunch action new-pane -- htop -``` - -`-s ` is the form to use and the only one worth depending on. Bare -`zellij action new-pane` happens to work by falling back to the single running -session, which stops being a single session the moment there are two of them. - -`devlaunch` is the session name `dl` creates and the one to name here. - -### Switching the wrap on - -The session an agent opens panes into has to exist first, and creating it is -**off by default**: +## Shell Completion -```bash -DEVLAUNCH_ZELLIJ=1 dl someone/repo -- claude -p "do the thing" -``` +After running `dl --install`, you get intelligent tab completion: -| Variable | Description | -|----------|-------------| -| `DEVLAUNCH_ZELLIJ=1` | Before running `dl -- `, make sure a zellij session named `devlaunch` exists in the container, so the command can open panes into it | +- Workspace names from your devpod list +- Known GitHub owners and repositories from your workspaces +- File/directory paths when starting with `./`, `/`, or `~` +- All global flags (`--ls`, `--install`, etc.) and workspace commands -With it off, no invocation changes meaning at all — that is what off means here, and -it is why the switch exists rather than the behaviour simply being on. +### How the completion cache stays current -**The command runs beside the session, not inside a pane of it.** That is deliberate. -Putting the command in a pane would hand its stdin, stdout and exit status to zellij, -and all three are things `dl` promises to leave alone: `dl -- cmd > file` has to -put the command's own output in the file, and a failing command has to come back with -its own status. Since `zellij -s action new-pane` works perfectly well from a -command that is in no session at all, running beside the session costs nothing and -delivers the same pane. +The data behind completions lives in `~/.cache/devlaunch/completions.json`, and +building it means a `git ls-remote` per known repo — seconds of work. So it is +rebuilt in the background at most once an hour (the same interval the worktree +backend's background fetch sweep uses), and at most once per `dl` invocation. Commands +that change your workspaces (starting, stopping or deleting one) rebuild it as +soon as they finish, regardless of when it was last built. Commands with no use +for it — `dl --help`, `dl --version` — do not touch it at all. -**The interactive session of a bare `dl ` is untouched, switched on or -off.** An interactive attach sends no command for the wrap to attach to — that is -exactly what gets it a terminal from devpod — and giving it one would cost either the -terminal or a round trip in front of every shell. You land in an ordinary login shell -with `zellij` on `PATH`, so `zellij attach -c devlaunch` gets you the session, and any -panes an agent has opened in it, whenever you want them. +A branch created on a remote in the last hour may therefore not be offered yet. +`dl --refresh` rebuilds the cache immediately and ignores the interval. -There is one exception, and it is a pleasant one: if you also run with -`DEVLAUNCH_DOTFILES_ON_ATTACH=1`, that refresh is a command, so it gets wrapped like -any other and the session is already there when the shell arrives. +--- -### Existing workspaces +*Everything above is what it takes to use `dl`. What follows is reference — flags, ids, disk +accounting, and the internals worth knowing when something surprises you.* -zellij arrives on the setup pass, which runs on every `devpod up`. So a workspace -that predates this picks it up on its next **`dl restart`** — a full -`dl recreate` also works but is not needed, because nothing here is a -bind mount and mounts are the thing that only lands at container creation. +## Options -Attaching to a workspace that is *already running* skips `devpod up` and so skips -this too, which is what makes the restart necessary rather than automatic. +| Option | Description | +|--------|-------------| +| `--devcontainer ` | Use a non-default `devcontainer.json`. A bare name means `.devcontainer//devcontainer.json`. Stored with the workspace, so pass it once. | +| `DEVLAUNCH_NO_TTY=1` | Never give a workspace command a terminal; always use the plain `devpod ssh` transport. | +| `DEVLAUNCH_DOTFILES_ON_ATTACH=1` | Refresh dotfiles before handing over an interactive shell. Off by default; see below. | -### What it costs +Projects with demanding devcontainers — several variants, compose sidecars, or a +host-side `initializeCommand` that has to tell branch workspaces apart — are +covered in [docs/devcontainer-projects.md](docs/devcontainer-projects.md). -Almost nothing, and that was measured rather than assumed. zellij is a conda-forge -package installed by pixi into the container, so it lands in the shared package cache -above and every container after the first extracts rather than downloads: +### Refreshing dotfiles on attach -| | | -|---|---| -| **Warm install** (shared cache populated) | 0.56s / 0.23s / 0.23s over three fresh containers | -| **Cold install** (empty cache) | 3.0s, filling 167MB of shared cache | -| **Every launch after the first** | one `command -v`; the whole setup pass measured at 50ms | +devpod applies dotfiles when it *provisions* a workspace, so a workspace that has +been up for a fortnight still has the dotfiles it was born with. `dl +dotfiles` fixes that when you think of it; `DEVLAUNCH_DOTFILES_ON_ATTACH=1` makes +`dl` think of it for you, running the same refresh just before it hands you the +shell. -**It can never fail a launch.** Provisioning zellij is a stage of the setup pass, so a -container with no network, no pixi and no way to get either reports the stage as -failed, by name, and then opens exactly as it would have. A container that ends up -without zellij still works; with the wrap on, the command still runs, because the -session setup is allowed to fail and the command runs regardless. +```bash +DEVLAUNCH_DOTFILES_ON_ATTACH=1 dl someone/repo +``` -`DEVLAUNCH_NO_TOOLS=1` turns this off along with the rest of tool provisioning — -installing zellij is tool provisioning, where naming a container is not. +It is off unless you set it, and that is the point rather than caution. The +refresh is a `devpod ssh` round-trip — measured at ~1.7s, almost all of it +connection setup — with a `git pull` behind it, and it would otherwise be charged +to every attach on every machine to close a gap most people do not have. -## The shared pixi package cache +Two things it deliberately does not do: -Every container `dl` creates gets one host directory bound into it, and -`PIXI_CACHE_DIR` pointed at it, so that dotfiles which provision their tools with -`pixi global sync` download each package once per machine instead of once per -container: +- **It never runs for `dl -- `.** A one-shot command renders no + prompt and sources no interactive shell, so refreshing in front of it would + buy that command nothing and cost it the round-trip. That path is the one + agent launchers use, and it stays exactly as fast as it was. +- **It never holds the shell hostage.** The refresh gets 60 seconds; an + unreachable dotfiles remote, or one that wants a password nobody is there to + type, means a pause and then your shell, not a hang. Failure is a warning — + you get the workspace either way. -| | | -|---|---| -| **On the host** | `$XDG_CACHE_HOME`, or `~/.cache`, then `devlaunch/pixi` | -| **In the container** | `/var/tmp/devlaunch-pixi` | +Refreshes run every time you attach, with no cooldown, because you asked for +them. If that is too often, unset the variable and use `dl dotfiles`. -Measured on the profile this was built for — 23 pixi-global environments — a -container with a cold cache spends 62–113 s and downloads 1.2 GB; one that finds -the packages already there finishes in 18–28 s and fetches nothing. Two containers -syncing against it at the same time is fine: the downloads are content-addressed -and rattler takes a lock per package. +## Workspace IDs -**Deleting it is always safe, at any moment, including while containers are -running.** It holds nothing but downloaded package archives — every one of them -re-fetchable from the network, and none of them referenced by a path anything -inside a container has stored. The worst a deletion costs is the next container's -download. +`dl user/repo@branch` derives one id that names both the devpod workspace (what you +see in `dl --ls`) and the clone directory under `~/.cache/devlaunch/repos/`: -```bash -rm -rf ~/.cache/devlaunch/pixi ``` +-- at most 38 characters -`dl --purge` takes it away with the rest of `~/.cache/devlaunch/`, for the same -reason. - -Two things it deliberately is not. It is **not the host's own** -`~/.cache/rattler/cache`: containers write into it as their own remote user, -whose uid only happens to match yours, and a `pixi clean cache` you run for your -own reasons must not be able to pull packages out from under a running container. -And it is **not a shared `PIXI_HOME`** — the installed environments and their -trampolines are baked with absolute paths, and two containers sharing one -environment tree is [pixi#5476](https://github.com/prefix-dev/pixi/issues/5476). -Only the download cache is shared, which is the part that is safe to share. +blooop/devlaunch@main -> devlaunch-main-zovomobo +blooop/devlaunch@feature/auth -> devlaunch-feature-auth-poliseno +blooop/devlaunch@feature-auth -> devlaunch-feature-auth-nesatabe +blooop/test_renv@nb4 -> test-renv-nb4-polenita +kinisi-robotics/kinisi_ros@ags-devcontainer-tooling-support + -> kinisi-ros-ags-devcontainer-t-lenevere +blooop/devlaunch@dependabot/github_actions/codecov/codecov-action-6 + -> devlaunch-dependabot-codecov-sifivasa +``` -If the directory cannot be created, or is not there when the launch reaches it — -a full disk, a read-only cache home, a cache swept between the two — the launch -goes ahead without the mount and the container downloads its own packages, -exactly as it did before this existed. +The eight-character syllable suffix is a hash of the full `(owner, repo, branch)` triple. +It is what makes the id unique: the readable part is shortened to fit the length limit, +and shortening it does not affect whether two branches share an id. Long branch names +drop whole `/`-separated middle segments before losing characters, so the part that +identifies the branch survives. Note the third and fourth lines above: `feature/auth` and +`feature-auth` read the same once slugged but are different branches, and they get +different ids. -**Sharing requires the container's user to be able to write the directory**, -which in practice means its uid matches yours or it is root. The mount carries -host ownership through unchanged, and pixi does not degrade to reading a cache -it cannot write: pointing `PIXI_CACHE_DIR` at a directory owned by another uid -fails the install outright (`Permission denied` on the repodata, exit 1) even -when every package it wants is already in there. So an image whose remote user -is neither root nor your uid does not merely lose the sharing — its `pixi global -sync` fails, and its tools do not get provisioned. +Owner and repo are matched case-insensitively, the way GitHub treats them, so +`dl NVIDIA/cuda-samples@main` and `dl nvidia/cuda-samples@main` are the same workspace. +Branch names are case-sensitive, because git refs are. -`dl` cannot see the container's uid before it launches, so it cannot decide this -for you. In practice the common case is safe: every mainstream base declares a -remote user at uid 1000, which is the first human user on a Linux host. If you -hit the failure, the fixes available to you are to run that image as your own -uid, or to take the cache out of play for it (`rm -rf ~/.cache/devlaunch/pixi` -recovers a directory an earlier container left owned by someone else). +URL specs (`dl github.com/owner/repo`) get an id in the same shape, with the suffix +hashed over the URL. -### Existing containers, and what a recreate is for +The id is also the container hostname, so it stays well inside the 38-character budget +to leave room for tools that add their own prefixes. -**A mount lands only when a container is created.** devpod re-applies -`--workspace-env` on every `up`, but it will not add a bind mount to a container -that already exists — passing `--mount` there is a silent no-op. So a container -built before this feature, or before a change to where the mount lands, keeps -whatever it was created with until `dl recreate`, and only then -picks the current arrangement up. +Branch names must be safe as both git refs and directory names — a name with a space or +a leading dash is rejected rather than quietly rewritten. -In between, `PIXI_CACHE_DIR` points at `/var/tmp/devlaunch-pixi` with nothing -mounted on it. That is a working private cache, not a failure — `/var/tmp` is -world-writable in every image, so pixi creates the directory and fills it. The -container re-warms itself and simply never shares, abandoning whatever pixi had -already warmed in its default location. **This is the reason the container-side -path is under `/var/tmp` rather than somewhere tidier like `/var/cache`:** a -target whose parent is root-owned is a hard `pixi global sync` failure on every -container that predates it, not a lost optimisation. +### Upgrading from an older devlaunch -One older breakage needs the recreate rather than a restart. Devlaunch briefly -mounted this cache inside `~/.cache`, which left that directory root-owned in -any image that ships no `~/.cache` of its own. `$HOME` lives on the container's -own layer, so `dl stop` and a fresh `up` keep the root-owned -directory; `dl recreate` gets a new layer where `~/.cache` is the -user's own again. +This id format is new, and the directories and containers on your machine were named by +the previous scheme. The first `dl user/repo…` command after upgrading migrates the cache +once, and leaves what it did behind in the cache directory (the two listings named below). +`dl --help`, `dl --version`, `dl --ls` and opening an existing workspace by name do not trigger it. -## Global Commands +**Your clone directories are renamed.** What was +`~/.cache/devlaunch/repos/blooop/devlaunch/main` becomes +`~/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo`. A workspace is a git +clone whose `origin` points at the `.bare` cache next to it, and `.bare` does not move, so +this is a plain rename: branches, history and **uncommitted changes all survive** — only +the folder name changes. `metadata.json` is updated in the same pass, so nothing is left +pointing at the old name. -| Command | Description | -|---------|-------------| -| `dl --ls` | List all workspaces | -| `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 | -| `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 | -| `dl --install` | Install shell completions | -| `dl --prune [-y] [--force]` | Remove [the clone directories no workspace opens any more](#pruning-the-clones-nothing-opens) — and nothing else | -| `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 | -| `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches | -| `dl --refresh` | Refresh completion cache | -| `dl --help, -h` | Show this help | -| `dl --version` | Show version | +**Your existing devpod containers keep their old ids and are orphaned — and they can +often be repaired rather than replaced.** An orphaned container is sourced at the path this +migration just renamed, with the real clone sitting next to it under the new name, which is +precisely what [`dl --reconcile`](#reconciling-records-that-disagree) is for: it re-points +devpod's record at the renamed clone, and `dl recreate` finishes the repair. +That gives you back the clone association and the workspace's identity — not state that +lived only inside the old container, which nothing can bring back. The repair is +order-dependent: relaunching the branch claims the renamed clone for a fresh container, +and reconcile never re-points a clone a live container holds — so reconcile first, then +relaunch. Left alone, the next `dl user/repo@branch` simply builds a fresh container +under the new id, and deleting the old one is all that remains for it. + +dl does not delete containers for you — deleting by id is how a running sidecar got +destroyed the last time something tried ([kinisi_ros#9766](https://github.com/kinisi-robotics/kinisi_ros/pull/9766)) — +so it writes the old ids to +`~/.cache/devlaunch/orphaned-workspaces.txt`. For the workspaces you are finished with, +the disposal command reads from that listing: ```bash -$ dl --version -dl 0.1.0 +xargs -r -n1 devpod delete < ~/.cache/devlaunch/orphaned-workspaces.txt ``` -The version and nothing else. `aid --version` reports the same version under its -own name — the two binaries are built from one cargo package and cannot disagree -about it. (Through 0.0.29 an editable install appended `(dev, editable from -)`, read out of the installed package's PEP 610 metadata. A compiled binary -has no such metadata and no editable installs, so there is nothing left to say.) +**A clone directory with no metadata record is left alone.** Nothing records which branch +it was cloned for, and the old directory name cannot be turned back into one — `feature/auth` +and `feature-auth` both became `feature-auth` — so a guessed name would be worse than no +rename. Those directories stay exactly where they are and are listed in +`~/.cache/devlaunch/unmigrated-clones.txt`. + +Running dl again changes nothing: the migration is keyed on the `version` field in +`metadata.json`, not on directory names, so a branch that happens to look like a new-scheme +id is never mistaken for one. If a migration is interrupted, the next run finishes it — the +version is written last, in the same atomic save as the new paths, so it never claims more +than the filesystem has actually done. A rename the filesystem refuses (a read-only mount, +tightened permissions) is treated the same way: the version stays put and every later run +retries the refused directories and repeats the notice until the underlying refusal is +fixed by hand. + +## Cleaning up: purge, prune, reconcile + +The three global commands that touch what is already on disk, in full. `--prune` takes clone +directories and no workspaces, `--purge` takes both but only what devlaunch made, and `--reconcile` +removes nothing — it repairs records that stopped matching the disk. Each one prints its plan and +asks before acting, and `-y` is what skips the question. ### What purge deletes @@ -1206,27 +1159,131 @@ store and does not manage volumes. `docker system df` is the tool that knows — the same boundary [`--prune` and `--purge` name](#the-disk-neither-command-frees) when they finish. -## Examples +## The shared pixi package cache + +Every container `dl` creates gets one host directory bound into it, and +`PIXI_CACHE_DIR` pointed at it, so that dotfiles which provision their tools with +`pixi global sync` download each package once per machine instead of once per +container: + +| | | +|---|---| +| **On the host** | `$XDG_CACHE_HOME`, or `~/.cache`, then `devlaunch/pixi` | +| **In the container** | `/var/tmp/devlaunch-pixi` | + +Measured on the profile this was built for — 23 pixi-global environments — a +container with a cold cache spends 62–113 s and downloads 1.2 GB; one that finds +the packages already there finishes in 18–28 s and fetches nothing. Two containers +syncing against it at the same time is fine: the downloads are content-addressed +and rattler takes a lock per package. + +**Deleting it is always safe, at any moment, including while containers are +running.** It holds nothing but downloaded package archives — every one of them +re-fetchable from the network, and none of them referenced by a path anything +inside a container has stored. The worst a deletion costs is the next container's +download. ```bash -dl # Select a workspace interactively -dl devpod # Open existing workspace -dl loft-sh/devpod # Create from GitHub -dl blooop/devlaunch@main # Create from specific branch -dl ./my-project # Create from local folder -dl blooop/devlaunch code # Open in VS Code -dl blooop/devlaunch -- make test # Run command in workspace -dl blooop/devlaunch stop # Stop workspace +rm -rf ~/.cache/devlaunch/pixi ``` -## Features +`dl --purge` takes it away with the rest of `~/.cache/devlaunch/`, for the same +reason. -- **Fuzzy Selection**: When called without arguments — or with a verb and no workspace — opens a built-in fuzzy selector; nothing to install, and no `fzf` on `PATH` to find -- **Smart Completion**: Tab completion for workspaces, GitHub repos (owner/repo format), and paths -- **GitHub Shorthand**: Use `owner/repo` instead of full URLs - automatically expands to `github.com/owner/repo` -- **Branch Support**: Specify branches with `owner/repo@branch` syntax -- **Fast Autocomplete**: Completion cache for ~3ms response time (vs ~700ms without cache) -- **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 ` and a one-shot `dl -- ` cost the same trips +Two things it deliberately is not. It is **not the host's own** +`~/.cache/rattler/cache`: containers write into it as their own remote user, +whose uid only happens to match yours, and a `pixi clean cache` you run for your +own reasons must not be able to pull packages out from under a running container. +And it is **not a shared `PIXI_HOME`** — the installed environments and their +trampolines are baked with absolute paths, and two containers sharing one +environment tree is [pixi#5476](https://github.com/prefix-dev/pixi/issues/5476). +Only the download cache is shared, which is the part that is safe to share. + +If the directory cannot be created, or is not there when the launch reaches it — +a full disk, a read-only cache home, a cache swept between the two — the launch +goes ahead without the mount and the container downloads its own packages, +exactly as it did before this existed. + +**Sharing requires the container's user to be able to write the directory**, +which in practice means its uid matches yours or it is root. The mount carries +host ownership through unchanged, and pixi does not degrade to reading a cache +it cannot write: pointing `PIXI_CACHE_DIR` at a directory owned by another uid +fails the install outright (`Permission denied` on the repodata, exit 1) even +when every package it wants is already in there. So an image whose remote user +is neither root nor your uid does not merely lose the sharing — its `pixi global +sync` fails, and its tools do not get provisioned. + +`dl` cannot see the container's uid before it launches, so it cannot decide this +for you. In practice the common case is safe: every mainstream base declares a +remote user at uid 1000, which is the first human user on a Linux host. If you +hit the failure, the fixes available to you are to run that image as your own +uid, or to take the cache out of play for it (`rm -rf ~/.cache/devlaunch/pixi` +recovers a directory an earlier container left owned by someone else). + +### Existing containers, and what a recreate is for + +**A mount lands only when a container is created.** devpod re-applies +`--workspace-env` on every `up`, but it will not add a bind mount to a container +that already exists — passing `--mount` there is a silent no-op. So a container +built before this feature, or before a change to where the mount lands, keeps +whatever it was created with until `dl recreate`, and only then +picks the current arrangement up. + +In between, `PIXI_CACHE_DIR` points at `/var/tmp/devlaunch-pixi` with nothing +mounted on it. That is a working private cache, not a failure — `/var/tmp` is +world-writable in every image, so pixi creates the directory and fills it. The +container re-warms itself and simply never shares, abandoning whatever pixi had +already warmed in its default location. **This is the reason the container-side +path is under `/var/tmp` rather than somewhere tidier like `/var/cache`:** a +target whose parent is root-owned is a hard `pixi global sync` failure on every +container that predates it, not a lost optimisation. + +One older breakage needs the recreate rather than a restart. Devlaunch briefly +mounted this cache inside `~/.cache`, which left that directory root-owned in +any image that ships no `~/.cache` of its own. `$HOME` lives on the container's +own layer, so `dl stop` and a fresh `up` keep the root-owned +directory; `dl recreate` gets a new layer where `~/.cache` is the +user's own again. + +## Worktree Backend + +For git repositories, devlaunch uses an efficient worktree backend by default: + +- **Efficient Storage**: Repos are cloned once to `~/.cache/devlaunch/repos/owner/repo/`, then git worktrees are created for each branch +- **Shared Git Objects**: All branches share git objects, saving disk space +- **Targeted Fetch**: A launch fetches only the one branch it is launching, so no launch waits 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`. +- **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. +- **A branch that does not exist yet**: created from the default branch's freshly + fetched tip. +- **Offline**: a warning, and the launch proceeds from whatever the cache holds. + It only fails when there is nothing cached to launch from. +- **Everything else** (other branches, tags, prunes) is refreshed by the + background updater within the configured interval (default: 1 hour), which + never blocks a launch. + +### Container Sharing Mode + +Use `--shared` to share a single container across multiple branches of the same repo: + +```bash +dl --shared owner/repo@branch1 # Creates container "owner-repo" +dl --shared owner/repo@branch2 # Reuses "owner-repo" container +``` + +### Pre-warming + +Use `--warm` to prepare a workspace without attaching a shell: + +```bash +dl --warm owner/repo@branch # Creates container in background +``` ## Measuring launch time @@ -1426,67 +1483,21 @@ load-bearing: above the threshold leaves a commit comment and a red mark on the chart, and the build stays green. -## Worktree Backend - -For git repositories, devlaunch uses an efficient worktree backend by default: - -- **Efficient Storage**: Repos are cloned once to `~/.cache/devlaunch/repos/owner/repo/`, then git worktrees are created for each branch -- **Shared Git Objects**: All branches share git objects, saving disk space -- **Targeted Fetch**: A launch fetches only the one branch it is launching, so no launch waits 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`. -- **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. -- **A branch that does not exist yet**: created from the default branch's freshly - fetched tip. -- **Offline**: a warning, and the launch proceeds from whatever the cache holds. - It only fails when there is nothing cached to launch from. -- **Everything else** (other branches, tags, prunes) is refreshed by the - background updater within the configured interval (default: 1 hour), which - never blocks a launch. - -### Container Sharing Mode - -Use `--shared` to share a single container across multiple branches of the same repo: - -```bash -dl --shared owner/repo@branch1 # Creates container "owner-repo" -dl --shared owner/repo@branch2 # Reuses "owner-repo" container -``` - -### Pre-warming +## Going back to the Python build -Use `--warm` to prepare a workspace without attaching a shell: +`0.1.0` is the Rust rewrite: same commands, same cache, same `metadata.json`, different +implementation (the whole of it is in [docs/rust-rewrite-plan.md](docs/rust-rewrite-plan.md), whose +divergence table is the complete list of what changed on purpose). The last Python release is +`0.0.29`, and nothing in the cache stops you going back to it: ```bash -dl --warm owner/repo@branch # Creates container in background +pixi global install --channel conda-forge --channel https://prefix.dev/blooop "devlaunch<0.1" +pip install "devlaunch<0.1" ``` -## Shell Completion - -After running `dl --install`, you get intelligent tab completion: - -- Workspace names from your devpod list -- Known GitHub owners and repositories from your workspaces -- File/directory paths when starting with `./`, `/`, or `~` -- All global flags (`--ls`, `--install`, etc.) and workspace commands - -### How the completion cache stays current - -The data behind completions lives in `~/.cache/devlaunch/completions.json`, and -building it means a `git ls-remote` per known repo — seconds of work. So it is -rebuilt in the background at most once an hour (the same interval the worktree -backend's background fetch sweep uses), and at most once per `dl` invocation. Commands -that change your workspaces (starting, stopping or deleting one) rebuild it as -soon as they finish, regardless of when it was last built. Commands with no use -for it — `dl --help`, `dl --version` — do not touch it at all. - -A branch created on a remote in the last hour may therefore not be offered yet. -`dl --refresh` rebuilds the cache immediately and ignores the interval. +Both builds read and write the same `metadata.json` at schema 2 under the same locks, so a downgrade +keeps every workspace you already have and needs no cleanup. Please open an issue for whatever sent +you back. ## Development From 83c5089b4ac5bc9b1dbd4a1bebd71441f0e9585c Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Thu, 20 Aug 2026 15:11:14 +0000 Subject: [PATCH 2/4] Release 0.1.3 --- CHANGELOG.md | 14 ++++++++++++++ rust/Cargo.lock | 10 +++++----- rust/Cargo.toml | 2 +- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 124fd177..2e6f013e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.3] - 2026-08-20 + +### Changed + +- **The README is ordered for people first.** The options table, the workspace id derivation + and the purge/prune/reconcile and disk-accounting detail used to come before the things + someone actually types, and `## Examples` sat 1200 lines down. What a reader needs on the way + in — features, install, usage with the examples beside it, workspace and global commands, + `aid` — is now the top half; a rule and a note mark where reference begins; and the header + carries a two-row index. The `dl --ls` table was split out of `## Global Commands` from the + 500 lines of cleanup detail beneath it, now `## Cleaning up: purge, prune, reconcile`. One + duplicated example block (`## Workspace Sources`) was folded into `### Examples`. No + documented behaviour changed, and every existing anchor still resolves. + ## [0.1.2] - 2026-08-20 ### Added diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 00607a57..67b2379e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "aid" -version = "0.1.2" +version = "0.1.3" dependencies = [ "dl", "serde_json", @@ -438,7 +438,7 @@ dependencies = [ [[package]] name = "devlaunch-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "devlaunch-runner", "devlaunch-test-support", @@ -456,7 +456,7 @@ dependencies = [ [[package]] name = "devlaunch-runner" -version = "0.1.2" +version = "0.1.3" dependencies = [ "libc", "tempfile", @@ -464,7 +464,7 @@ dependencies = [ [[package]] name = "devlaunch-test-support" -version = "0.1.2" +version = "0.1.3" dependencies = [ "devlaunch-runner", "serde_json", @@ -510,7 +510,7 @@ dependencies = [ [[package]] name = "dl" -version = "0.1.2" +version = "0.1.3" dependencies = [ "assert_cmd", "clap", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4fa7c8d3..728f5326 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -11,7 +11,7 @@ members = [ # The single source of the version (docs/rust-rewrite-plan.md: cutover ships # 0.1.0, version read from Cargo.toml). [workspace.package] -version = "0.1.2" +version = "0.1.3" edition = "2024" license = "MIT" repository = "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/blooop/devlaunch" From 3fdc4a50d84fd8084e1cc2674571d069536c6eaf Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Thu, 20 Aug 2026 15:40:44 +0000 Subject: [PATCH 3/4] Order dl --help for human readers too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README reorder was only half the job: `dl --help` had the same shape it was complaining about. clap's default template renders `{all-args}` — every one of the fourteen flags — between the usage line and the `after_help` block, so the half of this CLI clap cannot describe from its own arguments, the verbs and the examples somebody opened `--help` to find, sat underneath the options nobody did. `before_help` now carries the examples (first) and the workspace verbs, and `help_template` is clap's own default with `{before-help}` moved from the top to just above `{all-args}`. `Environment:` splits off into `after_help`, where it stays last: it is read once and put in a shell profile. Same flags, same verbs, same words, for both `-h` and `--help` — a different order on screen. `help_template` is a string clap resolves at render time, so a marker that stops resolving is not a compile error, it renders literally and takes its section with it. One test pins the section order, which is what catches that. --- docs/rust-rewrite-plan.md | 2 +- rust/dl/src/cli.rs | 87 ++++++++++++++++++++++++++++++--------- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/docs/rust-rewrite-plan.md b/docs/rust-rewrite-plan.md index a235b4ee..4caf66dd 100644 --- a/docs/rust-rewrite-plan.md +++ b/docs/rust-rewrite-plan.md @@ -148,7 +148,7 @@ Nothing diverges silently. |---|---|---| | 1 | Reserved verb words (`stop`, `prune`, …) win over bare workspace specs; verb-first with no target opens the fuzzy selector. Python parses `dl stop` as a spec. | Owner requirement folded into #251 §6. | | 2 | clap strictness: unknown flags are rejected; no argparse-style prefix abbreviation. | Correctness aim winning over accidental laxity. | -| 3 | `--help` layout is clap's, not the hand-rolled text. | Generated help feeds the README rule. | +| 3 | `--help` layout is clap's, not the hand-rolled text — reordered so the verbs and examples land above the options table. | Generated help feeds the README rule. | | 4 | Failure output: no Python tracebacks, ever; typed errors rendered as one-line diagnostics with the same exit codes. OS-level reasons inside diagnostics use Rust's phrasing (`Permission denied (os error 13)`) rather than Python's `[Errno 13] …`, and JSON type names in refusals may differ (`int` where Python said `float`). | #251 §5. | | 5 | Startup/perf deltas asserted nowhere; `bench.yml` stays outside the gate. | Perf is not a parity dimension. | | 6 | The fuzzy selector is embedded skim, not spawned fzf; `iterfzf`/fzf are no longer runtime dependencies. With no terminal (`dl < /dev/null`, workspaces present) the picker declines silently where fzf wrote `inappropriate ioctl for device` to stderr; stdout and exit 1 are identical. | Removes a launch-failure class (#251 §6). | diff --git a/rust/dl/src/cli.rs b/rust/dl/src/cli.rs index ffe02b16..47ea3005 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -226,12 +226,28 @@ pub(crate) enum GrammarError { // `--version` is dispatched like every other command, so the summary that // prints it lives with the rest of the rendering rather than in clap. disable_version_flag = true, - // One epilogue for both `-h` and `--help`: the verbs are a grammar clap - // cannot describe from its own arguments — they are positional words — so a - // help text without this block would not document half the CLI, and - // README's "regenerate against `dl --help`" rule would carry the hole - // forward. - after_help = EPILOGUE, + // Two hand-written blocks for both `-h` and `--help`: the verbs are a grammar + // clap cannot describe from its own arguments — they are positional words — so + // a help text without them would not document half the CLI, and README's + // "regenerate against `dl --help`" rule would carry the hole forward. + // + // `GRAMMAR` is `before_help` rather than `after_help` so that it lands above + // the options table, which is what the template below moves it there for: the + // flags are the part of this CLI a person reaches for least, and clap's default + // layout puts all of them between the usage line and the verbs and examples + // somebody opened `--help` to find. The same reordering the README got, for the + // same reason. + before_help = GRAMMAR, + after_help = ENVIRONMENT, + // clap's own default template with `{before-help}` moved from the top to just + // above `{all-args}`. Every marker here is one the default already uses — the + // order is the only change — because an unknown marker is not a compile error, + // it renders literally. + help_template = "\ +{about-with-newline} +{usage-heading} {usage} + +{before-help}{all-args}{after-help}", )] pub(crate) struct Cli { /// The workspace to open, and optionally the verb to apply: `dl owner/repo`, @@ -310,7 +326,21 @@ pub(crate) struct Cli { /// The half of the grammar clap's own argument list cannot show: the verbs are /// positional words, so nothing in the options table names them. -const EPILOGUE: &str = "Workspace commands (dl , or dl ): +/// +/// Rendered above that options table (`before_help` plus the reordered +/// `help_template` on [`Cli`]), examples first, because this is the half somebody +/// opened `--help` to read. +const GRAMMAR: &str = "Examples: + dl Pick a workspace interactively + dl blooop/devlaunch Open it on its default branch + dl blooop/devlaunch@fix/123 Open it on that branch, creating it if needed + dl ./my-project Open a local folder + dl blooop/devlaunch -- make test Run one command inside the workspace + dl blooop/devlaunch stop Stop it + dl stop blooop-devlaunch-main-1a2b Stop it by workspace id + dl --ls --json Every workspace, machine-readable + +Workspace commands (dl , or dl ): up Start it without attaching stop Stop it rm, prune Delete it. Refuses if its clone holds @@ -326,19 +356,13 @@ const EPILOGUE: &str = "Workspace commands (dl , or dl dotfiles Refresh dotfiles (chezmoi update) -- Run one command inside it -A verb with no workspace named picks one interactively. +A verb with no workspace named picks one interactively."; -Examples: - dl Pick a workspace interactively - dl blooop/devlaunch Open it on its default branch - dl blooop/devlaunch@fix/123 Open it on that branch, creating it if needed - dl ./my-project Open a local folder - dl blooop/devlaunch -- make test Run one command inside the workspace - dl blooop/devlaunch stop Stop it - dl stop blooop-devlaunch-main-1a2b Stop it by workspace id - dl --ls --json Every workspace, machine-readable - -Environment: +/// What no flag and no verb names: the variables that change a launch. +/// +/// Last, below the options table, because reading it is what somebody does once +/// and then puts in a shell profile. +const ENVIRONMENT: &str = "Environment: DEVLAUNCH_TIMING=1|json Write a timing summary to stderr DEVLAUNCH_NO_GH_TOKEN=1 Do not forward the host's gh login DEVLAUNCH_DOTFILES_ON_ATTACH=1 Refresh dotfiles before every attach"; @@ -1084,4 +1108,29 @@ mod tests { assert_eq!(argv_without_devcontainer(&owned), expected, "{argv:?}"); } } + + /// Divergence row 3 licenses clap's layout; *which* clap layout is still a + /// decision, and this is it — the verbs and examples somebody opened `--help` + /// to find come above the options table, not below it. + /// + /// `help_template` is a string clap resolves at render time, so a marker that + /// stops resolving is not a compile error — it renders as its own literal text + /// and the section it stood for goes missing, which is what every `find` below + /// is also checking for. + #[test] + fn help_puts_the_grammar_above_the_options_table() { + use clap::CommandFactory; + + let help = Cli::command().render_long_help().to_string(); + let examples = help.find("Examples:").expect("no examples"); + let verbs = help.find("Workspace commands").expect("no verb list"); + let options = help.find("Options:").expect("no options table"); + let environment = help.find("Environment:").expect("no environment"); + + assert!(examples < verbs, "{help}"); + assert!(verbs < options, "{help}"); + assert!(options < environment, "{help}"); + assert!(help.starts_with("Open a devcontainer"), "{help}"); + assert!(help.contains("Usage: dl "), "{help}"); + } } From 0934eb591cbde343508ad2f50f6993cc4137e059 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Thu, 20 Aug 2026 15:40:44 +0000 Subject: [PATCH 4/4] Finish the 0.1.3 bump: the version tripwire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `devlaunch-test-support`'s `versioned_with_the_workspace` asserts the crate's inherited version against a literal and cannot read it any other way, so it has to move with every release — the step 0.1.2's release commit documented after the 0.1.1 bump forgot it and left a developer's `cargo test --workspace` red. No CI job runs that crate, so nothing but running the suite catches it. Ran it; it was red; it is green. Also expands the changelog entry to cover the `--help` reorder. --- CHANGELOG.md | 33 +++++++++++++++++++------- rust/devlaunch-test-support/src/lib.rs | 2 +- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6f013e..bc3120f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,15 +11,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **The README is ordered for people first.** The options table, the workspace id derivation - and the purge/prune/reconcile and disk-accounting detail used to come before the things - someone actually types, and `## Examples` sat 1200 lines down. What a reader needs on the way - in — features, install, usage with the examples beside it, workspace and global commands, - `aid` — is now the top half; a rule and a note mark where reference begins; and the header - carries a two-row index. The `dl --ls` table was split out of `## Global Commands` from the - 500 lines of cleanup detail beneath it, now `## Cleaning up: purge, prune, reconcile`. One - duplicated example block (`## Workspace Sources`) was folded into `### Examples`. No - documented behaviour changed, and every existing anchor still resolves. +- **`dl --help` puts the verbs and the examples above the options table.** clap's default + layout renders every flag between the usage line and the `after_help` block, so the half of + this CLI clap cannot describe from its own arguments — the workspace verbs, the examples, the + environment variables — sat below fourteen options nobody opened `--help` to read. The + examples and the verb list now come straight after the usage line (`before_help`, plus a + `help_template` that is clap's own default with `{before-help}` moved above `{all-args}`), + `Environment:` stays last, and a unit test pins the order and the markers. Same flags, same + verbs, same text — a different order on screen, for `-h` and `--help` alike. +- **The README is ordered the same way, for the same reason.** The options table, the workspace + id derivation and the purge/prune/reconcile and disk-accounting detail used to come before + the things someone actually types, and `## Examples` sat 1200 lines down. What a reader needs + on the way in — features, install, usage with the examples beside it, workspace and global + commands, `aid` — is now the top half; a rule and a note mark where reference begins; and the + header carries a two-row index. The `dl --ls` table was split out of `## Global Commands` + from the 500 lines of cleanup detail beneath it, now `## Cleaning up: purge, prune, + reconcile`. One duplicated example block (`## Workspace Sources`) was folded into + `### Examples`. Every existing anchor still resolves. + +### Fixed + +- **`devlaunch-test-support`'s version tripwire moves with this release**, so + `cargo test --workspace` is green. It asserts the inherited version against a literal and + cannot read it any other way; no CI job runs that crate, which is how the 0.1.1 bump left it + behind. Noted here because the next release has to do the same. ## [0.1.2] - 2026-08-20 diff --git a/rust/devlaunch-test-support/src/lib.rs b/rust/devlaunch-test-support/src/lib.rs index f2e08212..ec73ebce 100644 --- a/rust/devlaunch-test-support/src/lib.rs +++ b/rust/devlaunch-test-support/src/lib.rs @@ -32,6 +32,6 @@ pub use response::Response; mod tests { #[test] fn versioned_with_the_workspace() { - assert_eq!(env!("CARGO_PKG_VERSION"), "0.1.2"); + assert_eq!(env!("CARGO_PKG_VERSION"), "0.1.3"); } }