ferridriver_test/git_info.rs
1//! Capture git metadata for `captureGitInfo` (§7.26).
2//!
3//! Returns a `GitInfo` snapshot built from `git rev-parse HEAD`,
4//! `git symbolic-ref --short HEAD`, and `git status --porcelain` so
5//! reporters can annotate test results with the run's git context.
6//! Outside of a git repo the helper returns a default record with
7//! every field empty rather than failing the run.
8
9use serde::{Deserialize, Serialize};
10use std::process::Command;
11
12/// Minimal git metadata surfaced via `RunSummary.metadata.git`. Each
13/// field is best-effort — missing data is rendered as empty strings
14/// so the JSON shape stays predictable regardless of repo state.
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct GitInfo {
17 /// Full commit hash of `HEAD`. Empty when outside a git repo or
18 /// the branch has no commits.
19 pub commit: String,
20 /// Symbolic branch name. Empty in detached-HEAD state.
21 pub branch: String,
22 /// `true` when the worktree has uncommitted changes (porcelain
23 /// status non-empty).
24 pub dirty: bool,
25}
26
27impl GitInfo {
28 /// Run `git` against the current working directory and return what
29 /// the helper could collect. Never panics — every shell-out failure
30 /// degrades to an empty field.
31 pub fn capture() -> Self {
32 let mut info = Self::default();
33 if let Some(out) = run_git(&["rev-parse", "HEAD"]) {
34 info.commit = out;
35 }
36 if let Some(out) = run_git(&["symbolic-ref", "--short", "HEAD"]) {
37 info.branch = out;
38 }
39 if let Some(out) = run_git(&["status", "--porcelain"]) {
40 info.dirty = !out.is_empty();
41 }
42 info
43 }
44
45 /// Files changed between `HEAD` and `reference`. Used by
46 /// `--only-changed`. `reference` may be empty (`""`), in which
47 /// case the working-tree diff is returned. Returns `None` when
48 /// `git` is unavailable so callers can skip the filter.
49 pub fn changed_files(reference: &str) -> Option<Vec<String>> {
50 let args: Vec<&str> = if reference.is_empty() {
51 vec!["status", "--porcelain"]
52 } else {
53 vec!["diff", "--name-only", reference, "HEAD"]
54 };
55 let raw = run_git(&args)?;
56 if reference.is_empty() {
57 // Porcelain lines look like `XY <path>` with two-char status
58 // followed by a space.
59 let mut files = Vec::new();
60 for line in raw.lines() {
61 if line.len() <= 3 {
62 continue;
63 }
64 let path = &line[3..];
65 files.push(path.trim().to_string());
66 }
67 Some(files)
68 } else {
69 Some(
70 raw
71 .lines()
72 .map(|s| s.trim().to_string())
73 .filter(|s| !s.is_empty())
74 .collect(),
75 )
76 }
77 }
78}
79
80fn run_git(args: &[&str]) -> Option<String> {
81 let out = Command::new("git").args(args).output().ok()?;
82 if !out.status.success() {
83 return None;
84 }
85 let stdout = String::from_utf8(out.stdout).ok()?;
86 Some(stdout.trim().to_string())
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn capture_in_a_repo_returns_a_commit_or_empty_default() {
95 // Smoke test: under cargo test the cwd is the workspace root,
96 // which is a git repo. We only assert the helper doesn't panic
97 // and returns a string-shaped value.
98 let info = GitInfo::capture();
99 // Either we picked up a commit or we degraded gracefully.
100 let _ = info.commit.len();
101 }
102}