Skip to main content

hh_record/
git.rs

1//! Git metadata capture (FR-1.2: branch, HEAD sha, dirty flag).
2//!
3//! Shells out to `git` — the SRS does not list a git library in §6, and the
4//! recorder already spawns subprocesses. All failures are non-fatal: a missing
5//! `git` binary or a non-repo cwd simply yield `None` fields (best-effort, per
6//! "if the cwd is a repo"). We never let git problems fail a recording.
7
8use std::path::Path;
9use std::process::{Command, Stdio};
10
11/// Git metadata captured for a session (FR-1.2). All fields are `None` if the
12/// cwd is not a git repo or `git` is unavailable.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct GitMeta {
15    /// Current branch name (e.g. `main`); `None` in a detached-HEAD repo.
16    pub branch: Option<String>,
17    /// HEAD commit sha (short or full — whatever `git` prints).
18    pub sha: Option<String>,
19    /// Whether the working tree had uncommitted changes.
20    pub dirty: Option<bool>,
21}
22
23impl GitMeta {
24    /// Capture git metadata for `cwd`, best-effort (FR-1.2). Never errors.
25    #[must_use]
26    pub fn capture(cwd: &Path) -> Self {
27        let branch = git_output(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
28        // `--abbrev-ref HEAD` prints "HEAD" when detached; treat that as no
29        // branch (the SRS wants the branch name, not a sentinel).
30        let branch = branch.and_then(|b| {
31            let trimmed = b.trim();
32            if trimmed.is_empty() || trimmed == "HEAD" {
33                None
34            } else {
35                Some(trimmed.to_string())
36            }
37        });
38        let sha = git_output(cwd, &["rev-parse", "HEAD"])
39            .map(|s| s.trim().to_string())
40            .filter(|s| !s.is_empty());
41        let dirty = git_exit_ok(cwd, &["diff", "--quiet"]).map(|clean| !clean);
42        Self { branch, sha, dirty }
43    }
44}
45
46/// Run `git` in `cwd` with the given args and return stdout on success.
47/// stderr is discarded so a non-repo cwd does not leak `fatal: not a git
48/// repository` to the user's terminal.
49fn git_output(cwd: &Path, args: &[&str]) -> Option<String> {
50    Command::new("git")
51        .args(args)
52        .current_dir(cwd)
53        .stderr(Stdio::null())
54        .output()
55        .ok()
56        .filter(|o| o.status.success())
57        .and_then(|o| String::from_utf8(o.stdout).ok())
58}
59
60/// Run `git` in `cwd` with the given args; return `Some(true)` if it exits
61/// success (e.g. `diff --quiet` → clean), `Some(false)` on nonzero (dirty),
62/// `None` if `git` could not be run at all. stderr is discarded (see
63/// [`git_output`]).
64fn git_exit_ok(cwd: &Path, args: &[&str]) -> Option<bool> {
65    Command::new("git")
66        .args(args)
67        .current_dir(cwd)
68        .stderr(Stdio::null())
69        .status()
70        .ok()
71        .map(|s| s.success())
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use std::env;
78
79    /// Smoke test: capture in the repo root (CI runs inside this repo, so git
80    /// is present and `GitMeta` should be fully populated). If git is missing
81    /// (unlikely on CI) the fields are `None` and we still pass — best-effort.
82    #[test]
83    fn capture_in_repo_yields_metadata_or_none() {
84        let cwd = env::current_dir().unwrap();
85        let meta = GitMeta::capture(&cwd);
86        if let Some(sha) = &meta.sha {
87            assert!(!sha.is_empty());
88        }
89        // `dirty` should at least be determined when sha is present.
90        if meta.sha.is_some() {
91            assert!(meta.dirty.is_some());
92        }
93    }
94}