use super::fork::{fork_core, remove_worktree_dir};
use super::provision::run_provision;
use crate::git;
use anyhow::{Context, bail};
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CreateAction {
Fork { base: String, name: String },
Passthrough { name: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CreateRefusal {
MissingCwd,
BadName(NameRefusal),
MissingBase,
BadBase,
}
impl CreateRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
CreateRefusal::MissingCwd => "missing-cwd",
CreateRefusal::BadName(_) => "bad-name",
CreateRefusal::MissingBase => "missing-base",
CreateRefusal::BadBase => "bad-base",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NameRefusal {
Empty,
Whitespace,
Slash,
DotDot,
RefInvalid,
}
impl NameRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
NameRefusal::Empty => "empty",
NameRefusal::Whitespace => "whitespace",
NameRefusal::Slash => "slash",
NameRefusal::DotDot => "dotdot",
NameRefusal::RefInvalid => "ref-invalid",
}
}
}
pub(crate) fn sanitise_name(name: &str) -> Result<String, NameRefusal> {
if name.is_empty() {
return Err(NameRefusal::Empty);
}
if name.chars().any(char::is_whitespace) {
return Err(NameRefusal::Whitespace);
}
if name.contains('/') {
return Err(NameRefusal::Slash);
}
if name.contains("..") {
return Err(NameRefusal::DotDot);
}
let charset_ok = name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
#[expect(
clippy::case_sensitive_file_extension_comparisons,
reason = "git's ref `.lock` ban is the literal lowercase suffix, not a file extension"
)]
let lock_suffixed = name.ends_with(".lock");
if !charset_ok || name.starts_with('.') || lock_suffixed {
return Err(NameRefusal::RefInvalid);
}
Ok(name.to_string())
}
fn plausible_sha(base: &str) -> bool {
let trimmed = base.trim();
(4..=64).contains(&trimmed.len()) && trimmed.chars().all(|c| c.is_ascii_hexdigit())
}
pub(crate) fn classify_create(
cwd_resolved: bool,
cwd_is_arming_dir: bool,
base: Option<&str>,
name: &str,
) -> Result<CreateAction, CreateRefusal> {
if !cwd_resolved {
return Err(CreateRefusal::MissingCwd);
}
let slug = sanitise_name(name).map_err(CreateRefusal::BadName)?;
if cwd_is_arming_dir {
match base {
None => Err(CreateRefusal::MissingBase),
Some(b) if !plausible_sha(b) => Err(CreateRefusal::BadBase),
Some(b) => Ok(CreateAction::Fork {
base: b.trim().to_string(),
name: slug,
}),
}
} else {
Ok(CreateAction::Passthrough { name: slug })
}
}
pub(crate) const ARMING_SUBPATH: &str = ".doctrine/state/dispatch/spawn";
const WORKTREES_SUBDIR: &str = ".worktrees";
#[derive(Debug, Default, serde::Deserialize)]
struct CreatePayload {
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
name: Option<String>,
}
fn resolve_root(cwd: &Path) -> Option<PathBuf> {
git::git_text(cwd, &["rev-parse", "--show-toplevel"])
.ok()
.and_then(|top| fs::canonicalize(top.trim()).ok())
}
fn act_on_create(root: &Path, action: CreateAction) -> anyhow::Result<PathBuf> {
match action {
CreateAction::Fork { base, name } => {
let dir = root.join(WORKTREES_SUBDIR).join(&name);
let branch = format!("dispatch/{name}");
fork_core(root, &base, &branch, &dir, true)?;
fs::canonicalize(&dir)
.with_context(|| format!("canonicalize fork dir {}", dir.display()))
}
CreateAction::Passthrough { name } => {
let dir = root.join(WORKTREES_SUBDIR).join(&name);
if dir.exists() {
bail!(
"create-refused: name-collision (dir {} already exists)",
dir.display()
);
}
git::git_text(
root,
&[
"worktree",
"add",
"--detach",
&dir.to_string_lossy(),
"HEAD",
],
)
.with_context(|| format!("git worktree add --detach {} HEAD", dir.display()))?;
if let Err(cause) = run_provision(Some(root.to_path_buf()), &dir) {
let debris = remove_worktree_dir(root, &dir);
if debris.is_empty() {
return Err(cause.context(format!(
"passthrough provision failed; compensated cleanly (removed {})",
dir.display()
)));
}
bail!(
"passthrough-rollback-debris: {} (original cause: {cause:#})",
debris.join(", ")
);
}
fs::canonicalize(&dir)
.with_context(|| format!("canonicalize passthrough dir {}", dir.display()))
}
}
}
pub(crate) fn run_create_fork() -> anyhow::Result<()> {
let mut raw = String::new();
io::stdin()
.read_to_string(&mut raw)
.context("read WorktreeCreate payload")?;
let payload: CreatePayload = serde_json::from_str(&raw).unwrap_or_default();
let cwd_str = payload.cwd.unwrap_or_default();
let name = payload.name.unwrap_or_default();
let cwd_canon = if cwd_str.is_empty() {
None
} else {
fs::canonicalize(&cwd_str).ok()
};
let cwd_resolved = cwd_canon.is_some();
let root = cwd_canon.as_deref().and_then(resolve_root);
let cwd_is_arming = match (root.as_deref(), cwd_canon.as_deref()) {
(Some(root), Some(cwd)) => {
fs::canonicalize(root.join(ARMING_SUBPATH)).is_ok_and(|arming| arming == cwd)
}
_ => false,
};
let base = if cwd_is_arming {
root.as_deref()
.and_then(|r| fs::read_to_string(r.join(ARMING_SUBPATH).join("base")).ok())
} else {
None
};
match classify_create(cwd_resolved, cwd_is_arming, base.as_deref(), &name) {
Err(refusal) => {
let line = match refusal {
CreateRefusal::BadName(reason) => {
format!("create-refused: {} ({})", refusal.token(), reason.token())
}
_ => format!("create-refused: {}", refusal.token()),
};
writeln!(io::stderr(), "{line}")?;
bail!("{line}");
}
Ok(action) => {
let Some(root) = root else {
writeln!(io::stderr(), "create-refused: no-root")?;
bail!("create-refused: no-root");
};
let dir = act_on_create(&root, action)?;
writeln!(io::stdout(), "{}", dir.display())?;
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitise_accepts_both_observed_name_forms_unchanged() {
assert_eq!(
sanitise_name("agent-abc123"),
Ok("agent-abc123".to_string())
);
assert_eq!(
sanitise_name("bold-oak-a3f2"),
Ok("bold-oak-a3f2".to_string())
);
assert_eq!(sanitise_name("a.b_c-1"), Ok("a.b_c-1".to_string()));
}
#[test]
fn sanitise_rejects_each_unsafe_shape_with_its_named_token() {
assert_eq!(sanitise_name(""), Err(NameRefusal::Empty));
assert_eq!(sanitise_name("a b"), Err(NameRefusal::Whitespace));
assert_eq!(sanitise_name(" abc"), Err(NameRefusal::Whitespace));
assert_eq!(sanitise_name("abc\t"), Err(NameRefusal::Whitespace));
assert_eq!(sanitise_name(" "), Err(NameRefusal::Whitespace));
assert_eq!(sanitise_name("a/b"), Err(NameRefusal::Slash));
assert_eq!(sanitise_name("a..b"), Err(NameRefusal::DotDot));
assert_eq!(sanitise_name(".."), Err(NameRefusal::DotDot));
assert_eq!(sanitise_name("a~b"), Err(NameRefusal::RefInvalid));
assert_eq!(sanitise_name("a:b"), Err(NameRefusal::RefInvalid));
assert_eq!(sanitise_name(".hidden"), Err(NameRefusal::RefInvalid));
assert_eq!(sanitise_name("x.lock"), Err(NameRefusal::RefInvalid));
}
#[test]
fn name_refusal_tokens_are_distinct() {
let tokens = [
NameRefusal::Empty.token(),
NameRefusal::Whitespace.token(),
NameRefusal::Slash.token(),
NameRefusal::DotDot.token(),
NameRefusal::RefInvalid.token(),
];
let unique: std::collections::BTreeSet<&str> = tokens.iter().copied().collect();
assert_eq!(unique.len(), 5, "every NameRefusal token is distinct");
assert_eq!(NameRefusal::Empty.token(), "empty");
assert_eq!(NameRefusal::Whitespace.token(), "whitespace");
assert_eq!(NameRefusal::Slash.token(), "slash");
assert_eq!(NameRefusal::DotDot.token(), "dotdot");
assert_eq!(NameRefusal::RefInvalid.token(), "ref-invalid");
}
const SHA: &str = "68250bcd";
#[test]
fn missing_cwd_refuses_first_regardless_of_everything_else() {
assert_eq!(
classify_create(false, true, Some(SHA), "agent-abc123"),
Err(CreateRefusal::MissingCwd)
);
assert_eq!(
classify_create(false, false, None, ""),
Err(CreateRefusal::MissingCwd)
);
assert_eq!(CreateRefusal::MissingCwd.token(), "missing-cwd");
}
#[test]
fn bad_name_refuses_before_base_on_both_channels() {
assert_eq!(
classify_create(true, true, Some(SHA), "a/b"),
Err(CreateRefusal::BadName(NameRefusal::Slash))
);
assert_eq!(
classify_create(true, false, None, ""),
Err(CreateRefusal::BadName(NameRefusal::Empty))
);
assert_eq!(
CreateRefusal::BadName(NameRefusal::Slash).token(),
"bad-name"
);
}
#[test]
fn armed_without_base_refuses_missing_base() {
assert_eq!(
classify_create(true, true, None, "agent-abc123"),
Err(CreateRefusal::MissingBase)
);
assert_eq!(CreateRefusal::MissingBase.token(), "missing-base");
}
#[test]
fn armed_with_unparseable_base_refuses_bad_base() {
assert_eq!(
classify_create(true, true, Some("zzz"), "agent-abc123"),
Err(CreateRefusal::BadBase)
);
assert_eq!(
classify_create(true, true, Some("ab"), "agent-abc123"),
Err(CreateRefusal::BadBase)
);
assert_eq!(CreateRefusal::BadBase.token(), "bad-base");
}
#[test]
fn armed_with_plausible_base_and_valid_name_forks() {
assert_eq!(
classify_create(true, true, Some(" 68250bcd\n"), "bold-oak-a3f2"),
Ok(CreateAction::Fork {
base: "68250bcd".to_string(),
name: "bold-oak-a3f2".to_string(),
})
);
}
#[test]
fn benign_cwd_with_valid_name_passes_through_ignoring_base() {
assert_eq!(
classify_create(true, false, None, "agent-abc123"),
Ok(CreateAction::Passthrough {
name: "agent-abc123".to_string(),
})
);
assert_eq!(
classify_create(true, false, Some(SHA), "agent-abc123"),
Ok(CreateAction::Passthrough {
name: "agent-abc123".to_string(),
})
);
}
}