#![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,
}
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)
}
pub(crate) fn run_import(
path: Option<PathBuf>,
base: &str,
fork: Option<&str>,
from_worktree: Option<&Path>,
) -> anyhow::Result<()> {
match (fork, from_worktree) {
(Some(fork), None) => run_import_fork(path, base, fork),
(None, Some(dir)) => run_import_from_worktree(path, base, dir),
(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) -> 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(())
}
fn run_import_from_worktree(path: Option<PathBuf>, base: &str, dir: &Path) -> 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)?;
match classify_import(head_at_base, tree_clean, single_commit, &delta_paths) {
Err(refusal) => bail!("import-refused: {}", refusal.token()),
Ok(Apply::Ok) => {}
}
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"
);
}
}