crates/cli/tests/merge_e2e.rs 15.5 KiB 350 lines Raw
1 #![forbid(unsafe_code)]2 //! M6 e2e: merge folder|zip|branch against temp repos via the binary.3 //! Happy -> record; repeat -> already-landed 0; conflict -> 1 + files;4 //! traversal -> 1; ci fail -> 1, pr open.5 use std::os::unix::fs::PermissionsExt;6 use std::path::{Path, PathBuf};7 use std::process::Command;8 use std::sync::atomic::{AtomicU64, Ordering};9 10 static CTR: AtomicU64 = AtomicU64::new(0);11 12 fn bin() -> String {13     env!("CARGO_BIN_EXE_f0rge").to_string()14 }15 16 fn tmp(tag: &str) -> PathBuf {17     let n = CTR.fetch_add(1, Ordering::Relaxed);18     let d = std::env::temp_dir().join(format!("f0rge-m6-{}-{}-{n}", std::process::id(), tag));19     let _ = std::fs::remove_dir_all(&d);20     std::fs::create_dir_all(&d).unwrap();21     d22 }23 24 fn git(repo: &Path, args: &[&str]) {25     let o = Command::new("git").args(args).current_dir(repo).output().unwrap();26     assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));27 }28 29 fn f0rge(repo: &Path, args: &[&str]) -> (i32, String, String) {30     let o = Command::new(bin()).args(args).current_dir(repo).output().unwrap();31     (32         o.status.code().unwrap_or(-1),33         String::from_utf8_lossy(&o.stdout).into_owned(),34         String::from_utf8_lossy(&o.stderr).into_owned(),35     )36 }37 38 fn setup() -> PathBuf {39     let repo = tmp("repo");40     git(&repo, &["init"]);41     git(&repo, &["config", "user.email", "[email protected]"]);42     git(&repo, &["config", "user.name", "t"]);43     git(&repo, &["config", "commit.gpgsign", "false"]);44     std::fs::write(repo.join("a.txt"), "line1\nline2\n").unwrap();45     git(&repo, &["add", "-A"]);46     git(&repo, &["commit", "-m", "init"]);47     git(&repo, &["checkout", "-b", "main"]);48     let (c, _, e) = f0rge(&repo, &["init"]);49     assert_eq!(c, 0, "init: {e}");50     repo51 }52 53 fn pr_md(id: &str, base: &str, head: &str) -> String {54     format!(55         "---\nid: {id}\ntitle: T\nstate: open\nbase: {base}\nhead: {head}\ncreated: 2025-09-08T04:00:00Z\n---\nBody.\n"56     )57 }58 59 /// Payload dir with patch.diff + pr.md; patch = worktree diff, tree restored.60 fn payload(repo: &Path, tag: &str, id: &str, new_content: &str) -> PathBuf {61     std::fs::write(repo.join("a.txt"), new_content).unwrap();62     let o = Command::new("git").args(["diff", "--", "a.txt"]).current_dir(repo).output().unwrap();63     assert!(o.status.success());64     assert!(!o.stdout.is_empty(), "empty diff");65     git(&repo, &["checkout", "--", "a.txt"]);66     let d = tmp(tag);67     std::fs::write(d.join("patch.diff"), &o.stdout).unwrap();68     std::fs::write(d.join("pr.md"), pr_md(id, "main", "feat")).unwrap();69     d70 }71 72 fn merges(repo: &Path) -> Vec<PathBuf> {73     std::fs::read_dir(repo.join(".forge").join("merges"))74         .unwrap()75         .flatten()76         .map(|e| e.path())77         .filter(|p| p.extension().map(|x| x == "md").unwrap_or(false))78         .collect()79 }80 81 const ID1: &str = "44444444-4444-4444-8444-444444444444";82 const ID2: &str = "55555555-5555-4555-8555-555555555555";83 const ID3: &str = "66666666-6666-4666-8666-666666666666";84 const ID4: &str = "77777777-7777-4777-8777-777777777777";85 const ID5: &str = "88888888-8888-4888-8888-888888888888";86 const ID6: &str = "99999999-9999-4999-8999-999999999999";87 88 #[test]89 fn folder_happy_then_landed() {90     let repo = setup();91     let dir = payload(&repo, "pay", ID1, "line1-payload\nline2\n");92     let s = dir.to_string_lossy().into_owned();93     let (c, o, e) = f0rge(&repo, &["merge", &s]);94     assert_eq!(c, 0, "merge: {e}");95     assert!(o.trim() == format!("merged {ID1}"), "out: {o}");96     assert_eq!(merges(&repo).len(), 1);97     let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();98     assert!(rec.contains("verdict: pass") && rec.contains("applied: true"), "record:\n{rec}");99     assert!(rec.contains("patch: ") && rec.contains("applied: "), "record pins hash+sha:\n{rec}");100     let pr = std::fs::read_to_string(dir.join("pr.md")).unwrap();101     assert!(pr.contains("state: merged"), "payload pr flipped:\n{pr}");102     // repeat: idempotent already-landed, exit 0.103     let (c, o, e) = f0rge(&repo, &["merge", &s]);104     assert_eq!(c, 0, "repeat: {e}");105     assert!(o.contains("already landed"), "out: {o}");106     assert_eq!(merges(&repo).len(), 1, "no duplicate record");107     let _ = std::fs::remove_dir_all(&repo);108     let _ = std::fs::remove_dir_all(&dir);109 }110 111 #[test]112 fn folder_json_shape() {113     let repo = setup();114     let dir = payload(&repo, "payj", ID6, "line1-j\nline2\n");115     let s = dir.to_string_lossy().into_owned();116     let (c, o, e) = f0rge(&repo, &["merge", "--json", &s]);117     assert_eq!(c, 0, "merge: {e}");118     let v: serde_json::Value = serde_json::from_str(o.trim()).expect("json line");119     assert_eq!(v["pr"], ID6);120     assert_eq!(v["verdict"], "pass");121     assert_eq!(v["applied"], true);122     assert!(v["resolved"].is_array() && v["created"].is_string(), "fields: {v}");123     let _ = std::fs::remove_dir_all(&repo);124     let _ = std::fs::remove_dir_all(&dir);125 }126 127 #[test]128 fn conflict_lists_files_leaves_open() {129     let repo = setup();130     // advance base on the same line the payload will touch.131     std::fs::write(repo.join("a.txt"), "line1-main\nline2\n").unwrap();132     git(&repo, &["add", "-A"]);133     git(&repo, &["commit", "-m", "advance"]);134     // payload diff made against pre-advance base.135     git(&repo, &["checkout", "-b", "tmpbase", "HEAD~1"]);136     let dir = tmp("payc");137     std::fs::create_dir_all(&dir).unwrap();138     git(&repo, &["checkout", "main"]);139     // hand patch touching line1 (conflicts with main's line1-main).140     let patch = "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n-line1\n+line1-payload\n line2\n";141     std::fs::write(dir.join("patch.diff"), patch).unwrap();142     std::fs::write(dir.join("pr.md"), pr_md(ID2, "main", "feat")).unwrap();143     let s = dir.to_string_lossy().into_owned();144     let (c, _, e) = f0rge(&repo, &["merge", &s]);145     assert_eq!(c, 1, "conflict exits 1");146     assert_eq!(e.lines().count(), 1, "one-line error: {e}");147     assert!(e.contains("a.txt"), "names file: {e}");148     assert_eq!(merges(&repo).len(), 1);149     let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();150     assert!(rec.contains("verdict: fail") && rec.contains("a.txt"), "record:\n{rec}");151     let pr = std::fs::read_to_string(dir.join("pr.md")).unwrap();152     assert!(pr.contains("state: open"), "pr stays open:\n{pr}");153     let _ = std::fs::remove_dir_all(&repo);154     let _ = std::fs::remove_dir_all(&dir);155 }156 157 #[test]158 fn zip_payload_merges() {159     let repo = setup();160     let dir = payload(&repo, "payz", ID3, "line1-zip\nline2\n");161     let zip = tmp("zipp").join("p.zip");162     let o = Command::new("zip").arg("-qr").arg(&zip).arg(".").current_dir(&dir).output().unwrap();163     assert!(o.status.success(), "zip: {}", String::from_utf8_lossy(&o.stderr));164     let s = zip.to_string_lossy().into_owned();165     let (c, o, e) = f0rge(&repo, &["merge", &s]);166     assert_eq!(c, 0, "merge zip: {e}");167     assert!(o.contains(ID3), "out: {o}");168     assert_eq!(merges(&repo).len(), 1);169     let _ = std::fs::remove_dir_all(&repo);170     let _ = std::fs::remove_dir_all(&dir);171 }172 173 #[test]174 fn branch_payload_merges() {175     let repo = setup();176     git(&repo, &["checkout", "-b", "feat"]);177     std::fs::write(repo.join("a.txt"), "line1-feat\nline2\n").unwrap();178     git(&repo, &["add", "-A"]);179     git(&repo, &["commit", "-m", "feat"]);180     git(&repo, &["checkout", "main"]);181     let (c, o, e) = f0rge(&repo, &["merge", "feat"]);182     assert_eq!(c, 0, "merge branch: {e}");183     assert!(o.trim().starts_with("merged "), "out: {o}");184     assert_eq!(merges(&repo).len(), 1);185     let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();186     assert!(rec.contains("verdict: pass"), "record:\n{rec}");187     // repeat branch: already landed.188     let (c, o, _) = f0rge(&repo, &["merge", "feat"]);189     assert_eq!(c, 0);190     assert!(o.contains("already landed"), "out: {o}");191     let _ = std::fs::remove_dir_all(&repo);192 }193 194 #[test]195 fn traversal_patch_rejected() {196     // SEC-05: git's own path validation must refuse escape.197     let repo = setup();198     let dir = tmp("payt");199     let patch = "diff --git a/../evil.txt b/../evil.txt\nnew file mode 100644\n--- /dev/null\n+++ b/../evil.txt\n@@ -0,0 +1 @@\n+evil\n";200     std::fs::write(dir.join("patch.diff"), patch).unwrap();201     std::fs::write(dir.join("pr.md"), pr_md(ID4, "main", "feat")).unwrap();202     let s = dir.to_string_lossy().into_owned();203     let (c, _, e) = f0rge(&repo, &["merge", &s]);204     assert_eq!(c, 1, "traversal exits 1");205     assert_eq!(e.lines().count(), 1, "one-line error: {e}");206     assert!(!repo.join("evil.txt").exists() && !repo.parent().unwrap().join("evil.txt").exists());207     assert_eq!(merges(&repo).len(), 1, "conflict recorded");208     let _ = std::fs::remove_dir_all(&repo);209     let _ = std::fs::remove_dir_all(&dir);210 }211 212 #[test]213 fn ci_fail_leaves_open() {214     let repo = setup();215     let step = repo.join(".forge").join("ci").join("steps").join("01_fail.sh");216     std::fs::write(&step, "#!/bin/sh\nexit 1\n").unwrap();217     std::fs::set_permissions(&step, std::fs::Permissions::from_mode(0o755)).unwrap();218     let dir = payload(&repo, "payf", ID5, "line1-ci\nline2\n");219     let s = dir.to_string_lossy().into_owned();220     let (c, _, e) = f0rge(&repo, &["merge", &s]);221     assert_eq!(c, 1, "ci fail exits 1");222     assert_eq!(e.lines().count(), 1, "one-line error: {e}");223     assert!(e.contains("01_fail.sh"), "names step: {e}");224     let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();225     assert!(rec.contains("verdict: fail"), "record:\n{rec}");226     let pr = std::fs::read_to_string(dir.join("pr.md")).unwrap();227     assert!(pr.contains("state: open"), "pr stays open:\n{pr}");228     let _ = std::fs::remove_dir_all(&repo);229     let _ = std::fs::remove_dir_all(&dir);230 }231 232 #[test]233 fn usage_and_missing() {234     let repo = setup();235     let (c, _, e) = f0rge(&repo, &["merge"]);236     assert_eq!(c, 2);237     assert_eq!(e.lines().count(), 1);238     let (c, _, e) = f0rge(&repo, &["merge", "no-such-branch-xyz"]);239     assert_eq!(c, 1);240     assert_eq!(e.lines().count(), 1);241     let _ = std::fs::remove_dir_all(&repo);242 }243 #[test]244 fn branch_merge_links_canonical_pr() {245     // #15: PR folder committed on the branch tip (absent on main);246     // `f0rge merge <branch>` records the canonical PR uuid, fresh only as last resort.247     let repo = setup();248     git(&repo, &["add", "-A"]);249     git(&repo, &["commit", "-m", "forge"]);250     git(&repo, &["checkout", "-b", "feat"]);251     std::fs::write(repo.join("a.txt"), "line1-feat\nline2\n").unwrap();252     git(&repo, &["add", "-A"]);253     git(&repo, &["commit", "-m", "feat"]);254     let (c, o, e) = f0rge(&repo, &["pr", "create", "--base", "main", "--head", "feat", "--title", "T", "--json"]);255     assert_eq!(c, 0, "create: {e}");256     let v: serde_json::Value = serde_json::from_str(o.lines().last().unwrap().trim()).unwrap();257     let id = v["databaseId"].as_str().unwrap().to_string();258     git(&repo, &["add", "-A"]);259     git(&repo, &["commit", "-m", "pr"]);260     git(&repo, &["checkout", "main"]);261     assert!(!repo.join(".forge").join("prs").join(&id).exists(), "pr rides the branch, not main");262     let (c, o, e) = f0rge(&repo, &["merge", "feat"]);263     assert_eq!(c, 0, "merge: {e}");264     assert!(o.trim() == format!("merged {id}"), "out links canonical: {o}");265     assert_eq!(merges(&repo).len(), 1);266     let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();267     assert!(rec.contains(&format!("pr: {id}")), "record links canonical:\n{rec}");268     let _ = std::fs::remove_dir_all(&repo);269 }270 #[test]271 fn branch_forge_deletion_is_conflict_no_loss() {272     // #17/#20: a branch deleting live .forge/prs state is a conflict (list + stop),273     // never a silent apply. No locks, no resolvers: the agent reconciles.274     let repo = setup();275     git(&repo, &["add", "-A"]);276     git(&repo, &["commit", "-m", "forge"]);277     const LIVE: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";278     let live = repo.join(".forge").join("prs").join(LIVE);279     std::fs::create_dir_all(&live).unwrap();280     std::fs::write(live.join("pr.md"), format!("---\nid: {LIVE}\ntitle: T\nstate: open\nbase: main\nhead: other\ncreated: 2025-09-08T04:00:00Z\n---\nBody.\n")).unwrap();281     std::fs::write(live.join("patch.diff"), "fake\n").unwrap();282     git(&repo, &["add", "-A"]);283     git(&repo, &["commit", "-m", "land"]);284     git(&repo, &["checkout", "-b", "evil"]);285     let _ = std::fs::remove_dir_all(repo.join(".forge").join("prs").join(LIVE));286     git(&repo, &["add", "-A"]);287     git(&repo, &["commit", "-m", "evil delete"]);288     git(&repo, &["checkout", "main"]);289     let (c, _, e) = f0rge(&repo, &["merge", "evil"]);290     assert_eq!(c, 1, "deletion conflicts, exit 1");291     assert_eq!(e.lines().count(), 1, "one-line error: {e}");292     assert!(e.contains(".forge/prs") && e.contains(LIVE), "lists deleted state: {e}");293     assert_eq!(merges(&repo).len(), 1);294     let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();295     assert!(rec.contains("verdict: fail"), "fail record:\n{rec}");296     assert!(rec.contains(LIVE) && rec.contains("patch: "), "record names state + hash:\n{rec}");297     assert!(!rec.contains(&format!("pr: {LIVE}")), "deletion must not become record id:\n{rec}");298     assert!(repo.join(".forge").join("prs").join(LIVE).join("pr.md").is_file(), "no loss on main");299     let _ = std::fs::remove_dir_all(&repo);300 }301 #[test]302 fn conflict_record_has_both_sides_context() {303     // #21: fail record carries per-file base + patch context so a fresh agent304     // resolves without re-running git; stderr stays one line.305     let repo = setup();306     std::fs::write(repo.join("a.txt"), "line1-main\nline2\n").unwrap();307     git(&repo, &["add", "-A"]);308     git(&repo, &["commit", "-m", "advance"]);309     let dir = tmp("payctx");310     let patch = "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n-line1\n+line1-payload\n line2\n";311     std::fs::write(dir.join("patch.diff"), patch).unwrap();312     std::fs::write(dir.join("pr.md"), pr_md("cccccccc-cccc-4ccc-8ccc-cccccccccccc", "main", "feat")).unwrap();313     let s = dir.to_string_lossy().into_owned();314     let (c, _, e) = f0rge(&repo, &["merge", &s]);315     assert_eq!(c, 1, "conflict exits 1");316     assert_eq!(e.lines().count(), 1, "one-line error: {e}");317     assert!(e.contains("a.txt"), "names file: {e}");318     let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();319     assert!(rec.contains("verdict: fail") && rec.contains("file: a.txt"), "record per-file:\n{rec}");320     assert!(rec.contains("line1-main"), "record has base side:\n{rec}");321     assert!(rec.contains("line1-payload"), "record has patch hunk:\n{rec}");322     assert!(rec.contains("patch: "), "record pins hash:\n{rec}");323     let _ = std::fs::remove_dir_all(&repo);324     let _ = std::fs::remove_dir_all(&dir);325 }326 #[test]327 fn branch_conflict_record_has_head_side() {328     // #21 (branch flow): fail record carries base + head-branch + patch context.329     let repo = setup();330     git(&repo, &["add", "-A"]);331     git(&repo, &["commit", "-m", "forge"]);332     git(&repo, &["checkout", "-b", "feat"]);333     std::fs::write(repo.join("a.txt"), "line1-feat\nline2\n").unwrap();334     git(&repo, &["add", "-A"]);335     git(&repo, &["commit", "-m", "feat"]);336     git(&repo, &["checkout", "main"]);337     std::fs::write(repo.join("a.txt"), "line1-main\nline2\n").unwrap();338     git(&repo, &["add", "-A"]);339     git(&repo, &["commit", "-m", "advance"]);340     let (c, _, e) = f0rge(&repo, &["merge", "feat"]);341     assert_eq!(c, 1, "conflict exits 1");342     assert_eq!(e.lines().count(), 1, "one-line error: {e}");343     assert!(e.contains("a.txt"), "names file: {e}");344     let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();345     assert!(rec.contains("file: a.txt"), "record per-file:\n{rec}");346     assert!(rec.contains("line1-main"), "record has base side:\n{rec}");347     assert!(rec.contains("line1-feat"), "record has head side:\n{rec}");348     let _ = std::fs::remove_dir_all(&repo);349 }350