Two facts about a workspace that are worth knowing but that you never have to
type: the id dl derives for it, and how recent the branch you land on is.
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/:
<repo-slug>-<branch-slug>-<hash> at most 47 characters
blooop/devlaunch@main -> devlaunch-main-3j1t
blooop/devlaunch@feature/auth -> devlaunch-feature-auth-np10
blooop/devlaunch@feature-auth -> devlaunch-feature-auth-lsi0
blooop/test_renv@nb4 -> test-renv-nb4-n95z
kinisi-robotics/kinisi_ros@ags-devcontainer-tooling-support
-> kinisi-ros-ags-devcontainer-tooling-suppor-17uu
blooop/devlaunch@dependabot/github_actions/codecov/codecov-action-6
-> devlaunch-dependabot-codecov-action-6-amlt
The four-character suffix is a hash of the full (owner, repo, branch) triple, written
in base 36. 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.
The suffix was eight characters and spelled in pronounceable syllables (zovomobo,
hesirora). Base 36 packs 5.17 bits into a character where the syllable table managed
3.0, so the suffix halves in length, and the four characters it gives back go to the
branch, which is the part anyone reads. What it costs is collision headroom: a tenth of
the old room, 20.7 bits against the old 24, or roughly one chance in
37000 that ten near-identical long branches in one repository collide. Only branches
whose readable half truncates to the same string can contend at all, so the number that
matters is a repository's crop of release/999...176-shaped names rather than the count
of workspaces on the machine.
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.
One workspace, one id, three ways of reading it. The id itself is what devpod is
addressed by, what dl --ls prints and what the container's hostname is set to:
everything that has to be unique, or has to be typed back, or has to fit in a DNS
label.
The other two are read rather than typed back, and each is cut to what its surface is for:
| Where | Reads | Why that shape |
|---|---|---|
dl --ls, the hostname, devpod |
devlaunch-feat-abc-123-ktsk |
Addressed and typed back, so it must be unique and it must be a DNS label |
| The terminal tab | devlaunch@feat/ABC_123 |
A handful of characters read at a glance beside a dozen others, so the suffix goes and the branch is spelled the way you typed it |
| The selector | blooop | devlaunch | feat/ABC_123 |
One row at a time with the width of a terminal, so the owner comes back and the branch is spelled in full, out of the clone's HEAD |
This table is where the tab's spelling is decided, and no other page states it as
the answer to a command; where one appears elsewhere it is inside an example of the
mechanism, like the escape sequence and the profile line in
workspace-tools.md.
a_label_is_the_branch_as_typed_beside_the_id_slug_of_the_repo in
rust/devlaunch-core/src/domain/workspace_id.rs pins the tab's cells against what
WorkspaceId::label returns.
What the tab shares with the id is its structure, not its alphabet. One
derivation decides which branch segments survive and what budget they are cut to, so
a middle segment the id dropped is missing from the tab too and neither is truncated
to a budget of its own. Where that cut falls is not shared, because the two halves
are not counting the same characters. Inside a surviving segment they part company: the id has to
be a DNS label, and a tab is neither a DNS label nor a devpod name, so the tab keeps
the case, the _ and the . that the id has to flatten. A branch is what tells two
workspaces apart at a glance, and feat/ABC_123 and feat-abc-123 are different
branches that the id, which must spell both feat-abc-123, cannot separate.
The price is that the two no longer match character for character. Putting
devlaunch@feat/ABC_123 beside devlaunch-feat-abc-123-ktsk takes knowing that the
id lowercases and dashes: slug the tab's branch half and the id's readable half
comes back, so long as the branch was short enough to escape the cut. That trade is made on the half of the pair nothing addresses a workspace
by.
The repo half is still slugged, and that is deliberate. Owner and repo are
matched case-insensitively, so NVIDIA/cuda-samples and nvidia/cuda-samples are
one workspace with two spellings, and "as typed" would name neither of them: the
same container would take a different tab name depending on how the launch happened
to be typed. Branch names are case-sensitive, because git refs are, so a workspace
has exactly one branch spelling and "as typed" is a property of the workspace there.
Neither the dash the @ replaces nor the dashes that stood for slashes are readable
off the id, since a repo slug holds dashes of its own and so does a branch name, so
the tab's name travels with the launch that resolved it rather than being parsed back
out of the id later. A workspace you name by its bare id on the command line is
looked up instead: metadata.json holds the triple beside the id derived from it, so
dl devlaunch-main-3j1t reads devlaunch@main too. The selector needs neither, since
it read the triple to draw the row and hands it on with the pick.
The record is the authority on what the workspace is, because it is the file dl
wrote when it made the workspace rather than a guess about which one this is. So a
workspace made under an older id scheme is named from its record's triple whatever
that triple would derive today: the tab reads devlaunch@main while the dl --ls row
beside it reads devlaunch-main-legacy. The tab says what the workspace is, the row
says what it is called, and a tab is not addressed by anything, so two of them may
read alike. What a lookup cannot answer it declines, and the id is the answer then: a
workspace dl holds no record of, one devpod made or one whose cache was cleared, keeps
its id on the tab.
The tab still gives one thing up. It does not name the owner, since an id never carried one, so a fork and its upstream read alike. That is recoverable in the selector, which is where a name is read carefully rather than glanced at.
At 47 characters the id leaves 17 of the 64-byte hostname limit for tools that stack their own prefixes onto the container name. That was about 26 when the hostname was the id without its suffix.
47 is held by devpod instead: it is one character inside devpod's own hard ceiling of 48, and a 49-character id is refused outright rather than truncated.
Branch names must be safe as both git refs and directory names, so a name with a space or a leading dash is rejected rather than quietly rewritten.
The ids on your machine were derived by an older rule, so the directories and containers
already there are named by it. 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.
This has happened twice. Clone directories were once named after the flattened branch
alone (main), and the suffix was once eight characters of pronounceable syllables
(devlaunch-main-zovomobo). Both reach today's names through the same pass, which
re-derives every id from the (owner, repo, branch) its record already stores, so there
is nothing to do differently depending on which one your cache is on.
Your clone directories are renamed. What was
~/.cache/devlaunch/repos/blooop/devlaunch/main, or
~/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo, becomes
~/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-3j1t. 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, and only
the folder name changes. metadata.json is updated in the same pass, so nothing is left
pointing at the old name.
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 is for: it re-points
devpod's record at the renamed clone, and dl <workspace> recreate finishes the repair.
That gives you back the clone association and the workspace's identity, though 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),
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:
xargs -r -n1 devpod delete < ~/.cache/devlaunch/orphaned-workspaces.txtA 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, since 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, on a read-only mount
or under 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.
dl keeps one bare clone per repo at ~/.cache/devlaunch/repos/owner/repo/.bare/
and cuts every workspace's checkout from it as a sibling directory named after the
workspace id. That is an ordinary clone whose git objects are hardlinks into that cache,
not a git worktree. How much disk a workspace costs
is the accounting for that. What follows is the other half: how fresh the branch
you land on is, which is what decides whether the tip you just pushed is the tip
you get. A launch fetches only the one branch it is launching, so no launch waits
on a repo-wide refresh.
- 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.dlnow says so when it matters: launchowner/repo@branchand, if the checkout indl's clone is behind theorigin/<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
dlthe 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.
rm is the only verb that refreshes git state. Everything else acts on the
container and leaves the clone standing:
restart,recreateandresetall reach a workspace devpod already knows, so they take the attach path above and run no git.resetis 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.dlhands 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> rmdeletes 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 fetchandgit pullare 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.
dl <workspace> up prepares one, and running it repeatedly is
cheap on purpose: against a container that is already up, a second up costs one
devpod status and nothing else. It used to also pay the tools setup pass,
~1.7s of devpod ssh to be told the tools it was told about last time, and now
reuses the recorded answer instead. See
The trip a launch can skip for what makes a recorded
answer stop being believed; the short version is that any completed devpod up,
by anything, does.
There is no flag that shares one container across several branches, and none that
warms a workspace in the background. An earlier revision of this section
documented --shared and --warm with worked examples; neither has ever existed
in the shipped dl, which exits 2 on both. test/test_readme_cli_doc.py is what
caught that, by handing every flag the README writes on a dl line to the
binary's own parser. It reads the README and not this page, so the protection
comes from documenting flags there rather than here.