diff --git a/README.md b/README.md index e5a0f68..01d7f40 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/cli.md b/docs/cli.md index 6896553..30db43d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 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 --force rm -> Unknown command '--force'. (exit 1, nothing deleted) +dl --force 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 up --force`, `dl stop --force` +and `dl 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 diff --git a/docs/workspace-tools.md b/docs/workspace-tools.md index 09a1fa7..95338ac 100644 --- a/docs/workspace-tools.md +++ b/docs/workspace-tools.md @@ -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: -`@`, 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. +`@`, 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 @@ -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, @@ -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. diff --git a/docs/workspaces.md b/docs/workspaces.md index 4b68579..dacaf6e 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -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 diff --git a/rust/devlaunch-core/src/domain/workspace_id.rs b/rust/devlaunch-core/src/domain/workspace_id.rs index f3370a4..2ceb264 100644 --- a/rust/devlaunch-core/src/domain/workspace_id.rs +++ b/rust/devlaunch-core/src/domain/workspace_id.rs @@ -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. diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index 7d45046..745c5a7 100644 --- a/rust/devlaunch-core/src/flows/launch.rs +++ b/rust/devlaunch-core/src/flows/launch.rs @@ -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 diff --git a/rust/dl/src/select.rs b/rust/dl/src/select.rs index 40f198f..eeb8450 100644 --- a/rust/dl/src/select.rs +++ b/rust/dl/src/select.rs @@ -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 diff --git a/rust/dl/tests/grammar.rs b/rust/dl/tests/grammar.rs index 0d00b76..a17d760 100644 --- a/rust/dl/tests/grammar.rs +++ b/rust/dl/tests/grammar.rs @@ -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 --force rm", + ["blooop/devlaunch@main", "--force", "rm"], + ), + ( + "dl --force rm", + ["--force", "blooop/devlaunch@main", "rm"], + ), + ] { + let quoted = quoted_refusal(§ion, 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();