Skip to main content

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}