Completes delivery. A phase's work is now captured, committed, gated and pushed — in that order, so every failure costs strictly less than the one before it. Publishing is last for a reason. By the time it runs the patch is on disk, the artifact is registered and the work is on a local branch, so a rejected ref, a rotated token or an unreachable forge costs a push and nothing else. A test pushes at a path that does not exist and asserts the commit is still there afterwards. The gate decides the branch name, never whether the work survives: - green, or policy `always` → `clawmates/mission-<m8>-<p8>` - red / unrunnable / no suite → `…-wip` - `on_reviewer_approval` → `…-review` Both land on the forge. A human can inspect, fix and re-push a branch; nobody can recover work discarded for failing a test. Deleting a red branch reproduces the old behaviour on purpose rather than by accident. `verify_tests` runs the project's own suite through the runtime container and returns `Option<bool>` — `None` for "could not establish", which the gate treats as unproven. An unreadable exit status is not a pass. That is the same fail-closed stance as the phase evaluator, and it is here because this tranche has now found four separate things reporting success while doing nothing. Never force-push. A rejected update is reported and left alone: the remote ref belongs to whoever set it, and overwriting it to make delivery look tidy is how a mission eats someone else's commit. The push URL is built fresh from the repo row and the ambient token, not read from `.git/config` — which no longer carries credentials, since agents run as root in a container that mounts the checkout. Tests push to a real `git init --bare` remote and assert the ref and its content actually arrived. A mock would have accepted anything. Co-Authored-By: Claude Opus 5 <[email protected]>
588 lines
19 KiB
Rust
588 lines
19 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"]);
|
|
record_base(&repo);
|
|
repo
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn captures_modified_and_untracked_files() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
let mission = Uuid::now_v7();
|
|
let repo = seed_repo(tmp.path(), mission);
|
|
let (ws, phase) = seed_mission_phase(&pool, mission).await;
|
|
let _ = ws;
|
|
|
|
// A modified file and a brand-new one. The new file is the case that
|
|
// matters: without `--intent-to-add` it would not appear in `git diff`,
|
|
// and a phase that only creates files is the likeliest shape of all.
|
|
std::fs::write(repo.join("README.md"), "# base\nchanged\n").unwrap();
|
|
std::fs::write(repo.join("new_module.rs"), "fn added() {}\n").unwrap();
|
|
|
|
let cap = capture(&pool, tmp.path(), mission, phase)
|
|
.await
|
|
.unwrap()
|
|
.expect("mission has a checkout");
|
|
|
|
assert!(!cap.empty, "the phase changed files");
|
|
assert_eq!(cap.files_changed, 2, "one modified, one created");
|
|
assert!(cap.insertions >= 2);
|
|
|
|
let patch = std::fs::read_to_string(&cap.patch_path).unwrap();
|
|
assert!(
|
|
patch.contains("new_module.rs"),
|
|
"untracked file is captured"
|
|
);
|
|
assert!(patch.contains("fn added()"), "its content is captured");
|
|
assert!(patch.contains("changed"), "the modification is captured");
|
|
|
|
// Capture is followed by a commit, so the tree the agents left is now on a
|
|
// branch of its own. The patch was written first and is what guarantees
|
|
// the work survives; the branch is the convenience on top.
|
|
let branch = Command::new("git")
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
.output()
|
|
.unwrap();
|
|
let branch = String::from_utf8_lossy(&branch.stdout).trim().to_string();
|
|
assert!(
|
|
branch.starts_with("clawmates/mission-"),
|
|
"work lands on a namespaced mission branch, never the default one: {branch}"
|
|
);
|
|
|
|
let status = Command::new("git")
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["status", "--porcelain"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
|
|
"everything the phase produced is committed, nothing left dangling"
|
|
);
|
|
|
|
let show = Command::new("git")
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["show", "--stat", "--oneline", "HEAD"])
|
|
.output()
|
|
.unwrap();
|
|
let show = String::from_utf8_lossy(&show.stdout);
|
|
assert!(
|
|
show.contains("new_module.rs"),
|
|
"the created file is in the commit: {show}"
|
|
);
|
|
|
|
assert!(
|
|
cap.committed.is_some(),
|
|
"the capture records where the work landed"
|
|
);
|
|
|
|
let row: (String, serde_json::Value) = sqlx::query_as(
|
|
"SELECT kind, metadata FROM mission_artifacts WHERE mission_id = $1 AND phase_id = $2",
|
|
)
|
|
.bind(mission)
|
|
.bind(phase)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(row.0, "code_diff");
|
|
assert_eq!(row.1["files_changed"], 2);
|
|
assert_eq!(row.1["empty"], false);
|
|
assert!(row.1["base_sha"].as_str().unwrap().len() >= 7);
|
|
}
|
|
|
|
/// "This coding phase wrote no code" is a result, and currently an invisible
|
|
/// one. It must still produce an artifact.
|
|
#[tokio::test]
|
|
async fn an_empty_phase_still_produces_an_artifact() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
let mission = Uuid::now_v7();
|
|
seed_repo(tmp.path(), mission);
|
|
let (_, phase) = seed_mission_phase(&pool, mission).await;
|
|
|
|
let cap = capture(&pool, tmp.path(), mission, phase)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(cap.empty);
|
|
assert_eq!(cap.files_changed, 0);
|
|
|
|
let (kind, meta): (String, serde_json::Value) =
|
|
sqlx::query_as("SELECT kind, metadata FROM mission_artifacts WHERE mission_id = $1")
|
|
.bind(mission)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(kind, "code_diff");
|
|
assert_eq!(meta["empty"], true, "the emptiness is recorded, not hidden");
|
|
}
|
|
|
|
/// Build output must never reach the patch. A phase that ran `cargo build`
|
|
/// leaves a `target/` larger than the repository, and committing it would be
|
|
/// worse than losing the diff.
|
|
#[tokio::test]
|
|
async fn build_output_is_not_captured() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
let mission = Uuid::now_v7();
|
|
let repo = seed_repo(tmp.path(), mission);
|
|
let (_, phase) = seed_mission_phase(&pool, mission).await;
|
|
|
|
std::fs::create_dir_all(repo.join("target/debug")).unwrap();
|
|
std::fs::write(repo.join("target/debug/huge.bin"), vec![b'x'; 200_000]).unwrap();
|
|
std::fs::create_dir_all(repo.join("node_modules/left-pad")).unwrap();
|
|
std::fs::write(
|
|
repo.join("node_modules/left-pad/index.js"),
|
|
"module.exports=0",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(repo.join("real_change.rs"), "fn kept() {}\n").unwrap();
|
|
|
|
let cap = capture(&pool, tmp.path(), mission, phase)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
let patch = std::fs::read_to_string(&cap.patch_path).unwrap();
|
|
assert!(patch.contains("real_change.rs"), "genuine work is captured");
|
|
assert!(!patch.contains("huge.bin"), "target/ is excluded");
|
|
assert!(!patch.contains("left-pad"), "node_modules is excluded");
|
|
assert_eq!(cap.files_changed, 1, "only the real change counts");
|
|
}
|
|
|
|
/// A research mission has no checkout. That is not an error.
|
|
#[tokio::test]
|
|
async fn a_mission_without_a_checkout_captures_nothing() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
let mission = Uuid::now_v7();
|
|
let (_, phase) = seed_mission_phase(&pool, mission).await;
|
|
assert!(capture(&pool, tmp.path(), mission, phase)
|
|
.await
|
|
.unwrap()
|
|
.is_none());
|
|
}
|
|
|
|
async fn seed_mission_phase(pool: &sqlx::PgPool, mission: Uuid) -> (Uuid, Uuid) {
|
|
let ws = Uuid::now_v7();
|
|
sqlx::query("INSERT INTO workspaces (id, name, plan) VALUES ($1,'t','team')")
|
|
.bind(ws)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO missions (id, workspace_id, title, template_kind, schedule, status, config)
|
|
VALUES ($1,$2,'t','research_and_code','{}'::jsonb,'running','{}'::jsonb)",
|
|
)
|
|
.bind(mission)
|
|
.bind(ws)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
let phase = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO mission_phases (id, mission_id, kind, order_idx, status, config)
|
|
VALUES ($1,$2,'coding',0,'completed','{}'::jsonb)",
|
|
)
|
|
.bind(phase)
|
|
.bind(mission)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
(ws, phase)
|
|
}
|
|
|
|
/// The production failure this pairs with. Mission 019fc372's agent created
|
|
/// the file it was asked for and *committed* it — `rust_sdlc` has a committer
|
|
/// role, so that is the intended path — leaving a clean working tree. Capture
|
|
/// diffed against HEAD, found nothing, and recorded `empty: true` next to a
|
|
/// commit that plainly contained the work.
|
|
#[tokio::test]
|
|
async fn work_the_agent_committed_is_captured() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
let mission = Uuid::now_v7();
|
|
let repo = seed_repo(tmp.path(), mission);
|
|
let (_, phase) = seed_mission_phase(&pool, mission).await;
|
|
|
|
std::fs::write(repo.join("DELIVERY_PROBE.md"), "CAPTURED-BY-CLAWMATES\n").unwrap();
|
|
git(&repo, &["add", "DELIVERY_PROBE.md"]);
|
|
git(&repo, &["commit", "--quiet", "-m", "Add DELIVERY_PROBE.md"]);
|
|
|
|
// The tree is clean — `git status --porcelain` is empty here, which is
|
|
// precisely why the HEAD-relative version saw nothing.
|
|
let status = Command::new("git")
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["status", "--porcelain"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
|
|
"the agent committed, so the tree is clean"
|
|
);
|
|
|
|
let cap = capture(&pool, tmp.path(), mission, phase)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(
|
|
!cap.empty,
|
|
"committed work must be captured, not reported as empty"
|
|
);
|
|
assert_eq!(cap.files_changed, 1);
|
|
|
|
let patch = std::fs::read_to_string(&cap.patch_path).unwrap();
|
|
assert!(
|
|
patch.contains("CAPTURED-BY-CLAWMATES"),
|
|
"the committed content is in the patch"
|
|
);
|
|
}
|
|
|
|
/// Committed *and* uncommitted work in the same phase — a coder that committed
|
|
/// one change and left another in progress.
|
|
#[tokio::test]
|
|
async fn committed_and_uncommitted_changes_are_both_captured() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
let mission = Uuid::now_v7();
|
|
let repo = seed_repo(tmp.path(), mission);
|
|
let (_, phase) = seed_mission_phase(&pool, mission).await;
|
|
|
|
std::fs::write(repo.join("committed.rs"), "fn done() {}\n").unwrap();
|
|
git(&repo, &["add", "committed.rs"]);
|
|
git(&repo, &["commit", "--quiet", "-m", "first"]);
|
|
std::fs::write(repo.join("in_progress.rs"), "fn wip() {}\n").unwrap();
|
|
|
|
let cap = capture(&pool, tmp.path(), mission, phase)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let patch = std::fs::read_to_string(&cap.patch_path).unwrap();
|
|
assert!(patch.contains("fn done()"), "committed work");
|
|
assert!(patch.contains("fn wip()"), "uncommitted work");
|
|
assert_eq!(cap.files_changed, 2);
|
|
}
|
|
|
|
/// Build output must stay out of the commit as well as the patch. Putting a
|
|
/// `target/` directory into someone's history is worse than losing the diff.
|
|
#[tokio::test]
|
|
async fn excluded_paths_are_not_committed() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
let mission = Uuid::now_v7();
|
|
let repo = seed_repo(tmp.path(), mission);
|
|
let (_, phase) = seed_mission_phase(&pool, mission).await;
|
|
|
|
std::fs::create_dir_all(repo.join("target/debug")).unwrap();
|
|
std::fs::write(repo.join("target/debug/blob.bin"), vec![b'x'; 50_000]).unwrap();
|
|
std::fs::write(
|
|
repo.join(".gitconfig_temp"),
|
|
"[safe]\n\tdirectory = /mission/repo\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(repo.join("real.rs"), "fn kept() {}\n").unwrap();
|
|
|
|
capture(&pool, tmp.path(), mission, phase)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
let tracked = Command::new("git")
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["ls-files"])
|
|
.output()
|
|
.unwrap();
|
|
let tracked = String::from_utf8_lossy(&tracked.stdout);
|
|
assert!(tracked.contains("real.rs"), "genuine work is committed");
|
|
assert!(
|
|
!tracked.contains("blob.bin"),
|
|
"build output is not committed"
|
|
);
|
|
assert!(
|
|
!tracked.contains(".gitconfig_temp"),
|
|
"an agent's workaround file is not committed into the user's history"
|
|
);
|
|
}
|
|
|
|
/// 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()"));
|
|
}
|