merge c8a0e434-7caa-424f-8366-ddb2bed8a9be

b600058gl0w

all commits

Message

merge c8a0e434-7caa-424f-8366-ddb2bed8a9be

Diff

crates/cli/src/cmd/merge.rs+304 −7
diff --git a/crates/cli/src/cmd/merge.rs b/crates/cli/src/cmd/merge.rsindex c625cac..be2a21b 100644--- 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
diff --git a/crates/cli/tests/merge_e2e.rs b/crates/cli/tests/merge_e2e.rsindex 4a8c8c8..ab74c05 100644--- 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
diff --git a/crates/cli/tests/sec08.rs b/crates/cli/tests/sec08.rsindex 4bf3b4a..26a7fa8 100644--- 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+