1 #![forbid(unsafe_code)]2 //! SEC-08: already-landed parity for pr merge / merge.3 use std::path::{Path, PathBuf};4 use std::process::Command;5 use std::sync::atomic::{AtomicU64, Ordering};6 7 static CTR: AtomicU64 = AtomicU64::new(0);8 9 fn bin() -> String {10 env!("CARGO_BIN_EXE_f0rge").to_string()11 }12 13 fn tmp_repo(tag: &str) -> PathBuf {14 let n = CTR.fetch_add(1, Ordering::Relaxed);15 let d = std::env::temp_dir().join(format!("f0rge-sec08-{}-{}-{n}", std::process::id(), tag));16 let _ = std::fs::remove_dir_all(&d);17 std::fs::create_dir_all(&d).unwrap();18 d19 }20 21 fn git(repo: &Path, args: &[&str]) {22 let o = Command::new("git").args(args).current_dir(repo).output().unwrap();23 assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));24 }25 26 fn f0rge(repo: &Path, args: &[&str]) -> (i32, String, String) {27 let o = Command::new(bin()).args(args).current_dir(repo).output().unwrap();28 (29 o.status.code().unwrap_or(-1),30 String::from_utf8_lossy(&o.stdout).into_owned(),31 String::from_utf8_lossy(&o.stderr).into_owned(),32 )33 }34 35 fn merges(repo: &Path) -> Vec<PathBuf> {36 std::fs::read_dir(repo.join(".forge").join("merges"))37 .unwrap()38 .flatten()39 .map(|e| e.path())40 .filter(|p| p.extension().map(|x| x == "md").unwrap_or(false))41 .collect()42 }43 44 #[test]45 fn sec08_double_merge_is_already_landed() {46 let repo = tmp_repo("dbl");47 git(&repo, &["init"]);48 git(&repo, &["config", "user.email", "[email protected]"]);49 git(&repo, &["config", "user.name", "t"]);50 git(&repo, &["config", "commit.gpgsign", "false"]);51 std::fs::write(repo.join("a.txt"), "line1\nline2\n").unwrap();52 git(&repo, &["add", "-A"]);53 git(&repo, &["commit", "-m", "init"]);54 git(&repo, &["checkout", "-b", "main"]);55 let (c, _, e) = f0rge(&repo, &["init"]);56 assert_eq!(c, 0, "init: {e}");57 git(&repo, &["checkout", "-b", "feat"]);58 std::fs::write(repo.join("a.txt"), "line1-feat\nline2\n").unwrap();59 git(&repo, &["add", "-A"]);60 git(&repo, &["commit", "-m", "feat"]);61 git(&repo, &["checkout", "main"]);62 let (c, o, e) = f0rge(&repo, &["pr", "create", "--base", "main", "--head", "feat", "--title", "T", "--json"]);63 assert_eq!(c, 0, "create: {e}");64 let v: serde_json::Value = serde_json::from_str(o.lines().last().unwrap().trim()).unwrap();65 let id = v["databaseId"].as_str().unwrap().to_string();66 // first land via pr merge.67 let (c, o, e) = f0rge(&repo, &["pr", "merge", &id]);68 assert_eq!(c, 0, "first pr merge: {e} {o}");69 assert_eq!(merges(&repo).len(), 1);70 let prmd = std::fs::read_to_string(repo.join(".forge").join("prs").join(&id).join("pr.md")).unwrap();71 assert!(prmd.contains("state: merged"), "pr flipped:\n{prmd}");72 assert!(prmd.contains("ci: pass"), "ci pass:\n{prmd}");73 let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();74 assert!(rec.contains("patch: "), "record pins hash:\n{rec}");75 // second pr merge: already landed, exit 0.76 let (c, o, e) = f0rge(&repo, &["pr", "merge", &id]);77 assert_eq!(c, 0, "second pr merge exit 0, got {c} err: {e}");78 assert!(o.contains("already landed"), "out: {o}");79 assert!(o.contains(&id), "out names pr: {o}");80 assert_eq!(e.lines().count(), 0, "no stderr: {e}");81 assert_eq!(merges(&repo).len(), 1, "no duplicate record");82 // merge on canonical folder: already landed, exit 0.83 let folder = repo.join(".forge").join("prs").join(&id).to_string_lossy().into_owned();84 let (c, o, e) = f0rge(&repo, &["merge", &folder]);85 assert_eq!(c, 0, "merge folder repeat: {e}");86 assert!(o.contains("already landed"), "out: {o}");87 assert_eq!(merges(&repo).len(), 1);88 // merge branch repeat: already landed, exit 0.89 let (c, o, e) = f0rge(&repo, &["merge", "feat"]);90 assert_eq!(c, 0, "merge branch repeat: {e}");91 assert!(o.contains("already landed"), "out: {o}");92 assert_eq!(merges(&repo).len(), 1);93 let _ = std::fs::remove_dir_all(&repo);94 }95 #[test]96 fn sec08_branch_canonical_repeat_is_landed() {97 // #15 x SEC-08: branch merge records the canonical PR uuid; repeat is98 // already-landed (exit 0), names it, writes no second record.99 let repo = tmp_repo("canon");100 git(&repo, &["init"]);101 git(&repo, &["config", "user.email", "[email protected]"]);102 git(&repo, &["config", "user.name", "t"]);103 git(&repo, &["config", "commit.gpgsign", "false"]);104 std::fs::write(repo.join("a.txt"), "line1\nline2\n").unwrap();105 git(&repo, &["add", "-A"]);106 git(&repo, &["commit", "-m", "init"]);107 git(&repo, &["checkout", "-b", "main"]);108 let (c, _, e) = f0rge(&repo, &["init"]);109 assert_eq!(c, 0, "init: {e}");110 git(&repo, &["add", "-A"]);111 git(&repo, &["commit", "-m", "forge"]);112 git(&repo, &["checkout", "-b", "feat"]);113 std::fs::write(repo.join("a.txt"), "line1-feat\nline2\n").unwrap();114 git(&repo, &["add", "-A"]);115 git(&repo, &["commit", "-m", "feat"]);116 let (c, o, e) = f0rge(&repo, &["pr", "create", "--base", "main", "--head", "feat", "--title", "T", "--json"]);117 assert_eq!(c, 0, "create: {e}");118 let v: serde_json::Value = serde_json::from_str(o.lines().last().unwrap().trim()).unwrap();119 let id = v["databaseId"].as_str().unwrap().to_string();120 git(&repo, &["add", "-A"]);121 git(&repo, &["commit", "-m", "pr"]);122 git(&repo, &["checkout", "main"]);123 let (c, o, e) = f0rge(&repo, &["merge", "feat"]);124 assert_eq!(c, 0, "merge: {e}");125 assert!(o.trim() == format!("merged {id}"), "canonical: {o}");126 assert_eq!(merges(&repo).len(), 1);127 let (c, o, e) = f0rge(&repo, &["merge", "feat"]);128 assert_eq!(c, 0, "repeat exit 0, got {c} err: {e}");129 assert!(o.contains("already landed") && o.contains(&id), "names canonical: {o}");130 assert_eq!(e.lines().count(), 0, "no stderr: {e}");131 assert_eq!(merges(&repo).len(), 1, "no duplicate record");132 let _ = std::fs::remove_dir_all(&repo);133 }134