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 }