use std::collections::BTreeSet;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use anyhow::{Context as _, bail};
use clap::Subcommand;
use crate::boundary::{BoundaryRow, Provenance};
use crate::corpus_guard;
use crate::git::{self, MergeTree, RefCas, ZERO_OID};
use crate::ledger::{
Admission, Boundaries, CandidateKind, CandidatePayload, CandidateRole, CandidateRow,
CandidateStatus, Candidates, Journal, JournalRow, LedgerStatus, Orthogonal, read_candidates,
};
use crate::listing::render_table;
use crate::root;
#[derive(Subcommand)]
pub(crate) enum DispatchCommand {
Sync {
#[arg(long)]
slice: u32,
#[arg(long, group = "stage", required = true)]
prepare_review: bool,
#[arg(long, group = "stage", required = true)]
integrate: bool,
#[arg(long, group = "stage", required = true)]
show_journal_trunk_oid: bool,
#[arg(long, conflicts_with = "prepare_review")]
trunk: Option<String>,
#[arg(long, requires = "integrate")]
edge: Option<String>,
#[arg(long = "allow-corpus-clobber", requires = "integrate")]
allow_corpus_clobber: Vec<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
RecordBoundary {
#[arg(long)]
slice: u32,
#[arg(long)]
phase: String,
#[arg(long)]
code_start: String,
#[arg(long)]
code_end: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
RefreshBase {
#[arg(long)]
slice: u32,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Setup {
#[arg(long)]
slice: u32,
#[arg(long)]
dir: PathBuf,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Candidate {
#[command(subcommand)]
command: CandidateCommand,
},
PlanNext {
#[arg(long)]
slice: u32,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
#[arg(long)]
slice: u32,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
DeliverTo {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
ArmSpawn {
#[arg(long)]
base: String,
#[arg(long)]
slice: Option<u32>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
pub(crate) enum CandidateCommand {
Create {
#[arg(long)]
slice: u32,
#[arg(long, visible_alias = "target")]
label: String,
#[arg(long, default_value = "audit")]
kind: String,
#[arg(long)]
role: String,
#[arg(long)]
payload: String,
#[arg(long)]
base: String,
#[arg(long)]
source: Option<String>,
#[arg(long)]
supersedes: Option<String>,
#[arg(long)]
worktree: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
#[arg(long)]
slice: u32,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Admit {
#[arg(long)]
slice: u32,
#[arg(long)]
role: String,
#[arg(long)]
candidate: String,
#[arg(long)]
review: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
pub(crate) fn dispatch(cmd: DispatchCommand, _color: bool) -> anyhow::Result<()> {
match cmd {
DispatchCommand::Sync {
slice,
integrate,
show_journal_trunk_oid,
trunk,
edge,
allow_corpus_clobber,
path,
..
} => {
if show_journal_trunk_oid {
run_show_journal_trunk_oid(path, slice, trunk.as_deref())
} else if integrate {
let allow: BTreeSet<String> = allow_corpus_clobber.into_iter().collect();
run_integrate(path, slice, trunk.as_deref(), edge.as_deref(), &allow)
} else {
run_prepare_review(path, slice)
}
}
DispatchCommand::RecordBoundary {
slice,
phase,
code_start,
code_end,
path,
} => run_record_boundary(path, slice, &phase, &code_start, &code_end),
DispatchCommand::RefreshBase { slice, path } => run_refresh_base(path, slice),
DispatchCommand::Setup { slice, dir, path } => {
let claude_harness =
std::env::vars_os().any(|(k, _v)| k.to_string_lossy().starts_with("CLAUDE"));
run_setup(path, slice, &dir, claude_harness)
}
DispatchCommand::Candidate { command } => match command {
CandidateCommand::Create {
slice,
label,
kind,
role,
payload,
base,
source,
supersedes,
worktree,
path,
} => {
let req = CreateRequest {
slice,
label,
kind: parse_kind(&kind)?,
role: parse_role(&role)?,
payload: parse_payload(&payload)?,
base,
source,
supersedes,
worktree,
created_at: crate::clock::today(),
};
run_candidate_create(path, &req)
}
CandidateCommand::Status { slice, path } => run_candidate_status(path, slice),
CandidateCommand::Admit {
slice,
role,
candidate,
review,
path,
} => {
let req = AdmitRequest {
slice,
role: parse_role(&role)?,
candidate,
review,
admitted_at: crate::clock::today(),
};
run_candidate_admit(path, &req)
}
},
DispatchCommand::PlanNext { slice, json, path } => run_plan_next(path, slice, json),
DispatchCommand::Status { slice, json, path } => run_status(path, slice, json),
DispatchCommand::DeliverTo { path } => run_deliver_to(path),
DispatchCommand::ArmSpawn { base, slice, path } => run_arm_spawn(path, &base, slice),
}
}
fn run_arm_spawn(path: Option<PathBuf>, base: &str, slice: Option<u32>) -> anyhow::Result<()> {
let b = base.trim();
if !(4..=64).contains(&b.len()) || !b.bytes().all(|c| c.is_ascii_hexdigit()) {
bail!("bad-base: `{base}` is not a 4..=64-char hex oid");
}
let root = root::find(path, &root::default_markers())?;
let spawn = root.join(crate::worktree::ARMING_SUBPATH);
std::fs::create_dir_all(&spawn)
.with_context(|| format!("create arming dir {}", spawn.display()))?;
crate::fsutil::write_atomic(&spawn.join("base"), format!("{b}\n").as_bytes())
.with_context(|| format!("write arming base in {}", spawn.display()))?;
let spawn_canon = std::fs::canonicalize(&spawn)
.with_context(|| format!("canonicalize arming dir {}", spawn.display()))?;
if let Some(slice) = slice {
writeln!(io::stderr(), "armed SL-{slice:03} at base {b}")?;
}
writeln!(io::stdout(), "{}", spawn_canon.display())?;
Ok(())
}
fn classify_coord_placement(
dir_inside_root: bool,
claude_harness: bool,
) -> Result<(), &'static str> {
if claude_harness && !dir_inside_root {
Err("coord-outside-root-under-claude")
} else {
Ok(())
}
}
fn absolutize(p: &Path) -> PathBuf {
if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir().map_or_else(|_unused| p.to_path_buf(), |cwd| cwd.join(p))
}
}
pub(crate) fn run_setup(
path: Option<PathBuf>,
slice: u32,
dir: &Path,
claude_harness: bool,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let dir_inside_root = absolutize(dir).starts_with(absolutize(&root));
classify_coord_placement(dir_inside_root, claude_harness).map_err(|token| {
anyhow::anyhow!(
"{token}: coordination worktree '{}' is outside the project root '{}'. \
The Claude dispatch arm forks the Agent worktree off the Bash cwd's HEAD; \
under a cwd-confining jail a `cd` outside the root silently reverts, so the \
worker would fork `main` instead of base B. Use a path under the project \
root — convention: .dispatch/SL-{slice:03}.",
dir.display(),
root.display()
)
})?;
let slice_root = root.join(".doctrine/slice");
let plan = crate::slice::read_plan(&slice_root, slice).with_context(|| {
format!("no plan for SL-{slice:03}; run 'doctrine slice plan {slice}' first")
})?;
if plan.phases.is_empty() {
anyhow::bail!("plan for SL-{slice:03} has no phases; add phases to plan.toml first");
}
let authoring = crate::dtoml::load_doctrine_toml(&root)?
.dispatch
.authoring_branch;
let outcome = crate::worktree::coordinate(&root, slice, dir, authoring.as_deref())?;
let dispatch_ref = format!("refs/heads/dispatch/{slice:03}");
writeln!(io::stdout(), "coordination_dir={}", dir.display())?;
writeln!(io::stdout(), "base={}", outcome.dispatch_tip)?;
writeln!(io::stdout(), "slice={slice}")?;
writeln!(io::stdout(), "dispatch_ref={dispatch_ref}")?;
Ok(())
}
struct Planned {
target_ref: String,
source_oid: String,
commit_oid: String,
}
pub(crate) fn run_prepare_review(path: Option<PathBuf>, slice: u32) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
prepare_review(&root, slice)
}
pub(crate) fn run_show_journal_trunk_oid(
path: Option<PathBuf>,
slice: u32,
trunk: Option<&str>,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let trunk: String = match trunk {
Some(t) => t.to_string(),
None => crate::dtoml::load_doctrine_toml(&root)?.dispatch.deliver_to,
};
let slice3 = format!("{slice:03}");
let journal = crate::ledger::read_journal_at_ref(&root, slice)?.unwrap_or_default();
let oid = journal
.rows
.iter()
.find(|r| r.target_ref == trunk)
.map(|r| r.planned_new_oid.as_str())
.with_context(|| {
format!("show-journal-trunk-oid: no journal row for {trunk} on dispatch/{slice3}")
})?;
writeln!(io::stdout(), "{oid}")?;
Ok(())
}
pub(crate) fn run_deliver_to(path: Option<PathBuf>) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let deliver_to = crate::dtoml::load_doctrine_toml(&root)?.dispatch.deliver_to;
writeln!(io::stdout(), "{deliver_to}")?;
Ok(())
}
pub(crate) fn run_integrate(
path: Option<PathBuf>,
slice: u32,
trunk: Option<&str>,
edge: Option<&str>,
allow: &BTreeSet<String>,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let cfg = crate::dtoml::load_doctrine_toml(&root)?.dispatch;
guard_not_on_integration_ref(&root, &cfg)?;
integrate(&root, slice, trunk, edge, allow)
}
fn guard_not_on_integration_ref(
root: &Path,
cfg: &crate::dispatch_config::DispatchConfig,
) -> anyhow::Result<()> {
let current = current_branch(root)?;
if corpus_guard::on_integration_buffer(
current.as_deref(),
cfg.authoring_branch.as_deref(),
&cfg.deliver_to,
) {
let authoring = cfg.authoring_branch.as_deref().unwrap_or_default();
let buffer = corpus_guard::short_branch_name(&cfg.deliver_to);
bail!(
"{} `{}` — the primary must stay on `{authoring}`. Restore \
(`git checkout {authoring}`) and promote via \
`git fetch . {authoring}:{buffer}`, never `checkout {buffer}`.",
corpus_guard::REFUSE_ON_TRUNK,
cfg.deliver_to,
);
}
Ok(())
}
pub(crate) fn run_record_boundary(
path: Option<PathBuf>,
slice: u32,
phase: &str,
code_start: &str,
code_end: &str,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let resolve = |refish: &str| -> anyhow::Result<String> {
resolve_commit(&root, refish)?
.with_context(|| format!("record-boundary: {refish} does not resolve to a commit"))
};
let row = crate::boundary::BoundaryRow {
phase: phase.to_string(),
code_start_oid: resolve(code_start)?,
code_end_oid: resolve(code_end)?,
provenance: crate::boundary::Provenance::Funnel,
};
crate::ledger::record_boundary(&root, slice, row.clone())?;
crate::state::record_source_delta(&root, slice, row)
}
pub(crate) fn run_refresh_base(path: Option<PathBuf>, slice: u32) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let slice3 = format!("{slice:03}");
let dispatch_ref = format!("refs/heads/dispatch/{slice3}");
let trunk_tip = git::trunk_commit(&root)?.with_context(|| "trunk ref not found")?;
let coord = git::worktree_for_ref(&root, &dispatch_ref)?.with_context(|| {
format!(
"no live coordination worktree for dispatch/{slice3}; \
run 'dispatch setup --slice {slice}' (or resume) first"
)
})?;
let dispatch_tip = git::git_text(&coord, &["rev-parse", "HEAD"])?;
let dirty = git::git_text(&coord, &["status", "--porcelain"])?;
if !dirty.is_empty() {
bail!("refusing to refresh over a dirty coordination worktree (dispatch/{slice3})");
}
if git::merge_base(&coord, &dispatch_tip, &trunk_tip)?.is_none() {
bail!("unrelated histories — dispatch/{slice3} and trunk share no common ancestor");
}
if git::is_ancestor(&coord, &trunk_tip, &dispatch_tip)? {
writeln!(
io::stdout(),
"dispatch/{slice3} already fresh — trunk {} is already merged",
short(&trunk_tip)
)?;
return Ok(());
}
let msg = format!("refresh-base: merge trunk into dispatch/{slice3}");
let clean = git::git_status_ok(&coord, &["merge", "--no-ff", "-m", &msg, &trunk_tip])?;
if clean {
let new_tip = git::git_text(&coord, &["rev-parse", "HEAD"])?;
let merged = git::git_text(
&coord,
&[
"rev-list",
"--count",
&format!("{dispatch_tip}..{trunk_tip}"),
],
)?;
writeln!(
io::stdout(),
"dispatch/{slice3} refreshed: merged {merged} trunk commit(s); new tip {}",
short(&new_tip)
)?;
return Ok(());
}
let conflicts = git::git_text(&coord, &["diff", "--name-only", "--diff-filter=U"])?;
let paths: Vec<&str> = conflicts.lines().filter(|l| !l.is_empty()).collect();
bail!(
"refresh-base merge of trunk into dispatch/{slice3} conflicted in {} path(s):\n {}\n\
resolve them in the coordination worktree, then commit the merge \
(MERGE_HEAD is left in place; the dispatch ref is unadvanced).",
paths.len(),
paths.join("\n ")
);
}
fn short(oid: &str) -> &str {
oid.get(..7).unwrap_or(oid)
}
pub(crate) struct CreateRequest {
pub slice: u32,
pub label: String,
pub kind: CandidateKind,
pub role: CandidateRole,
pub payload: CandidatePayload,
pub base: String,
pub source: Option<String>,
pub supersedes: Option<String>,
pub worktree: bool,
pub created_at: String,
}
pub(crate) fn parse_kind(token: &str) -> anyhow::Result<CandidateKind> {
match token {
"audit" => Ok(CandidateKind::Audit),
"experiment" => Ok(CandidateKind::Experiment),
other => bail!("unknown candidate kind {other:?} (expected audit|experiment)"),
}
}
pub(crate) fn parse_role(token: &str) -> anyhow::Result<CandidateRole> {
match token {
"review_surface" => Ok(CandidateRole::ReviewSurface),
"close_target" => Ok(CandidateRole::CloseTarget),
"scratch" => Ok(CandidateRole::Scratch),
other => {
bail!("unknown candidate role {other:?} (expected review_surface|close_target|scratch)")
}
}
}
pub(crate) fn parse_payload(token: &str) -> anyhow::Result<CandidatePayload> {
match token {
"impl_bundle" => Ok(CandidatePayload::ImplBundle),
"code" => Ok(CandidatePayload::Code),
other => bail!("unknown candidate payload {other:?} (expected impl_bundle|code)"),
}
}
pub(crate) fn run_candidate_create(
path: Option<PathBuf>,
req: &CreateRequest,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
candidate_create(&root, req)
}
fn resolve_source_ref(req: &CreateRequest, slice3: &str) -> anyhow::Result<String> {
if let Some(src) = &req.source {
return Ok(src.clone());
}
match req.role {
CandidateRole::ReviewSurface => Ok(format!("refs/heads/review/{slice3}")),
CandidateRole::CloseTarget | CandidateRole::Scratch => bail!(
"candidate create: --source is required for a {} candidate",
role_token(req.role)
),
}
}
fn role_token(role: CandidateRole) -> &'static str {
match role {
CandidateRole::ReviewSurface => "review_surface",
CandidateRole::CloseTarget => "close_target",
CandidateRole::Scratch => "scratch",
}
}
fn is_journaled_evidence_ref(source_ref: &str, slice3: &str) -> bool {
source_ref == format!("refs/heads/review/{slice3}")
|| source_ref
.strip_prefix(&format!("refs/heads/phase/{slice3}-"))
.and_then(|nn| nn.parse::<u32>().ok())
.is_some()
}
const CANDIDATE_PROVENANCE_DEPTH_BUDGET: u32 = 16;
fn is_candidate_ref(source_ref: &str) -> bool {
source_ref.starts_with("refs/heads/candidate/")
}
fn trace_candidate_provenance<'a>(
candidates: &'a Candidates,
journal: &Journal,
slice3: &str,
ref_name: &str,
budget: u32,
) -> anyhow::Result<&'a CandidateRow> {
if budget == 0 {
bail!(
"candidate create: provenance chain too deep or cyclic — \
budget exhausted at {ref_name}"
);
}
let mut rows = candidates.rows.iter().filter(|r| r.target_ref == ref_name);
let row = rows.next().with_context(|| {
format!("candidate create: no recorded candidate row for source {ref_name}")
})?;
if rows.next().is_some() {
bail!(
"candidate create: ambiguous candidate row for {ref_name} — \
multiple rows share the same target_ref"
);
}
anyhow::ensure!(
row.status == CandidateStatus::Created,
"candidate create: source candidate {ref_name} is {:?}, not clean (must be Created)",
row.status
);
anyhow::ensure!(
matches!(
row.role,
CandidateRole::ReviewSurface | CandidateRole::CloseTarget
) && row.kind == CandidateKind::Audit,
"candidate create: source candidate {ref_name} is role={:?}/kind={:?} — \
only an audit review_surface (or chained close_target) may source a close_target",
row.role,
row.kind
);
let next = &row.source_ref;
if is_journaled_evidence_ref(next, slice3) {
let jrow = journal
.rows
.iter()
.find(|r| r.target_ref == *next)
.with_context(|| {
format!(
"candidate create: no prepare-review journal row for source {next} — \
run `dispatch sync --prepare-review` first"
)
})?;
anyhow::ensure!(
jrow.status == LedgerStatus::Verified,
"candidate create: source {next} is not verified (status {:?}) — \
no verified evidence to build a candidate from",
jrow.status
);
let prefix = format!("refs/heads/phase/{slice3}-");
if let Some(nn) = next
.strip_prefix(&prefix)
.and_then(|nn| nn.parse::<u32>().ok())
{
for r in &journal.rows {
if let Some(other) = r
.target_ref
.strip_prefix(&prefix)
.and_then(|n| n.parse::<u32>().ok())
&& other < nn
&& r.status == LedgerStatus::Failed
{
bail!(
"candidate create: an earlier phase row {} failed — the phase chain \
below {next} has an unresolved hole",
r.target_ref
);
}
}
}
Ok(row)
} else if is_candidate_ref(next) {
trace_candidate_provenance(candidates, journal, slice3, next, budget - 1)
} else {
bail!(
"candidate create: source candidate built from non-evidence {next} — \
the recorded chain must terminate at a journaled evidence ref"
)
}
}
fn check_provenance<'a>(
journal: &Journal,
candidates: &'a Candidates,
slice3: &str,
role: CandidateRole,
source_ref: &str,
) -> anyhow::Result<Option<&'a CandidateRow>> {
if is_journaled_evidence_ref(source_ref, slice3) {
let row = journal
.rows
.iter()
.find(|r| r.target_ref == source_ref)
.with_context(|| {
format!(
"candidate create: no prepare-review journal row for source {source_ref} — \
run `dispatch sync --prepare-review` first"
)
})?;
anyhow::ensure!(
row.status == LedgerStatus::Verified,
"candidate create: source {source_ref} is not verified (status {:?}) — \
no verified evidence to build a candidate from",
row.status
);
let prefix = format!("refs/heads/phase/{slice3}-");
if let Some(nn) = source_ref
.strip_prefix(&prefix)
.and_then(|nn| nn.parse::<u32>().ok())
{
for r in &journal.rows {
if let Some(other) = r
.target_ref
.strip_prefix(&prefix)
.and_then(|n| n.parse::<u32>().ok())
&& other < nn
&& r.status == LedgerStatus::Failed
{
bail!(
"candidate create: an earlier phase row {} failed — the phase chain \
below {source_ref} has an unresolved hole",
r.target_ref
);
}
}
}
Ok(None)
} else if role == CandidateRole::CloseTarget && is_candidate_ref(source_ref) {
let row = trace_candidate_provenance(
candidates,
journal,
slice3,
source_ref,
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
)?;
Ok(Some(row))
} else {
bail!(
"candidate create: no prepare-review journal row for source {source_ref} — \
run `dispatch sync --prepare-review` first"
)
}
}
fn candidate_conflict_message(source_ref: &str, base: &str, ahead: u32) -> String {
let hint = if ahead > 0 {
format!(
"; trunk has advanced {ahead} commit(s) past this source — \
the conflict may be base divergence; try `dispatch refresh-base` \
then re-prepare + re-create"
)
} else {
String::new()
};
format!(
"candidate create: 3-way merge of {source_ref} onto {base} conflicts — \
pass --worktree to park the candidate branch at the base for \
manual resolve+commit, or abort (no row/ref/worktree written){hint}"
)
}
fn candidate_create(root: &Path, req: &CreateRequest) -> anyhow::Result<()> {
let slice3 = format!("{:03}", req.slice);
let coord_ref = format!("refs/heads/dispatch/{slice3}");
let target_ref = format!("refs/heads/candidate/{slice3}/{}", req.label);
let id = format!("cand-{slice3}-{}", req.label);
if let Some(branch) = current_branch(root)?
&& is_raw_evidence_ref(&branch)
{
bail!(
"candidate create: the current worktree is checked out on raw evidence ref {branch:?} \
(review/* and phase/* are immutable, invariant I9) — never edit it in place; \
run `dispatch candidate create` from a safe branch (e.g. the coordination tree) \
to publish a candidate instead"
);
}
if req.role == CandidateRole::ReviewSurface && !req.worktree {
bail!(
"candidate create: a review_surface candidate requires an explicit --worktree \
(v1: the review surface is always materialised for the reviewer to read)"
);
}
let source_ref = resolve_source_ref(req, &slice3)?;
let journal = read_ledger::<Journal>(root, &coord_ref, &slice3, "journal.toml")?;
let mut ledger = read_candidates(root, req.slice)?;
let matched_row = check_provenance(&journal, &ledger, &slice3, req.role, &source_ref)?;
let source_oid = resolve_commit(root, &source_ref)?
.with_context(|| format!("candidate create: source {source_ref} does not resolve"))?;
let base_oid = resolve_commit(root, &req.base)?
.with_context(|| format!("candidate create: base {} does not resolve", req.base))?;
if let Some(row) = matched_row {
anyhow::ensure!(
!row.merge_oid.is_empty(),
"candidate create: source candidate {source_ref} has an empty merge_oid — cannot verify lineage"
);
anyhow::ensure!(
git::is_ancestor(root, &row.merge_oid, &source_oid)?,
"candidate create: source candidate {} tip {} does not descend from its recorded \
merge {} — the ref moved off its provenance lineage",
source_ref,
source_oid,
row.merge_oid
);
}
let supersedes = match &req.supersedes {
Some(prior) => {
anyhow::ensure!(
ledger.rows.iter().any(|r| r.id == *prior),
"candidate create: --supersedes {prior} names no recorded candidate"
);
prior.clone()
}
None => String::new(),
};
let merge_base = git::merge_base(root, &base_oid, &source_oid)?.with_context(|| {
format!(
"candidate create: base {base_oid} and source {source_oid} share no common ancestor"
)
})?;
let (branch_oid, merge_oid, status) =
match git::merge_tree(root, &merge_base, &base_oid, &source_oid)? {
MergeTree::Clean { tree } => {
let merge_oid = git::commit_tree_merge(
root,
&tree,
&base_oid,
&source_oid,
&format!("candidate({slice3}/{}): merge {source_ref}", req.label),
)?;
(merge_oid.clone(), merge_oid, CandidateStatus::Created)
}
MergeTree::Conflict if !req.worktree => {
let ahead = trunk_drift(root, &source_oid)?.map_or(0, |d| d.ahead);
bail!(candidate_conflict_message(&source_ref, &req.base, ahead))
}
MergeTree::Conflict => (base_oid.clone(), String::new(), CandidateStatus::Conflicted),
};
match git::update_ref_cas(root, &target_ref, &branch_oid, ZERO_OID)? {
RefCas::Updated => {}
RefCas::Moved { actual } => bail!(
"candidate create: {target_ref} already exists (at {}) — \
supersede creates a fresh label, never rewrites a branch",
actual.as_deref().unwrap_or("?")
),
}
let worktree_path = if req.worktree {
match add_candidate_worktree(root, &id, &target_ref) {
Ok(path) => Some(path),
Err(e) => {
rollback_ref(root, &target_ref, &branch_oid);
return Err(e);
}
}
} else {
None
};
let row = CandidateRow {
id: id.clone(),
label: req.label.clone(),
kind: req.kind,
role: req.role,
payload: req.payload,
target_ref: target_ref.clone(),
source_ref,
source_oid,
base_ref: req.base.clone(),
base_oid,
merge_oid: merge_oid.clone(),
status,
supersedes,
reason: String::new(),
created_by: "dispatch candidate create".to_owned(),
created_at: req.created_at.clone(),
};
ledger.rows.push(row);
crate::ledger::write_candidates(root, req.slice, &ledger)?;
writeln!(io::stdout(), "{target_ref}")?;
if let Some(path) = &worktree_path {
writeln!(io::stdout(), "{}", path.display())?;
}
match status {
CandidateStatus::Conflicted => writeln!(
io::stderr(),
"candidate create: {id} conflicted — branch parked at base {branch_oid}; \
resolve+commit in {}",
worktree_path
.as_ref()
.map_or_else(|| "(worktree)".to_owned(), |p| p.display().to_string())
)?,
_ => writeln!(
io::stderr(),
"candidate create: {id} created at {merge_oid}"
)?,
}
Ok(())
}
fn add_candidate_worktree(root: &Path, id: &str, target_ref: &str) -> anyhow::Result<PathBuf> {
let wt_path = root.join(".doctrine/state/dispatch/candidate").join(id);
if let Some(parent) = wt_path.parent() {
std::fs::create_dir_all(parent)?;
}
let wt_str = wt_path
.to_str()
.context("candidate create: worktree path is not valid UTF-8")?;
git::git_text(root, &["worktree", "add", "--quiet", wt_str, target_ref])?;
Ok(wt_path)
}
fn rollback_ref(root: &Path, target_ref: &str, expected: &str) {
let _ignored = git::git_opt(root, &["update-ref", "-d", target_ref, expected]);
}
fn current_branch(root: &Path) -> anyhow::Result<Option<String>> {
Ok(git::git_opt(
root,
&["symbolic-ref", "--quiet", "--short", "HEAD"],
)?)
}
fn is_raw_evidence_ref(branch: &str) -> bool {
branch.starts_with("review/") || branch.starts_with("phase/")
}
pub(crate) struct AdmitRequest {
pub slice: u32,
pub role: CandidateRole,
pub candidate: String,
pub review: Option<String>,
pub admitted_at: String,
}
pub(crate) fn run_candidate_admit(path: Option<PathBuf>, req: &AdmitRequest) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
candidate_admit(&root, req)
}
fn candidate_admit(root: &Path, req: &AdmitRequest) -> anyhow::Result<()> {
if let Some(branch) = current_branch(root)?
&& is_raw_evidence_ref(&branch)
{
bail!(
"candidate admit: the current worktree is checked out on raw evidence ref {branch:?} \
(review/* and phase/* are immutable, invariant I9) — never edit it in place; \
run `dispatch candidate admit` from a safe branch (e.g. the coordination tree)"
);
}
if req.role == CandidateRole::Scratch {
bail!("candidate admit: a scratch candidate is not admissible (no review/close target)");
}
let admitted_1 = resolve_commit(root, &req.candidate)?.with_context(|| {
format!(
"candidate admit: candidate {} does not resolve to a committed tip",
req.candidate
)
})?;
let mut ledger = read_candidates(root, req.slice)?;
let row = ledger
.rows
.iter()
.find(|r| r.target_ref == req.candidate)
.with_context(|| {
format!(
"candidate admit: no recorded candidate at {} — admit pins a recorded candidate",
req.candidate
)
})?
.clone();
anyhow::ensure!(
row.role == req.role,
"candidate admit: candidate {} is role {}, cannot admit as {}",
row.id,
role_token(row.role),
role_token(req.role)
);
anyhow::ensure!(
!row.merge_oid.is_empty(),
"candidate admit: candidate {} has no Doctrine merge to validate \
(conflicted/unresolved) — resolve and re-create before admitting",
row.id
);
let merge_parents: std::collections::BTreeSet<String> =
git::parents(root, &row.merge_oid)?.into_iter().collect();
let expected_parents: std::collections::BTreeSet<String> =
[row.base_oid.clone(), row.source_oid.clone()]
.into_iter()
.collect();
anyhow::ensure!(
merge_parents == expected_parents,
"candidate admit: merge_oid {} is not the Doctrine candidate merge \
(parents != base+source)",
row.merge_oid
);
anyhow::ensure!(
git::is_ancestor(root, &row.merge_oid, &admitted_1)?,
"candidate admit: admitted tip {admitted_1} does not descend from candidate merge {} (I3)",
row.merge_oid
);
let admitted_2 = resolve_commit(root, &req.candidate)?;
anyhow::ensure!(
admitted_2.as_deref() == Some(admitted_1.as_str()),
"candidate admit: candidate {} moved during admission (was {admitted_1}, now {}) — \
re-run admit",
req.candidate,
admitted_2.as_deref().unwrap_or("absent")
);
let supersedes = prior_admission(&ledger, req.role)
.map(|a| a.candidate_id.clone())
.unwrap_or_default();
let admission = Admission {
candidate_id: row.id.clone(),
candidate_ref: req.candidate.clone(),
expected_ref_oid: admitted_1.clone(),
admitted_oid: admitted_1.clone(),
review: req.review.clone().unwrap_or_default(),
supersedes,
admitted_at: req.admitted_at.clone(),
};
let slot = match req.role {
CandidateRole::ReviewSurface => &mut ledger.current_admission.review_surface,
CandidateRole::CloseTarget | CandidateRole::Scratch => {
&mut ledger.current_admission.close_target
}
};
*slot = Some(admission);
crate::ledger::write_candidates(root, req.slice, &ledger)?;
writeln!(io::stdout(), "{admitted_1}")?;
writeln!(
io::stderr(),
"candidate admit: {} admitted at {admitted_1} ({})",
row.id,
role_token(req.role)
)?;
Ok(())
}
fn prior_admission(ledger: &Candidates, role: CandidateRole) -> Option<&Admission> {
match role {
CandidateRole::CloseTarget => ledger.current_admission.close_target.as_ref(),
CandidateRole::ReviewSurface => ledger.current_admission.review_surface.as_ref(),
CandidateRole::Scratch => None,
}
}
pub(crate) fn run_candidate_status(path: Option<PathBuf>, slice: u32) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
candidate_status(&root, slice)
}
fn short_oid(oid: &str) -> String {
if oid.is_empty() || oid == "—" {
return oid.to_owned();
}
oid.chars().take(12).collect()
}
struct EvidenceRow {
refname: String,
group: &'static str,
tip: String,
}
fn candidate_status(root: &Path, slice: u32) -> anyhow::Result<()> {
let slice3 = format!("{slice:03}");
if let Some(branch) = current_branch(root)?
&& is_raw_evidence_ref(&branch)
{
writeln!(
io::stderr(),
"candidate status: the current worktree is checked out on raw evidence ref `{branch}` \
(review/* and phase/* are immutable) — status is read-only and changes nothing, but \
never edit an evidence ref in place; publish via `dispatch candidate create`"
)?;
}
let ledger = read_candidates(root, slice)?;
let evidence = collect_evidence(root, &slice3)?;
let mut grid: Vec<Vec<String>> = vec![cells(&["ref", "group", "tip"])];
for row in &evidence {
grid.push(cells(&[&row.refname, row.group, &short_oid(&row.tip)]));
}
writeln!(io::stdout(), "evidence refs:")?;
write!(io::stdout(), "{}", render_table(&grid, None))?;
writeln!(io::stdout(), "\ncandidates (interaction branches):")?;
let mut cgrid: Vec<Vec<String>> = vec![cells(&[
"id",
"branch",
"status",
"base",
"source",
"tip",
"admission",
"drift",
])];
let mut any_drift = false;
for row in &ledger.rows {
let report = candidate_report(root, &ledger, row)?;
any_drift |= report.drift;
cgrid.push(cells(&[
&row.id,
&row.target_ref,
status_token(row.status),
&short_oid(&row.base_oid),
&short_oid(&row.source_oid),
&short_oid(&report.tip),
&report.admission,
if report.drift { "DRIFT" } else { "ok" },
]));
}
if ledger.rows.is_empty() {
writeln!(io::stdout(), "(none recorded)")?;
} else {
write!(io::stdout(), "{}", render_table(&cgrid, None))?;
}
write_next_commands(&slice3, &ledger, any_drift)?;
Ok(())
}
struct CandidateReport {
tip: String,
admission: String,
drift: bool,
}
fn candidate_report(
root: &Path,
ledger: &Candidates,
row: &CandidateRow,
) -> anyhow::Result<CandidateReport> {
let tip = resolve_commit(root, &row.target_ref)?.unwrap_or_else(|| "—".to_owned());
let admitted = admission_for(ledger, &row.id);
let admission = match admitted {
Some(a) => format!("admitted ({})", a.review),
None => "—".to_owned(),
};
let pinned = match admitted {
Some(a) => Some(a.admitted_oid.as_str()),
None if row.status == CandidateStatus::Conflicted => None,
None if row.merge_oid.is_empty() => None,
None => Some(row.merge_oid.as_str()),
};
let drift = match (pinned, tip.as_str()) {
(Some(pin), live) => live != "—" && live != pin,
(None, _) => false,
};
Ok(CandidateReport {
tip,
admission,
drift,
})
}
fn admission_for<'a>(ledger: &'a Candidates, id: &str) -> Option<&'a Admission> {
[
ledger.current_admission.close_target.as_ref(),
ledger.current_admission.review_surface.as_ref(),
]
.into_iter()
.flatten()
.find(|a| a.candidate_id == id)
}
fn collect_evidence(root: &Path, slice3: &str) -> anyhow::Result<Vec<EvidenceRow>> {
let mut rows: Vec<EvidenceRow> = Vec::new();
for (refname, group) in [
(format!("refs/heads/dispatch/{slice3}"), "coordination"),
(format!("refs/heads/review/{slice3}"), "impl-bundle"),
] {
let tip = resolve_commit(root, &refname)?.unwrap_or_else(|| "—".to_owned());
rows.push(EvidenceRow {
refname,
group,
tip,
});
}
for refname in for_each_ref(root, &format!("refs/heads/phase/{slice3}-*"))? {
let tip = resolve_commit(root, &refname)?.unwrap_or_else(|| "—".to_owned());
rows.push(EvidenceRow {
refname,
group: "phase-cut",
tip,
});
}
Ok(rows)
}
fn for_each_ref(root: &Path, pattern: &str) -> anyhow::Result<Vec<String>> {
let out = git::git_text(root, &["for-each-ref", "--format=%(refname)", pattern])?;
Ok(out.lines().map(str::to_owned).collect())
}
fn status_token(status: CandidateStatus) -> &'static str {
match status {
CandidateStatus::Created => "created",
CandidateStatus::Conflicted => "conflicted",
CandidateStatus::Abandoned => "abandoned",
CandidateStatus::Superseded => "superseded",
}
}
fn cells(values: &[&str]) -> Vec<String> {
values.iter().map(|s| (*s).to_string()).collect()
}
fn write_next_commands(slice3: &str, ledger: &Candidates, any_drift: bool) -> anyhow::Result<()> {
let slice = slice3.trim_start_matches('0');
let slice = if slice.is_empty() { "0" } else { slice };
writeln!(io::stdout(), "\nnext:")?;
if ledger.rows.is_empty() {
writeln!(
io::stdout(),
" dispatch candidate create --slice {slice} --role review_surface \
--payload impl_bundle --base refs/heads/main --label review-001 --worktree"
)?;
return Ok(());
}
writeln!(
io::stdout(),
" dispatch candidate create --slice {slice} ... # publish a fresh candidate"
)?;
writeln!(
io::stdout(),
" dispatch candidate admit --slice {slice} --id <candidate-id> --review RV-NNN \
# pin a candidate for review/close"
)?;
if any_drift {
writeln!(
io::stdout(),
" note: a DRIFTED candidate's live tip moved off its recorded/admitted oid \
(immutable) — supersede with a fresh candidate rather than editing in place"
)?;
}
Ok(())
}
fn resolve_commit(root: &Path, refish: &str) -> anyhow::Result<Option<String>> {
Ok(git::git_opt(
root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{refish}^{{commit}}"),
],
)?)
}
fn tree_of(root: &Path, commit: &str) -> anyhow::Result<String> {
Ok(git::git_text(
root,
&["rev-parse", &format!("{commit}^{{tree}}")],
)?)
}
fn missing_committed_funnel_phases<'a>(
registry: &'a [BoundaryRow],
committed: &BTreeSet<&str>,
) -> Vec<&'a str> {
registry
.iter()
.filter(|r| matches!(r.provenance, Provenance::Funnel | Provenance::Unknown))
.map(|r| r.phase.as_str())
.filter(|p| !committed.contains(p))
.collect()
}
fn prepare_review(root: &Path, slice: u32) -> anyhow::Result<()> {
let slice3 = format!("{slice:03}");
let coord_ref = format!("refs/heads/dispatch/{slice3}");
let journal_path = format!(".doctrine/dispatch/{slice3}/journal.toml");
let tip0 = resolve_commit(root, &coord_ref)?
.with_context(|| format!("prepare-review: dispatch/{slice3} does not exist"))?;
let tip = match git::live_worktree_for_ref(root, &coord_ref)? {
Some(coord) => commit_boundaries(root, &tip0, &coord_ref, &coord, slice)?,
None => tip0,
};
let tip_tree = tree_of(root, &tip)?;
let trunk_tip = git::trunk_commit(root)?
.context("prepare-review: no trunk ref resolves — a trunk base is required")?;
let trunk_base = git::merge_base(root, &tip, &trunk_tip)?.with_context(|| {
format!(
"prepare-review: dispatch/{slice3} and trunk ({trunk_tip}) share no common ancestor"
)
})?;
let orthogonal = read_ledger::<Orthogonal>(root, &coord_ref, &slice3, "orthogonal.toml")?;
let boundaries = read_ledger::<Boundaries>(root, &coord_ref, &slice3, "boundaries.toml")?;
let primary = git::primary_worktree(root)?;
let registry = crate::state::read_source_deltas(&primary, slice)?;
let committed: BTreeSet<&str> = boundaries.rows.iter().map(|r| r.phase.as_str()).collect();
let missing = missing_committed_funnel_phases(®istry, &committed);
if !missing.is_empty() {
bail!(
"prepare-review: committed boundaries ledger is missing phase(s) {missing:?} on \
dispatch/{slice3} that the registry records as funnel-owned (or legacy/unclassified). \
The registry has them but the dispatch ref does not — the coordination worktree was \
likely removed before prepare-review, or these are pre-provenance rows. Re-run with \
the coord worktree present (it persists until integrate), or record-delta + COMMIT \
the ledger for the named phase(s)."
);
}
for row in &boundaries.rows {
crate::state::record_source_delta(&primary, slice, row.clone())?;
}
if let crate::state::Completeness::Incomplete { gaps } =
crate::state::registry_completeness(&primary, &primary, slice)?
{
let detail = gaps
.iter()
.map(crate::state::CompletenessGap::describe)
.collect::<Vec<_>>()
.join("; ");
bail!(
"prepare-review: conformance registry incomplete: {detail}; \
record-delta the missing phase(s) before audit"
);
}
let mut planned: Vec<Planned> = Vec::new();
plan_review(
root,
&slice3,
&tip,
&tip_tree,
&trunk_base,
&orthogonal,
&mut planned,
)?;
plan_phases(root, &slice3, &trunk_base, &boundaries, &mut planned)?;
let mut journal = pending_journal(&planned);
let outcomes = with_journaled_projection(
root,
&tip,
&tip_tree,
&journal_path,
&coord_ref,
&mut journal,
"journal: prepare-review",
|root, row| match git::update_ref_cas(
root,
&row.target_ref,
&row.planned_new_oid,
ZERO_OID,
)? {
RefCas::Updated => {
row.status = LedgerStatus::Verified;
row.applied_new_oid = row.planned_new_oid.clone();
writeln!(io::stdout(), "{}", row.target_ref)?;
Ok(RowOutcome::Done {
disposition: Disposition::Created,
})
}
RefCas::Moved { actual } => {
row.status = LedgerStatus::Failed;
Ok(RowOutcome::Refused {
token: format!(
"{} (exists at {})",
row.target_ref,
actual.as_deref().unwrap_or("?")
),
})
}
},
)?;
let stale: Vec<String> = outcomes
.into_iter()
.filter_map(|o| match o {
RowOutcome::Refused { token } => Some(token),
RowOutcome::Done { .. } => None,
})
.collect();
if stale.is_empty() {
writeln!(
io::stderr(),
"prepare-review: {} ref(s) created",
journal.rows.len()
)?;
Ok(())
} else {
bail!(
"prepare-review: {} stale ref(s) reported, not clobbered: {}",
stale.len(),
stale.join(", ")
)
}
}
fn integrate(
root: &Path,
slice: u32,
trunk: Option<&str>,
edge: Option<&str>,
allow: &BTreeSet<String>,
) -> anyhow::Result<()> {
let slice3 = format!("{slice:03}");
let coord_ref = format!("refs/heads/dispatch/{slice3}");
let journal_path = format!(".doctrine/dispatch/{slice3}/journal.toml");
let tip = resolve_commit(root, &coord_ref)?
.with_context(|| format!("integrate: dispatch/{slice3} does not exist"))?;
let tip_tree = tree_of(root, &tip)?;
let mut journal = read_ledger::<Journal>(root, &coord_ref, &slice3, "journal.toml")?;
if journal.rows.is_empty() {
bail!("integrate: no prepared journal on dispatch/{slice3} — run prepare-review first");
}
let candidates = read_candidates(root, slice)?;
let candidate_active = !candidates.rows.is_empty();
let fresh = |j: &Journal, target: &str| !j.rows.iter().any(|r| r.target_ref == target);
if let Some(trunk_ref) = trunk.filter(|t| fresh(&journal, t)) {
let row = if candidate_active {
plan_candidate_trunk_row(root, &candidates, trunk_ref)?
} else {
plan_trunk_row(root, &slice3, &journal, trunk_ref)?
};
journal.rows.push(row);
}
if let Some(edge_ref) = edge.filter(|e| fresh(&journal, e)) {
let row = if candidate_active {
plan_candidate_edge_row(root, &candidates, edge_ref)?
} else {
plan_edge_row(root, &slice3, edge_ref)?
};
journal.rows.push(row);
}
for row in &journal.rows {
if let Some(wt) = git::worktree_for_ref(root, &row.target_ref)?
&& !git::tree_clean(&wt)?
{
bail!("integrate-dirty-worktree ({})", row.target_ref);
}
}
journal.allowed_clobbers = allow.iter().cloned().collect();
let outcomes = with_journaled_projection(
root,
&tip,
&tip_tree,
&journal_path,
&coord_ref,
&mut journal,
"journal: integrate",
|root, row| advance_row(root, row, allow),
)?;
report_integrate(&journal, &outcomes)
}
fn advance_row(
root: &Path,
row: &mut JournalRow,
allow: &BTreeSet<String>,
) -> anyhow::Result<RowOutcome> {
let actual = resolve_commit(root, &row.target_ref)?;
let current = actual.as_deref().unwrap_or(ZERO_OID);
let planned = row.planned_new_oid.clone();
let expected_old = row.expected_old_oid.clone();
if current == planned {
row.status = LedgerStatus::Verified;
row.applied_new_oid = planned;
return Ok(RowOutcome::Done {
disposition: Disposition::NoOp,
});
}
if current != expected_old {
row.status = LedgerStatus::Failed;
return Ok(RowOutcome::Refused {
token: format!(
"{} (target at {})",
row.target_ref,
actual.as_deref().unwrap_or("?")
),
});
}
if current != ZERO_OID
&& let Some(token) = corpus_clobber_refusal(root, &planned, current, allow)?
{
row.status = LedgerStatus::Failed;
return Ok(RowOutcome::Refused { token });
}
match git::worktree_for_ref(root, &row.target_ref)? {
None => advance_pure_ref(root, row, &planned, &expected_old),
Some(wt) => advance_checked_out(root, row, &wt, &planned, &expected_old),
}
}
fn corpus_clobber_refusal(
root: &Path,
new: &str,
cur: &str,
allow: &BTreeSet<String>,
) -> anyhow::Result<Option<String>> {
let base = git::merge_base(root, new, cur)?.unwrap_or_else(|| git::EMPTY_TREE_OID.to_owned());
let changed = git::diff_doctrine_paths(root, &base, cur, corpus_guard::DOCTRINE_PATHSPEC)?;
if changed.is_empty() {
return Ok(None);
}
let readings = changed
.into_iter()
.map(|path| -> anyhow::Result<corpus_guard::ClobberReading> {
let base_oid = git::blob_oid_at(root, &base, &path)?;
let new_oid = git::blob_oid_at(root, new, &path)?;
Ok(corpus_guard::ClobberReading {
path,
base_oid,
new_oid,
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
let clobbers = corpus_guard::corpus_clobber_check(&readings, allow);
if clobbers.is_empty() {
Ok(None)
} else {
Ok(Some(format!(
"{} ({})",
corpus_guard::CORPUS_CLOBBER,
corpus_guard::render_clobbers(&clobbers, corpus_guard::CLOBBER_RENDER_CAP),
)))
}
}
fn advance_pure_ref(
root: &Path,
row: &mut JournalRow,
planned: &str,
expected_old: &str,
) -> anyhow::Result<RowOutcome> {
match git::update_ref_cas(root, &row.target_ref, planned, expected_old)? {
RefCas::Moved { actual } => {
row.status = LedgerStatus::Failed;
Ok(RowOutcome::Refused {
token: format!(
"{} (target at {})",
row.target_ref,
actual.as_deref().unwrap_or("?")
),
})
}
RefCas::Updated => {
row.status = LedgerStatus::Verified;
planned.clone_into(&mut row.applied_new_oid);
Ok(RowOutcome::Done {
disposition: Disposition::AdvancedPureRef,
})
}
}
}
fn advance_checked_out(
root: &Path,
row: &mut JournalRow,
wt: &Path,
planned: &str,
expected_old: &str,
) -> anyhow::Result<RowOutcome> {
if git::is_ancestor(root, expected_old, planned)? {
match git::ff_advance_in_worktree(wt, &row.target_ref, planned)? {
git::FfAdvance::Advanced => {
row.status = LedgerStatus::Verified;
planned.clone_into(&mut row.applied_new_oid);
Ok(RowOutcome::Done {
disposition: Disposition::AdvancedResynced,
})
}
git::FfAdvance::Raced { token } => {
row.status = LedgerStatus::Failed;
Ok(RowOutcome::Refused {
token: format!("{} ({token})", row.target_ref),
})
}
}
} else {
row.status = LedgerStatus::Failed;
Ok(RowOutcome::Refused {
token: format!("integrate-nonff-checkout ({})", row.target_ref),
})
}
}
fn report_integrate(journal: &Journal, outcomes: &[RowOutcome]) -> anyhow::Result<()> {
let mut applied_refs: Vec<String> = Vec::new();
let mut detail: Vec<String> = Vec::new();
let mut refusals: Vec<String> = Vec::new();
for (row, outcome) in journal.rows.iter().zip(outcomes) {
match outcome {
RowOutcome::Done { disposition } => match disposition {
Disposition::NoOp => {
detail.push(format!("integrate: {} (no-op)", row.target_ref));
}
disp => {
applied_refs.push(row.target_ref.clone());
detail.push(format!(
"integrate: {} {}..{} ({})",
row.target_ref,
short_oid(&row.expected_old_oid),
short_oid(&row.applied_new_oid),
disp.label(),
));
}
},
RowOutcome::Refused { token } => refusals.push(token.clone()),
}
}
for refname in &applied_refs {
writeln!(io::stdout(), "{refname}")?;
}
for line in &detail {
writeln!(io::stderr(), "{line}")?;
}
if refusals.is_empty() {
writeln!(
io::stderr(),
"integrate: {} ref(s) replayed",
journal.rows.len()
)?;
Ok(())
} else {
bail!(
"integrate: {} moved target(s), not clobbered: {}",
refusals.len(),
refusals.join(", ")
)
}
}
fn phase_chain_tip(journal: &Journal, slice3: &str) -> Option<String> {
let prefix = format!("refs/heads/phase/{slice3}-");
journal
.rows
.iter()
.filter(|r| r.status == LedgerStatus::Verified)
.filter_map(|r| {
r.target_ref
.strip_prefix(&prefix)
.and_then(|nn| nn.parse::<u32>().ok())
.map(|n| (n, r.target_ref.clone()))
})
.max_by_key(|(n, _)| *n)
.map(|(_, refname)| refname)
}
fn plan_trunk_row(
root: &Path,
slice3: &str,
journal: &Journal,
trunk_ref: &str,
) -> anyhow::Result<JournalRow> {
let phase_ref = phase_chain_tip(journal, slice3).with_context(|| {
format!("integrate --trunk: no phase/{slice3}-NN code units to integrate")
})?;
let planned = resolve_commit(root, &phase_ref)?
.with_context(|| format!("integrate --trunk: {phase_ref} does not resolve"))?;
let expected_old = resolve_commit(root, trunk_ref)?;
if let Some(tip) = &expected_old {
anyhow::ensure!(
git::is_ancestor(root, tip, &planned)?,
"integrate --trunk: {planned} does not fast-forward {trunk_ref} (at {tip}) — \
trunk moved; re-anchor required, not auto-resolved"
);
}
Ok(projection_row(trunk_ref, planned, expected_old))
}
fn plan_edge_row(root: &Path, slice3: &str, edge_ref: &str) -> anyhow::Result<JournalRow> {
let review_ref = format!("refs/heads/review/{slice3}");
let planned = resolve_commit(root, &review_ref)?
.with_context(|| format!("integrate --edge: {review_ref} does not resolve"))?;
let expected_old = resolve_commit(root, edge_ref)?;
Ok(projection_row(edge_ref, planned, expected_old))
}
fn plan_candidate_trunk_row(
root: &Path,
candidates: &Candidates,
trunk_ref: &str,
) -> anyhow::Result<JournalRow> {
let admission = candidates.current_admission.close_target.as_ref().context(
"integrate --trunk: a candidate workflow is active but no close_target admission \
exists — run `dispatch candidate admit --role close_target` first; integrate will \
not fall back to a raw phase ref",
)?;
let planned = admission.admitted_oid.clone();
let expected_old = resolve_commit(root, trunk_ref)?;
if let Some(tip) = &expected_old {
anyhow::ensure!(
git::is_ancestor(root, tip, &planned)?,
"integrate --trunk: admitted close_target {planned} does not fast-forward {trunk_ref} \
(at {tip}) — trunk moved; create a superseding close-target candidate on the new \
base and re-admit (not auto-resolved)"
);
}
Ok(projection_row(trunk_ref, planned, expected_old))
}
fn plan_candidate_edge_row(
root: &Path,
candidates: &Candidates,
edge_ref: &str,
) -> anyhow::Result<JournalRow> {
let admission = candidates
.current_admission
.review_surface
.as_ref()
.context(
"integrate --edge: a candidate workflow is active but no review_surface admission \
exists — run `dispatch candidate admit --role review_surface` first; integrate will \
not fall back to the raw review ref",
)?;
let planned = admission.admitted_oid.clone();
let expected_old = resolve_commit(root, edge_ref)?;
Ok(projection_row(edge_ref, planned, expected_old))
}
fn projection_row(target_ref: &str, planned: String, expected_old: Option<String>) -> JournalRow {
JournalRow {
source_oid: planned.clone(),
target_ref: target_ref.to_owned(),
expected_old_oid: expected_old.unwrap_or_else(|| ZERO_OID.to_owned()),
planned_new_oid: planned,
applied_new_oid: String::new(),
status: LedgerStatus::Pending,
}
}
fn read_ledger<T: serde::de::DeserializeOwned + Default>(
root: &Path,
coord_ref: &str,
slice3: &str,
file: &str,
) -> anyhow::Result<T> {
let path = format!(".doctrine/dispatch/{slice3}/{file}");
match git::read_path_at(root, coord_ref, &path)? {
Some(text) => Ok(toml::from_str(&text)?),
None => Ok(T::default()),
}
}
fn plan_review(
root: &Path,
slice3: &str,
tip: &str,
tip_tree: &str,
trunk_base: &str,
orthogonal: &Orthogonal,
planned: &mut Vec<Planned>,
) -> anyhow::Result<()> {
let mut exclude: Vec<String> = vec![format!(".doctrine/dispatch/{slice3}")];
for mark in &orthogonal.rows {
if mark.status == LedgerStatus::Verified {
exclude.push(mark.path.clone());
}
}
let exclude_refs: Vec<&str> = exclude.iter().map(String::as_str).collect();
let review_tree = git::filter_tree(root, tip_tree, &exclude_refs)?;
let review_commit = git::commit_tree(
root,
&review_tree,
trunk_base,
&format!("review({slice3}): impl bundle"),
)?;
planned.push(Planned {
target_ref: format!("refs/heads/review/{slice3}"),
source_oid: tip.to_owned(),
commit_oid: review_commit,
});
Ok(())
}
fn plan_phases(
root: &Path,
slice3: &str,
trunk_base: &str,
boundaries: &Boundaries,
planned: &mut Vec<Planned>,
) -> anyhow::Result<()> {
let mut parent = trunk_base.to_owned();
for boundary in &boundaries.rows {
if boundary.code_start_oid == boundary.code_end_oid {
continue; }
let nn = boundary
.phase
.strip_prefix("PHASE-")
.unwrap_or(&boundary.phase);
let code_tree = tree_of(root, &boundary.code_end_oid)?;
let phase_tree =
git::filter_tree(root, &code_tree, &[crate::corpus_guard::DOCTRINE_PATHSPEC])?;
let phase_commit =
git::commit_tree(root, &phase_tree, &parent, &format!("phase({slice3}-{nn})"))?;
planned.push(Planned {
target_ref: format!("refs/heads/phase/{slice3}-{nn}"),
source_oid: boundary.code_end_oid.clone(),
commit_oid: phase_commit.clone(),
});
parent = phase_commit;
}
Ok(())
}
fn pending_journal(planned: &[Planned]) -> Journal {
Journal {
rows: planned
.iter()
.map(|p| JournalRow {
source_oid: p.source_oid.clone(),
target_ref: p.target_ref.clone(),
expected_old_oid: ZERO_OID.to_owned(),
planned_new_oid: p.commit_oid.clone(),
applied_new_oid: String::new(),
status: LedgerStatus::Pending,
})
.collect(),
allowed_clobbers: Vec::new(),
}
}
fn commit_journal(
root: &Path,
base_tree: &str,
parent: &str,
journal_path: &str,
coord_ref: &str,
journal: &Journal,
msg: &str,
) -> anyhow::Result<String> {
let body = journal.to_toml()?;
let tree = git::tree_with_file(root, base_tree, journal_path, &body)?;
let commit = git::commit_tree(root, &tree, parent, msg)?;
match git::update_ref_cas(root, coord_ref, &commit, parent)? {
RefCas::Updated => Ok(commit),
RefCas::Moved { actual } => bail!(
"journal-commit: dispatch branch moved under us (expected {parent}, found {})",
actual.as_deref().unwrap_or("?")
),
}
}
fn commit_boundaries(
root: &Path,
parent: &str,
coord_ref: &str,
coord: &git::WorktreeEntry,
slice: u32,
) -> anyhow::Result<String> {
let Some(raw) = crate::ledger::read_boundaries_file(&coord.path, slice)? else {
return Ok(parent.to_owned()); };
let boundaries = Boundaries::parse(&raw).with_context(|| {
format!("commit_boundaries: working boundaries.toml for dispatch/{slice:03} is malformed")
})?;
let canonical = boundaries.to_toml()?;
let path = format!(".doctrine/dispatch/{slice:03}/boundaries.toml");
let tip_tree = tree_of(root, parent)?;
let candidate = git::tree_with_file(root, &tip_tree, &path, &canonical)?;
if candidate == tip_tree {
return Ok(parent.to_owned()); }
let commit = git::commit_tree(root, &candidate, parent, "ledger: boundaries")?;
match git::update_ref_cas(root, coord_ref, &commit, parent)? {
RefCas::Updated => Ok(commit),
RefCas::Moved { actual } => bail!(
"commit_boundaries: dispatch branch moved under us (expected {parent}, found {})",
actual.as_deref().unwrap_or("?")
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Disposition {
Created,
NoOp,
AdvancedResynced,
AdvancedPureRef,
}
impl Disposition {
fn label(self) -> &'static str {
match self {
Self::Created => "created",
Self::NoOp => "no-op",
Self::AdvancedResynced => "advanced+resynced",
Self::AdvancedPureRef => "advanced+pure-ref",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RowOutcome {
Done { disposition: Disposition },
Refused { token: String },
}
#[expect(
clippy::too_many_arguments,
reason = "thin journal-cycle bracket threads the commit_journal arg set plus the apply closure"
)]
fn with_journaled_projection(
root: &Path,
tip: &str,
tip_tree: &str,
journal_path: &str,
coord_ref: &str,
journal: &mut Journal,
message: &str,
mut apply: impl FnMut(&Path, &mut JournalRow) -> anyhow::Result<RowOutcome>,
) -> anyhow::Result<Vec<RowOutcome>> {
let journal_commit = commit_journal(
root,
tip_tree,
tip,
journal_path,
coord_ref,
journal,
message,
)?;
let mut outcomes = Vec::with_capacity(journal.rows.len());
for row in &mut journal.rows {
outcomes.push(apply(root, row)?);
}
commit_journal(
root,
tip_tree,
&journal_commit,
journal_path,
coord_ref,
journal,
message,
)?;
Ok(outcomes)
}
pub(crate) fn render_phase_table(rows: &[(String, String, String)]) -> String {
use comfy_table::Table;
let mut table = Table::new();
table
.load_preset(comfy_table::presets::NOTHING)
.set_header(vec![" ID", " Status", " Name"])
.force_no_tty();
for (id, status, name) in rows {
table.add_row(vec![
format!(" {id}"),
format!(" {status}"),
format!(" {name}"),
]);
}
let out = table.to_string();
out.lines()
.map(|l| l.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn run_plan_next(path: Option<PathBuf>, slice: u32, json: bool) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let plan = crate::slice::read_plan(&root.join(".doctrine/slice"), slice)?;
let state_dir = crate::state::phases_dir(&root, slice);
let mut rows: Vec<(String, String, String)> = Vec::new();
for ph in &plan.phases {
let stem = ph.id.to_lowercase();
let status = match crate::state::read_phase_status(&state_dir, &stem) {
Ok(Some(s)) => s,
Ok(None) => "pending".to_string(), Err(_) => "unknown".to_string(),
};
rows.push((ph.id.clone(), status, ph.name.clone()));
}
let mut next: Vec<String> = Vec::new();
let mut found_actionable = false;
let mut saw_blocked = false;
for (id, status, _) in &rows {
match status.as_str() {
"completed" => {}
"blocked" => {
saw_blocked = true;
if found_actionable {
break; }
}
"in_progress" => {
if !found_actionable {
next.push(id.clone());
break; }
}
_ => {
if !found_actionable {
next.push(id.clone());
found_actionable = true;
} else if status.as_str() == "pending" {
next.push(id.clone());
} else {
break; }
}
}
}
if json {
#[derive(serde::Serialize)]
struct PhaseRow {
id: String,
name: String,
status: String,
}
#[derive(serde::Serialize)]
struct Output {
phases: Vec<PhaseRow>,
next: Vec<String>,
batching_requires_phase_plan: bool,
}
let output = Output {
phases: rows
.iter()
.map(|(id, status, name)| PhaseRow {
id: id.clone(),
name: name.clone(),
status: status.clone(),
})
.collect(),
next,
batching_requires_phase_plan: true,
};
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&output)?)?;
} else {
let table = render_phase_table(&rows);
writeln!(io::stdout(), "{table}")?;
if next.is_empty() {
if saw_blocked {
writeln!(
io::stdout(),
"\nnext: (none — all remaining phases are blocked)"
)?;
}
} else {
let ids = next.join(", ");
writeln!(io::stdout(), "\nnext: {ids}")?;
writeln!(
io::stdout(),
" ⚠ run /phase-plan before parallel spawn; do not assume file-disjointness"
)?;
}
}
Ok(())
}
struct Drift {
trunk_tip: String,
fork_point: String,
ahead: u32,
}
fn trunk_drift(root: &Path, tip: &str) -> anyhow::Result<Option<Drift>> {
let trunk_tip = git::trunk_commit(root)?.with_context(|| "trunk ref not found")?;
let Some(fork_point) = git::merge_base(root, tip, &trunk_tip)? else {
return Ok(None);
};
let ahead_cnt = git::git_text(
root,
&["rev-list", "--count", &format!("{fork_point}..{trunk_tip}")],
)?;
let ahead: u32 = ahead_cnt.trim().parse().unwrap_or(0);
Ok(Some(Drift {
trunk_tip,
fork_point,
ahead,
}))
}
pub(crate) fn run_status(path: Option<PathBuf>, slice: u32, json: bool) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice3 = format!("{slice:03}");
let dispatch_ref = format!("refs/heads/dispatch/{slice3}");
let dispatch_tip = resolve_commit(&root, &dispatch_ref)?.with_context(|| {
format!("dispatch branch not found; run 'dispatch setup --slice {slice}' first")
})?;
let dispatch_short = git::git_text(&root, &["rev-parse", "--short=7", &dispatch_tip])?;
let coord_state = find_coordination_worktree(&root, &slice3);
let Drift {
trunk_tip,
fork_point,
ahead,
} = trunk_drift(&root, &dispatch_tip)?
.with_context(|| format!("dispatch/{slice3} and trunk share no common ancestor"))?;
let trunk_state = if ahead == 0 { "stable" } else { "moved" };
let plan = crate::slice::read_plan(&root.join(".doctrine/slice"), slice)?;
let state_dir = crate::state::phases_dir(&root, slice);
let mut phase_rows: Vec<(String, String, String)> = Vec::new();
for ph in &plan.phases {
let stem = ph.id.to_lowercase();
let status = match crate::state::read_phase_status(&state_dir, &stem) {
Ok(Some(s)) => s,
Ok(None) => "pending".to_string(),
Err(_) => "unknown".to_string(),
};
phase_rows.push((ph.id.clone(), status, ph.name.clone()));
}
let review_ref = format!("refs/heads/review/{slice3}");
let review_exists = resolve_commit(&root, &review_ref)?.is_some();
let phase_ref_count = count_phase_refs(&root, &slice3);
let candidates = read_candidates(&root, slice)?;
let candidate_total = candidates.rows.len();
let candidate_admitted = [
candidates.current_admission.close_target.is_some(),
candidates.current_admission.review_surface.is_some(),
]
.into_iter()
.filter(|&x| x)
.count();
let all_completed = phase_rows
.iter()
.all(|(_, status, _)| status == "completed");
let coord_live = !matches!(coord_state.as_str(), "(removed)");
let admitted_ct = candidates.current_admission.close_target.as_ref();
let review_tip = if review_exists {
resolve_commit(&root, &review_ref)?.unwrap_or(dispatch_tip)
} else {
dispatch_tip
};
let bundle_stale = all_completed && trunk_drift(&root, &review_tip)?.map_or(0, |d| d.ahead) > 0;
let admitted_is_ancestor = match admitted_ct {
Some(ct) if !coord_live => is_ancestor_of_trunk(&root, &ct.admitted_oid, &trunk_tip)?,
_ => false,
};
let next_guidance = select_guidance(GuidanceInputs {
all_completed,
bundle_stale,
review_exists,
coord_live,
admitted: admitted_ct.is_some(),
admitted_is_ancestor,
next_phases: || compute_next_phases(&phase_rows),
});
if json {
let output = StatusOutput {
dispatch: DispatchState {
r#ref: dispatch_ref,
tip: dispatch_short,
},
coord: CoordState {
state: if coord_live {
"live".to_string()
} else {
"removed".to_string()
},
path: if coord_live { Some(coord_state) } else { None },
},
trunk: TrunkState {
state: trunk_state.to_string(),
fork_point,
ahead,
},
phases: phase_rows
.iter()
.map(|(id, status, name)| PhaseState {
id: id.clone(),
name: name.clone(),
status: status.clone(),
})
.collect(),
sync: SyncState {
state: if review_exists {
"prepared".to_string()
} else {
"not_prepared".to_string()
},
review_ref: if review_exists {
Some(review_ref)
} else {
None
},
phase_cuts: phase_ref_count,
},
candidates: CandidateSummary {
total: candidate_total,
admitted: candidate_admitted,
},
next: next_guidance.to_json(),
};
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&output)?)?;
} else {
writeln!(io::stdout(), "dispatch: {dispatch_ref} ({dispatch_short})")?;
writeln!(io::stdout(), "coord: {coord_state}")?;
if ahead > 0 {
writeln!(
io::stdout(),
"trunk: {trunk_state} ({ahead} commit(s) ahead of fork-point)"
)?;
} else {
writeln!(io::stdout(), "trunk: {trunk_state}")?;
}
writeln!(io::stdout())?;
writeln!(io::stdout(), "phases:")?;
write!(io::stdout(), "{}", render_phase_table(&phase_rows))?;
writeln!(io::stdout())?;
writeln!(io::stdout())?;
if review_exists {
writeln!(
io::stdout(),
"sync: prepared — {review_ref} ({phase_ref_count} phase cut(s))"
)?;
} else {
writeln!(io::stdout(), "sync: not yet run")?;
}
writeln!(
io::stdout(),
"candidates: {candidate_total} ({candidate_admitted} admitted)"
)?;
match &next_guidance {
NextGuidance::Phases { phases } => {
let ids = phases.join(", ");
writeln!(io::stdout(), "next: {ids}")?;
}
NextGuidance::RefreshBase => {
writeln!(
io::stdout(),
"next: trunk advanced past the prepared base — run 'dispatch refresh-base --slice {slice}' then re-prepare"
)?;
}
NextGuidance::PrepareReview => {
writeln!(
io::stdout(),
"next: all phases completed — run 'dispatch sync --prepare-review'"
)?;
}
NextGuidance::AuditThenIntegrate => {
writeln!(
io::stdout(),
"next: all phases completed — admitted candidate exists; run audit then 'dispatch sync --integrate'"
)?;
}
NextGuidance::AuditOrCandidateStatus => {
writeln!(
io::stdout(),
"next: all phases completed — review ref prepared; run audit or 'dispatch candidate status'"
)?;
}
NextGuidance::Complete => {
writeln!(
io::stdout(),
"next: complete — coordination worktree removed; slice is integrated"
)?;
}
NextGuidance::AwaitingIntegration => {
writeln!(
io::stdout(),
"next: awaiting integration — run 'dispatch sync --integrate' after audit"
)?;
}
}
}
Ok(())
}
fn find_coordination_worktree(root: &Path, slice3: &str) -> String {
let target_branch = format!("refs/heads/dispatch/{slice3}");
match git::worktree_for_ref(root, &target_branch) {
Ok(Some(path)) => path.to_string_lossy().into_owned(),
Ok(None) | Err(_) => "(removed)".to_string(),
}
}
fn count_phase_refs(root: &Path, slice3: &str) -> usize {
let pattern = format!("refs/heads/phase/{slice3}-*");
let Ok(out) = git::git_text(root, &["for-each-ref", "--format=%(refname)", &pattern]) else {
return 0;
};
if out.trim().is_empty() {
0
} else {
out.lines().count()
}
}
fn compute_next_phases(rows: &[(String, String, String)]) -> Vec<String> {
let mut next: Vec<String> = Vec::new();
let mut found_actionable = false;
for (id, status, _) in rows {
match status.as_str() {
"completed" => {}
"blocked" => {
if found_actionable {
break;
}
}
"in_progress" => {
if !found_actionable {
next.push(id.clone());
break;
}
}
_ => {
if !found_actionable {
next.push(id.clone());
found_actionable = true;
} else if status.as_str() == "pending" {
next.push(id.clone());
} else {
break;
}
}
}
}
next
}
fn is_ancestor_of_trunk(root: &Path, oid: &str, trunk_tip: &str) -> anyhow::Result<bool> {
if oid == trunk_tip {
return Ok(true);
}
let mb = git::merge_base(root, oid, trunk_tip)?;
Ok(mb.as_deref() == Some(oid))
}
#[derive(serde::Serialize)]
struct StatusOutput {
dispatch: DispatchState,
coord: CoordState,
trunk: TrunkState,
phases: Vec<PhaseState>,
sync: SyncState,
candidates: CandidateSummary,
next: NextJson,
}
#[derive(serde::Serialize)]
struct DispatchState {
#[serde(rename = "ref")]
r#ref: String,
tip: String,
}
#[derive(serde::Serialize)]
struct CoordState {
state: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
}
#[derive(serde::Serialize)]
struct TrunkState {
state: String,
fork_point: String,
ahead: u32,
}
#[derive(serde::Serialize)]
struct PhaseState {
id: String,
name: String,
status: String,
}
#[derive(serde::Serialize)]
struct SyncState {
state: String,
#[serde(skip_serializing_if = "Option::is_none")]
review_ref: Option<String>,
phase_cuts: usize,
}
#[derive(serde::Serialize)]
struct CandidateSummary {
total: usize,
admitted: usize,
}
#[derive(serde::Serialize)]
struct NextJson {
kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
phases: Option<Vec<String>>,
}
struct GuidanceInputs<F: FnOnce() -> Vec<String>> {
all_completed: bool,
bundle_stale: bool,
review_exists: bool,
coord_live: bool,
admitted: bool,
admitted_is_ancestor: bool,
next_phases: F,
}
fn select_guidance<F: FnOnce() -> Vec<String>>(inputs: GuidanceInputs<F>) -> NextGuidance {
let GuidanceInputs {
all_completed,
bundle_stale,
review_exists,
coord_live,
admitted,
admitted_is_ancestor,
next_phases,
} = inputs;
if !all_completed {
NextGuidance::Phases {
phases: next_phases(),
}
} else if bundle_stale {
NextGuidance::RefreshBase
} else if !review_exists {
NextGuidance::PrepareReview
} else if coord_live && admitted {
NextGuidance::AuditThenIntegrate
} else if coord_live {
NextGuidance::AuditOrCandidateStatus
} else if admitted {
if admitted_is_ancestor {
NextGuidance::Complete
} else {
NextGuidance::AwaitingIntegration
}
} else {
NextGuidance::AuditOrCandidateStatus
}
}
enum NextGuidance {
Phases {
phases: Vec<String>,
},
RefreshBase,
PrepareReview,
AuditThenIntegrate,
AuditOrCandidateStatus,
Complete,
AwaitingIntegration,
}
impl NextGuidance {
fn to_json(&self) -> NextJson {
match self {
NextGuidance::Phases { phases } => NextJson {
kind: "phases".to_string(),
phases: Some(phases.clone()),
},
NextGuidance::RefreshBase => NextJson {
kind: "refresh_base".to_string(),
phases: None,
},
NextGuidance::PrepareReview => NextJson {
kind: "blocked".to_string(),
phases: None,
},
NextGuidance::AuditThenIntegrate | NextGuidance::AuditOrCandidateStatus => NextJson {
kind: "audit".to_string(),
phases: None,
},
NextGuidance::Complete => NextJson {
kind: "completed".to_string(),
phases: None,
},
NextGuidance::AwaitingIntegration => NextJson {
kind: "awaiting_integration".to_string(),
phases: None,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::SCHEMA_PLAN_OVERVIEW;
use std::path::Path;
fn git(dir: &Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn init_repo(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
git(dir, &["init", "-q", "-b", "main"]);
git(dir, &["config", "user.email", "t@example.com"]);
git(dir, &["config", "user.name", "Test"]);
std::fs::create_dir_all(dir.join(".doctrine")).unwrap();
std::fs::write(dir.join("a.txt"), "hello").unwrap();
git(dir, &["add", "."]);
git(dir, &["commit", "-q", "-m", "base"]);
}
fn seed_slice_dir(dir: &Path, slice: u32) {
let rel = format!(".doctrine/slice/{slice:03}");
let full = dir.join(&rel);
std::fs::create_dir_all(&full).unwrap();
std::fs::write(
full.join("slice.toml"),
format!("id = {slice}\ntitle = \"test\"\nkind = \"slice\"\nstatus = \"planned\"\n"),
)
.unwrap();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-q", "-m", "seed slice dir"]);
}
fn seed_plan(dir: &Path, slice: u32, phases: &str) {
let rel = format!(".doctrine/slice/{slice:03}/plan.toml");
let full = dir.join(&rel);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(&full, phases).unwrap();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-q", "-m", "seed plan"]);
}
#[test]
fn dispatch_setup_gates_on_no_plan() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
let holder = tempfile::tempdir().unwrap();
let coord = holder.path().join("coord");
let result = run_setup(Some(src.path().to_path_buf()), 85, &coord, false);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("no plan"),
"error should mention 'no plan'; got: {err}"
);
}
#[test]
fn dispatch_setup_gates_on_empty_plan() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&format!("schema = \"{SCHEMA_PLAN_OVERVIEW}\"\nversion = 1\nslice = \"SL-085\"\n"),
);
let holder = tempfile::tempdir().unwrap();
let coord = holder.path().join("coord");
let result = run_setup(Some(src.path().to_path_buf()), 85, &coord, false);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("no phases"),
"error should mention 'no phases'; got: {err}"
);
}
#[test]
fn dispatch_setup_creates_coordination() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&format!(
"schema = \"{SCHEMA_PLAN_OVERVIEW}\"\nversion = 1\nslice = \"SL-085\"\n\n[[phase]]\nid = \"PHASE-01\"\nname = \"fixture\"\nobjective = \"fixture\"\n"
),
);
let holder = tempfile::tempdir().unwrap();
let coord = holder.path().join("coord");
let result = run_setup(Some(src.path().to_path_buf()), 85, &coord, false);
assert!(result.is_ok(), "setup must succeed; err: {result:?}");
assert!(coord.exists(), "coordination dir exists");
assert!(coord.join("a.txt").exists(), "checkout exists");
assert!(coord.join(".doctrine").exists(), "provisioned");
}
#[test]
fn classify_coord_placement_truth_table() {
assert!(classify_coord_placement(true, true).is_ok());
assert!(classify_coord_placement(true, false).is_ok());
assert!(classify_coord_placement(false, false).is_ok());
assert_eq!(
classify_coord_placement(false, true),
Err("coord-outside-root-under-claude")
);
}
#[test]
fn dispatch_setup_refuses_outside_root_under_claude() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&format!(
"schema = \"{SCHEMA_PLAN_OVERVIEW}\"\nversion = 1\nslice = \"SL-085\"\n\n[[phase]]\nid = \"PHASE-01\"\nname = \"fixture\"\nobjective = \"fixture\"\n"
),
);
let holder = tempfile::tempdir().unwrap();
let coord = holder.path().join("coord");
let result = run_setup(Some(src.path().to_path_buf()), 85, &coord, true);
assert!(
result.is_err(),
"must refuse outside-root coord under Claude"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("coord-outside-root-under-claude"),
"error names the placement token; got: {err}"
);
assert!(
!coord.exists(),
"no coordination worktree created on refusal"
);
}
#[test]
fn dispatch_setup_allows_inside_root_under_claude() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&format!(
"schema = \"{SCHEMA_PLAN_OVERVIEW}\"\nversion = 1\nslice = \"SL-085\"\n\n[[phase]]\nid = \"PHASE-01\"\nname = \"fixture\"\nobjective = \"fixture\"\n"
),
);
let coord = src.path().join(".dispatch/SL-085");
let result = run_setup(Some(src.path().to_path_buf()), 85, &coord, true);
assert!(
result.is_ok(),
"inside-root coord must pass; err: {result:?}"
);
assert!(coord.join(".doctrine").exists(), "provisioned inside root");
}
fn seed_phase_tracking(dir: &Path, slice: u32, phase_num: u32, status: &str) {
let state_dir = dir
.join(".doctrine/state/slice")
.join(format!("{slice:03}"))
.join("phases");
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(
state_dir.join(format!("phase-{phase_num:02}.toml")),
format!("status = \"{status}\"\n"),
)
.unwrap();
}
fn plan_body(phases: &[(&str, &str)]) -> String {
let mut body =
format!("schema = \"{SCHEMA_PLAN_OVERVIEW}\"\nversion = 1\nslice = \"SL-085\"\n");
for (id, name) in phases {
body.push_str(&format!(
"\n[[phase]]\nid = \"{id}\"\nname = \"{name}\"\nobjective = \"fixture\"\n"
));
}
body
}
#[test]
fn dispatch_plan_next_orders_phases() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[
("PHASE-01", "setup"),
("PHASE-02", "build"),
("PHASE-03", "blocked-one"),
("PHASE-04", "final"),
]),
);
seed_phase_tracking(src.path(), 85, 1, "completed");
seed_phase_tracking(src.path(), 85, 2, "completed");
seed_phase_tracking(src.path(), 85, 3, "blocked");
let result = run_plan_next(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "plan-next should succeed; err: {result:?}");
}
#[test]
fn dispatch_plan_next_all_blocked() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[
("PHASE-01", "setup"),
("PHASE-02", "blocked-one"),
("PHASE-03", "blocked-two"),
]),
);
seed_phase_tracking(src.path(), 85, 1, "completed");
seed_phase_tracking(src.path(), 85, 2, "blocked");
seed_phase_tracking(src.path(), 85, 3, "blocked");
let result = run_plan_next(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "plan-next should succeed; err: {result:?}");
}
#[test]
fn dispatch_plan_next_stops_at_blocked_mid() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[
("PHASE-01", "setup"),
("PHASE-02", "first-pending"),
("PHASE-03", "second-pending"),
("PHASE-04", "blocked"),
("PHASE-05", "after-blocked"),
]),
);
seed_phase_tracking(src.path(), 85, 1, "completed");
seed_phase_tracking(src.path(), 85, 4, "blocked");
let result = run_plan_next(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "plan-next should succeed; err: {result:?}");
}
#[test]
fn dispatch_plan_next_resume_in_progress() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[
("PHASE-01", "setup"),
("PHASE-02", "in-progress"),
("PHASE-03", "next-one"),
("PHASE-04", "next-two"),
]),
);
seed_phase_tracking(src.path(), 85, 1, "completed");
seed_phase_tracking(src.path(), 85, 2, "in_progress");
let result = run_plan_next(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "plan-next should succeed; err: {result:?}");
}
#[test]
fn dispatch_plan_next_json() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[("PHASE-01", "setup"), ("PHASE-02", "active")]),
);
seed_phase_tracking(src.path(), 85, 1, "completed");
let result = run_plan_next(Some(src.path().to_path_buf()), 85, true);
assert!(
result.is_ok(),
"plan-next --json should succeed; err: {result:?}"
);
}
#[test]
fn dispatch_plan_next_no_plan() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
let result = run_plan_next(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_err(), "plan-next without plan should fail");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("not found"),
"error should mention 'not found'; got: {err}"
);
}
fn create_dispatch_ref(dir: &Path, slice: u32) {
let head = git(dir, &["rev-parse", "HEAD"]);
git(
dir,
&[
"update-ref",
&format!("refs/heads/dispatch/{slice:03}"),
&head,
],
);
}
fn create_review_ref(dir: &Path, slice: u32) {
let head = git(dir, &["rev-parse", "HEAD"]);
git(
dir,
&[
"update-ref",
&format!("refs/heads/review/{slice:03}"),
&head,
],
);
}
fn advance_trunk(dir: &Path) -> String {
std::fs::write(dir.join("b.txt"), "world").unwrap();
git(dir, &["add", "b.txt"]);
git(dir, &["commit", "-q", "-m", "advance trunk"]);
git(dir, &["rev-parse", "HEAD"])
}
#[test]
fn dispatch_status_fresh_after_setup() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[("PHASE-01", "setup"), ("PHASE-02", "build")]),
);
create_dispatch_ref(src.path(), 85);
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_missing_dispatch_ref() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_err(), "status without dispatch ref should fail");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("dispatch branch not found"),
"error should mention 'dispatch branch not found'; got: {err}"
);
}
#[test]
fn dispatch_status_missing_trunk_ref() {
let src = tempfile::tempdir().unwrap();
std::fs::create_dir_all(src.path()).unwrap();
git(src.path(), &["init", "-q", "-b", "other"]);
git(src.path(), &["config", "user.email", "t@example.com"]);
git(src.path(), &["config", "user.name", "Test"]);
std::fs::create_dir_all(src.path().join(".doctrine")).unwrap();
std::fs::write(src.path().join("a.txt"), "hello").unwrap();
git(src.path(), &["add", "."]);
git(src.path(), &["commit", "-q", "-m", "base"]);
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_err(), "status without trunk ref should fail");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("trunk ref not found"),
"error should mention 'trunk ref not found'; got: {err}"
);
}
#[test]
fn dispatch_status_after_sync() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
create_review_ref(src.path(), 85);
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_moved_trunk() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
advance_trunk(src.path());
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_all_completed_no_review() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
seed_phase_tracking(src.path(), 85, 1, "completed");
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_all_completed_review_present() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
create_review_ref(src.path(), 85);
seed_phase_tracking(src.path(), 85, 1, "completed");
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_coord_removed() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_json() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[("PHASE-01", "setup"), ("PHASE-02", "build")]),
);
create_dispatch_ref(src.path(), 85);
let result = run_status(Some(src.path().to_path_buf()), 85, true);
assert!(
result.is_ok(),
"status --json should succeed; err: {result:?}"
);
}
fn add_dispatch_worktree(repo: &Path, slice: u32, holder: &Path) -> std::path::PathBuf {
let branch = format!("dispatch/{slice:03}");
let head = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["branch", &branch, &head]);
let coord = holder.join("coord");
git(
repo,
&[
"worktree",
"add",
"--quiet",
coord.to_str().unwrap(),
&branch,
],
);
coord
}
fn commit_file(wt: &Path, file: &str, content: &str, msg: &str) -> String {
std::fs::write(wt.join(file), content).unwrap();
git(wt, &["add", file]);
git(wt, &["commit", "-q", "-m", msg]);
git(wt, &["rev-parse", "HEAD"])
}
#[test]
fn trunk_drift_measures_against_trunk() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let fork = git(src.path(), &["rev-parse", "HEAD"]);
let tip = fork.clone();
let d0 = trunk_drift(src.path(), &tip)
.unwrap()
.expect("shared ancestor");
assert_eq!(d0.fork_point, fork, "fork_point is the merge-base");
assert_eq!(d0.ahead, 0, "trunk == fork ⇒ zero ahead");
commit_file(src.path(), "b.txt", "trunk-1\n", "advance trunk 1");
let trunk_tip = commit_file(src.path(), "b.txt", "trunk-2\n", "advance trunk 2");
let d = trunk_drift(src.path(), &tip)
.unwrap()
.expect("shared ancestor");
assert_eq!(d.trunk_tip, trunk_tip, "carries the resolved trunk tip");
assert_eq!(d.fork_point, fork, "fork unchanged — tip did not move");
assert_eq!(d.ahead, 2, "trunk is two commits ahead of the fork");
let d_fresh = trunk_drift(src.path(), &trunk_tip)
.unwrap()
.expect("shared ancestor");
assert_eq!(d_fresh.ahead, 0, "trunk ancestor of tip ⇒ zero ahead");
}
#[test]
fn refresh_base_clean_advances_dispatch() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let holder = tempfile::tempdir().unwrap();
let coord = add_dispatch_worktree(src.path(), 85, holder.path());
let dispatch_tip = commit_file(&coord, "c.txt", "dispatch work\n", "dispatch commit");
let trunk_tip = commit_file(src.path(), "a.txt", "hello trunk-moved\n", "advance trunk");
run_refresh_base(Some(src.path().to_path_buf()), 85).expect("clean refresh");
let new_tip = git(&coord, &["rev-parse", "HEAD"]);
assert_ne!(new_tip, dispatch_tip, "coord HEAD advanced");
let parents = git(&coord, &["rev-list", "--parents", "-n", "1", &new_tip]);
let p: Vec<&str> = parents.split_whitespace().skip(1).collect();
assert_eq!(
p,
vec![dispatch_tip.as_str(), trunk_tip.as_str()],
"merge parents"
);
let mb = git(&coord, &["merge-base", &new_tip, &trunk_tip]);
assert_eq!(mb, trunk_tip, "merge_base(dispatch, trunk) == trunk_tip");
}
#[test]
fn refresh_base_conflict_reports_and_halts() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let holder = tempfile::tempdir().unwrap();
let coord = add_dispatch_worktree(src.path(), 85, holder.path());
let dispatch_tip = commit_file(&coord, "a.txt", "DISPATCH\n", "dispatch edits a.txt");
commit_file(src.path(), "a.txt", "TRUNK\n", "trunk edits a.txt");
let result = run_refresh_base(Some(src.path().to_path_buf()), 85);
let err = format!("{}", result.expect_err("conflict must Err"));
assert!(
err.contains("a.txt"),
"names the conflicting path; got: {err}"
);
assert!(err.contains("conflicted"), "reports conflict; got: {err}");
let merge_head = coord.join(".git");
let _ = merge_head;
let mh = git(&coord, &["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
assert!(!mh.is_empty(), "MERGE_HEAD left in place");
let tip_now = git(&coord, &["rev-parse", "dispatch/085"]);
assert_eq!(
tip_now, dispatch_tip,
"dispatch ref unadvanced past pre-merge tip"
);
}
#[test]
fn refresh_base_refuses_unrelated_histories() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let holder = tempfile::tempdir().unwrap();
let coord = add_dispatch_worktree(src.path(), 85, holder.path());
git(&coord, &["checkout", "-q", "--orphan", "orphan-tmp"]);
std::fs::write(coord.join("orphan.txt"), "orphan\n").unwrap();
git(&coord, &["add", "orphan.txt"]);
git(&coord, &["commit", "-q", "-m", "orphan root"]);
let orphan = git(&coord, &["rev-parse", "HEAD"]);
git(&coord, &["branch", "-f", "dispatch/085", &orphan]);
git(&coord, &["checkout", "-q", "dispatch/085"]);
git(&coord, &["branch", "-D", "orphan-tmp"]);
let result = run_refresh_base(Some(src.path().to_path_buf()), 85);
let err = format!("{}", result.expect_err("unrelated histories must Err"));
assert!(
err.contains("unrelated histories"),
"refuses unrelated histories; got: {err}"
);
}
#[test]
fn refresh_base_noop_when_already_fresh() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let holder = tempfile::tempdir().unwrap();
let coord = add_dispatch_worktree(src.path(), 85, holder.path());
let before = commit_file(&coord, "c.txt", "ahead of trunk\n", "dispatch ahead");
run_refresh_base(Some(src.path().to_path_buf()), 85).expect("already-fresh is Ok");
let after = git(&coord, &["rev-parse", "HEAD"]);
assert_eq!(after, before, "no new commit on a fresh dispatch branch");
}
#[test]
fn refresh_base_refuses_dirty_coord() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let holder = tempfile::tempdir().unwrap();
let coord = add_dispatch_worktree(src.path(), 85, holder.path());
advance_trunk(src.path()); std::fs::write(coord.join("a.txt"), "uncommitted edit\n").unwrap();
let result = run_refresh_base(Some(src.path().to_path_buf()), 85);
let err = format!("{}", result.expect_err("dirty coord must Err"));
assert!(
err.contains("dirty coordination worktree"),
"refuses a dirty coord tree; got: {err}"
);
}
#[test]
fn refresh_base_refuses_without_coord_worktree() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
create_dispatch_ref(src.path(), 85);
let result = run_refresh_base(Some(src.path().to_path_buf()), 85);
let err = format!("{}", result.expect_err("missing coord worktree must Err"));
assert!(
err.contains("no live coordination worktree") && err.contains("setup"),
"hints at setup/resume; got: {err}"
);
}
const LEGACY_CONFLICT_TEXT: &str = "candidate create: 3-way merge of refs/heads/review/085 onto trunk conflicts — pass --worktree to park the candidate branch at the base for manual resolve+commit, or abort (no row/ref/worktree written)";
#[test]
fn candidate_conflict_message_appends_drift_hint() {
let msg = candidate_conflict_message("refs/heads/review/085", "trunk", 3);
assert!(
msg.starts_with(LEGACY_CONFLICT_TEXT),
"legacy text is preserved as a prefix; got: {msg}"
);
assert!(
msg.contains("trunk has advanced 3 commit(s) past this source"),
"names the drift count; got: {msg}"
);
assert!(
msg.contains("refresh-base") && msg.contains("re-prepare + re-create"),
"hints the refresh-base remedy; got: {msg}"
);
assert!(
msg.contains("may be base divergence"),
"non-asserting ('may be'); got: {msg}"
);
}
#[test]
fn candidate_conflict_message_byte_identical_when_not_behind_trunk() {
let msg = candidate_conflict_message("refs/heads/review/085", "trunk", 0);
assert_eq!(msg, LEGACY_CONFLICT_TEXT, "ahead==0 ⇒ verbatim legacy text");
}
fn all_done_inputs() -> GuidanceInputs<fn() -> Vec<String>> {
GuidanceInputs {
all_completed: true,
bundle_stale: false,
review_exists: false,
coord_live: true,
admitted: false,
admitted_is_ancestor: false,
next_phases: Vec::new,
}
}
#[test]
fn select_guidance_refresh_base_precedes_prepare_review_and_audit() {
let g = select_guidance(GuidanceInputs {
bundle_stale: true,
..all_done_inputs()
});
assert!(
matches!(g, NextGuidance::RefreshBase),
"stale ⇒ RefreshBase"
);
assert_eq!(g.to_json().kind, "refresh_base");
let g2 = select_guidance(GuidanceInputs {
bundle_stale: true,
review_exists: true,
admitted: true,
..all_done_inputs()
});
assert!(
matches!(g2, NextGuidance::RefreshBase),
"stale wins over the audit legs"
);
}
#[test]
fn select_guidance_fresh_bundle_keeps_existing_guidance() {
let no_review = select_guidance(all_done_inputs());
assert!(
matches!(no_review, NextGuidance::PrepareReview),
"fresh + no review ⇒ PrepareReview (unchanged)"
);
let with_review = select_guidance(GuidanceInputs {
review_exists: true,
..all_done_inputs()
});
assert!(
matches!(with_review, NextGuidance::AuditOrCandidateStatus),
"fresh + review ⇒ audit leg (unchanged)"
);
}
#[test]
fn dispatch_status_stale_bundle_routes_refresh_base() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
advance_trunk(src.path());
seed_phase_tracking(src.path(), 85, 1, "completed");
let result = run_status(Some(src.path().to_path_buf()), 85, true);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
fn reg_row(phase: &str, provenance: Provenance) -> BoundaryRow {
BoundaryRow {
phase: phase.to_string(),
code_start_oid: "s".to_string(),
code_end_oid: "e".to_string(),
provenance,
}
}
fn committed_set<'a>(phases: &'a [&str]) -> BTreeSet<&'a str> {
phases.iter().copied().collect()
}
#[test]
fn guard_total_loss_names_every_funnel_phase() {
let registry = vec![
reg_row("PHASE-01", Provenance::Funnel),
reg_row("PHASE-02", Provenance::Funnel),
];
let committed = committed_set(&[]);
let missing = missing_committed_funnel_phases(®istry, &committed);
assert_eq!(missing, vec!["PHASE-01", "PHASE-02"]);
}
#[test]
fn guard_partial_loss_names_only_the_uncommitted_phase() {
let registry = vec![
reg_row("PHASE-01", Provenance::Funnel),
reg_row("PHASE-02", Provenance::Funnel),
];
assert_eq!(
missing_committed_funnel_phases(®istry, &committed_set(&["PHASE-01"])),
vec!["PHASE-02"],
);
assert!(
missing_committed_funnel_phases(®istry, &committed_set(&["PHASE-01", "PHASE-02"]))
.is_empty(),
"a complete committed ledger leaves nothing missing",
);
}
#[test]
fn guard_includes_unknown_excludes_solo_and_manual() {
let registry = vec![
reg_row("PHASE-01", Provenance::Unknown),
reg_row("PHASE-02", Provenance::Solo),
reg_row("PHASE-03", Provenance::Manual),
];
let missing = missing_committed_funnel_phases(®istry, &committed_set(&[]));
assert_eq!(
missing,
vec!["PHASE-01"],
"Unknown halts; Solo/Manual excluded"
);
}
#[test]
fn guard_empty_registry_is_silent() {
assert!(missing_committed_funnel_phases(&[], &committed_set(&["PHASE-01"])).is_empty(),);
}
fn cand_row(
target_ref: &str,
source_ref: &str,
role: CandidateRole,
kind: CandidateKind,
status: CandidateStatus,
) -> CandidateRow {
CandidateRow {
id: format!("cand-{target_ref}"),
label: "l".into(),
kind,
role,
payload: CandidatePayload::ImplBundle,
target_ref: target_ref.into(),
source_ref: source_ref.into(),
source_oid: "0".repeat(40),
base_ref: "refs/heads/main".into(),
base_oid: "0".repeat(40),
merge_oid: "0".repeat(40),
status,
supersedes: String::new(),
reason: String::new(),
created_by: "test".into(),
created_at: "2026-01-01".into(),
}
}
fn jrow(target_ref: &str, status: LedgerStatus) -> JournalRow {
JournalRow {
source_oid: "0".repeat(40),
target_ref: target_ref.into(),
expected_old_oid: "0".repeat(40),
planned_new_oid: "0".repeat(40),
applied_new_oid: String::new(),
status,
}
}
fn audit_surface_row() -> CandidateRow {
cand_row(
"refs/heads/candidate/200/review-001",
"refs/heads/review/200",
CandidateRole::ReviewSurface,
CandidateKind::Audit,
CandidateStatus::Created,
)
}
fn verified_review_journal() -> Journal {
Journal {
rows: vec![jrow("refs/heads/review/200", LedgerStatus::Verified)],
..Default::default()
}
}
fn trace_err(
candidates: &Candidates,
journal: &Journal,
ref_name: &str,
budget: u32,
) -> String {
let err = trace_candidate_provenance(candidates, journal, "200", ref_name, budget)
.expect_err("trace should refuse");
format!("{err}")
}
#[test]
fn trace_accepts_audit_surface_to_verified_root() {
let candidates = Candidates {
rows: vec![audit_surface_row()],
..Default::default()
};
let row = trace_candidate_provenance(
&candidates,
&verified_review_journal(),
"200",
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
)
.expect("clean audit surface should trace to the verified root");
assert_eq!(row.target_ref, "refs/heads/candidate/200/review-001");
}
#[test]
fn trace_over_budget_refuses() {
let candidates = Candidates {
rows: vec![audit_surface_row()],
..Default::default()
};
let err = trace_err(
&candidates,
&verified_review_journal(),
"refs/heads/candidate/200/review-001",
0,
);
assert!(err.contains("too deep or cyclic"), "got: {err}");
}
#[test]
fn trace_cyclic_chain_refuses() {
let a = cand_row(
"refs/heads/candidate/200/a",
"refs/heads/candidate/200/b",
CandidateRole::CloseTarget,
CandidateKind::Audit,
CandidateStatus::Created,
);
let b = cand_row(
"refs/heads/candidate/200/b",
"refs/heads/candidate/200/a",
CandidateRole::CloseTarget,
CandidateKind::Audit,
CandidateStatus::Created,
);
let candidates = Candidates {
rows: vec![a, b],
..Default::default()
};
let err = trace_err(
&candidates,
&Journal::default(),
"refs/heads/candidate/200/a",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("too deep or cyclic"), "got: {err}");
}
#[test]
fn trace_ambiguous_row_refuses() {
let candidates = Candidates {
rows: vec![audit_surface_row(), audit_surface_row()],
..Default::default()
};
let err = trace_err(
&candidates,
&verified_review_journal(),
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("ambiguous candidate row"), "got: {err}");
}
#[test]
fn trace_missing_row_refuses() {
let candidates = Candidates::default();
let err = trace_err(
&candidates,
&Journal::default(),
"refs/heads/candidate/200/ghost",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("no recorded candidate row"), "got: {err}");
}
#[test]
fn trace_conflicted_status_refuses() {
let mut row = audit_surface_row();
row.status = CandidateStatus::Conflicted;
let candidates = Candidates {
rows: vec![row],
..Default::default()
};
let err = trace_err(
&candidates,
&verified_review_journal(),
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("not clean"), "got: {err}");
}
#[test]
fn trace_experiment_kind_refuses() {
let mut row = audit_surface_row();
row.kind = CandidateKind::Experiment;
let candidates = Candidates {
rows: vec![row],
..Default::default()
};
let err = trace_err(
&candidates,
&verified_review_journal(),
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(
err.contains("kind=Experiment") || err.contains("only an audit review_surface"),
"got: {err}"
);
}
#[test]
fn trace_scratch_role_refuses() {
let mut row = audit_surface_row();
row.role = CandidateRole::Scratch;
let candidates = Candidates {
rows: vec![row],
..Default::default()
};
let err = trace_err(
&candidates,
&verified_review_journal(),
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(
err.contains("role=Scratch") || err.contains("only an audit review_surface"),
"got: {err}"
);
}
#[test]
fn trace_non_evidence_hop_refuses() {
let row = cand_row(
"refs/heads/candidate/200/review-001",
"refs/heads/feature/random",
CandidateRole::ReviewSurface,
CandidateKind::Audit,
CandidateStatus::Created,
);
let candidates = Candidates {
rows: vec![row],
..Default::default()
};
let err = trace_err(
&candidates,
&Journal::default(),
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("non-evidence"), "got: {err}");
}
#[test]
fn trace_unverified_journal_root_refuses() {
let candidates = Candidates {
rows: vec![audit_surface_row()],
..Default::default()
};
let journal = Journal {
rows: vec![jrow("refs/heads/review/200", LedgerStatus::Pending)],
..Default::default()
};
let err = trace_err(
&candidates,
&journal,
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("not verified"), "got: {err}");
}
#[test]
fn ref_classifiers_agree() {
assert!(is_journaled_evidence_ref("refs/heads/review/200", "200"));
assert!(is_journaled_evidence_ref("refs/heads/phase/200-03", "200"));
assert!(!is_journaled_evidence_ref("refs/heads/phase/200-xx", "200"));
assert!(!is_journaled_evidence_ref(
"refs/heads/candidate/200/x",
"200"
));
assert!(is_candidate_ref("refs/heads/candidate/200/review-001"));
assert!(!is_candidate_ref("refs/heads/review/200"));
}
fn posture_cfg(authoring: Option<&str>) -> crate::dispatch_config::DispatchConfig {
crate::dispatch_config::DispatchConfig {
authoring_branch: authoring.map(str::to_owned),
..Default::default()
}
}
#[test]
fn integrate_refused_when_head_on_buffer() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path()); let cfg = posture_cfg(Some("refs/heads/edge"));
let err = guard_not_on_integration_ref(src.path(), &cfg).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains(corpus_guard::REFUSE_ON_TRUNK),
"names the g1 token: {msg}"
);
assert!(
msg.contains("refs/heads/main"),
"names the buffer ref: {msg}"
);
assert!(
msg.contains("git fetch . refs/heads/edge:main")
&& msg.contains("never `checkout main`"),
"names the fetch-not-checkout recovery: {msg}"
);
}
#[test]
fn integrate_allowed_on_authoring_branch() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
git(src.path(), &["checkout", "-q", "-b", "edge"]);
let cfg = posture_cfg(Some("refs/heads/edge"));
assert!(guard_not_on_integration_ref(src.path(), &cfg).is_ok());
}
#[test]
fn g1_inert_when_posture_unset() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let cfg = posture_cfg(None);
assert!(guard_not_on_integration_ref(src.path(), &cfg).is_ok());
}
#[test]
fn g1_guards_only_the_integrate_verb_entry() {
let this = include_str!("dispatch.rs");
let prod = this
.split("#[cfg(test)]")
.next()
.expect("production source before the test module");
let call_sites = prod
.lines()
.filter(|l| {
l.contains("guard_not_on_integration_ref(")
&& !l.contains("fn guard_not_on_integration_ref")
&& !l.trim_start().starts_with("//")
&& !l.trim_start().starts_with("///")
})
.count();
assert_eq!(
call_sites, 1,
"exactly one g1 call site (the integrate verb)"
);
assert!(
prod.contains(
"let cfg = crate::dtoml::load_doctrine_toml(&root)?.dispatch;\n guard_not_on_integration_ref(&root, &cfg)?;"
),
"the g1 call site is run_integrate's verb entry"
);
for fn_name in ["fn candidate_create", "fn run_candidate_admit"] {
let body_start = prod.find(fn_name).expect("candidate fn present");
let after = &prod[body_start..];
let window = &after[..after.len().min(6000)];
assert!(
!window.contains("guard_not_on_integration_ref("),
"{fn_name} must not call g1 (advances no integration ref)"
);
}
}
}