crates/cli/src/cmd/merge.rs 32.1 KiB 860 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge merge: import folder|zip|branch (PLAN M6). Utility, not ceremony.3 //! Fresh disposable worktree; git apply --check; patch-hash dedup; CI;4 //! on conflict write record + stop. No engine, no resolution, no hooks.5 use crate::args::{parse_flags, spec_from_lists};6 use crate::output;7 use crate::CliError;8 use f0rge_core::{now_rfc3339, Forge, MergeRecord, Pr};9 use std::path::{Path, PathBuf};10 use std::process::Command;11 use std::sync::atomic::{AtomicU64, Ordering};12 13 pub fn help() -> &'static str {14     "f0rge merge: imports\nusage: f0rge merge <folder|zip|branch-name> [--json]\ngate: scratch worktree; never touches main/branches; conflicts stop; you integrate (ff) + delete\ndocs: spec/schema.md §Merge-record\ncontract: f0rge --skill\n"15 }16 17 /// No sub-verbs: single positional form (#36). Stub for API uniformity.18 #[allow(dead_code)]19 pub fn verb_help(_verb: &str) -> Option<&'static str> {20     None21 }22 23 fn with_try(e: CliError) -> CliError {24     match e {25         CliError::Usage(m) if !m.contains("(try:") => {26             CliError::Usage(format!("{m} (try: f0rge merge -h)"))27         }28         other => other,29     }30 }31 32 static CTR: AtomicU64 = AtomicU64::new(0);33 34 const VALS: &[&str] = &["jq", "base"];35 const BOOLS: &[&str] = &["json"];36 37 fn one_line(s: &str) -> String {38     s.chars().filter(|c| *c != '\n' && *c != '\r').take(160).collect::<String>().trim().to_string()39 }40 41 fn repo_dir(fg: &Forge) -> Result<PathBuf, CliError> {42     fg.root().parent().map(Path::to_path_buf).ok_or_else(|| CliError::Runtime("repo: not found".to_string()))43 }44 45 fn git(cwd: &Path, args: &[&str]) -> Result<std::process::Output, CliError> {46     Command::new("git").args(args).current_dir(cwd).output().map_err(|_| CliError::Runtime("git: not found".to_string()))47 }48 49 fn git_err(what: &str, o: &std::process::Output) -> CliError {50     let s = String::from_utf8_lossy(&o.stderr);51     CliError::Runtime(one_line(&format!("{what}: {}", s.lines().next().unwrap_or("failed").trim())))52 }53 54 /// argv-safe ref: no leading dash, no whitespace/control (SEC-04).55 fn safe_ref(s: &str, what: &str) -> Result<String, CliError> {56     let t = one_line(s);57     if t.is_empty() || t.starts_with('-') || t.chars().any(|c| c.is_whitespace() || c.is_control()) {58         return Err(CliError::Runtime(format!("{what} {t}: bad ref")));59     }60     Ok(t)61 }62 63 fn tmp_name(tag: &str) -> PathBuf {64     let n = CTR.fetch_add(1, Ordering::Relaxed);65     std::env::temp_dir().join(format!("f0rge-merge-{tag}-{}-{n}", std::process::id()))66 }67 68 fn conflict_files(stderr: &str) -> Vec<String> {69     let mut out = Vec::new();70     for line in stderr.lines() {71         let l = line.strip_prefix("error: ").unwrap_or(line).trim();72         let f = if let Some(r) = l.strip_prefix("patch failed: ") {73             r.split(':').next().unwrap_or("").trim()74         } else if let Some(i) = l.find(": already exists") {75             l[..i].trim()76         } else if let Some(i) = l.find(": does not match index") {77             l[..i].trim()78         } else {79             continue;80         };81         if !f.is_empty() && !out.contains(&f.to_string()) {82             out.push(f.to_string());83         }84         if out.len() >= 8 {85             break;86         }87     }88     out89 }90 91 fn record_name(fg: &Forge, now: &str) -> PathBuf {92     let base = now.replace(':', "-");93     let mut t = base.clone();94     let mut n = 0u64;95     loop {96         let rel = fg.merge_file(&t);97         if !fg.root().join(&rel).exists() {98             return rel;99         }100         n += 1;101         t = format!("{base}-{n}");102     }103 }104 105 /// ONE canonical byte-form for hashing (SEC-08): CRLF->LF so folder106 /// patch.diff bytes and branch `format-patch --stdout` bytes hash equal.107 fn canon_patch(p: &[u8]) -> Vec<u8> {108     let mut out = Vec::with_capacity(p.len());109     let mut i = 0;110     while i < p.len() {111         if p[i] == b'\r' {112             if i + 1 < p.len() && p[i + 1] == b'\n' {113                 out.push(b'\n');114                 i += 2;115             } else {116                 out.push(b'\n');117                 i += 1;118             }119         } else {120             out.push(p[i]);121             i += 1;122         }123     }124     out125 }126 127 /// patch-id via git; fallback hash-object (both deterministic, no new deps).128 fn patch_hash(repo: &Path, patch: &[u8]) -> String {129     let canon = canon_patch(patch);130     let patch = &canon;131     let mut c = Command::new("git");132     c.arg("patch-id").arg("--stable").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());133     if let Ok(mut kid) = c.spawn() {134         use std::io::Write;135         if let Some(mut stdin) = kid.stdin.take() {136             let _ = stdin.write_all(patch);137         }138         if let Ok(o) = kid.wait_with_output() {139             if o.status.success() {140                 let t = String::from_utf8_lossy(&o.stdout);141                 if let Some(h) = t.split_whitespace().next().filter(|h| h.len() >= 8) {142                     return h.to_string();143                 }144             }145         }146     }147     let mut c2 = Command::new("git");148     c2.arg("hash-object").arg("--stdin").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());149     if let Ok(mut kid) = c2.spawn() {150         use std::io::Write;151         if let Some(mut stdin) = kid.stdin.take() {152             let _ = stdin.write_all(patch);153         }154         if let Ok(o) = kid.wait_with_output() {155             if o.status.success() {156                 return String::from_utf8_lossy(&o.stdout).trim().to_string();157             }158         }159     }160     format!("len{}", patch.len())161 }162 163 /// SEC-08: successful merge record whose body pins this patch hash?164 /// Pass+applied only: fail records must not block retry.165 fn landed(fg: &Forge, hash: &str) -> Option<MergeRecord> {166     let rd = std::fs::read_dir(fg.root().join("merges")).ok()?;167     let want = format!("patch: {hash}");168     for e in rd.flatten() {169         let ft = e.file_type().ok()?;170         if ft.is_symlink() || !ft.is_file() {171             continue;172         }173         let name = e.file_name().to_string_lossy().into_owned();174         if !name.ends_with(".md") || name.starts_with('.') {175             continue;176         }177         let rel = Path::new("merges").join(&name);178         let p: f0rge_core::Parsed<MergeRecord> = fg.load_entity(&rel).ok()?;179         if p.value.verdict == "pass" && p.value.applied && p.body.lines().any(|l| l.trim() == want) {180             return Some(p.value);181         }182     }183     None184 }185 186 struct Payload {187     patch: Vec<u8>,188     id: String,189     base: String,190     /// head branch (branch flow) or payload pr.head (folder flow), for canon flip.191     head: Option<String>,192     /// payload pr.md on disk (outside .forge), if any, to flip on success.193     pr_path: Option<PathBuf>,194 }195 196 fn read_capped(path: &Path, label: &str) -> Result<Vec<u8>, CliError> {197     let md = std::fs::metadata(path).map_err(|_| CliError::Runtime(format!("{label}: not found")))?;198     if md.len() > f0rge_core::MAX_FILE_BYTES as u64 {199         return Err(CliError::Runtime(format!("{label}: file over 256 KiB cap")));200     }201     std::fs::read(path).map_err(|_| CliError::Runtime(format!("{label}: io error")))202 }203 204 fn payload_pr(dir: &Path) -> Result<Option<Pr>, CliError> {205     let f = dir.join("pr.md");206     if !f.is_file() {207         return Ok(None);208     }209     let bytes = read_capped(&f, "pr.md")?;210     let text = String::from_utf8(bytes).map_err(|_| CliError::Runtime("pr.md: not utf-8".to_string()))?;211     let p = f0rge_core::parse_entity::<Pr>(&text, "pr.md").map_err(CliError::from)?;212     Ok(Some(p.value))213 }214 215 fn from_folder(dir: &Path, base_flag: Option<&str>) -> Result<Payload, CliError> {216     let patch = read_capped(&dir.join("patch.diff"), "patch.diff")?;217     if patch.iter().all(|b| b.is_ascii_whitespace()) {218         return Err(CliError::Runtime("patch.diff: empty patch".to_string()));219     }220     let (id, base, head, pr_path) = match payload_pr(dir)? {221         Some(pr) => (pr.id.clone(), base_flag.unwrap_or(&pr.base).to_string(), Some(pr.head.clone()), Some(dir.join("pr.md"))),222         None => (uuid::Uuid::new_v4().to_string(), base_flag.unwrap_or("main").to_string(), None, None),223     };224     Ok(Payload { patch, id, base: safe_ref(&base, "base")?, head, pr_path })225 }226 227 fn find_patch(dir: &Path) -> Option<PathBuf> {228     let top = dir.join("patch.diff");229     if top.is_file() {230         return Some(top);231     }232     let mut subs: Vec<PathBuf> = std::fs::read_dir(dir)233         .ok()?234         .flatten()235         .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))236         .map(|e| e.path().join("patch.diff"))237         .filter(|p| p.is_file())238         .collect();239     subs.sort();240     subs.into_iter().next()241 }242 243 fn from_zip(zip: &Path, base_flag: Option<&str>) -> Result<(Payload, PathBuf), CliError> {244     // SEC-05: list names first; reject abs/parent escapes before extract.245     let o = Command::new("unzip").arg("-Z1").arg(zip).output().map_err(|_| CliError::Runtime("unzip: not found".to_string()))?;246     if !o.status.success() {247         return Err(CliError::Runtime("zip: not a zip".to_string()));248     }249     for n in String::from_utf8_lossy(&o.stdout).lines() {250         let t = n.trim().trim_start_matches("./");251         if t.is_empty() {252             continue;253         }254         if t.starts_with('/') || t.contains("..") || t.contains('\\') || t.get(1..2) == Some(":") {255             return Err(CliError::Runtime(format!("zip: bad entry {t}")));256         }257     }258     let tmp = tmp_name("zip");259     std::fs::create_dir_all(&tmp).map_err(|_| CliError::Runtime("tmp: io error".to_string()))?;260     let o = Command::new("unzip").arg("-q").arg("-o").arg(zip).arg("-d").arg(&tmp).output().map_err(|_| CliError::Runtime("unzip: not found".to_string()))?;261     if !o.status.success() {262         let _ = std::fs::remove_dir_all(&tmp);263         return Err(CliError::Runtime("zip: extract failed".to_string()));264     }265     let dir = find_patch(&tmp).and_then(|p| p.parent().map(Path::to_path_buf)).unwrap_or_else(|| tmp.clone());266     match from_folder(&dir, base_flag) {267         Ok(p) => Ok((p, tmp)),268         Err(e) => {269             let _ = std::fs::remove_dir_all(&tmp);270             Err(e)271         }272     }273 }274 275 fn canon_pr(fg: &Forge, id: &str) -> Option<(Pr, String)> {276     let p: f0rge_core::Parsed<Pr> = fg.load_entity(&fg.pr_file(id)).ok()?;277     Some((p.value, p.body))278 }279 280 fn pr_by_head(fg: &Forge, head: &str) -> Option<(Pr, String)> {281     let rd = std::fs::read_dir(fg.root().join("prs")).ok()?;282     for e in rd.flatten() {283         let ft = e.file_type().ok()?;284         if ft.is_symlink() || !ft.is_dir() {285             continue;286         }287         let name = e.file_name().to_string_lossy().into_owned();288         if let Some((pr, body)) = canon_pr(fg, &name) {289             if pr.head == head && pr.state == "open" {290                 return Some((pr, body));291             }292         }293     }294     None295 }296 297 /// #15: PR committed on the branch tip but not yet on main (fork case).298 /// Read-only argv-only scan of the branch tree; no locks, no stored state.299 fn pr_on_branch(repo: &Path, branch: &str) -> Option<Pr> {300     let o = Command::new("git").args(["ls-tree", "-r", "--name-only", branch, "--", ".forge/prs"]).current_dir(repo).output().ok()?;301     if !o.status.success() {302         return None;303     }304     let mut best: Option<Pr> = None;305     for line in String::from_utf8_lossy(&o.stdout).lines() {306         let tr = line.trim();307         let Some(rest) = tr.strip_prefix(".forge/prs/") else { continue };308         if !tr.ends_with("/pr.md") {309             continue;310         }311         let uuid = rest.split('/').next().unwrap_or("");312         if !f0rge_core::is_uuid4_lower(uuid) {313             continue;314         }315         let arg = format!("{branch}:{tr}");316         let s = Command::new("git").args(["show", &arg]).current_dir(repo).output().ok()?;317         if !s.status.success() {318             continue;319         }320         let content = String::from_utf8(s.stdout).ok()?;321         let parsed = f0rge_core::parse_entity::<Pr>(&content, "pr.md").ok()?;322         if parsed.value.head != branch || parsed.value.state != "open" {323             continue;324         }325         let take = match &best {326             None => true,327             Some(b) => (parsed.value.created.as_str(), parsed.value.id.as_str()) < (b.created.as_str(), b.id.as_str()),328         };329         if take {330             best = Some(parsed.value);331         }332     }333     best334 }335 336 /// #15: uuid riding inside the branch patch itself (format-patch carries337 /// the .forge/prs/<uuid>/pr.md creation). Pure scan, no state.338 fn pr_in_patch(patch: &[u8], branch: &str) -> Option<String> {339     let text = String::from_utf8_lossy(patch);340     let mut uuids: Vec<String> = Vec::new();341     // Only creations: deletions of shared pr records must not become the record id.342     // (Skip the mail header block: its diffstat names paths without diff markers.)343     for block in text.split("diff --git ") {344         if !block.starts_with("a/") && !block.starts_with("\"a/") {345             continue;346         }347         if block.contains("deleted file mode") || block.contains("+++ /dev/null") {348             continue;349         }350         if !block.contains(".forge/prs/") || !block.contains("/pr.md") {351             continue;352         }353         let mut s: &str = block;354         loop {355             let Some(i) = s.find(".forge/prs/") else { break };356             let after = &s[i + ".forge/prs/".len()..];357             if after.len() >= 36 {358                 let cand = &after[..36];359                 if f0rge_core::is_uuid4_lower(cand) && after[36..].starts_with("/pr.md") && !uuids.contains(&cand.to_string()) {360                     uuids.push(cand.to_string());361                 }362             }363             s = &s[i + 1..];364             if s.is_empty() {365                 break;366             }367         }368         if uuids.len() >= 8 {369             break;370         }371     }372     if uuids.is_empty() {373         return None;374     }375     if uuids.len() == 1 {376         return Some(uuids.remove(0));377     }378     // multiple: prefer the block whose added head line matches this branch.379     let want = format!("+head: {branch}");380     let want_q = format!("+head: \"{branch}\"");381     for block in text.split("diff --git ") {382         if !(block.contains(&want) || block.contains(&want_q)) {383             continue;384         }385         for u in &uuids {386             if block.contains(&format!(".forge/prs/{u}/pr.md")) {387                 return Some(u.clone());388             }389         }390     }391     Some(uuids.remove(0))392 }393 394 fn from_branch(repo: &Path, fg: &Forge, branch: &str, base_flag: Option<&str>) -> Result<Payload, CliError> {395     let branch = safe_ref(branch, "branch")?;396     let o = git(repo, &["rev-parse", "--verify", "--quiet", &format!("{branch}^{{commit}}")])?;397     if !o.status.success() {398         return Err(CliError::Runtime(format!("branch {branch}: not found")));399     }400     // #15: canonical uuid first (open PR here), then PR on the branch tip,401     // then uuid riding in the patch; fresh uuid only as last resort.402     let here = pr_by_head(fg, &branch);403     let there = if here.is_none() { pr_on_branch(repo, &branch) } else { None };404     let raw_base = if let Some(b) = base_flag {405         b.to_string()406     } else if let Some((pr, _)) = &here {407         pr.base.clone()408     } else if let Some(pr) = &there {409         pr.base.clone()410     } else {411         "main".to_string()412     };413     let base = safe_ref(&raw_base, "base")?;414     let spec = format!("{base}..{branch}");415     let o = git(repo, &["format-patch", &spec, "--stdout"])?;416     if !o.status.success() {417         return Err(git_err("git format-patch", &o));418     }419     if o.stdout.iter().all(|b| b.is_ascii_whitespace()) {420         return Err(CliError::Runtime(format!("empty patch: nothing in {spec}")));421     }422     let id = if let Some((pr, _)) = here {423         pr.id424     } else if let Some(pr) = there {425         pr.id426     } else if let Some(u) = pr_in_patch(&o.stdout, &branch) {427         u428     } else {429         uuid::Uuid::new_v4().to_string()430     };431     Ok(Payload { patch: o.stdout, id, base, head: Some(branch.clone()), pr_path: None })432 }433 434 /// #17: paths under .forge/ the patch deletes (fork-stale tip diffs must435 /// not silently drop shared state). Lists, never resolves.436 fn forge_deletions(patch: &[u8]) -> Vec<String> {437     let text = String::from_utf8_lossy(patch);438     let mut out: Vec<String> = Vec::new();439     for chunk in text.split("diff --git ") {440         let Some(nl) = chunk.find('\n') else { continue };441         let hdr = &chunk[..nl];442         // hdr: a/<path> b/<path> (possibly quoted).443         let mut apath: Option<String> = None;444         for tok in hdr.split_whitespace() {445             let q = tok.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(tok);446             if let Some(rest) = q.strip_prefix("a/") {447                 apath = Some(rest.to_string());448                 break;449             }450         }451         let Some(ap) = apath else { continue };452         if !ap.starts_with(".forge/") {453             continue;454         }455         if chunk.contains("deleted file mode") || chunk.contains("\n+++ /dev/null") || chunk.contains("+++ /dev/null") {456             if !ap.is_empty() && !out.contains(&ap) {457                 out.push(ap);458             }459         }460         if out.len() >= 8 {461             break;462         }463     }464     out465 }466 467 /// #21: one patch block for a file, capped. Context only, no engine.468 fn patch_block(patch: &[u8], file: &str) -> Option<String> {469     let text = String::from_utf8_lossy(patch);470     let needle = format!("a/{file}");471     for chunk in text.split("diff --git ") {472         let first = chunk.lines().next().unwrap_or("");473         if first.contains(&needle) {474             let mut s = format!("diff --git {chunk}");475             if s.len() > 4000 {476                 s.truncate(4000);477                 s.push_str("\n...truncated");478             }479             return Some(s);480         }481     }482     None483 }484 485 fn capped_text(t: &str) -> String {486     const MAX_LINES: usize = 80;487     const MAX_BYTES: usize = 4000;488     let mut lines: Vec<&str> = t.lines().take(MAX_LINES + 1).collect();489     let trunc_lines = lines.len() > MAX_LINES;490     if trunc_lines {491         lines.truncate(MAX_LINES);492     }493     let s = lines.join("\n");494     let mut out = s;495     if out.len() > MAX_BYTES {496         out.truncate(MAX_BYTES);497         out.push_str("\n...truncated");498         return out;499     }500     if trunc_lines {501         out.push_str("\n...truncated");502     }503     if !out.ends_with('\n') {504         out.push('\n');505     }506     out507 }508 509 fn base_snippet(wt: &Path, file: &str) -> String {510     if file.contains("..") || file.starts_with('/') || file.contains('\\') {511         return "<invalid path>\n".to_string();512     }513     let p = wt.join(file);514     let md = match std::fs::symlink_metadata(&p) {515         Ok(m) => m,516         Err(_) => return "<absent on base>\n".to_string(),517     };518     if md.file_type().is_symlink() || md.is_dir() {519         return "<is a directory on base>\n".to_string();520     }521     if md.len() > 32768 {522         return "<too large on base>\n".to_string();523     }524     match std::fs::read(&p) {525         Ok(b) => capped_text(&String::from_utf8_lossy(&b)),526         Err(_) => "<unreadable on base>\n".to_string(),527     }528 }529 530 fn head_snippet(repo: &Path, branch: &str, file: &str) -> String {531     let arg = format!("{branch}:{file}");532     match Command::new("git").args(["show", &arg]).current_dir(repo).output() {533         Ok(o) if o.status.success() => {534             if o.stdout.len() > 32768 {535                 return "<too large on head>\n".to_string();536             }537             capped_text(&String::from_utf8_lossy(&o.stdout))538         }539         _ => format!("<absent on {branch}>\n"),540     }541 }542 543 /// #21: fail-record body with per-file both-sides context so a fresh agent544 /// resolves without re-running git. Detail lives here; stderr stays 1 line.545 fn conflict_body(repo: &Path, wt: &Path, base: &str, branch: Option<&str>, patch: &[u8], files: &[String], hash: &str) -> String {546     let mut b = String::new();547     b.push_str(&format!("conflict: {}\n", if files.is_empty() { "patch does not apply".to_string() } else { files.join(", ") }));548     b.push_str(&format!("patch: {hash}\n"));549     if files.is_empty() {550         let t = String::from_utf8_lossy(patch);551         let mut ex: String = t.chars().take(4000).collect();552         if t.len() > 4000 {553             ex.push_str("\n...truncated");554         }555         b.push_str("---\n patch excerpt:\n");556         b.push_str(&ex);557         if !ex.ends_with('\n') {558             b.push('\n');559         }560         return b;561     }562     for f in files.iter().take(8) {563         b.push_str("---\n");564         b.push_str(&format!("file: {f}\n"));565         b.push_str(&format!("base {base}:{f}:\n"));566         b.push_str(&base_snippet(wt, f));567         match branch {568             Some(br) => {569                 b.push_str(&format!("head {br}:{f}:\n"));570                 b.push_str(&head_snippet(repo, br, f));571             }572             None => b.push_str("head (payload):\n<payload-only; see patch hunk>\n"),573         }574         b.push_str("patch:\n");575         match patch_block(patch, f) {576             Some(pb) => {577                 b.push_str(&pb);578                 if !pb.ends_with('\n') {579                     b.push('\n');580                 }581             }582             None => b.push_str("<no patch hunk for file>\n"),583         }584         if b.len() > 90000 {585             b.push_str("...truncated\n");586             break;587         }588     }589     b590 }591 592 /// Minimal CI: steps in filename order, cwd=worktree, fail-fast (M4 ritual).593 fn run_ci(fg: &Forge, wt: &Path) -> Result<Option<String>, CliError> {594     let dir = fg.root().join("ci").join("steps");595     let rd = match std::fs::read_dir(&dir) {596         Ok(r) => r,597         Err(_) => return Ok(None), // no steps dir: trivially green598     };599     let mut steps: Vec<(String, PathBuf)> = Vec::new();600     for e in rd.flatten() {601         let ft = match e.file_type() {602             Ok(t) => t,603             Err(_) => continue,604         };605         if ft.is_symlink() || !ft.is_file() {606             continue;607         }608         let n = e.file_name().to_string_lossy().into_owned();609         if n.is_empty() || n.starts_with('.') {610             continue;611         }612         steps.push((n, e.path()));613     }614     steps.sort_by(|a, b| a.0.cmp(&b.0));615     for (name, full) in &steps {616         let o = Command::new(full).current_dir(wt).output();617         let code = match o {618             Ok(o) => o.status.code().unwrap_or(128),619             Err(_) => 126,620         };621         if code != 0 {622             return Ok(Some(name.clone()));623         }624     }625     Ok(None)626 }627 628 fn flip_pr_file(path: &Path, state: &str, ci: Option<&str>) -> Result<(), CliError> {629     let text = std::fs::read_to_string(path).map_err(|_| CliError::Runtime("pr.md: io error".to_string()))?;630     let p = f0rge_core::parse_entity::<Pr>(&text, "pr.md").map_err(CliError::from)?;631     let mut pr = p.value;632     pr.state = state.to_string();633     if let Some(v) = ci {634         pr.ci = Some(v.to_string());635     }636     pr.updated = Some(now_rfc3339());637     let doc = f0rge_core::render_entity(&pr, &p.body).map_err(|m| CliError::Runtime(one_line(&m)))?;638     let tmp = path.with_extension("tmp");639     std::fs::write(&tmp, doc.as_bytes()).map_err(|_| CliError::Runtime("pr.md: io error".to_string()))?;640     if let Ok(f) = std::fs::File::open(&tmp) {641         let _ = f.sync_all();642     }643     std::fs::rename(&tmp, path).map_err(|_| CliError::Runtime("pr.md: io error".to_string()))?;644     Ok(())645 }646 647 fn flip_canon(fg: &Forge, id: &str, state: &str, ci: Option<&str>) {648     if let Some((mut pr, body)) = canon_pr(fg, id) {649         pr.state = state.to_string();650         if let Some(v) = ci {651             pr.ci = Some(v.to_string());652         }653         pr.updated = Some(now_rfc3339());654         let _ = fg.save_entity(&fg.pr_file(id), &pr, &body);655     }656 }657 658 /// Commit applied tree in worktree with hooks/drivers off (SEC-06).659 fn commit_applied(wt: &Path, id: &str) -> Result<String, CliError> {660     let ident = git(wt, &["config", "user.email"]).map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();661     let a = git(wt, &["add", "-A"])?;662     if !a.status.success() {663         return Err(git_err("git add", &a));664     }665     let mut c = Command::new("git");666     c.arg("-c").arg("core.hooksPath=/dev/null").arg("-c").arg("core.attributesFile=/dev/null");667     if ident.is_empty() {668         c.arg("-c").arg("user.name=f0rge").arg("-c").arg("user.email=f0rge@localhost");669     }670     c.arg("commit").arg("--no-verify").arg("--no-gpg-sign").arg("-m").arg(format!("merge {id}"));671     c.current_dir(wt);672     c.env("GIT_CONFIG_COUNT", "2").env("GIT_CONFIG_KEY_0", "core.hooksPath").env("GIT_CONFIG_VALUE_0", "/dev/null").env("GIT_CONFIG_KEY_1", "core.attributesFile").env("GIT_CONFIG_VALUE_1", "/dev/null");673     let o = c.output().map_err(|_| CliError::Runtime("git: not found".to_string()))?;674     if !o.status.success() {675         return Err(git_err("git commit", &o));676     }677     let o = git(wt, &["rev-parse", "HEAD"])?;678     if !o.status.success() {679         return Err(git_err("git rev-parse", &o));680     }681     Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())682 }683 684 pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {685     let spec = spec_from_lists(VALS, BOOLS);686     let fs = parse_flags(args, &spec).map_err(with_try)?;687     let (json, jq) = (fs.is("json"), fs.get("jq").map(|s| s.to_string()));688     let base_flag = fs.get("base").map(|s| s.to_string());689     if fs.positional.len() != 1 {690         return Err(with_try(CliError::Usage("f0rge merge <folder|zip|branch-name>".to_string())));691     }692     let src = fs.positional[0].clone();693     let repo = repo_dir(fg)?;694     let src_path = Path::new(&src);695     let abs_src = if src_path.is_absolute() { src_path.to_path_buf() } else { repo.join(src_path) };696 697     // 1. import payload (folder | zip | branch); never touches live checkout.698     let is_folder = abs_src.is_dir();699     let is_file = !is_folder && abs_src.is_file();700     let (payload, zip_tmp): (Payload, Option<PathBuf>) = if is_folder {701         (from_folder(&abs_src, base_flag.as_deref())?, None)702     } else if is_file {703         let (p, t) = from_zip(&abs_src, base_flag.as_deref())?;704         (p, Some(t))705     } else {706         (from_branch(&repo, fg, &src, base_flag.as_deref())?, None)707     };708     // #21: real branch only for branch flow; folder/zip are payload-only.709     let branch_opt: Option<String> = if is_folder || is_file { None } else { payload.head.clone() };710 711     // 2. already-landed check (SEC-08): idempotent-safe, exit 0.712     let hash = patch_hash(&repo, &payload.patch);713     if let Some(rec) = landed(fg, &hash) {714         if let Some(t) = &zip_tmp {715             let _ = std::fs::remove_dir_all(t);716         }717         flip_canon(fg, &payload.id, "merged", Some("pass"));718         flip_canon(fg, &rec.pr, "merged", Some("pass"));719         if let Some(h) = &payload.head {720             if let Some((pr, _)) = pr_by_head(fg, h) {721                 flip_canon(fg, &pr.id, "merged", Some("pass"));722             }723         }724         if json {725             output::emit_json(&rec, jq.as_deref());726         } else {727             output::out(&format!("already landed: {}\n", rec.pr));728         }729         return Ok(());730     }731 732     // base must exist; then fresh disposable worktree at base (SEC-05).733     let o = git(&repo, &["rev-parse", "--verify", "--quiet", &format!("{}^{{commit}}", payload.base)])?;734     if !o.status.success() {735         if let Some(t) = &zip_tmp {736             let _ = std::fs::remove_dir_all(t);737         }738         return Err(CliError::Runtime(format!("base {}: not found", payload.base)));739     }740     let wt = tmp_name("wt");741     let wt_s = wt.to_string_lossy().into_owned();742     let cleanup_wt = |repo: &Path, wt_s: &str, wt: &Path| {743         let _ = git(repo, &["worktree", "remove", "--force", wt_s]);744         let _ = std::fs::remove_dir_all(wt);745     };746     let fail = |repo: &Path, e: CliError| -> CliError {747         cleanup_wt(repo, &wt_s, &wt);748         if let Some(t) = &zip_tmp {749             let _ = std::fs::remove_dir_all(t);750         }751         e752     };753     let o = git(&repo, &["worktree", "add", "--detach", &wt_s, &payload.base])?;754     if !o.status.success() {755         let e = git_err("git worktree", &o);756         if let Some(t) = &zip_tmp {757             let _ = std::fs::remove_dir_all(t);758         }759         let _ = std::fs::remove_dir_all(&wt);760         return Err(e);761     }762     let patchf = tmp_name("patch.diff");763     if std::fs::write(&patchf, &payload.patch).is_err() {764         return Err(fail(&repo, CliError::Runtime("tmp: io error".to_string())));765     }766     let patch_s = patchf.to_string_lossy().into_owned();767     let now = now_rfc3339();768 769     // #17: fork-stale guard: a patch deleting shared .forge state that is770     // live at base is a conflict (list + stop), never a silent apply.771     let stale: Vec<String> = forge_deletions(&payload.patch).into_iter().filter(|f| {772         if f.contains("..") || f.starts_with('/') || f.contains('\\') || !f.starts_with(".forge/") {773             return false;774         }775         wt.join(f).exists()776     }).take(8).collect();777     if !stale.is_empty() {778         let body = conflict_body(&repo, &wt, &payload.base, branch_opt.as_deref(), &payload.patch, &stale, &hash);779         let rec = MergeRecord { pr: payload.id.clone(), applied: false, verdict: "fail".to_string(), resolved: Vec::new(), created: now };780         let rel = record_name(fg, &rec.created);781         let _ = fg.save_entity(&rel, &rec, &body);782         let _ = std::fs::remove_file(&patchf);783         cleanup_wt(&repo, &wt_s, &wt);784         if let Some(t) = &zip_tmp {785             let _ = std::fs::remove_dir_all(t);786         }787         return Err(CliError::Runtime(one_line(&format!("merge conflict: {}", stale.join(", ")))));788     }789     // 3. git apply --check with builtin path validation ON (SEC-05).790     let o = git(&wt, &["apply", "--check", &patch_s])?;791     if !o.status.success() {792         let files = conflict_files(&String::from_utf8_lossy(&o.stderr));793         let body = conflict_body(&repo, &wt, &payload.base, branch_opt.as_deref(), &payload.patch, &files, &hash);794         let rec = MergeRecord { pr: payload.id.clone(), applied: false, verdict: "fail".to_string(), resolved: Vec::new(), created: now };795         let rel = record_name(fg, &rec.created);796         let _ = fg.save_entity(&rel, &rec, &body);797         let _ = std::fs::remove_file(&patchf);798         cleanup_wt(&repo, &wt_s, &wt);799         if let Some(t) = &zip_tmp {800             let _ = std::fs::remove_dir_all(t);801         }802         if files.is_empty() {803             return Err(CliError::Runtime("merge conflict: patch does not apply".to_string()));804         }805         return Err(CliError::Runtime(one_line(&format!("merge conflict: {}", files.join(", ")))));806     }807     let o = git(&wt, &["apply", &patch_s])?;808     let _ = std::fs::remove_file(&patchf);809     if !o.status.success() {810         let e = git_err("git apply", &o);811         return Err(fail(&repo, e));812     }813 814     // 4. CI in the disposable worktree; fail leaves pr open.815     if let Some(step) = run_ci(fg, &wt)? {816         let body = format!("failed at {step}.\npatch: {hash}\n");817         let rec = MergeRecord { pr: payload.id.clone(), applied: false, verdict: "fail".to_string(), resolved: Vec::new(), created: now };818         let rel = record_name(fg, &rec.created);819         let _ = fg.save_entity(&rel, &rec, &body);820         flip_canon(fg, &payload.id, "open", Some("fail"));821         cleanup_wt(&repo, &wt_s, &wt);822         if let Some(t) = &zip_tmp {823             let _ = std::fs::remove_dir_all(t);824         }825         return Err(CliError::Runtime(one_line(&format!("ci fail: {step}"))));826     }827 828     // green: commit (hooks off, SEC-06), flip pr, record.829     let sha = match commit_applied(&wt, &payload.id) {830         Ok(s) => s,831         Err(e) => return Err(fail(&repo, e)),832     };833     cleanup_wt(&repo, &wt_s, &wt);834     if let Some(t) = &zip_tmp {835         let _ = std::fs::remove_dir_all(t);836     }837     flip_canon(fg, &payload.id, "merged", Some("pass"));838     if let Some(h) = &payload.head {839         if let Some((pr, _)) = pr_by_head(fg, h) {840             if pr.id != payload.id {841                 flip_canon(fg, &pr.id, "merged", Some("pass"));842             }843         }844     }845     if let Some(p) = &payload.pr_path {846         if fg.root().join(fg.pr_file(&payload.id)) != *p {847             let _ = flip_pr_file(p, "merged", Some("pass"));848         }849     }850     let rec = MergeRecord { pr: payload.id.clone(), applied: true, verdict: "pass".to_string(), resolved: Vec::new(), created: now };851     let rel = record_name(fg, &rec.created);852     let body = format!("applied: {sha}\npatch: {hash}\npass.\n");853     let _ = fg.save_entity(&rel, &rec, &body);854     if json {855         output::emit_json(&rec, jq.as_deref());856     } else {857         output::out(&format!("merged {}\n", payload.id));858     }859     Ok(())860 }