merge c8a0e434-7caa-424f-8366-ddb2bed8a9be
Message
merge c8a0e434-7caa-424f-8366-ddb2bed8a9be
Diff
crates/cli/src/cmd/merge.rs+304 −7
--- a/crates/cli/src/cmd/merge.rs+++ b/crates/cli/src/cmd/merge.rs@@ -279,17 +279,123 @@ fn pr_by_head(fg: &Forge, head: &str) -> Option<(Pr, String)> {279279 None280280 }281281 282+/// #15: PR committed on the branch tip but not yet on main (fork case).283+/// Read-only argv-only scan of the branch tree; no locks, no stored state.284+fn pr_on_branch(repo: &Path, branch: &str) -> Option<Pr> {285+ let o = Command::new("git").args(["ls-tree", "-r", "--name-only", branch, "--", ".forge/prs"]).current_dir(repo).output().ok()?;286+ if !o.status.success() {287+ return None;288+ }289+ let mut best: Option<Pr> = None;290+ for line in String::from_utf8_lossy(&o.stdout).lines() {291+ let tr = line.trim();292+ let Some(rest) = tr.strip_prefix(".forge/prs/") else { continue };293+ if !tr.ends_with("/pr.md") {294+ continue;295+ }296+ let uuid = rest.split('/').next().unwrap_or("");297+ if !f0rge_core::is_uuid4_lower(uuid) {298+ continue;299+ }300+ let arg = format!("{branch}:{tr}");301+ let s = Command::new("git").args(["show", &arg]).current_dir(repo).output().ok()?;302+ if !s.status.success() {303+ continue;304+ }305+ let content = String::from_utf8(s.stdout).ok()?;306+ let parsed = f0rge_core::parse_entity::<Pr>(&content, "pr.md").ok()?;307+ if parsed.value.head != branch || parsed.value.state != "open" {308+ continue;309+ }310+ let take = match &best {311+ None => true,312+ Some(b) => (parsed.value.created.as_str(), parsed.value.id.as_str()) < (b.created.as_str(), b.id.as_str()),313+ };314+ if take {315+ best = Some(parsed.value);316+ }317+ }318+ best319+}320+321+/// #15: uuid riding inside the branch patch itself (format-patch carries322+/// the .forge/prs/<uuid>/pr.md creation). Pure scan, no state.323+fn pr_in_patch(patch: &[u8], branch: &str) -> Option<String> {324+ let text = String::from_utf8_lossy(patch);325+ let mut uuids: Vec<String> = Vec::new();326+ // Only creations: deletions of shared pr records must not become the record id.327+ // (Skip the mail header block: its diffstat names paths without diff markers.)328+ for block in text.split("diff --git ") {329+ if !block.starts_with("a/") && !block.starts_with("\"a/") {330+ continue;331+ }332+ if block.contains("deleted file mode") || block.contains("+++ /dev/null") {333+ continue;334+ }335+ if !block.contains(".forge/prs/") || !block.contains("/pr.md") {336+ continue;337+ }338+ let mut s: &str = block;339+ loop {340+ let Some(i) = s.find(".forge/prs/") else { break };341+ let after = &s[i + ".forge/prs/".len()..];342+ if after.len() >= 36 {343+ let cand = &after[..36];344+ if f0rge_core::is_uuid4_lower(cand) && after[36..].starts_with("/pr.md") && !uuids.contains(&cand.to_string()) {345+ uuids.push(cand.to_string());346+ }347+ }348+ s = &s[i + 1..];349+ if s.is_empty() {350+ break;351+ }352+ }353+ if uuids.len() >= 8 {354+ break;355+ }356+ }357+ if uuids.is_empty() {358+ return None;359+ }360+ if uuids.len() == 1 {361+ return Some(uuids.remove(0));362+ }363+ // multiple: prefer the block whose added head line matches this branch.364+ let want = format!("+head: {branch}");365+ let want_q = format!("+head: \"{branch}\"");366+ for block in text.split("diff --git ") {367+ if !(block.contains(&want) || block.contains(&want_q)) {368+ continue;369+ }370+ for u in &uuids {371+ if block.contains(&format!(".forge/prs/{u}/pr.md")) {372+ return Some(u.clone());373+ }374+ }375+ }376+ Some(uuids.remove(0))377+}378+282379 fn from_branch(repo: &Path, fg: &Forge, branch: &str, base_flag: Option<&str>) -> Result<Payload, CliError> {283380 let branch = safe_ref(branch, "branch")?;284381 let o = git(repo, &["rev-parse", "--verify", "--quiet", &format!("{branch}^{{commit}}")])?;285382 if !o.status.success() {286383 return Err(CliError::Runtime(format!("branch {branch}: not found")));287384 }288- let (id, base) = match pr_by_head(fg, &branch) {289- Some((pr, _)) => (pr.id.clone(), base_flag.unwrap_or(&pr.base).to_string()),290- None => (uuid::Uuid::new_v4().to_string(), base_flag.unwrap_or("main").to_string()),385+ // #15: canonical uuid first (open PR here), then PR on the branch tip,386+ // then uuid riding in the patch; fresh uuid only as last resort.387+ let here = pr_by_head(fg, &branch);388+ let there = if here.is_none() { pr_on_branch(repo, &branch) } else { None };389+ let raw_base = if let Some(b) = base_flag {390+ b.to_string()391+ } else if let Some((pr, _)) = &here {392+ pr.base.clone()393+ } else if let Some(pr) = &there {394+ pr.base.clone()395+ } else {396+ "main".to_string()291397 };292- let base = safe_ref(&base, "base")?;398+ let base = safe_ref(&raw_base, "base")?;293399 let spec = format!("{base}..{branch}");294400 let o = git(repo, &["format-patch", &spec, "--stdout"])?;295401 if !o.status.success() {@@ -298,9 +404,176 @@ fn from_branch(repo: &Path, fg: &Forge, branch: &str, base_flag: Option<&str>) -298404 if o.stdout.iter().all(|b| b.is_ascii_whitespace()) {299405 return Err(CliError::Runtime(format!("empty patch: nothing in {spec}")));300406 }407+ let id = if let Some((pr, _)) = here {408+ pr.id409+ } else if let Some(pr) = there {410+ pr.id411+ } else if let Some(u) = pr_in_patch(&o.stdout, &branch) {412+ u413+ } else {414+ uuid::Uuid::new_v4().to_string()415+ };301416 Ok(Payload { patch: o.stdout, id, base, head: Some(branch.clone()), pr_path: None })302417 }303418 419+/// #17: paths under .forge/ the patch deletes (fork-stale tip diffs must420+/// not silently drop shared state). Lists, never resolves.421+fn forge_deletions(patch: &[u8]) -> Vec<String> {422+ let text = String::from_utf8_lossy(patch);423+ let mut out: Vec<String> = Vec::new();424+ for chunk in text.split("diff --git ") {425+ let Some(nl) = chunk.find('\n') else { continue };426+ let hdr = &chunk[..nl];427+ // hdr: a/<path> b/<path> (possibly quoted).428+ let mut apath: Option<String> = None;429+ for tok in hdr.split_whitespace() {430+ let q = tok.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(tok);431+ if let Some(rest) = q.strip_prefix("a/") {432+ apath = Some(rest.to_string());433+ break;434+ }435+ }436+ let Some(ap) = apath else { continue };437+ if !ap.starts_with(".forge/") {438+ continue;439+ }440+ if chunk.contains("deleted file mode") || chunk.contains("\n+++ /dev/null") || chunk.contains("+++ /dev/null") {441+ if !ap.is_empty() && !out.contains(&ap) {442+ out.push(ap);443+ }444+ }445+ if out.len() >= 8 {446+ break;447+ }448+ }449+ out450+}451+452+/// #21: one patch block for a file, capped. Context only, no engine.453+fn patch_block(patch: &[u8], file: &str) -> Option<String> {454+ let text = String::from_utf8_lossy(patch);455+ let needle = format!("a/{file}");456+ for chunk in text.split("diff --git ") {457+ let first = chunk.lines().next().unwrap_or("");458+ if first.contains(&needle) {459+ let mut s = format!("diff --git {chunk}");460+ if s.len() > 4000 {461+ s.truncate(4000);462+ s.push_str("\n...truncated");463+ }464+ return Some(s);465+ }466+ }467+ None468+}469+470+fn capped_text(t: &str) -> String {471+ const MAX_LINES: usize = 80;472+ const MAX_BYTES: usize = 4000;473+ let mut lines: Vec<&str> = t.lines().take(MAX_LINES + 1).collect();474+ let trunc_lines = lines.len() > MAX_LINES;475+ if trunc_lines {476+ lines.truncate(MAX_LINES);477+ }478+ let s = lines.join("\n");479+ let mut out = s;480+ if out.len() > MAX_BYTES {481+ out.truncate(MAX_BYTES);482+ out.push_str("\n...truncated");483+ return out;484+ }485+ if trunc_lines {486+ out.push_str("\n...truncated");487+ }488+ if !out.ends_with('\n') {489+ out.push('\n');490+ }491+ out492+}493+494+fn base_snippet(wt: &Path, file: &str) -> String {495+ if file.contains("..") || file.starts_with('/') || file.contains('\\') {496+ return "<invalid path>\n".to_string();497+ }498+ let p = wt.join(file);499+ let md = match std::fs::symlink_metadata(&p) {500+ Ok(m) => m,501+ Err(_) => return "<absent on base>\n".to_string(),502+ };503+ if md.file_type().is_symlink() || md.is_dir() {504+ return "<is a directory on base>\n".to_string();505+ }506+ if md.len() > 32768 {507+ return "<too large on base>\n".to_string();508+ }509+ match std::fs::read(&p) {510+ Ok(b) => capped_text(&String::from_utf8_lossy(&b)),511+ Err(_) => "<unreadable on base>\n".to_string(),512+ }513+}514+515+fn head_snippet(repo: &Path, branch: &str, file: &str) -> String {516+ let arg = format!("{branch}:{file}");517+ match Command::new("git").args(["show", &arg]).current_dir(repo).output() {518+ Ok(o) if o.status.success() => {519+ if o.stdout.len() > 32768 {520+ return "<too large on head>\n".to_string();521+ }522+ capped_text(&String::from_utf8_lossy(&o.stdout))523+ }524+ _ => format!("<absent on {branch}>\n"),525+ }526+}527+528+/// #21: fail-record body with per-file both-sides context so a fresh agent529+/// resolves without re-running git. Detail lives here; stderr stays 1 line.530+fn conflict_body(repo: &Path, wt: &Path, base: &str, branch: Option<&str>, patch: &[u8], files: &[String], hash: &str) -> String {531+ let mut b = String::new();532+ b.push_str(&format!("conflict: {}\n", if files.is_empty() { "patch does not apply".to_string() } else { files.join(", ") }));533+ b.push_str(&format!("patch: {hash}\n"));534+ if files.is_empty() {535+ let t = String::from_utf8_lossy(patch);536+ let mut ex: String = t.chars().take(4000).collect();537+ if t.len() > 4000 {538+ ex.push_str("\n...truncated");539+ }540+ b.push_str("---\n patch excerpt:\n");541+ b.push_str(&ex);542+ if !ex.ends_with('\n') {543+ b.push('\n');544+ }545+ return b;546+ }547+ for f in files.iter().take(8) {548+ b.push_str("---\n");549+ b.push_str(&format!("file: {f}\n"));550+ b.push_str(&format!("base {base}:{f}:\n"));551+ b.push_str(&base_snippet(wt, f));552+ match branch {553+ Some(br) => {554+ b.push_str(&format!("head {br}:{f}:\n"));555+ b.push_str(&head_snippet(repo, br, f));556+ }557+ None => b.push_str("head (payload):\n<payload-only; see patch hunk>\n"),558+ }559+ b.push_str("patch:\n");560+ match patch_block(patch, f) {561+ Some(pb) => {562+ b.push_str(&pb);563+ if !pb.ends_with('\n') {564+ b.push('\n');565+ }566+ }567+ None => b.push_str("<no patch hunk for file>\n"),568+ }569+ if b.len() > 90000 {570+ b.push_str("...truncated\n");571+ break;572+ }573+ }574+ b575+}576+304577 /// Minimal CI: steps in filename order, cwd=worktree, fail-fast (M4 ritual).305578 fn run_ci(fg: &Forge, wt: &Path) -> Result<Option<String>, CliError> {306579 let dir = fg.root().join("ci").join("steps");@@ -407,14 +680,18 @@ pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {407680 let abs_src = if src_path.is_absolute() { src_path.to_path_buf() } else { repo.join(src_path) };408681 409682 // 1. import payload (folder | zip | branch); never touches live checkout.410- let (payload, zip_tmp): (Payload, Option<PathBuf>) = if abs_src.is_dir() {683+ let is_folder = abs_src.is_dir();684+ let is_file = !is_folder && abs_src.is_file();685+ let (payload, zip_tmp): (Payload, Option<PathBuf>) = if is_folder {411686 (from_folder(&abs_src, base_flag.as_deref())?, None)412- } else if abs_src.is_file() {687+ } else if is_file {413688 let (p, t) = from_zip(&abs_src, base_flag.as_deref())?;414689 (p, Some(t))415690 } else {416691 (from_branch(&repo, fg, &src, base_flag.as_deref())?, None)417692 };693+ // #21: real branch only for branch flow; folder/zip are payload-only.694+ let branch_opt: Option<String> = if is_folder || is_file { None } else { payload.head.clone() };418695 419696 // 2. already-landed check (SEC-08): idempotent-safe, exit 0.420697 let hash = patch_hash(&repo, &payload.patch);@@ -474,11 +751,31 @@ pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {474751 let patch_s = patchf.to_string_lossy().into_owned();475752 let now = now_rfc3339();476753 754+ // #17: fork-stale guard: a patch deleting shared .forge state that is755+ // live at base is a conflict (list + stop), never a silent apply.756+ let stale: Vec<String> = forge_deletions(&payload.patch).into_iter().filter(|f| {757+ if f.contains("..") || f.starts_with('/') || f.contains('\\') || !f.starts_with(".forge/") {758+ return false;759+ }760+ wt.join(f).exists()761+ }).take(8).collect();762+ if !stale.is_empty() {763+ let body = conflict_body(&repo, &wt, &payload.base, branch_opt.as_deref(), &payload.patch, &stale, &hash);764+ let rec = MergeRecord { pr: payload.id.clone(), applied: false, verdict: "fail".to_string(), resolved: Vec::new(), created: now };765+ let rel = record_name(fg, &rec.created);766+ let _ = fg.save_entity(&rel, &rec, &body);767+ let _ = std::fs::remove_file(&patchf);768+ cleanup_wt(&repo, &wt_s, &wt);769+ if let Some(t) = &zip_tmp {770+ let _ = std::fs::remove_dir_all(t);771+ }772+ return Err(CliError::Runtime(one_line(&format!("merge conflict: {}", stale.join(", ")))));773+ }477774 // 3. git apply --check with builtin path validation ON (SEC-05).478775 let o = git(&wt, &["apply", "--check", &patch_s])?;479776 if !o.status.success() {480777 let files = conflict_files(&String::from_utf8_lossy(&o.stderr));481- let body = format!("conflict: {}\npatch: {hash}\n", if files.is_empty() { "patch does not apply".to_string() } else { files.join(", ") });778+ let body = conflict_body(&repo, &wt, &payload.base, branch_opt.as_deref(), &payload.patch, &files, &hash);482779 let rec = MergeRecord { pr: payload.id.clone(), applied: false, verdict: "fail".to_string(), resolved: Vec::new(), created: now };483780 let rel = record_name(fg, &rec.created);484781 let _ = fg.save_entity(&rel, &rec, &body);
crates/cli/tests/merge_e2e.rs+108 −0
--- a/crates/cli/tests/merge_e2e.rs+++ b/crates/cli/tests/merge_e2e.rs@@ -240,3 +240,111 @@ fn usage_and_missing() {240240 assert_eq!(e.lines().count(), 1);241241 let _ = std::fs::remove_dir_all(&repo);242242 }243+#[test]244+fn branch_merge_links_canonical_pr() {245+ // #15: PR folder committed on the branch tip (absent on main);246+ // `f0rge merge <branch>` records the canonical PR uuid, fresh only as last resort.247+ let repo = setup();248+ git(&repo, &["add", "-A"]);249+ git(&repo, &["commit", "-m", "forge"]);250+ git(&repo, &["checkout", "-b", "feat"]);251+ std::fs::write(repo.join("a.txt"), "line1-feat\nline2\n").unwrap();252+ git(&repo, &["add", "-A"]);253+ git(&repo, &["commit", "-m", "feat"]);254+ let (c, o, e) = f0rge(&repo, &["pr", "create", "--base", "main", "--head", "feat", "--title", "T", "--json"]);255+ assert_eq!(c, 0, "create: {e}");256+ let v: serde_json::Value = serde_json::from_str(o.lines().last().unwrap().trim()).unwrap();257+ let id = v["databaseId"].as_str().unwrap().to_string();258+ git(&repo, &["add", "-A"]);259+ git(&repo, &["commit", "-m", "pr"]);260+ git(&repo, &["checkout", "main"]);261+ assert!(!repo.join(".forge").join("prs").join(&id).exists(), "pr rides the branch, not main");262+ let (c, o, e) = f0rge(&repo, &["merge", "feat"]);263+ assert_eq!(c, 0, "merge: {e}");264+ assert!(o.trim() == format!("merged {id}"), "out links canonical: {o}");265+ assert_eq!(merges(&repo).len(), 1);266+ let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();267+ assert!(rec.contains(&format!("pr: {id}")), "record links canonical:\n{rec}");268+ let _ = std::fs::remove_dir_all(&repo);269+}270+#[test]271+fn branch_forge_deletion_is_conflict_no_loss() {272+ // #17/#20: a branch deleting live .forge/prs state is a conflict (list + stop),273+ // never a silent apply. No locks, no resolvers: the agent reconciles.274+ let repo = setup();275+ git(&repo, &["add", "-A"]);276+ git(&repo, &["commit", "-m", "forge"]);277+ const LIVE: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";278+ let live = repo.join(".forge").join("prs").join(LIVE);279+ std::fs::create_dir_all(&live).unwrap();280+ std::fs::write(live.join("pr.md"), format!("---\nid: {LIVE}\ntitle: T\nstate: open\nbase: main\nhead: other\ncreated: 2025-09-08T04:00:00Z\n---\nBody.\n")).unwrap();281+ std::fs::write(live.join("patch.diff"), "fake\n").unwrap();282+ git(&repo, &["add", "-A"]);283+ git(&repo, &["commit", "-m", "land"]);284+ git(&repo, &["checkout", "-b", "evil"]);285+ let _ = std::fs::remove_dir_all(repo.join(".forge").join("prs").join(LIVE));286+ git(&repo, &["add", "-A"]);287+ git(&repo, &["commit", "-m", "evil delete"]);288+ git(&repo, &["checkout", "main"]);289+ let (c, _, e) = f0rge(&repo, &["merge", "evil"]);290+ assert_eq!(c, 1, "deletion conflicts, exit 1");291+ assert_eq!(e.lines().count(), 1, "one-line error: {e}");292+ assert!(e.contains(".forge/prs") && e.contains(LIVE), "lists deleted state: {e}");293+ assert_eq!(merges(&repo).len(), 1);294+ let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();295+ assert!(rec.contains("verdict: fail"), "fail record:\n{rec}");296+ assert!(rec.contains(LIVE) && rec.contains("patch: "), "record names state + hash:\n{rec}");297+ assert!(!rec.contains(&format!("pr: {LIVE}")), "deletion must not become record id:\n{rec}");298+ assert!(repo.join(".forge").join("prs").join(LIVE).join("pr.md").is_file(), "no loss on main");299+ let _ = std::fs::remove_dir_all(&repo);300+}301+#[test]302+fn conflict_record_has_both_sides_context() {303+ // #21: fail record carries per-file base + patch context so a fresh agent304+ // resolves without re-running git; stderr stays one line.305+ let repo = setup();306+ std::fs::write(repo.join("a.txt"), "line1-main\nline2\n").unwrap();307+ git(&repo, &["add", "-A"]);308+ git(&repo, &["commit", "-m", "advance"]);309+ let dir = tmp("payctx");310+ let patch = "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n-line1\n+line1-payload\n line2\n";311+ std::fs::write(dir.join("patch.diff"), patch).unwrap();312+ std::fs::write(dir.join("pr.md"), pr_md("cccccccc-cccc-4ccc-8ccc-cccccccccccc", "main", "feat")).unwrap();313+ let s = dir.to_string_lossy().into_owned();314+ let (c, _, e) = f0rge(&repo, &["merge", &s]);315+ assert_eq!(c, 1, "conflict exits 1");316+ assert_eq!(e.lines().count(), 1, "one-line error: {e}");317+ assert!(e.contains("a.txt"), "names file: {e}");318+ let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();319+ assert!(rec.contains("verdict: fail") && rec.contains("file: a.txt"), "record per-file:\n{rec}");320+ assert!(rec.contains("line1-main"), "record has base side:\n{rec}");321+ assert!(rec.contains("line1-payload"), "record has patch hunk:\n{rec}");322+ assert!(rec.contains("patch: "), "record pins hash:\n{rec}");323+ let _ = std::fs::remove_dir_all(&repo);324+ let _ = std::fs::remove_dir_all(&dir);325+}326+#[test]327+fn branch_conflict_record_has_head_side() {328+ // #21 (branch flow): fail record carries base + head-branch + patch context.329+ let repo = setup();330+ git(&repo, &["add", "-A"]);331+ git(&repo, &["commit", "-m", "forge"]);332+ git(&repo, &["checkout", "-b", "feat"]);333+ std::fs::write(repo.join("a.txt"), "line1-feat\nline2\n").unwrap();334+ git(&repo, &["add", "-A"]);335+ git(&repo, &["commit", "-m", "feat"]);336+ git(&repo, &["checkout", "main"]);337+ std::fs::write(repo.join("a.txt"), "line1-main\nline2\n").unwrap();338+ git(&repo, &["add", "-A"]);339+ git(&repo, &["commit", "-m", "advance"]);340+ let (c, _, e) = f0rge(&repo, &["merge", "feat"]);341+ assert_eq!(c, 1, "conflict exits 1");342+ assert_eq!(e.lines().count(), 1, "one-line error: {e}");343+ assert!(e.contains("a.txt"), "names file: {e}");344+ let rec = std::fs::read_to_string(&merges(&repo)[0]).unwrap();345+ assert!(rec.contains("file: a.txt"), "record per-file:\n{rec}");346+ assert!(rec.contains("line1-main"), "record has base side:\n{rec}");347+ assert!(rec.contains("line1-feat"), "record has head side:\n{rec}");348+ let _ = std::fs::remove_dir_all(&repo);349+}350+
crates/cli/tests/sec08.rs+40 −0
--- a/crates/cli/tests/sec08.rs+++ b/crates/cli/tests/sec08.rs@@ -92,3 +92,43 @@ fn sec08_double_merge_is_already_landed() {9292 assert_eq!(merges(&repo).len(), 1);9393 let _ = std::fs::remove_dir_all(&repo);9494 }95+#[test]96+fn sec08_branch_canonical_repeat_is_landed() {97+ // #15 x SEC-08: branch merge records the canonical PR uuid; repeat is98+ // already-landed (exit 0), names it, writes no second record.99+ let repo = tmp_repo("canon");100+ git(&repo, &["init"]);101+ git(&repo, &["config", "user.email", "[email protected]"]);102+ git(&repo, &["config", "user.name", "t"]);103+ git(&repo, &["config", "commit.gpgsign", "false"]);104+ std::fs::write(repo.join("a.txt"), "line1\nline2\n").unwrap();105+ git(&repo, &["add", "-A"]);106+ git(&repo, &["commit", "-m", "init"]);107+ git(&repo, &["checkout", "-b", "main"]);108+ let (c, _, e) = f0rge(&repo, &["init"]);109+ assert_eq!(c, 0, "init: {e}");110+ git(&repo, &["add", "-A"]);111+ git(&repo, &["commit", "-m", "forge"]);112+ git(&repo, &["checkout", "-b", "feat"]);113+ std::fs::write(repo.join("a.txt"), "line1-feat\nline2\n").unwrap();114+ git(&repo, &["add", "-A"]);115+ git(&repo, &["commit", "-m", "feat"]);116+ let (c, o, e) = f0rge(&repo, &["pr", "create", "--base", "main", "--head", "feat", "--title", "T", "--json"]);117+ assert_eq!(c, 0, "create: {e}");118+ let v: serde_json::Value = serde_json::from_str(o.lines().last().unwrap().trim()).unwrap();119+ let id = v["databaseId"].as_str().unwrap().to_string();120+ git(&repo, &["add", "-A"]);121+ git(&repo, &["commit", "-m", "pr"]);122+ git(&repo, &["checkout", "main"]);123+ let (c, o, e) = f0rge(&repo, &["merge", "feat"]);124+ assert_eq!(c, 0, "merge: {e}");125+ assert!(o.trim() == format!("merged {id}"), "canonical: {o}");126+ assert_eq!(merges(&repo).len(), 1);127+ let (c, o, e) = f0rge(&repo, &["merge", "feat"]);128+ assert_eq!(c, 0, "repeat exit 0, got {c} err: {e}");129+ assert!(o.contains("already landed") && o.contains(&id), "names canonical: {o}");130+ assert_eq!(e.lines().count(), 0, "no stderr: {e}");131+ assert_eq!(merges(&repo).len(), 1, "no duplicate record");132+ let _ = std::fs::remove_dir_all(&repo);133+}134+