All four were surfaced while tracing #55 and left open. Each one on its own is
small; together they are why a two-line git rejection took hours to read.
**1. `with_ambient_auth` failed open.** It matched one literal prefix,
`https://git.redclaw.dev/`, and returned the URL unchanged for everything else
with no log line. An `http://` remote, an explicit port, a different case in the
host, an ssh remote, a URL that already carried userinfo — all came back
unauthenticated and looked identical to success. It now returns `Authed`, which
carries the URL AND why no credential reached it, and recognises the forge in
every shape a remote can be written (host parsed with userinfo stripped BEFORE
the port, or `oauth2:token@host` reports its username as the host — the first
version of this function did exactly that and failed its own test).
**2. Nothing set `GIT_TERMINAL_PROMPT=0`.** So a credential-less URL did not
fail — git opened `/dev/tty`, and in a server container that surfaces as
`No such device or address`, several layers from the missing token. Now set on
every git invocation that can reach the network. And `push_url_for` refuses
outright when the URL is on OUR forge and unauthenticated: that push cannot
succeed, and letting it proceed only buys a symptom that looks like something
else.
**3. The truncation fix went to the wrong path.** e31688b clamped the caller,
but a rejected push comes back as `Ok(Publish { error })` — the string was
already cut to 300 head chars inside `git()`, so the reject reason had been
dropped before the both-ends clamp ever saw it. Clamped where the output is
produced, and redacted there too.
**4. #55: a mission that re-clones can never push.** The branch name is
deterministic per (mission, phase, iteration), so a checkout rebuilt after a
retry, a container teardown or disk loss produces divergent history against its
own branch, and git rejects it — leaving the work on a local branch in a
directory the sweeper deletes. Reachable in normal operation, not just by
deleting a checkout by hand.
The escape is a NEW ref, not `--force`: forcing would overwrite whatever the
earlier attempt pushed, which may be the only copy of that work, to make this
attempt look tidy. The retry lands on `<branch>-<sha8>` — deterministic,
self-describing in a branch list, and collision-free since divergent history is
by definition a different sha. "Never force" stays a rule.
NEGATIVE CONTROL, run rather than assumed: with the rescue arm disabled,
`diverged_history_lands_on_a_new_branch_instead_of_being_lost` FAILS with git's
real `! [rejected] ... (fetch first)` — which also demonstrates fix 3, since that
whole message now survives to the assertion. The test asserts the earlier
attempt's ref is byte-identical afterwards.
Also measured, not read off docs: which hooks fire under `claude -p` (2.1.222,
via `--settings`). SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
SubagentStop and Stop fire; TaskCreated, TaskCompleted, TeammateIdle, SessionEnd,
Notification and PreCompact do not. So the agent-teams hooks Slice 3 deferred are
inert on our path by construction, and `Stop` is the seam that could move
`done_when` into the agent's own loop.
507 tests pass, clippy clean.
Co-Authored-By: Claude Opus 5 <[email protected]>
1022 lines
36 KiB
Rust
1022 lines
36 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"
|
|
);
|
|
}
|
|
|
|
/// #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<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"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "No test suite" and "could not run the test suite" must not look alike.
|
|
///
|
|
/// verify_tests returned Option<bool>, 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:?}"
|
|
);
|
|
}
|