merge 25c6502b-0bc4-4e67-8ad9-2519e1256ef0

7e9d3f1gl0w

all commits

Message

merge 25c6502b-0bc4-4e67-8ad9-2519e1256ef0

Diff

crates/cli/src/cmd/ci.rs+17 −14
diff --git a/crates/cli/src/cmd/ci.rs b/crates/cli/src/cmd/ci.rsindex da6eb6f..255df16 100644--- a/crates/cli/src/cmd/ci.rs+++ b/crates/cli/src/cmd/ci.rs@@ -8,7 +8,7 @@ use std::process::Command;88 use std::time::Instant;99 1010 pub fn help() -> &'static str {11-    "f0rge ci: script runs\nusage: f0rge ci <run|list|view|log|rerun|cancel>\nverdict = step exit code\n"11+    "f0rge ci: script runs\nusage: f0rge ci <run|list|view|log|rerun|cancel>\nverbs: run list view log rerun cancel\ncontract: f0rge --skill\n"1212 }1313 1414 const VERBS: &[&str] = &["run", "list", "view", "log", "rerun", "cancel"];@@ -288,7 +288,7 @@ fn do_run(fs: &args::FlagSet, fg: &Forge, pr: Option<String>) -> Result<(), CliE288288             fs.get("jq"),289289         );290290     } else {291-        println!("{ts} {verdict}");291+        output::out(&format!("{ts} {verdict}\n"));292292     }293293     if verdict == "fail" {294294         return Err(CliError::Runtime(format!("ci run {ts}: fail")));@@ -349,7 +349,7 @@ fn list_runs(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {349349     }350350     let grid: Vec<Vec<String>> =351351         runs.iter().map(|(_, r)| vec![r.run.clone(), r.verdict.clone()]).collect();352-    println!("{}", output::align(&grid));352+    output::out(&format!("{}\n", output::align(&grid)));353353     Ok(())354354 }355355 @@ -362,9 +362,10 @@ fn view(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {362362     })?;363363     let text =364364         String::from_utf8(bytes).map_err(|_| CliError::Runtime(format!("ci {id}: not utf-8")))?;365-    print!("{text}");366-    if !text.ends_with('\n') {367-        println!();365+    if text.ends_with('\n') {366+        output::out(&text);367+    } else {368+        output::out(&format!("{text}\n"));368369     }369370     Ok(())370371 }@@ -385,9 +386,10 @@ fn log(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {385386             _ => CliError::from(e),386387         })?;387388         let text = String::from_utf8_lossy(&bytes);388-        print!("{text}");389-        if !text.ends_with('\n') {390-            println!();389+        if text.ends_with('\n') {390+            output::out(&text);391+        } else {392+            output::out(&format!("{text}\n"));391393         }392394         return Ok(());393395     }@@ -409,12 +411,13 @@ fn log(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {409411     }410412     names.sort();411413     for name in &names {412-        println!("== {name} ==");414+        output::out(&format!("== {name} ==\n"));413415         let bytes = std::fs::read(sdir.join(name)).map_err(|_| io_err())?;414416         let text = String::from_utf8_lossy(&bytes);415-        print!("{text}");416-        if !text.ends_with('\n') {417-            println!();417+        if text.ends_with('\n') {418+            output::out(&text);419+        } else {420+            output::out(&format!("{text}\n"));418421         }419422     }420423     Ok(())@@ -456,7 +459,7 @@ fn cancel(fg: &Forge) -> Result<(), CliError> {456459         }457460     }458461     let _ = std::fs::remove_file(&cur);459-    println!("{ts} cancelled");462+    output::out(&format!("{ts} cancelled\n"));460463     Ok(())461464 }462465 
crates/cli/src/cmd/init.rs+2 −2
diff --git a/crates/cli/src/cmd/init.rs b/crates/cli/src/cmd/init.rsindex fd07a38..0d2b13d 100644--- a/crates/cli/src/cmd/init.rs+++ b/crates/cli/src/cmd/init.rs@@ -3,14 +3,14 @@33 use crate::CliError;44 55 pub fn help() -> &'static str {6-    "f0rge init: bootstrap\nusage: f0rge init [dir]\ncreates .forge/ skeleton; idempotent\n"6+    "f0rge init: bootstrap\nusage: f0rge init [dir]\ncreates .forge/ skeleton; idempotent\ncontract: f0rge --skill\n"77 }88 99 /// Bootstrap skeleton. Nothing else. Existing .forge kept.1010 pub fn run(args: &[String], _fg: &f0rge_core::Forge) -> Result<(), CliError> {1111     for a in args {1212         if a == "-h" || a == "--help" {13-            print!("{}", help());13+            crate::output::out(help());1414             return Ok(());1515         }1616         if a.starts_with('-') {
crates/cli/src/cmd/issue.rs+7 −6
diff --git a/crates/cli/src/cmd/issue.rs b/crates/cli/src/cmd/issue.rsindex 74cdd23..8e26e84 100644--- a/crates/cli/src/cmd/issue.rs+++ b/crates/cli/src/cmd/issue.rs@@ -10,7 +10,7 @@ use std::collections::BTreeMap;1010 use std::path::Path;1111 1212 pub fn help() -> &'static str {13-    "f0rge issue: issues\nusage: f0rge issue [--json] <verb> [flags]\nverbs: create list view comment close reopen assign label edit\nflags: --title --body --label --assignee --milestone --state --search\n"13+    "f0rge issue: issues\nusage: f0rge issue [--json] <verb> [flags]\nverbs: create list view comment close reopen assign label edit\nflags: --title --body --label --assignee --milestone --state --search\ncontract: f0rge --skill\n"1414 }1515 1616 const VERBS: &[&str] =@@ -365,7 +365,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {365365         .iter()366366         .map(|(n, f, _)| vec![format!("#{n}"), f.issue.state.clone(), f.issue.title.clone()])367367         .collect();368-    println!("{}", output::align(&grid));368+    output::out(&format!("{}\n", output::align(&grid)));369369     Ok(())370370 }371371 @@ -397,14 +397,15 @@ fn view(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {397397         output::emit_json(&vj, fs.get("jq"));398398         return Ok(());399399     }400-    println!("#{n} {} [{}]", f.issue.title, f.issue.state);401-    print!("{}", f.body);400+    let mut human = format!("#{n} {} [{}]\n", f.issue.title, f.issue.state);401+    human.push_str(&f.body);402402     if !f.body.ends_with('\n') {403-        println!();403+        human.push('\n');404404     }405405     for (c, b) in &cs {406-        println!("- {}: {}", c.author.as_deref().unwrap_or("?"), b.trim_end());406+        human.push_str(&format!("- {}: {}\n", c.author.as_deref().unwrap_or("?"), b.trim_end()));407407     }408+    output::out(&human);408409     Ok(())409410 }410411 
crates/cli/src/cmd/label.rs+4 −4
diff --git a/crates/cli/src/cmd/label.rs b/crates/cli/src/cmd/label.rsindex 0059489..328f4ae 100644--- a/crates/cli/src/cmd/label.rs+++ b/crates/cli/src/cmd/label.rs@@ -6,7 +6,7 @@ use crate::CliError;66 use f0rge_core::{Forge, Label};77 88 pub fn help() -> &'static str {9-    "f0rge label: definitions\nusage: f0rge label [--json] <list|create|edit>\nfile: .forge/labels.md\n"9+    "f0rge label: definitions\nusage: f0rge label [--json] <list|create|edit>\nfile: .forge/labels.md\ncontract: f0rge --skill\n"1010 }1111 1212 const VALS: &[&str] = &["name", "color", "description", "desc", "jq"];@@ -169,7 +169,7 @@ fn list(fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {169169                 ]170170             })171171             .collect();172-        println!("{}", output::align(&rows));172+        output::out(&format!("{}\n", output::align(&rows)));173173     }174174     Ok(())175175 }@@ -190,7 +190,7 @@ fn create(fs: &FlagSet, p: &[String], fg: &Forge, json: bool, jq: Option<&str>)190190     if json {191191         output::emit_json(&l, jq);192192     } else {193-        println!("{}", l.name);193+        output::out(&format!("{}\n", l.name));194194     }195195     Ok(())196196 }@@ -210,7 +210,7 @@ fn edit(fs: &FlagSet, p: &[String], fg: &Forge, json: bool, jq: Option<&str>) ->210210     if json {211211         output::emit_json(&done, jq);212212     } else {213-        println!("{}", done.name);213+        output::out(&format!("{}\n", done.name));214214     }215215     Ok(())216216 }
crates/cli/src/cmd/merge.rs+3 −3
diff --git a/crates/cli/src/cmd/merge.rs b/crates/cli/src/cmd/merge.rsindex be2a21b..06e2cd6 100644--- a/crates/cli/src/cmd/merge.rs+++ b/crates/cli/src/cmd/merge.rs@@ -11,7 +11,7 @@ use std::process::Command;1111 use std::sync::atomic::{AtomicU64, Ordering};1212 1313 pub fn help() -> &'static str {14-    "f0rge merge: imports\nusage: f0rge merge [--json] <folder|zip|branch-name>\nconflicts stop; agent resolves\n"14+    "f0rge merge: imports\nusage: f0rge merge [--json] <folder|zip|branch-name>\nconflicts stop; agent resolves\ncontract: f0rge --skill\n"1515 }1616 1717 static CTR: AtomicU64 = AtomicU64::new(0);@@ -709,7 +709,7 @@ pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {709709         if json {710710             output::emit_json(&rec, jq.as_deref());711711         } else {712-            println!("already landed: {}", rec.pr);712+            output::out(&format!("already landed: {}\n", rec.pr));713713         }714714         return Ok(());715715     }@@ -839,7 +839,7 @@ pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {839839     if json {840840         output::emit_json(&rec, jq.as_deref());841841     } else {842-        println!("merged {}", payload.id);842+        output::out(&format!("merged {}\n", payload.id));843843     }844844     Ok(())845845 }
crates/cli/src/cmd/milestone.rs+4 −4
diff --git a/crates/cli/src/cmd/milestone.rs b/crates/cli/src/cmd/milestone.rsindex d6a0b58..e964fb2 100644--- a/crates/cli/src/cmd/milestone.rs+++ b/crates/cli/src/cmd/milestone.rs@@ -6,7 +6,7 @@ use crate::CliError;66 use f0rge_core::{Forge, Milestone};77 88 pub fn help() -> &'static str {9-    "f0rge milestone: spans\nusage: f0rge milestone [--json] <list|create|edit>\nfile: .forge/milestones.md\n"9+    "f0rge milestone: spans\nusage: f0rge milestone [--json] <list|create|edit>\nfile: .forge/milestones.md\ncontract: f0rge --skill\n"1010 }1111 1212 const VALS: &[&str] = &["name", "state", "due", "description", "desc", "jq"];@@ -180,7 +180,7 @@ fn list(fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {180180                 ]181181             })182182             .collect();183-        println!("{}", output::align(&rows));183+        output::out(&format!("{}\n", output::align(&rows)));184184     }185185     Ok(())186186 }@@ -205,7 +205,7 @@ fn create(fs: &FlagSet, p: &[String], fg: &Forge, json: bool, jq: Option<&str>)205205     if json {206206         output::emit_json(&m, jq);207207     } else {208-        println!("{}", m.name);208+        output::out(&format!("{}\n", m.name));209209     }210210     Ok(())211211 }@@ -228,7 +228,7 @@ fn edit(fs: &FlagSet, p: &[String], fg: &Forge, json: bool, jq: Option<&str>) ->228228     if json {229229         output::emit_json(&done, jq);230230     } else {231-        println!("{}", done.name);231+        output::out(&format!("{}\n", done.name));232232     }233233     Ok(())234234 }
crates/cli/src/cmd/mod.rs+1 −1
diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rsindex 21def66..0e4afca 100644--- a/crates/cli/src/cmd/mod.rs+++ b/crates/cli/src/cmd/mod.rs@@ -36,7 +36,7 @@ pub fn dispatch(3636 ) -> Result<(), CliError> {3737     // -h anywhere in sub args prints that subcommand's help.3838     if args.iter().any(|a| a == "-h" || a == "--help") {39-        print!("{}", help_for(sub).unwrap_or(ROOT_HELP));39+        crate::output::out(help_for(sub).unwrap_or(ROOT_HELP));4040         return Ok(());4141     }4242     match sub {
crates/cli/src/cmd/pr.rs+8 −8
diff --git a/crates/cli/src/cmd/pr.rs b/crates/cli/src/cmd/pr.rsindex 1266144..180765b 100644--- a/crates/cli/src/cmd/pr.rs+++ b/crates/cli/src/cmd/pr.rs@@ -12,7 +12,7 @@ use std::process::Command;1212 use std::sync::atomic::{AtomicU64, Ordering};1313 1414 pub fn help() -> &'static str {15-    "f0rge pr: pull requests\nusage: f0rge pr [--json] <create|diff|review|comment|merge|close|list>\nverbs: create diff review comment merge close list\n"15+    "f0rge pr: pull requests\nusage: f0rge pr [--json] <create|diff|review|comment|merge|close|list>\nverbs: create diff review comment merge close list\ncontract: f0rge --skill\n"1616 }1717 1818 static TMP_CTR: AtomicU64 = AtomicU64::new(0);@@ -224,7 +224,7 @@ fn create(224224     if json {225225         output::emit_json(&PrJson::new(n, &pr), jq);226226     } else {227-        println!("{id}");227+        output::out(&format!("{id}\n"));228228     }229229     Ok(())230230 }@@ -238,7 +238,7 @@ fn diff(p: &[String], fg: &Forge) -> Result<(), CliError> {238238     if !text.ends_with('\n') {239239         text.push('\n');240240     }241-    print!("{text}");241+    output::out(&text);242242     Ok(())243243 }244244 @@ -270,7 +270,7 @@ fn review(270270     if json {271271         output::emit_json(&serde_json::json!({"created": created, "id": rid}), jq);272272     } else {273-        println!("{rid}");273+        output::out(&format!("{rid}\n"));274274     }275275     Ok(())276276 }@@ -411,7 +411,7 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C411411         if json {412412             output::emit_json(&rec, jq);413413         } else {414-            println!("already landed: {}", rec.pr);414+            output::out(&format!("already landed: {}\n", rec.pr));415415         }416416         return Ok(());417417     }@@ -459,7 +459,7 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C459459         if json {460460             output::emit_json(&PrJson::new(num, &pr), jq);461461         } else {462-            println!("merged {id}");462+            output::out(&format!("merged {id}\n"));463463         }464464         Ok(())465465     } else {@@ -502,7 +502,7 @@ fn close(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C502502     if json {503503         output::emit_json(&PrJson::new(n, &pr), jq);504504     } else {505-        println!("closed {id}");505+        output::out(&format!("closed {id}\n"));506506     }507507     Ok(())508508 }@@ -544,7 +544,7 @@ fn list(fs: &FlagSet, fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), Cl544544             output::emit_str(&rows.join("\n"), jq);545545         }546546     } else if !human.is_empty() {547-        println!("{}", output::align(&human));547+        output::out(&format!("{}\n", output::align(&human)));548548     }549549     Ok(())550550 }
crates/cli/src/cmd/release.rs+6 −5
diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rsindex 2ad135f..1264fe2 100644--- a/crates/cli/src/cmd/release.rs+++ b/crates/cli/src/cmd/release.rs@@ -12,7 +12,7 @@ use std::process::Command;1212 use std::sync::atomic::{AtomicU64, Ordering};1313 1414 pub fn help() -> &'static str {15-    "f0rge release: versions\nusage: f0rge release <create|list|view> [ver]\nnotes: .forge/releases/<ver>.md\n"15+    "f0rge release: versions\nusage: f0rge release <create|list|view> [ver]\nnotes: .forge/releases/<ver>.md\ncontract: f0rge --skill\n"1616 }1717 1818 fn spec() -> Vec<args::FlagSpec> {@@ -409,7 +409,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {409409             vec![r.version, r.created, st]410410         })411411         .collect();412-    println!("{}", output::align(&grid));412+    output::out(&format!("{}\n", output::align(&grid)));413413     Ok(())414414 }415415 @@ -434,10 +434,11 @@ fn view(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {434434         );435435         return Ok(());436436     }437-    print!("{}", p.body);438-    if !p.body.is_empty() && !p.body.ends_with('\n') {439-        println!();437+    let mut body = p.body.clone();438+    if !body.is_empty() && !body.ends_with('\n') {439+        body.push('\n');440440     }441+    output::out(&body);441442     Ok(())442443 }443444 
crates/cli/src/cmd/skill.rs+1 −1
diff --git a/crates/cli/src/cmd/skill.rs b/crates/cli/src/cmd/skill.rsindex 2974e70..43b7b08 100644--- a/crates/cli/src/cmd/skill.rs+++ b/crates/cli/src/cmd/skill.rs@@ -3,7 +3,7 @@33 use crate::CliError;44 55 pub fn help() -> &'static str {6-    "f0rge skill: contract\nusage: f0rge [--skill|skill]\nprints schema v1 contract\n"6+    "f0rge skill: contract\nusage: f0rge [--skill|skill]\nprints schema v1 contract\ncontract: f0rge --skill\n"77 }88 99 /// Contract text: <=30 lines, <=900 bytes. Schema v1 decisions only.
crates/cli/src/cmd/task.rs+6 −3
diff --git a/crates/cli/src/cmd/task.rs b/crates/cli/src/cmd/task.rsindex b43b585..0a4b2fc 100644--- a/crates/cli/src/cmd/task.rs+++ b/crates/cli/src/cmd/task.rs@@ -5,7 +5,7 @@ use f0rge_core::{Forge, Task};55 use serde::Serialize;66 77 pub fn help() -> &'static str {8-    "f0rge task: kanban cards\nusage: f0rge task [--json] <create|list|move|done>\ncreate: --title T [--body B] [--column C]\ncols: todo doing blocked done\n"8+    "f0rge task: kanban cards\nusage: f0rge task [--json] <create|list|move|done>\ncreate: --title T [--body B] [--column C]\ncols: todo doing blocked done\ncontract: f0rge --skill\n"99 }1010 1111 const COLS: &[&str] = &["todo", "doing", "blocked", "done"];@@ -276,12 +276,15 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {276276     if all.is_empty() {277277         return Ok(());278278     }279+    let mut human = String::new();279280     for col in COLS {280-        println!("{col}:");281+        human.push_str(col);282+        human.push_str(":\n");281283         for (t, _) in all.iter().filter(|(t, _)| &t.status == col) {282-            println!("  {}  {}", t.id, t.title);284+            human.push_str(&format!("  {}  {}\n", t.id, t.title));283285         }284286     }287+    output::out(&human);285288     Ok(())286289 }287290 
crates/cli/src/cmd/wiki.rs+3 −3
diff --git a/crates/cli/src/cmd/wiki.rs b/crates/cli/src/cmd/wiki.rsindex 0a02e7d..017c0b0 100644--- a/crates/cli/src/cmd/wiki.rs+++ b/crates/cli/src/cmd/wiki.rs@@ -8,7 +8,7 @@ use serde::Serialize;88 use std::path::{Component, Path};99 1010 pub fn help() -> &'static str {11-    "f0rge wiki: memory\nusage: f0rge wiki <get|put|list> <path>\nindex derived: tree + first H1\n"11+    "f0rge wiki: memory\nusage: f0rge wiki <get|put|list> <path>\nindex derived: tree + first H1\ncontract: f0rge --skill\n"1212 }1313 1414 fn spec() -> Vec<args::FlagSpec> {@@ -133,7 +133,7 @@ fn put(fs: &args::FlagSet, fg: &Forge, raw: &str, src: &str) -> Result<(), CliEr133133         output::emit_json(&PutJson { path: rel, title, size }, fs.get("jq"));134134         return Ok(());135135     }136-    println!(".forge/wiki/{rel}");136+    output::out(&format!(".forge/wiki/{rel}\n"));137137     Ok(())138138 }139139 @@ -223,7 +223,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {223223     }224224     let grid: Vec<Vec<String>> =225225         idx.into_iter().map(|e| vec![e.path, e.title]).collect();226-    println!("{}", output::align(&grid));226+    output::out(&format!("{}\n", output::align(&grid)));227227     Ok(())228228 }229229 
crates/cli/tests/cli.rs+55 −0
diff --git a/crates/cli/tests/cli.rs b/crates/cli/tests/cli.rsindex 8dcaa4c..623fc17 100644--- a/crates/cli/tests/cli.rs+++ b/crates/cli/tests/cli.rs@@ -137,3 +137,58 @@ fn jq_missing_is_one_line_note() {137137         assert_eq!(err.lines().count(), 1, "stderr:\n{err}");138138     }139139 }140+141+#[test]142+fn epipe_closed_stdout_is_clean_exit_zero() {143+    // #18: closed stdout pipe = clean end, exit 0, no panic, no stderr.144+    use std::process::Stdio;145+    let dir = std::env::temp_dir()146+        .join(format!("f0rge-cli-epipe-{}-{}", std::process::id(), line!()));147+    let _ = std::fs::remove_dir_all(&dir);148+    std::fs::create_dir_all(&dir).unwrap();149+    let out = bin().current_dir(&dir).arg("init").output().expect("init");150+    assert!(out.status.success());151+    // Wide listing so output exceeds the pipe buffer: reader close => EPIPE mid-write.152+    for i in 0..300 {153+        let out = bin()154+            .current_dir(&dir)155+            .args(["issue", "create", "--title", &format!("bulk-{i}")])156+            .output()157+            .expect("create");158+        assert!(out.status.success(), "seed {i} failed");159+    }160+    for args in [vec!["issue", "list", "--json"], vec!["issue", "list"]] {161+        let mut child = bin()162+            .current_dir(&dir)163+            .args(&args)164+            .stdout(Stdio::piped())165+            .stderr(Stdio::piped())166+            .spawn()167+            .expect("spawn");168+        // Close the reader at once: `| true` with the read end already gone.169+        drop(child.stdout.take());170+        let res = child.wait_with_output().expect("wait");171+        let code = res.status.code().unwrap_or(-1);172+        let err = String::from_utf8_lossy(&res.stderr);173+        assert_eq!(code, 0, "{args:?} exit {code}, stderr: {err}");174+        assert!(!err.contains("panicked"), "{args:?} panicked: {err}");175+        assert!(err.is_empty(), "{args:?} stderr not empty: {err}");176+    }177+    let _ = std::fs::remove_dir_all(&dir);178+}179+#[test]180+fn sub_help_single_style_with_contract_pointer() {181+    // #19: every `f0rge <cmd> -h` = title + usage + truthful detail + contract pointer.182+    for sub in SUBS {183+        let (out, _, code) = run(&[sub, "-h"]);184+        assert_eq!(code, 0, "{sub} -h exit {code}");185+        let first = out.lines().next().unwrap_or("");186+        assert!(first.starts_with(&format!("f0rge {sub}")), "{sub} style: {first:?}");187+        assert!(out.contains("usage:"), "{sub} missing usage:\n{out}");188+        assert!(189+            out.trim_end().ends_with("contract: f0rge --skill"),190+            "{sub} missing contract pointer:\n{out}"191+        );192+    }193+}194+