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}