cersei_tools/tool_primitives/
git.rs1use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
9pub struct GitStatus {
10 pub branch: Option<String>,
11 pub files: Vec<GitFileStatus>,
12}
13
14#[derive(Debug, Clone)]
16pub struct GitFileStatus {
17 pub path: String,
18 pub status: String, }
20
21#[derive(Debug, Clone)]
23pub struct GitLogEntry {
24 pub hash: String,
25 pub message: String,
26}
27
28#[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
71pub 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
79pub 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
87pub 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
95pub 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
116pub 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
127pub async fn diff_file_content(path: &Path, file: &str) -> Result<String, GitError> {
129 git_cmd(path, &["diff", "--", file]).await
130}
131
132pub 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
148pub 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#[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}