Skip to main content

oxi/
git_utils.rs

1//! Git utilities for version control operations
2//!
3//! Provides utilities for interacting with git repositories,
4//! including checkpoints, diffs, and log retrieval.
5
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::time::SystemTime;
9
10/// Git commit information
11#[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/// Git log entry
21#[derive(Debug, Clone)]
22pub struct GitLogEntry {
23    pub commit: GitCommit,
24    pub branch: Option<String>,
25}
26
27/// Git diff result
28#[derive(Debug, Clone)]
29pub struct GitDiff {
30    pub staged: String,
31    pub unstaged: String,
32    pub untracked: String,
33}
34
35/// Git status
36#[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
46/// Check if a directory is a git repository
47pub fn is_git_repo(dir: &Path) -> bool {
48    find_git_root(dir).is_some()
49}
50
51/// Find the git root directory by walking up from a path
52pub 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
86/// Get the git root for a given directory
87pub fn get_git_root(cwd: &Path) -> PathBuf {
88    find_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf())
89}
90
91/// Run a git command
92fn 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
107/// Get the current branch name
108pub 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
114/// Check if the repository is in detached HEAD state
115pub 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
123/// Create a checkpoint commit
124pub 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
143/// Get the git diff output
144pub 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
163/// Get the git log
164pub 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
205/// Restore a file or path to a specific commit
206pub 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
219/// Get git status
220pub 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
272/// Get the number of commits ahead/behind a remote branch
273pub 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    // Build upstream ref string: branch@ {u}
276    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", &current, &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
294/// Get the tags that contain a specific commit
295pub 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
300/// Get the last modified date of a file in the repo
301pub 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
310/// Check if a file has uncommitted changes
311pub 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}