From d09f83d487315603fd331c80524546cb5efefc5e Mon Sep 17 00:00:00 2001 From: gl0w Date: Tue, 8 Sep 2026 04:59:36 +0100 Subject: [PATCH 1/2] fix #1: root package+workspace for cargo install (ref 0724688e-7d6d-4610-b36f-62bf9fd51c3e) --- Cargo.lock | 10 ++++++++++ Cargo.toml | 15 +++++++++++++++ crates/cli/Cargo.toml | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 2f12b83..2ca8022 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "f0rge-cli" +version = "0.1.0" +dependencies = [ + "f0rge-core", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "f0rge-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b501192..b400345 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,12 @@ +[package] +name = "f0rge" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "f0rge" +path = "crates/cli/src/main.rs" + [workspace] resolver = "2" members = ["crates/core", "crates/cli"] @@ -9,6 +18,12 @@ serde_json = "=1.0.151" yaml_serde = "=0.10.7" uuid = { version = "=1.26.0", features = ["v4"] } +[dependencies] +f0rge-core = { path = "crates/core" } +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true + [profile.release] lto = true codegen-units = 1 diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 9ebfd06..59fec0f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "f0rge" +name = "f0rge-cli" version = "0.1.0" edition = "2021" -- 2.47.3 From f252557ace5fa49d99d4b8d27c07e85e7c9d1ecd Mon Sep 17 00:00:00 2001 From: gl0w Date: Tue, 8 Sep 2026 04:59:39 +0100 Subject: [PATCH 2/2] fix #3: canonical patch hash + pr merge already-landed (ref bcdfa6e5-2d0e-4c3e-9646-c192cdab199c) --- .forge/merges/2026-09-08T03-59-16Z.md | 9 ++ .../patch.diff | 20 ++++ .../pr.md | 13 +++ .../patch.diff | 20 ++++ .../pr.md | 13 +++ .../c661a9bb-2756-4104-9165-daf53f1bd877.md | 7 ++ .../ce1318c7-5211-4291-8871-69296cb24ae1.md | 7 ++ crates/cli/src/cmd/merge.rs | 55 +++++++-- crates/cli/src/cmd/pr.rs | 105 +++++++++++++++++- crates/cli/tests/sec08.rs | 94 ++++++++++++++++ 10 files changed, 332 insertions(+), 11 deletions(-) create mode 100644 .forge/merges/2026-09-08T03-59-16Z.md create mode 100644 .forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff create mode 100644 .forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/c661a9bb-2756-4104-9165-daf53f1bd877.md create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/ce1318c7-5211-4291-8871-69296cb24ae1.md create mode 100644 crates/cli/tests/sec08.rs diff --git a/.forge/merges/2026-09-08T03-59-16Z.md b/.forge/merges/2026-09-08T03-59-16Z.md new file mode 100644 index 0000000..ef48661 --- /dev/null +++ b/.forge/merges/2026-09-08T03-59-16Z.md @@ -0,0 +1,9 @@ +--- +pr: dc85f9c1-a63c-47fe-a67c-335754282366 +applied: true +verdict: pass +resolved: [] +created: 2026-09-08T03:59:16Z +--- +clean apply +patch: 5f61af4a30532025a3d213d4349698db710fcda8 diff --git a/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff new file mode 100644 index 0000000..4c7a2eb --- /dev/null +++ b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff @@ -0,0 +1,20 @@ +From 70c56687c6c22dc596686b97709fb765a220a163 Mon Sep 17 00:00:00 2001 +From: gl0w +Date: Tue, 8 Sep 2026 04:59:05 +0100 +Subject: [PATCH] dogfood: tiny note 2 + +--- + dogfood-note2.md | 1 + + 1 file changed, 1 insertion(+) + create mode 100644 dogfood-note2.md + +diff --git a/dogfood-note2.md b/dogfood-note2.md +new file mode 100644 +index 0000000..91993fd +--- /dev/null ++++ b/dogfood-note2.md +@@ -0,0 +1 @@ ++# second +-- +2.47.3 + diff --git a/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md new file mode 100644 index 0000000..b550184 --- /dev/null +++ b/.forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md @@ -0,0 +1,13 @@ +--- +id: 5feefe83-f09c-455b-b6d1-148c6f88faeb +title: dogfood t2 +state: closed +base: main +head: dogfood-t2 +draft: false +issues: [] +ci: null +created: 2026-09-08T03:59:05Z +updated: 2026-09-08T03:59:11Z +--- +second tiny pr \ No newline at end of file diff --git a/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff new file mode 100644 index 0000000..ac149ae --- /dev/null +++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff @@ -0,0 +1,20 @@ +From bd9acf03bcf335511a340a83e26a7bae268d2d98 Mon Sep 17 00:00:00 2001 +From: gl0w +Date: Tue, 8 Sep 2026 04:59:05 +0100 +Subject: [PATCH] dogfood: tiny note 1 + +--- + dogfood-note.md | 1 + + 1 file changed, 1 insertion(+) + create mode 100644 dogfood-note.md + +diff --git a/dogfood-note.md b/dogfood-note.md +new file mode 100644 +index 0000000..d0e4d23 +--- /dev/null ++++ b/dogfood-note.md +@@ -0,0 +1 @@ ++# dogfood note +-- +2.47.3 + diff --git a/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md new file mode 100644 index 0000000..225b567 --- /dev/null +++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md @@ -0,0 +1,13 @@ +--- +id: dc85f9c1-a63c-47fe-a67c-335754282366 +title: dogfood t1 +state: merged +base: main +head: dogfood-t1 +draft: false +issues: [] +ci: pass +created: 2026-09-08T03:59:05Z +updated: 2026-09-08T03:59:16Z +--- +first tiny pr \ No newline at end of file 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.md new file mode 100644 index 0000000..e634bba --- /dev/null +++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/c661a9bb-2756-4104-9165-daf53f1bd877.md @@ -0,0 +1,7 @@ +--- +id: c661a9bb-2756-4104-9165-daf53f1bd877 +author: z +state: approved +created: 2026-09-08T03:59:11Z +--- +lgtm dogfood \ No newline at end of file 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.md new file mode 100644 index 0000000..e86b33e --- /dev/null +++ b/.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/ce1318c7-5211-4291-8871-69296cb24ae1.md @@ -0,0 +1,7 @@ +--- +id: ce1318c7-5211-4291-8871-69296cb24ae1 +author: null +state: null +created: 2026-09-08T03:59:11Z +--- +nit: tiny \ No newline at end of file diff --git a/crates/cli/src/cmd/merge.rs b/crates/cli/src/cmd/merge.rs index 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 { } } +/// ONE canonical byte-form for hashing (SEC-08): CRLF->LF so folder +/// patch.diff bytes and branch `format-patch --stdout` bytes hash equal. +fn canon_patch(p: &[u8]) -> Vec { + let mut out = Vec::with_capacity(p.len()); + let mut i = 0; + while i < p.len() { + if p[i] == b'\r' { + if i + 1 < p.len() && p[i + 1] == b'\n' { + out.push(b'\n'); + i += 2; + } else { + out.push(b'\n'); + i += 1; + } + } else { + out.push(p[i]); + i += 1; + } + } + out +} + /// patch-id via git; fallback hash-object (both deterministic, no new deps). fn patch_hash(repo: &Path, patch: &[u8]) -> String { + let canon = canon_patch(patch); + let patch = &canon; let mut c = Command::new("git"); c.arg("patch-id").arg("--stable").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()); if let Ok(mut kid) = c.spawn() { @@ -121,7 +145,8 @@ fn patch_hash(repo: &Path, patch: &[u8]) -> String { format!("len{}", patch.len()) } -/// SEC-08: any merge record whose body pins this patch hash? +/// SEC-08: successful merge record whose body pins this patch hash? +/// Pass+applied only: fail records must not block retry. fn landed(fg: &Forge, hash: &str) -> Option { let rd = std::fs::read_dir(fg.root().join("merges")).ok()?; let want = format!("patch: {hash}"); @@ -136,7 +161,7 @@ fn landed(fg: &Forge, hash: &str) -> Option { } let rel = Path::new("merges").join(&name); let p: f0rge_core::Parsed = fg.load_entity(&rel).ok()?; - if p.body.lines().any(|l| l.trim() == want) { + if p.value.verdict == "pass" && p.value.applied && p.body.lines().any(|l| l.trim() == want) { return Some(p.value); } } @@ -147,6 +172,8 @@ struct Payload { patch: Vec, id: String, base: String, + /// head branch (branch flow) or payload pr.head (folder flow), for canon flip. + head: Option, /// payload pr.md on disk (outside .forge), if any, to flip on success. pr_path: Option, } @@ -175,11 +202,11 @@ fn from_folder(dir: &Path, base_flag: Option<&str>) -> Result if patch.iter().all(|b| b.is_ascii_whitespace()) { return Err(CliError::Runtime("patch.diff: empty patch".to_string())); } - let (id, base, pr_path) = match payload_pr(dir)? { - Some(pr) => (pr.id.clone(), base_flag.unwrap_or(&pr.base).to_string(), Some(dir.join("pr.md"))), - None => (uuid::Uuid::new_v4().to_string(), base_flag.unwrap_or("main").to_string(), None), + let (id, base, head, pr_path) = match payload_pr(dir)? { + Some(pr) => (pr.id.clone(), base_flag.unwrap_or(&pr.base).to_string(), Some(pr.head.clone()), Some(dir.join("pr.md"))), + None => (uuid::Uuid::new_v4().to_string(), base_flag.unwrap_or("main").to_string(), None, None), }; - Ok(Payload { patch, id, base: safe_ref(&base, "base")?, pr_path }) + Ok(Payload { patch, id, base: safe_ref(&base, "base")?, head, pr_path }) } fn find_patch(dir: &Path) -> Option { @@ -271,7 +298,7 @@ fn from_branch(repo: &Path, fg: &Forge, branch: &str, base_flag: Option<&str>) - if o.stdout.iter().all(|b| b.is_ascii_whitespace()) { return Err(CliError::Runtime(format!("empty patch: nothing in {spec}"))); } - Ok(Payload { patch: o.stdout, id, base, pr_path: None }) + Ok(Payload { patch: o.stdout, id, base, head: Some(branch.clone()), pr_path: None }) } /// 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> { if let Some(t) = &zip_tmp { let _ = std::fs::remove_dir_all(t); } + flip_canon(fg, &payload.id, "merged", Some("pass")); + flip_canon(fg, &rec.pr, "merged", Some("pass")); + if let Some(h) = &payload.head { + if let Some((pr, _)) = pr_by_head(fg, h) { + flip_canon(fg, &pr.id, "merged", Some("pass")); + } + } if json { output::emit_json(&rec, jq.as_deref()); } else { @@ -489,6 +523,13 @@ pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> { let _ = std::fs::remove_dir_all(t); } flip_canon(fg, &payload.id, "merged", Some("pass")); + if let Some(h) = &payload.head { + if let Some((pr, _)) = pr_by_head(fg, h) { + if pr.id != payload.id { + flip_canon(fg, &pr.id, "merged", Some("pass")); + } + } + } if let Some(p) = &payload.pr_path { if fg.root().join(fg.pr_file(&payload.id)) != *p { let _ = flip_pr_file(p, "merged", Some("pass")); diff --git a/crates/cli/src/cmd/pr.rs b/crates/cli/src/cmd/pr.rs index 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 { } } +/// ONE canonical byte-form for hashing (SEC-08): CRLF->LF so folder +/// patch.diff bytes and branch `format-patch --stdout` bytes hash equal. +fn canon_patch(p: &[u8]) -> Vec { + let mut out = Vec::with_capacity(p.len()); + let mut i = 0; + while i < p.len() { + if p[i] == b'\r' { + if i + 1 < p.len() && p[i + 1] == b'\n' { + out.push(b'\n'); + i += 2; + } else { + out.push(b'\n'); + i += 1; + } + } else { + out.push(p[i]); + i += 1; + } + } + out +} + +/// patch-id via git; fallback hash-object (both deterministic, no new deps). +fn patch_hash(repo: &Path, patch: &[u8]) -> String { + let canon = canon_patch(patch); + let patch = &canon; + let mut c = Command::new("git"); + c.arg("patch-id").arg("--stable").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()); + if let Ok(mut kid) = c.spawn() { + use std::io::Write; + if let Some(mut stdin) = kid.stdin.take() { + let _ = stdin.write_all(patch); + } + if let Ok(o) = kid.wait_with_output() { + if o.status.success() { + let t = String::from_utf8_lossy(&o.stdout); + if let Some(h) = t.split_whitespace().next().filter(|h| h.len() >= 8) { + return h.to_string(); + } + } + } + } + let mut c2 = Command::new("git"); + c2.arg("hash-object").arg("--stdin").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()); + if let Ok(mut kid) = c2.spawn() { + use std::io::Write; + if let Some(mut stdin) = kid.stdin.take() { + let _ = stdin.write_all(patch); + } + if let Ok(o) = kid.wait_with_output() { + if o.status.success() { + return String::from_utf8_lossy(&o.stdout).trim().to_string(); + } + } + } + format!("len{}", patch.len()) +} + +/// SEC-08: successful merge record whose body pins this patch hash? +fn landed(fg: &Forge, hash: &str) -> Option { + let rd = std::fs::read_dir(fg.root().join("merges")).ok()?; + let want = format!("patch: {hash}"); + for e in rd.flatten() { + let ft = e.file_type().ok()?; + if ft.is_symlink() || !ft.is_file() { + continue; + } + let name = e.file_name().to_string_lossy().into_owned(); + if !name.ends_with(".md") || name.starts_with('.') { + continue; + } + let rel = Path::new("merges").join(&name); + let p: f0rge_core::Parsed = fg.load_entity(&rel).ok()?; + if p.value.verdict == "pass" && p.value.applied && p.body.lines().any(|l| l.trim() == want) { + return Some(p.value); + } + } + None +} + fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> { let s = p.get(1).ok_or_else(|| CliError::Usage("pr merge ".to_string()))?; let all = load_all(fg)?; @@ -318,11 +398,26 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C let parsed = fg.load_entity::(&fg.pr_file(&id)).map_err(CliError::from)?; output::warn_schema(&parsed.schema_note); let mut pr = parsed.value; + let patch = fg.read_capped(&fg.pr_patch(&id)).map_err(CliError::from)?; + let repo = repo_dir(fg)?; + let hash = patch_hash(&repo, &patch); + if let Some(rec) = landed(fg, &hash) { + if pr.state == "open" { + pr.state = "merged".to_string(); + pr.ci = Some("pass".to_string()); + pr.updated = Some(now_rfc3339()); + let _ = fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body); + } + if json { + output::emit_json(&rec, jq); + } else { + println!("already landed: {}", rec.pr); + } + return Ok(()); + } if pr.state != "open" { return Err(CliError::Runtime(format!("pr {id}: already {}", pr.state))); } - let patch = fg.read_capped(&fg.pr_patch(&id)).map_err(CliError::from)?; - let repo = repo_dir(fg)?; let n = TMP_CTR.fetch_add(1, Ordering::Relaxed); let pid = std::process::id(); 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<(), C if o.status.success() { cleanup(None); pr.state = "merged".to_string(); + pr.ci = Some("pass".to_string()); pr.updated = Some(now.clone()); fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body).map_err(CliError::from)?; let rec = MergeRecord { @@ -357,7 +453,7 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C created: now, }; let rel = merge_record_name(fg, &rec.created); - fg.save_entity(&rel, &rec, "clean apply\n").map_err(CliError::from)?; + fg.save_entity(&rel, &rec, &format!("clean apply\npatch: {hash}\n")).map_err(CliError::from)?; let all2 = load_all(fg)?; let num = numbers(&all2).get(&id).copied().unwrap_or(1); if json { @@ -368,11 +464,12 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C Ok(()) } else { let files = conflict_files(&String::from_utf8_lossy(&o.stderr)); - let body = if files.is_empty() { + let base_body = if files.is_empty() { "patch does not apply\n".to_string() } else { format!("conflict: {}\n", files.join(", ")) }; + let body = format!("{base_body}patch: {hash}\n"); let rec = MergeRecord { pr: id.clone(), applied: false, diff --git a/crates/cli/tests/sec08.rs b/crates/cli/tests/sec08.rs new file mode 100644 index 0000000..4bf3b4a --- /dev/null +++ b/crates/cli/tests/sec08.rs @@ -0,0 +1,94 @@ +#![forbid(unsafe_code)] +//! SEC-08: already-landed parity for pr merge / merge. +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +static CTR: AtomicU64 = AtomicU64::new(0); + +fn bin() -> String { + env!("CARGO_BIN_EXE_f0rge").to_string() +} + +fn tmp_repo(tag: &str) -> PathBuf { + let n = CTR.fetch_add(1, Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("f0rge-sec08-{}-{}-{n}", std::process::id(), tag)); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn git(repo: &Path, args: &[&str]) { + let o = Command::new("git").args(args).current_dir(repo).output().unwrap(); + assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr)); +} + +fn f0rge(repo: &Path, args: &[&str]) -> (i32, String, String) { + let o = Command::new(bin()).args(args).current_dir(repo).output().unwrap(); + ( + o.status.code().unwrap_or(-1), + String::from_utf8_lossy(&o.stdout).into_owned(), + String::from_utf8_lossy(&o.stderr).into_owned(), + ) +} + +fn merges(repo: &Path) -> Vec { + std::fs::read_dir(repo.join(".forge").join("merges")) + .unwrap() + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().map(|x| x == "md").unwrap_or(false)) + .collect() +} + +#[test] +fn sec08_double_merge_is_already_landed() { + let repo = tmp_repo("dbl"); + git(&repo, &["init"]); + git(&repo, &["config", "user.email", "t@t.t"]); + git(&repo, &["config", "user.name", "t"]); + git(&repo, &["config", "commit.gpgsign", "false"]); + std::fs::write(repo.join("a.txt"), "line1\nline2\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "init"]); + git(&repo, &["checkout", "-b", "main"]); + let (c, _, e) = f0rge(&repo, &["init"]); + assert_eq!(c, 0, "init: {e}"); + git(&repo, &["checkout", "-b", "feat"]); + std::fs::write(repo.join("a.txt"), "line1-feat\nline2\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "feat"]); + git(&repo, &["checkout", "main"]); + let (c, o, e) = f0rge(&repo, &["pr", "create", "--base", "main", "--head", "feat", "--title", "T", "--json"]); + assert_eq!(c, 0, "create: {e}"); + let v: serde_json::Value = serde_json::from_str(o.lines().last().unwrap().trim()).unwrap(); + let id = v["databaseId"].as_str().unwrap().to_string(); + // first land via pr merge. + let (c, o, e) = f0rge(&repo, &["pr", "merge", &id]); + assert_eq!(c, 0, "first pr merge: {e} {o}"); + assert_eq!(merges(&repo).len(), 1); + let prmd = std::fs::read_to_string(repo.join(".forge").join("prs").join(&id).join("pr.md")).unwrap(); + assert!(prmd.contains("state: merged"), "pr flipped:\n{prmd}"); + assert!(prmd.contains("ci: pass"), "ci pass:\n{prmd}"); + let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap(); + assert!(rec.contains("patch: "), "record pins hash:\n{rec}"); + // second pr merge: already landed, exit 0. + let (c, o, e) = f0rge(&repo, &["pr", "merge", &id]); + assert_eq!(c, 0, "second pr merge exit 0, got {c} err: {e}"); + assert!(o.contains("already landed"), "out: {o}"); + assert!(o.contains(&id), "out names pr: {o}"); + assert_eq!(e.lines().count(), 0, "no stderr: {e}"); + assert_eq!(merges(&repo).len(), 1, "no duplicate record"); + // merge on canonical folder: already landed, exit 0. + let folder = repo.join(".forge").join("prs").join(&id).to_string_lossy().into_owned(); + let (c, o, e) = f0rge(&repo, &["merge", &folder]); + assert_eq!(c, 0, "merge folder repeat: {e}"); + assert!(o.contains("already landed"), "out: {o}"); + assert_eq!(merges(&repo).len(), 1); + // merge branch repeat: already landed, exit 0. + let (c, o, e) = f0rge(&repo, &["merge", "feat"]); + assert_eq!(c, 0, "merge branch repeat: {e}"); + assert!(o.contains("already landed"), "out: {o}"); + assert_eq!(merges(&repo).len(), 1); + let _ = std::fs::remove_dir_all(&repo); +} -- 2.47.3