//! Merging a delivered branch into the base, when that is provably safe. //! //! Every mission type delivers to a branch and never to `main`. For most that //! is where it should stop — a human reads the code and merges. But some //! missions only ever *add* files in a folder they own: a paper catalogue, a //! benchmark record. Those branches carry no judgement call, and leaving them //! to pile up unmerged means the work is done but not actually in the vault. //! //! # Additive-only is a property, not a preference //! //! The gate is not "is this mission type trusted". It is measured from the //! diff: if the branch modifies or deletes anything that already existed, it //! does not qualify, whatever its template says. A research harvest that //! somehow rewrote a hand-written note would be refused by the same check //! that lets its new notes through. //! //! Three conditions, all required: //! //! 1. the mission type declares [`MergePolicy::AdditiveOnly`] //! 2. verification passed — a run that did not prove its work does not merge //! 3. the diff against the base contains only additions //! //! Anything else lands as a branch for a human, which is the existing //! behaviour and the safe default. use std::path::Path; /// What a mission type is allowed to do with its own branch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MergePolicy { /// Always leave the branch for a human. Correct for anything that touches /// code: `refactor`, `research_and_code`, security patches. Never, /// Merge automatically when the diff is provably additive and the run /// verified. Correct for catalogues and recorded measurements. AdditiveOnly, } impl MergePolicy { /// Parse a template's `merge_policy`. Unknown values fall back to `Never` /// and say so: a typo must not silently grant auto-merge. pub fn parse(raw: Option<&str>) -> MergePolicy { match raw.map(str::trim) { Some("additive_only") => MergePolicy::AdditiveOnly, Some("never") | None => MergePolicy::Never, Some(other) => { eprintln!( "auto_merge: unknown merge_policy {other:?} — refusing to auto-merge" ); MergePolicy::Never } } } } /// Why a branch was or was not merged. The reason is always recorded: a /// branch that silently did not merge is indistinguishable from one that was /// never delivered. #[derive(Debug, Clone)] pub struct MergeOutcome { pub merged: bool, pub reason: String, } impl MergeOutcome { fn refused(reason: impl Into) -> MergeOutcome { MergeOutcome { merged: false, reason: reason.into(), } } } /// Classify a `git diff --name-status` body. /// /// Returns the offending entries, empty when every change is an addition. /// Split out so the rule is testable without a repository. pub fn non_additive_changes(name_status: &str) -> Vec { name_status .lines() .filter(|l| !l.trim().is_empty()) .filter(|l| { // Status is the first field: A/M/D/R###/C###. !matches!(l.chars().next(), Some('A')) }) .map(|l| l.trim().to_string()) .collect() } async fn git(repo: &Path, args: &[&str]) -> Result { let out = tokio::process::Command::new("git") .arg("-C") .arg(repo) .args(["-c", &format!("safe.directory={}", repo.display())]) .args(args) .env("GIT_AUTHOR_NAME", crate::mission_delivery::commit_identity().0) .env("GIT_AUTHOR_EMAIL", crate::mission_delivery::commit_identity().1) .env( "GIT_COMMITTER_NAME", crate::mission_delivery::commit_identity().0, ) .env( "GIT_COMMITTER_EMAIL", crate::mission_delivery::commit_identity().1, ) .output() .await .map_err(|e| format!("spawn git: {e}"))?; if !out.status.success() { return Err(format!( "git {} → {}: {}", args.first().copied().unwrap_or("?"), out.status, crate::mission_workspace::redact_token(&String::from_utf8_lossy(&out.stderr)) .chars() .take(300) .collect::() )); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } /// Merge `branch` into `base` and push, if all three conditions hold. /// /// Never returns `Err` for a refusal — a refusal is a normal outcome with a /// reason. `Err` is reserved for the merge itself going wrong after we decided /// to attempt it. pub async fn try_merge( repo: &Path, push_url: &str, branch: &str, base: &str, policy: MergePolicy, verified: bool, ) -> Result { if policy != MergePolicy::AdditiveOnly { return Ok(MergeOutcome::refused( "merge_policy is not additive_only; left for a human", )); } if !verified { return Ok(MergeOutcome::refused( "run did not verify; refusing to merge unproven work", )); } // Compare against the base as the REMOTE has it, not a local ref that may // be stale. `...` gives changes on the branch since it diverged, so an // unrelated commit landing on main meanwhile is not misread as ours. git(repo, &["fetch", push_url, base]).await?; let diff = git( repo, &["diff", "--name-status", &format!("FETCH_HEAD...{branch}")], ) .await?; let offending = non_additive_changes(&diff); if !offending.is_empty() { return Ok(MergeOutcome::refused(format!( "diff is not additive ({} non-add change(s), first: {}); left for a human", offending.len(), offending.first().map(String::as_str).unwrap_or("?") ))); } if diff.trim().is_empty() { return Ok(MergeOutcome::refused("branch adds nothing")); } merge_and_push(repo, push_url, branch, base, "auto-merge") .await .map(|o| match o.merged { true => MergeOutcome { merged: true, reason: format!("additive-only and verified; merged into {base}"), }, false => o, }) } /// The git half of a merge, with no policy in it. /// /// Split out so an OPERATOR-approved merge runs exactly the same commands as an /// automatic one — fetch the base as the remote has it, merge onto that, push. /// The gates differ; the mechanics must not, or the rarely-taken path is the one /// that breaks. async fn merge_and_push( repo: &Path, push_url: &str, branch: &str, base: &str, label: &str, ) -> Result { // Merge onto the freshly fetched base rather than a local branch. git(repo, &["checkout", "-B", base, "FETCH_HEAD"]).await?; if let Err(e) = git( repo, &["merge", "--no-ff", "-m", &format!("{label} {branch}"), branch], ) .await { // Leave the repo clean so the next run is not fighting a wedged merge. let _ = git(repo, &["merge", "--abort"]).await; return Ok(MergeOutcome::refused(format!( "merge conflicted ({e}); left for a human" ))); } git(repo, &["push", push_url, &format!("HEAD:refs/heads/{base}")]).await?; Ok(MergeOutcome { merged: true, reason: format!("merged into {base}"), }) } /// Merge a delivered branch because an OPERATOR asked for it. /// /// `MergePolicy::Never` means "do not merge on your own" — it defers to a human, /// and this is that human. So the additive-only test does not apply: an operator /// looking at a code change is exactly the judgement the policy was holding out /// for. /// /// What is NOT waived: /// /// - the branch must exist on the remote and differ from the base, so the button /// cannot report success for a merge of nothing; /// - a conflict refuses and leaves the repo clean, rather than forcing; /// - the work happens in a FRESH CLONE, never the mission checkout — that /// directory is reaped on a timer after the mission ends, so a merge that /// depended on it would work right after a run and mysteriously fail later. pub async fn merge_on_operator_approval( workdir: &Path, push_url: &str, branch: &str, base: &str, ) -> Result { git(workdir, &["fetch", push_url, base]).await?; git(workdir, &["fetch", push_url, branch]).await?; git(workdir, &["branch", "-f", branch, "FETCH_HEAD"]).await?; git(workdir, &["fetch", push_url, base]).await?; let diff = git( workdir, &["diff", "--name-status", &format!("FETCH_HEAD...{branch}")], ) .await?; if diff.trim().is_empty() { return Ok(MergeOutcome::refused( "branch has nothing the base does not already have", )); } merge_locally(workdir, branch, base, "merge mission branch").await } /// Merge onto the fetched base WITHOUT publishing it. /// /// Split from the push so a caller can run the project's tests against the /// merged tree first. Verifying BEFORE publishing rather than reverting after is /// the difference between "main was never broken" and "main was broken for as /// long as it took us to notice". pub async fn merge_locally( repo: &Path, branch: &str, base: &str, label: &str, ) -> Result { git(repo, &["checkout", "-B", base, "FETCH_HEAD"]).await?; if let Err(e) = git( repo, &["merge", "--no-ff", "-m", &format!("{label} {branch}"), branch], ) .await { // Leave the repo clean so the next attempt is not fighting a wedged merge. let _ = git(repo, &["merge", "--abort"]).await; return Ok(MergeOutcome::refused(format!( "merge conflicted ({e}); left for a human" ))); } Ok(MergeOutcome { merged: true, reason: format!("merged into {base} locally, not yet published"), }) } /// Publish an already-merged base. pub async fn push_merged(repo: &Path, push_url: &str, base: &str) -> Result<(), String> { git(repo, &["push", push_url, &format!("HEAD:refs/heads/{base}")]) .await .map(|_| ()) } #[cfg(test)] mod tests { use super::*; /// Publication must be gated on the merged tree, and refusal must not push. /// /// The two halves are separate functions precisely so a caller can run tests /// BETWEEN them. If `merge_locally` ever pushed, verification would be /// after-the-fact and `main` would be broken for as long as it took to /// notice — which is the failure mode this whole thing exists to avoid. #[test] fn merging_locally_never_publishes() { let src = include_str!("auto_merge.rs"); let body = src .split("pub async fn merge_locally") .nth(1) .and_then(|s| s.split("\n}").next()) .unwrap_or(""); assert!(!body.is_empty(), "merge_locally not found"); assert!( !body.contains("\"push\""), "merge_locally must not push — publication is the caller's decision \ after it has verified the result" ); // And the push half must exist separately, or the caller cannot publish. assert!(src.contains("pub async fn push_merged"), "push_merged missing"); } /// An operator merge and an automatic one must run the SAME git commands. /// /// The gates differ — that is the whole point — but if the mechanics /// diverged, the rarely-taken path would be the untested one. Both go /// through `merge_and_push`. #[test] fn both_merge_paths_share_the_same_mechanics() { let src = include_str!("auto_merge.rs"); let calls = src.matches("merge_and_push(").count(); // one definition + one call from each path assert!( calls >= 3, "expected try_merge and merge_on_operator_approval to both call \ merge_and_push, found {calls} mention(s)" ); // And the operator path must NOT re-implement the policy gate it exists // to bypass — if this string appears there, the button is a no-op. let op = src .split("pub async fn merge_on_operator_approval") .nth(1) .unwrap_or(""); let body = op.split("\n}").next().unwrap_or(""); assert!( !body.contains("MergePolicy::AdditiveOnly"), "the operator path must not apply the additive-only gate" ); // It must still refuse an empty branch: a button that reports success // for merging nothing is worse than no button. assert!( body.contains("nothing the base does not already have"), "the operator path must refuse an empty branch" ); } #[test] fn only_pure_additions_qualify() { assert!(non_additive_changes("A\t60 Papers/a.md\nA\t60 Papers/b.md\n").is_empty()); // A modification disqualifies the whole branch. let m = non_additive_changes("A\t60 Papers/a.md\nM\tREADME.md\n"); assert_eq!(m.len(), 1); assert!(m[0].contains("README.md")); // So do deletes and renames — a rename is a delete plus an add, and // the delete half can destroy hand-written work. assert_eq!(non_additive_changes("D\tnotes/old.md\n").len(), 1); assert_eq!(non_additive_changes("R100\ta.md\tb.md\n").len(), 1); } #[test] fn an_unknown_policy_never_grants_auto_merge() { assert_eq!(MergePolicy::parse(None), MergePolicy::Never); assert_eq!(MergePolicy::parse(Some("never")), MergePolicy::Never); assert_eq!( MergePolicy::parse(Some("additive_only")), MergePolicy::AdditiveOnly ); // A typo must fail closed, not open. assert_eq!(MergePolicy::parse(Some("aditive_only")), MergePolicy::Never); assert_eq!(MergePolicy::parse(Some("always")), MergePolicy::Never); } }