//! Diff capture, against a real git repository. //! //! Deliberately not mocked. Every bug this area has produced came from git //! behaving differently than assumed — a shallow clone refusing a push, an //! ownership check refusing the repo, untracked files invisible to `git diff`. //! A fake `git` would agree with whatever the code believed and prove nothing. use std::path::Path; use std::process::Command; use cm_api::mission_delivery; use uuid::Uuid; /// Capture against an explicit root, so parallel tests cannot race each other /// through the process-global `CLAWMATES_MISSIONS_ROOT`. async fn capture( pool: &sqlx::PgPool, root: &Path, mission: Uuid, phase: Uuid, ) -> Result, String> { mission_delivery::capture_phase_diff_at( pool, mission, phase, &root.join(mission.to_string()).join("repo"), &root.join("_outputs").join(mission.to_string()), 0, ) .await } fn git(repo: &Path, args: &[&str]) { let out = Command::new("git") .arg("-C") .arg(repo) .args(args) .output() .expect("run git"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); } /// A repo with one commit, at `//repo` so `checkout_path` /// finds it. /// Record the clone point the way `mission_workspace` does after a clone. fn record_base(repo: &Path) { let out = Command::new("git") .arg("-C") .arg(repo) .args(["rev-parse", "HEAD"]) .output() .unwrap(); std::fs::write( repo.join(".git/clawmates-base"), String::from_utf8_lossy(&out.stdout).trim(), ) .unwrap(); } fn seed_repo(root: &Path, mission: Uuid) -> std::path::PathBuf { let repo = root.join(mission.to_string()).join("repo"); std::fs::create_dir_all(&repo).unwrap(); git(&repo, &["init", "--quiet"]); git(&repo, &["config", "user.email", "test@clawmates.local"]); git(&repo, &["config", "user.name", "Test"]); std::fs::write(repo.join("README.md"), "# base\n").unwrap(); git(&repo, &["add", "."]); git(&repo, &["commit", "--quiet", "-m", "base"]); record_base(&repo); repo } #[tokio::test] async fn captures_modified_and_untracked_files() { let pool = cm_testkit::test_pool().await; let tmp = tempfile::tempdir().unwrap(); let mission = Uuid::now_v7(); let repo = seed_repo(tmp.path(), mission); let (ws, phase) = seed_mission_phase(&pool, mission).await; let _ = ws; // A modified file and a brand-new one. The new file is the case that // matters: without `--intent-to-add` it would not appear in `git diff`, // and a phase that only creates files is the likeliest shape of all. std::fs::write(repo.join("README.md"), "# base\nchanged\n").unwrap(); std::fs::write(repo.join("new_module.rs"), "fn added() {}\n").unwrap(); let cap = capture(&pool, tmp.path(), mission, phase) .await .unwrap() .expect("mission has a checkout"); assert!(!cap.empty, "the phase changed files"); assert_eq!(cap.files_changed, 2, "one modified, one created"); assert!(cap.insertions >= 2); let patch = std::fs::read_to_string(&cap.patch_path).unwrap(); assert!( patch.contains("new_module.rs"), "untracked file is captured" ); assert!(patch.contains("fn added()"), "its content is captured"); assert!(patch.contains("changed"), "the modification is captured"); // Capture is followed by a commit, so the tree the agents left is now on a // branch of its own. The patch was written first and is what guarantees // the work survives; the branch is the convenience on top. let branch = Command::new("git") .arg("-C") .arg(&repo) .args(["rev-parse", "--abbrev-ref", "HEAD"]) .output() .unwrap(); let branch = String::from_utf8_lossy(&branch.stdout).trim().to_string(); assert!( branch.starts_with("clawmates/mission-"), "work lands on a namespaced mission branch, never the default one: {branch}" ); let status = Command::new("git") .arg("-C") .arg(&repo) .args(["status", "--porcelain"]) .output() .unwrap(); assert!( String::from_utf8_lossy(&status.stdout).trim().is_empty(), "everything the phase produced is committed, nothing left dangling" ); let show = Command::new("git") .arg("-C") .arg(&repo) .args(["show", "--stat", "--oneline", "HEAD"]) .output() .unwrap(); let show = String::from_utf8_lossy(&show.stdout); assert!( show.contains("new_module.rs"), "the created file is in the commit: {show}" ); assert!( cap.committed.is_some(), "the capture records where the work landed" ); let row: (String, serde_json::Value) = sqlx::query_as( "SELECT kind, metadata FROM mission_artifacts WHERE mission_id = $1 AND phase_id = $2", ) .bind(mission) .bind(phase) .fetch_one(&pool) .await .unwrap(); assert_eq!(row.0, "code_diff"); assert_eq!(row.1["files_changed"], 2); assert_eq!(row.1["empty"], false); assert!(row.1["base_sha"].as_str().unwrap().len() >= 7); } /// "This coding phase wrote no code" is a result, and currently an invisible /// one. It must still produce an artifact. #[tokio::test] async fn an_empty_phase_still_produces_an_artifact() { let pool = cm_testkit::test_pool().await; let tmp = tempfile::tempdir().unwrap(); let mission = Uuid::now_v7(); seed_repo(tmp.path(), mission); let (_, phase) = seed_mission_phase(&pool, mission).await; let cap = capture(&pool, tmp.path(), mission, phase) .await .unwrap() .unwrap(); assert!(cap.empty); assert_eq!(cap.files_changed, 0); let (kind, meta): (String, serde_json::Value) = sqlx::query_as("SELECT kind, metadata FROM mission_artifacts WHERE mission_id = $1") .bind(mission) .fetch_one(&pool) .await .unwrap(); assert_eq!(kind, "code_diff"); assert_eq!(meta["empty"], true, "the emptiness is recorded, not hidden"); } /// Build output must never reach the patch. A phase that ran `cargo build` /// leaves a `target/` larger than the repository, and committing it would be /// worse than losing the diff. #[tokio::test] async fn build_output_is_not_captured() { let pool = cm_testkit::test_pool().await; let tmp = tempfile::tempdir().unwrap(); let mission = Uuid::now_v7(); let repo = seed_repo(tmp.path(), mission); let (_, phase) = seed_mission_phase(&pool, mission).await; std::fs::create_dir_all(repo.join("target/debug")).unwrap(); std::fs::write(repo.join("target/debug/huge.bin"), vec![b'x'; 200_000]).unwrap(); std::fs::create_dir_all(repo.join("node_modules/left-pad")).unwrap(); std::fs::write( repo.join("node_modules/left-pad/index.js"), "module.exports=0", ) .unwrap(); std::fs::write(repo.join("real_change.rs"), "fn kept() {}\n").unwrap(); let cap = capture(&pool, tmp.path(), mission, phase) .await .unwrap() .unwrap(); let patch = std::fs::read_to_string(&cap.patch_path).unwrap(); assert!(patch.contains("real_change.rs"), "genuine work is captured"); assert!(!patch.contains("huge.bin"), "target/ is excluded"); assert!(!patch.contains("left-pad"), "node_modules is excluded"); assert_eq!(cap.files_changed, 1, "only the real change counts"); } /// A research mission has no checkout. That is not an error. #[tokio::test] async fn a_mission_without_a_checkout_captures_nothing() { let pool = cm_testkit::test_pool().await; let tmp = tempfile::tempdir().unwrap(); let mission = Uuid::now_v7(); let (_, phase) = seed_mission_phase(&pool, mission).await; assert!(capture(&pool, tmp.path(), mission, phase) .await .unwrap() .is_none()); } async fn seed_mission_phase(pool: &sqlx::PgPool, mission: Uuid) -> (Uuid, Uuid) { let ws = Uuid::now_v7(); sqlx::query("INSERT INTO workspaces (id, name, plan) VALUES ($1,'t','team')") .bind(ws) .execute(pool) .await .unwrap(); sqlx::query( "INSERT INTO missions (id, workspace_id, title, template_kind, schedule, status, config) VALUES ($1,$2,'t','research_and_code','{}'::jsonb,'running','{}'::jsonb)", ) .bind(mission) .bind(ws) .execute(pool) .await .unwrap(); let phase = Uuid::now_v7(); sqlx::query( "INSERT INTO mission_phases (id, mission_id, kind, order_idx, status, config) VALUES ($1,$2,'coding',0,'completed','{}'::jsonb)", ) .bind(phase) .bind(mission) .execute(pool) .await .unwrap(); (ws, phase) } /// The production failure this pairs with. Mission 019fc372's agent created /// the file it was asked for and *committed* it — `rust_sdlc` has a committer /// role, so that is the intended path — leaving a clean working tree. Capture /// diffed against HEAD, found nothing, and recorded `empty: true` next to a /// commit that plainly contained the work. #[tokio::test] async fn work_the_agent_committed_is_captured() { let pool = cm_testkit::test_pool().await; let tmp = tempfile::tempdir().unwrap(); let mission = Uuid::now_v7(); let repo = seed_repo(tmp.path(), mission); let (_, phase) = seed_mission_phase(&pool, mission).await; std::fs::write(repo.join("DELIVERY_PROBE.md"), "CAPTURED-BY-CLAWMATES\n").unwrap(); git(&repo, &["add", "DELIVERY_PROBE.md"]); git(&repo, &["commit", "--quiet", "-m", "Add DELIVERY_PROBE.md"]); // The tree is clean — `git status --porcelain` is empty here, which is // precisely why the HEAD-relative version saw nothing. let status = Command::new("git") .arg("-C") .arg(&repo) .args(["status", "--porcelain"]) .output() .unwrap(); assert!( String::from_utf8_lossy(&status.stdout).trim().is_empty(), "the agent committed, so the tree is clean" ); let cap = capture(&pool, tmp.path(), mission, phase) .await .unwrap() .unwrap(); assert!( !cap.empty, "committed work must be captured, not reported as empty" ); assert_eq!(cap.files_changed, 1); let patch = std::fs::read_to_string(&cap.patch_path).unwrap(); assert!( patch.contains("CAPTURED-BY-CLAWMATES"), "the committed content is in the patch" ); } /// Committed *and* uncommitted work in the same phase — a coder that committed /// one change and left another in progress. #[tokio::test] async fn committed_and_uncommitted_changes_are_both_captured() { let pool = cm_testkit::test_pool().await; let tmp = tempfile::tempdir().unwrap(); let mission = Uuid::now_v7(); let repo = seed_repo(tmp.path(), mission); let (_, phase) = seed_mission_phase(&pool, mission).await; std::fs::write(repo.join("committed.rs"), "fn done() {}\n").unwrap(); git(&repo, &["add", "committed.rs"]); git(&repo, &["commit", "--quiet", "-m", "first"]); std::fs::write(repo.join("in_progress.rs"), "fn wip() {}\n").unwrap(); let cap = capture(&pool, tmp.path(), mission, phase) .await .unwrap() .unwrap(); let patch = std::fs::read_to_string(&cap.patch_path).unwrap(); assert!(patch.contains("fn done()"), "committed work"); assert!(patch.contains("fn wip()"), "uncommitted work"); assert_eq!(cap.files_changed, 2); } /// Build output must stay out of the commit as well as the patch. Putting a /// `target/` directory into someone's history is worse than losing the diff. #[tokio::test] async fn excluded_paths_are_not_committed() { let pool = cm_testkit::test_pool().await; let tmp = tempfile::tempdir().unwrap(); let mission = Uuid::now_v7(); let repo = seed_repo(tmp.path(), mission); let (_, phase) = seed_mission_phase(&pool, mission).await; std::fs::create_dir_all(repo.join("target/debug")).unwrap(); std::fs::write(repo.join("target/debug/blob.bin"), vec![b'x'; 50_000]).unwrap(); std::fs::write( repo.join(".gitconfig_temp"), "[safe]\n\tdirectory = /mission/repo\n", ) .unwrap(); std::fs::write(repo.join("real.rs"), "fn kept() {}\n").unwrap(); capture(&pool, tmp.path(), mission, phase) .await .unwrap() .unwrap(); let tracked = Command::new("git") .arg("-C") .arg(&repo) .args(["ls-files"]) .output() .unwrap(); let tracked = String::from_utf8_lossy(&tracked.stdout); assert!(tracked.contains("real.rs"), "genuine work is committed"); assert!( !tracked.contains("blob.bin"), "build output is not committed" ); assert!( !tracked.contains(".gitconfig_temp"), "an agent's workaround file is not committed into the user's history" ); } /// A re-run must not collide with the pass before it. #[test] fn a_rerun_lands_on_its_own_branch() { let m = Uuid::now_v7(); let p = Uuid::now_v7(); let first = mission_delivery::branch_name(m, p, 0); let second = mission_delivery::branch_name(m, p, 1); assert_ne!(first, second); assert!(first.starts_with("clawmates/mission-")); assert!( second.ends_with("-i2"), "pass 2 is named for the pass, not the index: {second}" ); }