Skip to main content

cersei_tools/tool_primitives/
git.rs

1//! Async git operations via the git CLI.
2//!
3//! All functions use `tokio::process::Command` — no blocking I/O.
4
5use std::path::{Path, PathBuf};
6
7/// Git repository status.
8#[derive(Debug, Clone)]
9pub struct GitStatus {
10    pub branch: Option<String>,
11    pub files: Vec<GitFileStatus>,
12}
13
14/// Status of a single file.
15#[derive(Debug, Clone)]
16pub struct GitFileStatus {
17    pub path: String,
18    pub status: String, // "M", "A", "D", "??", etc.
19}
20
21/// A single git log entry.
22#[derive(Debug, Clone)]
23pub struct GitLogEntry {
24    pub hash: String,
25    pub message: String,
26}
27
28/// Git errors.
29#[derive(Debug)]
30pub enum GitError {
31    NotARepo,
32    CommandFailed(String),
33    IoError(std::io::Error),
34}
35
36impl std::fmt::Display for GitError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::NotARepo => write!(f, "not a git repository"),
40            Self::CommandFailed(msg) => write!(f, "git command failed: {msg}"),
41            Self::IoError(e) => write!(f, "I/O error: {e}"),
42        }
43    }
44}
45
46impl std::error::Error for GitError {}
47
48impl From<std::io::Error> for GitError {
49    fn from(e: std::io::Error) -> Self {
50        Self::IoError(e)
51    }
52}
53
54async fn git_cmd(path: &Path, args: &[&str]) -> Result<String, GitError> {
55    let output = tokio::process::Command::new("git")
56        .args(args)
57        .current_dir(path)
58        .stdout(std::process::Stdio::piped())
59        .stderr(std::process::Stdio::piped())
60        .output()
61        .await?;
62
63    if output.status.success() {
64        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
65    } else {
66        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
67        Err(GitError::CommandFailed(stderr))
68    }
69}
70
71/// Check if a path is inside a git repository.
72pub async fn is_repo(path: &Path) -> bool {
73    git_cmd(path, &["rev-parse", "--is-inside-work-tree"])
74        .await
75        .map(|s| s == "true")
76        .unwrap_or(false)
77}
78
79/// Get the repository root directory.
80pub async fn repo_root(path: &Path) -> Option<PathBuf> {
81    git_cmd(path, &["rev-parse", "--show-toplevel"])
82        .await
83        .ok()
84        .map(PathBuf::from)
85}
86
87/// Get the current branch name.
88pub async fn current_branch(path: &Path) -> Option<String> {
89    git_cmd(path, &["branch", "--show-current"])
90        .await
91        .ok()
92        .filter(|s| !s.is_empty())
93}
94
95/// Get the repository status (branch + changed files).
96pub async fn status(path: &Path) -> Result<GitStatus, GitError> {
97    let branch = current_branch(path).await;
98
99    let output = git_cmd(path, &["status", "--porcelain"]).await?;
100    let files: Vec<GitFileStatus> = output
101        .lines()
102        .filter(|l| !l.is_empty())
103        .map(|line| {
104            let status = line[..2].trim().to_string();
105            let file_path = line[3..].to_string();
106            GitFileStatus {
107                path: file_path,
108                status,
109            }
110        })
111        .collect();
112
113    Ok(GitStatus { branch, files })
114}
115
116/// Get the diff (unified format).
117/// `staged = true` shows staged changes, `false` shows unstaged.
118pub async fn diff(path: &Path, staged: bool) -> Result<String, GitError> {
119    if staged {
120        git_cmd(path, &["diff", "--cached"])
121    } else {
122        git_cmd(path, &["diff"])
123    }
124    .await
125}
126
127/// Get the diff for a specific file.
128pub async fn diff_file_content(path: &Path, file: &str) -> Result<String, GitError> {
129    git_cmd(path, &["diff", "--", file]).await
130}
131
132/// Get recent log entries.
133pub async fn log(path: &Path, n: usize) -> Result<Vec<GitLogEntry>, GitError> {
134    let output = git_cmd(path, &["log", "--oneline", &format!("-{n}")]).await?;
135    let entries = output
136        .lines()
137        .filter_map(|line| {
138            let (hash, message) = line.split_once(' ')?;
139            Some(GitLogEntry {
140                hash: hash.to_string(),
141                message: message.to_string(),
142            })
143        })
144        .collect();
145    Ok(entries)
146}
147
148/// List files modified since HEAD.
149pub async fn list_modified_files(path: &Path) -> Result<Vec<String>, GitError> {
150    let output = git_cmd(path, &["diff", "--name-only", "HEAD"]).await?;
151    Ok(output
152        .lines()
153        .map(String::from)
154        .filter(|s| !s.is_empty())
155        .collect())
156}
157
158// ─── Tests ─────────────────────────────────────────────────────────────────
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[tokio::test]
165    async fn test_is_repo_false() {
166        let tmp = tempfile::tempdir().unwrap();
167        assert!(!is_repo(tmp.path()).await);
168    }
169
170    #[tokio::test]
171    async fn test_is_repo_true() {
172        let tmp = tempfile::tempdir().unwrap();
173        tokio::process::Command::new("git")
174            .args(["init"])
175            .current_dir(tmp.path())
176            .output()
177            .await
178            .unwrap();
179        assert!(is_repo(tmp.path()).await);
180    }
181
182    #[tokio::test]
183    async fn test_repo_root() {
184        let tmp = tempfile::tempdir().unwrap();
185        tokio::process::Command::new("git")
186            .args(["init"])
187            .current_dir(tmp.path())
188            .output()
189            .await
190            .unwrap();
191        let root = repo_root(tmp.path()).await;
192        assert!(root.is_some());
193    }
194
195    #[tokio::test]
196    async fn test_status_empty_repo() {
197        let tmp = tempfile::tempdir().unwrap();
198        tokio::process::Command::new("git")
199            .args(["init"])
200            .current_dir(tmp.path())
201            .output()
202            .await
203            .unwrap();
204
205        let st = status(tmp.path()).await.unwrap();
206        assert!(st.files.is_empty());
207    }
208}