Skip to main content

aqc_git_helpers/
git.rs

1//! The crate's subprocess boundary: every `git` invocation lives here.
2
3#![expect(
4    clippy::disallowed_methods,
5    reason = "Running git as a subprocess is this crate's purpose; the invocations are confined to this boundary module."
6)]
7
8use std::path::Path;
9use std::process::Command;
10
11use crate::error::GitError;
12use crate::porcelain::parse_porcelain_v1z;
13use crate::status::{ChangeStatus, PorcelainOptions, WorktreeChange};
14
15/// Run porcelain status at `repo_root` and return all changes.
16///
17/// # Errors
18///
19/// [`GitError`] per the contract in `plan.md`.
20pub fn worktree_changes(
21    repo_root: impl AsRef<Path>,
22    options: PorcelainOptions,
23) -> Result<Vec<WorktreeChange>, GitError> {
24    let repo_root = repo_root.as_ref();
25    let mut command = Command::new("git");
26    // Pin the locale: classification must not depend on translated output.
27    let _ = command.env("LC_ALL", "C");
28    let _ = command
29        .arg("-C")
30        .arg(repo_root)
31        .args(["status", "--porcelain=v1", "-z"]);
32    if options.include_ignored {
33        let _ = command.arg("--ignored");
34    }
35    let output = command.output().map_err(|source| {
36        if source.kind() == std::io::ErrorKind::NotFound {
37            GitError::GitNotInstalled
38        } else {
39            GitError::CommandFailed {
40                command: "git status --porcelain=v1 -z".to_owned(),
41                stderr: source.to_string(),
42            }
43        }
44    })?;
45    if !output.status.success() {
46        // Decide repo-ness structurally, never by stderr text.
47        if !is_inside_work_tree(repo_root) {
48            return Err(GitError::NotARepository);
49        }
50        return Err(GitError::CommandFailed {
51            command: "git status --porcelain=v1 -z".to_owned(),
52            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
53        });
54    }
55    let text = String::from_utf8(output.stdout).map_err(|_| GitError::ParseError {
56        message: "porcelain output is not UTF-8".to_owned(),
57    })?;
58    let changes = parse_porcelain_v1z(&text)?;
59    Ok(changes
60        .into_iter()
61        .filter(|c| match c.status {
62            ChangeStatus::Untracked => options.include_untracked,
63            ChangeStatus::Ignored => options.include_ignored,
64            ChangeStatus::Tracked { .. } | ChangeStatus::Conflicted => true,
65        })
66        .collect())
67}
68
69/// True when `git rev-parse --is-inside-work-tree` answers `true`.
70fn is_inside_work_tree(repo_root: &Path) -> bool {
71    let mut command = Command::new("git");
72    let _ = command.env("LC_ALL", "C");
73    let output = command
74        .arg("-C")
75        .arg(repo_root)
76        .args(["rev-parse", "--is-inside-work-tree"])
77        .output();
78    output.is_ok_and(|out| out.status.success() && out.stdout.starts_with(b"true"))
79}