Skip to content
Merged
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,9 +354,11 @@ anything to its `devcontainer.json`.
terminal beside itself in the same container, and you can attach to it from anywhere.
`DEVLAUNCH_ZELLIJ=1` is the ask, once in a shell profile or per launch; it costs 2.2s to 3.5s
of a cold launch, which is why it waits to be asked.
- **A terminal named after the workspace.** `dl blooop/devlaunch` names the pane
`devlaunch-main-3j1t` in zellij, tmux, or a plain terminal window: the workspace id,
the same string `dl --ls` prints and the container's hostname carries.
- **A terminal named after the workspace.** In zellij, tmux, or a plain terminal window
the pane takes the workspace id read for a person: the hashed suffix off and the dash
before the branch spelled `@`, so a tab and the row `dl --ls` prints still match by
eye. A workspace you name by its id keeps the id.
[The three renderings side by side](docs/workspaces.md#workspace-ids).
- **A shared pixi package cache**, bound in from the host, so dotfiles that provision tools with
`pixi global sync` download each package once per machine instead of once per container. On one
measured profile that is 18s to 28s instead of 62s to 113s and 1.2 GB.
Expand Down
41 changes: 41 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,47 @@ reporting an unknown workspace called `prune`, and a workspace that really is ca
`prune` is still reachable as `dl stop prune`. Use `dl <ws> rm` from now on.


### Where `--force` may sit

`--force` is read in one position only: after both the workspace and the verb, which
is where every example on this page writes it. The two slots ahead of it are already
spoken for, so a `--force` that lands in either is read as the word that belongs
there and refused:

```
dl <ws> --force rm -> Unknown command '--force'. (exit 1, nothing deleted)
dl --force <ws> rm -> Unknown workspace '--force'. (exit 1, nothing deleted)
```

**Past the verb it is read, but only `rm` and `rme` do anything with it.** Every
other verb takes the flag and drops it: `dl <ws> up --force`, `dl <ws> stop --force`
and `dl <ws> recreate --force` all run exactly as they would without it, silently.
That is the one place this grammar discards a word rather than refusing it, and it is
worth knowing because the two halves of the rule read as though they were one: a
misplaced `--force` refuses, a meaningless one does not. `kill` is the case to
remember, since it is the verb whose whole point is going ahead anyway. It has no
`--force` to type and ignores one offered.

**The selector form cannot be forced at all.** `dl rm` with no workspace opens the
picker, which leaves no slot after the verb, so `dl rm --force` is the verb-slot
refusal above. There is no spelling of "pick some workspaces and force the removal";
name the workspace, or answer the refusal the guard prints. The diagnostic's
suggestion (`dl rm -- --force`) is the generic one for an unknown verb word and would
run `--force` as a shell command, which is not what anybody typing that meant.

**A global command has no slots, so placement stops mattering there.**
`dl --force --prune` and `dl --prune --force` are one line, and the same holds for
every other global. Note this is about the literal word `--force`: `--devcontainer`,
`--force-worktrees` and `-y` have rules of their own.

Pinned by `force_deletes_only_where_it_follows_both_the_name_and_the_verb` and
`a_globals_force_reads_the_same_wherever_it_sits` in `rust/dl/src/cli.rs`, and by
`force_after_the_verb_still_deletes` with its two neighbours in
`rust/dl/tests/grammar.rs`. The two refusals quoted above are a second copy of
strings `render.rs` and `lib.rs` own, so
`the_force_placement_section_quotes_the_refusals_it_says_it_does` reads this
section back and diffs them against what the binary prints.

## Remote Control: every `aid` session, on your phone too

Every `aid` launch of claude starts with Claude Code's Remote Control on. There is no
Expand Down
13 changes: 7 additions & 6 deletions docs/workspace-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,10 +508,11 @@ the same size of decision. That one installs a package into a container and star
session; this one writes an escape sequence and two lines into a profile.

**It is the [workspace id](workspaces.md#workspace-ids) read for a person:
`<repo>@<branch>`, with the hashed suffix off.** `dl blooop/devlaunch` names the pane
`devlaunch@main` where devpod, the container's hostname and the `WORKSPACE` column of
`dl --ls` all say `devlaunch-main-3j1t`. One string with two characters changed, so a
tab and a listing row still match by eye.
`<repo>@<branch>`, with the hashed suffix off.** The [renderings
table](workspaces.md#workspace-ids) is where the three are written down side by side
and where the spelling is decided; the escape sequence above and the profile line
below show it in place rather than settle it. One string with two characters changed,
so a tab and a listing row still match by eye.

Two characters, and they are the two a glance cannot use. The suffix carries the
workspace's identity and none of its meaning: it is what keeps two branches whose
Expand Down Expand Up @@ -545,7 +546,7 @@ no arguments is how a workspace is reopened. It hands the launch a workspace id
any other, but it had the triple a moment earlier: it read the owner and repo out of
the cache layout and the branch out of the clone's `HEAD` to draw the row you picked.
That travels with the pick, so a workspace opened from the selector is titled
`devlaunch@main`, the same as one opened as `dl blooop/devlaunch@main`.
exactly as one opened as `dl blooop/devlaunch@main` is.

It is checked rather than trusted. `HEAD` is the branch checked out *now*, so a
`git switch` inside the container leaves a triple that derives some other workspace,
Expand Down Expand Up @@ -595,7 +596,7 @@ a hash of its own text, so a second, different name for one workspace does not r
the first, it sits after it, and the last one wins. Every launch that resolves a
branch derives the same label, and the arms that never had one all use the id, so
those agree among themselves. What does not agree is one workspace opened **both**
ways: `dl blooop/devlaunch@main` installs `devlaunch@main`, a later
ways: `dl blooop/devlaunch@main` installs the readable name, a later
`dl devlaunch-main-3j1t` installs the id, and the tab reads as the id from then on. It
costs one extra line in the profile and a less readable tab, in a case most workspaces
never reach.
Expand Down
11 changes: 9 additions & 2 deletions docs/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,15 @@ The other two are renderings of that same id, cut to what their surface is for:
| The [selector](cli.md#the-selector) | `blooop \| devlaunch \| main` | 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` |

They are renderings and not separate derivations, which is what keeps them
matchable: the tab is the id with the suffix off and one dash spelled `@`, so
`devlaunch@main` and `devlaunch-main-3j1t` are recognisably the same workspace.
matchable: the tab is the id with the suffix off and one dash spelled `@`, so the
first two rows are recognisably the same workspace. 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](workspace-tools.md#naming-the-terminal-after-the-workspace).
`a_label_is_the_id_with_the_suffix_off_and_an_at_where_the_dash_was` in
`rust/devlaunch-core/src/domain/workspace_id.rs` pins the tab's cell against what
`WorkspaceId::label` returns.

Which dash the `@` replaces is not readable off the id, since a repo slug holds
dashes of its own, so the tab's name travels with the launch that resolved it rather
Expand Down
6 changes: 6 additions & 0 deletions rust/devlaunch-core/src/domain/workspace_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1415,6 +1415,12 @@ mod tests {
assert_eq!(parsed.value(), "devlaunch-feature-auth-np10");
assert_eq!(parsed.label(), "devlaunch@feature-auth");

// The example `docs/workspaces.md` publishes in its renderings table, which
// is where that spelling is decided. Pinned here because nothing else checks
// the cell, and a table of three strings a reader is invited to match by eye
// is worth exactly as much as its being right.
assert_eq!(id("blooop", "devlaunch", "main").label(), "devlaunch@main");

// Stated as a *difference*, not as a reconstruction, because which dash the
// `@` replaces cannot be read off the id: a repo slug holds dashes of its
// own, so `my-repo@main` and `my@repo-main` are the same id read two ways.
Expand Down
8 changes: 5 additions & 3 deletions rust/devlaunch-core/src/flows/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2233,9 +2233,11 @@ pub(crate) fn dotfiles_update(
/// The name is the placement's ([`Placement::title`]), and it is the workspace id
/// with its two unreadable characteristics taken off: the four-character suffix,
/// which carries identity and no meaning, and the dash between the repo and the ref,
/// which is spelled `@`. `dl blooop/devlaunch` names the pane `devlaunch@main` where
/// devpod, the hostname and the `WORKSPACE` column of `dl --ls` all say
/// `devlaunch-main-3j1t`.
/// which is spelled `@`. `docs/workspaces.md` tabulates what a tab, a listing row and
/// a selector row read for one workspace, and is where that spelling is decided; this
/// comment deliberately does not write it, because a comment nothing checks is the
/// copy that goes stale. [`WorkspaceId::label`]'s own tests carry the worked
/// examples.
///
/// **It is the id, not a second derivation of the spec.** The slugs and the
/// truncation are [`WorkspaceId::label`]'s, which are [`WorkspaceId::value`]'s, so a
Expand Down
6 changes: 3 additions & 3 deletions rust/dl/src/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,9 @@ pub(crate) struct Offer {
/// knows its triple: it read the owner and repo out of the cache layout and the
/// branch out of the clone's `HEAD` to draw the row. A launch handed a bare id
/// cannot recover any of that, so without this the tab of every workspace
/// opened from the picker reads as the id
/// (`devlaunch-main-3j1t`) where the same workspace opened as
/// `dl blooop/devlaunch@main` reads `devlaunch@main`.
/// opened from the picker reads as the id where the same workspace opened as
/// `dl blooop/devlaunch@main` reads the name for a person that
/// `docs/workspaces.md` tabulates.
///
/// `None` where the row is not one of dl's clones, and **also** where the
/// recovered triple derives some *other* id: `HEAD` is the branch checked out
Expand Down
98 changes: 98 additions & 0 deletions rust/dl/tests/grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,104 @@ fn force_after_the_verb_still_deletes() {
assert!(!world.clone_is_there(), "the clone was deleted");
}

/// The refusals `docs/cli.md` quotes, held to the ones the binary prints.
///
/// The "Where `--force` may sit" section writes both into a code block, cut at the
/// end of the first sentence. That is a hand-maintained copy of a string owned by
/// `render.rs` and `lib.rs`, and this repository allows a second copy only with a
/// test beside it that diffs it against the first. The section names the tests that
/// pin the *behaviour*, which is not the same promise: those would still pass with
/// the message reworded and the page left quoting the old one.
///
/// Compared against the message's first sentence, because the block drops the
/// `Use '...'` suggestion that follows on purpose. Not by prefix, which was the
/// first shape of this test and was worth nothing: every truncation of the message
/// satisfies a prefix test, the empty string included, and an empty quote is
/// precisely what a code block reflowed to wrap after the arrow produces. The guard
/// then stood there passing on any message at all.
#[test]
fn the_force_placement_section_quotes_the_refusals_it_says_it_does() {
let doc = std::fs::read_to_string(repo_root().join("docs/cli.md")).expect("docs/cli.md");
let section = force_placement_section(&doc);

for (spelling, args) in [
(
"dl <ws> --force rm",
["blooop/devlaunch@main", "--force", "rm"],
),
(
"dl --force <ws> rm",
["--force", "blooop/devlaunch@main", "rm"],
),
] {
let quoted = quoted_refusal(&section, spelling);
let world = World::full();
let run = world.dl(&args);

assert_eq!(run.code, Some(1), "stderr: {}", run.err);
assert_eq!(
quoted,
first_sentence(&run.err),
"docs/cli.md says `{spelling}` prints {quoted:?}; it prints {:?}",
run.err
);
assert!(
world.clone_is_there(),
"`{spelling}` must not have deleted the clone"
);
}
}

/// The text under the `--force` placement heading, up to the next heading.
///
/// Matched on the heading rather than on a phrase under it, so the prose stays free
/// to be rewritten while this test keeps pointing at one span. A missing heading
/// says so rather than yielding an empty section that every assertion passes over.
fn force_placement_section(document: &str) -> String {
const HEADING: &str = "### Where `--force` may sit";
let start = document
.find(HEADING)
.unwrap_or_else(|| panic!("docs/cli.md no longer has a '{HEADING}' section"));
let rest = &document[start + HEADING.len()..];
let end = rest
.find(
"
## ",
)
.unwrap_or(rest.len());
rest[..end].to_string()
}

/// The refusal `docs/cli.md` writes for one spelling: what follows the `->` arrow,
/// up to the run of spaces before the `(exit 1, ...)` annotation.
fn quoted_refusal(section: &str, spelling: &str) -> String {
let line = section
.lines()
.find(|line| line.starts_with(spelling))
.unwrap_or_else(|| panic!("docs/cli.md no longer shows `{spelling}`"));
let (_, printed) = line
.split_once("->")
.unwrap_or_else(|| panic!("`{spelling}` in docs/cli.md no longer names what it prints"));
printed
.trim_start()
.split(" ")
.next()
.expect("split always yields one part")
.trim_end()
.to_string()
}

/// The first sentence of a diagnostic, which is the span `docs/cli.md` quotes.
///
/// The rest of the message is the `Use '...'` suggestion, which the page leaves out
/// because it names a workspace the reader did not type.
fn first_sentence(message: &str) -> String {
match message.split_once(". ") {
Some((head, _)) => format!("{head}."),
None => message.trim_end().to_string(),
}
}

#[test]
fn a_path_that_names_no_workspace_is_refused() {
let world = World::full();
Expand Down
Loading