#![expect(unused, reason = "extraction; PHASE-03 prunes")]
use super::allowlist::{
Allowlist, allowlist_violations, is_withheld, parse_allowlist, select_copies,
};
use super::marker::{DISPATCH_WORKER_AGENT_TYPE, marker_present, write_marker};
use super::shared::{
gather_fork_worktree, gather_tree_clean, is_linked_worktree, matches, resolve_commit,
resolve_common_dir, target_dir_for_branch,
};
use crate::fsutil::{self, CopyOutcome};
use crate::git;
use crate::root;
use anyhow::{Context, bail};
use std::fs;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};
const DOCTRINE_PREFIX: &str = ".doctrine/";
const CLAUDE_PREFIX: &str = ".claude/";
const QUOTE_PATH_OFF: [&str; 2] = ["-c", "core.quotePath=false"];
const NO_RENAMES: &str = "--no-renames";
const DEV_NULL: &str = "/dev/null";
#[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,
UndeclaredScope,
}
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",
Refusal::UndeclaredScope => "undeclared-scope",
}
}
}
pub(crate) fn classify_import(
head_at_base: bool,
tree_clean: bool,
single_commit: bool,
delta_paths: &[String],
selectors: &[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);
}
}
if !selectors.is_empty() {
let paths: Vec<&str> = delta_paths.iter().map(String::as_str).collect();
if !crate::conformance::undeclared_paths(selectors, &paths).is_empty() {
return Err(Refusal::UndeclaredScope);
}
}
Ok(Apply::Ok)
}
fn report_undeclared_scope(
selectors: &[String],
delta_paths: &[String],
) -> anyhow::Result<Vec<String>> {
let paths: Vec<&str> = delta_paths.iter().map(String::as_str).collect();
let undeclared = crate::conformance::undeclared_paths(selectors, &paths);
let mut out = io::stdout();
writeln!(
out,
"import-refused: undeclared-scope — the worker delta touches {} path(s) no design-target selector declares:",
undeclared.len()
)?;
for path in &undeclared {
writeln!(
out,
" {path}\n remediation: doctrine slice selector add {path} --intent design-target --note <why>"
)?;
}
Ok(undeclared)
}
fn classify_or_report(
head_at_base: bool,
tree_clean: bool,
single_commit: bool,
delta_paths: &[String],
selectors: &[String],
) -> anyhow::Result<()> {
match classify_import(
head_at_base,
tree_clean,
single_commit,
delta_paths,
selectors,
) {
Err(Refusal::UndeclaredScope) => {
let undeclared = report_undeclared_scope(selectors, delta_paths)?;
bail!(
"import-refused: {} ({})",
Refusal::UndeclaredScope.token(),
undeclared.join(", ")
);
}
Err(refusal) => bail!("import-refused: {}", refusal.token()),
Ok(Apply::Ok) => Ok(()),
}
}
pub(crate) fn run_import(
path: Option<PathBuf>,
base: &str,
fork: Option<&str>,
from_worktree: Option<&Path>,
selectors: &[String],
) -> anyhow::Result<()> {
match (fork, from_worktree) {
(Some(fork), None) => run_import_fork(path, base, fork, selectors),
(None, Some(dir)) => run_import_from_worktree(path, base, dir, selectors),
(Some(_), Some(_)) => bail!("import: --fork and --from-worktree are mutually exclusive"),
(None, None) => bail!("import: exactly one of --fork / --from-worktree is required"),
}
}
fn run_import_fork(
path: Option<PathBuf>,
base: &str,
fork: &str,
selectors: &[String],
) -> 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();
classify_or_report(
head_at_base,
tree_clean,
single_commit,
&delta_paths,
selectors,
)?;
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(())
}
fn run_import_from_worktree(
path: Option<PathBuf>,
base: &str,
dir: &Path,
selectors: &[String],
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let patch_bytes = gather_worktree_patch(dir)?;
if patch_bytes.is_empty() {
bail!(
"import: worker worktree {} carries no delta; halting",
dir.display()
);
}
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 single_commit = true;
let delta_paths = gather_worktree_delta_paths(dir)?;
classify_or_report(
head_at_base,
tree_clean,
single_commit,
&delta_paths,
selectors,
)?;
git::git_apply_index(&root, &patch_bytes)
.with_context(|| format!("git apply --3way --index from {}", dir.display()))?;
writeln!(
io::stdout(),
"imported worktree {}: delta staged (uncommitted)",
dir.display()
)?;
Ok(())
}
fn gather_worktree_patch(wt: &Path) -> anyhow::Result<Vec<u8>> {
let mut patch = git::git_bytes(
wt,
&[
QUOTE_PATH_OFF[0],
QUOTE_PATH_OFF[1],
"diff",
NO_RENAMES,
"HEAD",
],
)
.with_context(|| format!("git diff HEAD in {}", wt.display()))?;
let untracked = git::git_text(wt, &["ls-files", "--others", "--exclude-standard"])
.with_context(|| format!("list untracked in {}", wt.display()))?;
for rel in untracked.lines().filter(|l| !l.is_empty()) {
let hunk = git::git_bytes_lenient(
wt,
&[
QUOTE_PATH_OFF[0],
QUOTE_PATH_OFF[1],
"diff",
NO_RENAMES,
"--no-index",
"--",
DEV_NULL,
rel,
],
)
.with_context(|| format!("synthesize untracked hunk for {rel} in {}", wt.display()))?;
patch.extend_from_slice(&hunk);
}
Ok(patch)
}
fn gather_worktree_delta_paths(wt: &Path) -> anyhow::Result<Vec<String>> {
let tracked = git::git_text(
wt,
&[
QUOTE_PATH_OFF[0],
QUOTE_PATH_OFF[1],
"diff",
"--name-only",
NO_RENAMES,
"HEAD",
],
)
.with_context(|| format!("git diff --name-only HEAD in {}", wt.display()))?;
let untracked = git::git_text(
wt,
&[
QUOTE_PATH_OFF[0],
QUOTE_PATH_OFF[1],
"ls-files",
"--others",
"--exclude-standard",
],
)
.with_context(|| format!("list untracked in {}", wt.display()))?;
Ok(tracked
.lines()
.chain(untracked.lines())
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::worktree::test_helpers::{git, init_repo};
use std::fs;
#[test]
fn gather_from_worktree_captures_tracked_and_untracked_and_reapplies() {
let tmp = tempfile::tempdir().unwrap();
let primary = init_repo(&tmp.path().join("primary"));
let wt = tmp.path().join("wt");
git(
&primary,
&["worktree", "add", "-q", wt.to_str().unwrap(), "HEAD"],
);
let wt = fs::canonicalize(&wt).unwrap();
fs::write(wt.join("seed"), "mutated\n").unwrap();
fs::write(wt.join("newfile"), "brand new\n").unwrap();
let patch = gather_worktree_patch(&wt).unwrap();
assert!(!patch.is_empty(), "patch must be non-empty");
let text = String::from_utf8_lossy(&patch);
assert!(text.contains("seed"), "tracked change captured");
assert!(text.contains("newfile"), "untracked add captured");
let names = gather_worktree_delta_paths(&wt).unwrap();
assert!(names.iter().any(|p| p == "seed"), "tracked path listed");
assert!(
names.iter().any(|p| p == "newfile"),
"untracked path listed"
);
let target = tmp.path().join("apply");
git(
&primary,
&["worktree", "add", "-q", target.to_str().unwrap(), "HEAD"],
);
let patch_file = tmp.path().join("captured.patch");
fsutil::write_atomic(&patch_file, &patch).unwrap();
git(&target, &["apply", patch_file.to_str().unwrap()]);
assert_eq!(
fs::read_to_string(target.join("seed")).unwrap(),
"mutated\n"
);
assert_eq!(
fs::read_to_string(target.join("newfile")).unwrap(),
"brand new\n"
);
}
#[test]
fn classify_import_undeclared_delta_path_is_undeclared_scope() {
let selectors = vec!["src/**".to_string()];
let delta = vec!["docs/readme.md".to_string()];
assert_eq!(
classify_import(true, true, true, &delta, &selectors),
Err(Refusal::UndeclaredScope)
);
}
#[test]
fn classify_import_doctrine_path_is_doctrine_touch_even_when_undeclared() {
let selectors = vec!["src/**".to_string()];
let delta = vec![".doctrine/state/x".to_string()];
assert_eq!(
classify_import(true, true, true, &delta, &selectors),
Err(Refusal::DoctrineTouch)
);
}
#[test]
fn classify_import_empty_selectors_is_ok_noop() {
let delta = vec!["anything/at/all".to_string()];
assert_eq!(
classify_import(true, true, true, &delta, &[]),
Ok(Apply::Ok)
);
}
#[test]
fn classify_import_fully_declared_delta_is_ok() {
let selectors = vec!["src/**".to_string()];
let delta = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
assert_eq!(
classify_import(true, true, true, &delta, &selectors),
Ok(Apply::Ok)
);
}
fn worker_tree_touching_seed() -> (tempfile::TempDir, PathBuf, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let primary = init_repo(&tmp.path().join("primary"));
let wt = tmp.path().join("wt");
git(
&primary,
&["worktree", "add", "-q", wt.to_str().unwrap(), "HEAD"],
);
let wt = fs::canonicalize(&wt).unwrap();
fs::write(wt.join("seed"), "mutated\n").unwrap();
(tmp, primary, wt)
}
#[test]
fn run_import_from_worktree_refuses_an_undeclared_worker_path() {
let (_tmp, primary, wt) = worker_tree_touching_seed();
let selectors = vec!["docs/**".to_string()];
let err = run_import_from_worktree(Some(primary), "HEAD", &wt, &selectors)
.expect_err("an undeclared worker path must be refused");
let msg = err.to_string();
assert!(
msg.contains("import-refused: undeclared-scope"),
"carries the token: {msg}"
);
assert!(msg.contains("seed"), "names the offending path: {msg}");
}
#[test]
fn run_import_from_worktree_stages_a_declared_worker_delta() {
let (_tmp, primary, wt) = worker_tree_touching_seed();
let selectors = vec!["seed".to_string()];
run_import_from_worktree(Some(primary.clone()), "HEAD", &wt, &selectors)
.expect("a fully-declared worker delta must import");
let staged = std::process::Command::new("git")
.arg("-C")
.arg(&primary)
.args(["diff", "--cached", "--name-only"])
.output()
.unwrap();
let names = String::from_utf8_lossy(&staged.stdout);
assert!(
names.contains("seed"),
"seed staged into the index: {names}"
);
}
}