use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Default)]
pub struct GitSnapshot {
pub branch: Option<String>,
pub head_oneline: Option<String>,
pub status_short: String,
pub is_dirty: bool,
}
const STATUS_MAX_LINES: usize = 20;
#[derive(Debug, Clone, Default)]
pub struct EnvSnapshot {
pub git: Option<GitSnapshot>,
}
impl EnvSnapshot {
pub fn capture(wd: &Path) -> Self {
if !is_git_repo(wd) {
return Self::default();
}
let branch = run_git(wd, &["branch", "--show-current"]).filter(|s| !s.is_empty());
let head_oneline = run_git(wd, &["log", "-1", "--format=%h %s"]).filter(|s| !s.is_empty());
let status_raw = run_git(wd, &["status", "--short"]).unwrap_or_default();
let is_dirty = !status_raw.trim().is_empty();
let status_short = truncate_status(&status_raw, STATUS_MAX_LINES);
Self {
git: Some(GitSnapshot {
branch,
head_oneline,
status_short,
is_dirty,
}),
}
}
pub fn as_prompt_section(&self) -> String {
let Some(git) = self.git.as_ref() else {
return String::new();
};
let mut out = String::from("\n=== GIT STATUS (snapshot at session start, not live) ===\n");
if let Some(branch) = git.branch.as_deref() {
out.push_str(&format!("Branch: {}\n", branch));
} else {
out.push_str("Branch: (detached HEAD)\n");
}
if let Some(head) = git.head_oneline.as_deref() {
out.push_str(&format!("HEAD: {}\n", head));
}
if git.is_dirty {
let line_count = git.status_short.lines().count();
out.push_str(&format!("Status: {} change(s)\n", line_count));
out.push_str(&git.status_short);
if !git.status_short.ends_with('\n') {
out.push('\n');
}
} else {
out.push_str("Status: clean\n");
}
out.push_str(
"\nThis is a snapshot from session start. \
Use `bash` + `git status` to check live state.\n",
);
out
}
}
fn is_git_repo(wd: &Path) -> bool {
run_git(wd, &["rev-parse", "--is-inside-work-tree"])
.map(|s| s.trim() == "true")
.unwrap_or(false)
}
fn run_git(wd: &Path, args: &[&str]) -> Option<String> {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(wd)
.env("GIT_PAGER", "cat")
.env("PAGER", "cat");
crate::process_utils::suppress_console_window_sync(&mut cmd);
let output = cmd.output().ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8(output.stdout).ok()?;
Some(s.trim().to_string())
}
fn truncate_status(raw: &str, max_lines: usize) -> String {
let lines: Vec<&str> = raw.lines().collect();
if lines.len() <= max_lines {
return raw.to_string();
}
let kept: Vec<&str> = lines.iter().take(max_lines).copied().collect();
format!(
"{}\n... and {} more line(s)\n",
kept.join("\n"),
lines.len() - max_lines
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_snapshot_renders_nothing() {
let snap = EnvSnapshot::default();
assert_eq!(snap.as_prompt_section(), "");
}
#[test]
fn capture_non_git_dir_returns_empty() {
let tmp = tempfile::tempdir().expect("tempdir");
let snap = EnvSnapshot::capture(tmp.path());
assert!(snap.git.is_none(), "non-git dir must yield git: None");
assert_eq!(snap.as_prompt_section(), "");
}
#[test]
fn snapshot_with_branch_renders_section() {
let snap = EnvSnapshot {
git: Some(GitSnapshot {
branch: Some("main".into()),
head_oneline: Some("abc1234 test commit".into()),
status_short: String::new(),
is_dirty: false,
}),
};
let out = snap.as_prompt_section();
assert!(out.contains("=== GIT STATUS"));
assert!(out.contains("snapshot at session start"));
assert!(out.contains("Branch: main"));
assert!(out.contains("HEAD: abc1234 test commit"));
assert!(out.contains("Status: clean"));
assert!(out.contains("Use `bash` + `git status` to check live state"));
}
#[test]
fn detached_head_shown_explicitly() {
let snap = EnvSnapshot {
git: Some(GitSnapshot {
branch: None, head_oneline: Some("deadbee detached state".into()),
status_short: String::new(),
is_dirty: false,
}),
};
let out = snap.as_prompt_section();
assert!(out.contains("(detached HEAD)"));
}
#[test]
fn dirty_status_includes_changes() {
let snap = EnvSnapshot {
git: Some(GitSnapshot {
branch: Some("feat/x".into()),
head_oneline: Some("abc1234 wip".into()),
status_short: " M src/foo.rs\n M src/bar.rs\n?? new.rs".into(),
is_dirty: true,
}),
};
let out = snap.as_prompt_section();
assert!(out.contains("Status: 3 change(s)"));
assert!(out.contains(" M src/foo.rs"));
assert!(out.contains("?? new.rs"));
assert!(!out.contains("Status: clean"));
}
#[test]
fn truncate_status_caps_long_output() {
let raw = (0..50)
.map(|i| format!(" M file_{}.rs", i))
.collect::<Vec<_>>()
.join("\n");
let out = truncate_status(&raw, 20);
let kept_lines = out.lines().filter(|l| l.starts_with(" M")).count();
assert_eq!(kept_lines, 20);
assert!(out.contains("... and 30 more line"));
}
#[test]
fn truncate_status_passthrough_when_under_cap() {
let raw = " M a.rs\n M b.rs";
let out = truncate_status(raw, 20);
assert_eq!(out, raw);
assert!(!out.contains("more line"));
}
}