1use anyhow::{Context, Result};
5use blake3::Hasher;
6use std::path::Path;
7use std::process::Command;
8
9#[derive(Debug, Clone, Default)]
10pub struct RepoBinding {
11 pub start_commit: Option<String>,
12 pub end_commit: Option<String>,
13 pub branch: Option<String>,
14 pub dirty_start: Option<bool>,
15 pub dirty_end: Option<bool>,
16 pub source: Option<String>,
17}
18
19pub fn binding_for_session(
20 workspace: &Path,
21 started_at_ms: u64,
22 ended_at_ms: Option<u64>,
23) -> RepoBinding {
24 if !workspace.join(".git").exists() {
25 return RepoBinding::default();
26 }
27 let branch = git_trimmed(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok();
28 let dirty = git_dirty(workspace).ok();
29 let start_commit = git_commit_before(workspace, started_at_ms).ok().flatten();
30 let end_commit = git_commit_before(workspace, ended_at_ms.unwrap_or(started_at_ms))
31 .ok()
32 .flatten()
33 .or_else(|| start_commit.clone());
34 RepoBinding {
35 start_commit,
36 end_commit,
37 branch,
38 dirty_start: dirty,
39 dirty_end: dirty,
40 source: Some("git_shell".into()),
41 }
42}
43
44pub fn repo_head(workspace: &Path) -> Result<Option<String>> {
45 if !workspace.join(".git").exists() {
46 return Ok(None);
47 }
48 git_trimmed(workspace, &["rev-parse", "HEAD"]).map(Some)
49}
50
51pub fn dirty_fingerprint(workspace: &Path) -> Result<String> {
52 let status = git_output(workspace, &["status", "--porcelain"])?;
53 let mut hasher = Hasher::new();
54 hasher.update(status.as_bytes());
55 Ok(hex::encode(hasher.finalize().as_bytes()))
56}
57
58pub fn tracked_files(workspace: &Path) -> Result<Vec<String>> {
59 let out = git_output(workspace, &["ls-files"])?;
60 Ok(out
61 .lines()
62 .map(str::trim)
63 .filter(|s| !s.is_empty())
64 .map(ToOwned::to_owned)
65 .collect())
66}
67
68fn git_commit_before(workspace: &Path, ts_ms: u64) -> Result<Option<String>> {
69 let secs = (ts_ms / 1000).max(1);
70 let out = git_output(
71 workspace,
72 &["rev-list", "-1", &format!("--before=@{secs}"), "HEAD"],
73 )?;
74 let trimmed = out.trim();
75 if trimmed.is_empty() {
76 return Ok(None);
77 }
78 Ok(Some(trimmed.to_string()))
79}
80
81fn git_dirty(workspace: &Path) -> Result<bool> {
82 let out = git_output(workspace, &["status", "--porcelain"])?;
83 Ok(!out.trim().is_empty())
84}
85
86fn git_trimmed(workspace: &Path, args: &[&str]) -> Result<String> {
87 Ok(git_output(workspace, args)?.trim().to_string())
88}
89
90fn git_output(workspace: &Path, args: &[&str]) -> Result<String> {
91 let out = Command::new("git")
92 .arg("-C")
93 .arg(workspace)
94 .args(args)
95 .output()
96 .with_context(|| format!("git {:?}", args))?;
97 if !out.status.success() {
98 anyhow::bail!(
99 "git {:?} failed: {}",
100 args,
101 String::from_utf8_lossy(&out.stderr)
102 );
103 }
104 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
105}