diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs index d3d373d..f07b266 100644 --- a/crates/relicario-cli/src/helpers.rs +++ b/crates/relicario-cli/src/helpers.rs @@ -55,6 +55,37 @@ pub fn git_command(repo: &Path, args: &[&str]) -> Command { cmd } +/// Run `git ` in `repo` with the same hardening as `git_command`, +/// capturing stdout/stderr and reproducing them on failure so the caller +/// sees git's exact diagnostic instead of just a verb. +/// +/// `context` should be a short caller-supplied label like `"commit add: "` +/// or `"sync: git push"`; it prefixes the bail message so the failing call is +/// identifiable from the error alone. +/// +/// Trade-off vs. `git_command(...).status()`: this captures the child's stderr +/// (so live progress disappears during long-running fetches/pushes) but the +/// captured chunk is replayed verbatim on failure. The win is that +/// non-interactive callers (tests, hooks, CI, redirected stdout) finally see +/// pre-receive rejections, signing-key prompts, and dirty-tree complaints +/// instead of one-line "git X failed" bails. Use `git_command` directly when +/// live streaming is required. +pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> { + let output = git_command(repo, args) + .output() + .with_context(|| format!("{context}: failed to spawn git"))?; + if !output.status.success() { + if !output.stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stdout)); + } + if !output.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + } + bail!("{context}: git failed ({})", output.status); + } + Ok(()) +} + /// Format a Unix-seconds timestamp as an ISO-8601 UTC string. /// Audit M11: replaces the old `now_iso8601` helper that actually returned /// a numeric string. @@ -220,6 +251,24 @@ mod tests { assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}"); } + #[test] + fn git_run_bails_with_context_on_failure() { + // Empty tempdir — `git status` will fail with "not a git repository". + let tmp = TempDir::new().unwrap(); + let err = git_run(tmp.path(), &["status"], "test_ctx").unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("test_ctx"), "context not in error: {msg}"); + assert!(msg.contains("git failed"), "missing failure marker: {msg}"); + } + + #[test] + fn git_run_succeeds_for_a_zero_exit_command() { + // `git --version` always succeeds and is independent of cwd. + let tmp = TempDir::new().unwrap(); + git_run(tmp.path(), &["--version"], "version probe") + .expect("git --version should succeed"); + } + #[test] fn humanize_age_buckets() { assert_eq!(humanize_age(0), "just now");