diff --git a/docs/turn-loop.md b/docs/turn-loop.md index cd3e7ab5..52b32eb9 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -342,14 +342,6 @@ meta's. one-shot otherwise. Even self-targeted schedules go through approval (use `remind` for unapproved self-wake). Long downtime fires once per recurring row on resume (catch-up clamp). -- `edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` — - partial-update a schedule (#474/#478). Pass only the fields to - change; absent fields are left alone. `targets_add` / `targets_remove` - mutate the recipient list in the same transaction; re-adding a - previously-cancelled target drops its tombstone + history (fresh - start). Clearing a scalar (e.g. `interval_seconds: null`) flips - recurring→one-shot. Refuses cancelled rows. Same authorization as - `cancel_schedule`. - `cancel_schedule(id, targets?)` — cancel a schedule. Omit `targets` / pass empty to cancel the whole schedule; pass a list to cancel just those recipients (auto-cancels when every target diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index c7b53f9d..1c00ae6c 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -1164,18 +1164,10 @@ summary:hover { color: var(--purple); } text-indent: 0; } .live .msgrow .msg-body { - /* #485: body takes a full flex line of its own beneath the - metadata chips (ts / arrow / from / sep / to). Previously the - body sat inline with `flex: 1 1 0`, eating whatever the chips - left — which on a long timestamp + agent names + arrows meant - the body started ~30ch in and wrapped awkwardly. Pushing - `flex-basis: 100%` forces the body to wrap to its own line in - the existing `flex-wrap: wrap` row, where it can use the full - width down to the row's content edge. - `min-width: 0` still applies so `word-break: break-word` - actually kicks in instead of forcing the row wider than its - container. */ - flex: 1 1 100%; + flex: 1 1 0; + /* min-width: 0 lets the body shrink below its longest token so + `word-break: break-word` actually kicks in instead of forcing + the whole flex line wider than the container. */ min-width: 0; } .live .msgrow.sent .msg-arrow { color: var(--cyan); } diff --git a/frontend/packages/dashboard/src/flow.html b/frontend/packages/dashboard/src/flow.html index 407f3b85..195f1c14 100644 --- a/frontend/packages/dashboard/src/flow.html +++ b/frontend/packages/dashboard/src/flow.html @@ -29,10 +29,6 @@ ◆ SYST3M ◆ - - ◆ SCH3DUL3S ◆ - - diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 956f9ae9..0ff15d86 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -72,7 +72,6 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/cancel-reminder/{id}", post(post_cancel_reminder)) .route("/retry-reminder/{id}", post(post_retry_reminder)) .route("/request-spawn", post(post_request_spawn)) - .route("/api/topology/set-parent", post(post_set_parent)) .route("/op-send", post(post_op_send)) .route("/meta-update", post(post_meta_update)) .route("/api/schedules", get(api_schedules).post(post_schedule_new)) @@ -850,21 +849,6 @@ struct RequestSpawnForm { name: String, } -/// `POST /api/topology/set-parent` body. `child` is required. -/// `new_parent` may be: -/// - absent or empty / whitespace-only → promote to root, -/// - non-empty → new parent's logical name. -/// -/// (The CLI surface gates "no parent specified" behind an explicit -/// `--root` flag for safety; the HTTP surface is permissive -/// because the dashboard form encodes "no value" as the empty -/// string for the optional radio-group input.) -#[derive(Deserialize)] -struct SetParentForm { - child: String, - new_parent: Option, -} - #[derive(Deserialize)] struct AnswerForm { answer: String, @@ -1862,45 +1846,6 @@ async fn post_request_spawn( } } -/// `POST /api/topology/set-parent` — operator-driven parent move -/// (#486). Form fields: `child` (required, agent name), `new_parent` -/// (optional — empty / absent string ⇒ promote to root). Refuses -/// cycles, unknown agents, and reparenting the manager. On success -/// re-emits container snapshots so the dashboard tree repaints -/// without a refresh. -async fn post_set_parent( - State(state): State, - Form(form): Form, -) -> Response { - let child = form.child.trim().to_owned(); - if child.is_empty() { - return error_response("set-parent: `child` required"); - } - // Empty / whitespace-only `new_parent` ⇒ promote to root. Web - // forms submit the empty string for a "no value" radio button, - // so this is the ergonomic encoding. - let new_parent = form - .new_parent - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_owned); - match crate::topology::set_parent(&child, new_parent.as_deref()) { - Ok(()) => { - tracing::info!( - child = %child, - new_parent = ?new_parent, - "operator: set-parent via dashboard" - ); - // Topology drives ContainerView.parent; refresh the - // snapshot so connected viewers see the new tree. - state.coord.rescan_containers_and_emit().await; - (StatusCode::OK, "ok").into_response() - } - Err(e) => error_response(&format!("set-parent {child} failed: {e}")), - } -} - async fn post_rebuild(State(state): State, AxumPath(name): AxumPath) -> Response { let logical = strip_container_prefix(&name); state.coord.rebuild_queue.enqueue( diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 9b00cc91..3b9f0e77 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -102,23 +102,6 @@ enum Cmd { Approve { id: i64 }, /// Deny a pending request by id. Deny { id: i64 }, - /// Move an agent in the topology tree (#486). Set `--parent` to - /// a new parent agent name; pass `--root` to promote the agent - /// to root (no parent). Refuses cycles, unknown agents, and - /// any attempt to reparent the manager. - SetParent { - child: String, - /// New parent agent name. Mutually exclusive with `--root`. - /// Exactly one of `--parent` / `--root` is required — clap - /// rejects both-absent calls so a fat-fingered - /// `hive-c0re set-parent alice` doesn't silently promote - /// alice to root (argus flag on PR #492). - #[arg(long, conflicts_with = "root", required_unless_present = "root")] - parent: Option, - /// Promote `child` to root (no parent). - #[arg(long)] - root: bool, - }, } #[tokio::main] @@ -160,20 +143,6 @@ async fn main() -> Result<()> { render(client::request(&cli.socket, HostRequest::Approve { id }).await?) } Cmd::Deny { id } => render(client::request(&cli.socket, HostRequest::Deny { id }).await?), - Cmd::SetParent { - child, - parent, - root, - } => { - let new_parent = if root { None } else { parent }; - render( - client::request( - &cli.socket, - HostRequest::SetParent { child, new_parent }, - ) - .await?, - ) - } } } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 30be0364..42ccc5fb 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -185,17 +185,6 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { actions::deny(&coord, *id, None).await?; HostResponse::success() } - HostRequest::SetParent { child, new_parent } => { - tracing::info!(%child, ?new_parent, "set_parent"); - crate::topology::set_parent(child, new_parent.as_deref()) - .map_err(anyhow::Error::msg)?; - // ContainerView.parent is read from topology.json — a - // change here means every container row potentially - // moves in the dashboard tree. Rescan + diff-emit so - // open viewers repaint without polling. - coord.rescan_containers_and_emit().await; - HostResponse::success() - } }) } .await; diff --git a/hive-c0re/src/topology.rs b/hive-c0re/src/topology.rs index 7830e9a9..527f2cdb 100644 --- a/hive-c0re/src/topology.rs +++ b/hive-c0re/src/topology.rs @@ -123,72 +123,6 @@ pub fn default_seed(agent_names: &[String]) -> BTreeMap> out } -/// Pure validation + apply for [`set_parent`]. Splits off so tests -/// can exercise the rules (cycle / unknown / manager-protect) on -/// an in-memory `BTreeMap` without touching the on-disk -/// `topology.json`. Returns either the post-move map (caller -/// writes it back) or a user-readable error string. -pub fn apply_set_parent( - topo: &BTreeMap>, - child: &str, - new_parent: Option<&str>, -) -> Result>, String> { - if child == crate::lifecycle::MANAGER_NAME { - return Err("cannot reparent the manager — it is structurally root".to_owned()); - } - if !topo.contains_key(child) { - return Err(format!("unknown agent: {child}")); - } - if let Some(p) = new_parent { - if !topo.contains_key(p) { - return Err(format!("unknown parent: {p}")); - } - if p == child { - return Err("an agent cannot be its own parent".to_owned()); - } - // Cycle check: walk `p`'s ancestors in the EXISTING map. If - // we hit `child`, then making `child`'s parent = `p` would - // close the loop (child → … → p → child). - let mut cur = p.to_owned(); - for _ in 0..32 { - if cur == child { - return Err(format!( - "cycle: {p} is in {child}'s subtree (would create a loop)" - )); - } - let Some(next) = topo.get(&cur).cloned().flatten() else { - break; - }; - cur = next; - } - } - let mut next = topo.clone(); - next.insert(child.to_owned(), new_parent.map(str::to_owned)); - Ok(next) -} - -/// Operator-driven parent move (#486 / #487). Set `child`'s parent -/// to `new_parent` (or `None` to promote to root). See -/// [`apply_set_parent`] for the validation rules. The operator-set -/// parent sticks across `reconcile()` calls (which preserves -/// existing entries). -/// -/// No bind-mount / container churn today — the hierarchy is -/// currently logical-only (see #486 comment 5042). Once -/// sub-manager bind mounts land alongside #361, the caller adds -/// an umount-old / mount-new / restart-cascade step on top. -pub fn set_parent(child: &str, new_parent: Option<&str>) -> Result<(), String> { - let current = read(); - // Idempotent no-op fast path: skip the disk write when nothing - // changes. apply_set_parent still runs to surface validation - // errors (e.g. unknown child) so the caller gets a real signal. - let next = apply_set_parent(¤t, child, new_parent)?; - if next == current { - return Ok(()); - } - write(&next).map_err(|e| format!("write topology.json: {e}")) -} - /// Reconcile `topology.json` against the current agent set. Adds an /// entry (default: parent = manager, manager itself = root) for any /// agent missing from the file; removes entries for agents no longer @@ -257,85 +191,4 @@ mod tests { let seed = default_seed(&[]); assert!(seed.is_empty()); } - - fn topo_three_level() -> BTreeMap> { - let mut m = BTreeMap::new(); - m.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); - m.insert( - "alice".to_owned(), - Some(crate::lifecycle::MANAGER_NAME.to_owned()), - ); - m.insert("bob".to_owned(), Some("alice".to_owned())); - m.insert("carol".to_owned(), Some("alice".to_owned())); - m - } - - #[test] - fn apply_set_parent_promotes_to_root() { - let next = apply_set_parent(&topo_three_level(), "alice", None).unwrap(); - assert_eq!(next.get("alice"), Some(&None)); - } - - #[test] - fn apply_set_parent_reparents_under_sibling_subtree() { - // bob and carol both under alice; move carol under bob. - let next = apply_set_parent(&topo_three_level(), "carol", Some("bob")).unwrap(); - assert_eq!(next.get("carol"), Some(&Some("bob".to_owned()))); - } - - #[test] - fn apply_set_parent_refuses_manager_move() { - let err = - apply_set_parent(&topo_three_level(), crate::lifecycle::MANAGER_NAME, None).unwrap_err(); - assert!(err.contains("manager"), "err = {err}"); - } - - #[test] - fn apply_set_parent_refuses_unknown_child() { - let err = apply_set_parent(&topo_three_level(), "nobody", Some("alice")).unwrap_err(); - assert!(err.contains("unknown agent"), "err = {err}"); - } - - #[test] - fn apply_set_parent_refuses_unknown_parent() { - let err = apply_set_parent(&topo_three_level(), "bob", Some("nobody")).unwrap_err(); - assert!(err.contains("unknown parent"), "err = {err}"); - } - - #[test] - fn apply_set_parent_refuses_self() { - let err = apply_set_parent(&topo_three_level(), "alice", Some("alice")).unwrap_err(); - assert!(err.contains("own parent"), "err = {err}"); - } - - #[test] - fn apply_set_parent_refuses_cycle() { - // bob's parent is alice; trying to make alice's parent = - // bob would close the loop alice → bob → alice. - let err = apply_set_parent(&topo_three_level(), "alice", Some("bob")).unwrap_err(); - assert!(err.contains("cycle"), "err = {err}"); - } - - #[test] - fn apply_set_parent_refuses_deep_cycle() { - // Three-deep chain: manager → alice → bob → carol. Moving - // alice under carol would create the loop alice → carol → bob → alice. - let mut topo = BTreeMap::new(); - topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); - topo.insert( - "alice".to_owned(), - Some(crate::lifecycle::MANAGER_NAME.to_owned()), - ); - topo.insert("bob".to_owned(), Some("alice".to_owned())); - topo.insert("carol".to_owned(), Some("bob".to_owned())); - let err = apply_set_parent(&topo, "alice", Some("carol")).unwrap_err(); - assert!(err.contains("cycle"), "err = {err}"); - } - - #[test] - fn apply_set_parent_is_idempotent_noop() { - // bob is already under alice — same value returned. - let next = apply_set_parent(&topo_three_level(), "bob", Some("alice")).unwrap(); - assert_eq!(next, topo_three_level()); - } } diff --git a/hive-forge/src/verbs/diff.rs b/hive-forge/src/verbs/diff.rs index e35793b9..aa64c5cd 100644 --- a/hive-forge/src/verbs/diff.rs +++ b/hive-forge/src/verbs/diff.rs @@ -1,15 +1,4 @@ //! `diff [repo]` — print the unified diff for a PR. -//! -//! By default the hunks for known autogenerated lockfiles -//! (`flake.lock`, `Cargo.lock`, `package-lock.json`, …) are -//! collapsed to a `[: +N -M (autogenerated; pass --full for -//! content)]` placeholder so a `flake.lock` rev bump doesn't drown -//! the human-authored changes in 5 000 lines of lock churn -//! (#222). The header (`diff --git`, `index`, `---`, `+++`, -//! rename / mode metadata) is preserved so the reader can still -//! see WHICH lockfiles changed; the +/- counts give a `diff -//! --stat`-style magnitude (excluding the `@@` hunk header). -//! Pass `--full` to dump the unfiltered diff. use anyhow::Result; use clap::Args as ClapArgs; @@ -20,394 +9,11 @@ use crate::client::Client; pub struct Args { /// PR number. number: u64, - /// Print the unfiltered diff including autogenerated-file - /// hunks (`flake.lock`, `Cargo.lock`, etc.). Default is to - /// collapse those hunks to a placeholder so the human-authored - /// changes aren't drowned in lock churn. - #[arg(long)] - full: bool, } pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?; - let out = if args.full { - diff - } else { - collapse_autogenerated(&diff) - }; - print!("{out}"); + print!("{diff}"); Ok(()) } - -/// Walk a unified diff line-by-line. For each per-file section -/// whose target path matches a known autogenerated file -/// (`is_autogenerated`), drop every line from the first hunk -/// header (`@@`) onward and emit a single `diff --stat`-style -/// `[file.lock: +N -M (autogenerated, --full for content)]` -/// placeholder before the next file. Non-autogenerated files -/// pass through unchanged. -fn collapse_autogenerated(diff: &str) -> String { - let mut out = String::with_capacity(diff.len()); - let mut state: Option = None; - - for line in diff.lines() { - if let Some(rest) = line.strip_prefix("diff --git ") { - // New file section — flush prior collapse counter. - if let Some(s) = state.take() { - out.push_str(&s.placeholder()); - } - let path = parse_diff_git_path(rest); - if let Some(p) = path.as_deref() - && is_autogenerated(p) - { - state = Some(CollapseState::new(p.to_owned())); - } - out.push_str(line); - out.push('\n'); - continue; - } - if let Some(s) = state.as_mut() { - // First `@@` marks the boundary between file header and - // hunk content; everything from this point is - // suppressed (and tallied) while `state` is Some. - if !s.in_body && line.starts_with("@@") { - s.in_body = true; - } - if s.in_body { - // The hunk header `@@` itself counts as a body line - // for the +/− tally only via its descendant content - // lines; skip it for the counters. - if !line.starts_with("@@") { - match line.as_bytes().first() { - Some(b'+') => s.added += 1, - Some(b'-') => s.removed += 1, - _ => {} - } - } - continue; - } - } - out.push_str(line); - out.push('\n'); - } - if let Some(s) = state.take() { - out.push_str(&s.placeholder()); - } - out -} - -/// Per-file accumulator for the collapsed-hunk placeholder. Tracks -/// the file's display name plus `+`/`−` line counts (excluding the -/// `@@` hunk header), so the placeholder shows operator-meaningful -/// magnitude instead of "N lines omitted" (which mixed context + -/// added + removed indistinguishably). -struct CollapseState { - path: String, - in_body: bool, - added: u32, - removed: u32, -} - -impl CollapseState { - fn new(path: String) -> Self { - Self { - path, - in_body: false, - added: 0, - removed: 0, - } - } - - fn placeholder(&self) -> String { - format!( - "[{}: +{} -{} (autogenerated; pass --full for content)]\n", - self.path, self.added, self.removed - ) - } -} - -/// `rest` is everything after `diff --git `, e.g. `a/foo b/foo` -/// or `"a/path with space" "b/path with space"`. Return the -/// post-rename (`b/`-side) path so renames report the new path. -/// -/// Git uses C-style quoting (`\"`, `\\`, octal escapes) for paths -/// with spaces or unusual bytes — we don't unescape because we -/// only need the bytewise file name for the lockfile allowlist -/// match. We do parse the quoted-vs-unquoted form correctly so a -/// whitespace-containing path doesn't break `split_whitespace`. -fn parse_diff_git_path(rest: &str) -> Option { - if let Some(after_open) = rest.strip_prefix('"') { - // Quoted form: `"a/" "b/"`. Find the closing - // quote of the a-side (skipping `\"` escapes so a path - // containing `"` doesn't terminate early). - let mut iter = after_open.char_indices(); - let mut a_close = None; - loop { - let Some((i, c)) = iter.next() else { break }; - if c == '\\' { - // Skip the next char — it's part of the escape. - iter.next(); - continue; - } - if c == '"' { - a_close = Some(i); - break; - } - } - let a_close = a_close?; - // After the a-side closing quote, we expect `" "b/...`. - // `after_open` skipped the leading `"`, so the b-side - // starts in `after_open[a_close + 1..]` — strip leading - // space + opening quote + `b/`, then strip trailing `"`. - let after_a = after_open.get(a_close + 1..)?; - let after_a = after_a.strip_prefix(' ')?; - let b_inside = after_a.strip_prefix('"')?; - // Find b-side's closing quote with the same escape rule. - let mut iter = b_inside.char_indices(); - let mut b_close = None; - loop { - let Some((i, c)) = iter.next() else { break }; - if c == '\\' { - iter.next(); - continue; - } - if c == '"' { - b_close = Some(i); - break; - } - } - let b_close = b_close?; - let b_path = b_inside.get(..b_close)?; - Some(b_path.strip_prefix("b/").unwrap_or(b_path).to_owned()) - } else { - // Unquoted: paths have no whitespace, so `split_whitespace` - // gives exactly two tokens. - let token = rest.split_whitespace().nth(1)?; - Some(token.strip_prefix("b/").unwrap_or(token).to_owned()) - } -} - -/// File-name match against a small whitelist of well-known -/// machine-generated lockfiles. We deliberately don't pattern-match -/// extensions like `*.lock` because some real human-authored files -/// use that suffix (e.g. `keep.lock` config markers); explicit -/// listing avoids surprising the user. -fn is_autogenerated(path: &str) -> bool { - let name = path.rsplit('/').next().unwrap_or(path); - matches!( - name, - "flake.lock" - | "Cargo.lock" - | "package-lock.json" - | "pnpm-lock.yaml" - | "yarn.lock" - | "Gemfile.lock" - | "poetry.lock" - | "Pipfile.lock" - | "composer.lock" - | "go.sum" - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn detects_known_lockfiles() { - assert!(is_autogenerated("flake.lock")); - assert!(is_autogenerated("a/flake.lock")); - assert!(is_autogenerated("hive-c0re/Cargo.lock")); - assert!(is_autogenerated("frontend/packages/dashboard/package-lock.json")); - assert!(!is_autogenerated("src/main.rs")); - assert!(!is_autogenerated("Cargo.toml")); - // Suffix-only files we deliberately don't match — keep - // `is_autogenerated` allowlist-driven, not pattern-driven. - assert!(!is_autogenerated("config/keep.lock")); - } - - #[test] - fn collapse_replaces_lockfile_hunks_with_placeholder() { - let diff = "\ -diff --git a/Cargo.lock b/Cargo.lock -index 1111..2222 100644 ---- a/Cargo.lock -+++ b/Cargo.lock -@@ -1,3 +1,3 @@ - [[package]] --name = \"old\" -+name = \"new\" -diff --git a/src/main.rs b/src/main.rs -index 3333..4444 100644 ---- a/src/main.rs -+++ b/src/main.rs -@@ -1,2 +1,2 @@ --fn main() {} -+fn main() { println!(\"hi\"); } -"; - let out = collapse_autogenerated(diff); - assert!(out.contains("diff --git a/Cargo.lock")); - assert!(out.contains("--- a/Cargo.lock")); - assert!(out.contains("+++ b/Cargo.lock")); - assert!(!out.contains("[[package]]")); - // Stat-style placeholder: 1 added (`+name = "new"`), 1 - // removed (`-name = "old"`); context line and `@@` header - // don't count. - assert!( - out.contains("[Cargo.lock: +1 -1"), - "expected stat placeholder, got: {out}" - ); - // Non-lockfile file passes through untouched. - assert!(out.contains("fn main() {}")); - assert!(out.contains("println!(\"hi\")")); - } - - #[test] - fn collapse_passes_normal_diff_through() { - let diff = "\ -diff --git a/src/lib.rs b/src/lib.rs -index aaaa..bbbb 100644 ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -1,1 +1,1 @@ --fn old() {} -+fn new() {} -"; - let out = collapse_autogenerated(diff); - assert_eq!(out, diff); - } - - #[test] - fn collapse_handles_trailing_lockfile() { - // Lockfile is the LAST file in the diff — exercises the - // post-loop flush path. - let diff = "\ -diff --git a/README.md b/README.md -index aaaa..bbbb 100644 ---- a/README.md -+++ b/README.md -@@ -1,1 +1,1 @@ --old -+new -diff --git a/flake.lock b/flake.lock -index 1111..2222 100644 ---- a/flake.lock -+++ b/flake.lock -@@ -1,2 +1,2 @@ - lock-line-one --lock-line-two -+lock-line-two-bumped -"; - let out = collapse_autogenerated(diff); - assert!(out.contains("-old")); - assert!(out.contains("+new")); - assert!(out.contains("diff --git a/flake.lock")); - assert!(!out.contains("lock-line-one")); - assert!( - out.contains("[flake.lock: +1 -1"), - "expected stat placeholder, got: {out}" - ); - } - - #[test] - fn collapse_counts_distinguish_added_and_removed() { - // Big asymmetric churn — flake.lock rev bump with several - // adds and one removal in the worker fields. Verifies - // we don't conflate `+`/`-` totals. - let diff = "\ -diff --git a/flake.lock b/flake.lock -index 1111..2222 100644 ---- a/flake.lock -+++ b/flake.lock -@@ -1,5 +1,8 @@ - { -- \"old\": 1 -+ \"new\": 1, -+ \"another\": 2, -+ \"and\": 3, -+ \"more\": 4 - } -"; - let out = collapse_autogenerated(diff); - assert!( - out.contains("[flake.lock: +4 -1"), - "expected +4 -1, got: {out}" - ); - } - - #[test] - fn parse_diff_git_path_picks_b_side() { - assert_eq!( - parse_diff_git_path("a/foo b/foo"), - Some("foo".to_owned()) - ); - assert_eq!( - parse_diff_git_path("a/old.txt b/new.txt"), - Some("new.txt".to_owned()) - ); - assert_eq!( - parse_diff_git_path("a/dir/Cargo.lock b/dir/Cargo.lock"), - Some("dir/Cargo.lock".to_owned()) - ); - } - - #[test] - fn parse_diff_git_path_handles_quoted_paths_with_spaces() { - // Git quotes paths with spaces / unusual chars per - // `core.quotePath`. The b-side path must survive intact. - assert_eq!( - parse_diff_git_path("\"a/foo bar\" \"b/foo bar\""), - Some("foo bar".to_owned()) - ); - // Rename with quoted sides. - assert_eq!( - parse_diff_git_path("\"a/old name\" \"b/new name\""), - Some("new name".to_owned()) - ); - // Lockfile inside a directory whose name has a space. - assert_eq!( - parse_diff_git_path("\"a/dir with space/Cargo.lock\" \"b/dir with space/Cargo.lock\""), - Some("dir with space/Cargo.lock".to_owned()) - ); - } - - #[test] - fn parse_diff_git_path_handles_escaped_quote_inside_path() { - // Git escapes embedded `"` as `\"`. The closing-quote - // search must skip these so it doesn't terminate early. - // Path is literally `a/has"quote` / `b/has"quote`. - let rest = r#""a/has\"quote" "b/has\"quote""#; - assert_eq!( - parse_diff_git_path(rest), - // Bytewise: backslash + quote stay in the result - // because we don't unescape (allowlist match is by - // file name, never contains escapes). - Some(r#"has\"quote"#.to_owned()) - ); - } - - #[test] - fn collapse_recognises_lockfile_in_quoted_path() { - // Path "a/odd dir/Cargo.lock" forces git to use the - // quoted form. We must still detect it as a lockfile. - let diff = "\ -diff --git \"a/odd dir/Cargo.lock\" \"b/odd dir/Cargo.lock\" -index 1111..2222 100644 ---- \"a/odd dir/Cargo.lock\" -+++ \"b/odd dir/Cargo.lock\" -@@ -1,1 +1,1 @@ --old -+new -"; - let out = collapse_autogenerated(diff); - // Content must be suppressed (lockfile detected). - assert!(!out.contains("old\n"), "lock content leaked: {out}"); - assert!(!out.contains("+new"), "lock content leaked: {out}"); - // Placeholder uses the parsed b-side path (without quotes). - assert!( - out.contains("[odd dir/Cargo.lock: +1 -1"), - "expected stat placeholder for quoted-path lockfile, got: {out}" - ); - } -} diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 684a9496..e5411178 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -46,15 +46,6 @@ pub enum HostRequest { Approve { id: i64 }, /// Deny a pending request by id. Deny { id: i64 }, - /// Move an agent in the topology tree (#486). Pass `new_parent = - /// None` to promote the agent to root, or `Some(name)` to set a - /// new parent. Refuses cycles, unknown agents, and any attempt - /// to reparent the manager (which is structurally root). - /// Pure topology-json edit today; bind-mount work follows in #361. - SetParent { - child: String, - new_parent: Option, - }, } #[derive(Debug, Clone, Serialize, Deserialize)]