use super::create::{WORKTREES_SUBDIR, sanitise_name};
use crate::git;
use anyhow::Context;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
pub(crate) const RECORD_SUBPATH: &str = ".doctrine/state/dispatch/record";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ForkBinding {
pub(crate) slice: u32,
pub(crate) phase: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct DispatchRecord {
pub(crate) name: String,
pub(crate) dir: PathBuf,
pub(crate) branch: String,
pub(crate) base: String,
pub(crate) coord: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) slice: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) phase: Option<String>,
}
impl DispatchRecord {
pub(crate) fn binding(&self) -> Option<ForkBinding> {
match (self.slice, self.phase.as_deref()) {
(Some(slice), Some(phase)) if !phase.is_empty() => Some(ForkBinding {
slice,
phase: phase.to_owned(),
}),
_ => None,
}
}
}
pub(crate) fn require_binding(record: &DispatchRecord) -> Result<ForkBinding, ResolveRefusal> {
record.binding().ok_or(ResolveRefusal::UnprovableFork)
}
fn record_path(coord: &Path, name: &str) -> PathBuf {
coord.join(RECORD_SUBPATH).join(format!("{name}.toml"))
}
pub(crate) fn provision_dispatch_record(
coord: &Path,
name: &str,
base: &str,
dir: &Path,
branch: &str,
binding: Option<&ForkBinding>,
) -> anyhow::Result<()> {
let record = DispatchRecord {
name: name.to_string(),
dir: dir.to_path_buf(),
branch: branch.to_string(),
base: base.to_string(),
coord: coord.to_path_buf(),
slice: binding.map(|b| b.slice),
phase: binding.map(|b| b.phase.clone()),
};
let body = toml::to_string(&record)
.with_context(|| format!("serialise dispatch record for {name}"))?;
let dest = record_path(coord, name);
let parent = dest
.parent()
.ok_or_else(|| anyhow::anyhow!("record path {} has no parent", dest.display()))?;
fs::create_dir_all(parent)
.with_context(|| format!("create dispatch record dir {}", parent.display()))?;
crate::fsutil::write_atomic(&dest, body.as_bytes())
.with_context(|| format!("write dispatch record {}", dest.display()))?;
Ok(())
}
pub(crate) fn bind_dispatch_record(
coord: &Path,
name: &str,
base: &str,
dir: &Path,
branch: &str,
binding: Option<&ForkBinding>,
) -> anyhow::Result<()> {
let dest = record_path(coord, name);
if dest.exists() {
anyhow::bail!(
"bind-refused: a dispatch record already exists for {name} ({}) — the name is \
already bound; sweep the residue (`doctrine worktree gc --fork {branch}`) before \
re-claiming it",
dest.display()
);
}
provision_dispatch_record(coord, name, base, dir, branch, binding)
}
pub(crate) fn delete_dispatch_record(coord: &Path, name: &str) -> anyhow::Result<()> {
let path = record_path(coord, name);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| format!("delete dispatch record {}", path.display())),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ResolveRefusal {
UnknownAgent,
AmbiguousAgent,
StaleRecord,
UnprovableFork,
}
impl ResolveRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
ResolveRefusal::UnknownAgent => "unknown-agent",
ResolveRefusal::AmbiguousAgent => "ambiguous-agent",
ResolveRefusal::StaleRecord => "stale-record",
ResolveRefusal::UnprovableFork => "unprovable-fork",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ForkExpect {
AtBase,
Advanced,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolveFacts {
pub(crate) worktree_hits: usize,
pub(crate) record: Option<DispatchRecord>,
pub(crate) dir_exists: bool,
pub(crate) branch_head: Option<String>,
pub(crate) base_commit: Option<String>,
pub(crate) head_parents: Vec<String>,
pub(crate) expect: ForkExpect,
}
fn head_meets(expect: ForkExpect, head: &str, base: &str, head_parents: &[String]) -> bool {
match expect {
ForkExpect::AtBase => head == base,
ForkExpect::Advanced => head != base && matches!(head_parents, [parent] if parent == base),
}
}
pub(crate) fn classify_resolve(facts: ResolveFacts) -> Result<DispatchRecord, ResolveRefusal> {
if facts.worktree_hits == 0 {
return Err(ResolveRefusal::UnknownAgent);
}
if facts.worktree_hits > 1 {
return Err(ResolveRefusal::AmbiguousAgent);
}
let Some(record) = facts.record else {
return Err(ResolveRefusal::StaleRecord);
};
if !facts.dir_exists {
return Err(ResolveRefusal::StaleRecord);
}
let Some(head) = facts.branch_head else {
return Err(ResolveRefusal::StaleRecord);
};
let Some(base) = facts.base_commit else {
return Err(ResolveRefusal::StaleRecord);
};
if !head_meets(facts.expect, &head, &base, &facts.head_parents) {
return Err(ResolveRefusal::StaleRecord);
}
Ok(record)
}
pub(super) fn coord_and_name(dir: &Path) -> Option<(PathBuf, String)> {
let name = dir.file_name()?.to_str()?.to_owned();
let worktrees = dir.parent()?;
if worktrees.file_name()?.to_str()? != WORKTREES_SUBDIR {
return None;
}
Some((worktrees.parent()?.to_path_buf(), name))
}
fn coord_from_worktree_dir(dir: &Path, name: &str) -> Option<PathBuf> {
coord_and_name(dir).and_then(|(coord, got)| (got == name).then_some(coord))
}
fn read_record(coord: &Path, name: &str) -> Option<DispatchRecord> {
let raw = fs::read_to_string(record_path(coord, name)).ok()?;
toml::from_str(&raw).ok()
}
fn resolve_commit(root: &Path, rev: &str) -> Option<String> {
git::git_opt(
root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{rev}^{{commit}}"),
],
)
.ok()
.flatten()
}
pub(crate) fn resolve_agent(
root: &Path,
agent: &str,
expect: ForkExpect,
) -> Result<DispatchRecord, ResolveRefusal> {
let Ok(name) = sanitise_name(agent) else {
return Err(ResolveRefusal::UnknownAgent);
};
let branch_ref = format!("refs/heads/dispatch/{name}");
let worktree = git::worktree_for_ref(root, &branch_ref).unwrap_or(None);
let worktree_hits = usize::from(worktree.is_some());
let record = worktree
.as_deref()
.and_then(|dir| coord_from_worktree_dir(dir, &name))
.and_then(|coord| read_record(&coord, &name));
let dir_exists = record.as_ref().is_some_and(|r| r.dir.exists());
let branch_head = resolve_commit(root, &branch_ref);
let base_commit = record.as_ref().and_then(|r| resolve_commit(root, &r.base));
let head_parents = branch_head
.as_deref()
.and_then(|head| git::parents(root, head).ok())
.unwrap_or_default();
classify_resolve(ResolveFacts {
worktree_hits,
record,
dir_exists,
branch_head,
base_commit,
head_parents,
expect,
})
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests: fail-fast unwrap on fixture setup is idiomatic"
)]
mod tests {
use super::*;
const B: &str = "1111111111111111111111111111111111111111";
const C: &str = "2222222222222222222222222222222222222222";
const D: &str = "3333333333333333333333333333333333333333";
fn record() -> DispatchRecord {
DispatchRecord {
name: "wk1".to_owned(),
dir: PathBuf::from("/coord/.worktrees/wk1"),
branch: "dispatch/wk1".to_owned(),
base: B.to_owned(),
coord: PathBuf::from("/coord"),
slice: Some(228),
phase: Some("PHASE-04".to_owned()),
}
}
fn facts(head: &str, parents: &[&str], expect: ForkExpect) -> ResolveFacts {
ResolveFacts {
worktree_hits: 1,
record: Some(record()),
dir_exists: true,
branch_head: Some(head.to_owned()),
base_commit: Some(B.to_owned()),
head_parents: parents.iter().map(|p| (*p).to_owned()).collect(),
expect,
}
}
#[test]
fn at_base_accepts_only_head_equal_to_base() {
assert!(classify_resolve(facts(B, &[], ForkExpect::AtBase)).is_ok());
assert_eq!(
classify_resolve(facts(C, &[B], ForkExpect::AtBase)),
Err(ResolveRefusal::StaleRecord)
);
assert_eq!(
classify_resolve(facts(D, &[C], ForkExpect::AtBase)),
Err(ResolveRefusal::StaleRecord)
);
}
#[test]
fn advanced_accepts_exactly_one_non_merge_commit_past_base() {
assert!(classify_resolve(facts(C, &[B], ForkExpect::Advanced)).is_ok());
assert_eq!(
classify_resolve(facts(B, &[], ForkExpect::Advanced)),
Err(ResolveRefusal::StaleRecord)
);
assert_eq!(
classify_resolve(facts(C, &[D], ForkExpect::Advanced)),
Err(ResolveRefusal::StaleRecord)
);
assert_eq!(
classify_resolve(facts(C, &[B, D], ForkExpect::Advanced)),
Err(ResolveRefusal::StaleRecord)
);
assert_eq!(
classify_resolve(facts(C, &[], ForkExpect::Advanced)),
Err(ResolveRefusal::StaleRecord)
);
}
#[test]
fn every_other_consistency_check_is_preserved_under_both_expectations() {
for expect in [ForkExpect::AtBase, ForkExpect::Advanced] {
let (ok_head, parents): (&str, &[&str]) = match expect {
ForkExpect::AtBase => (B, &[]),
ForkExpect::Advanced => (C, &[B]),
};
let mut f = facts(ok_head, parents, expect);
f.worktree_hits = 0;
assert_eq!(classify_resolve(f), Err(ResolveRefusal::UnknownAgent));
let mut f = facts(ok_head, parents, expect);
f.worktree_hits = 2;
assert_eq!(classify_resolve(f), Err(ResolveRefusal::AmbiguousAgent));
let mut f = facts(ok_head, parents, expect);
f.record = None;
assert_eq!(classify_resolve(f), Err(ResolveRefusal::StaleRecord));
let mut f = facts(ok_head, parents, expect);
f.dir_exists = false;
assert_eq!(classify_resolve(f), Err(ResolveRefusal::StaleRecord));
let mut f = facts(ok_head, parents, expect);
f.branch_head = None;
assert_eq!(classify_resolve(f), Err(ResolveRefusal::StaleRecord));
let mut f = facts(ok_head, parents, expect);
f.base_commit = None;
assert_eq!(classify_resolve(f), Err(ResolveRefusal::StaleRecord));
assert!(classify_resolve(facts(ok_head, parents, expect)).is_ok());
}
}
#[test]
fn a_complete_binding_is_the_only_provable_one() {
assert_eq!(
require_binding(&record()),
Ok(ForkBinding {
slice: 228,
phase: "PHASE-04".to_owned(),
})
);
for half in [
DispatchRecord {
phase: None,
..record()
},
DispatchRecord {
slice: None,
..record()
},
DispatchRecord {
phase: Some(String::new()),
..record()
},
DispatchRecord {
slice: None,
phase: None,
..record()
},
] {
assert_eq!(
require_binding(&half),
Err(ResolveRefusal::UnprovableFork),
"a half-bound record is unprovable, never half-proven"
);
}
assert_eq!(ResolveRefusal::UnprovableFork.token(), "unprovable-fork");
}
#[test]
fn the_binding_round_trips_through_provision_and_read() {
let tmp = tempfile::tempdir().unwrap();
let coord = tmp.path();
let dir = coord.join(WORKTREES_SUBDIR).join("wk1");
let binding = ForkBinding {
slice: 228,
phase: "PHASE-04".to_owned(),
};
provision_dispatch_record(coord, "wk1", B, &dir, "dispatch/wk1", Some(&binding)).unwrap();
let read = read_record(coord, "wk1").expect("the record parses");
assert_eq!(read.base, B, "base is still snapshotted at fork time");
assert_eq!(read.binding(), Some(binding), "the binding round-trips");
}
#[test]
fn an_unbound_record_still_parses_and_reports_itself_unbound() {
let tmp = tempfile::tempdir().unwrap();
let coord = tmp.path();
let dir = coord.join(WORKTREES_SUBDIR).join("wk1");
provision_dispatch_record(coord, "wk1", B, &dir, "dispatch/wk1", None).unwrap();
let raw = fs::read_to_string(record_path(coord, "wk1")).unwrap();
assert!(
!raw.contains("slice") && !raw.contains("phase"),
"an unbound record writes neither half: {raw}"
);
let read = read_record(coord, "wk1").expect("an unbound record still parses");
assert_eq!(read.binding(), None);
}
#[test]
fn bind_refuses_to_clobber_an_existing_binding_but_provision_restamps() {
let tmp = tempfile::tempdir().unwrap();
let coord = tmp.path();
let dir = coord.join(WORKTREES_SUBDIR).join("wk1");
bind_dispatch_record(coord, "wk1", B, &dir, "dispatch/wk1", None).unwrap();
let err = bind_dispatch_record(coord, "wk1", C, &dir, "dispatch/wk1", None)
.expect_err("a second bind for the same name must refuse");
assert!(
format!("{err:#}").contains("bind-refused"),
"the refusal names itself: {err:#}"
);
assert_eq!(
read_record(coord, "wk1").unwrap().base,
B,
"the live binding is untouched by the refused clobber"
);
provision_dispatch_record(coord, "wk1", C, &dir, "dispatch/wk1", None).unwrap();
assert_eq!(read_record(coord, "wk1").unwrap().base, C);
}
#[test]
fn coord_and_name_strips_the_layout_and_rejects_anything_else() {
assert_eq!(
coord_and_name(Path::new("/coord/.worktrees/wk1")),
Some((PathBuf::from("/coord"), "wk1".to_owned()))
);
assert_eq!(coord_and_name(Path::new("/coord/not-worktrees/wk1")), None);
assert_eq!(coord_and_name(Path::new("/coord/wk1")), None);
}
}