feat(missions): commit captured work to a branch of its own
Second half of delivery, minus the push. After the patch is on disk and the artifact registered, the phase's work is committed onto `clawmates/mission-<mission8>-<phase8>`, with `-i<N>` for re-runs so a second pass cannot collide with the first. Three rules hold throughout: - Never the default branch. The name is derived from the mission and phase, so a mission can only ever add a ref nobody else owns. - Never force. A rejected update gets reported, not overwritten. - The same exclusions as capture. What was too noisy for a patch is too noisy for someone's history — build output, vendored trees, and the workaround files agents write when infrastructure fights them. A test drops a 50 KB binary in `target/` and a `.gitconfig_temp` beside the real change and asserts neither is committed. Ordering is deliberate: commit runs *after* capture, and a commit failure is logged without failing the capture. The patch is the guarantee; the branch is the convenience on top. The branch is created even when there is nothing to stage, because agents often commit their own work — `rust_sdlc` has a committer role — and that commit is unreachable once the checkout is reaped unless a ref points at it. One test changed meaning rather than breaking: it asserted capture left the working tree untouched, which was correct while capture stood alone. Capture now commits, so it asserts the new invariant — work on a namespaced branch, a clean tree, and the created file present in the commit. Push is still deliberately absent. Everything here is local, so a bug costs a retry rather than reaching a remote. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a0e6b16abc
commit
ca1fd46e08
@@ -25,6 +25,7 @@ async fn capture(
|
||||
phase,
|
||||
&root.join(mission.to_string()).join("repo"),
|
||||
&root.join("_outputs").join(mission.to_string()),
|
||||
0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -106,18 +107,47 @@ async fn captures_modified_and_untracked_files() {
|
||||
assert!(patch.contains("fn added()"), "its content is captured");
|
||||
assert!(patch.contains("changed"), "the modification is captured");
|
||||
|
||||
// Capture must leave the working tree exactly as the agents left it — a
|
||||
// later commit has to see the same thing this diff described.
|
||||
// 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();
|
||||
let status = String::from_utf8_lossy(&status.stdout);
|
||||
assert!(
|
||||
status.contains("new_module.rs"),
|
||||
"the new file is still untracked after capture, not left staged: {status}"
|
||||
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(
|
||||
@@ -312,3 +342,61 @@ async fn committed_and_uncommitted_changes_are_both_captured() {
|
||||
assert!(patch.contains("fn wip()"), "uncommitted work");
|
||||
assert_eq!(cap.files_changed, 2);
|
||||
}
|
||||
|
||||
/// Build output must stay out of the commit as well as the patch. Putting a
|
||||
/// `target/` directory into someone's history is worse than losing the diff.
|
||||
#[tokio::test]
|
||||
async fn excluded_paths_are_not_committed() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let mission = Uuid::now_v7();
|
||||
let repo = seed_repo(tmp.path(), mission);
|
||||
let (_, phase) = seed_mission_phase(&pool, mission).await;
|
||||
|
||||
std::fs::create_dir_all(repo.join("target/debug")).unwrap();
|
||||
std::fs::write(repo.join("target/debug/blob.bin"), vec![b'x'; 50_000]).unwrap();
|
||||
std::fs::write(
|
||||
repo.join(".gitconfig_temp"),
|
||||
"[safe]\n\tdirectory = /mission/repo\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(repo.join("real.rs"), "fn kept() {}\n").unwrap();
|
||||
|
||||
capture(&pool, tmp.path(), mission, phase)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let tracked = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["ls-files"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let tracked = String::from_utf8_lossy(&tracked.stdout);
|
||||
assert!(tracked.contains("real.rs"), "genuine work is committed");
|
||||
assert!(
|
||||
!tracked.contains("blob.bin"),
|
||||
"build output is not committed"
|
||||
);
|
||||
assert!(
|
||||
!tracked.contains(".gitconfig_temp"),
|
||||
"an agent's workaround file is not committed into the user's history"
|
||||
);
|
||||
}
|
||||
|
||||
/// A re-run must not collide with the pass before it.
|
||||
#[test]
|
||||
fn a_rerun_lands_on_its_own_branch() {
|
||||
let m = Uuid::now_v7();
|
||||
let p = Uuid::now_v7();
|
||||
let first = mission_delivery::branch_name(m, p, 0);
|
||||
let second = mission_delivery::branch_name(m, p, 1);
|
||||
assert_ne!(first, second);
|
||||
assert!(first.starts_with("clawmates/mission-"));
|
||||
assert!(
|
||||
second.ends_with("-i2"),
|
||||
"pass 2 is named for the pass, not the index: {second}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user