//! 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, mission_delivery::Gate::Always, ) .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"]); // The real clone path applies this; seeding a repo by hand and skipping it // is what let the uid-split failure reach production untested. cm_api::mission_workspace::share_repository_across_uids(&repo); 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) } /// Add a second phase to a mission `seed_mission_phase` already created. async fn seed_extra_phase(pool: &sqlx::PgPool, mission: Uuid, order_idx: i32) -> Uuid { let phase = Uuid::now_v7(); sqlx::query( "INSERT INTO mission_phases (id, mission_id, kind, order_idx, status, config) VALUES ($1,$2,'coding',$3,'completed','{}'::jsonb)", ) .bind(phase) .bind(mission) .bind(order_idx) .execute(pool) .await .unwrap(); 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" ); } /// Sibling phases of one mission must not share a branch. /// /// They did. Both ids are UUIDv7, which leads with a timestamp, so two phases /// created in the same millisecond had identical leading hex and the name /// collapsed to one branch per mission — each phase quietly moving the ref the /// previous one had set. Production showed /// `clawmates/mission-019fc40e-019fc40e` for both phases of a mission. #[test] fn sibling_phases_get_distinct_branches() { let mission = Uuid::now_v7(); // Minted back to back, so they share a timestamp prefix exactly as they do // when a mission inserts its phases in one transaction. let research = Uuid::now_v7(); let coding = Uuid::now_v7(); assert_eq!( research.simple().to_string()[..8], coding.simple().to_string()[..8], "precondition: v7 ids minted together share their leading hex" ); let a = mission_delivery::branch_name(mission, research, 0); let b = mission_delivery::branch_name(mission, coding, 0); assert_ne!(a, b, "each phase needs its own ref: {a} vs {b}"); assert!(a.starts_with("clawmates/mission-")); } /// 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}" ); } /// Push against a real bare repository. /// /// A mock remote would accept whatever we sent and prove nothing; the failures /// worth catching here — a rejected ref, a branch that never arrives, work /// pushed to the wrong name — are all things only a real git remote reports. #[tokio::test] async fn a_gated_push_reaches_the_remote() { let tmp = tempfile::tempdir().unwrap(); let remote = tmp.path().join("remote.git"); std::fs::create_dir_all(&remote).unwrap(); Command::new("git") .args(["init", "--bare", "--quiet"]) .arg(&remote) .output() .unwrap(); let mission = Uuid::now_v7(); let repo = seed_repo(tmp.path(), mission); std::fs::write(repo.join("work.rs"), "fn shipped() {}\n").unwrap(); git(&repo, &["add", "."]); git(&repo, &["commit", "--quiet", "-m", "work"]); git( &repo, &["checkout", "-B", "clawmates/mission-test-aaaaaaaa"], ); let out = mission_delivery::publish_phase_branch( &repo, remote.to_str().unwrap(), "clawmates/mission-test-aaaaaaaa", mission_delivery::Gate::Always, None, ) .await .unwrap(); assert!(out.pushed, "push failed: {:?}", out.error); assert_eq!(out.branch, "clawmates/mission-test-aaaaaaaa"); // The remote genuinely has it, with the content. let refs = Command::new("git") .arg("-C") .arg(&remote) .args(["for-each-ref", "--format=%(refname:short)"]) .output() .unwrap(); let refs = String::from_utf8_lossy(&refs.stdout); assert!( refs.contains("clawmates/mission-test-aaaaaaaa"), "refs: {refs}" ); let show = Command::new("git") .arg("-C") .arg(&remote) .args(["show", "clawmates/mission-test-aaaaaaaa:work.rs"]) .output() .unwrap(); assert!(String::from_utf8_lossy(&show.stdout).contains("fn shipped()")); } /// A red suite must not block delivery — it must redirect it. The work still /// reaches the forge, on a branch whose name says it is unproven. #[tokio::test] async fn a_failed_gate_publishes_to_a_wip_branch() { let tmp = tempfile::tempdir().unwrap(); let remote = tmp.path().join("remote.git"); std::fs::create_dir_all(&remote).unwrap(); Command::new("git") .args(["init", "--bare", "--quiet"]) .arg(&remote) .output() .unwrap(); let mission = Uuid::now_v7(); let repo = seed_repo(tmp.path(), mission); std::fs::write(repo.join("half_done.rs"), "fn broken() {}\n").unwrap(); git(&repo, &["add", "."]); git(&repo, &["commit", "--quiet", "-m", "wip"]); git( &repo, &["checkout", "-B", "clawmates/mission-test-bbbbbbbb"], ); let out = mission_delivery::publish_phase_branch( &repo, remote.to_str().unwrap(), "clawmates/mission-test-bbbbbbbb", mission_delivery::Gate::OnGreenTests, Some(false), ) .await .unwrap(); assert!(out.pushed, "a failed gate still publishes: {:?}", out.error); assert!( out.branch.ends_with("-wip"), "verdict is in the name: {}", out.branch ); let refs = Command::new("git") .arg("-C") .arg(&remote) .args(["for-each-ref", "--format=%(refname:short)"]) .output() .unwrap(); let refs = String::from_utf8_lossy(&refs.stdout); assert!( refs.contains("-wip"), "the work reached the forge anyway: {refs}" ); assert!( !refs.contains("clawmates/mission-test-bbbbbbbb\n"), "and did not claim the clean branch name" ); } /// #55: a mission whose checkout was re-cloned builds divergent history against /// its OWN deterministic branch, and git rejects every push it will ever make. /// That was terminal — the work stayed on a local branch in a directory that /// gets reaped — and it is reachable from a retry, a container teardown, or disk /// loss, not just from someone deleting a checkout by hand. #[tokio::test] async fn diverged_history_lands_on_a_new_branch_instead_of_being_lost() { let tmp = tempfile::tempdir().unwrap(); let remote = tmp.path().join("remote.git"); std::fs::create_dir_all(&remote).unwrap(); Command::new("git") .args(["init", "--bare", "--quiet"]) .arg(&remote) .output() .unwrap(); let branch = "clawmates/mission-test-dddddddd"; let url = remote.to_str().unwrap(); // The first attempt: a checkout that pushed its work and then vanished. let first = seed_repo(tmp.path(), Uuid::now_v7()); std::fs::write(first.join("first.rs"), "fn first() {}\n").unwrap(); git(&first, &["add", "."]); git(&first, &["commit", "--quiet", "-m", "first pass"]); git(&first, &["checkout", "-B", branch]); let one = mission_delivery::publish_phase_branch( &first, url, branch, mission_delivery::Gate::Always, None, ) .await .unwrap(); assert!(one.pushed, "setup push failed: {:?}", one.error); let claimed = Command::new("git") .arg("-C") .arg(&remote) .args(["rev-parse", branch]) .output() .unwrap(); let claimed = String::from_utf8_lossy(&claimed.stdout).trim().to_string(); // The retry: a fresh clone of the same mission, so unrelated history under // the same deterministic branch name. let second = seed_repo(tmp.path(), Uuid::now_v7()); std::fs::write(second.join("second.rs"), "fn second() {}\n").unwrap(); git(&second, &["add", "."]); git(&second, &["commit", "--quiet", "-m", "retry"]); git(&second, &["checkout", "-B", branch]); let out = mission_delivery::publish_phase_branch( &second, url, branch, mission_delivery::Gate::Always, None, ) .await .unwrap(); assert!( out.pushed, "the retry's work never reached the forge: {:?}", out.error ); assert!( out.branch.starts_with(branch) && out.branch != branch, "it must land on a NEW ref, not the contested one: {}", out.branch ); let refs = Command::new("git") .arg("-C") .arg(&remote) .args(["for-each-ref", "--format=%(refname:short)"]) .output() .unwrap(); let refs = String::from_utf8_lossy(&refs.stdout); assert!(refs.contains(&out.branch), "refs: {refs}"); // NEVER force: the first attempt's ref still points where it did. Losing it // to make this push look tidy would trade one lost copy of the work for // another. let still = Command::new("git") .arg("-C") .arg(&remote) .args(["rev-parse", branch]) .output() .unwrap(); assert_eq!( String::from_utf8_lossy(&still.stdout).trim(), claimed, "the earlier attempt's branch was overwritten" ); let show = Command::new("git") .arg("-C") .arg(&remote) .args(["show", &format!("{}:second.rs", out.branch)]) .output() .unwrap(); assert!(String::from_utf8_lossy(&show.stdout).contains("fn second()")); } /// An unreachable remote is a degraded success, not a failure: the patch and /// the local branch both still exist. #[tokio::test] async fn an_unreachable_remote_does_not_lose_the_work() { let tmp = tempfile::tempdir().unwrap(); let mission = Uuid::now_v7(); let repo = seed_repo(tmp.path(), mission); std::fs::write(repo.join("work.rs"), "fn kept() {}\n").unwrap(); git(&repo, &["add", "."]); git(&repo, &["commit", "--quiet", "-m", "work"]); git( &repo, &["checkout", "-B", "clawmates/mission-test-cccccccc"], ); let out = mission_delivery::publish_phase_branch( &repo, &tmp.path().join("does-not-exist.git").display().to_string(), "clawmates/mission-test-cccccccc", mission_delivery::Gate::Always, None, ) .await .unwrap(); assert!(!out.pushed); assert!( out.error.is_some(), "the reason is recorded for the operator" ); // The commit is still there locally — nothing was rolled back. let show = Command::new("git") .arg("-C") .arg(&repo) .args(["show", "HEAD:work.rs"]) .output() .unwrap(); assert!(String::from_utf8_lossy(&show.stdout).contains("fn kept()")); } /// A later phase must report its own work, not its predecessor's. /// /// The capture base is recorded once at clone time. Left there, phase 2 diffs /// against the original clone point and claims phase 1's commits as its own — /// which is exactly what mission `019fc42b` produced: two coding phases, two /// artifacts, and the second one reporting the union of both. #[tokio::test] async fn a_later_phase_reports_only_its_own_work() { 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_one) = seed_mission_phase(&pool, mission).await; let phase_two = seed_extra_phase(&pool, mission, 1).await; std::fs::write(repo.join("ALPHA.md"), "ALPHA-DELIVERED\n").unwrap(); let first = capture(&pool, tmp.path(), mission, phase_one) .await .unwrap() .unwrap(); assert_eq!(first.files_changed, 1, "phase one wrote one file"); std::fs::write(repo.join("BETA.md"), "BETA-DELIVERED\n").unwrap(); let second = capture(&pool, tmp.path(), mission, phase_two) .await .unwrap() .unwrap(); assert_eq!( second.files_changed, 1, "phase two must report only BETA.md, not ALPHA.md as well" ); let patch = std::fs::read_to_string(&second.patch_path).unwrap(); assert!(patch.contains("BETA-DELIVERED"), "phase two's own work"); assert!( !patch.contains("ALPHA-DELIVERED"), "phase one's work must not reappear in phase two's patch" ); // The branch, unlike the patch, stays cumulative: it is built from HEAD, // so it still carries phase one's commit underneath phase two's. let branch = second.committed.expect("phase two committed").branch; let files = Command::new("git") .arg("-C") .arg(&repo) .args(["ls-tree", "--name-only", "-r", &branch]) .output() .unwrap(); let listed = String::from_utf8_lossy(&files.stdout); assert!(listed.contains("ALPHA.md"), "branch is cumulative: {listed}"); assert!(listed.contains("BETA.md"), "branch is cumulative: {listed}"); } /// A checkout must stay writable after another user has written to it. /// /// The production failure (mission `019fc437`) is a uid split: cm-api runs as /// 65532, the mission runtime container runs as root, and they share one /// checkout. Git's `.git/objects/xx/` fan-out directories inherit the /// ownership of whoever creates them, so the agent committing first locked the /// server out — `git add` returned "insufficient permission for adding an /// object to repository database". /// /// A test process cannot become two users, so this asserts the mechanism that /// makes the two-user case work: the clone sets `core.sharedRepository`, and /// objects git writes afterwards are group- and world-writable. Without that /// mode bit the second user is refused regardless of which one arrived first. #[tokio::test] async fn a_checkout_is_writable_by_both_uids_that_share_it() { use std::os::unix::fs::PermissionsExt; 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; let shared = Command::new("git") .arg("-C") .arg(&repo) .args(["config", "core.sharedRepository"]) .output() .unwrap(); assert_eq!( String::from_utf8_lossy(&shared.stdout).trim(), "0777", "the checkout must be marked shared, or a second uid cannot write objects" ); // Only directories created *after* the setting can carry its mode, and // only those matter: the clone writes its own objects before any config // exists, but the party that would be blocked by them is the container, // which runs as root and ignores permission bits. The failing direction is // the other one — directories the agent creates later, which the server // must still be able to write into. Snapshot first, then diff. let objects = repo.join(".git/objects"); let fanout = |dir: &std::path::Path| -> std::collections::HashSet { std::fs::read_dir(dir) .map(|rd| { rd.filter_map(|e| e.ok()) .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| n.len() == 2 && n.chars().all(|c| c.is_ascii_hexdigit())) .collect() }) .unwrap_or_default() }; let before = fanout(&objects); std::fs::write(repo.join("SHARED.md"), "SHARED\n").unwrap(); capture(&pool, tmp.path(), mission, phase) .await .unwrap() .unwrap(); let mut checked = 0; for name in fanout(&objects).difference(&before) { let mode = std::fs::metadata(objects.join(name)) .unwrap() .permissions() .mode() & 0o777; assert_eq!( mode & 0o022, 0o022, "{name} is {mode:o}; the other uid sharing this checkout could not \ write objects into it" ); checked += 1; } assert!(checked > 0, "no object directories were created to check"); } /// Delivery must commit without depending on the checkout's git identity. /// /// The server container has no identity of its own (`git config --global /// user.email` exits 1), so `git commit` fails with "Author identity unknown" /// unless one is supplied. Mission `019fc450` lost its first phase that way, /// while earlier missions committed fine — because their agents had happened /// to run `git config user.email` in the checkout first. /// /// A test process cannot unset the developer's global git config without /// racing every other test, so this asserts the stronger, deterministic /// property: the pipeline's identity is used even when the checkout already /// has a different one. An identity that overrides existing config is /// necessarily also present when config is absent. #[tokio::test] async fn delivery_commits_under_its_own_identity() { 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; // seed_repo configures "Test " locally; the base // commit therefore carries it, and the delivery commit must not. std::fs::write(repo.join("IDENTITY_PROBE.md"), "PROBE\n").unwrap(); let cap = capture(&pool, tmp.path(), mission, phase) .await .unwrap() .unwrap(); let commit = cap.committed.expect("delivery committed"); let author = Command::new("git") .arg("-C") .arg(&repo) .args(["log", "-1", "--format=%an <%ae>", &commit.sha]) .output() .unwrap(); let author = String::from_utf8_lossy(&author.stdout).trim().to_string(); assert_eq!( author, "Omar Sobh ", "delivery must supply a configured identity, not inherit whatever \ the checkout happens to have configured" ); let base_author = Command::new("git") .arg("-C") .arg(&repo) .args(["log", "-1", "--format=%an", "HEAD~1"]) .output() .unwrap(); assert_eq!( String::from_utf8_lossy(&base_author.stdout).trim(), "Test", "the pre-existing local identity is still configured, so the \ assertion above proves an override rather than an absence" ); } /// The commit subject must read as English on both the first pass and a rerun. /// /// Mission `019fc4e0` pushed commits titled "clawmates: phase phase work" — the /// iteration marker was interpolated into a slot that already said "phase". /// Cosmetic, but it lands in the operator's git history under their own name. #[tokio::test] async fn commit_subjects_read_correctly_on_first_pass_and_rerun() { let pool = cm_testkit::test_pool().await; for (iteration, expected) in [(0, "clawmates: phase work"), (1, "clawmates: phase work (pass 2)")] { 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("SUBJECT_PROBE.md"), "PROBE\n").unwrap(); let commit = cm_api::mission_delivery::commit_phase_work(&repo, mission, phase, iteration) .await .unwrap() .expect("committed"); let subject = Command::new("git") .arg("-C") .arg(&repo) .args(["log", "-1", "--format=%s", &commit.sha]) .output() .unwrap(); assert_eq!( String::from_utf8_lossy(&subject.stdout).trim(), expected, "iteration {iteration} produced a malformed subject" ); } } /// "No test suite" and "could not run the test suite" must not look alike. /// /// verify_tests returned Option, so both produced `None`. That is how a /// runtime image shipped without `cargo` stayed invisible: every on_green_tests /// phase landed on -wip, which reads exactly like a repository that has no /// tests — the conclusion I drew at the time and reported. /// /// Both still gate identically, and that part is deliberate: unproven is not a /// pass, whatever the reason. What changes is that the artifact now says which /// of the two happened, so an infrastructure fault is legible as one. #[tokio::test] async fn an_unrunnable_suite_is_distinguishable_from_no_suite() { use cm_api::mission_delivery::{verify_tests, TestOutcome}; let tmp = tempfile::tempdir().unwrap(); let mission = Uuid::now_v7(); let repo = seed_repo(tmp.path(), mission); // No Cargo.toml / package.json / pytest markers: nothing to run. let none = verify_tests(&repo, "clawmates-runtime-does-not-exist").await; assert_eq!(none, TestOutcome::NoSuite); assert_eq!(none.status(), "no_suite"); assert_eq!(none.verified(), None, "no suite must not clear the gate"); // A suite exists, but the container named here does not, so it cannot run. std::fs::write( repo.join("Cargo.toml"), "[package]\nname = \"p\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", ) .unwrap(); let unrunnable = verify_tests(&repo, "clawmates-runtime-does-not-exist").await; assert_eq!(unrunnable.status(), "could_not_run"); assert_eq!( unrunnable.verified(), None, "an unrunnable suite must not clear the gate either" ); assert!( unrunnable.detail().is_some_and(|d| !d.is_empty()), "an infrastructure fault must carry its reason into the artifact" ); assert_ne!( unrunnable.status(), none.status(), "the two must be distinguishable — this is the whole point" ); } /// A COMMIT_EDITMSG left by the agent must not block delivery. /// /// From mission 019fcd0c: the agent ran `git commit` itself, leaving /// `.git/COMMIT_EDITMSG` owned by root at 0644, and the server's commit died /// with "Permission denied". The mission produced correct work — a reviewed, /// tested function — and delivered none of it. /// /// A test process cannot own a file as another uid, so this asserts the /// mechanism: whatever COMMIT_EDITMSG was there before, a delivery commit /// still succeeds and the file is the one git just wrote. #[tokio::test] async fn a_stale_commit_editmsg_does_not_block_delivery() { 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; // Stand in for the agent's leftover: content that must not survive. let msg = repo.join(".git/COMMIT_EDITMSG"); std::fs::write(&msg, "LEFTOVER FROM THE AGENT\n").unwrap(); std::fs::write(repo.join("WORK.md"), "work\n").unwrap(); let cap = capture(&pool, tmp.path(), mission, phase) .await .unwrap() .unwrap(); let commit = cap .committed .expect("delivery must commit despite a stale COMMIT_EDITMSG"); assert!(!commit.sha.is_empty()); let body = std::fs::read_to_string(&msg).unwrap_or_default(); assert!( !body.contains("LEFTOVER FROM THE AGENT"), "the stale message survived: {body:?}" ); }