use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use anyhow::{Context as _, bail};
use crate::git::{self, MergeTree, RefCas, ReplayOutcome, 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;
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_integrate(
path: Option<PathBuf>,
slice: u32,
trunk: Option<&str>,
edge: Option<&str>,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
integrate(&root, slice, trunk, edge)
}
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"))
};
crate::ledger::record_boundary(
&root,
slice,
crate::ledger::BoundaryRow {
phase: phase.to_string(),
code_start_oid: resolve(code_start)?,
code_end_oid: resolve(code_end)?,
},
)
}
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 check_provenance(journal: &Journal, slice3: &str, source_ref: &str) -> anyhow::Result<()> {
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(())
}
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")?;
check_provenance(&journal, &slice3, &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))?;
let mut ledger = read_candidates(root, req.slice)?;
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 => bail!(
"candidate create: 3-way merge of {source_ref} onto {} conflicts — \
pass --worktree to park the candidate branch at the base for \
manual resolve+commit, or abort (no row/ref/worktree written)",
req.base
),
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 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 tip = resolve_commit(root, &coord_ref)?
.with_context(|| format!("prepare-review: dispatch/{slice3} does not exist"))?;
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 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 journal_commit = commit_journal(
root,
&tip_tree,
&tip,
&journal_path,
&coord_ref,
&journal,
"journal: prepare-review",
)?;
let mut stale: Vec<String> = Vec::new();
for row in &mut journal.rows {
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)?;
}
RefCas::Moved { actual } => {
row.status = LedgerStatus::Failed;
stale.push(format!(
"{} (exists at {})",
row.target_ref,
actual.as_deref().unwrap_or("?")
));
}
}
}
commit_journal(
root,
&tip_tree,
&journal_commit,
&journal_path,
&coord_ref,
&journal,
"journal: prepare-review",
)?;
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>,
) -> 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);
}
let journal_commit = commit_journal(
root,
&tip_tree,
&tip,
&journal_path,
&coord_ref,
&journal,
"journal: integrate",
)?;
let mut moved: Vec<String> = Vec::new();
for row in &mut journal.rows {
match git::replay_ref(
root,
&row.target_ref,
&row.expected_old_oid,
&row.planned_new_oid,
)? {
ReplayOutcome::NoOp => {
row.status = LedgerStatus::Verified;
row.applied_new_oid = row.planned_new_oid.clone();
}
ReplayOutcome::Applied => {
row.status = LedgerStatus::Verified;
row.applied_new_oid = row.planned_new_oid.clone();
writeln!(io::stdout(), "{}", row.target_ref)?;
}
ReplayOutcome::Moved { actual } => {
row.status = LedgerStatus::Failed;
moved.push(format!(
"{} (target at {})",
row.target_ref,
actual.as_deref().unwrap_or("?")
));
}
}
}
commit_journal(
root,
&tip_tree,
&journal_commit,
&journal_path,
&coord_ref,
&journal,
"journal: integrate",
)?;
if moved.is_empty() {
writeln!(
io::stderr(),
"integrate: {} ref(s) replayed",
journal.rows.len()
)?;
Ok(())
} else {
bail!(
"integrate: {} moved target(s), not clobbered: {}",
moved.len(),
moved.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, &[".doctrine"])?;
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(),
}
}
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("?")
),
}
}