use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use serde_json::Value;
use tempfile::TempDir;
mod common;
use common::TestHome;
fn bin(home: &TempDir) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_orchestratectl"));
c.env("ORCHESTRATECTL_HOME", home.path());
c.env("OCTL_TEST_SKIP_MATERIALIZE", "1");
c.env("TMUX_BIN", "/usr/bin/true");
c
}
fn run_ok(cmd: &mut Command) -> Value {
let out = cmd.output().expect("spawn");
assert!(
out.status.success(),
"exit={:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("stdout is valid JSON")
}
fn create_run(home: &TempDir, kind: &str, title: &str) -> String {
run_ok(bin(home).args([
"--output", "json", "run", "create", "--kind", kind, "--title", title,
]))["data"]["run_id"]
.as_str()
.unwrap()
.to_string()
}
fn run_dir(home: &TempDir, run_id: &str) -> std::path::PathBuf {
home.path().join("runs").join(run_id)
}
fn forge_worker_node(home: &TempDir, run_id: &str, kind: &str, worktree: &Path, branch: &str) {
let node = home.path().join(format!("node-{run_id}.json"));
std::fs::write(
&node,
format!(
r#"{{"kind":"{kind}","task":"x","worktree_path":"{}","branch":"{branch}","tmux_session":"octl","tmux_window_id":"@42"}}"#,
worktree.display()
),
)
.unwrap();
run_ok(bin(home).args([
"--output",
"json",
"event",
"create",
run_id,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
node.to_str().unwrap(),
]));
}
fn fake_merge_sh(dir: &Path, code: i32, stderr: &str) -> std::path::PathBuf {
let p = dir.join("fake-merge.sh");
let log = dir.join("merge.log");
let body = format!(
"#!/bin/bash\nprintf '%s ' \"$@\" >> '{}'\nprintf '\\n' >> '{}'\n{}\nexit {code}\n",
log.display(),
log.display(),
if stderr.is_empty() {
String::new()
} else {
format!("echo '{stderr}' >&2")
},
);
std::fs::write(&p, body).unwrap();
let mut perms = std::fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&p, perms).unwrap();
p
}
fn read_events(events: &Path) -> Vec<Value> {
std::fs::read_to_string(events)
.unwrap_or_default()
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap())
.collect()
}
fn node_reports(events: &Path) -> Vec<Value> {
read_events(events)
.into_iter()
.filter(|v| v["kind"] == "node.report")
.collect()
}
#[test]
fn successful_merge_submits_explicit_merge_report() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "code", "merge-ok");
forge_worker_node(&home, &run_id, "code", worktree.path(), "wt/test-x");
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let v = run_ok(bin(&home).env("OCTL_MERGE_SH", &merge_sh).args([
"--output",
"json",
"run",
"merge",
&run_id,
"--source",
"main",
"--confirm-interactive",
]));
assert_eq!(v["data"]["merged"], true);
assert_eq!(v["data"]["branch"], "wt/test-x");
assert_eq!(v["data"]["source"], "main");
let argv = std::fs::read_to_string(scratch.path().join("merge.log")).unwrap();
assert!(
argv.contains("--target main") && argv.contains("wt/test-x"),
"merge backend argv was {argv:?}"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
let reports = node_reports(&events);
assert_eq!(reports.len(), 1, "expected one terminal node.report");
assert_eq!(reports[0]["data"]["success"], true);
assert_eq!(reports[0]["data"]["via"], "explicit-merge");
}
#[test]
fn report_file_payload_is_submitted_with_marker() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "research", "merge-rich");
forge_worker_node(&home, &run_id, "research", worktree.path(), "wt/test-x");
let report = scratch.path().join("report.json");
std::fs::write(
&report,
r#"{
"success": true,
"summary": "research delivered",
"discussion_items": [{"topic": "scope creep", "severity": "discuss"}],
"spinoff_proposals": [{"proposed_title": "follow-up", "proposed_kind": "research"}],
"wrap_up_recommendations": ["read sources/"]
}"#,
)
.unwrap();
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
run_ok(bin(&home).env("OCTL_MERGE_SH", &merge_sh).args([
"--output",
"json",
"run",
"merge",
&run_id,
"--report-file",
report.to_str().unwrap(),
]));
let events = run_dir(&home, &run_id).join("events.jsonl");
let reports = node_reports(&events);
assert_eq!(reports.len(), 1);
let data = &reports[0]["data"];
assert_eq!(data["via"], "explicit-merge");
assert_eq!(data["summary"], "research delivered");
assert_eq!(data["discussion_items"][0]["topic"], "scope creep");
assert_eq!(data["spinoff_proposals"][0]["proposed_title"], "follow-up");
assert_eq!(data["wrap_up_recommendations"][0], "read sources/");
}
#[test]
fn non_success_report_file_is_rejected() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
for body in [
r#"{"success": false, "summary": "blocked"}"#,
r#"{"success": true, "cancelled": true, "summary": "cancelled"}"#,
] {
let run_id = create_run(&home, "code", "reject-nonsuccess");
forge_worker_node(&home, &run_id, "code", worktree.path(), "wt/foo");
let report = scratch.path().join("bad-report.json");
std::fs::write(&report, body).unwrap();
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let out = bin(&home)
.env("OCTL_MERGE_SH", &merge_sh)
.args([
"--output",
"json",
"run",
"merge",
&run_id,
"--source",
"main",
"--confirm-interactive",
"--report-file",
report.to_str().unwrap(),
])
.output()
.expect("spawn");
assert!(!out.status.success(), "must reject: {body}");
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(err["error"]["code"], "invalid_merge_report", "body: {body}");
assert!(
!scratch.path().join("merge.log").exists(),
"merge backend must not run when the report is rejected: {body}"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(node_reports(&events).len(), 0, "no report appended: {body}");
}
}
#[test]
fn bad_report_file_rejected_before_merge() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "merge-badreport");
forge_worker_node(&home, &run_id, "spinoff", worktree.path(), "wt/test-x");
let report = scratch.path().join("bad.json");
std::fs::write(&report, r#"{"summary": "no success field"}"#).unwrap();
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let out = bin(&home)
.env("OCTL_MERGE_SH", &merge_sh)
.args([
"--output",
"json",
"run",
"merge",
&run_id,
"--report-file",
report.to_str().unwrap(),
])
.output()
.expect("spawn");
assert!(!out.status.success());
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr JSON");
assert_eq!(err["error"]["code"], "schema_violation");
assert!(
!scratch.path().join("merge.log").exists(),
"merge must not run when the report file is invalid"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(node_reports(&events).len(), 0);
}
#[test]
fn failed_merge_surfaces_error_and_writes_no_report() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "code", "merge-fail");
forge_worker_node(&home, &run_id, "code", worktree.path(), "wt/test-x");
let merge_sh = fake_merge_sh(scratch.path(), 1, "Error: rebase conflict");
let out = bin(&home)
.env("OCTL_MERGE_SH", &merge_sh)
.args([
"--output",
"json",
"run",
"merge",
&run_id,
"--confirm-interactive",
])
.output()
.expect("spawn");
assert!(!out.status.success(), "merge failure must exit non-zero");
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(err["error"]["code"], "merge_failed");
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
node_reports(&events).len(),
0,
"a failed merge must not submit a terminal report"
);
}
#[test]
fn dry_run_resolves_without_side_effects() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "code", "merge-dry");
forge_worker_node(&home, &run_id, "code", worktree.path(), "wt/test-x");
let merge_sh = fake_merge_sh(scratch.path(), 1, "should never run");
let v = run_ok(bin(&home).env("OCTL_MERGE_SH", &merge_sh).args([
"--output",
"json",
"run",
"merge",
&run_id,
"--dry-run",
]));
assert_eq!(v["data"]["dry_run"], true);
assert_eq!(v["data"]["branch"], "wt/test-x");
assert!(
!scratch.path().join("merge.log").exists(),
"dry-run must not invoke the merge backend"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(node_reports(&events).len(), 0);
}
fn set_run_status(home: &TempDir, run_id: &str, scratch: &Path, status: &str) {
let f = scratch.join(format!("run-status-{status}.json"));
std::fs::write(&f, format!(r#"{{"status":"{status}"}}"#)).unwrap();
run_ok(bin(home).args([
"--output",
"json",
"event",
"create",
run_id,
"--kind",
"run.status",
"--from-file",
f.to_str().unwrap(),
]));
}
fn assert_refused_terminal(
out: std::process::Output,
scratch: &Path,
expected_backend_lines: usize,
) {
assert!(!out.status.success(), "the merge must be refused");
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(
err["error"]["code"], "run_already_terminal",
"a terminal run must surface run_already_terminal, not merge_spawn_failed: {err}"
);
let log = scratch.join("merge.log");
let lines = std::fs::read_to_string(&log).map_or(0, |s| s.lines().count());
assert_eq!(
lines, expected_backend_lines,
"the refused merge must NOT invoke the merge backend"
);
}
#[test]
fn second_merge_on_terminal_run_is_run_already_terminal() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "double-merge");
forge_worker_node(&home, &run_id, "spinoff", worktree.path(), "wt/test-x");
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let v = run_ok(bin(&home).env("OCTL_MERGE_SH", &merge_sh).args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
]));
assert_eq!(v["data"]["merged"], true);
set_run_status(&home, &run_id, scratch.path(), "done");
std::fs::remove_dir_all(worktree.path()).unwrap();
let out = bin(&home)
.env("OCTL_MERGE_SH", &merge_sh)
.args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
])
.output()
.expect("spawn");
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
let msg = err["error"]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("no worktree left to merge"),
"the message must explain there is nothing to merge: {msg}"
);
assert_refused_terminal(out, scratch.path(), 1);
}
#[test]
fn merge_on_cancelled_run_is_refused() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "cancelled-merge");
forge_worker_node(&home, &run_id, "spinoff", worktree.path(), "wt/test-x");
set_run_status(&home, &run_id, scratch.path(), "cancelled");
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let out = bin(&home)
.env("OCTL_MERGE_SH", &merge_sh)
.args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
])
.output()
.expect("spawn");
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert!(
err["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("cancelled"),
"the message must name the cancellation: {err}"
);
assert_refused_terminal(out, scratch.path(), 0);
}
#[test]
fn terminal_failed_torn_down_is_run_already_terminal() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "failed-torn-down");
forge_worker_node(&home, &run_id, "spinoff", worktree.path(), "wt/test-x");
set_run_status(&home, &run_id, scratch.path(), "failed");
std::fs::remove_dir_all(worktree.path()).unwrap();
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let out = bin(&home)
.env("OCTL_MERGE_SH", &merge_sh)
.args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
])
.output()
.expect("spawn");
assert_refused_terminal(out, scratch.path(), 0);
}
#[test]
fn nonterminal_missing_worktree_is_worktree_missing() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "live-no-worktree");
forge_worker_node(&home, &run_id, "spinoff", worktree.path(), "wt/test-x");
std::fs::remove_dir_all(worktree.path()).unwrap();
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let out = bin(&home)
.env("OCTL_MERGE_SH", &merge_sh)
.args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
])
.output()
.expect("spawn");
assert!(!out.status.success());
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(err["error"]["code"], "worktree_missing", "{err}");
assert!(
!scratch.path().join("merge.log").exists(),
"the merge backend must not run when the worktree is missing"
);
}
#[test]
fn missing_backend_with_live_worktree_is_merge_spawn_failed() {
let home = TestHome::new();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "bad-backend");
forge_worker_node(&home, &run_id, "spinoff", worktree.path(), "wt/test-x");
let out = bin(&home)
.env("OCTL_MERGE_SH", "/no/such/merge-backend.sh")
.args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
])
.output()
.expect("spawn");
assert!(!out.status.success());
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(
err["error"]["code"], "merge_spawn_failed",
"a missing backend (worktree present) must not be misread as worktree_missing: {err}"
);
}
#[test]
fn terminal_but_unmerged_run_still_merges() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "swallowed-then-merge");
forge_worker_node(&home, &run_id, "spinoff", worktree.path(), "wt/test-x");
append_node_report(
&home,
&run_id,
scratch.path(),
r#"{"success": false, "failed": true, "reason": "agent-died"}"#,
);
set_run_status(&home, &run_id, scratch.path(), "failed");
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let v = run_ok(bin(&home).env("OCTL_MERGE_SH", &merge_sh).args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
]));
assert_eq!(
v["data"]["merged"], true,
"a terminal run with a surviving worktree must still accept run merge: {}",
v["data"]
);
}
#[test]
fn missing_run_is_run_not_found() {
let home = TestHome::new();
let out = bin(&home)
.args([
"--output",
"json",
"run",
"merge",
"01jxsnap000000000000000000",
])
.output()
.expect("spawn");
assert!(!out.status.success());
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(err["error"]["code"], "run_not_found");
}
fn git(cwd: &Path, args: &[&str]) {
let ok = Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.expect("spawn git")
.status
.success();
assert!(ok, "git {args:?} failed in {}", cwd.display());
}
fn branch_exists(repo: &Path, branch: &str) -> bool {
Command::new("git")
.current_dir(repo)
.args(["rev-parse", "--verify", "--quiet", branch])
.output()
.expect("spawn git")
.status
.success()
}
fn init_repo_with_worktree(tmp: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
let repo = tmp.join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["config", "user.email", "t@example.com"]);
git(&repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("README"), "x").unwrap();
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-qm", "init"]);
let wt = tmp.join("wt");
git(
&repo,
&[
"worktree",
"add",
"-q",
"-b",
"wt/foo",
wt.to_str().unwrap(),
],
);
(repo, wt)
}
fn append_node_report(home: &TempDir, run_id: &str, scratch: &Path, data: &str) {
let f = scratch.join("pre-report.json");
std::fs::write(&f, data).unwrap();
run_ok(bin(home).args([
"--output",
"json",
"node",
"report",
run_id,
"n-0001",
"--from-file",
f.to_str().unwrap(),
]));
}
#[test]
fn merge_adopts_swallowed_report_and_defers_teardown() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let gitroot = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(gitroot.path());
let run_id = create_run(&home, "code", "swallowed-merge");
forge_worker_node(&home, &run_id, "code", &wt, "wt/foo");
append_node_report(
&home,
&run_id,
scratch.path(),
r#"{"success": false, "failed": true, "reason": "agent-died"}"#,
);
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let v = run_ok(bin(&home).env("OCTL_MERGE_SH", &merge_sh).args([
"--output",
"json",
"run",
"merge",
&run_id,
"--source",
"main",
"--confirm-interactive",
]));
assert_eq!(v["data"]["merged"], true);
assert_eq!(
v["data"]["supervisor"]["state"], "not-supervised",
"a never-supervised run has no teardown actor: {}",
v["data"]
);
assert!(
wt.exists(),
"run merge no longer reclaims inline; the supervisor owns teardown"
);
assert!(
branch_exists(&repo, "wt/foo"),
"the branch is left for the supervisor"
);
let node_show =
run_ok(bin(&home).args(["--output", "json", "node", "show", &run_id, "n-0001"]));
assert_eq!(node_show["data"]["last_report"]["via"], "explicit-merge");
assert_eq!(node_show["data"]["status"], "done");
}
#[test]
fn merge_defers_to_supervisor_when_report_adopted() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let gitroot = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(gitroot.path());
let run_id = create_run(&home, "code", "adopted-merge");
forge_worker_node(&home, &run_id, "code", &wt, "wt/foo");
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let v = run_ok(bin(&home).env("OCTL_MERGE_SH", &merge_sh).args([
"--output",
"json",
"run",
"merge",
&run_id,
"--source",
"main",
"--confirm-interactive",
]));
assert_eq!(v["data"]["merged"], true);
assert!(
wt.exists(),
"adopted path must NOT reclaim inline — the supervisor is the teardown actor"
);
assert!(
branch_exists(&repo, "wt/foo"),
"adopted path must leave the branch for the supervisor"
);
let node_show =
run_ok(bin(&home).args(["--output", "json", "node", "show", &run_id, "n-0001"]));
assert_eq!(node_show["data"]["last_report"]["via"], "explicit-merge");
}
#[test]
fn failed_merge_on_preterminal_node_reclaims_nothing() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let gitroot = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(gitroot.path());
let run_id = create_run(&home, "code", "swallowed-merge-fail");
forge_worker_node(&home, &run_id, "code", &wt, "wt/foo");
append_node_report(
&home,
&run_id,
scratch.path(),
r#"{"success": false, "failed": true, "reason": "agent-died"}"#,
);
let merge_sh = fake_merge_sh(scratch.path(), 1, "Error: rebase conflict");
let out = bin(&home)
.env("OCTL_MERGE_SH", &merge_sh)
.args([
"--output",
"json",
"run",
"merge",
&run_id,
"--source",
"main",
"--confirm-interactive",
])
.output()
.expect("spawn");
assert!(!out.status.success(), "a failed merge must exit non-zero");
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(err["error"]["code"], "merge_failed");
assert!(wt.exists(), "a failed merge must not reclaim the worktree");
assert!(
branch_exists(&repo, "wt/foo"),
"a failed merge must not reclaim the branch"
);
}
#[test]
fn interactive_run_merge_without_confirmation_is_refused() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "code", "no-selfmerge");
forge_worker_node(&home, &run_id, "code", worktree.path(), "wt/test-x");
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let out = bin(&home)
.env("OCTL_MERGE_SH", &merge_sh)
.args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
])
.output()
.expect("spawn");
assert!(
!out.status.success(),
"an interactive run must refuse a bare (unconfirmed) merge"
);
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(
err["error"]["code"], "interactive_merge_requires_confirmation",
"body: {err}"
);
assert!(
!scratch.path().join("merge.log").exists(),
"the merge backend must NOT run when the interactive gate refuses"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
node_reports(&events).len(),
0,
"no explicit-merge report may be appended for an unconfirmed interactive merge"
);
}
#[test]
fn interactive_run_merge_with_confirmation_proceeds() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "code", "human-merge");
forge_worker_node(&home, &run_id, "code", worktree.path(), "wt/test-x");
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let v = run_ok(bin(&home).env("OCTL_MERGE_SH", &merge_sh).args([
"--output",
"json",
"run",
"merge",
&run_id,
"--source",
"main",
"--confirm-interactive",
]));
assert_eq!(v["data"]["merged"], true);
let events = run_dir(&home, &run_id).join("events.jsonl");
let reports = node_reports(&events);
assert_eq!(
reports.len(),
1,
"the confirmed merge submits one terminal report"
);
assert_eq!(reports[0]["data"]["via"], "explicit-merge");
}
#[test]
fn autonomous_run_merge_needs_no_confirmation() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "auto-merge");
forge_worker_node(&home, &run_id, "spinoff", worktree.path(), "wt/test-x");
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let v = run_ok(bin(&home).env("OCTL_MERGE_SH", &merge_sh).args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
]));
assert_eq!(
v["data"]["merged"], true,
"an autonomous kind self-merges without --confirm-interactive"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(node_reports(&events).len(), 1);
}
#[test]
fn autonomous_run_merge_accepts_confirmation_flag_as_noop() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "auto-merge-flag");
forge_worker_node(&home, &run_id, "spinoff", worktree.path(), "wt/test-x");
let merge_sh = fake_merge_sh(scratch.path(), 0, "");
let v = run_ok(bin(&home).env("OCTL_MERGE_SH", &merge_sh).args([
"--output",
"json",
"run",
"merge",
&run_id,
"--source",
"main",
"--confirm-interactive",
]));
assert_eq!(
v["data"]["merged"], true,
"an autonomous kind merges the same whether or not the flag is present"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
let reports = node_reports(&events);
assert_eq!(reports.len(), 1);
assert_eq!(reports[0]["data"]["via"], "explicit-merge");
}
fn real_merge_sh(dir: &Path) -> std::path::PathBuf {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/merge.sh");
let body = std::fs::read(&src).expect("read scripts/merge.sh");
let dst = dir.join("merge.sh");
std::fs::write(&dst, body).unwrap();
let mut perms = std::fs::metadata(&dst).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&dst, perms).unwrap();
dst
}
struct ChildGuard(std::process::Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn hold_merge_lock(repo: &Path, ready: &Path) -> ChildGuard {
let lock = repo.join(".git").join("worktree-merge.lock");
let child = Command::new("flock")
.arg("-x")
.arg(&lock)
.arg("-c")
.arg(format!("touch '{}'; sleep 30", ready.display()))
.spawn()
.expect("spawn flock holder");
ChildGuard(child)
}
fn hold_lock_dirty_then_clean(repo: &Path, dirty: &Path, ready: &Path) -> ChildGuard {
let lock = repo.join(".git").join("worktree-merge.lock");
let child = Command::new("flock")
.arg("-x")
.arg(&lock)
.arg("-c")
.arg(format!(
"touch '{dirty}'; touch '{ready}'; sleep 2; rm -f '{dirty}'",
dirty = dirty.display(),
ready = ready.display(),
))
.spawn()
.expect("spawn flock holder");
ChildGuard(child)
}
fn fake_workmux_dir(dir: &Path, code: i32) -> std::path::PathBuf {
let bindir = dir.join("fakebin");
std::fs::create_dir_all(&bindir).unwrap();
let p = bindir.join("workmux");
std::fs::write(&p, format!("#!/bin/bash\nexit {code}\n")).unwrap();
let mut perms = std::fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&p, perms).unwrap();
bindir
}
fn path_with(prepend: &Path) -> String {
format!(
"{}:{}",
prepend.display(),
std::env::var("PATH").unwrap_or_default()
)
}
fn wait_for(path: &Path, secs: u64) {
for _ in 0..(secs * 50) {
if path.exists() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("timed out waiting for {}", path.display());
}
#[test]
fn concurrent_self_merge_serializes_instead_of_false_dirty() {
let home = TestHome::new();
let gitroot = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(gitroot.path());
let run_id = create_run(&home, "spinoff", "race-merge");
forge_worker_node(&home, &run_id, "spinoff", &wt, "wt/foo");
std::fs::write(repo.join("RACE.txt"), "in-flight merge state").unwrap();
let ready = gitroot.path().join("lock-ready");
let _holder = hold_merge_lock(&repo, &ready);
wait_for(&ready, 5);
let out = bin(&home)
.env("OCTL_MERGE_SH", real_merge_sh(gitroot.path()))
.env("MERGE_LOCK_TIMEOUT", "1")
.args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
])
.output()
.expect("spawn");
assert!(
!out.status.success(),
"a merge blocked by a concurrent one must not succeed"
);
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(
err["error"]["code"], "merge_in_progress",
"a lock-held concurrent merge must surface the distinct serialization code, \
not a dirty-tree failure: {err}"
);
let msg = err["error"]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("another merge is holding"),
"the error must name the serialization conflict, not the transient dirt: {msg}"
);
assert!(
!msg.to_lowercase().contains("uncommitted changes in target"),
"the false-positive dirty-target error must be gone: {msg}"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
node_reports(&events).len(),
0,
"a serialized-out merge must not submit a terminal report"
);
}
#[test]
fn genuine_dirty_target_still_blocks() {
let home = TestHome::new();
let gitroot = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(gitroot.path());
let run_id = create_run(&home, "spinoff", "dirty-target");
forge_worker_node(&home, &run_id, "spinoff", &wt, "wt/foo");
std::fs::write(repo.join("USER-WORK.txt"), "human's uncommitted edit").unwrap();
let out = bin(&home)
.env("OCTL_MERGE_SH", real_merge_sh(gitroot.path()))
.args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
])
.output()
.expect("spawn");
assert!(
!out.status.success(),
"a genuinely dirty target must still block the merge"
);
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(
err["error"]["code"], "merge_failed",
"a genuine dirty target is a hard merge failure, not a serialization retry: {err}"
);
let msg = err["error"]["message"].as_str().unwrap_or_default();
assert!(
msg.to_lowercase().contains("uncommitted changes in target"),
"the genuine dirty-target message must survive: {msg}"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(node_reports(&events).len(), 0);
}
#[test]
fn concurrent_self_merge_waits_then_succeeds() {
let home = TestHome::new();
let gitroot = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(gitroot.path());
let run_id = create_run(&home, "spinoff", "race-success");
forge_worker_node(&home, &run_id, "spinoff", &wt, "wt/foo");
let fakebin = fake_workmux_dir(gitroot.path(), 0);
let dirty = repo.join("PEER-INFLIGHT.txt");
let ready = gitroot.path().join("lock-ready");
let _holder = hold_lock_dirty_then_clean(&repo, &dirty, &ready);
wait_for(&ready, 5);
let v = run_ok(
bin(&home)
.env("OCTL_MERGE_SH", real_merge_sh(gitroot.path()))
.env("PATH", path_with(&fakebin))
.env("MERGE_LOCK_TIMEOUT", "30")
.args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
]),
);
assert_eq!(
v["data"]["merged"], true,
"a merge that serialized behind a concurrent one must still land: {}",
v["data"]
);
let events = run_dir(&home, &run_id).join("events.jsonl");
let reports = node_reports(&events);
assert_eq!(reports.len(), 1, "the serialized merge submits one report");
assert_eq!(reports[0]["data"]["via"], "explicit-merge");
}
#[test]
fn downstream_exit_75_is_not_merge_in_progress() {
let home = TestHome::new();
let gitroot = TempDir::new().unwrap();
let (_repo, wt) = init_repo_with_worktree(gitroot.path());
let run_id = create_run(&home, "spinoff", "exit75");
forge_worker_node(&home, &run_id, "spinoff", &wt, "wt/foo");
let fakebin = fake_workmux_dir(gitroot.path(), 75);
let out = bin(&home)
.env("OCTL_MERGE_SH", real_merge_sh(gitroot.path()))
.env("PATH", path_with(&fakebin))
.args([
"--output", "json", "run", "merge", &run_id, "--source", "main",
])
.output()
.expect("spawn");
assert!(
!out.status.success(),
"a workmux failure must fail the merge"
);
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON envelope");
assert_eq!(
err["error"]["code"], "merge_failed",
"a downstream exit 75 must not be misread as a lock-timeout retry: {err}"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
node_reports(&events).len(),
0,
"a failed merge writes no report"
);
}
#[test]
fn worktree_merge_skill_passes_confirm_interactive() {
let template = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("skills/worktree-merge/SKILL.template.md");
let body = std::fs::read_to_string(&template)
.unwrap_or_else(|e| panic!("read {}: {e}", template.display()));
assert!(
body.contains("--confirm-interactive"),
"worktree-merge SKILL must pass --confirm-interactive so the human's \
`code`-run merge clears the interactive gate"
);
}