use std::fs;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use glob::{MatchOptions, Pattern};
use crate::fsutil::{self, CopyOutcome};
use crate::git;
use crate::root;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Tier {
State,
PhaseLink,
Handover,
Inquisition,
Research,
MemoryCache,
}
impl std::fmt::Display for Tier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Tier::State => "state",
Tier::PhaseLink => "phase-link",
Tier::Handover => "handover",
Tier::Inquisition => "inquisition",
Tier::Research => "research",
Tier::MemoryCache => "memory-cache",
};
f.write_str(name)
}
}
#[derive(Debug)]
pub(crate) struct Withhold {
pub(crate) tier: Tier,
pub(crate) glob: &'static str,
}
const fn w(tier: Tier, glob: &'static str) -> Withhold {
Withhold { tier, glob }
}
pub(crate) const WITHHELD: &[Withhold] = &[
w(Tier::State, ".doctrine/state/**"),
w(Tier::PhaseLink, ".doctrine/slice/*/phases"),
w(Tier::Handover, "**/handover.md"),
w(Tier::Inquisition, ".doctrine/slice/*/inquisition.md"),
w(Tier::Research, ".doctrine/slice/*/research/**"),
w(Tier::MemoryCache, ".doctrine/memory/index/**"),
w(Tier::MemoryCache, ".doctrine/memory/embeddings/**"),
w(Tier::MemoryCache, ".doctrine/memory/state/**"),
w(Tier::MemoryCache, ".doctrine/memory/shipped/**"),
];
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "classification authority; only the .gitignore parity test reads it so far (SL-029)"
)
)]
pub(crate) const DERIVED_RUNTIME: &[&str] = &[".doctrine/skills/*", ".doctrine/agents/*"];
pub(crate) struct CoordOutcome {
pub dispatch_tip: String,
}
const MATCH_OPTS: MatchOptions = MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: false,
};
fn glob_matches(pat: &Pattern, path: &str) -> bool {
pat.matches_with(path, MATCH_OPTS)
}
#[derive(Debug)]
pub(crate) struct Allowlist {
pub(crate) patterns: Vec<Pattern>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ParseError {
#[error("line {line}: negation (`!`) is unsupported in .worktreeinclude v1: `{raw}`")]
Negation { line: usize, raw: String },
#[error("line {line}: anchoring (leading `/`) is unsupported in .worktreeinclude v1: `{raw}`")]
Anchoring { line: usize, raw: String },
#[error("line {line}: invalid glob `{raw}`: {source}")]
BadGlob {
line: usize,
raw: String,
#[source]
source: glob::PatternError,
},
}
pub(crate) fn parse_allowlist(text: &str) -> Result<Allowlist, ParseError> {
let mut patterns = Vec::new();
for (i, raw_line) in text.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let n = i + 1;
if line.starts_with('!') {
return Err(ParseError::Negation {
line: n,
raw: line.to_string(),
});
}
if line.starts_with('/') {
return Err(ParseError::Anchoring {
line: n,
raw: line.to_string(),
});
}
let pat = Pattern::new(line).map_err(|source| ParseError::BadGlob {
line: n,
raw: line.to_string(),
source,
})?;
patterns.push(pat);
}
Ok(Allowlist { patterns })
}
pub(crate) fn is_withheld(rel: &str) -> Option<Tier> {
WITHHELD.iter().find_map(|item| {
Pattern::new(item.glob)
.ok()
.filter(|p| glob_matches(p, rel))
.map(|_p| item.tier)
})
}
#[derive(Debug)]
pub(crate) struct Withheld {
pub(crate) path: String,
pub(crate) tier: Tier,
}
#[derive(Debug)]
pub(crate) struct Selection {
pub(crate) copy: Vec<String>,
pub(crate) withheld: Vec<Withheld>,
}
pub(crate) fn select_copies(allow: &Allowlist, candidates: &[String]) -> Selection {
let mut copy = Vec::new();
let mut withheld = Vec::new();
for cand in candidates {
if !allow.patterns.iter().any(|p| glob_matches(p, cand)) {
continue;
}
match is_withheld(cand) {
Some(tier) => withheld.push(Withheld {
path: cand.clone(),
tier,
}),
None => copy.push(cand.clone()),
}
}
Selection { copy, withheld }
}
#[derive(Debug)]
pub(crate) struct Violation {
pub(crate) pattern: String,
pub(crate) tier: Tier,
}
fn representative(glob: &str) -> String {
glob.replace("**", "x").replace(['*', '?'], "x")
}
pub(crate) fn allowlist_violations(allow: &Allowlist) -> Vec<Violation> {
let mut out = Vec::new();
for item in WITHHELD {
let rep = representative(item.glob);
for pat in &allow.patterns {
if glob_matches(pat, &rep) {
out.push(Violation {
pattern: pat.as_str().to_string(),
tier: item.tier,
});
}
}
}
out
}
pub(crate) fn matches(base: &str, head: &str) -> bool {
base == head
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Cause {
None,
Marker,
Env,
Both,
}
impl Cause {
fn token(self) -> &'static str {
match self {
Cause::None => "none",
Cause::Marker => "marker",
Cause::Env => "env",
Cause::Both => "both",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StatusLine {
pub(crate) refused: bool,
pub(crate) cause: Cause,
pub(crate) is_linked: bool,
}
impl StatusLine {
pub(crate) fn is_stale_marker(self) -> bool {
self.cause == Cause::Marker
}
pub(crate) fn is_env_on_nonlinked(self) -> bool {
matches!(self.cause, Cause::Env | Cause::Both) && !self.is_linked
}
pub(crate) fn cause_token(self) -> &'static str {
self.cause.token()
}
}
pub(crate) fn describe_mode(is_linked: bool, marker_present: bool, env_set: bool) -> StatusLine {
let marker_leg = is_linked && marker_present;
let cause = match (marker_leg, env_set) {
(true, true) => Cause::Both,
(true, false) => Cause::Marker,
(false, true) => Cause::Env,
(false, false) => Cause::None,
};
StatusLine {
refused: marker_leg || env_set,
cause,
is_linked,
}
}
const ALLOWLIST_FILE: &str = ".worktreeinclude";
fn read_allowlist(root: &Path) -> anyhow::Result<Allowlist> {
let path = root.join(ALLOWLIST_FILE);
match fs::read_to_string(&path) {
Ok(text) => parse_allowlist(&text).map_err(|e| anyhow::anyhow!("{}: {e}", path.display())),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Allowlist {
patterns: Vec::new(),
}),
Err(e) => Err(e).with_context(|| format!("read {}", path.display())),
}
}
fn resolve_common_dir(root: &Path, common: &str) -> anyhow::Result<PathBuf> {
let raw = Path::new(common);
let joined = if raw.is_absolute() {
raw.to_path_buf()
} else {
root.join(raw)
};
fs::canonicalize(&joined)
.with_context(|| format!("canonicalize git-common-dir {}", joined.display()))
}
pub(crate) fn is_linked_worktree(root: &Path) -> anyhow::Result<bool> {
let git_dir = resolve_common_dir(root, &git::git_text(root, &["rev-parse", "--git-dir"])?)?;
let common = resolve_common_dir(
root,
&git::git_text(root, &["rev-parse", "--git-common-dir"])?,
)?;
Ok(git_dir != common)
}
fn verify_sibling_worktree(source: &Path, fork: &Path) -> anyhow::Result<()> {
if source == fork {
bail!("fork path is the source tree itself; refusing to provision");
}
let source_common = resolve_common_dir(
source,
&git::git_text(source, &["rev-parse", "--git-common-dir"])?,
)?;
let fork_common = resolve_common_dir(
fork,
&git::git_text(fork, &["rev-parse", "--git-common-dir"])?,
)?;
if source_common != fork_common {
bail!(
"fork {} is not a worktree of the source repo (git-common-dir differs)",
fork.display()
);
}
Ok(())
}
fn enumerate_candidates(root: &Path) -> anyhow::Result<Vec<String>> {
let raw = git::git_bytes(
root,
&[
"ls-files",
"-z",
"--others",
"--ignored",
"--exclude-standard",
],
)?;
let mut out = Vec::new();
for chunk in raw.split(|b| *b == 0) {
if chunk.is_empty() {
continue;
}
let path = std::str::from_utf8(chunk)
.map_err(|e| anyhow::anyhow!("non-utf8 path from git ls-files: {e}"))?;
out.push(path.to_string());
}
Ok(out)
}
pub(crate) fn run_provision(path: Option<PathBuf>, fork: &Path) -> anyhow::Result<()> {
let source = root::find(path, &root::default_markers())?;
let source = fs::canonicalize(&source)
.with_context(|| format!("canonicalize source root {}", source.display()))?;
let allow = read_allowlist(&source)?;
let violations = allowlist_violations(&allow);
if !violations.is_empty() {
for v in &violations {
writeln!(
io::stderr(),
"refusing: pattern `{}` names the withheld {} tier",
v.pattern,
v.tier
)?;
}
bail!(
"{} .worktreeinclude pattern(s) name a withheld tier; refusing to provision",
violations.len()
);
}
let fork =
fs::canonicalize(fork).with_context(|| format!("canonicalize fork {}", fork.display()))?;
verify_sibling_worktree(&source, &fork)?;
let candidates = enumerate_candidates(&source)?;
let selection = select_copies(&allow, &candidates);
let withheld_target = |rel: &Path| rel.to_str().is_some_and(|s| is_withheld(s).is_some());
let mut copied = 0usize;
let mut skipped = 0usize;
for rel in &selection.copy {
match fsutil::copy_selected(&source, &fork, Path::new(rel), &withheld_target)? {
CopyOutcome::Copied => copied += 1,
CopyOutcome::Skipped(reason) => {
skipped += 1;
writeln!(io::stderr(), "skipped {rel}: {reason}")?;
}
}
}
for held in &selection.withheld {
writeln!(io::stderr(), "withheld {} ({} tier)", held.path, held.tier)?;
}
writeln!(
io::stdout(),
"provisioned {}: {copied} copied, {} withheld, {skipped} skipped",
fork.display(),
selection.withheld.len()
)?;
Ok(())
}
pub(crate) fn run_check_allowlist(path: Option<PathBuf>) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let file = root.join(ALLOWLIST_FILE);
let text = match fs::read_to_string(&file) {
Ok(t) => t,
Err(e) if e.kind() == ErrorKind::NotFound => {
writeln!(io::stdout(), "no {ALLOWLIST_FILE} — nothing to check")?;
return Ok(());
}
Err(e) => return Err(e).with_context(|| format!("read {}", file.display())),
};
let allow = parse_allowlist(&text).map_err(|e| anyhow::anyhow!("{}: {e}", file.display()))?;
let violations = allowlist_violations(&allow);
if violations.is_empty() {
writeln!(
io::stdout(),
"ok — no allowlist pattern names a withheld tier"
)?;
return Ok(());
}
for v in &violations {
writeln!(
io::stderr(),
"violation: pattern `{}` names the withheld {} tier",
v.pattern,
v.tier
)?;
}
bail!(
"{} allowlist pattern(s) name a withheld tier",
violations.len()
)
}
fn resolve_commit(root: &Path, reference: &str) -> anyhow::Result<String> {
Ok(git::git_text(
root,
&["rev-parse", "--verify", &format!("{reference}^{{commit}}")],
)?)
}
pub(crate) fn run_branch_point_check(
path: Option<PathBuf>,
base: &str,
head: Option<String>,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let head = head.unwrap_or_else(|| "HEAD".to_owned());
let base_sha = resolve_commit(&root, base)?;
let head_sha = resolve_commit(&root, &head)?;
if matches(&base_sha, &head_sha) {
writeln!(io::stdout(), "stationary: HEAD == base {base_sha}")?;
Ok(())
} else {
bail!("HEAD moved: base {base_sha} != HEAD {head_sha}");
}
}
const DOCTRINE_PREFIX: &str = ".doctrine/";
const CLAUDE_PREFIX: &str = ".claude/";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Apply {
Ok,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Refusal {
HeadMoved,
TreeUnclean,
MultiCommit,
DoctrineTouch,
ClaudeTouch,
}
impl Refusal {
pub(crate) fn token(self) -> &'static str {
match self {
Refusal::HeadMoved => "head-moved",
Refusal::TreeUnclean => "tree-unclean",
Refusal::MultiCommit => "multi-commit",
Refusal::DoctrineTouch => "doctrine-touch",
Refusal::ClaudeTouch => "claude-touch",
}
}
}
pub(crate) fn classify_import(
head_at_base: bool,
tree_clean: bool,
single_commit: bool,
delta_paths: &[String],
) -> Result<Apply, Refusal> {
if !head_at_base {
return Err(Refusal::HeadMoved);
}
if !tree_clean {
return Err(Refusal::TreeUnclean);
}
if !single_commit {
return Err(Refusal::MultiCommit);
}
for path in delta_paths {
if path.starts_with(DOCTRINE_PREFIX) {
return Err(Refusal::DoctrineTouch);
}
if path.starts_with(CLAUDE_PREFIX) {
return Err(Refusal::ClaudeTouch);
}
}
Ok(Apply::Ok)
}
fn gather_tree_clean(root: &Path) -> anyhow::Result<bool> {
Ok(git::tree_clean(root)?)
}
pub(crate) fn run_import(path: Option<PathBuf>, base: &str, fork: &str) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let base_sha = resolve_commit(&root, base)?;
let head_sha = resolve_commit(&root, "HEAD")?;
let head_at_base = matches(&base_sha, &head_sha);
let tree_clean = gather_tree_clean(&root)?;
let fork_parent = git::git_opt(
&root,
&["rev-parse", "--verify", &format!("{fork}^^{{commit}}")],
)?;
let single_commit = fork_parent
.as_deref()
.is_some_and(|p| matches(p, &base_sha));
let diff = git::git_text(
&root,
&[
"-c",
"core.quotePath=false",
"diff",
"--name-only",
"--no-renames",
&format!("{base}..{fork}"),
],
)?;
let delta_paths: Vec<String> = diff.lines().map(str::to_owned).collect();
match classify_import(head_at_base, tree_clean, single_commit, &delta_paths) {
Err(refusal) => bail!("import-refused: {}", refusal.token()),
Ok(Apply::Ok) => {}
}
let patch = git::git_bytes(&root, &["diff", "--no-renames", &format!("{base}..{fork}")])?;
git::git_apply_index(&root, &patch)
.with_context(|| format!("git apply --3way --index {base}..{fork}"))?;
writeln!(
io::stdout(),
"imported {base}..{fork}: delta staged (uncommitted)"
)?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Merge {
Ok,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LandRefusal {
TreeUnclean,
NoSuchFork,
WorktreeGone,
DispatchFork,
MergeConflict,
WedgedMerge,
InconsistentMergeState,
}
impl LandRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
LandRefusal::TreeUnclean => "tree-unclean",
LandRefusal::NoSuchFork => "no-such-fork",
LandRefusal::WorktreeGone => "worktree-gone",
LandRefusal::DispatchFork => "dispatch-fork",
LandRefusal::MergeConflict => "merge-conflict",
LandRefusal::WedgedMerge => "wedged-merge",
LandRefusal::InconsistentMergeState => "inconsistent-merge-state",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ForkState {
pub(crate) exists: bool,
pub(crate) has_live_worktree: bool,
pub(crate) bears_marker: bool,
}
pub(crate) fn classify_land(
tree_status_clean: bool,
_head: &str,
fork_state: ForkState,
) -> Result<Merge, LandRefusal> {
if !tree_status_clean {
return Err(LandRefusal::TreeUnclean);
}
if !fork_state.exists {
return Err(LandRefusal::NoSuchFork);
}
if !fork_state.has_live_worktree {
return Err(LandRefusal::WorktreeGone);
}
if fork_state.bears_marker {
return Err(LandRefusal::DispatchFork);
}
Ok(Merge::Ok)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CoordAction {
Create,
Resume,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CoordRefusal {
LiveWorktree,
}
impl CoordRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
CoordRefusal::LiveWorktree => "coordination-live",
}
}
}
pub(crate) fn classify_coordinate(
exists: bool,
has_live_worktree: bool,
) -> Result<CoordAction, CoordRefusal> {
match (exists, has_live_worktree) {
(false, _) => Ok(CoordAction::Create),
(true, true) => Err(CoordRefusal::LiveWorktree),
(true, false) => Ok(CoordAction::Resume),
}
}
fn gather_fork_worktree(root: &Path, fork: &str) -> anyhow::Result<Option<PathBuf>> {
Ok(git::worktree_for_ref(root, &format!("refs/heads/{fork}"))?)
}
pub(crate) fn run_land(path: Option<PathBuf>, fork: &str) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let tree_clean = gather_tree_clean(&root)?;
let exists = git::git_opt(
&root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{fork}^{{commit}}"),
],
)?
.is_some();
let fork_wt = gather_fork_worktree(&root, fork)?;
let has_live_worktree = fork_wt.is_some();
let bears_marker = fork_wt.as_deref().is_some_and(marker_present);
let head = git::git_text(&root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let fork_state = ForkState {
exists,
has_live_worktree,
bears_marker,
};
match classify_land(tree_clean, &head, fork_state) {
Err(refusal) => bail!("land-refused: {}", refusal.token()),
Ok(Merge::Ok) => {}
}
let merged = git::git_opt(&root, &["merge", "--no-ff", "--no-edit", fork])?;
if merged.is_some() {
writeln!(
io::stdout(),
"landed {fork}: --no-ff merge onto coordination HEAD"
)?;
return Ok(());
}
let mid_merge =
git::git_opt(&root, &["rev-parse", "--verify", "--quiet", "MERGE_HEAD"])?.is_some();
if !mid_merge {
bail!(
"land-refused: {}",
LandRefusal::InconsistentMergeState.token()
);
}
let unmerged = git::git_text(&root, &["diff", "--name-only", "--diff-filter=U"])?;
let aborted = git::git_opt(&root, &["merge", "--abort"])?;
if aborted.is_some() {
bail!("land-refused: {}", LandRefusal::MergeConflict.token());
}
bail!(
"land-refused: {token} — `git merge --abort` failed; MERGE_HEAD is present and the tree is NOT clean. Unmerged paths:\n{unmerged}\nManual remedy: resolve in place and `git commit`, or `git merge --abort` / `git reset --hard {head}` from the coordination root.",
token = LandRefusal::WedgedMerge.token(),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GcState {
pub(crate) branch_exists: bool,
pub(crate) worktree_present: bool,
pub(crate) target_present: bool,
pub(crate) landed_verdict: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GcPlan {
pub(crate) remove_worktree: bool,
pub(crate) delete_branch: bool,
pub(crate) reap_target: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GcRefusal {
NotLanded,
}
impl GcRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
GcRefusal::NotLanded => "not-landed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GcVerdict {
Reap(GcPlan),
Refuse(GcRefusal),
}
pub(crate) fn classify_gc(
state: GcState,
force: bool,
superseded_match: bool,
_dry_run: bool,
) -> GcVerdict {
if !state.branch_exists {
return GcVerdict::Reap(GcPlan {
remove_worktree: false,
delete_branch: false,
reap_target: state.target_present,
});
}
let authorised = force || superseded_match || state.landed_verdict == Some(true);
if !authorised {
return GcVerdict::Refuse(GcRefusal::NotLanded);
}
GcVerdict::Reap(GcPlan {
remove_worktree: state.worktree_present,
delete_branch: true,
reap_target: state.target_present,
})
}
fn gc_target_dir(fork: &Path, branch: &str) -> PathBuf {
let base = match std::env::var_os("CARGO_TARGET_DIR") {
Some(v) => PathBuf::from(v),
None => fork.join("target"),
};
base.join(target_dir_for_branch(branch))
}
fn reap_targets(plan: GcPlan) -> String {
let mut parts: Vec<&str> = Vec::new();
if plan.remove_worktree {
parts.push("worktree");
}
if plan.delete_branch {
parts.push("branch");
}
if plan.reap_target {
parts.push("target");
}
if parts.is_empty() {
"nothing".to_owned()
} else {
parts.join("/")
}
}
fn gather_landed(root: &Path, fork: &str) -> anyhow::Result<bool> {
if git::git_status_ok(root, &["merge-base", "--is-ancestor", fork, "HEAD"])? {
return Ok(true);
}
let cherry = git::git_cherry(root, "HEAD", fork)?;
Ok(!cherry.is_empty() && cherry.iter().all(|line| line.starts_with('-')))
}
pub(crate) fn run_gc(
path: Option<PathBuf>,
fork: &str,
superseded_head: Option<&str>,
force: bool,
dry_run: bool,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let root =
fs::canonicalize(&root).with_context(|| format!("canonicalize root {}", root.display()))?;
let branch_ref = format!("refs/heads/{fork}");
let branch_head = git::git_opt(
&root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{branch_ref}^{{commit}}"),
],
)?;
let branch_exists = branch_head.is_some();
let fork_wt = gather_fork_worktree(&root, fork)?;
let worktree_present = fork_wt.is_some();
let fork_dir = fork_wt.as_deref().unwrap_or(&root);
let target = gc_target_dir(fork_dir, fork);
let target_present = target.exists();
let landed_verdict = if branch_exists {
Some(gather_landed(&root, fork)?)
} else {
None
};
let superseded_match = match (superseded_head, &branch_head) {
(Some(sha), Some(head)) => {
match git::git_opt(
&root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{sha}^{{commit}}"),
],
)? {
Some(resolved) => matches(&resolved, head),
None => false,
}
}
_ => false,
};
let state = GcState {
branch_exists,
worktree_present,
target_present,
landed_verdict,
};
let verdict = classify_gc(state, force, superseded_match, dry_run);
if dry_run {
match verdict {
GcVerdict::Reap(plan) => {
let basis = if !branch_exists {
"already-certified (branch gone)".to_owned()
} else if landed_verdict == Some(true) {
"landed ✓ (oracle)".to_owned()
} else {
let how = if force {
"--force"
} else {
"--superseded-head"
};
format!("NOT landed — reap authorised by {how} (oracle override)")
};
writeln!(
io::stdout(),
"{fork}: {basis} — would reap ({})",
reap_targets(plan)
)?;
}
GcVerdict::Refuse(GcRefusal::NotLanded) => {
writeln!(
io::stdout(),
"{fork}: not-landed — `--force` to reap, or `--superseded-head <SHA>` if spent-and-abandoned. If you squash-merged, re-land via `worktree land` (--no-ff)."
)?;
}
}
return Ok(());
}
let plan = match verdict {
GcVerdict::Refuse(GcRefusal::NotLanded) => bail!(
"gc-refused: {} — fork {fork} has not provably landed; `--force` to reap, or `--superseded-head <SHA>` to assert it is spent-and-abandoned. Cannot certify a squash-merge — re-land via `worktree land` (--no-ff), or `--force` knowingly.",
GcRefusal::NotLanded.token()
),
GcVerdict::Reap(plan) => plan,
};
let mut leftovers: Vec<String> = Vec::new();
if let (true, Some(wt)) = (plan.remove_worktree, fork_wt.as_deref()) {
let removed = git::git_opt(
&root,
&["worktree", "remove", "--force", &wt.to_string_lossy()],
)?;
if removed.is_none() {
drop(git::git_opt(&root, &["worktree", "prune"]));
if wt.exists() {
leftovers.push(format!("worktree {}", wt.display()));
}
}
}
if plan.delete_branch {
let deleted = git::git_opt(&root, &["branch", "-D", fork])?;
if deleted.is_none()
&& git::git_opt(&root, &["rev-parse", "--verify", "--quiet", &branch_ref])?.is_some()
{
leftovers.push(format!("branch {fork}"));
}
}
if plan.reap_target
&& target.exists()
&& let Err(e) = fs::remove_dir_all(&target)
&& target.exists()
{
leftovers.push(format!("target {} ({e})", target.display()));
}
if !leftovers.is_empty() {
bail!(
"gc-incomplete: leftover(s) need manual cleanup: {}",
leftovers.join(", ")
);
}
writeln!(
io::stderr(),
"warning: test binaries baked with the reaped fork's CARGO_MANIFEST_DIR are now stale — recompile before trusting a RED"
)?;
writeln!(
io::stdout(),
"gc {fork}: reaped (worktree/branch/target as present)"
)?;
Ok(())
}
pub(crate) fn target_dir_for_branch(branch: &str) -> PathBuf {
Path::new("wt").join(branch)
}
fn project_env_contract(fork: &Path, branch: &str) -> Vec<(String, String)> {
let base = match std::env::var_os("CARGO_TARGET_DIR") {
Some(v) => PathBuf::from(v),
None => fork.join("target"),
};
let target = base.join(target_dir_for_branch(branch));
vec![(
"CARGO_TARGET_DIR".to_owned(),
target.to_string_lossy().into_owned(),
)]
}
fn remove_worktree_dir(repo: &Path, dir: &Path) -> Vec<String> {
let mut debris = Vec::new();
if git::git_text(
repo,
&["worktree", "remove", "--force", &dir.to_string_lossy()],
)
.is_err()
&& dir.exists()
{
debris.push(format!("worktree dir {}", dir.display()));
}
if dir.exists() {
drop(fs::remove_dir_all(dir));
let dir_str = dir.display().to_string();
if dir.exists() {
if !debris.iter().any(|d| d.contains(&dir_str)) {
debris.push(format!("dir {dir_str}"));
}
} else {
debris.retain(|d| !d.contains(&dir_str));
}
}
debris
}
fn rollback_fork(repo: &Path, branch: &str, dir: &Path) -> Vec<String> {
let mut debris = remove_worktree_dir(repo, dir);
if git::git_opt(repo, &["rev-parse", "--verify", "--quiet", branch])
.ok()
.flatten()
.is_some()
{
drop(git::git_text(repo, &["branch", "-D", branch]));
if git::git_opt(repo, &["rev-parse", "--verify", "--quiet", branch])
.ok()
.flatten()
.is_some()
{
debris.push(format!("branch {branch}"));
}
}
debris
}
pub(crate) fn run_fork(
path: Option<PathBuf>,
base: &str,
branch: &str,
dir: &Path,
worker: bool,
) -> anyhow::Result<()> {
let repo = root::find(path, &root::default_markers())?;
if dir.exists() {
bail!("fork-refused: dir {} already exists", dir.display());
}
if git::git_opt(&repo, &["rev-parse", "--verify", "--quiet", branch])
.ok()
.flatten()
.is_some()
{
bail!("fork-refused: branch {branch} already exists");
}
if git::git_opt(
&repo,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{base}^{{commit}}"),
],
)?
.is_none()
{
bail!("fork-refused: base {base} is not a commit");
}
git::git_text(
&repo,
&[
"worktree",
"add",
"-b",
branch,
&dir.to_string_lossy(),
base,
],
)
.with_context(|| format!("git worktree add -b {branch} {} {base}", dir.display()))?;
let finish = (|| -> anyhow::Result<()> {
run_provision(Some(repo.clone()), dir).context("provision fork")?;
if worker {
write_marker(dir).context("stamp worker marker")?;
}
Ok(())
})();
if let Err(cause) = finish {
let debris = rollback_fork(&repo, branch, dir);
if debris.is_empty() {
return Err(cause.context(format!(
"fork failed after add; rolled back cleanly (dir {} + branch {branch} removed)",
dir.display()
)));
}
bail!(
"fork-rollback-debris: {} (original cause: {cause:#})",
debris.join(", ")
);
}
for (key, value) in project_env_contract(dir, branch) {
writeln!(io::stdout(), "{key}={value}")?;
}
writeln!(
io::stderr(),
"forked {branch} at {base} → {}{}",
dir.display(),
if worker {
" (worker: marker stamped)"
} else {
""
}
)?;
Ok(())
}
pub(crate) fn coordinate(root: &Path, slice: u32, dir: &Path) -> anyhow::Result<CoordOutcome> {
let branch = format!("dispatch/{slice:03}");
if dir.exists() {
bail!("coordinate-refused: dir {} already exists", dir.display());
}
let exists = git::git_opt(
root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{branch}^{{commit}}"),
],
)?
.is_some();
let live_worktree = gather_fork_worktree(root, &branch)?;
let action = match classify_coordinate(exists, live_worktree.is_some()) {
Ok(action) => action,
Err(refusal) => {
let at = live_worktree
.map(|p| p.display().to_string())
.unwrap_or_default();
bail!(
"coordinate-refused: {} — {branch} has a live worktree at {at}",
refusal.token()
);
}
};
match action {
CoordAction::Create => {
let trunk = git::trunk_commit(root)?.ok_or_else(|| {
anyhow::anyhow!(
"coordinate-refused: no trunk ref resolves (set DOCTRINE_TRUNK_REF)"
)
})?;
git::git_text(
root,
&[
"worktree",
"add",
"-b",
&branch,
&dir.to_string_lossy(),
&trunk,
],
)
.with_context(|| format!("git worktree add -b {branch} {} {trunk}", dir.display()))?;
}
CoordAction::Resume => {
git::git_text(root, &["worktree", "add", &dir.to_string_lossy(), &branch])
.with_context(|| format!("git worktree add {} {branch}", dir.display()))?;
}
}
let finish = (|| -> anyhow::Result<()> {
run_provision(Some(root.to_path_buf()), dir).context("provision coordination worktree")?;
crate::slice::run_phases(Some(dir.to_path_buf()), slice, false)
.context("regenerate runtime phase sheets")?;
Ok(())
})();
if let Err(cause) = finish {
let debris = match action {
CoordAction::Create => rollback_fork(root, &branch, dir),
CoordAction::Resume => remove_worktree_dir(root, dir),
};
if debris.is_empty() {
return Err(cause.context(format!(
"coordinate failed after add; rolled back cleanly (worktree {} removed)",
dir.display()
)));
}
bail!(
"coordinate-rollback-debris: {} (original cause: {cause:#})",
debris.join(", ")
);
}
let dispatch_tip = git::git_text(
root,
&["rev-parse", "--short", &format!("refs/heads/{branch}")],
)?;
Ok(CoordOutcome { dispatch_tip })
}
pub(crate) fn run_coordinate(path: Option<PathBuf>, slice: u32, dir: &Path) -> anyhow::Result<()> {
let repo = root::find(path, &root::default_markers())?;
let branch = format!("dispatch/{slice:03}");
let branch_existed = git::git_opt(
&repo,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{branch}^{{commit}}"),
],
)?
.is_some();
let _outcome = coordinate(&repo, slice, dir)?;
for (key, value) in project_env_contract(dir, &branch) {
writeln!(io::stdout(), "{key}={value}")?;
}
let verb = if branch_existed { "resumed" } else { "created" };
writeln!(
io::stderr(),
"coordination worktree {verb}: {branch} → {} (markerless)",
dir.display()
)?;
Ok(())
}
pub(crate) const DISPATCH_WORKER_AGENT_TYPE: &str = "dispatch-worker";
pub(crate) fn marker_path(root: &Path) -> PathBuf {
root.join(".doctrine/state/dispatch/worker")
}
pub(crate) fn marker_present(root: &Path) -> bool {
marker_path(root).exists()
}
pub(crate) fn env_worker_set() -> bool {
std::env::var_os("DOCTRINE_WORKER").as_deref() == Some(std::ffi::OsStr::new("1"))
}
pub(crate) fn write_marker(root: &Path) -> anyhow::Result<()> {
let path = marker_path(root);
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)
.with_context(|| format!("create dispatch marker dir {}", dir.display()))?;
}
fs::write(&path, b"").with_context(|| format!("write worker marker {}", path.display()))?;
Ok(())
}
pub(crate) fn remove_marker(root: &Path) -> anyhow::Result<()> {
let path = marker_path(root);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| format!("remove worker marker {}", path.display())),
}
}
pub(crate) fn resolve_mode(root: &Path) -> StatusLine {
let env_set = env_worker_set();
let is_linked = is_linked_worktree(root).unwrap_or(false);
let marker = is_linked && marker_present(root);
describe_mode(is_linked, marker, env_set)
}
pub(crate) const DUAL_CAUSE: &str = "`DOCTRINE_WORKER` set outside a worker worktree: a worker was dropped on the coordination root → re-dispatch isolated; or the env leaked into this process → unset it";
pub(crate) fn run_status(path: Option<PathBuf>, assert: bool) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let mode = resolve_mode(&root);
if mode.refused {
writeln!(
io::stdout(),
"worker fork: yes — writes refused; signal: {}",
mode.cause_token()
)?;
} else {
writeln!(io::stdout(), "worker fork: no — writes allowed")?;
}
if assert && mode.is_stale_marker() {
bail!(
"stale-marker: a worker marker is present in this linked worktree but no dispatch is active — clear it with `doctrine worktree marker --clear --operator`"
);
}
Ok(())
}
pub(crate) fn run_marker_clear(path: Option<PathBuf>, operator: bool) -> anyhow::Result<()> {
if env_worker_set() {
bail!(
"refusing `marker --clear` while `DOCTRINE_WORKER` is set — run it from a process without the env leg (unset DOCTRINE_WORKER)"
);
}
let root = root::find(path, &root::default_markers())?;
let root =
fs::canonicalize(&root).with_context(|| format!("canonicalize root {}", root.display()))?;
let cwd = std::env::current_dir().context("current dir")?;
let cwd =
fs::canonicalize(&cwd).with_context(|| format!("canonicalize cwd {}", cwd.display()))?;
if cwd != root {
bail!(
"refusing `marker --clear`: cwd {} is not the marker's tree root {} — run it from the tree root",
cwd.display(),
root.display()
);
}
if is_linked_worktree(&root).unwrap_or(false) && !operator {
bail!(
"refusing `marker --clear` in a linked worktree without `--operator` — this is the accident-fence; pass `--operator` to confirm you are the trusted orchestrator"
);
}
let existed = marker_present(&root);
remove_marker(&root)?;
if existed {
writeln!(
io::stdout(),
"CLEARED worker marker at {} — writes restored",
marker_path(&root).display()
)?;
} else {
writeln!(
io::stdout(),
"no worker marker at {} — nothing to clear",
marker_path(&root).display()
)?;
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Stamp {
Ok,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StampRefusal {
MissingCwd,
BadDir,
MissingAgentType,
AlreadyMarked,
}
impl StampRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
StampRefusal::MissingCwd => "missing-cwd",
StampRefusal::BadDir => "bad-dir",
StampRefusal::MissingAgentType => "missing-agent-type",
StampRefusal::AlreadyMarked => "already-marked",
}
}
}
pub(crate) fn classify_stamp(
agent_type: &str,
cwd_present: bool,
cwd_is_under_repo_linked_worktree: bool,
already_marked: bool,
) -> Result<Stamp, StampRefusal> {
if !cwd_present {
return Err(StampRefusal::MissingCwd);
}
if !cwd_is_under_repo_linked_worktree {
return Err(StampRefusal::BadDir);
}
if agent_type != DISPATCH_WORKER_AGENT_TYPE {
return Err(StampRefusal::MissingAgentType);
}
if already_marked {
return Err(StampRefusal::AlreadyMarked);
}
Ok(Stamp::Ok)
}
#[derive(Debug, Default, serde::Deserialize)]
struct SubagentPayload {
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
agent_type: Option<String>,
}
fn cwd_shares_repo(repo: &Path, cwd: &Path) -> bool {
let repo_common = git::git_text(repo, &["rev-parse", "--git-common-dir"])
.ok()
.and_then(|c| resolve_common_dir(repo, &c).ok());
let cwd_common = git::git_text(cwd, &["rev-parse", "--git-common-dir"])
.ok()
.and_then(|c| resolve_common_dir(cwd, &c).ok());
match (repo_common, cwd_common) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
pub(crate) fn run_stamp_subagent(path: Option<PathBuf>) -> anyhow::Result<()> {
let mut raw = String::new();
io::Read::read_to_string(&mut io::stdin(), &mut raw).context("read SubagentStart payload")?;
let payload: SubagentPayload = serde_json::from_str(&raw).unwrap_or_default();
let agent_type = payload.agent_type.unwrap_or_default();
let cwd_str = payload.cwd.unwrap_or_default();
let cwd_present = !cwd_str.is_empty();
let repo = root::find(path, &root::default_markers())
.ok()
.and_then(|r| fs::canonicalize(&r).ok());
let cwd_canon = if cwd_present {
fs::canonicalize(&cwd_str).ok()
} else {
None
};
let cwd_valid = match (repo.as_deref(), cwd_canon.as_deref()) {
(Some(repo), Some(cwd)) => {
is_linked_worktree(cwd).unwrap_or(false) && cwd_shares_repo(repo, cwd)
}
_ => false,
};
let already_marked = cwd_canon.as_deref().is_some_and(marker_present);
match classify_stamp(&agent_type, cwd_present, cwd_valid, already_marked) {
Ok(Stamp::Ok) => {}
Err(refusal) => {
writeln!(io::stderr(), "stamp-refused: {}", refusal.token())?;
bail!("stamp-refused: {}", refusal.token());
}
}
let (Some(source), Some(cwd)) = (repo, cwd_canon) else {
let token = StampRefusal::BadDir.token();
writeln!(io::stderr(), "stamp-refused: {token}")?;
bail!("stamp-refused: {token}");
};
if let Err(cause) = run_provision(Some(source), &cwd).and_then(|()| write_marker(&cwd)) {
writeln!(
io::stderr(),
"STAMP FAILED for {} — worktree LEFT in place (not removed); orchestrator post-spawn check will catch the unstamped worker: {cause:#}",
cwd.display()
)?;
return Err(cause.context(format!("stamp worker worktree {}", cwd.display())));
}
writeln!(io::stderr(), "stamped worker worktree {}", cwd.display())?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WorkerVerify {
Ok,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WorkerVerifyRefusal {
NoWorkerHead,
NotIsolated,
Unstamped,
WrongBase,
BranchMismatch,
}
impl WorkerVerifyRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
WorkerVerifyRefusal::NoWorkerHead => "no-worker-head",
WorkerVerifyRefusal::NotIsolated => "not-isolated",
WorkerVerifyRefusal::Unstamped => "unstamped",
WorkerVerifyRefusal::WrongBase => "wrong-base",
WorkerVerifyRefusal::BranchMismatch => "branch-mismatch",
}
}
}
#[expect(
clippy::fn_params_excessive_bools,
reason = "pure classifier with distinct ordered preconditions; a struct would obscure the precond order"
)]
pub(crate) fn classify_worker_verify(
head_resolved: bool,
is_isolated: bool,
marker_present: bool,
base_is_ancestor: bool,
head_is_branch_tip: bool,
) -> Result<WorkerVerify, WorkerVerifyRefusal> {
if !head_resolved {
return Err(WorkerVerifyRefusal::NoWorkerHead);
}
if !is_isolated {
return Err(WorkerVerifyRefusal::NotIsolated);
}
if !marker_present {
return Err(WorkerVerifyRefusal::Unstamped);
}
if !base_is_ancestor {
return Err(WorkerVerifyRefusal::WrongBase);
}
if !head_is_branch_tip {
return Err(WorkerVerifyRefusal::BranchMismatch);
}
Ok(WorkerVerify::Ok)
}
pub(crate) fn run_verify_worker(
base: &str,
dir: &Path,
branch: Option<&str>,
) -> anyhow::Result<()> {
let head_resolved = git::git_opt(dir, &["rev-parse", "--verify", "HEAD"])?.is_some();
let is_isolated = head_resolved && is_linked_worktree(dir)?;
let marker = marker_present(dir);
let base_is_ancestor = git::git_status_ok(dir, &["merge-base", "--is-ancestor", base, "HEAD"])?;
let head_is_branch_tip = match branch {
Some(s) => {
let head = git::git_opt(dir, &["rev-parse", "--verify", "HEAD"])?;
let tip = git::git_opt(dir, &["rev-parse", "--verify", &format!("{s}^{{commit}}")])?;
matches!((head, tip), (Some(h), Some(t)) if h == t)
}
None => true,
};
match classify_worker_verify(
head_resolved,
is_isolated,
marker,
base_is_ancestor,
head_is_branch_tip,
) {
Ok(WorkerVerify::Ok) => {
writeln!(
io::stderr(),
"verify-worker: base==B holds for {}",
dir.display()
)?;
Ok(())
}
Err(refusal) => {
writeln!(
io::stderr(),
"verify-worker-refused: {} ({})",
refusal.token(),
dir.display()
)?;
bail!("verify-worker-refused: {}", refusal.token());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_is_ref_equality() {
assert!(matches("abc123", "abc123"), "equal shas ⇒ stationary");
assert!(!matches("abc123", "def456"), "differing shas ⇒ moved");
assert!(!matches("abc123", ""), "empty head ⇒ moved");
assert!(
matches("", ""),
"degenerate equal ⇒ stationary (caller guards emptiness)"
);
}
fn fork_state(exists: bool, has_live_worktree: bool, bears_marker: bool) -> ForkState {
ForkState {
exists,
has_live_worktree,
bears_marker,
}
}
#[test]
fn classify_land_precedence_and_ok() {
assert_eq!(
classify_land(true, "main", fork_state(true, true, false)),
Ok(Merge::Ok)
);
assert_eq!(
classify_land(false, "main", fork_state(false, false, true)),
Err(LandRefusal::TreeUnclean)
);
assert_eq!(
classify_land(true, "main", fork_state(false, false, true)),
Err(LandRefusal::NoSuchFork)
);
assert_eq!(
classify_land(true, "main", fork_state(true, false, false)),
Err(LandRefusal::WorktreeGone)
);
assert_eq!(
classify_land(true, "main", fork_state(true, true, true)),
Err(LandRefusal::DispatchFork)
);
}
#[test]
fn classify_land_ignores_head() {
let st = fork_state(true, true, false);
assert_eq!(
classify_land(true, "main", st),
classify_land(true, "detached-xyz", st)
);
}
#[test]
fn land_refusal_tokens_are_distinct_and_exhaustive() {
let all = [
LandRefusal::TreeUnclean,
LandRefusal::NoSuchFork,
LandRefusal::WorktreeGone,
LandRefusal::DispatchFork,
LandRefusal::MergeConflict,
LandRefusal::WedgedMerge,
LandRefusal::InconsistentMergeState,
];
let tokens: Vec<&str> = all.iter().map(|r| r.token()).collect();
assert_eq!(tokens.len(), 7, "exactly seven refusal tokens");
let unique: std::collections::BTreeSet<&str> = tokens.iter().copied().collect();
assert_eq!(unique.len(), 7, "every token is distinct");
assert_eq!(LandRefusal::WedgedMerge.token(), "wedged-merge");
assert_eq!(LandRefusal::MergeConflict.token(), "merge-conflict");
assert_eq!(
LandRefusal::InconsistentMergeState.token(),
"inconsistent-merge-state"
);
}
fn gc_state(
branch_exists: bool,
worktree_present: bool,
target_present: bool,
landed_verdict: Option<bool>,
) -> GcState {
GcState {
branch_exists,
worktree_present,
target_present,
landed_verdict,
}
}
#[test]
fn classify_coordinate_create_resume_collide() {
assert_eq!(classify_coordinate(false, false), Ok(CoordAction::Create));
assert_eq!(classify_coordinate(false, true), Ok(CoordAction::Create));
assert_eq!(classify_coordinate(true, false), Ok(CoordAction::Resume));
assert_eq!(
classify_coordinate(true, true),
Err(CoordRefusal::LiveWorktree)
);
}
#[test]
fn coord_refusal_token_distinct() {
assert_eq!(CoordRefusal::LiveWorktree.token(), "coordination-live");
}
#[test]
fn classify_gc_landed_reaps_present_things_in_order() {
let v = classify_gc(gc_state(true, true, true, Some(true)), false, false, false);
assert_eq!(
v,
GcVerdict::Reap(GcPlan {
remove_worktree: true,
delete_branch: true,
reap_target: true,
})
);
}
#[test]
fn classify_gc_skips_absent_steps() {
let v = classify_gc(
gc_state(true, false, false, Some(true)),
false,
false,
false,
);
assert_eq!(
v,
GcVerdict::Reap(GcPlan {
remove_worktree: false,
delete_branch: true,
reap_target: false,
})
);
}
#[test]
fn classify_gc_branch_gone_reaps_only_the_target() {
let v = classify_gc(gc_state(false, false, true, None), false, false, false);
assert_eq!(
v,
GcVerdict::Reap(GcPlan {
remove_worktree: false,
delete_branch: false,
reap_target: true,
})
);
let done = classify_gc(gc_state(false, false, false, None), false, false, false);
assert_eq!(
done,
GcVerdict::Reap(GcPlan {
remove_worktree: false,
delete_branch: false,
reap_target: false,
})
);
}
#[test]
fn classify_gc_not_landed_refuses_unless_overridden() {
let st = gc_state(true, true, true, Some(false));
assert_eq!(
classify_gc(st, false, false, false),
GcVerdict::Refuse(GcRefusal::NotLanded)
);
assert!(matches!(
classify_gc(st, true, false, false),
GcVerdict::Reap(_)
));
assert!(matches!(
classify_gc(st, false, true, false),
GcVerdict::Reap(_)
));
}
#[test]
fn classify_gc_dry_run_does_not_change_the_verdict() {
let landed = gc_state(true, true, true, Some(true));
assert_eq!(
classify_gc(landed, false, false, true),
classify_gc(landed, false, false, false)
);
let refused = gc_state(true, true, true, Some(false));
assert_eq!(
classify_gc(refused, false, false, true),
classify_gc(refused, false, false, false)
);
}
#[test]
fn gc_refusal_token_is_not_landed() {
assert_eq!(GcRefusal::NotLanded.token(), "not-landed");
}
#[test]
fn target_dir_for_branch_maps_under_wt() {
assert_eq!(
target_dir_for_branch("sl056-p06"),
PathBuf::from("wt/sl056-p06"),
"branch maps to wt/<branch>"
);
assert_eq!(
target_dir_for_branch("feature/x"),
PathBuf::from("wt/feature/x"),
"slashes in the branch survive as nested components"
);
}
#[test]
fn describe_mode_truth_table() {
let solo_plain = describe_mode(false, false, false);
assert!(!solo_plain.refused, "no signal ⇒ writes allowed");
assert_eq!(solo_plain.cause, Cause::None);
let marker_on_main = describe_mode(false, true, false);
assert!(
!marker_on_main.refused,
"marker without a linked worktree is inert ⇒ allowed"
);
assert_eq!(marker_on_main.cause, Cause::None);
let linked_no_marker = describe_mode(true, false, false);
assert!(!linked_no_marker.refused, "linked, no marker ⇒ allowed");
assert_eq!(linked_no_marker.cause, Cause::None);
let marker = describe_mode(true, true, false);
assert!(marker.refused);
assert_eq!(marker.cause, Cause::Marker);
assert!(
marker.is_stale_marker(),
"marker-only in a fork is the stale-marker case"
);
assert!(!marker.is_env_on_nonlinked());
let env_main = describe_mode(false, false, true);
assert!(env_main.refused);
assert_eq!(env_main.cause, Cause::Env);
assert!(env_main.is_env_on_nonlinked(), "env on main ⇒ dual-cause");
assert!(!env_main.is_stale_marker());
let env_linked = describe_mode(true, false, true);
assert!(env_linked.refused);
assert_eq!(env_linked.cause, Cause::Env);
assert!(!env_linked.is_env_on_nonlinked());
let both = describe_mode(true, true, true);
assert!(both.refused);
assert_eq!(both.cause, Cause::Both);
assert!(
!both.is_stale_marker(),
"both is not the marker-only stale case"
);
assert_eq!(solo_plain.cause_token(), "none");
assert_eq!(marker.cause_token(), "marker");
assert_eq!(env_main.cause_token(), "env");
assert_eq!(both.cause_token(), "both");
}
#[test]
fn withheld_globs_all_compile() {
for item in WITHHELD {
Pattern::new(item.glob).unwrap();
}
for g in DERIVED_RUNTIME {
Pattern::new(g).unwrap();
}
}
fn gitignore_representative(line: &str) -> String {
let base = line
.strip_suffix('/')
.map_or_else(|| line.to_string(), |dir| format!("{dir}/f"));
base.replace('*', "x")
}
fn classified(rep: &str) -> bool {
WITHHELD
.iter()
.any(|item| glob_matches(&Pattern::new(item.glob).unwrap(), rep))
|| DERIVED_RUNTIME
.iter()
.any(|g| glob_matches(&Pattern::new(g).unwrap(), rep))
}
#[test]
fn every_runtime_gitignore_glob_is_classified() {
let gitignore = fs::read_to_string(".gitignore").unwrap();
for raw in gitignore.lines() {
let line = raw.trim();
if !line.starts_with(".doctrine/") || line == ".doctrine/*" {
continue;
}
let rep = gitignore_representative(line);
assert!(
classified(&rep),
"unclassified runtime gitignore glob `{line}` (rep `{rep}`) — \
add it to WITHHELD or DERIVED_RUNTIME"
);
}
}
#[test]
fn parse_allowlist_accepts_each_supported_class() {
let text = "# a comment\n\nsrc/main.rs\nconfig/*.toml\n**/*.md\nfile?.txt\n";
let allow = parse_allowlist(text).unwrap();
assert_eq!(allow.patterns.len(), 4);
}
#[test]
fn parse_allowlist_rejects_negation() {
let err = parse_allowlist("src/*\n!secret").unwrap_err();
assert!(matches!(err, ParseError::Negation { .. }));
}
#[test]
fn parse_allowlist_rejects_anchoring() {
let err = parse_allowlist("/anchored").unwrap_err();
assert!(matches!(err, ParseError::Anchoring { .. }));
}
#[test]
fn parse_allowlist_rejects_bad_glob() {
let err = parse_allowlist("a[b").unwrap_err();
assert!(matches!(err, ParseError::BadGlob { .. }));
}
#[test]
fn is_withheld_classifies_each_tier() {
assert_eq!(is_withheld(".doctrine/state/boot.md"), Some(Tier::State));
assert_eq!(
is_withheld(".doctrine/state/slice/029/phases/phase-01.md"),
Some(Tier::State)
);
assert_eq!(
is_withheld(".doctrine/slice/029/phases"),
Some(Tier::PhaseLink)
);
assert_eq!(
is_withheld(".doctrine/slice/029/handover.md"),
Some(Tier::Handover)
);
assert_eq!(
is_withheld(".doctrine/memory/index/foo"),
Some(Tier::MemoryCache)
);
assert_eq!(is_withheld("src/main.rs"), None);
assert_eq!(is_withheld(".doctrine/skills/code-review/SKILL.md"), None);
}
#[test]
fn marker_write_present_remove_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
assert!(!marker_present(root), "no marker initially");
write_marker(root).unwrap();
assert!(marker_present(root), "marker present after write");
assert!(
marker_path(root).exists(),
"marker file exists under .doctrine/state/dispatch/worker"
);
remove_marker(root).unwrap();
assert!(!marker_present(root), "marker gone after remove");
remove_marker(root).unwrap();
}
#[test]
fn worker_marker_classifies_as_state_tier() {
let rel = marker_path(Path::new("")).to_string_lossy().into_owned();
let rel = rel.trim_start_matches('/');
assert_eq!(
is_withheld(rel),
Some(Tier::State),
"marker {rel} must classify as the withheld State tier"
);
assert_eq!(
is_withheld(".doctrine/state/dispatch/worker"),
Some(Tier::State)
);
}
#[test]
fn select_copies_withholds_tier_files_under_a_broad_glob() {
let allow = parse_allowlist("**").unwrap();
let candidates = vec![
"src/main.rs".to_string(),
".doctrine/state/boot.md".to_string(),
".doctrine/slice/029/handover.md".to_string(),
];
let sel = select_copies(&allow, &candidates);
assert_eq!(sel.copy, ["src/main.rs"]);
let held: Vec<&str> = sel.withheld.iter().map(|h| h.path.as_str()).collect();
assert!(held.contains(&".doctrine/state/boot.md"));
assert!(held.contains(&".doctrine/slice/029/handover.md"));
}
#[test]
fn select_copies_skips_unallowlisted_candidates() {
let allow = parse_allowlist("docs/**").unwrap();
let candidates = vec!["src/main.rs".to_string(), "docs/guide.md".to_string()];
let sel = select_copies(&allow, &candidates);
assert_eq!(sel.copy, ["docs/guide.md"]);
assert!(sel.withheld.is_empty());
}
#[test]
fn allowlist_violations_flags_a_tier_naming_pattern() {
let allow = parse_allowlist(".doctrine/state/*").unwrap();
let v = allowlist_violations(&allow);
assert!(!v.is_empty());
assert_eq!(v[0].tier, Tier::State);
}
#[test]
fn allowlist_violations_passes_benign_patterns() {
let allow = parse_allowlist("src/**\nconfig/app.toml").unwrap();
assert!(allowlist_violations(&allow).is_empty());
}
#[test]
fn allowlist_violations_flags_a_broad_wildcard() {
let allow = parse_allowlist("**").unwrap();
assert!(!allowlist_violations(&allow).is_empty());
}
fn git(dir: &Path, args: &[&str]) {
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)
);
}
fn init_repo(dir: &Path) -> PathBuf {
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"]);
fs::write(dir.join("seed"), "x").unwrap();
git(dir, &["add", "."]);
git(dir, &["commit", "-q", "-m", "base"]);
fs::canonicalize(dir).unwrap()
}
#[test]
fn is_linked_worktree_true_for_a_fork_false_for_the_primary_tree() {
let tmp = tempfile::tempdir().unwrap();
let primary = init_repo(&tmp.path().join("src"));
let fork = tmp.path().join("fork");
git(
&primary,
&[
"worktree",
"add",
"-q",
"-b",
"feat",
fork.to_str().unwrap(),
],
);
let fork = fs::canonicalize(&fork).unwrap();
assert!(is_linked_worktree(&fork).unwrap(), "a linked worktree");
assert!(!is_linked_worktree(&primary).unwrap(), "the primary tree");
}
#[test]
fn rollback_fork_retracts_stale_worktree_entry_after_fs_reap() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_repo(&tmp.path().join("src"));
let dir = tmp.path().join("orphan");
fs::create_dir_all(&dir).unwrap();
let debris = rollback_fork(&repo, "no-such-branch", &dir);
assert!(
debris.is_empty(),
"a fully fs-reaped rollback reports no debris; got: {debris:?}"
);
assert!(!dir.exists(), "the orphan dir was reaped");
}
#[test]
fn classify_stamp_ok_when_all_inputs_hold() {
assert_eq!(
classify_stamp(DISPATCH_WORKER_AGENT_TYPE, true, true, false),
Ok(Stamp::Ok)
);
}
#[test]
fn classify_stamp_missing_cwd_refuses() {
assert_eq!(
classify_stamp(DISPATCH_WORKER_AGENT_TYPE, false, false, false),
Err(StampRefusal::MissingCwd)
);
assert_eq!(StampRefusal::MissingCwd.token(), "missing-cwd");
}
#[test]
fn classify_stamp_bad_dir_refuses_when_cwd_present_but_invalid() {
assert_eq!(
classify_stamp(DISPATCH_WORKER_AGENT_TYPE, true, false, false),
Err(StampRefusal::BadDir)
);
assert_eq!(
classify_stamp("anything", true, false, false),
Err(StampRefusal::BadDir)
);
assert_eq!(StampRefusal::BadDir.token(), "bad-dir");
}
#[test]
fn classify_stamp_missing_agent_type_refuses() {
assert_eq!(
classify_stamp("", true, true, false),
Err(StampRefusal::MissingAgentType)
);
assert_eq!(
classify_stamp("some-other-agent", true, true, false),
Err(StampRefusal::MissingAgentType)
);
assert_eq!(StampRefusal::MissingAgentType.token(), "missing-agent-type");
}
#[test]
fn classify_stamp_already_marked_refuses() {
assert_eq!(
classify_stamp(DISPATCH_WORKER_AGENT_TYPE, true, true, true),
Err(StampRefusal::AlreadyMarked)
);
assert_eq!(StampRefusal::AlreadyMarked.token(), "already-marked");
}
#[test]
fn classify_worker_verify_ok_when_all_preconds_hold() {
assert_eq!(
classify_worker_verify(true, true, true, true, true),
Ok(WorkerVerify::Ok)
);
}
#[test]
fn classify_worker_verify_no_worker_head_refuses_first() {
assert_eq!(
classify_worker_verify(false, true, true, true, true),
Err(WorkerVerifyRefusal::NoWorkerHead)
);
assert_eq!(
classify_worker_verify(false, false, false, false, false),
Err(WorkerVerifyRefusal::NoWorkerHead)
);
assert_eq!(WorkerVerifyRefusal::NoWorkerHead.token(), "no-worker-head");
}
#[test]
fn classify_worker_verify_unstamped_names_itself_before_base() {
assert_eq!(
classify_worker_verify(true, true, false, false, true),
Err(WorkerVerifyRefusal::Unstamped)
);
assert_eq!(WorkerVerifyRefusal::Unstamped.token(), "unstamped");
}
#[test]
fn classify_worker_verify_wrong_base_refuses_last() {
assert_eq!(
classify_worker_verify(true, true, true, false, true),
Err(WorkerVerifyRefusal::WrongBase)
);
assert_eq!(WorkerVerifyRefusal::WrongBase.token(), "wrong-base");
}
#[test]
fn classify_worker_verify_not_isolated_refuses_after_head_before_marker() {
assert_eq!(
classify_worker_verify(true, false, true, true, true),
Err(WorkerVerifyRefusal::NotIsolated)
);
assert_eq!(
classify_worker_verify(true, false, false, false, false),
Err(WorkerVerifyRefusal::NotIsolated)
);
assert_eq!(WorkerVerifyRefusal::NotIsolated.token(), "not-isolated");
}
#[test]
fn classify_worker_verify_branch_mismatch_refuses_last() {
assert_eq!(
classify_worker_verify(true, true, true, true, false),
Err(WorkerVerifyRefusal::BranchMismatch)
);
assert_eq!(
classify_worker_verify(true, true, true, true, true),
Ok(WorkerVerify::Ok)
);
assert_eq!(
WorkerVerifyRefusal::BranchMismatch.token(),
"branch-mismatch"
);
}
#[test]
fn dispatch_worker_agent_def_name_matches_const() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let def = manifest.join("install/agents/claude/dispatch-worker.md");
let text =
fs::read_to_string(&def).unwrap_or_else(|e| panic!("read {}: {e}", def.display()));
let name = text
.lines()
.find_map(|l| l.trim().strip_prefix("name:"))
.map(str::trim)
.unwrap_or_else(|| panic!("no `name:` frontmatter in {}", def.display()));
assert_eq!(
name, DISPATCH_WORKER_AGENT_TYPE,
"agent-def name must equal DISPATCH_WORKER_AGENT_TYPE"
);
}
#[test]
fn dispatch_agent_skill_subagent_type_matches_const() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let skill = manifest.join("plugins/doctrine/skills/dispatch-agent/SKILL.md");
let text =
fs::read_to_string(&skill).unwrap_or_else(|e| panic!("read {}: {e}", skill.display()));
let pinned = text
.lines()
.find_map(|l| l.split_once("subagent_type:").map(|(_, rest)| rest))
.map(|rest| {
rest.trim()
.trim_start_matches('`')
.split([' ', '`', '#'])
.next()
.unwrap_or("")
.trim()
})
.unwrap_or_else(|| panic!("no `subagent_type:` line in {}", skill.display()));
assert_eq!(
pinned, DISPATCH_WORKER_AGENT_TYPE,
"/dispatch-agent subagent_type must equal DISPATCH_WORKER_AGENT_TYPE"
);
}
}