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