diff --git a/crates/aura-cli/src/scaffold.rs b/crates/aura-cli/src/scaffold.rs index 9cd75c9..0c4a0b1 100644 --- a/crates/aura-cli/src/scaffold.rs +++ b/crates/aura-cli/src/scaffold.rs @@ -239,6 +239,12 @@ pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> { write("blueprints/signal.json", render(SIGNAL_JSON, spec))?; write("CLAUDE.md", render(CLAUDE_MD, spec))?; // Best-effort git init: a warning, never an error (cargo-new mirror). + // Also the cargo-new mirror: inside an existing work tree the init is + // SKIPPED silently — a nested .git is a gitlink/submodule hazard when the + // scaffold lands in a tracked tree (#204). + if inside_git_work_tree(&spec.target_dir) { + return Ok(()); + } match std::process::Command::new("git") .arg("init") .current_dir(&spec.target_dir) @@ -250,6 +256,18 @@ pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> { Ok(()) } +/// Whether `dir` already sits inside a git work tree (`git rev-parse`, the +/// same oracle cargo-new consults). A missing git binary or any failure reads +/// as "not in a work tree" — the init stays best-effort either way. +fn inside_git_work_tree(dir: &std::path::Path) -> bool { + std::process::Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(dir) + .output() + .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true") + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/aura-cli/tests/project_new.rs b/crates/aura-cli/tests/project_new.rs index 1ea0fcd..c91fde2 100644 --- a/crates/aura-cli/tests/project_new.rs +++ b/crates/aura-cli/tests/project_new.rs @@ -141,6 +141,72 @@ fn new_namespace_override_reaches_the_built_vocabulary() { let _ = std::fs::remove_dir_all(&base); } +/// Property: `aura new` mirrors `cargo new` — when the target directory is +/// already inside a git work tree, the scaffolder does NOT initialize a +/// nested repo. A nested `.git` under a tracked tree is a gitlink/submodule +/// hazard (it bit the 0107 fieldtest corpus); the enclosing repo owns the +/// scaffold's history. +#[test] +fn new_inside_a_work_tree_skips_the_nested_git() { + let base = tmp("insidegit"); + let init = Command::new("git") + .arg("init") + .current_dir(&base) + .output() + .expect("git init the enclosing work tree"); + assert!( + init.status.success(), + "git init base failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + + let out = aura(&["new", "demo-lab"], &base); + assert!( + out.status.success(), + "aura new failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let proj = base.join("demo-lab"); + // The full scaffold still lands unchanged... + for f in [ + "Cargo.toml", + "Aura.toml", + ".gitignore", + "src/lib.rs", + "blueprints/signal.json", + "CLAUDE.md", + ] { + assert!(proj.join(f).is_file(), "missing scaffolded file {f}"); + } + // ...but without a nested repo of its own. + assert!( + !proj.join(".git").exists(), + "scaffold nested a .git inside an existing work tree (gitlink hazard)" + ); + let _ = std::fs::remove_dir_all(&base); +} + +/// Guard against over-removal of the git-init: OUTSIDE any work tree the +/// scaffold still initializes its own repo. `std::env::temp_dir()` is not +/// inside a git work tree (the e2e helpers here already rely on that), so the +/// scaffold's parent is genuinely detached from this checkout's tree. +#[test] +fn new_outside_a_work_tree_initializes_git() { + let base = tmp("outsidegit"); + let out = aura(&["new", "demo-lab"], &base); + assert!( + out.status.success(), + "aura new failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let proj = base.join("demo-lab"); + assert!( + proj.join(".git").is_dir(), + "scaffold outside a work tree must initialize its own repo" + ); + let _ = std::fs::remove_dir_all(&base); +} + #[test] fn new_works_inside_an_unbuilt_project_tree() { // Scaffold once (unbuilt), then scaffold AGAIN from inside that tree: