Closes the branch pile-up: a catalogue branch that only adds notes now merges into main by itself, so the work is actually in the vault rather than waiting in a branch nobody opened. Additive-only is measured from the diff, not assumed from the mission type. Three conditions, all required: the type declares additive_only, the run verified, and `git diff --name-status base...branch` contains only A entries. A research harvest that somehow rewrote a hand-written note is refused by the same check that lets its new notes through — which is the case the test pins down, asserting README.md on main is byte-identical afterwards. Renames and deletes count as non-additive. A rename is a delete plus an add and the delete half can destroy hand-written work. Unknown merge_policy values fail closed to Never. A typo must not grant auto-merge. The diff is taken against FETCH_HEAD, freshly fetched, using `...` so an unrelated commit landing on main meanwhile is not misread as ours. A conflicted merge aborts and leaves the branch for a human rather than wedging the checkout for the next run. merge_reason is always populated and surfaced in the API: a branch that quietly did not merge is indistinguishable from one never delivered. 399 tests, clippy clean. Co-Authored-By: Claude Opus 5 <[email protected]>
148 lines
5.2 KiB
Rust
148 lines
5.2 KiB
Rust
//! Auto-merge against real git repositories.
|
|
//!
|
|
//! The rule is measured from the diff, so it has to be tested against real
|
|
//! diffs — a unit test on the classifier alone would not catch a wrong
|
|
//! revision range.
|
|
|
|
use cm_api::auto_merge::{self, MergePolicy};
|
|
|
|
fn git(repo: &std::path::Path, args: &[&str]) {
|
|
let out = std::process::Command::new("git")
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(args)
|
|
.env("GIT_AUTHOR_NAME", "T")
|
|
.env("GIT_AUTHOR_EMAIL", "[email protected]")
|
|
.env("GIT_COMMITTER_NAME", "T")
|
|
.env("GIT_COMMITTER_EMAIL", "[email protected]")
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
out.status.success(),
|
|
"git {args:?}: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
/// Returns (work checkout, bare remote path).
|
|
fn seed() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let remote = tmp.path().join("remote.git");
|
|
let work = tmp.path().join("work");
|
|
std::process::Command::new("git")
|
|
.args(["init", "--quiet", "--bare"])
|
|
.arg(&remote)
|
|
.output()
|
|
.unwrap();
|
|
std::fs::create_dir_all(&work).unwrap();
|
|
git(&work, &["init", "--quiet"]);
|
|
git(&work, &["checkout", "-q", "-B", "main"]);
|
|
std::fs::write(work.join("README.md"), "# vault\n").unwrap();
|
|
git(&work, &["add", "."]);
|
|
git(&work, &["commit", "--quiet", "-m", "base"]);
|
|
git(&work, &["remote", "add", "origin", remote.to_str().unwrap()]);
|
|
git(&work, &["push", "--quiet", "origin", "main"]);
|
|
(tmp, work, remote)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_purely_additive_branch_is_merged() {
|
|
let (_tmp, work, remote) = seed();
|
|
git(&work, &["checkout", "-q", "-B", "lib/add"]);
|
|
std::fs::create_dir_all(work.join("60 Papers")).unwrap();
|
|
std::fs::write(work.join("60 Papers/a.md"), "# paper\n").unwrap();
|
|
git(&work, &["add", "."]);
|
|
git(&work, &["commit", "--quiet", "-m", "add paper"]);
|
|
git(&work, &["push", "--quiet", "origin", "lib/add"]);
|
|
|
|
let out = auto_merge::try_merge(
|
|
&work, remote.to_str().unwrap(), "lib/add", "main",
|
|
MergePolicy::AdditiveOnly, true,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(out.merged, "should have merged: {}", out.reason);
|
|
|
|
// The note must really be on main at the remote, not just locally.
|
|
let ls = std::process::Command::new("git")
|
|
.arg("-C").arg(&remote)
|
|
.args(["ls-tree", "--name-only", "-r", "main"])
|
|
.output()
|
|
.unwrap();
|
|
let listed = String::from_utf8_lossy(&ls.stdout);
|
|
assert!(listed.contains("60 Papers/a.md"), "remote main: {listed}");
|
|
}
|
|
|
|
/// The load-bearing refusal: a branch that rewrites an existing file must be
|
|
/// left for a human even though its mission type is allowed to auto-merge.
|
|
#[tokio::test]
|
|
async fn a_branch_that_modifies_an_existing_file_is_refused() {
|
|
let (_tmp, work, remote) = seed();
|
|
git(&work, &["checkout", "-q", "-B", "lib/bad"]);
|
|
std::fs::create_dir_all(work.join("60 Papers")).unwrap();
|
|
std::fs::write(work.join("60 Papers/a.md"), "# paper\n").unwrap();
|
|
// …and clobbers a hand-written file.
|
|
std::fs::write(work.join("README.md"), "# REWRITTEN BY A MACHINE\n").unwrap();
|
|
git(&work, &["add", "."]);
|
|
git(&work, &["commit", "--quiet", "-m", "add + clobber"]);
|
|
git(&work, &["push", "--quiet", "origin", "lib/bad"]);
|
|
|
|
let out = auto_merge::try_merge(
|
|
&work, remote.to_str().unwrap(), "lib/bad", "main",
|
|
MergePolicy::AdditiveOnly, true,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(!out.merged, "must refuse a non-additive branch");
|
|
assert!(out.reason.contains("not additive"), "reason: {}", out.reason);
|
|
|
|
let show = std::process::Command::new("git")
|
|
.arg("-C").arg(&remote)
|
|
.args(["show", "main:README.md"])
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&show.stdout),
|
|
"# vault\n",
|
|
"the hand-written file must be untouched on main"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unverified_work_is_never_merged() {
|
|
let (_tmp, work, remote) = seed();
|
|
git(&work, &["checkout", "-q", "-B", "lib/unverified"]);
|
|
std::fs::create_dir_all(work.join("60 Papers")).unwrap();
|
|
std::fs::write(work.join("60 Papers/a.md"), "# paper\n").unwrap();
|
|
git(&work, &["add", "."]);
|
|
git(&work, &["commit", "--quiet", "-m", "add"]);
|
|
git(&work, &["push", "--quiet", "origin", "lib/unverified"]);
|
|
|
|
let out = auto_merge::try_merge(
|
|
&work, remote.to_str().unwrap(), "lib/unverified", "main",
|
|
MergePolicy::AdditiveOnly, false,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(!out.merged);
|
|
assert!(out.reason.contains("did not verify"), "reason: {}", out.reason);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_never_policy_branch_is_left_alone() {
|
|
let (_tmp, work, remote) = seed();
|
|
git(&work, &["checkout", "-q", "-B", "code/change"]);
|
|
std::fs::write(work.join("new.rs"), "fn main() {}\n").unwrap();
|
|
git(&work, &["add", "."]);
|
|
git(&work, &["commit", "--quiet", "-m", "code"]);
|
|
git(&work, &["push", "--quiet", "origin", "code/change"]);
|
|
|
|
let out = auto_merge::try_merge(
|
|
&work, remote.to_str().unwrap(), "code/change", "main",
|
|
MergePolicy::Never, true,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(!out.merged, "code must never auto-merge");
|
|
}
|