Skip to main content

cersei_tools/
git_utils.rs

1//! Git utilities: repo detection, status, diff, and history.
2//!
3//! Used by the system prompt builder to inject git context.
4
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8/// Check if a path is inside a git repository.
9pub fn is_git_repo(path: &Path) -> bool {
10    Command::new("git")
11        .args(["rev-parse", "--is-inside-work-tree"])
12        .current_dir(path)
13        .output()
14        .map(|o| o.status.success())
15        .unwrap_or(false)
16}
17
18/// Get the root of the git repository.
19pub fn get_repo_root(path: &Path) -> Option<PathBuf> {
20    Command::new("git")
21        .args(["rev-parse", "--show-toplevel"])
22        .current_dir(path)
23        .output()
24        .ok()
25        .and_then(|o| {
26            if o.status.success() {
27                Some(PathBuf::from(String::from_utf8_lossy(&o.stdout).trim()))
28            } else {
29                None
30            }
31        })
32}
33
34/// Get current branch name.
35pub fn current_branch(path: &Path) -> Option<String> {
36    Command::new("git")
37        .args(["rev-parse", "--abbrev-ref", "HEAD"])
38        .current_dir(path)
39        .output()
40        .ok()
41        .and_then(|o| {
42            if o.status.success() {
43                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
44            } else {
45                None
46            }
47        })
48}
49
50/// Get git status (short format).
51pub fn git_status(path: &Path) -> Option<String> {
52    Command::new("git")
53        .args(["status", "--short"])
54        .current_dir(path)
55        .output()
56        .ok()
57        .and_then(|o| {
58            if o.status.success() {
59                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
60            } else {
61                None
62            }
63        })
64}
65
66/// Get git diff (staged + unstaged).
67pub fn git_diff(path: &Path) -> Option<String> {
68    let staged = Command::new("git")
69        .args(["diff", "--cached", "--stat"])
70        .current_dir(path)
71        .output()
72        .ok()
73        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
74        .unwrap_or_default();
75
76    let unstaged = Command::new("git")
77        .args(["diff", "--stat"])
78        .current_dir(path)
79        .output()
80        .ok()
81        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
82        .unwrap_or_default();
83
84    let combined = format!("{}\n{}", staged, unstaged).trim().to_string();
85    if combined.is_empty() {
86        None
87    } else {
88        Some(combined)
89    }
90}
91
92/// Get recent commit history (one-line format).
93pub fn recent_commits(path: &Path, count: usize) -> Option<String> {
94    Command::new("git")
95        .args(["log", "--oneline", &format!("-{}", count)])
96        .current_dir(path)
97        .output()
98        .ok()
99        .and_then(|o| {
100            if o.status.success() {
101                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
102            } else {
103                None
104            }
105        })
106}
107
108/// List modified files (both staged and unstaged).
109pub fn list_modified_files(path: &Path) -> Vec<String> {
110    Command::new("git")
111        .args(["diff", "--name-only", "HEAD"])
112        .current_dir(path)
113        .output()
114        .ok()
115        .map(|o| {
116            String::from_utf8_lossy(&o.stdout)
117                .lines()
118                .filter(|l| !l.is_empty())
119                .map(String::from)
120                .collect()
121        })
122        .unwrap_or_default()
123}
124
125/// Build a git context string for the system prompt.
126pub fn build_git_context(working_dir: &Path) -> Option<String> {
127    if !is_git_repo(working_dir) {
128        return None;
129    }
130
131    let mut parts = Vec::new();
132
133    if let Some(branch) = current_branch(working_dir) {
134        parts.push(format!("Current branch: {}", branch));
135    }
136
137    if let Some(status) = git_status(working_dir) {
138        if !status.is_empty() {
139            parts.push(format!("Status:\n{}", status));
140        } else {
141            parts.push("Status: (clean)".to_string());
142        }
143    }
144
145    if let Some(commits) = recent_commits(working_dir, 5) {
146        parts.push(format!("Recent commits:\n{}", commits));
147    }
148
149    if parts.is_empty() {
150        None
151    } else {
152        Some(parts.join("\n\n"))
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_is_git_repo() {
162        // The project root should be a git repo
163        let cwd = std::env::current_dir().unwrap();
164        // This might not be true in all test environments
165        let _ = is_git_repo(&cwd); // just verify it doesn't panic
166    }
167
168    #[test]
169    fn test_not_git_repo() {
170        let tmp = tempfile::tempdir().unwrap();
171        assert!(!is_git_repo(tmp.path()));
172    }
173
174    #[test]
175    fn test_build_git_context_non_repo() {
176        let tmp = tempfile::tempdir().unwrap();
177        assert!(build_git_context(tmp.path()).is_none());
178    }
179
180    #[test]
181    fn test_git_context_real_repo() {
182        // Try on the actual project repo
183        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
184            .parent()
185            .unwrap()
186            .parent()
187            .unwrap();
188        if is_git_repo(root) {
189            let ctx = build_git_context(root);
190            assert!(ctx.is_some());
191            let ctx = ctx.unwrap();
192            assert!(ctx.contains("branch") || ctx.contains("Status"));
193        }
194    }
195}