merge 5c99b18d-3c25-448f-8564-85eaa7f5919f

787a133gl0w

all commits

Message

merge 5c99b18d-3c25-448f-8564-85eaa7f5919f

Diff

.forge/merges/2026-09-08T03-59-16Z.md+9 −0
diff --git a/.forge/merges/2026-09-08T03-59-16Z.md b/.forge/merges/2026-09-08T03-59-16Z.mdnew file mode 100644index 0000000..ef48661--- /dev/null+++ b/.forge/merges/2026-09-08T03-59-16Z.md@@ -0,0 +1,9 @@1+---2+pr: dc85f9c1-a63c-47fe-a67c-3357542823663+applied: true4+verdict: pass5+resolved: []6+created: 2026-09-08T03:59:16Z7+---8+clean apply9+patch: 5f61af4a30532025a3d213d4349698db710fcda8
.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff+20 −0
diff --git a/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diffnew file mode 100644index 0000000..607c2b2--- /dev/null+++ b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff@@ -0,0 +1,20 @@1+From 70c56687c6c22dc596686b97709fb765a220a163 Mon Sep 17 00:00:00 20012+From: z <gl0w@world>3+Date: Tue, 8 Sep 2026 04:59:05 +01004+Subject: [PATCH] dogfood: tiny note 25+6+---7+ dogfood-note2.md | 1 +8+ 1 file changed, 1 insertion(+)9+ create mode 100644 dogfood-note2.md10+11+diff --git a/dogfood-note2.md b/dogfood-note2.md12+new file mode 10064413+index 0000000..91993fd14+--- /dev/null15++++ b/dogfood-note2.md16+@@ -0,0 +1 @@17++# second18+-- 19+2.47.320+
.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md+13 −0
diff --git a/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.mdnew file mode 100644index 0000000..b550184--- /dev/null+++ b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md@@ -0,0 +1,13 @@1+---2+id: 5feefe83-f09c-455b-b6d1-148c6f88faeb3+title: dogfood t24+state: closed5+base: main6+head: dogfood-t27+draft: false8+issues: []9+ci: null10+created: 2026-09-08T03:59:05Z11+updated: 2026-09-08T03:59:11Z12+---13+second tiny pr\ No newline at end of file
.forge/prs/78e0c97e-6ae3-488e-9e4a-acdf9b025f19/patch.diff+612 −0
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+
.forge/prs/78e0c97e-6ae3-488e-9e4a-acdf9b025f19/pr.md+15 −0
diff --git a/.forge/prs/78e0c97e-6ae3-488e-9e4a-acdf9b025f19/pr.md b/.forge/prs/78e0c97e-6ae3-488e-9e4a-acdf9b025f19/pr.mdnew file mode 100644index 0000000..be81704--- /dev/null+++ b/.forge/prs/78e0c97e-6ae3-488e-9e4a-acdf9b025f19/pr.md@@ -0,0 +1,15 @@1+---2+id: 78e0c97e-6ae3-488e-9e4a-acdf9b025f193+title: 'dog-pr: fix #1 install + #3 merge SEC-08'4+state: open5+base: main6+head: dog-pr7+draft: false8+issues:9+- 0724688e-7d6d-4610-b36f-62bf9fd51c3e10+- bcdfa6e5-2d0e-4c3e-9646-c192cdab199c11+ci: null12+created: 2026-09-08T04:00:04Z13+updated: null14+---15+Root install works (package+workspace). Canonical patch hash, pr merge already-landed parity, canon flip merged+pass. Fixes 0724688e-7d6d-4610-b36f-62bf9fd51c3e and bcdfa6e5-2d0e-4c3e-9646-c192cdab199c.\ No newline at end of file
.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff+20 −0
diff --git a/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diffnew file mode 100644index 0000000..fac07bc--- /dev/null+++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff@@ -0,0 +1,20 @@1+From bd9acf03bcf335511a340a83e26a7bae268d2d98 Mon Sep 17 00:00:00 20012+From: z <gl0w@world>3+Date: Tue, 8 Sep 2026 04:59:05 +01004+Subject: [PATCH] dogfood: tiny note 15+6+---7+ dogfood-note.md | 1 +8+ 1 file changed, 1 insertion(+)9+ create mode 100644 dogfood-note.md10+11+diff --git a/dogfood-note.md b/dogfood-note.md12+new file mode 10064413+index 0000000..d0e4d2314+--- /dev/null15++++ b/dogfood-note.md16+@@ -0,0 +1 @@17++# dogfood note18+-- 19+2.47.320+
.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md+13 −0
diff --git a/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.mdnew file mode 100644index 0000000..225b567--- /dev/null+++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md@@ -0,0 +1,13 @@1+---2+id: dc85f9c1-a63c-47fe-a67c-3357542823663+title: dogfood t14+state: merged5+base: main6+head: dogfood-t17+draft: false8+issues: []9+ci: pass10+created: 2026-09-08T03:59:05Z11+updated: 2026-09-08T03:59:16Z12+---13+first tiny pr\ No newline at end of file
.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/c661a9bb-2756-4104-9165-daf53f1bd877.md+7 −0
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.mdnew file mode 100644index 0000000..e634bba--- /dev/null+++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/c661a9bb-2756-4104-9165-daf53f1bd877.md@@ -0,0 +1,7 @@1+---2+id: c661a9bb-2756-4104-9165-daf53f1bd8773+author: z4+state: approved5+created: 2026-09-08T03:59:11Z6+---7+lgtm dogfood\ No newline at end of file
.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/ce1318c7-5211-4291-8871-69296cb24ae1.md+7 −0
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.mdnew file mode 100644index 0000000..e86b33e--- /dev/null+++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/ce1318c7-5211-4291-8871-69296cb24ae1.md@@ -0,0 +1,7 @@1+---2+id: ce1318c7-5211-4291-8871-69296cb24ae13+author: null4+state: null5+created: 2026-09-08T03:59:11Z6+---7+nit: tiny\ No newline at end of file
Cargo.lock+10 −0
diff --git a/Cargo.lock b/Cargo.lockindex 2f12b83..2ca8022 100644--- a/Cargo.lock+++ b/Cargo.lock@@ -30,6 +30,16 @@ dependencies = [3030  "uuid",3131 ]3232 33+[[package]]34+name = "f0rge-cli"35+version = "0.1.0"36+dependencies = [37+ "f0rge-core",38+ "serde",39+ "serde_json",40+ "uuid",41+]42+3343 [[package]]3444 name = "f0rge-core"3545 version = "0.1.0"
Cargo.toml+15 −0
diff --git a/Cargo.toml b/Cargo.tomlindex b501192..b400345 100644--- a/Cargo.toml+++ b/Cargo.toml@@ -1,3 +1,12 @@1+[package]2+name = "f0rge"3+version = "0.1.0"4+edition = "2021"5+6+[[bin]]7+name = "f0rge"8+path = "crates/cli/src/main.rs"9+110 [workspace]211 resolver = "2"312 members = ["crates/core", "crates/cli"]@@ -9,6 +18,12 @@ serde_json = "=1.0.151"918 yaml_serde = "=0.10.7"1019 uuid = { version = "=1.26.0", features = ["v4"] }1120 21+[dependencies]22+f0rge-core = { path = "crates/core" }23+serde.workspace = true24+serde_json.workspace = true25+uuid.workspace = true26+1227 [profile.release]1328 lto = true1429 codegen-units = 1
crates/cli/Cargo.toml+1 −1
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.tomlindex 9ebfd06..59fec0f 100644--- a/crates/cli/Cargo.toml+++ b/crates/cli/Cargo.toml@@ -1,5 +1,5 @@11 [package]2-name = "f0rge"2+name = "f0rge-cli"33 version = "0.1.0"44 edition = "2021"55 
crates/cli/src/cmd/merge.rs+48 −7
diff --git a/crates/cli/src/cmd/merge.rs b/crates/cli/src/cmd/merge.rsindex 899eb66..e869c17 100644--- a/crates/cli/src/cmd/merge.rs+++ b/crates/cli/src/cmd/merge.rs@@ -87,8 +87,32 @@ fn record_name(fg: &Forge, now: &str) -> PathBuf {8787     }8888 }8989 90+/// ONE canonical byte-form for hashing (SEC-08): CRLF->LF so folder91+/// patch.diff bytes and branch `format-patch --stdout` bytes hash equal.92+fn canon_patch(p: &[u8]) -> Vec<u8> {93+    let mut out = Vec::with_capacity(p.len());94+    let mut i = 0;95+    while i < p.len() {96+        if p[i] == b'\r' {97+            if i + 1 < p.len() && p[i + 1] == b'\n' {98+                out.push(b'\n');99+                i += 2;100+            } else {101+                out.push(b'\n');102+                i += 1;103+            }104+        } else {105+            out.push(p[i]);106+            i += 1;107+        }108+    }109+    out110+}111+90112 /// patch-id via git; fallback hash-object (both deterministic, no new deps).91113 fn patch_hash(repo: &Path, patch: &[u8]) -> String {114+    let canon = canon_patch(patch);115+    let patch = &canon;92116     let mut c = Command::new("git");93117     c.arg("patch-id").arg("--stable").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());94118     if let Ok(mut kid) = c.spawn() {@@ -121,7 +145,8 @@ fn patch_hash(repo: &Path, patch: &[u8]) -> String {121145     format!("len{}", patch.len())122146 }123147 124-/// SEC-08: any merge record whose body pins this patch hash?148+/// SEC-08: successful merge record whose body pins this patch hash?149+/// Pass+applied only: fail records must not block retry.125150 fn landed(fg: &Forge, hash: &str) -> Option<MergeRecord> {126151     let rd = std::fs::read_dir(fg.root().join("merges")).ok()?;127152     let want = format!("patch: {hash}");@@ -136,7 +161,7 @@ fn landed(fg: &Forge, hash: &str) -> Option<MergeRecord> {136161         }137162         let rel = Path::new("merges").join(&name);138163         let p: f0rge_core::Parsed<MergeRecord> = fg.load_entity(&rel).ok()?;139-        if p.body.lines().any(|l| l.trim() == want) {164+        if p.value.verdict == "pass" && p.value.applied && p.body.lines().any(|l| l.trim() == want) {140165             return Some(p.value);141166         }142167     }@@ -147,6 +172,8 @@ struct Payload {147172     patch: Vec<u8>,148173     id: String,149174     base: String,175+    /// head branch (branch flow) or payload pr.head (folder flow), for canon flip.176+    head: Option<String>,150177     /// payload pr.md on disk (outside .forge), if any, to flip on success.151178     pr_path: Option<PathBuf>,152179 }@@ -175,11 +202,11 @@ fn from_folder(dir: &Path, base_flag: Option<&str>) -> Result<Payload, CliError>175202     if patch.iter().all(|b| b.is_ascii_whitespace()) {176203         return Err(CliError::Runtime("patch.diff: empty patch".to_string()));177204     }178-    let (id, base, pr_path) = match payload_pr(dir)? {179-        Some(pr) => (pr.id.clone(), base_flag.unwrap_or(&pr.base).to_string(), Some(dir.join("pr.md"))),180-        None => (uuid::Uuid::new_v4().to_string(), base_flag.unwrap_or("main").to_string(), None),205+    let (id, base, head, pr_path) = match payload_pr(dir)? {206+        Some(pr) => (pr.id.clone(), base_flag.unwrap_or(&pr.base).to_string(), Some(pr.head.clone()), Some(dir.join("pr.md"))),207+        None => (uuid::Uuid::new_v4().to_string(), base_flag.unwrap_or("main").to_string(), None, None),181208     };182-    Ok(Payload { patch, id, base: safe_ref(&base, "base")?, pr_path })209+    Ok(Payload { patch, id, base: safe_ref(&base, "base")?, head, pr_path })183210 }184211 185212 fn find_patch(dir: &Path) -> Option<PathBuf> {@@ -271,7 +298,7 @@ fn from_branch(repo: &Path, fg: &Forge, branch: &str, base_flag: Option<&str>) -271298     if o.stdout.iter().all(|b| b.is_ascii_whitespace()) {272299         return Err(CliError::Runtime(format!("empty patch: nothing in {spec}")));273300     }274-    Ok(Payload { patch: o.stdout, id, base, pr_path: None })301+    Ok(Payload { patch: o.stdout, id, base, head: Some(branch.clone()), pr_path: None })275302 }276303 277304 /// Minimal CI: steps in filename order, cwd=worktree, fail-fast (M4 ritual).@@ -395,6 +422,13 @@ pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {395422         if let Some(t) = &zip_tmp {396423             let _ = std::fs::remove_dir_all(t);397424         }425+        flip_canon(fg, &payload.id, "merged", Some("pass"));426+        flip_canon(fg, &rec.pr, "merged", Some("pass"));427+        if let Some(h) = &payload.head {428+            if let Some((pr, _)) = pr_by_head(fg, h) {429+                flip_canon(fg, &pr.id, "merged", Some("pass"));430+            }431+        }398432         if json {399433             output::emit_json(&rec, jq.as_deref());400434         } else {@@ -489,6 +523,13 @@ pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {489523         let _ = std::fs::remove_dir_all(t);490524     }491525     flip_canon(fg, &payload.id, "merged", Some("pass"));526+    if let Some(h) = &payload.head {527+        if let Some((pr, _)) = pr_by_head(fg, h) {528+            if pr.id != payload.id {529+                flip_canon(fg, &pr.id, "merged", Some("pass"));530+            }531+        }532+    }492533     if let Some(p) = &payload.pr_path {493534         if fg.root().join(fg.pr_file(&payload.id)) != *p {494535             let _ = flip_pr_file(p, "merged", Some("pass"));
crates/cli/src/cmd/pr.rs+101 −4
diff --git a/crates/cli/src/cmd/pr.rs b/crates/cli/src/cmd/pr.rsindex 8e763d3..9459f63 100644--- a/crates/cli/src/cmd/pr.rs+++ b/crates/cli/src/cmd/pr.rs@@ -311,6 +311,86 @@ fn merge_record_name(fg: &Forge, now: &str) -> PathBuf {311311     }312312 }313313 314+/// ONE canonical byte-form for hashing (SEC-08): CRLF->LF so folder315+/// patch.diff bytes and branch `format-patch --stdout` bytes hash equal.316+fn canon_patch(p: &[u8]) -> Vec<u8> {317+    let mut out = Vec::with_capacity(p.len());318+    let mut i = 0;319+    while i < p.len() {320+        if p[i] == b'\r' {321+            if i + 1 < p.len() && p[i + 1] == b'\n' {322+                out.push(b'\n');323+                i += 2;324+            } else {325+                out.push(b'\n');326+                i += 1;327+            }328+        } else {329+            out.push(p[i]);330+            i += 1;331+        }332+    }333+    out334+}335+336+/// patch-id via git; fallback hash-object (both deterministic, no new deps).337+fn patch_hash(repo: &Path, patch: &[u8]) -> String {338+    let canon = canon_patch(patch);339+    let patch = &canon;340+    let mut c = Command::new("git");341+    c.arg("patch-id").arg("--stable").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());342+    if let Ok(mut kid) = c.spawn() {343+        use std::io::Write;344+        if let Some(mut stdin) = kid.stdin.take() {345+            let _ = stdin.write_all(patch);346+        }347+        if let Ok(o) = kid.wait_with_output() {348+            if o.status.success() {349+                let t = String::from_utf8_lossy(&o.stdout);350+                if let Some(h) = t.split_whitespace().next().filter(|h| h.len() >= 8) {351+                    return h.to_string();352+                }353+            }354+        }355+    }356+    let mut c2 = Command::new("git");357+    c2.arg("hash-object").arg("--stdin").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());358+    if let Ok(mut kid) = c2.spawn() {359+        use std::io::Write;360+        if let Some(mut stdin) = kid.stdin.take() {361+            let _ = stdin.write_all(patch);362+        }363+        if let Ok(o) = kid.wait_with_output() {364+            if o.status.success() {365+                return String::from_utf8_lossy(&o.stdout).trim().to_string();366+            }367+        }368+    }369+    format!("len{}", patch.len())370+}371+372+/// SEC-08: successful merge record whose body pins this patch hash?373+fn landed(fg: &Forge, hash: &str) -> Option<MergeRecord> {374+    let rd = std::fs::read_dir(fg.root().join("merges")).ok()?;375+    let want = format!("patch: {hash}");376+    for e in rd.flatten() {377+        let ft = e.file_type().ok()?;378+        if ft.is_symlink() || !ft.is_file() {379+            continue;380+        }381+        let name = e.file_name().to_string_lossy().into_owned();382+        if !name.ends_with(".md") || name.starts_with('.') {383+            continue;384+        }385+        let rel = Path::new("merges").join(&name);386+        let p: f0rge_core::Parsed<MergeRecord> = fg.load_entity(&rel).ok()?;387+        if p.value.verdict == "pass" && p.value.applied && p.body.lines().any(|l| l.trim() == want) {388+            return Some(p.value);389+        }390+    }391+    None392+}393+314394 fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {315395     let s = p.get(1).ok_or_else(|| CliError::Usage("pr merge <id|number>".to_string()))?;316396     let all = load_all(fg)?;@@ -318,11 +398,26 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C318398     let parsed = fg.load_entity::<Pr>(&fg.pr_file(&id)).map_err(CliError::from)?;319399     output::warn_schema(&parsed.schema_note);320400     let mut pr = parsed.value;401+    let patch = fg.read_capped(&fg.pr_patch(&id)).map_err(CliError::from)?;402+    let repo = repo_dir(fg)?;403+    let hash = patch_hash(&repo, &patch);404+    if let Some(rec) = landed(fg, &hash) {405+        if pr.state == "open" {406+            pr.state = "merged".to_string();407+            pr.ci = Some("pass".to_string());408+            pr.updated = Some(now_rfc3339());409+            let _ = fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body);410+        }411+        if json {412+            output::emit_json(&rec, jq);413+        } else {414+            println!("already landed: {}", rec.pr);415+        }416+        return Ok(());417+    }321418     if pr.state != "open" {322419         return Err(CliError::Runtime(format!("pr {id}: already {}", pr.state)));323420     }324-    let patch = fg.read_capped(&fg.pr_patch(&id)).map_err(CliError::from)?;325-    let repo = repo_dir(fg)?;326421     let n = TMP_CTR.fetch_add(1, Ordering::Relaxed);327422     let pid = std::process::id();328423     let tmp = std::env::temp_dir().join(format!("f0rge-pr-{pid}-{n}"));@@ -347,6 +442,7 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C347442     if o.status.success() {348443         cleanup(None);349444         pr.state = "merged".to_string();445+        pr.ci = Some("pass".to_string());350446         pr.updated = Some(now.clone());351447         fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body).map_err(CliError::from)?;352448         let rec = MergeRecord {@@ -357,7 +453,7 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C357453             created: now,358454         };359455         let rel = merge_record_name(fg, &rec.created);360-        fg.save_entity(&rel, &rec, "clean apply\n").map_err(CliError::from)?;456+        fg.save_entity(&rel, &rec, &format!("clean apply\npatch: {hash}\n")).map_err(CliError::from)?;361457         let all2 = load_all(fg)?;362458         let num = numbers(&all2).get(&id).copied().unwrap_or(1);363459         if json {@@ -368,11 +464,12 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C368464         Ok(())369465     } else {370466         let files = conflict_files(&String::from_utf8_lossy(&o.stderr));371-        let body = if files.is_empty() {467+        let base_body = if files.is_empty() {372468             "patch does not apply\n".to_string()373469         } else {374470             format!("conflict: {}\n", files.join(", "))375471         };472+        let body = format!("{base_body}patch: {hash}\n");376473         let rec = MergeRecord {377474             pr: id.clone(),378475             applied: false,
crates/cli/tests/sec08.rs+94 −0
diff --git a/crates/cli/tests/sec08.rs b/crates/cli/tests/sec08.rsnew file mode 100644index 0000000..4bf3b4a--- /dev/null+++ b/crates/cli/tests/sec08.rs@@ -0,0 +1,94 @@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+}