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