.forge/prs/78e0c97e-6ae3-488e-9e4a-acdf9b025f19/patch.diff 22.3 KiB 612 lines Raw
1 From d09f83d487315603fd331c80524546cb5efefc5e Mon Sep 17 00:00:00 20012 From: gl0w <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: gl0w <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: gl0w <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: gl0w <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