oxicode/tui_vt/git_tui/git_io.rs
1//! Thin wrappers around the `git` binary for the TUI overlay.
2//!
3//! All git calls in the overlay funnel through [`run_git`] so:
4//!
5//! * the binary path lives in exactly one place,
6//! * stderr is folded into `anyhow::Error` with the failing argv,
7//! * empty-repos / non-repo paths surface as errors the slash command can
8//! reply with instead of panicking.
9//!
10//! Every helper here shells out to `git`. No in-process git library is
11//! pulled in — keeps the TUI overlay's dependency surface bounded.
12
13use std::path::Path;
14use std::process::Command;
15
16use super::state::{StatusEntry, parse_status_porcelain_z};
17
18/// Run `git <args…>` in `cwd` and return stdout as a `String`.
19///
20/// Stderr is captured and surfaced as part of the error so the slash
21/// command can `ctx.reply(Error, ...)` with the real reason. Non-zero
22/// exit codes are always an error — callers that want to treat empty
23/// output as success must inspect the returned `Ok(String)` themselves
24/// (see [`diff_head`]).
25pub fn run_git(cwd: &Path, args: &[&str]) -> anyhow::Result<String> {
26 let output = Command::new("git")
27 .args(args)
28 .current_dir(cwd)
29 .output()
30 .map_err(|e| anyhow::anyhow!("failed to spawn git {args:?}: {e}"))?;
31 if !output.status.success() {
32 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
33 return Err(anyhow::anyhow!(
34 "git {args:?} failed ({}): {stderr}",
35 output.status
36 ));
37 }
38 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
39}
40
41/// `git status --porcelain -z` parsed into [`StatusEntry`]s.
42pub fn status_porcelain_z(cwd: &Path) -> anyhow::Result<Vec<StatusEntry>> {
43 let raw = run_git(cwd, &["status", "--porcelain", "-z"])?;
44 Ok(parse_status_porcelain_z(raw.as_bytes()))
45}
46
47/// `git diff HEAD --no-ext-diff` with a fallback for fresh repos.
48///
49/// In a fresh repo `git diff HEAD` exits with status 128 (fatal: bad
50/// revision) because there is no HEAD yet. We detect that case by
51/// probing `git rev-parse --verify HEAD` first; when HEAD does not
52/// resolve we return `Ok(String::new())` so the overlay can render an
53/// empty diff doc instead of failing. Real errors (non-zero exit for
54/// any other reason) propagate to the caller unchanged.
55pub fn diff_head(cwd: &Path) -> anyhow::Result<String> {
56 // HEAD existence probe — distinguishes "no HEAD yet" (fresh repo,
57 // `git diff HEAD` exits 128) from a real diff failure. When HEAD is
58 // missing we return an empty string; the parser turns that into an
59 // empty diff doc the overlay can render without erroring.
60 let head_ok = Command::new("git")
61 .args(["rev-parse", "--verify", "HEAD"])
62 .current_dir(cwd)
63 .output()
64 .map(|o| o.status.success())
65 .unwrap_or(false);
66 if head_ok {
67 run_git(cwd, &["diff", "HEAD", "--no-ext-diff", "--no-color"])
68 } else {
69 Ok(String::new())
70 }
71}