#4 dog-pr: fix #1 install + #3 merge SEC-08
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.
Patch
Cargo.lock+10 −0--- 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--- 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 −3--- 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 6-- 76 (ref bcdfa6e5-2d0e-4c3e-9646-c192cdab199c)8---97 .forge/merges/2026-09-08T03-59-16Z.md | 9 ++108 .../patch.diff | 20 ++++119 .../pr.md | 13 +++1210 .../patch.diff | 20 ++++1311 .../pr.md | 13 +++1412 .../c661a9bb-2756-4104-9165-daf53f1bd877.md | 7 ++1513 .../ce1318c7-5211-4291-8871-69296cb24ae1.md | 7 ++1614 crates/cli/src/cmd/merge.rs | 55 +++++++--1715 crates/cli/src/cmd/pr.rs | 105 +++++++++++++++++-1816 crates/cli/tests/sec08.rs | 94 ++++++++++++++++1917 10 files changed, 332 insertions(+), 11 deletions(-)2018 create mode 100644 .forge/merges/2026-09-08T03-59-16Z.md2119 create mode 100644 .forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/patch.diff2220 create mode 100644 .forge/prs/5feefe83-f09c-455b-b6d1-148c6f88faeb/pr.md2321 create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff2422 create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/pr.md2523 create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/c661a9bb-2756-4104-9165-daf53f1bd877.md2624 create mode 100644 .forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/ce1318c7-5211-4291-8871-69296cb24ae1.md2725 create mode 100644 crates/cli/tests/sec08.rs
.forge/merges/2026-09-08T03-59-16Z.md+9 −0--- /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--- /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: gl0w <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--- /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
.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/patch.diff+20 −0--- /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: gl0w <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--- /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
.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/c661a9bb-2756-4104-9165-daf53f1bd877.md+7 −0--- /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
.forge/prs/dc85f9c1-a63c-47fe-a67c-335754282366/reviews/ce1318c7-5211-4291-8871-69296cb24ae1.md+7 −0--- /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
crates/cli/src/cmd/merge.rs+48 −7--- 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--- 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 −1--- /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+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.