Skip to main content

kaizen/core/
repo.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Git-bound repo facts. Shell out only at boundary.
3
4use 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}