diff --git a/.forge/prs/78e0c97e-6ae3-488e-9e4a-acdf9b025f19/patch.diff b/.forge/prs/78e0c97e-6ae3-488e-9e4a-acdf9b025f19/patch.diffnew file mode 100644index 0000000..baeeeaa--- /dev/null+++ b/.forge/prs/78e0c97e-6ae3-488e-9e4a-acdf9b025f19/patch.diff@@ -0,0 +1,612 @@1+From d09f83d487315603fd331c80524546cb5efefc5e Mon Sep 17 00:00:00 20012+From: z <gl0w@world>3+Date: Tue, 8 Sep 2026 04:59:36 +01004+Subject: [PATCH 1/2] fix #1: root package+workspace for cargo install (ref5+ 0724688e-7d6d-4610-b36f-62bf9fd51c3e)6+7+---8+ Cargo.lock | 10 ++++++++++9+ Cargo.toml | 15 +++++++++++++++10+ crates/cli/Cargo.toml | 2 +-11+ 3 files changed, 26 insertions(+), 1 deletion(-)12+13+diff --git a/Cargo.lock b/Cargo.lock14+index 2f12b83..2ca8022 10064415+--- a/Cargo.lock16++++ b/Cargo.lock17+@@ -30,6 +30,16 @@ dependencies = [18+ "uuid",19+ ]20+ 21++[[package]]22++name = "f0rge-cli"23++version = "0.1.0"24++dependencies = [25++ "f0rge-core",26++ "serde",27++ "serde_json",28++ "uuid",29++]30++31+ [[package]]32+ name = "f0rge-core"33+ version = "0.1.0"34+diff --git a/Cargo.toml b/Cargo.toml35+index b501192..b400345 10064436+--- a/Cargo.toml37++++ b/Cargo.toml38+@@ -1,3 +1,12 @@39++[package]40++name = "f0rge"41++version = "0.1.0"42++edition = "2021"43++44++[[bin]]45++name = "f0rge"46++path = "crates/cli/src/main.rs"47++48+ [workspace]49+ resolver = "2"50+ members = ["crates/core", "crates/cli"]51+@@ -9,6 +18,12 @@ serde_json = "=1.0.151"52+ yaml_serde = "=0.10.7"53+ uuid = { version = "=1.26.0", features = ["v4"] }54+ 55++[dependencies]56++f0rge-core = { path = "crates/core" }57++serde.workspace = true58++serde_json.workspace = true59++uuid.workspace = true60++61+ [profile.release]62+ lto = true63+ codegen-units = 164+diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml65+index 9ebfd06..59fec0f 10064466+--- a/crates/cli/Cargo.toml67++++ b/crates/cli/Cargo.toml68+@@ -1,5 +1,5 @@69+ [package]70+-name = "f0rge"71++name = "f0rge-cli"72+ version = "0.1.0"73+ edition = "2021"74+ 75+-- 76+2.47.377+78+79+From f252557ace5fa49d99d4b8d27c07e85e7c9d1ecd Mon Sep 17 00:00:00 200180+From: z <gl0w@world>81+Date: Tue, 8 Sep 2026 04:59:39 +010082+Subject: [PATCH 2/2] fix #3: canonical patch hash + pr merge already-landed83+ (ref bcdfa6e5-2d0e-4c3e-9646-c192cdab199c)84+85+---86+ .forge/merges/2026-09-08T03-59-16Z.md | 9 ++87+ .../patch.diff | 20 ++++88+ .../pr.md | 13 +++89+ .../patch.diff | 20 ++++90+ .../pr.md | 13 +++91+ .../c661a9bb-2756-4104-9165-daf53f1bd877.md | 7 ++92+ .../ce1318c7-5211-4291-8871-69296cb24ae1.md | 7 ++93+ crates/cli/src/cmd/merge.rs | 55 +++++++--94+ crates/cli/src/cmd/pr.rs | 105 +++++++++++++++++-95+ crates/cli/tests/sec08.rs | 94 ++++++++++++++++96+ 10 files changed, 332 insertions(+), 11 deletions(-)97+ create mode 100644 .forge/merges/2026-09-08T03-59-16Z.md98+ create mode 100644 .forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff99+ create mode 100644 .forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md100+ create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff101+ create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md102+ create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/c661a9bb-2756-4104-9165-daf53f1bd877.md103+ create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/ce1318c7-5211-4291-8871-69296cb24ae1.md104+ create mode 100644 crates/cli/tests/sec08.rs105+106+diff --git a/.forge/merges/2026-09-08T03-59-16Z.md b/.forge/merges/2026-09-08T03-59-16Z.md107+new file mode 100644108+index 0000000..ef48661109+--- /dev/null110++++ b/.forge/merges/2026-09-08T03-59-16Z.md111+@@ -0,0 +1,9 @@112++---113++pr: dc85f9c1-a63c-47fe-a67c-335754282366114++applied: true115++verdict: pass116++resolved: []117++created: 2026-09-08T03:59:16Z118++---119++clean apply120++patch: 5f61af4a30532025a3d213d4349698db710fcda8121+diff --git a/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff122+new file mode 100644123+index 0000000..4c7a2eb124+--- /dev/null125++++ b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff126+@@ -0,0 +1,20 @@127++From 70c56687c6c22dc596686b97709fb765a220a163 Mon Sep 17 00:00:00 2001128++From: z <gl0w@world>129++Date: Tue, 8 Sep 2026 04:59:05 +0100130++Subject: [PATCH] dogfood: tiny note 2131++132++---133++ dogfood-note2.md | 1 +134++ 1 file changed, 1 insertion(+)135++ create mode 100644 dogfood-note2.md136++137++diff --git a/dogfood-note2.md b/dogfood-note2.md138++new file mode 100644139++index 0000000..91993fd140++--- /dev/null141+++++ b/dogfood-note2.md142++@@ -0,0 +1 @@143+++# second144++-- 145++2.47.3146++147+diff --git a/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md148+new file mode 100644149+index 0000000..b550184150+--- /dev/null151++++ b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md152+@@ -0,0 +1,13 @@153++---154++id: 5feefe83-f09c-455b-b6d1-148c6f88faeb155++title: dogfood t2156++state: closed157++base: main158++head: dogfood-t2159++draft: false160++issues: []161++ci: null162++created: 2026-09-08T03:59:05Z163++updated: 2026-09-08T03:59:11Z164++---165++second tiny pr166+\ No newline at end of file167+diff --git a/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff168+new file mode 100644169+index 0000000..ac149ae170+--- /dev/null171++++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff172+@@ -0,0 +1,20 @@173++From bd9acf03bcf335511a340a83e26a7bae268d2d98 Mon Sep 17 00:00:00 2001174++From: z <gl0w@world>175++Date: Tue, 8 Sep 2026 04:59:05 +0100176++Subject: [PATCH] dogfood: tiny note 1177++178++---179++ dogfood-note.md | 1 +180++ 1 file changed, 1 insertion(+)181++ create mode 100644 dogfood-note.md182++183++diff --git a/dogfood-note.md b/dogfood-note.md184++new file mode 100644185++index 0000000..d0e4d23186++--- /dev/null187+++++ b/dogfood-note.md188++@@ -0,0 +1 @@189+++# dogfood note190++-- 191++2.47.3192++193+diff --git a/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md194+new file mode 100644195+index 0000000..225b567196+--- /dev/null197++++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md198+@@ -0,0 +1,13 @@199++---200++id: dc85f9c1-a63c-47fe-a67c-335754282366201++title: dogfood t1202++state: merged203++base: main204++head: dogfood-t1205++draft: false206++issues: []207++ci: pass208++created: 2026-09-08T03:59:05Z209++updated: 2026-09-08T03:59:16Z210++---211++first tiny pr212+\ No newline at end of file213+diff --git a/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/c661a9bb-2756-4104-9165-daf53f1bd877.md b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/c661a9bb-2756-4104-9165-daf53f1bd877.md214+new file mode 100644215+index 0000000..e634bba216+--- /dev/null217++++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/c661a9bb-2756-4104-9165-daf53f1bd877.md218+@@ -0,0 +1,7 @@219++---220++id: c661a9bb-2756-4104-9165-daf53f1bd877221++author: z222++state: approved223++created: 2026-09-08T03:59:11Z224++---225++lgtm dogfood226+\ No newline at end of file227+diff --git a/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/ce1318c7-5211-4291-8871-69296cb24ae1.md b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/ce1318c7-5211-4291-8871-69296cb24ae1.md228+new file mode 100644229+index 0000000..e86b33e230+--- /dev/null231++++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/ce1318c7-5211-4291-8871-69296cb24ae1.md232+@@ -0,0 +1,7 @@233++---234++id: ce1318c7-5211-4291-8871-69296cb24ae1235++author: null236++state: null237++created: 2026-09-08T03:59:11Z238++---239++nit: tiny240+\ No newline at end of file241+diff --git a/crates/cli/src/cmd/merge.rs b/crates/cli/src/cmd/merge.rs242+index 899eb66..e869c17 100644243+--- a/crates/cli/src/cmd/merge.rs244++++ b/crates/cli/src/cmd/merge.rs245+@@ -87,8 +87,32 @@ fn record_name(fg: &Forge, now: &str) -> PathBuf {246+ }247+ }248+ 249++/// ONE canonical byte-form for hashing (SEC-08): CRLF->LF so folder250++/// patch.diff bytes and branch `format-patch --stdout` bytes hash equal.251++fn canon_patch(p: &[u8]) -> Vec<u8> {252++ let mut out = Vec::with_capacity(p.len());253++ let mut i = 0;254++ while i < p.len() {255++ if p[i] == b'\r' {256++ if i + 1 < p.len() && p[i + 1] == b'\n' {257++ out.push(b'\n');258++ i += 2;259++ } else {260++ out.push(b'\n');261++ i += 1;262++ }263++ } else {264++ out.push(p[i]);265++ i += 1;266++ }267++ }268++ out269++}270++271+ /// patch-id via git; fallback hash-object (both deterministic, no new deps).272+ fn patch_hash(repo: &Path, patch: &[u8]) -> String {273++ let canon = canon_patch(patch);274++ let patch = &canon;275+ let mut c = Command::new("git");276+ c.arg("patch-id").arg("--stable").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());277+ if let Ok(mut kid) = c.spawn() {278+@@ -121,7 +145,8 @@ fn patch_hash(repo: &Path, patch: &[u8]) -> String {279+ format!("len{}", patch.len())280+ }281+ 282+-/// SEC-08: any merge record whose body pins this patch hash?283++/// SEC-08: successful merge record whose body pins this patch hash?284++/// Pass+applied only: fail records must not block retry.285+ fn landed(fg: &Forge, hash: &str) -> Option<MergeRecord> {286+ let rd = std::fs::read_dir(fg.root().join("merges")).ok()?;287+ let want = format!("patch: {hash}");288+@@ -136,7 +161,7 @@ fn landed(fg: &Forge, hash: &str) -> Option<MergeRecord> {289+ }290+ let rel = Path::new("merges").join(&name);291+ let p: f0rge_core::Parsed<MergeRecord> = fg.load_entity(&rel).ok()?;292+- if p.body.lines().any(|l| l.trim() == want) {293++ if p.value.verdict == "pass" && p.value.applied && p.body.lines().any(|l| l.trim() == want) {294+ return Some(p.value);295+ }296+ }297+@@ -147,6 +172,8 @@ struct Payload {298+ patch: Vec<u8>,299+ id: String,300+ base: String,301++ /// head branch (branch flow) or payload pr.head (folder flow), for canon flip.302++ head: Option<String>,303+ /// payload pr.md on disk (outside .forge), if any, to flip on success.304+ pr_path: Option<PathBuf>,305+ }306+@@ -175,11 +202,11 @@ fn from_folder(dir: &Path, base_flag: Option<&str>) -> Result<Payload, CliError>307+ if patch.iter().all(|b| b.is_ascii_whitespace()) {308+ return Err(CliError::Runtime("patch.diff: empty patch".to_string()));309+ }310+- let (id, base, pr_path) = match payload_pr(dir)? {311+- Some(pr) => (pr.id.clone(), base_flag.unwrap_or(&pr.base).to_string(), Some(dir.join("pr.md"))),312+- None => (uuid::Uuid::new_v4().to_string(), base_flag.unwrap_or("main").to_string(), None),313++ let (id, base, head, pr_path) = match payload_pr(dir)? {314++ Some(pr) => (pr.id.clone(), base_flag.unwrap_or(&pr.base).to_string(), Some(pr.head.clone()), Some(dir.join("pr.md"))),315++ None => (uuid::Uuid::new_v4().to_string(), base_flag.unwrap_or("main").to_string(), None, None),316+ };317+- Ok(Payload { patch, id, base: safe_ref(&base, "base")?, pr_path })318++ Ok(Payload { patch, id, base: safe_ref(&base, "base")?, head, pr_path })319+ }320+ 321+ fn find_patch(dir: &Path) -> Option<PathBuf> {322+@@ -271,7 +298,7 @@ fn from_branch(repo: &Path, fg: &Forge, branch: &str, base_flag: Option<&str>) -323+ if o.stdout.iter().all(|b| b.is_ascii_whitespace()) {324+ return Err(CliError::Runtime(format!("empty patch: nothing in {spec}")));325+ }326+- Ok(Payload { patch: o.stdout, id, base, pr_path: None })327++ Ok(Payload { patch: o.stdout, id, base, head: Some(branch.clone()), pr_path: None })328+ }329+ 330+ /// Minimal CI: steps in filename order, cwd=worktree, fail-fast (M4 ritual).331+@@ -395,6 +422,13 @@ pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {332+ if let Some(t) = &zip_tmp {333+ let _ = std::fs::remove_dir_all(t);334+ }335++ flip_canon(fg, &payload.id, "merged", Some("pass"));336++ flip_canon(fg, &rec.pr, "merged", Some("pass"));337++ if let Some(h) = &payload.head {338++ if let Some((pr, _)) = pr_by_head(fg, h) {339++ flip_canon(fg, &pr.id, "merged", Some("pass"));340++ }341++ }342+ if json {343+ output::emit_json(&rec, jq.as_deref());344+ } else {345+@@ -489,6 +523,13 @@ pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {346+ let _ = std::fs::remove_dir_all(t);347+ }348+ flip_canon(fg, &payload.id, "merged", Some("pass"));349++ if let Some(h) = &payload.head {350++ if let Some((pr, _)) = pr_by_head(fg, h) {351++ if pr.id != payload.id {352++ flip_canon(fg, &pr.id, "merged", Some("pass"));353++ }354++ }355++ }356+ if let Some(p) = &payload.pr_path {357+ if fg.root().join(fg.pr_file(&payload.id)) != *p {358+ let _ = flip_pr_file(p, "merged", Some("pass"));359+diff --git a/crates/cli/src/cmd/pr.rs b/crates/cli/src/cmd/pr.rs360+index 8e763d3..9459f63 100644361+--- a/crates/cli/src/cmd/pr.rs362++++ b/crates/cli/src/cmd/pr.rs363+@@ -311,6 +311,86 @@ fn merge_record_name(fg: &Forge, now: &str) -> PathBuf {364+ }365+ }366+ 367++/// ONE canonical byte-form for hashing (SEC-08): CRLF->LF so folder368++/// patch.diff bytes and branch `format-patch --stdout` bytes hash equal.369++fn canon_patch(p: &[u8]) -> Vec<u8> {370++ let mut out = Vec::with_capacity(p.len());371++ let mut i = 0;372++ while i < p.len() {373++ if p[i] == b'\r' {374++ if i + 1 < p.len() && p[i + 1] == b'\n' {375++ out.push(b'\n');376++ i += 2;377++ } else {378++ out.push(b'\n');379++ i += 1;380++ }381++ } else {382++ out.push(p[i]);383++ i += 1;384++ }385++ }386++ out387++}388++389++/// patch-id via git; fallback hash-object (both deterministic, no new deps).390++fn patch_hash(repo: &Path, patch: &[u8]) -> String {391++ let canon = canon_patch(patch);392++ let patch = &canon;393++ let mut c = Command::new("git");394++ c.arg("patch-id").arg("--stable").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());395++ if let Ok(mut kid) = c.spawn() {396++ use std::io::Write;397++ if let Some(mut stdin) = kid.stdin.take() {398++ let _ = stdin.write_all(patch);399++ }400++ if let Ok(o) = kid.wait_with_output() {401++ if o.status.success() {402++ let t = String::from_utf8_lossy(&o.stdout);403++ if let Some(h) = t.split_whitespace().next().filter(|h| h.len() >= 8) {404++ return h.to_string();405++ }406++ }407++ }408++ }409++ let mut c2 = Command::new("git");410++ c2.arg("hash-object").arg("--stdin").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());411++ if let Ok(mut kid) = c2.spawn() {412++ use std::io::Write;413++ if let Some(mut stdin) = kid.stdin.take() {414++ let _ = stdin.write_all(patch);415++ }416++ if let Ok(o) = kid.wait_with_output() {417++ if o.status.success() {418++ return String::from_utf8_lossy(&o.stdout).trim().to_string();419++ }420++ }421++ }422++ format!("len{}", patch.len())423++}424++425++/// SEC-08: successful merge record whose body pins this patch hash?426++fn landed(fg: &Forge, hash: &str) -> Option<MergeRecord> {427++ let rd = std::fs::read_dir(fg.root().join("merges")).ok()?;428++ let want = format!("patch: {hash}");429++ for e in rd.flatten() {430++ let ft = e.file_type().ok()?;431++ if ft.is_symlink() || !ft.is_file() {432++ continue;433++ }434++ let name = e.file_name().to_string_lossy().into_owned();435++ if !name.ends_with(".md") || name.starts_with('.') {436++ continue;437++ }438++ let rel = Path::new("merges").join(&name);439++ let p: f0rge_core::Parsed<MergeRecord> = fg.load_entity(&rel).ok()?;440++ if p.value.verdict == "pass" && p.value.applied && p.body.lines().any(|l| l.trim() == want) {441++ return Some(p.value);442++ }443++ }444++ None445++}446++447+ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {448+ let s = p.get(1).ok_or_else(|| CliError::Usage("pr merge <id|number>".to_string()))?;449+ let all = load_all(fg)?;450+@@ -318,11 +398,26 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C451+ let parsed = fg.load_entity::<Pr>(&fg.pr_file(&id)).map_err(CliError::from)?;452+ output::warn_schema(&parsed.schema_note);453+ let mut pr = parsed.value;454++ let patch = fg.read_capped(&fg.pr_patch(&id)).map_err(CliError::from)?;455++ let repo = repo_dir(fg)?;456++ let hash = patch_hash(&repo, &patch);457++ if let Some(rec) = landed(fg, &hash) {458++ if pr.state == "open" {459++ pr.state = "merged".to_string();460++ pr.ci = Some("pass".to_string());461++ pr.updated = Some(now_rfc3339());462++ let _ = fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body);463++ }464++ if json {465++ output::emit_json(&rec, jq);466++ } else {467++ println!("already landed: {}", rec.pr);468++ }469++ return Ok(());470++ }471+ if pr.state != "open" {472+ return Err(CliError::Runtime(format!("pr {id}: already {}", pr.state)));473+ }474+- let patch = fg.read_capped(&fg.pr_patch(&id)).map_err(CliError::from)?;475+- let repo = repo_dir(fg)?;476+ let n = TMP_CTR.fetch_add(1, Ordering::Relaxed);477+ let pid = std::process::id();478+ let tmp = std::env::temp_dir().join(format!("f0rge-pr-{pid}-{n}"));479+@@ -347,6 +442,7 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C480+ if o.status.success() {481+ cleanup(None);482+ pr.state = "merged".to_string();483++ pr.ci = Some("pass".to_string());484+ pr.updated = Some(now.clone());485+ fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body).map_err(CliError::from)?;486+ let rec = MergeRecord {487+@@ -357,7 +453,7 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C488+ created: now,489+ };490+ let rel = merge_record_name(fg, &rec.created);491+- fg.save_entity(&rel, &rec, "clean apply\n").map_err(CliError::from)?;492++ fg.save_entity(&rel, &rec, &format!("clean apply\npatch: {hash}\n")).map_err(CliError::from)?;493+ let all2 = load_all(fg)?;494+ let num = numbers(&all2).get(&id).copied().unwrap_or(1);495+ if json {496+@@ -368,11 +464,12 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C497+ Ok(())498+ } else {499+ let files = conflict_files(&String::from_utf8_lossy(&o.stderr));500+- let body = if files.is_empty() {501++ let base_body = if files.is_empty() {502+ "patch does not apply\n".to_string()503+ } else {504+ format!("conflict: {}\n", files.join(", "))505+ };506++ let body = format!("{base_body}patch: {hash}\n");507+ let rec = MergeRecord {508+ pr: id.clone(),509+ applied: false,510+diff --git a/crates/cli/tests/sec08.rs b/crates/cli/tests/sec08.rs511+new file mode 100644512+index 0000000..4bf3b4a513+--- /dev/null514++++ b/crates/cli/tests/sec08.rs515+@@ -0,0 +1,94 @@516++#![forbid(unsafe_code)]517++//! SEC-08: already-landed parity for pr merge / merge.518++use std::path::{Path, PathBuf};519++use std::process::Command;520++use std::sync::atomic::{AtomicU64, Ordering};521++522++static CTR: AtomicU64 = AtomicU64::new(0);523++524++fn bin() -> String {525++ env!("CARGO_BIN_EXE_f0rge").to_string()526++}527++528++fn tmp_repo(tag: &str) -> PathBuf {529++ let n = CTR.fetch_add(1, Ordering::Relaxed);530++ let d = std::env::temp_dir().join(format!("f0rge-sec08-{}-{}-{n}", std::process::id(), tag));531++ let _ = std::fs::remove_dir_all(&d);532++ std::fs::create_dir_all(&d).unwrap();533++ d534++}535++536++fn git(repo: &Path, args: &[&str]) {537++ let o = Command::new("git").args(args).current_dir(repo).output().unwrap();538++ assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));539++}540++541++fn f0rge(repo: &Path, args: &[&str]) -> (i32, String, String) {542++ let o = Command::new(bin()).args(args).current_dir(repo).output().unwrap();543++ (544++ o.status.code().unwrap_or(-1),545++ String::from_utf8_lossy(&o.stdout).into_owned(),546++ String::from_utf8_lossy(&o.stderr).into_owned(),547++ )548++}549++550++fn merges(repo: &Path) -> Vec<PathBuf> {551++ std::fs::read_dir(repo.join(".forge").join("merges"))552++ .unwrap()553++ .flatten()554++ .map(|e| e.path())555++ .filter(|p| p.extension().map(|x| x == "md").unwrap_or(false))556++ .collect()557++}558++559++#[test]560++fn sec08_double_merge_is_already_landed() {561++ let repo = tmp_repo("dbl");562++ git(&repo, &["init"]);563++ git(&repo, &["config", "user.email", "[email protected]"]);564++ git(&repo, &["config", "user.name", "t"]);565++ git(&repo, &["config", "commit.gpgsign", "false"]);566++ std::fs::write(repo.join("a.txt"), "line1\nline2\n").unwrap();567++ git(&repo, &["add", "-A"]);568++ git(&repo, &["commit", "-m", "init"]);569++ git(&repo, &["checkout", "-b", "main"]);570++ let (c, _, e) = f0rge(&repo, &["init"]);571++ assert_eq!(c, 0, "init: {e}");572++ git(&repo, &["checkout", "-b", "feat"]);573++ std::fs::write(repo.join("a.txt"), "line1-feat\nline2\n").unwrap();574++ git(&repo, &["add", "-A"]);575++ git(&repo, &["commit", "-m", "feat"]);576++ git(&repo, &["checkout", "main"]);577++ let (c, o, e) = f0rge(&repo, &["pr", "create", "--base", "main", "--head", "feat", "--title", "T", "--json"]);578++ assert_eq!(c, 0, "create: {e}");579++ let v: serde_json::Value = serde_json::from_str(o.lines().last().unwrap().trim()).unwrap();580++ let id = v["databaseId"].as_str().unwrap().to_string();581++ // first land via pr merge.582++ let (c, o, e) = f0rge(&repo, &["pr", "merge", &id]);583++ assert_eq!(c, 0, "first pr merge: {e} {o}");584++ assert_eq!(merges(&repo).len(), 1);585++ let prmd = std::fs::read_to_string(repo.join(".forge").join("prs").join(&id).join("pr.md")).unwrap();586++ assert!(prmd.contains("state: merged"), "pr flipped:\n{prmd}");587++ assert!(prmd.contains("ci: pass"), "ci pass:\n{prmd}");588++ let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();589++ assert!(rec.contains("patch: "), "record pins hash:\n{rec}");590++ // second pr merge: already landed, exit 0.591++ let (c, o, e) = f0rge(&repo, &["pr", "merge", &id]);592++ assert_eq!(c, 0, "second pr merge exit 0, got {c} err: {e}");593++ assert!(o.contains("already landed"), "out: {o}");594++ assert!(o.contains(&id), "out names pr: {o}");595++ assert_eq!(e.lines().count(), 0, "no stderr: {e}");596++ assert_eq!(merges(&repo).len(), 1, "no duplicate record");597++ // merge on canonical folder: already landed, exit 0.598++ let folder = repo.join(".forge").join("prs").join(&id).to_string_lossy().into_owned();599++ let (c, o, e) = f0rge(&repo, &["merge", &folder]);600++ assert_eq!(c, 0, "merge folder repeat: {e}");601++ assert!(o.contains("already landed"), "out: {o}");602++ assert_eq!(merges(&repo).len(), 1);603++ // merge branch repeat: already landed, exit 0.604++ let (c, o, e) = f0rge(&repo, &["merge", "feat"]);605++ assert_eq!(c, 0, "merge branch repeat: {e}");606++ assert!(o.contains("already landed"), "out: {o}");607++ assert_eq!(merges(&repo).len(), 1);608++ let _ = std::fs::remove_dir_all(&repo);609++}610+-- 611+2.47.3612+