diff --git a/docs/turn-loop.md b/docs/turn-loop.md
index 52b32eb9..cd3e7ab5 100644
--- a/docs/turn-loop.md
+++ b/docs/turn-loop.md
@@ -342,6 +342,14 @@ 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 1c00ae6c..c7b53f9d 100644
--- a/frontend/packages/dashboard/src/dashboard.css
+++ b/frontend/packages/dashboard/src/dashboard.css
@@ -1164,10 +1164,18 @@ summary:hover { color: var(--purple); }
text-indent: 0;
}
.live .msgrow .msg-body {
- 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. */
+ /* #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%;
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 195f1c14..407f3b85 100644
--- a/frontend/packages/dashboard/src/flow.html
+++ b/frontend/packages/dashboard/src/flow.html
@@ -29,6 +29,10 @@
◆ SYST3M ◆
+
+ ◆ SCH3DUL3S ◆
+
+
diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs
index 0ff15d86..956f9ae9 100644
--- a/hive-c0re/src/dashboard.rs
+++ b/hive-c0re/src/dashboard.rs
@@ -72,6 +72,7 @@ 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))
@@ -849,6 +850,21 @@ 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,
@@ -1846,6 +1862,45 @@ 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 3b9f0e77..9b00cc91 100644
--- a/hive-c0re/src/main.rs
+++ b/hive-c0re/src/main.rs
@@ -102,6 +102,23 @@ 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]
@@ -143,6 +160,20 @@ 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 42ccc5fb..30be0364 100644
--- a/hive-c0re/src/server.rs
+++ b/hive-c0re/src/server.rs
@@ -185,6 +185,17 @@ 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 527f2cdb..7830e9a9 100644
--- a/hive-c0re/src/topology.rs
+++ b/hive-c0re/src/topology.rs
@@ -123,6 +123,72 @@ 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
@@ -191,4 +257,85 @@ 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 aa64c5cd..e35793b9 100644
--- a/hive-forge/src/verbs/diff.rs
+++ b/hive-forge/src/verbs/diff.rs
@@ -1,4 +1,15 @@
//! `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;
@@ -9,11 +20,394 @@ 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")?;
- print!("{diff}");
+ let out = if args.full {
+ diff
+ } else {
+ collapse_autogenerated(&diff)
+ };
+ print!("{out}");
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 e5411178..684a9496 100644
--- a/hive-sh4re/src/lib.rs
+++ b/hive-sh4re/src/lib.rs
@@ -46,6 +46,15 @@ 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)]