1use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::time::SystemTime;
9
10#[derive(Debug, Clone)]
12pub struct GitCommit {
13 pub sha: String,
14 pub short_sha: String,
15 pub message: String,
16 pub author: String,
17 pub timestamp: SystemTime,
18}
19
20#[derive(Debug, Clone)]
22pub struct GitLogEntry {
23 pub commit: GitCommit,
24 pub branch: Option<String>,
25}
26
27#[derive(Debug, Clone)]
29pub struct GitDiff {
30 pub staged: String,
31 pub unstaged: String,
32 pub untracked: String,
33}
34
35#[derive(Debug, Clone)]
37pub struct GitStatus {
38 pub is_repo: bool,
39 pub branch: Option<String>,
40 pub is_dirty: bool,
41 pub staged_files: Vec<String>,
42 pub modified_files: Vec<String>,
43 pub untracked_files: Vec<String>,
44}
45
46pub fn is_git_repo(dir: &Path) -> bool {
48 find_git_root(dir).is_some()
49}
50
51pub fn find_git_root(path: &Path) -> Option<PathBuf> {
53 let mut current = path.to_path_buf();
54
55 loop {
56 let git_dir = current.join(".git");
57 if git_dir.exists() {
58 return Some(current);
59 }
60
61 if git_dir.is_file() {
62 if let Ok(content) = std::fs::read_to_string(&git_dir) {
63 if content.starts_with("gitdir: ") {
64 let gitdir_path = content.trim_start_matches("gitdir: ").trim();
65 if let Ok(main_git) = PathBuf::from(gitdir_path).canonicalize() {
66 if let Some(main_dir) = main_git.parent() {
67 return Some(main_dir.to_path_buf());
68 }
69 }
70 }
71 }
72 return Some(current);
73 }
74
75 current = match current.parent() {
76 Some(parent) => parent.to_path_buf(),
77 None => return None,
78 };
79
80 if current.to_string_lossy() == "/" {
81 return None;
82 }
83 }
84}
85
86pub fn get_git_root(cwd: &Path) -> PathBuf {
88 find_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf())
89}
90
91fn run_git_command(repo_dir: &Path, args: &[&str]) -> Result<String, String> {
93 let output = Command::new("git")
94 .args(["-C", repo_dir.to_string_lossy().as_ref()])
95 .args(args)
96 .output()
97 .map_err(|e| format!("Failed to run git: {}", e))?;
98
99 if output.status.success() {
100 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
101 } else {
102 let stderr = String::from_utf8_lossy(&output.stderr);
103 Err(format!("Git command failed: {}", stderr))
104 }
105}
106
107pub fn get_current_branch(repo_dir: &Path) -> Option<String> {
109 run_git_command(repo_dir, &["symbolic-ref", "--quiet", "--short", "HEAD"])
110 .ok()
111 .filter(|b| !b.is_empty())
112}
113
114pub fn is_detached_head(repo_dir: &Path) -> bool {
116 if let Ok(head) = run_git_command(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"]) {
117 head == "HEAD"
118 } else {
119 false
120 }
121}
122
123pub fn git_checkpoint(repo_dir: &Path, message: Option<&str>) -> Result<String, String> {
125 run_git_command(repo_dir, &["add", "-A"])?;
126
127 let status = run_git_command(repo_dir, &["status", "--porcelain"])?;
128 if status.trim().is_empty() {
129 return Err("No changes to checkpoint".to_string());
130 }
131
132 let timestamp = chrono::Utc::now();
133 let default_msg = format!(
134 "Checkpoint: {}",
135 timestamp.format("%Y-%m-%d %H:%M:%S UTC")
136 );
137 let msg = message.unwrap_or(&default_msg);
138
139 run_git_command(repo_dir, &["commit", "-m", msg])?;
140 run_git_command(repo_dir, &["rev-parse", "--short", "HEAD"])
141}
142
143pub fn git_diff(repo_dir: &Path, diff_type: &str) -> Result<String, String> {
145 match diff_type {
146 "staged" => run_git_command(repo_dir, &["diff", "--cached"]),
147 "unstaged" => run_git_command(repo_dir, &["diff"]),
148 "untracked" => run_git_command(repo_dir, &["ls-files", "--others", "--exclude-standard"]),
149 "all" => {
150 let staged = run_git_command(repo_dir, &["diff", "--cached"]).unwrap_or_default();
151 let unstaged = run_git_command(repo_dir, &["diff"]).unwrap_or_default();
152 let untracked = run_git_command(repo_dir, &["ls-files", "--others", "--exclude-standard"])
153 .unwrap_or_default();
154 Ok(format!(
155 "=== STAGED ===\n{}\n\n=== UNSTAGED ===\n{}\n\n=== UNTRACKED ===\n{}",
156 staged, unstaged, untracked
157 ))
158 }
159 _ => Err(format!("Unknown diff type: {}", diff_type)),
160 }
161}
162
163pub fn git_log(repo_dir: &Path, count: usize) -> Result<Vec<GitLogEntry>, String> {
165 let format_str = "%H|%h|%s|%an|%ae|%at";
166 let output = run_git_command(
167 repo_dir,
168 &["log", &format!("-{}", count), &format!("--format={}", format_str), "--all"],
169 )?;
170
171 let branch = get_current_branch(repo_dir);
172
173 let entries: Vec<GitLogEntry> = output
174 .lines()
175 .filter_map(|line| {
176 let parts: Vec<&str> = line.split('|').collect();
177 if parts.len() < 6 {
178 return None;
179 }
180
181 let timestamp = parts[5]
182 .parse::<i64>()
183 .ok()
184 .and_then(|t| {
185 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(t as u64))
186 })
187 .unwrap_or(SystemTime::UNIX_EPOCH);
188
189 Some(GitLogEntry {
190 commit: GitCommit {
191 sha: parts[0].to_string(),
192 short_sha: parts[1].to_string(),
193 message: parts[2].to_string(),
194 author: parts[3].to_string(),
195 timestamp,
196 },
197 branch: branch.clone(),
198 })
199 })
200 .collect();
201
202 Ok(entries)
203}
204
205pub fn git_restore(repo_dir: &Path, sha: &str, path: Option<&str>) -> Result<(), String> {
207 let target = if sha.starts_with("HEAD~") || sha.starts_with("HEAD^") || sha.contains('~') {
208 sha.to_string()
209 } else {
210 run_git_command(repo_dir, &["rev-parse", "--verify", sha])?;
211 sha.to_string()
212 };
213
214 let path_arg = path.unwrap_or(".");
215 run_git_command(repo_dir, &["checkout", &target, "--", path_arg])?;
216 Ok(())
217}
218
219pub fn git_status(repo_dir: &Path) -> Result<GitStatus, String> {
221 let is_repo = is_git_repo(repo_dir);
222 if !is_repo {
223 return Ok(GitStatus {
224 is_repo: false,
225 branch: None,
226 is_dirty: false,
227 staged_files: vec![],
228 modified_files: vec![],
229 untracked_files: vec![],
230 });
231 }
232
233 let branch = get_current_branch(repo_dir);
234 let status_output = run_git_command(repo_dir, &["status", "--porcelain"])?;
235
236 let mut staged_files = Vec::new();
237 let mut modified_files = Vec::new();
238 let mut untracked_files = Vec::new();
239
240 for line in status_output.lines() {
241 if line.len() < 3 {
242 continue;
243 }
244 let index_status = line.chars().next().unwrap_or(' ');
245 let worktree_status = line.chars().nth(1).unwrap_or(' ');
246 let filename = line[3..].to_string();
247
248 if index_status == '?' && worktree_status == '?' {
249 untracked_files.push(filename.clone());
250 } else if index_status != ' ' && index_status != '?' {
251 staged_files.push(filename.clone());
252 }
253 if worktree_status != ' ' && worktree_status != '?' {
254 if !staged_files.contains(&filename) {
255 modified_files.push(filename);
256 }
257 }
258 }
259
260 let is_dirty = !staged_files.is_empty() || !modified_files.is_empty() || !untracked_files.is_empty();
261
262 Ok(GitStatus {
263 is_repo: true,
264 branch,
265 is_dirty,
266 staged_files,
267 modified_files,
268 untracked_files,
269 })
270}
271
272pub fn git_ahead_behind(repo_dir: &Path) -> Result<(usize, usize), String> {
274 let current = get_current_branch(repo_dir).ok_or("Not on a branch")?;
275 let upstream_ref = format!("{}@{{u}}", current);
277 let remote_branch = run_git_command(repo_dir, &["rev-parse", "--abbrev-ref", &upstream_ref])
278 .ok();
279
280 let remote_branch = match remote_branch {
281 Some(rb) => rb,
282 None => return Ok((0, 0)),
283 };
284
285 let base = run_git_command(repo_dir, &["merge-base", ¤t, &remote_branch])?;
286 let ahead = run_git_command(repo_dir, &["log", &format!("{}..{}", base, current), "--oneline"])
287 .unwrap_or_default();
288 let behind = run_git_command(repo_dir, &["log", &format!("{}..{}", current, base), "--oneline"])
289 .unwrap_or_default();
290
291 Ok((ahead.lines().count(), behind.lines().count()))
292}
293
294pub fn git_tags_containing(repo_dir: &Path, sha: &str) -> Result<Vec<String>, String> {
296 let output = run_git_command(repo_dir, &["tag", "--contains", sha])?;
297 Ok(output.lines().map(|s| s.to_string()).collect())
298}
299
300pub fn git_file_last_modified(repo_dir: &Path, file_path: &str) -> Result<SystemTime, String> {
302 let output = run_git_command(repo_dir, &["log", "-1", "--format=%at", "--", file_path])?;
303
304 let timestamp: i64 = output.trim().parse().map_err(|_| "Invalid timestamp")?;
305 SystemTime::UNIX_EPOCH
306 .checked_add(std::time::Duration::from_secs(timestamp as u64))
307 .ok_or_else(|| "Invalid timestamp".to_string())
308}
309
310pub fn git_file_is_modified(repo_dir: &Path, file_path: &str) -> Result<bool, String> {
312 let status = run_git_command(repo_dir, &["status", "--porcelain", "--", file_path])?;
313 Ok(!status.trim().is_empty())
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use std::env;
320
321 fn test_repo_path() -> PathBuf {
322 env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
323 }
324
325 #[test]
326 fn test_is_git_repo() {
327 let result = is_git_repo(&test_repo_path());
328 assert!(result == true || result == false);
329 }
330
331 #[test]
332 fn test_find_git_root() {
333 let result = find_git_root(&test_repo_path());
334 assert!(result.is_some());
335 }
336
337 #[test]
338 fn test_get_git_root() {
339 let root = get_git_root(&test_repo_path());
340 assert!(root.exists());
341 }
342
343 #[test]
344 fn test_git_status() {
345 let status = git_status(&test_repo_path());
346 assert!(status.is_ok());
347 let status = status.unwrap();
348 assert!(!status.is_repo || status.branch.is_some() || !status.branch.is_none());
349 }
350
351 #[test]
352 fn test_git_log_returns_vec() {
353 let result = git_log(&test_repo_path(), 5);
354 assert!(result.is_ok() || result.is_err());
355 }
356
357 #[test]
358 fn test_git_diff_invalid_type() {
359 let result = git_diff(&test_repo_path(), "invalid");
360 assert!(result.is_err());
361 }
362
363 #[test]
364 fn test_git_checkpoint_no_changes() {
365 let result = git_checkpoint(&test_repo_path(), None);
366 assert!(result.is_ok() || result == Err("No changes to checkpoint".to_string()));
367 }
368
369 #[test]
370 fn test_git_file_last_modified() {
371 let result = git_file_last_modified(&test_repo_path(), "Cargo.toml");
372 assert!(result.is_ok() || result.is_err());
373 }
374
375 #[test]
376 fn test_git_file_is_modified() {
377 let result = git_file_is_modified(&test_repo_path(), "Cargo.toml");
378 assert!(result.is_ok() || result.is_err());
379 }
380
381 #[test]
382 fn test_git_tags_containing() {
383 let result = git_tags_containing(&test_repo_path(), "HEAD");
384 assert!(result.is_ok());
385 }
386}