Skip to main content

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}