Compare commits
9 changed files with 5 additions and 672 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -29,10 +29,6 @@
|
|||
<span class="tab-label">◆ SYST3M ◆</span>
|
||||
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||
</a>
|
||||
<a class="tab" href="/#schedules" role="tab" data-tab="schedules">
|
||||
<span class="tab-label">◆ SCH3DUL3S ◆</span>
|
||||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||
</a>
|
||||
<a class="tab tab-link active" id="tab-flow" href="/flow.html"
|
||||
aria-current="page"
|
||||
title="all-agents chat — you are here">
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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<String>,
|
||||
}
|
||||
|
||||
#[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<AppState>,
|
||||
Form(form): Form<SetParentForm>,
|
||||
) -> 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<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
||||
let logical = strip_container_prefix(&name);
|
||||
state.coord.rebuild_queue.enqueue(
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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?,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -185,17 +185,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> 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;
|
||||
|
|
|
|||
|
|
@ -123,72 +123,6 @@ pub fn default_seed(agent_names: &[String]) -> BTreeMap<String, Option<String>>
|
|||
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<String, Option<String>>,
|
||||
child: &str,
|
||||
new_parent: Option<&str>,
|
||||
) -> Result<BTreeMap<String, Option<String>>, 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<String, Option<String>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,4 @@
|
|||
//! `diff <pr> [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 `[<path>: +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<CollapseState> = 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<String> {
|
||||
if let Some(after_open) = rest.strip_prefix('"') {
|
||||
// Quoted form: `"a/<path>" "b/<path>"`. 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue