From c4fbee4e82970dfd259906718154d717bd4c917e Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Fri, 16 Oct 2026 16:17:25 +0000 Subject: [PATCH 1/6] feat: dl rmt deletes the workspace its herdr tab is named after, then closes the tab A launch names its herdr tab `repo@branch`, and the name stays after the session closes. `dl rmt`, typed in that tab, reads the name with `herdr tab get $HERDR_TAB_ID`, turns it into the one workspace id devpod still lists (the id itself, or the records' label), prints the id, and runs the same guarded delete as `rm`. After a removal that worked it hangs up the shell, as `rme` does, so the tab closes. No workspace name and no selector. Claude-Session: https://claude.ai/code/session_018kqBWJQXnHEACr4nBxAMKF --- CHANGELOG.md | 9 + README.md | 7 + docs/cli.md | 30 ++++ rust/devlaunch-core/public-api.rest.txt | 17 ++ rust/devlaunch-core/src/clients/herdr.rs | 37 ++++ rust/devlaunch-core/src/flows/launch.rs | 84 +++++++++ .../src/flows/session_manager.rs | 125 ++++++++++++++ rust/dl/src/cli.rs | 97 +++++++++++ rust/dl/src/commands.rs | 89 +++++++++- rust/dl/src/hangup.rs | 4 +- rust/dl/src/lib.rs | 5 + rust/dl/src/render.rs | 40 +++++ rust/dl/tests/lifecycle.rs | 159 ++++++++++++++++++ 13 files changed, 701 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f94cd0..f9ed46d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`dl rmt` deletes the workspace a herdr tab is named after.** A launch names its herdr tab + `repo@branch`, and the name stays after the session closes. Typed in that tab, `dl rmt` reads + the name with `herdr tab get`, finds the one workspace devpod lists with that name, prints its + id, deletes it with the guard `rm` has (`--force` as for `rm`), and then closes the tab by + hanging up its shell, as `rme` does. No workspace name and no selector. A tab name that + matches no workspace, or two, is refused, and the tab stays open. + ## [0.60.0] - 2026-10-16 ### Added diff --git a/README.md b/README.md index 6b5f4c08..bafc0abe 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ there to answer. [docs/cli.md](docs/cli.md) has the rest. | `dl kill` | Kill whatever is holding a workspace that will not answer, then delete it | | `dl rm` | Delete it. Refuses if the clone holds work that is nowhere else | | `dl rme` | The same delete, and then the shell: it closes the terminal it was typed in | +| `dl rmt` | In a [herdr](https://herdr.dev) tab: delete the workspace the tab is named after, then close the tab as `rme` does. No selector | | `dl code` | Open it in VS Code | | `dl restart` | Stop and start, no rebuild | | `dl recreate` | Recreate the container | @@ -273,6 +274,12 @@ now; `dl --rm` deletes once the session ends, the way `docker run --rm` doe work that exists nowhere else: a clone with uncommitted or unpushed changes is kept and named, and `dl rm --force` is how you override that. +`rmt` is for the herdr tab a session ran in. A launch names the tab after its workspace +(`devlaunch@main`), and the name stays after the session closes. Type `dl rmt` in that tab and +it deletes that workspace and then closes the tab, as `rme` does, with the same guard and the +same `--force`. It prints the workspace id before it deletes anything, and it refuses a tab +name that no workspace or two workspaces carry. See [docs/cli.md](docs/cli.md#rmt-the-workspace-this-herdr-tab-is-named-after). + `rme` is neither of those. It deletes the workspace now, as `rm` does, and then hangs up the shell that asked, so the terminal tab you opened for that one workspace closes on its own instead of waiting out the delete and then wanting an `exit`. A delete that refused leaves the diff --git a/docs/cli.md b/docs/cli.md index 39f5e43c..66810df3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -497,6 +497,36 @@ background jobs with it, because that is what a shell does on SIGHUP. Use `rme` the tab that has nothing left in it, which is the tab it was written for, and `rm` in the one you are still working in. +### `rmt`: the workspace this herdr tab is named after + +`dl rmt` deletes a workspace without a name on the line and without the selector. The +workspace is the one the herdr tab you type it in is named after. + +A launch in a herdr pane renames its tab after the workspace's record, `repo@branch`, and +herdr keeps that name after the session ends. So when you close an `aid` session, the shell +left in the tab still knows which workspace the tab was for. `dl rmt` asks herdr for the +tab's name (`herdr tab get $HERDR_TAB_ID`) and deletes that workspace: + +``` +$ dl rmt +This tab is named 'devlaunch@main': removing workspace devlaunch-main-3j1t. +Removing workspace devlaunch-main-3j1t... +... +Hanging up the shell dl was called from (pid 4242). +``` + +The name becomes an id in one of two ways. A name that is a workspace id devpod lists is +that workspace, because a launch of a workspace `dl` holds no record for names the tab with +the id. Any other name is looked up in `metadata.json`, and only workspaces devpod still lists can answer, so the record +of a workspace deleted long ago cannot. A tab nobody renamed carries its number, which names +no workspace. No match and two matches are both refused, and nothing is deleted. + +After that it is `rme`. The guard stops at work that is nowhere else, and `dl rmt --force` +is the way past it. Once the workspace has gone, `dl` hangs up the shell it was typed in, so +the tab closes. A refusal leaves the shell open with the reason on screen. +[`rme`](#rme-the-delete-and-then-the-shell) has what the hangup does and when it is skipped. +`rmt` takes no workspace name, no `--rm` and no command. Outside a herdr tab it refuses and asks herdr nothing. + ### `--stop` and `--autorm` are retired Both moved because `--rm` changed meaning, and both are still recognised so that a diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index b9d88ac6..5598af96 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -1996,6 +1996,7 @@ pub fn devlaunch_core::flows::launch::UnquotableCommand::fmt(&self, &mut core::f impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::UnquotableCommand pub fn devlaunch_core::flows::launch::names_for(&devlaunch_core::flows::launch::Host, &str) -> (devlaunch_core::flows::launch::TerminalTitle, devlaunch_core::flows::launch::HerdrTabRename) pub fn devlaunch_core::flows::launch::plan(&str) -> core::result::Result +pub fn devlaunch_core::flows::launch::recorded_ids_labelled(&devlaunch_core::domain::metadata::MetadataStorage, &str, impl core::ops::function::Fn(&str) -> bool) -> alloc::vec::Vec pub fn devlaunch_core::flows::launch::recorded_label_in(&std::path::Path, &str) -> core::option::Option pub fn devlaunch_core::flows::launch::resolve_triple(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut dyn devlaunch_core::flows::launch::ColdMachinery<'_>, &devlaunch_core::domain::workspace_id::WorkspaceId, &mut dyn devlaunch_core::notices::Notices, devlaunch_core::clients::devpod::Patience) -> core::result::Result pub mod devlaunch_core::flows::launch_locks @@ -3188,7 +3189,23 @@ pub fn devlaunch_core::flows::session_manager::PaneDestination::eq(&self, &devla impl core::fmt::Debug for devlaunch_core::flows::session_manager::PaneDestination pub fn devlaunch_core::flows::session_manager::PaneDestination::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::session_manager::PaneDestination +pub enum devlaunch_core::flows::session_manager::ThisTab +pub devlaunch_core::flows::session_manager::ThisTab::Named +pub devlaunch_core::flows::session_manager::ThisTab::Named::label: alloc::string::String +pub devlaunch_core::flows::session_manager::ThisTab::Named::tab_id: alloc::string::String +pub devlaunch_core::flows::session_manager::ThisTab::NotInHerdr +pub devlaunch_core::flows::session_manager::ThisTab::Unanswered +pub devlaunch_core::flows::session_manager::ThisTab::Unanswered::tab_id: alloc::string::String +impl core::clone::Clone for devlaunch_core::flows::session_manager::ThisTab +pub fn devlaunch_core::flows::session_manager::ThisTab::clone(&self) -> devlaunch_core::flows::session_manager::ThisTab +impl core::cmp::Eq for devlaunch_core::flows::session_manager::ThisTab +impl core::cmp::PartialEq for devlaunch_core::flows::session_manager::ThisTab +pub fn devlaunch_core::flows::session_manager::ThisTab::eq(&self, &devlaunch_core::flows::session_manager::ThisTab) -> bool +impl core::fmt::Debug for devlaunch_core::flows::session_manager::ThisTab +pub fn devlaunch_core::flows::session_manager::ThisTab::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::session_manager::ThisTab pub fn devlaunch_core::flows::session_manager::pane_destination(&dyn devlaunch_runner::Runner) -> devlaunch_core::flows::session_manager::PaneDestination +pub fn devlaunch_core::flows::session_manager::this_tab(&dyn devlaunch_runner::Runner) -> devlaunch_core::flows::session_manager::ThisTab pub mod devlaunch_core::flows::workspace_clone pub enum devlaunch_core::flows::workspace_clone::EnsureBranchError pub devlaunch_core::flows::workspace_clone::EnsureBranchError::Branch(devlaunch_core::flows::branch_manager::BranchError) diff --git a/rust/devlaunch-core/src/clients/herdr.rs b/rust/devlaunch-core/src/clients/herdr.rs index baed0a64..a6193a07 100644 --- a/rust/devlaunch-core/src/clients/herdr.rs +++ b/rust/devlaunch-core/src/clients/herdr.rs @@ -1778,6 +1778,43 @@ pub(crate) fn process_info_in(answer: &str) -> Option { .map(|envelope| envelope.result.process_info) } +/// The argv that asks herdr about one tab. +/// +/// `herdr tab get `, the `tab.get` method of herdr's socket API, taken from +/// its published schema (`docs/next/api/herdr-api.schema.json`) and its tab CLI +/// bindings rather than measured here. +pub(crate) fn tab_get_argv(tab_id: &str) -> Vec { + vec!["tab".to_owned(), "get".to_owned(), tab_id.to_owned()] +} + +/// The label in `herdr tab get`'s answer: `{"result":{"type":"tab_info","tab":{…}}}`. +/// +/// The label is what `herdr tab rename` wrote, which is the name a launch gave the +/// tab. A tab nobody renamed answers its number instead, and that is still a +/// label: it names no workspace, and the caller finds that out by looking. +/// A label of only whitespace is no label. +pub(crate) fn tab_label_in(answer: &str) -> Option { + #[derive(Deserialize)] + struct Envelope { + result: Info, + } + #[derive(Deserialize)] + struct Info { + tab: Tab, + } + #[derive(Deserialize)] + struct Tab { + label: String, + } + let label = serde_json::from_str::(answer) + .ok()? + .result + .tab + .label; + let label = label.trim(); + (!label.is_empty()).then(|| label.to_owned()) +} + // =========================================================================== // starting the agent again after herdr restarts // =========================================================================== diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index e2251e7e..110ebc6b 100644 --- a/rust/devlaunch-core/src/flows/launch.rs +++ b/rust/devlaunch-core/src/flows/launch.rs @@ -4329,6 +4329,52 @@ fn label_in(records: &MetadataStorage, workspace_id: &str) -> Option { ) } +/// The workspaces a tab name was given to, read backwards out of the records. +/// +/// [`label_in`] the other way round. A launch names the herdr tab and the pane +/// after the record (`devlaunch@main`), and that name outlives the session: the +/// tab is closed, the container is still running, and the name is the one thing +/// the person still has in their head. `dl devlaunch@main stop` is what they type, +/// and this is how a lifecycle verb finds the workspace behind it. +/// +/// **Only ids `listed` admits come back**, and which of a record's two ids is +/// asked first is [`stored_as`]'s order reversed on purpose: `devpod_workspace_id` +/// is the name devpod was actually given, so it is the one a listing holds. A +/// record nothing lists is a workspace deleted months ago -- `metadata.json` is +/// append-mostly -- and must not turn a tab name into an id devpod has never +/// heard of. +/// +/// **More than one answer is an answer.** Names need not be unique: two owners' +/// forks of one repo on one branch carry the same label, and so do two records of +/// one triple under different id schemes. The caller refuses rather than picks, +/// because the verbs this serves include `rm`. +#[must_use] +pub fn recorded_ids_labelled( + records: &MetadataStorage, + label: &str, + listed: impl Fn(&str) -> bool, +) -> Vec { + let mut ids: Vec = records + .list_worktrees(crate::domain::metadata::WorktreeFilter::All) + .into_iter() + .filter(|record| { + WorkspaceId::new(&record.owner, &record.repo, &record.branch) + .is_ok_and(|workspace| workspace.label() == label) + }) + .filter_map(|record| { + record + .devpod_workspace_id + .iter() + .chain(std::iter::once(&record.workspace_id)) + .find(|id| listed(id)) + .cloned() + }) + .collect(); + ids.sort(); + ids.dedup(); + ids +} + /// The devpod workspace id `metadata.json` holds for a triple, if any. /// /// A store that cannot be opened answers `None`, which is Python's reading: a @@ -12506,6 +12552,44 @@ mod tests { ); } + #[test] + fn a_tab_name_reads_back_to_the_listed_workspaces_that_carry_it() { + // The tab says `devlaunch@main` long after the session that named it has + // gone, and that name is what `dl devlaunch@main stop` is typed with. The + // record that names the tab is the record that answers, and only for ids + // devpod still lists: a stale record must not become an id to act on. + let scene = Scene::new(); + record_worktree( + scene.cache_dir(), + "blooop", + "devlaunch", + "main", + "devlaunch-main-legacy", + ); + record_worktree( + scene.cache_dir(), + "someone", + "devlaunch", + "main", + "devlaunch-main-fork", + ); + let records = MetadataStorage::look(MetadataStorage::path_in(scene.cache_dir())); + + assert_eq!( + recorded_ids_labelled(&records, "devlaunch@main", |id| id + == "devlaunch-main-legacy"), + ["devlaunch-main-legacy"], + ); + // Two forks on one branch are one tab name, and both come back: which one + // the person meant is theirs to say. + assert_eq!( + recorded_ids_labelled(&records, "devlaunch@main", |_| true), + ["devlaunch-main-fork", "devlaunch-main-legacy"], + ); + assert!(recorded_ids_labelled(&records, "devlaunch@main", |_| false).is_empty()); + assert!(recorded_ids_labelled(&records, "devlaunch@other", |_| true).is_empty()); + } + #[test] fn a_record_whose_triple_will_not_parse_is_walked_past_rather_than_answered_with() { // `is_safe_name` refuses an empty ref, and nothing stops such a record diff --git a/rust/devlaunch-core/src/flows/session_manager.rs b/rust/devlaunch-core/src/flows/session_manager.rs index 8c0427fc..cf37c286 100644 --- a/rust/devlaunch-core/src/flows/session_manager.rs +++ b/rust/devlaunch-core/src/flows/session_manager.rs @@ -386,6 +386,65 @@ pub fn pane_destination(runner: &dyn Runner) -> PaneDestination { ) } +/// What herdr calls the tab this process runs in. +/// +/// Three arms, because `dl rmt` says something different for each: a shell outside +/// herdr has no tab to ask about, a herdr that did not answer is not a tab with no +/// name, and a name is what the removal looks the workspace up by. +#[derive(Clone, Debug, PartialEq, Eq)] +// binary surface -- not part of the frozen wf API (#251 §7) +pub enum ThisTab { + /// [`launch::HERDR_TAB_VAR`] is not set: this is not a herdr pane. + NotInHerdr, + /// herdr was asked about the tab and gave no label: it refused, timed out, was + /// not found, or answered something that is not a `tab_info`. + Unanswered { tab_id: String }, + /// The tab's label, as `herdr tab get` reports it. + Named { tab_id: String, label: String }, +} + +/// The label of the herdr tab this process runs in, asked of herdr. +/// +/// The same environment [`pane_destination`] reads -- the tab id from +/// [`launch::HERDR_TAB_VAR`], the binary through [`herdr::binary_from_process`] -- +/// and the same bound on herdr's answer, so a herdr that has gone away costs +/// [`herdr::ANSWER_WITHIN`] and not a hang. +pub fn this_tab(runner: &dyn Runner) -> ThisTab { + let tab_id = crate::osext::env_str(launch::HERDR_TAB_VAR); + let binary = herdr::binary_from_process(); + tab_for( + runner, + PaneEnv { + tab_id: tab_id.as_deref(), + binary: binary.as_deref(), + }, + ) +} + +/// [`this_tab`], against a stated environment rather than this process's. +fn tab_for(runner: &dyn Runner, host: PaneEnv<'_>) -> ThisTab { + let Some(tab_id) = host.tab_id.filter(|id| !id.is_empty()) else { + return ThisTab::NotInHerdr; + }; + let binary = host + .binary + .filter(|path| !path.is_empty()) + .unwrap_or(launch::HERDR_BIN_FALLBACK); + let mut budget = Budget::of(herdr::ANSWER_WITHIN); + match ask(runner, binary, &herdr::tab_get_argv(tab_id), &mut budget) + .as_deref() + .and_then(herdr::tab_label_in) + { + Some(label) => ThisTab::Named { + tab_id: tab_id.to_owned(), + label, + }, + None => ThisTab::Unanswered { + tab_id: tab_id.to_owned(), + }, + } +} + /// What herdr exported into this pane, as the pane shell reads it. /// /// A struct rather than two `Option<&str>` parameters, which is what this was: @@ -1826,6 +1885,72 @@ mod tests { assert_eq!(runner.call_count(), 0); } + /// A `tab get` answer in herdr's envelope, with this label. The fields this + /// does not read are written out for the reason [`pane_list`] gives. + fn tab_info(label: &str) -> String { + format!( + r#"{{"id":"cli:tab:get","result":{{"type":"tab_info","tab":{{"tab_id":"w1:t1","workspace_id":"w1","number":4,"label":"{label}","focused":true,"pane_count":1,"agent_status":"none"}}}}}}"# + ) + } + + #[test] + fn this_tab_reads_the_label_a_launch_renamed_it_to() { + let runner = ScriptedRunner::new(); + runner.on_unscripted(Unscripted::Panic); + runner.script( + [LENT_HERDR, "tab", "get", "w1:t1"], + Response::stdout(tab_info("devlaunch@main")), + ); + assert_eq!( + tab_for(&runner, in_pane_of("w1:t1")), + ThisTab::Named { + tab_id: "w1:t1".to_owned(), + label: "devlaunch@main".to_owned(), + } + ); + } + + #[test] + fn this_tab_outside_herdr_asks_nothing() { + let runner = ScriptedRunner::new(); + runner.on_unscripted(Unscripted::Panic); + for tab_id in [None, Some("")] { + assert_eq!( + tab_for( + &runner, + PaneEnv { + tab_id, + ..PaneEnv::default() + } + ), + ThisTab::NotInHerdr + ); + } + assert_eq!(runner.call_count(), 0); + } + + #[test] + fn a_herdr_that_refuses_or_answers_no_label_is_unanswered() { + for response in [ + Response::failed( + 1, + r#"{"id":"cli:tab:get","error":{"code":"tab_not_found"}}"#, + ), + Response::stdout(tab_info(" ")), + Response::stdout("not json"), + ] { + let runner = ScriptedRunner::new(); + runner.on_unscripted(Unscripted::Panic); + runner.script([LENT_HERDR, "tab", "get", "w1:t1"], response); + assert_eq!( + tab_for(&runner, in_pane_of("w1:t1")), + ThisTab::Unanswered { + tab_id: "w1:t1".to_owned() + } + ); + } + } + #[test] fn a_tab_holding_a_session_puts_the_pane_in_its_workspace() { let runner = ScriptedRunner::new(); diff --git a/rust/dl/src/cli.rs b/rust/dl/src/cli.rs index a667f07b..ffab079d 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -48,6 +48,14 @@ const VERBS: [(&str, VerbWord); 10] = [ ("dotfiles", VerbWord::Dotfiles), ]; +/// The word that removes the workspace this herdr tab is named after. +/// +/// Reserved like a verb, so it can never be read as a workspace name, but not in +/// [`VERBS`]: every verb there takes a workspace or opens the selector, and this +/// takes neither. The tab it is typed in is the workspace, which is the point of +/// it: the shell that asks is in the tab whose session has just closed. +pub(crate) const REMOVE_THIS_TAB: &str = "rmt"; + /// Words that were verbs once and are not any more. /// /// **Recognised rather than forgotten, and that is the whole point of the table.** @@ -435,6 +443,11 @@ pub(crate) enum Command { devcontainer: Option, claude_profile: Option, }, + /// `dl rmt [--force]`: remove the workspace this herdr tab is named after, + /// with the guard `rm` has. `force` is `rm --force`'s. + RemoveThisTab { + force: bool, + }, /// A workspace, and what to do with it. Workspace { target: String, @@ -496,6 +509,9 @@ pub(crate) enum GrammarError { RetiredVerb(RetiredWord), /// A flag that was a command in an earlier build and is not one now. RetiredFlag(RetiredFlag), + /// `rmt` beside a workspace name. The tab names the workspace, so a second + /// name is one too many. + TabTakesNoWorkspace, /// A global command was given a workspace to act on. TargetNotAllowed { command: &'static str }, /// A modifier that means nothing for the command it was given to. @@ -1148,6 +1164,9 @@ fn workspace_command(cli: Cli, argv: &[String]) -> Result claude_profile, }); } + // `dl rmt --force`: the one word that takes no workspace, so the slot + // after it is where its modifier goes. + ForcePlace::VerbSlot { target } if target == REMOVE_THIS_TAB => {} ForcePlace::VerbSlot { target } => { // A *retired* verb in the workspace slot earns the retirement's // sentence instead. `dl prune --force` was the remove verb with @@ -1168,6 +1187,9 @@ fn workspace_command(cli: Cli, argv: &[String]) -> Result ForcePlace::Trailing => {} } } + if cli.words.iter().any(|word| word == REMOVE_THIS_TAB) { + return remove_this_tab(&cli, devcontainer.is_some(), claude_profile.is_some()); + } let rm = rm_on_exit_of(&cli); let run = NonEmpty::of(cli.command.iter().cloned()).map(|words| Verb::Run(words, rm)); let attach = || Verb::Attach { rm }; @@ -1251,6 +1273,41 @@ fn workspace_command(cli: Cli, argv: &[String]) -> Result }) } +/// `dl rmt`, and every modifier a line holding it may not carry. +/// +/// Read before the verb table, so `rmt` beside any other word is refused rather +/// than taken as a workspace called `rmt`. +fn remove_this_tab( + cli: &Cli, + devcontainer: bool, + claude_profile: bool, +) -> Result { + if cli.words.len() > 1 { + return Err(GrammarError::TabTakesNoWorkspace); + } + if !cli.command.is_empty() { + return Err(GrammarError::CommandNotAllowed { + verb: REMOVE_THIS_TAB, + }); + } + if cli.rm { + return Err(GrammarError::RmNotAllowed { + command: REMOVE_THIS_TAB, + }); + } + if devcontainer { + return Err(GrammarError::DevcontainerNotAllowed { + command: REMOVE_THIS_TAB, + }); + } + if claude_profile { + return Err(GrammarError::ClaudeProfileNotAllowed { + command: REMOVE_THIS_TAB, + }); + } + Ok(Command::RemoveThisTab { force: cli.force }) +} + /// Whether this line asked for the workspace to go when the session does. fn rm_on_exit_of(cli: &Cli) -> RmOnExit { if cli.rm { RmOnExit::Yes } else { RmOnExit::No } @@ -1483,6 +1540,46 @@ mod tests { ); } + // ============================================================ rmt: rm, this tab + + #[test] + fn rmt_takes_the_tab_as_its_workspace_and_force_after_it() { + assert_eq!(parse(&["rmt"]), Ok(Command::RemoveThisTab { force: false })); + assert_eq!( + parse(&["rmt", "--force"]), + Ok(Command::RemoveThisTab { force: true }) + ); + } + + #[test] + fn rmt_beside_a_workspace_is_refused_rather_than_read_as_one() { + // `rmt` is reserved in both positions, so neither word becomes a workspace + // called `rmt`, and a second name is one too many. + for line in [&["rmt", "ws"], &["ws", "rmt"], &["rmt", "rm"]] { + assert_eq!( + parse(line), + Err(GrammarError::TabTakesNoWorkspace), + "{line:?}" + ); + } + } + + #[test] + fn rmt_refuses_the_modifiers_that_mean_nothing_for_it() { + assert_eq!( + parse(&["rmt", "--rm"]), + Err(GrammarError::RmNotAllowed { command: "rmt" }) + ); + assert_eq!( + parse(&["rmt", "--", "make"]), + Err(GrammarError::CommandNotAllowed { verb: "rmt" }) + ); + assert_eq!( + parse(&["rmt", "--devcontainer", "robot"]), + Err(GrammarError::DevcontainerNotAllowed { command: "rmt" }) + ); + } + // ============================================================ rme: rm, and out #[test] diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index 69b49b2a..4cfad534 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -35,7 +35,7 @@ use devlaunch_core::flows::session_manager::{self, PaneDestination}; use devlaunch_core::osext; use devlaunch_core::runner::{Exit, Runner}; -use crate::cli::{self, Command, ListOutput, RmOnExit, Verb}; +use crate::cli::{self, AfterRemoval, Command, ListOutput, RmOnExit, Verb}; use crate::hangup; use crate::launch::{self, Family, Reached}; use crate::pane_shell::{self, become_the_host_shell}; @@ -137,6 +137,13 @@ pub(crate) fn dispatch( // indistinguishable from one typed by hand -- same launch, same terminal // title, same agent reporting, same everything a manager reads. Command::HerdrSetup => crate::herdr_environment::setup(), + // `rme`'s ending: the tab this is typed in was for this workspace, so once + // the workspace has gone the shell is hung up and the tab closes with it. + // Only after a removal that worked; see [`crate::hangup`]. + Command::RemoveThisTab { force } => hangup::after_the_command( + AfterRemoval::HangUpTheShell, + render_remove_this_tab(runner, &mut context, cache, refresh, removal_of(force)), + ), Command::HerdrEnv { action, workspace } => { crate::herdr_environment::manage(&action, workspace.as_deref()) } @@ -1107,6 +1114,86 @@ fn render_remove<'r>( ) } +/// `dl rmt` -- the workspace this herdr tab is named after, removed. +/// +/// **The tab is the target.** A launch renames its herdr tab after the workspace +/// (`repo@branch`, from the record), and the rename sticks after the session ends. +/// So a shell left in that tab has its workspace's name one `herdr tab get` away, +/// and this reads it there instead of asking anybody to type it or pick it. +/// +/// **The name is read back to an id, never guessed.** A label that is a workspace +/// id devpod lists is that workspace: a workspace dl holds no record for has its +/// tab named with the id. Otherwise the records answer with +/// [`devlaunch_core::flows::launch::recorded_ids_labelled`], and only for ids +/// devpod still lists. No answer and two answers are both refusals: +/// this deletes, and a tab that nobody named or a name two forks share is not a +/// workspace to delete. +/// +/// **Then it is `rme`**: the id goes on screen first, and [`remove_addressed`] is +/// the same guard and the same delete, so `rmt` refuses wherever `rm` would and +/// `--force` is the same way past. The hangup that closes the tab is the +/// dispatcher's, as it is for `rme`. +fn render_remove_this_tab<'r>( + runner: &'r dyn Runner, + context: &mut CommandContext<'r>, + cache: &Path, + refresh: &mut Refresh<'_>, + removal: Removal, +) -> Ending { + let label = match session_manager::this_tab(runner) { + session_manager::ThisTab::Named { label, .. } => label, + session_manager::ThisTab::NotInHerdr => { + eprintln!("{}", render::rmt_outside_herdr()); + return Ending::Refused; + } + session_manager::ThisTab::Unanswered { tab_id } => { + eprintln!("{}", render::rmt_tab_unanswered(&tab_id)); + return Ending::Refused; + } + }; + let listed = match context.workspaces() { + Err(refused) => return refuse_listing(&refused), + Ok(listed) => listed, + }; + let is_listed = |id: &str| listed.iter().any(|workspace| workspace.id == id); + let mut opening = render::Saying; + let mut cold = ColdPath::new(runner, &mut opening); + let mut ids = if is_listed(&label) { + vec![label.clone()] + } else { + match cold.records() { + Err(refused) => return refuse_startup(&refused), + Ok(records) => devlaunch_core::flows::launch::recorded_ids_labelled( + &records.storage, + &label, + is_listed, + ), + } + }; + let workspace_id = match ids.len() { + 0 => { + eprintln!("{}", render::rmt_names_nothing(&label)); + return Ending::Refused; + } + 1 => ids.remove(0), + _ => { + eprintln!("{}", render::rmt_names_several(&label, &ids)); + return Ending::Refused; + } + }; + eprintln!("{}", render::rmt_removing(&label, &workspace_id)); + remove_addressed( + context, + cache, + refresh, + &mut cold, + &workspace_id, + &workspace_id, + removal, + crate::cli::REMOVE_THIS_TAB, + ) +} + /// The guard and the delete, for a workspace something else has already named. /// /// Split from [`render_remove`] for one caller, and the split is exactly where it diff --git a/rust/dl/src/hangup.rs b/rust/dl/src/hangup.rs index 29772923..029eafca 100644 --- a/rust/dl/src/hangup.rs +++ b/rust/dl/src/hangup.rs @@ -47,7 +47,9 @@ //! there is. A `nohup` is the one form `dl` refuses outright, above. //! //! Nothing here is reachable from `rm`: [`crate::cli::AfterRemoval`] is the only -//! way in, and the grammar builds `HangUpTheShell` for one word. +//! way in, and two words reach `HangUpTheShell`. The grammar builds it for `rme`, +//! and the dispatcher passes it for `rmt`, which is `rme` with the herdr tab as its +//! target and so ends the same way. use std::io::Write as _; diff --git a/rust/dl/src/lib.rs b/rust/dl/src/lib.rs index dc46623b..7b1263b5 100644 --- a/rust/dl/src/lib.rs +++ b/rust/dl/src/lib.rs @@ -702,6 +702,11 @@ fn grammar_refusal(refused: &cli::GrammarError) -> String { 'dl rm' to delete one now." .to_owned() } + cli::GrammarError::TabTakesNoWorkspace => format!( + "{} takes no workspace: it removes the workspace this herdr tab is named after. \ + Use 'dl rm' to name one.", + cli::REMOVE_THIS_TAB + ), cli::GrammarError::TargetNotAllowed { command } => { format!("{command} takes no workspace: it is not a workspace command.") } diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 1c1030b9..036954eb 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -1730,6 +1730,46 @@ pub(crate) fn id_collision(workspace_id: &str, spec: &str, held_by: &str) -> Str ) } +// --------------------------------------------------------------------------- +// dl rmt +// --------------------------------------------------------------------------- + +pub(crate) fn rmt_outside_herdr() -> String { + "dl rmt removes the workspace a herdr tab is named after, and this shell is not in a \ + herdr tab (HERDR_TAB_ID is not set). Use 'dl rm' or 'dl rm'." + .to_owned() +} + +pub(crate) fn rmt_tab_unanswered(tab_id: &str) -> String { + format!( + "herdr did not say what tab {tab_id} is called, so dl rmt has no workspace to remove. \ + Use 'dl rm' or 'dl rm'." + ) +} + +pub(crate) fn rmt_names_nothing(label: &str) -> String { + format!( + "This tab is named '{label}', and no workspace devpod lists has that name. Use 'dl \ + rm' or 'dl rm'." + ) +} + +/// The ids and not a picker: the person wanted to skip the picker, and any one of +/// these is a line to type. +pub(crate) fn rmt_names_several(label: &str, workspace_ids: &[String]) -> String { + format!( + "This tab is named '{label}', and {} workspaces have that name: {}. Use 'dl \ + rm' with one of them.", + workspace_ids.len(), + workspace_ids.join(", ") + ) +} + +/// Said before anything is removed, so the id this tab led to is on screen first. +pub(crate) fn rmt_removing(label: &str, workspace_id: &str) -> String { + format!("This tab is named '{label}': removing workspace {workspace_id}.") +} + pub(crate) fn unknown_workspace(target: &str) -> String { format!( "Unknown workspace '{target}'. Use 'dl --ls' to list workspaces, or specify owner/repo or \ diff --git a/rust/dl/tests/lifecycle.rs b/rust/dl/tests/lifecycle.rs index b71ace98..be505d4d 100644 --- a/rust/dl/tests/lifecycle.rs +++ b/rust/dl/tests/lifecycle.rs @@ -83,6 +83,17 @@ impl World { /// Run `dl` with `stdin` for the `[y/N]` question to read. fn answering(&self, stdin: &str, args: &[&str]) -> Run { + self.answering_with(stdin, &[], args) + } + + /// Run `dl` in a herdr pane of tab `tab_id`: `HERDR_TAB_ID` set, and the + /// `herdr` on the world's PATH is whichever [`World::herdr_names_tab`] put + /// there. + fn dl_in_tab(&self, tab_id: &str, args: &[&str]) -> Run { + self.answering_with("", &[("HERDR_TAB_ID", tab_id)], args) + } + + fn answering_with(&self, stdin: &str, env: &[(&str, &str)], args: &[&str]) -> Run { use std::io::Write as _; use std::process::Stdio; @@ -108,6 +119,7 @@ impl World { .env("GIT_SSH_COMMAND", "false") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null") + .envs(env.iter().copied()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -123,6 +135,23 @@ impl World { Run::of(&output, &self.root) } + /// Put a `herdr` on the world's PATH whose `tab get` answers with `label`, in + /// the `tab_info` envelope herdr's API schema publishes. It logs each argv to + /// `herdr-log`, one line per call. + fn herdr_names_tab(&self, label: &str) { + let root = self.root.display(); + let script = format!( + "#!/bin/sh\necho \"$*\" >> '{root}/herdr-log'\n[ \"$1 $2\" = 'tab get' ] || exit 2\n\ + printf '%s' '{{\"id\":\"cli:tab:get\",\"result\":{{\"type\":\"tab_info\",\"tab\":\ + {{\"tab_id\":\"'\"$3\"'\",\"workspace_id\":\"w1\",\"number\":4,\"label\":\"{label}\",\ + \"focused\":true,\"pane_count\":1,\"agent_status\":\"none\"}}}}}}'\n" + ); + let path = self.root.join("bin/herdr"); + std::fs::write(&path, script).expect("the scratch bin is writable"); + std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o755)) + .expect("the fake herdr is executable"); + } + /// Run `dl` from inside a shell, and report what became of *the shell*. /// /// **The one thing `dl()` above cannot be asked, and it is a safety property @@ -955,6 +984,136 @@ fn a_clean_clone_is_deleted_with_its_workspace() { ); } +// =========================================================================== +// dl rmt +// =========================================================================== + +#[test] +fn rmt_removes_the_workspace_its_herdr_tab_is_named_after() { + // Not a Python golden: Python had no herdr tab to read. The launch named this + // tab after the record (`devlaunch@main`), the session has closed, and the + // shell left in the tab asks for its workspace to go. + let world = World::base(); + world.herdr_names_tab("devlaunch@main"); + let clone = "cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-legacy"; + assert!(world.exists(clone), "the fixture's clean clone"); + + // In a shell and not through `dl()`: `rmt` hangs up its parent as `rme` does, + // and through `dl()` the parent is the test binary. + let hung = world.shell_run("export HERDR_TAB_ID=w1:t7; ", &["rmt"]); + + // The tab goes too: the shell it was typed in is hung up, as `rme` hangs it up. + hung.was_hung_up(); + assert_eq!(world.read("herdr-log"), "tab get w1:t7\n"); + assert!( + hung.err.starts_with( + "This tab is named 'devlaunch@main': removing workspace devlaunch-main-legacy.\n\ + Removing workspace devlaunch-main-legacy...\n" + ), + "the id goes on screen before anything is removed: {}", + hung.err + ); + assert!( + hung.err.ends_with(&format!( + "Hanging up the shell dl was called from (pid {}).\n", + hung.shell + )), + "the hangup is the last thing said, once the workspace has gone: {}", + hung.err + ); + assert!(!world.exists(clone), "the clone was left behind"); + assert_eq!( + world.devpod_calls(), + [ + "devpod list --output json", + "devpod delete devlaunch-main-legacy", + ] + ); +} + +#[test] +fn rmt_takes_a_tab_named_with_the_workspace_id_itself() { + // A workspace dl holds no record for is launched under its id, and the tab is + // named with the id, so an id devpod lists is an answer without the records. + let world = World::base(); + world.herdr_names_tab("devlaunch-main-legacy"); + let hung = world.shell_run("export HERDR_TAB_ID=w1:t7; ", &["rmt"]); + hung.was_hung_up(); + assert!( + hung.err.starts_with( + "This tab is named 'devlaunch-main-legacy': removing workspace devlaunch-main-legacy.\n" + ), + "{}", + hung.err + ); +} + +#[test] +fn rmt_in_a_tab_nobody_named_removes_nothing() { + // herdr falls back to the tab number, which names no workspace. The record of a + // workspace devpod has forgotten (`other@feature/x`) is refused the same way. + for label in ["4", "other@feature/x"] { + let world = World::base(); + world.herdr_names_tab(label); + let run = world.dl_in_tab("w1:t7", &["rmt"]); + run.exited(1); + assert_eq!( + run.err, + format!( + "This tab is named '{label}', and no workspace devpod lists has that name. Use \ + 'dl rm' or 'dl rm'.\n" + ) + ); + assert!( + !world + .devpod_calls() + .iter() + .any(|call| call.contains("delete")), + "{:?}", + world.devpod_calls() + ); + } +} + +#[test] +fn rmt_outside_herdr_asks_nobody_and_removes_nothing() { + let world = World::base(); + let run = world.dl(&["rmt"]); + run.exited(1); + assert_eq!( + run.err, + "dl rmt removes the workspace a herdr tab is named after, and this shell is not in a \ + herdr tab (HERDR_TAB_ID is not set). Use 'dl rm' or 'dl rm'.\n" + ); + assert!( + world.devpod_calls().is_empty(), + "{:?}", + world.devpod_calls() + ); +} + +#[test] +fn rmt_keeps_the_guard_rm_has() { + // `rmt` is `rm` with the tab as the target, so unsaved work stops it as it + // stops `rm`. The `unpushed` fixture's tab name is `devlaunch@unpushed`. + // In a shell, so a guard that let the removal through would show as a hangup + // here rather than as a hung-up test binary. A refusal keeps the shell and the + // reason on screen, as it does for `rme`. + let world = World::with(&["unpushed"]); + world.herdr_names_tab("devlaunch@unpushed"); + let hung = world.shell_run("export HERDR_TAB_ID=w1:t7; ", &["rmt"]); + hung.survived_with(1); + assert!( + !world + .devpod_calls() + .iter() + .any(|call| call.contains("delete")), + "{:?}\n{}", + world.devpod_calls(), + hung.err + ); +} + /// devlaunch#325 at the binary boundary: the two volumes go with the workspace, /// and it is the *binary* that has to hand core the devpod home they are named /// from — a `None` there would leave every core test passing and every volume on From 69693e46dbccf288283b9e7d9418ab122816e52c Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Fri, 16 Oct 2026 16:24:44 +0000 Subject: [PATCH 2/6] fix: a refused dl rmt told you to run dl rmt --force, which the grammar refuses The guard's way past is now the command as it was typed, so rmt is told dl rmt --force and rm, rme and kill print the line they did before. Claude-Session: https://claude.ai/code/session_018kqBWJQXnHEACr4nBxAMKF --- rust/dl/src/commands.rs | 10 ++++----- rust/dl/src/render.rs | 25 ++++++++++++----------- rust/dl/tests/lifecycle.rs | 42 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index 4cfad534..319f7770 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -1079,7 +1079,7 @@ fn render_kill<'r>( refresh, cold, &workspace_id, - target, + &format!("{target} {word}"), Removal::Wedged, word, ) @@ -1108,7 +1108,7 @@ fn render_remove<'r>( refresh, cold, &addressed.workspace_id, - target, + &format!("{target} {word}"), removal, word, ) @@ -1188,7 +1188,7 @@ fn render_remove_this_tab<'r>( refresh, &mut cold, &workspace_id, - &workspace_id, + crate::cli::REMOVE_THIS_TAB, removal, crate::cli::REMOVE_THIS_TAB, ) @@ -1210,7 +1210,7 @@ fn remove_addressed<'r>( refresh: &mut Refresh<'_>, cold: &mut ColdPath<'r, '_>, workspace_id: &str, - target: &str, + typed: &str, removal: Removal, word: &str, ) -> Ending { @@ -1273,7 +1273,7 @@ fn remove_addressed<'r>( // `rm --force` never looks and `kill` says it and goes on, both of which are // decided inside the removal now rather than sequenced out here. Ok(RemoveOutcome::Refused(refusal)) => { - eprintln!("{}", render::removal_refusal(&refusal, target, word)); + eprintln!("{}", render::removal_refusal(&refusal, typed)); Ending::Refused } Ok(RemoveOutcome::DevpodRefused { exit }) => { diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 036954eb..47247ec3 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -1434,22 +1434,25 @@ pub(crate) fn unsafe_name(refused: &UnsafeName) -> String { /// Why `dl rm` will not delete this workspace, and the way past it. /// -/// `spec` is the target *as the user typed it*, because the sentence ends in a -/// command they can run: a refusal that echoed the resolved id would print a line -/// that works, but not the line they typed. +/// `typed` is the command *as the user typed it*, after the `dl` and before any +/// `--force`, because the sentence ends in a command they can run: a refusal that +/// echoed the resolved id would print a line that works, but not the line they +/// typed. /// -/// `word` is the verb, for the same reason and one more. `rm` and `rme` refuse +/// The verb is in it for the same reason and one more. `rm` and `rme` refuse /// identically, and the way past is `--force` on whichever was typed — so a line /// that always said `rm --force` would answer an `rme` by sending the reader back /// to the two-step `rme` exists to collapse: delete, wait, then close the tab by -/// hand. Both words are offered the way past *they* asked for. +/// hand. Both words are offered the way past *they* asked for. `rmt` takes no +/// workspace, so its way past is `dl rmt --force` and not the id with the verb +/// after it, which the grammar refuses. /// /// Where the remote could not be asked (devlaunch#638), a sentence says so /// between the finding and the way past, because it changes what the finding /// means: the count is as of the clone's last fetch, and some of it may already be /// on the remote. A refusal the remote was asked about, or was never going to be, /// reads exactly as it did before there was a fetch. -pub(crate) fn removal_refusal(refused: &RemovalRefused, spec: &str, word: &str) -> String { +pub(crate) fn removal_refusal(refused: &RemovalRefused, typed: &str) -> String { let workspace_id = &refused.workspace_id; let (finding, way_past) = match &refused.because { RemovalGrounds::WouldLose(holds) => ( @@ -1490,7 +1493,7 @@ pub(crate) fn removal_refusal(refused: &RemovalRefused, spec: &str, word: &str) .unwrap_or("no reason given") ), }; - format!("{finding}{remote} {way_past}, or run: dl {spec} {word} --force") + format!("{finding}{remote} {way_past}, or run: dl {typed} --force") } /// `--rm`, at the moment the session has ended and the removal begins. @@ -6307,7 +6310,7 @@ mod tests { fn a_remote_asked_or_never_asked_leaves_the_refusal_as_it_was() { for remote in [RemoteCheck::NotAsked, RemoteCheck::Fetched] { assert_eq!( - removal_refusal(&refused_over_unpushed(remote), "devlaunch-main", "rm"), + removal_refusal(&refused_over_unpushed(remote), "devlaunch-main rm"), "devlaunch-main holds 1 unpushed commit(s). Push or commit it, or run: dl \ devlaunch-main rm --force" ); @@ -6320,8 +6323,7 @@ mod tests { &refused_over_unpushed(RemoteCheck::Unreachable { reason: "\n \nfatal: x\nmore".to_owned(), }), - "devlaunch-main", - "rm", + "devlaunch-main rm", ); assert!( refusal.contains(" could not reach the remote to check (fatal: x), so "), @@ -6335,8 +6337,7 @@ mod tests { &refused_over_unpushed(RemoteCheck::Unreachable { reason: String::new(), }), - "devlaunch-main", - "rm", + "devlaunch-main rm", ); assert!( refusal.contains(" could not reach the remote to check (no reason given), so "), diff --git a/rust/dl/tests/lifecycle.rs b/rust/dl/tests/lifecycle.rs index be505d4d..04d527d3 100644 --- a/rust/dl/tests/lifecycle.rs +++ b/rust/dl/tests/lifecycle.rs @@ -1101,8 +1101,25 @@ fn rmt_keeps_the_guard_rm_has() { // reason on screen, as it does for `rme`. let world = World::with(&["unpushed"]); world.herdr_names_tab("devlaunch@unpushed"); + let clone = "cache/devlaunch/repos/blooop/devlaunch/devlaunch-unpushed-committed"; let hung = world.shell_run("export HERDR_TAB_ID=w1:t7; ", &["rmt"]); hung.survived_with(1); + assert!( + hung.err.starts_with( + "This tab is named 'devlaunch@unpushed': removing workspace \ + devlaunch-unpushed-committed.\n" + ), + "{}", + hung.err + ); + assert!( + hung.err.ends_with( + "devlaunch-unpushed-committed holds 1 unpushed commit(s). Push or commit it, or run: \ + dl rmt --force\n" + ), + "the way past is a line the grammar takes, and it is still rmt: {}", + hung.err + ); assert!( !world .devpod_calls() @@ -1112,6 +1129,31 @@ fn rmt_keeps_the_guard_rm_has() { world.devpod_calls(), hung.err ); + assert!(world.exists(clone), "the refused removal took the clone"); +} + +#[test] +fn rmt_force_is_the_way_past_the_guard() { + // The line the refusal above ends on, typed in the same tab. + let world = World::with(&["unpushed"]); + world.herdr_names_tab("devlaunch@unpushed"); + let clone = "cache/devlaunch/repos/blooop/devlaunch/devlaunch-unpushed-committed"; + let hung = world.shell_run("export HERDR_TAB_ID=w1:t7; ", &["rmt", "--force"]); + hung.was_hung_up(); + assert!( + world + .devpod_calls() + .iter() + .any(|call| call.starts_with("devpod delete devlaunch-unpushed-committed")), + "{:?}\n{}", + world.devpod_calls(), + hung.err + ); + assert!( + !world.exists(clone), + "the clone was left behind: {}", + hung.err + ); } /// devlaunch#325 at the binary boundary: the two volumes go with the workspace, From cfdc9ac29fbee5c571623b7734d59bbf07ccfcff Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Fri, 16 Oct 2026 16:25:07 +0000 Subject: [PATCH 3/6] fix: dl rmt said it was removing a workspace before the guard had decided, so a refusal opened with a removal that never happened Claude-Session: https://claude.ai/code/session_018kqBWJQXnHEACr4nBxAMKF --- docs/cli.md | 2 +- rust/dl/src/render.rs | 5 +++-- rust/dl/tests/lifecycle.rs | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 66810df3..a93a5677 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -509,7 +509,7 @@ tab's name (`herdr tab get $HERDR_TAB_ID`) and deletes that workspace: ``` $ dl rmt -This tab is named 'devlaunch@main': removing workspace devlaunch-main-3j1t. +This tab is named 'devlaunch@main', which is workspace devlaunch-main-3j1t. Removing workspace devlaunch-main-3j1t... ... Hanging up the shell dl was called from (pid 4242). diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 47247ec3..badcb1d7 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -1768,9 +1768,10 @@ pub(crate) fn rmt_names_several(label: &str, workspace_ids: &[String]) -> String ) } -/// Said before anything is removed, so the id this tab led to is on screen first. +/// Said before the guard decides, so the id this tab led to is on screen first and +/// the line is true whether the removal goes ahead or is refused. pub(crate) fn rmt_removing(label: &str, workspace_id: &str) -> String { - format!("This tab is named '{label}': removing workspace {workspace_id}.") + format!("This tab is named '{label}', which is workspace {workspace_id}.") } pub(crate) fn unknown_workspace(target: &str) -> String { diff --git a/rust/dl/tests/lifecycle.rs b/rust/dl/tests/lifecycle.rs index 04d527d3..93e77439 100644 --- a/rust/dl/tests/lifecycle.rs +++ b/rust/dl/tests/lifecycle.rs @@ -1007,7 +1007,7 @@ fn rmt_removes_the_workspace_its_herdr_tab_is_named_after() { assert_eq!(world.read("herdr-log"), "tab get w1:t7\n"); assert!( hung.err.starts_with( - "This tab is named 'devlaunch@main': removing workspace devlaunch-main-legacy.\n\ + "This tab is named 'devlaunch@main', which is workspace devlaunch-main-legacy.\n\ Removing workspace devlaunch-main-legacy...\n" ), "the id goes on screen before anything is removed: {}", @@ -1041,7 +1041,7 @@ fn rmt_takes_a_tab_named_with_the_workspace_id_itself() { hung.was_hung_up(); assert!( hung.err.starts_with( - "This tab is named 'devlaunch-main-legacy': removing workspace devlaunch-main-legacy.\n" + "This tab is named 'devlaunch-main-legacy', which is workspace devlaunch-main-legacy.\n" ), "{}", hung.err @@ -1106,7 +1106,7 @@ fn rmt_keeps_the_guard_rm_has() { hung.survived_with(1); assert!( hung.err.starts_with( - "This tab is named 'devlaunch@unpushed': removing workspace \ + "This tab is named 'devlaunch@unpushed', which is workspace \ devlaunch-unpushed-committed.\n" ), "{}", From 849687055c928c9fc5774c875822fab978424206 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Fri, 16 Oct 2026 16:24:02 +0000 Subject: [PATCH 4/6] fix: nohup dl rmt said rme: about a command that was rmt The hangup's own lines (SIGHUP already ignored, no parent to hang up, kill refused) hard-coded 'rme:'. after_the_command now takes the word the line used: verb.word() for rm/rme, cli::REMOVE_THIS_TAB for rmt. Claude-Session: https://claude.ai/code/session_018kqBWJQXnHEACr4nBxAMKF --- rust/dl/src/commands.rs | 7 +++++-- rust/dl/src/hangup.rs | 12 ++++++------ rust/dl/src/render.rs | 21 ++++++++++++--------- rust/dl/tests/lifecycle.rs | 25 +++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index 319f7770..fc544fb5 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -142,6 +142,7 @@ pub(crate) fn dispatch( // Only after a removal that worked; see [`crate::hangup`]. Command::RemoveThisTab { force } => hangup::after_the_command( AfterRemoval::HangUpTheShell, + cli::REMOVE_THIS_TAB, render_remove_this_tab(runner, &mut context, cache, refresh, removal_of(force)), ), Command::HerdrEnv { action, workspace } => { @@ -193,6 +194,7 @@ pub(crate) fn dispatch( claude_profile, } => { let after = verb.after_removal(); + let word = verb.word(); let ending = render_select( runner, &mut context, @@ -202,7 +204,7 @@ pub(crate) fn dispatch( devcontainer.as_ref(), claude_profile.as_deref(), ); - hangup::after_the_command(after, ending) + hangup::after_the_command(after, word, ending) } Command::Workspace { target, @@ -225,6 +227,7 @@ pub(crate) fn dispatch( crate::herdr_editor::start(agent); } let after = verb.after_removal(); + let word = verb.word(); let ending = render_workspace( runner, &mut context, @@ -239,7 +242,7 @@ pub(crate) fn dispatch( None, resume, ); - hangup::after_the_command(after, ending) + hangup::after_the_command(after, word, ending) } } } diff --git a/rust/dl/src/hangup.rs b/rust/dl/src/hangup.rs index 029eafca..8e279eac 100644 --- a/rust/dl/src/hangup.rs +++ b/rust/dl/src/hangup.rs @@ -86,15 +86,15 @@ pub(crate) fn wanted(after: AfterRemoval, ending: Ending) -> bool { /// worked, as `rm` does, because the removal is what the exit code is *about* — and /// on the run this is for nobody reads it anyway, the shell that would have being /// the one that just went. -pub(crate) fn after_the_command(after: AfterRemoval, ending: Ending) -> Ending { +pub(crate) fn after_the_command(after: AfterRemoval, word: &str, ending: Ending) -> Ending { if wanted(after, ending) { - hang_up_the_parent(); + hang_up_the_parent(word); } ending } /// SIGHUP to whatever started `dl`. -fn hang_up_the_parent() { +fn hang_up_the_parent(word: &str) { // Something disarmed SIGHUP before this run started, which is what `nohup dl …` // does — and a shell runs that by `exec`ing dl in place, so the parent about to // be signalled is the terminal `nohup` was typed to outlive. `dl` already @@ -102,7 +102,7 @@ fn hang_up_the_parent() { // disarmed for the whole run (`install_signal_handlers`); sending the one it // refuses to act on would be the same process arguing both sides. if crate::sighup_arrived_ignored() { - eprintln!("{}", render::hangup_disarmed()); + eprintln!("{}", render::hangup_disarmed(word)); return; } // SAFETY: `getppid` reads a property of this process and touches nothing. @@ -112,7 +112,7 @@ fn hang_up_the_parent() { // pid 1 is init and a root `dl` would be asking it to shut the machine's // service manager down. if parent <= 1 { - eprintln!("{}", render::nothing_to_hang_up()); + eprintln!("{}", render::nothing_to_hang_up(word)); return; } eprintln!("{}", render::hanging_up(parent)); @@ -140,7 +140,7 @@ fn hang_up_the_parent() { // only thing that says why. eprintln!( "{}", - render::could_not_hang_up(parent, &std::io::Error::last_os_error().to_string()) + render::could_not_hang_up(word, parent, &std::io::Error::last_os_error().to_string()) ); } } diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index badcb1d7..465faf20 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -1647,9 +1647,11 @@ pub(crate) fn hanging_up(parent: i32) -> String { /// as the cause, because it is not the only one: `setsid` sets no `SIG_IGN` and does /// not reach this, but a wrapper script or a supervisor that disarmed the signal /// does, and a sentence naming the wrong tool is worse than one naming none. -pub(crate) fn hangup_disarmed() -> String { - "rme: SIGHUP was already ignored when dl started, so the shell stays. The removal is done." - .to_owned() +pub(crate) fn hangup_disarmed(word: &str) -> String { + format!( + "{word}: SIGHUP was already ignored when dl started, so the shell stays. The removal is \ + done." + ) } /// `rme` on a run whose parent has already gone: the removal happened, the hangup @@ -1658,10 +1660,11 @@ pub(crate) fn hangup_disarmed() -> String { /// Says the removal is done, because this is the one `rme` line printed *instead* /// of the signal rather than beside it, and a sentence that only said what did not /// happen would read like a failed delete. -pub(crate) fn nothing_to_hang_up() -> String { - "rme: dl's parent process has already gone, so there is no shell to hang up. The removal is \ - done." - .to_owned() +pub(crate) fn nothing_to_hang_up(word: &str) -> String { + format!( + "{word}: dl's parent process has already gone, so there is no shell to hang up. The \ + removal is done." + ) } /// `rme` whose signal was refused: a parent that exited in the meantime, or one @@ -1670,8 +1673,8 @@ pub(crate) fn nothing_to_hang_up() -> String { /// The removal is named for [`nothing_to_hang_up`]'s reason, and the OS error is /// quoted because the two causes are told apart by nothing else — a terminal left /// standing looks the same either way. -pub(crate) fn could_not_hang_up(parent: i32, why: &str) -> String { - format!("rme: could not hang up pid {parent} ({why}). The removal is done.") +pub(crate) fn could_not_hang_up(word: &str, parent: i32, why: &str) -> String { + format!("{word}: could not hang up pid {parent} ({why}). The removal is done.") } /// Whether the delete that was refused had `kill`'s sweep standing in front of it. diff --git a/rust/dl/tests/lifecycle.rs b/rust/dl/tests/lifecycle.rs index 93e77439..601e2642 100644 --- a/rust/dl/tests/lifecycle.rs +++ b/rust/dl/tests/lifecycle.rs @@ -1031,6 +1031,31 @@ fn rmt_removes_the_workspace_its_herdr_tab_is_named_after() { ); } +#[test] +fn rmt_under_nohup_names_rmt_and_leaves_the_terminal() { + let world = World::base(); + world.herdr_names_tab("devlaunch@main"); + let clone = "cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-legacy"; + + let ran = world.shell_run("trap \"\" HUP; export HERDR_TAB_ID=w1:t7; ", &["rmt"]); + + ran.survived_with(0); + assert!(!world.exists(clone), "the removal did not happen"); + assert!( + ran.err.contains( + "rmt: SIGHUP was already ignored when dl started, so the shell stays. The removal is \ + done." + ), + "the line names a verb other than the one typed: {}", + ran.err + ); + assert!( + !ran.err.contains("Hanging up"), + "dl sent the signal it had itself been told to ignore: {}", + ran.err + ); +} + #[test] fn rmt_takes_a_tab_named_with_the_workspace_id_itself() { // A workspace dl holds no record for is launched under its id, and the tab is From bc27a2a47a4bfeba1d9430fff5a0994b0e3bfaea Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Fri, 16 Oct 2026 16:24:02 +0000 Subject: [PATCH 5/6] docs: comments still said only rme hangs up and that stop reads tab names Claude-Session: https://claude.ai/code/session_018kqBWJQXnHEACr4nBxAMKF --- rust/devlaunch-core/src/flows/launch.rs | 8 ++++---- rust/dl/src/cli.rs | 12 ++++++------ rust/dl/src/hangup.rs | 6 ++++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index 110ebc6b..5dbb4d7c 100644 --- a/rust/devlaunch-core/src/flows/launch.rs +++ b/rust/devlaunch-core/src/flows/launch.rs @@ -4334,8 +4334,8 @@ fn label_in(records: &MetadataStorage, workspace_id: &str) -> Option { /// [`label_in`] the other way round. A launch names the herdr tab and the pane /// after the record (`devlaunch@main`), and that name outlives the session: the /// tab is closed, the container is still running, and the name is the one thing -/// the person still has in their head. `dl devlaunch@main stop` is what they type, -/// and this is how a lifecycle verb finds the workspace behind it. +/// the person still has in their head. `dl rmt` typed in that tab reads the name, +/// and this is how it finds the workspace behind it. /// /// **Only ids `listed` admits come back**, and which of a record's two ids is /// asked first is [`stored_as`]'s order reversed on purpose: `devpod_workspace_id` @@ -4347,7 +4347,7 @@ fn label_in(records: &MetadataStorage, workspace_id: &str) -> Option { /// **More than one answer is an answer.** Names need not be unique: two owners' /// forks of one repo on one branch carry the same label, and so do two records of /// one triple under different id schemes. The caller refuses rather than picks, -/// because the verbs this serves include `rm`. +/// because the verb this serves, `rmt`, deletes. #[must_use] pub fn recorded_ids_labelled( records: &MetadataStorage, @@ -12555,7 +12555,7 @@ mod tests { #[test] fn a_tab_name_reads_back_to_the_listed_workspaces_that_carry_it() { // The tab says `devlaunch@main` long after the session that named it has - // gone, and that name is what `dl devlaunch@main stop` is typed with. The + // gone, and that name is what `dl rmt` reads from the tab. The // record that names the tab is the record that answers, and only for ids // devpod still lists: a stale record must not become an id to act on. let scene = Scene::new(); diff --git a/rust/dl/src/cli.rs b/rust/dl/src/cli.rs index ffab079d..0d8c8fcc 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -213,9 +213,9 @@ pub(crate) enum RmOnExit { /// What becomes of the shell that asked, once the removal is over. /// /// A named pair rather than a bool, and it rides on [`Verb::Remove`] rather than -/// beside it, because there is exactly one verb it is defined for: `rme` is `rm` -/// and then the shell, and every other verb hands the shell back the way it found -/// it. +/// beside it, because it is defined for the removal alone: `rme` is `rm` and then +/// the shell, `rmt` ends the same way, and every other verb hands the shell back +/// the way it found it. /// /// **This is a third thing, and not a third spelling of the two above.** /// [`Verb::Remove`] deletes the workspace now, [`RmOnExit`] deletes it when a @@ -227,9 +227,9 @@ pub(crate) enum RmOnExit { pub(crate) enum AfterRemoval { /// `rm`: nothing. `dl` exits and the shell it was called from carries on. LeaveTheShell, - /// `rme`: SIGHUP to the process that started `dl`, so an interactive shell - /// ends and the terminal it was sitting in goes with it. Only after a removal - /// that worked; see [`crate::hangup`], which owns both halves of that. + /// `rme`, and `rmt` through the dispatcher: SIGHUP to the process that + /// started `dl`, so an interactive shell ends and the terminal it was sitting + /// in goes with it. Only after a removal that worked; see [`crate::hangup`], which owns both halves of that. HangUpTheShell, } diff --git a/rust/dl/src/hangup.rs b/rust/dl/src/hangup.rs index 8e279eac..6562a22b 100644 --- a/rust/dl/src/hangup.rs +++ b/rust/dl/src/hangup.rs @@ -1,6 +1,8 @@ -//! `rme`: hanging up the shell `dl` was called from, once the removal is done. +//! `rme` and `rmt`: hanging up the shell `dl` was called from, once the removal +//! is done. //! -//! One verb reaches this module. `dl rme` deletes the workspace exactly as +//! Two words reach this module, and `rmt` is `rme` with the herdr tab as its +//! target, so `rme` is the one described here. `dl rme` deletes the workspace exactly as //! `dl rm` does, and then sends SIGHUP to `dl`'s parent process — which for //! the line it exists for is an interactive shell, so the shell ends and the //! terminal tab it was sitting in closes on its own. The workspace tab has nothing From 1516aa7abbd4b5a60c6f2427422503d98848cb09 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Fri, 16 Oct 2026 16:25:45 +0000 Subject: [PATCH 6/6] test: dl rmt refuses a tab name two listed workspaces share Also pins the stale-record refusal against a record the listing really rejects (the orphan fixture), the unanswered tab, the unreadable listing, the delete the id-named tab ends in, and rmt --claude-profile. Claude-Session: https://claude.ai/code/session_018kqBWJQXnHEACr4nBxAMKF --- rust/dl/src/cli.rs | 4 + rust/dl/tests/lifecycle.rs | 125 +++++++++++++++++++++++----- rust/dl/tests/lifecycle_scenario.py | 15 +++- 3 files changed, 119 insertions(+), 25 deletions(-) diff --git a/rust/dl/src/cli.rs b/rust/dl/src/cli.rs index 0d8c8fcc..064d787f 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -1578,6 +1578,10 @@ mod tests { parse(&["rmt", "--devcontainer", "robot"]), Err(GrammarError::DevcontainerNotAllowed { command: "rmt" }) ); + assert_eq!( + parse(&["rmt", "--claude-profile", "p"]), + Err(GrammarError::ClaudeProfileNotAllowed { command: "rmt" }) + ); } // ============================================================ rme: rm, and out diff --git a/rust/dl/tests/lifecycle.rs b/rust/dl/tests/lifecycle.rs index 601e2642..1afbb509 100644 --- a/rust/dl/tests/lifecycle.rs +++ b/rust/dl/tests/lifecycle.rs @@ -1071,33 +1071,112 @@ fn rmt_takes_a_tab_named_with_the_workspace_id_itself() { "{}", hung.err ); + assert_eq!( + world.devpod_calls().last().map(String::as_str), + Some("devpod delete devlaunch-main-legacy"), + "{:?}", + world.devpod_calls() + ); } #[test] fn rmt_in_a_tab_nobody_named_removes_nothing() { - // herdr falls back to the tab number, which names no workspace. The record of a - // workspace devpod has forgotten (`other@feature/x`) is refused the same way. - for label in ["4", "other@feature/x"] { - let world = World::base(); - world.herdr_names_tab(label); - let run = world.dl_in_tab("w1:t7", &["rmt"]); - run.exited(1); - assert_eq!( - run.err, - format!( - "This tab is named '{label}', and no workspace devpod lists has that name. Use \ - 'dl rm' or 'dl rm'.\n" - ) - ); - assert!( - !world - .devpod_calls() - .iter() - .any(|call| call.contains("delete")), - "{:?}", - world.devpod_calls() - ); - } + // herdr falls back to the tab number, which names no workspace. + let world = World::base(); + world.herdr_names_tab("4"); + let run = world.dl_in_tab("w1:t7", &["rmt"]); + run.exited(1); + assert_eq!( + run.err, + "This tab is named '4', and no workspace devpod lists has that name. Use 'dl \ + rm' or 'dl rm'.\n" + ); + assert!( + !world + .devpod_calls() + .iter() + .any(|call| call.contains("delete")), + "{:?}", + world.devpod_calls() + ); +} + +#[test] +fn rmt_in_a_tab_named_after_a_record_devpod_has_forgotten_removes_nothing() { + // The `orphan` fixture records `blooop/other@feature/x` under ids devpod does + // not list, so the record matches the label and the listing rejects it. + let world = World::with(&["orphan"]); + let devpod_state = world.read("shim-state.json"); + let metadata = world.read("cache/devlaunch/metadata.json"); + assert!(metadata.contains("\"other-feature-x-t0h1\""), "{metadata}"); + assert!( + !devpod_state.contains("\"other-feature-x-t0h1\""), + "{devpod_state}" + ); + world.herdr_names_tab("other@feature/x"); + let hung = world.shell_run("export HERDR_TAB_ID=w1:t7; ", &["rmt"]); + hung.survived_with(1); + assert_eq!( + hung.err, + "This tab is named 'other@feature/x', and no workspace devpod lists has that name. \ + Use 'dl rm' or 'dl rm'.\n" + ); + assert_eq!(world.devpod_calls(), ["devpod list --output json"]); +} + +#[test] +fn rmt_refuses_a_tab_name_two_listed_workspaces_share() { + // The `fork` fixture records `someone/devlaunch@main` beside + // `blooop/devlaunch@main`, and devpod lists both. + let world = World::with(&["fork"]); + world.herdr_names_tab("devlaunch@main"); + let hung = world.shell_run("export HERDR_TAB_ID=w1:t7; ", &["rmt"]); + hung.survived_with(1); + assert_eq!( + hung.err, + "This tab is named 'devlaunch@main', and 2 workspaces have that name: \ + devlaunch-main-legacy, devlaunch-main-someone. Use 'dl rm' with one of \ + them.\n" + ); + assert_eq!(world.devpod_calls(), ["devpod list --output json"]); +} + +#[test] +fn rmt_in_a_tab_herdr_cannot_find_removes_nothing() { + let world = World::base(); + let root = world.root.display(); + let script = format!( + "#!/bin/sh\necho \"$*\" >> '{root}/herdr-log'\n\ + printf '%s' '{{\"error\":{{\"code\":\"tab_not_found\"}}}}'\nexit 1\n" + ); + let path = world.path("bin/herdr"); + std::fs::write(&path, script).expect("the scratch bin is writable"); + std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o755)) + .expect("the fake herdr is executable"); + let hung = world.shell_run("export HERDR_TAB_ID=w1:t7; ", &["rmt"]); + hung.survived_with(1); + assert_eq!(world.read("herdr-log"), "tab get w1:t7\n"); + assert_eq!( + hung.err, + "herdr did not say what tab w1:t7 is called, so dl rmt has no workspace to remove. \ + Use 'dl rm' or 'dl rm'.\n" + ); + assert!( + world.devpod_calls().is_empty(), + "{:?}", + world.devpod_calls() + ); +} + +#[test] +fn rmt_that_cannot_read_the_workspace_list_removes_nothing() { + let world = World::base(); + world.herdr_names_tab("devlaunch@main"); + world.devpod_answers(&["list"], 1, "boom"); + let hung = world.shell_run("export HERDR_TAB_ID=w1:t7; ", &["rmt"]); + hung.survived_with(1); + assert_eq!(hung.err, "error: `devpod list` exited 1: 'boom'\n"); + assert_eq!(world.devpod_calls(), ["devpod list --output json"]); } #[test] diff --git a/rust/dl/tests/lifecycle_scenario.py b/rust/dl/tests/lifecycle_scenario.py index 59e76c74..094ea804 100755 --- a/rust/dl/tests/lifecycle_scenario.py +++ b/rust/dl/tests/lifecycle_scenario.py @@ -16,7 +16,7 @@ [--orphan] [--unplaceable] [--unwritable] [--no-cache] [--no-workspaces] - [--devcontainer-volumes] + [--devcontainer-volumes] [--fork] The base world, under the root it is given: @@ -95,6 +95,11 @@ UNPUSHED_LEAF = "devlaunch-unpushed-committed" UNPUSHED_WS = "devlaunch-unpushed-committed" +# --fork: a second owner's `devlaunch@main`, so one tab label names two workspaces +# devpod lists. +FORK_LEAF = "devlaunch-main-someone" +FORK_WS = "devlaunch-main-someone" + # --tagged-release: a recorded clone that is clean and fully pushed, holding a tag # the remote carries on a branch the remote no longer has. Nothing in it is # unsaved; the tag is simply the last ref reaching those commits. @@ -322,6 +327,11 @@ def build(root: pathlib.Path, shim: pathlib.Path, wanted: set) -> None: UNPUSHED_WS, {"localFolder": str(held)}, OLDER, "Stopped" ) + if "fork" in wanted: + fork = _clone(root, origin, repos / "someone" / "devlaunch" / FORK_LEAF, "main") + worktrees["someone/devlaunch/main"] = _record("someone", "devlaunch", "main", fork, FORK_WS) + workspaces[FORK_WS] = _workspace(FORK_WS, {"localFolder": str(fork)}, OLDER, "Stopped") + if "tagged-release" in wanted: # devlaunch#485. The clone tags a release, pushes branch and tag, and then # the branch goes from both sides the way a merged release branch does. The @@ -566,7 +576,7 @@ def build(root: pathlib.Path, shim: pathlib.Path, wanted: set) -> None: "[--stale-record] [--orphan] [--unplaceable] [--unwritable] " "[--no-cache] [--no-workspaces] [--not-a-clone] [--unpushed] " "[--stranded-clone] " - "[--tagged-release] " + "[--tagged-release] [--fork] " "[--sealed-cache] [--symlinked-cache] [--v1-cache] " "[--devcontainer-volumes]" ) @@ -583,6 +593,7 @@ def build(root: pathlib.Path, shim: pathlib.Path, wanted: set) -> None: "stranded-clone", "unpushed", "tagged-release", + "fork", "local-tag", "sealed-cache", "symlinked-cache",