Mission 019fc4e0 pushed "clawmates: phase phase work" — the iteration
marker was interpolated into a slot whose default already said "phase".
A rerun read correctly ("pass 2 phase work"), so only the common case was
wrong. Cosmetic, but it lands in the operator's git history under their
own name now that delivery commits as them.
Subject is now "clawmates: phase work" and "clawmates: phase work
(pass 2)". The test covers both, since the bug lived only in the branch
the previous shape did not exercise.
Co-Authored-By: Claude Opus 5 <[email protected]>
831 lines
29 KiB
Rust
831 lines
29 KiB
Rust
//! 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<Option<mission_delivery::Capture>, 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 `<root>/<mission>/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", "[email protected]"]);
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// 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<String> {
|
|
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 <[email protected]>" 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 <[email protected]>",
|
|
"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"
|
|
);
|
|
}
|
|
}
|