1use std::path::Path;
29use std::process::Command;
30
31const MAX_DIFF_STAT_CHARS: usize = 2_000;
35const MAX_RECENT_COMMITS: usize = 5;
37
38pub fn git_context(project_root: &Path) -> Option<String> {
46 let branch = git_cmd(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
47
48 let mut parts = vec![format!("[Git: branch={branch}")];
49
50 if let Some(staged) = git_cmd(project_root, &["diff", "--cached", "--stat"])
52 && !staged.trim().is_empty()
53 {
54 let truncated = truncate_str(&staged, MAX_DIFF_STAT_CHARS);
55 parts.push(format!("staged:\n{truncated}"));
56 }
57
58 if let Some(unstaged) = git_cmd(project_root, &["diff", "--stat"])
60 && !unstaged.trim().is_empty()
61 {
62 let truncated = truncate_str(&unstaged, MAX_DIFF_STAT_CHARS);
63 parts.push(format!("unstaged:\n{truncated}"));
64 }
65
66 if let Some(untracked) = git_cmd(
68 project_root,
69 &["ls-files", "--others", "--exclude-standard"],
70 ) {
71 let count = untracked.lines().count();
72 if count > 0 {
73 parts.push(format!("{count} untracked file(s)"));
74 }
75 }
76
77 if let Some(log) = git_cmd(
79 project_root,
80 &[
81 "log",
82 "--oneline",
83 &format!("-{MAX_RECENT_COMMITS}"),
84 "--no-decorate",
85 ],
86 ) && !log.trim().is_empty()
87 {
88 parts.push(format!("recent commits:\n{log}"));
89 }
90
91 parts.push("]".to_string());
92 Some(parts.join(", "))
93}
94
95fn git_cmd(cwd: &Path, args: &[&str]) -> Option<String> {
99 Command::new("git")
100 .args(args)
101 .current_dir(cwd)
102 .output()
103 .ok()
104 .filter(|o| o.status.success())
105 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
106}
107
108fn truncate_str(s: &str, max: usize) -> String {
110 if s.len() <= max {
111 return s.to_string();
112 }
113 let end = s[..max].rfind('\n').unwrap_or(max);
115 let truncated = &s[..end];
116 let remaining = s[end..].lines().count();
117 format!("{truncated}\n ... ({remaining} more lines)")
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn test_git_context_in_repo() {
126 let ctx = git_context(Path::new("."));
128 assert!(ctx.is_some());
129 let ctx = ctx.unwrap();
130 assert!(ctx.contains("[Git: branch="));
131 assert!(ctx.contains("recent commits:"));
132 }
133
134 #[test]
135 fn test_git_context_not_a_repo() {
136 let tmp = tempfile::tempdir().unwrap();
137 let ctx = git_context(tmp.path());
138 assert!(ctx.is_none());
139 }
140
141 #[test]
142 fn test_truncate_str_short() {
143 assert_eq!(truncate_str("hello", 100), "hello");
144 }
145
146 #[test]
147 fn test_truncate_str_long() {
148 let lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
149 let input = lines.join("\n");
150 let truncated = truncate_str(&input, 50);
151 assert!(truncated.len() <= 80); assert!(truncated.contains("more lines"));
153 }
154}