use std::fs;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use crate::root;
use super::shared::is_linked_worktree;
#[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,
}
}
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()))?;
}
#[expect(clippy::disallowed_methods, reason = "runtime worker marker")]
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()))?;
let linked = is_linked_worktree(&root).unwrap_or(false);
if linked && !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 enforce_cwd = !linked || cwd.starts_with(&root);
if enforce_cwd && cwd != root {
bail!(
"refusing `marker --clear`: cwd {} is not the marker's tree root {} — run it from the tree root",
cwd.display(),
root.display()
);
}
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(())
}
#[cfg(test)]
mod tests {
use super::*;
fn skip_under_worker() -> bool {
if env_worker_set() {
eprintln!("skipping: DOCTRINE_WORKER set — env-unset test inapplicable");
return true;
}
false
}
#[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 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 env_worker_set_reads_the_env_flag() {
if skip_under_worker() {
return;
}
assert!(
!env_worker_set(),
"DOCTRINE_WORKER should not be set in the test harness"
);
}
#[test]
fn run_marker_clear_refuses_without_operator_in_linked_worktree() {
if skip_under_worker() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let primary = super::super::test_helpers::init_repo(&tmp.path().join("src"));
let fork = tmp.path().join("fork");
super::super::test_helpers::git(
&primary,
&[
"worktree",
"add",
"-q",
"-b",
"feat",
fork.to_str().unwrap(),
],
);
let fork = std::fs::canonicalize(&fork).unwrap();
write_marker(&fork).unwrap();
let err = run_marker_clear(Some(fork.clone()), false).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("--operator"),
"should refuse without --operator: {msg}"
);
}
#[test]
fn run_marker_clear_with_operator_clears_in_linked_worktree() {
if skip_under_worker() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let primary = super::super::test_helpers::init_repo(&tmp.path().join("src"));
let fork = tmp.path().join("fork");
super::super::test_helpers::git(
&primary,
&[
"worktree",
"add",
"-q",
"-b",
"feat",
fork.to_str().unwrap(),
],
);
let fork = std::fs::canonicalize(&fork).unwrap();
write_marker(&fork).unwrap();
run_marker_clear(Some(fork.clone()), true).unwrap();
assert!(!marker_present(&fork));
}
#[test]
fn run_status_no_marker_no_env_reports_writes_allowed() {
let tmp = tempfile::tempdir().unwrap();
let root = super::super::test_helpers::init_repo(&tmp.path().join("src"));
run_status(Some(root), false).unwrap();
}
}