use crate::storage::LedgerRow;
use std::path::Path;
pub const NO_GIT_CONTEXT: &str = "<no git context>";
pub const MAX_GIT_CONTEXT_CHARS: usize = 200;
pub fn extract_git_context(_rows: &[LedgerRow], cwd: Option<&Path>) -> String {
let dir = match cwd {
Some(p) if p.exists() => p,
_ => return NO_GIT_CONTEXT.to_string(),
};
let head_sha = match run_git_in(dir, &["rev-parse", "HEAD"]) {
Some(output) if !output.is_empty() => {
let short: String = output.chars().take(8).collect();
short
}
_ => return NO_GIT_CONTEXT.to_string(),
};
let diff_stat = run_git_in(dir, &["diff", "--stat"])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let result = match diff_stat {
Some(stat) => {
let summary_line = stat.lines().last().unwrap_or(&stat).trim().to_string();
format!("HEAD: {}; diff: {}", head_sha, summary_line)
}
None => format!("HEAD: {}", head_sha),
};
if result.chars().count() <= MAX_GIT_CONTEXT_CHARS {
result
} else {
let truncated: String = result.chars().take(MAX_GIT_CONTEXT_CHARS).collect();
format!("{truncated}…")
}
}
const GIT_TIMEOUT_SECS: u64 = 5;
fn run_git_in(cwd: &Path, args: &[&str]) -> Option<String> {
use std::process::Stdio;
let mut child = std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
.ok()?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(GIT_TIMEOUT_SECS);
loop {
match child.try_wait().ok()? {
Some(status) => {
if !status.success() {
return None;
}
let mut stdout = child.stdout.take()?;
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut stdout, &mut buf).ok()?;
return Some(String::from_utf8_lossy(&buf).trim().to_string());
}
None if std::time::Instant::now() > deadline => {
let _ = child.kill();
let _ = child.wait();
return None;
}
None => std::thread::sleep(std::time::Duration::from_millis(25)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git_init_with_commit(dir: &std::path::Path) {
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("git command failed");
};
run(&["init"]);
run(&["config", "user.email", "test@example.com"]);
run(&["config", "user.name", "Test"]);
run(&["commit", "--allow-empty", "-m", "init"]);
}
#[test]
fn extracts_head_in_real_git_repo() {
let dir = tempfile::tempdir().unwrap();
git_init_with_commit(dir.path());
let result = extract_git_context(&[], Some(dir.path()));
assert!(
result.starts_with("HEAD: "),
"expected HEAD: prefix, got: {result}"
);
let sha_part = result.trim_start_matches("HEAD: ");
let sha: String = sha_part.chars().take(8).collect();
assert_eq!(sha.len(), 8);
assert!(
sha.chars().all(|c| c.is_ascii_hexdigit()),
"sha should be hex, got: {sha}"
);
}
#[test]
fn non_git_dir_returns_sentinel() {
let dir = tempfile::tempdir().unwrap();
let result = extract_git_context(&[], Some(dir.path()));
assert_eq!(result, NO_GIT_CONTEXT);
}
#[test]
fn none_cwd_returns_sentinel() {
let result = extract_git_context(&[], None);
assert_eq!(result, NO_GIT_CONTEXT);
}
#[test]
fn handles_dirty_diff() {
let dir = tempfile::tempdir().unwrap();
git_init_with_commit(dir.path());
std::fs::write(dir.path().join("hello.txt"), "hello\n").unwrap();
Command::new("git")
.args(["add", "hello.txt"])
.current_dir(dir.path())
.output()
.unwrap();
let result = extract_git_context(&[], Some(dir.path()));
assert!(
result.starts_with("HEAD: "),
"expected HEAD: prefix, got: {result}"
);
}
#[test]
fn handles_empty_ledger() {
let result = extract_git_context(&[], None);
assert_eq!(result, NO_GIT_CONTEXT);
}
#[test]
fn nonexistent_path_returns_sentinel() {
let path = std::path::Path::new("/tmp/carryover-test-nonexistent-dir-xyz");
let result = extract_git_context(&[], Some(path));
assert_eq!(result, NO_GIT_CONTEXT);
}
}