merge 25c6502b-0bc4-4e67-8ad9-2519e1256ef0
Message
merge 25c6502b-0bc4-4e67-8ad9-2519e1256ef0
Diff
crates/cli/src/cmd/ci.rs+17 −14
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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+