use crate::dispatch_config::ForbiddenWrites;
use crate::git;
use crate::slice::SelectorIntent;
use crate::verify::{CheckKind, CheckPlan, VerificationConfig, resolve_check};
use crate::worktree::{
CLAUDE_PREFIX, DOCTRINE_PREFIX, DispatchRecord, gather_worktree_delta_paths, resolve_agent,
};
use anyhow::{Context, bail};
use serde::Serialize;
use std::path::Path;
const EMPTY_DELTA: &str = "empty-delta";
const FORBIDDEN_ZONE: &str = "forbidden-zone";
const NOT_AT_BASE: &str = "not-at-base";
const COMMIT_GATE_RED: &str = "commit-gate-red";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) enum WorkerCommitOutput {
Committed {
oid: String,
base: String,
undeclared: Vec<String>,
},
Refused { reason: String, detail: String },
}
fn refused(reason: &str, detail: String) -> WorkerCommitOutput {
WorkerCommitOutput::Refused {
reason: reason.to_owned(),
detail,
}
}
fn is_forbidden_zone(path: &str, forbidden: &ForbiddenWrites) -> bool {
path.starts_with(DOCTRINE_PREFIX)
|| path.starts_with(CLAUDE_PREFIX)
|| forbidden.is_forbidden(path)
}
fn classify_scope(
delta_paths: &[String],
forbidden: &ForbiddenWrites,
selectors: &[String],
) -> Result<Vec<String>, String> {
if let Some(hit) = delta_paths.iter().find(|p| is_forbidden_zone(p, forbidden)) {
return Err(hit.clone());
}
let paths: Vec<&str> = delta_paths.iter().map(String::as_str).collect();
Ok(crate::conformance::undeclared_paths(selectors, &paths))
}
fn head_at_base(head: &str, base: &str) -> bool {
head == base
}
enum GateOutcome {
Green,
Red(String),
}
fn run_commit_gate(dir: &Path, cfg: &VerificationConfig) -> anyhow::Result<GateOutcome> {
let argv = match resolve_check(cfg, CheckKind::Commit) {
CheckPlan::Run(argv) => argv,
CheckPlan::Empty(kind) => {
bail!(
"worker_commit: [verification].{} is empty — cannot run the commit gate",
kind.key()
)
}
CheckPlan::Noop(_) => {
bail!("worker_commit: the commit gate resolved to a no-op — cannot gate the commit")
}
};
let (program, rest) = argv
.split_first()
.ok_or_else(|| anyhow::anyhow!("worker_commit: resolved commit-gate argv is empty"))?;
let output = std::process::Command::new(program)
.args(rest)
.current_dir(dir)
.output()
.with_context(|| format!("spawning the worker commit gate: {}", argv.join(" ")))?;
if output.status.success() {
Ok(GateOutcome::Green)
} else {
let mut detail = String::from_utf8_lossy(&output.stdout).into_owned();
detail.push_str(&String::from_utf8_lossy(&output.stderr));
Ok(GateOutcome::Red(detail))
}
}
fn stage_and_commit(dir: &Path, message: &str, paths: &[String]) -> anyhow::Result<()> {
let mut add_args: Vec<&str> = vec!["add", "--"];
add_args.extend(paths.iter().map(String::as_str));
git::git_text(dir, &add_args).context("stage the classified worker delta")?;
let mut commit_args: Vec<&str> = vec!["commit", "-q", "-m", message, "--"];
commit_args.extend(paths.iter().map(String::as_str));
git::git_text(dir, &commit_args).context("create the gated worker commit")?;
Ok(())
}
fn design_target_selectors(record: &DispatchRecord) -> anyhow::Result<Vec<String>> {
let branch = git::git_text(&record.coord, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let slice_id: u32 = branch
.strip_prefix("dispatch/")
.and_then(|s| s.parse().ok())
.ok_or_else(|| anyhow::anyhow!("coord HEAD `{branch}` is not a dispatch/<slice> branch"))?;
crate::slice::selectors(&record.coord, slice_id, Some(SelectorIntent::DesignTarget))
}
fn commit_oid(dir: &Path, rev: &str) -> anyhow::Result<String> {
git::git_text(dir, &["rev-parse", &format!("{rev}^{{commit}}")])
.with_context(|| format!("rev-parse {rev} in {}", dir.display()))
}
pub(crate) fn run_worker_commit(
root: &Path,
agent: &str,
message: &str,
) -> anyhow::Result<WorkerCommitOutput> {
let record = match resolve_agent(root, agent) {
Ok(record) => record,
Err(refusal) => return Ok(refused(refusal.token(), String::new())),
};
let delta = gather_worktree_delta_paths(&record.dir)?;
if delta.is_empty() {
return Ok(refused(EMPTY_DELTA, String::new()));
}
let cfg = crate::dtoml::load_doctrine_toml(root)?;
let forbidden = cfg.dispatch.forbidden_writes();
let selectors = design_target_selectors(&record).unwrap_or_default();
let undeclared = match classify_scope(&delta, &forbidden, &selectors) {
Ok(undeclared) => undeclared,
Err(path) => return Ok(refused(FORBIDDEN_ZONE, path)),
};
let base = commit_oid(&record.dir, &record.base)?;
let head = commit_oid(&record.dir, "HEAD")?;
if !head_at_base(&head, &base) {
return Ok(refused(NOT_AT_BASE, String::new()));
}
match run_commit_gate(&record.dir, &cfg.verification)? {
GateOutcome::Green => {}
GateOutcome::Red(detail) => return Ok(refused(COMMIT_GATE_RED, detail)),
}
stage_and_commit(&record.dir, message, &delta)?;
let oid = commit_oid(&record.dir, "HEAD")?;
match git::parents(&record.dir, &oid)?.as_slice() {
[parent] if *parent == base => {}
[parent] => bail!("worker_commit parent {parent} != base {base} (C^ != B)"),
other => bail!(
"worker_commit produced a commit with {} parents (expected exactly 1)",
other.len()
),
}
Ok(WorkerCommitOutput::Committed {
oid,
base,
undeclared,
})
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests: fail-fast unwrap on fixture setup is idiomatic"
)]
mod tests {
use super::*;
use crate::dispatch_config::DispatchConfig;
use crate::worktree::{Apply, Refusal, classify_import, provision_dispatch_record};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
fn forbidden_from(lines: &[&str]) -> ForbiddenWrites {
let cfg = DispatchConfig {
worker_forbidden_writes: lines.iter().map(|s| (*s).to_string()).collect(),
..DispatchConfig::default()
};
cfg.forbidden_writes()
}
#[test]
fn classify_scope_hard_refuses_the_doctrine_and_claude_floors() {
let fw = forbidden_from(&[]);
assert_eq!(
classify_scope(&[".doctrine/state/x".to_string()], &fw, &[]),
Err(".doctrine/state/x".to_string())
);
assert_eq!(
classify_scope(&[".claude/agents/w.md".to_string()], &fw, &[]),
Err(".claude/agents/w.md".to_string())
);
}
#[test]
fn classify_scope_hard_refuses_a_config_forbidden_agent_def_or_flake() {
let fw = forbidden_from(&["flake.nix", "install/agents/**"]);
assert_eq!(
classify_scope(&["flake.nix".to_string()], &fw, &[]),
Err("flake.nix".to_string())
);
assert_eq!(
classify_scope(
&["install/agents/claude/dispatch-worker.md".to_string()],
&fw,
&[]
),
Err("install/agents/claude/dispatch-worker.md".to_string())
);
}
#[test]
fn classify_scope_soft_reports_undeclared_without_blocking() {
let fw = forbidden_from(&[]);
let selectors = vec!["src/allowed.rs".to_string()];
let delta = vec!["src/allowed.rs".to_string(), "src/other.rs".to_string()];
assert_eq!(
classify_scope(&delta, &fw, &selectors),
Ok(vec!["src/other.rs".to_string()])
);
}
#[test]
fn classify_scope_forbidden_wins_even_alongside_an_undeclared_path() {
let fw = forbidden_from(&[]);
let selectors = vec!["src/allowed.rs".to_string()];
let delta = vec!["src/other.rs".to_string(), ".doctrine/x".to_string()];
assert_eq!(
classify_scope(&delta, &fw, &selectors),
Err(".doctrine/x".to_string())
);
}
#[test]
fn worker_commit_and_classify_import_agree_on_the_hard_verdict() {
let fw = forbidden_from(&[]);
for hard in [".doctrine/state/x", ".claude/agents/w.md"] {
let delta = vec![hard.to_string()];
assert!(
classify_scope(&delta, &fw, &[]).is_err(),
"worker_commit rejects {hard}"
);
assert!(
classify_import(true, true, true, &delta, &[]).is_err(),
"classify_import rejects {hard}"
);
}
let benign = vec!["src/lib.rs".to_string()];
assert!(classify_scope(&benign, &fw, &[]).is_ok());
assert_eq!(
classify_import(true, true, true, &benign, &[]),
Ok(Apply::Ok)
);
assert_eq!(
classify_import(true, true, true, &[".doctrine/x".to_string()], &[]),
Err(Refusal::DoctrineTouch)
);
}
#[test]
fn head_at_base_is_exact_equality() {
assert!(head_at_base("abc123", "abc123"));
assert!(!head_at_base("abc123", "def456"));
}
fn git_run(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn worker_fixture(
commit_override: &str,
seed_files: &[(&str, &str)],
) -> (tempfile::TempDir, PathBuf, PathBuf, String, String) {
let tmp = tempfile::tempdir().unwrap();
let primary = fs::canonicalize(tmp.path()).unwrap().join("primary");
fs::create_dir_all(&primary).unwrap();
git_run(&primary, &["init", "-q", "-b", "main"]);
git_run(&primary, &["config", "user.email", "t@t"]);
git_run(&primary, &["config", "user.name", "t"]);
for (rel, body) in seed_files {
let path = primary.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, body).unwrap();
}
fs::write(primary.join("seed"), "base\n").unwrap();
git_run(&primary, &["add", "-A"]);
git_run(&primary, &["commit", "-q", "-m", "base"]);
let base = git_run(&primary, &["rev-parse", "HEAD^{commit}"]);
fs::create_dir_all(primary.join(".doctrine")).unwrap();
fs::write(
primary.join(".doctrine/doctrine.toml"),
format!("[verification]\ncommit = {commit_override}\n"),
)
.unwrap();
let agent = "wk1".to_string();
let coord = fs::canonicalize(tmp.path()).unwrap().join("coord");
let wt = coord.join(".worktrees").join(&agent);
fs::create_dir_all(coord.join(".worktrees")).unwrap();
git_run(
&primary,
&[
"worktree",
"add",
"-q",
"-b",
&format!("dispatch/{agent}"),
wt.to_str().unwrap(),
&base,
],
);
let wt = fs::canonicalize(&wt).unwrap();
provision_dispatch_record(&coord, &agent, &base, &wt, &format!("dispatch/{agent}"))
.unwrap();
(tmp, primary, wt, agent, base)
}
#[test]
fn worker_commit_unknown_agent_refuses() {
let (_tmp, primary, _wt, _agent, _base) = worker_fixture("[\"true\"]", &[]);
let out = run_worker_commit(&primary, "nosuchagent", "msg").unwrap();
assert_eq!(
out,
refused("unknown-agent", String::new()),
"an unresolvable agent refuses unknown-agent"
);
}
#[test]
fn worker_commit_empty_delta_refuses() {
let (_tmp, primary, _wt, agent, _base) = worker_fixture("[\"true\"]", &[]);
let out = run_worker_commit(&primary, &agent, "msg").unwrap();
assert_eq!(out, refused(EMPTY_DELTA, String::new()));
}
#[test]
fn worker_commit_gate_red_refuses_and_leaves_head_at_base() {
let (_tmp, primary, wt, agent, base) = worker_fixture("[\"false\"]", &[]);
fs::write(wt.join("seed"), "worker change\n").unwrap();
let out = run_worker_commit(&primary, &agent, "msg").unwrap();
match out {
WorkerCommitOutput::Refused { reason, .. } => assert_eq!(reason, COMMIT_GATE_RED),
other => panic!("expected commit-gate-red, got {other:?}"),
}
assert_eq!(git_run(&wt, &["rev-parse", "HEAD^{commit}"]), base);
}
#[test]
fn worker_commit_forbidden_zone_refuses() {
let (_tmp, primary, wt, agent, base) = worker_fixture("[\"true\"]", &[]);
fs::create_dir_all(wt.join(".doctrine/state")).unwrap();
fs::write(wt.join(".doctrine/state/x"), "sneaky\n").unwrap();
let out = run_worker_commit(&primary, &agent, "msg").unwrap();
match out {
WorkerCommitOutput::Refused { reason, detail } => {
assert_eq!(reason, FORBIDDEN_ZONE);
assert!(
detail.contains(".doctrine/state/x"),
"names the path: {detail}"
);
}
other => panic!("expected forbidden-zone, got {other:?}"),
}
assert_eq!(git_run(&wt, &["rev-parse", "HEAD^{commit}"]), base);
}
#[test]
fn worker_commit_happy_path_lands_one_non_merge_commit() {
let (_tmp, primary, wt, agent, base) = worker_fixture("[\"true\"]", &[]);
fs::write(wt.join("seed"), "worker change\n").unwrap();
let out = run_worker_commit(&primary, &agent, "the message").unwrap();
let (oid, out_base) = match out {
WorkerCommitOutput::Committed { oid, base, .. } => (oid, base),
other => panic!("expected Committed, got {other:?}"),
};
assert_eq!(out_base, base, "returned base == B");
assert_eq!(git_run(&wt, &["rev-parse", "HEAD^{commit}"]), oid);
let parents = git_run(&wt, &["rev-list", "--parents", "-n", "1", &oid]);
let cols: Vec<&str> = parents.split_whitespace().collect();
assert_eq!(cols.len(), 2, "exactly one parent: {parents}");
assert_eq!(cols[1], base, "C^ == B");
assert_eq!(git_run(&wt, &["log", "-1", "--format=%s"]), "the message");
}
#[test]
fn worker_commit_stages_only_the_in_scope_path_after_the_gate_fmt() {
let (_tmp, primary, wt, agent, _base) = worker_fixture(
"[\"sh\", \"-c\", \"printf reformatted > outofscope.txt\"]",
&[
("outofscope.txt", "misformatted\n"),
("src/feature.rs", "fn f(){}\n"),
],
);
fs::write(wt.join("src/feature.rs"), "fn f() {}\n").unwrap();
let out = run_worker_commit(&primary, &agent, "in-scope only").unwrap();
let oid = match out {
WorkerCommitOutput::Committed { oid, .. } => oid,
other => panic!("expected Committed, got {other:?}"),
};
let touched = git_run(&wt, &["show", "--name-only", "--format=", &oid]);
let names: Vec<&str> = touched.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
names,
vec!["src/feature.rs"],
"commit contains ONLY the in-scope path, not the gate-reformatted file"
);
}
#[test]
fn worker_commit_soft_undeclared_still_commits() {
let (_tmp, primary, wt, agent, _base) = worker_fixture("[\"true\"]", &[]);
fs::write(wt.join("seed"), "worker change\n").unwrap();
let out = run_worker_commit(&primary, &agent, "msg").unwrap();
match out {
WorkerCommitOutput::Committed { undeclared, .. } => {
assert!(
undeclared.contains(&"seed".to_string()),
"the out-of-selector src path is reported undeclared: {undeclared:?}"
);
}
other => panic!("expected Committed, got {other:?}"),
}
}
}