Skip to main content

oyo_core/
git.rs

1//! Git integration for detecting changed files
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use thiserror::Error;
6
7#[derive(Error, Debug)]
8pub enum GitError {
9    #[error("Not a git repository")]
10    NotARepo,
11    #[error("Git command failed: {0}")]
12    CommandFailed(String),
13    #[error("IO error: {0}")]
14    Io(#[from] std::io::Error),
15}
16
17/// Status of a file in git
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FileStatus {
20    Modified,
21    Added,
22    Deleted,
23    Renamed,
24    Untracked,
25}
26
27/// A changed file in git
28#[derive(Debug, Clone)]
29pub struct ChangedFile {
30    pub path: PathBuf,
31    pub status: FileStatus,
32    /// For renamed files, the original path
33    pub old_path: Option<PathBuf>,
34}
35
36/// Summary stats for a commit
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct CommitStats {
39    pub files_changed: usize,
40    pub insertions: usize,
41    pub deletions: usize,
42}
43
44/// Commit metadata for log views
45#[derive(Debug, Clone)]
46pub struct CommitEntry {
47    pub id: String,
48    pub short_id: String,
49    pub parents: Vec<String>,
50    pub author: String,
51    pub author_time: Option<i64>,
52    pub summary: String,
53    pub stats: Option<CommitStats>,
54}
55
56/// Check if a directory is a git repository
57pub fn is_git_repo(path: &Path) -> bool {
58    Command::new("git")
59        .arg("-C")
60        .arg(path)
61        .arg("rev-parse")
62        .arg("--git-dir")
63        .output()
64        .map(|o| o.status.success())
65        .unwrap_or(false)
66}
67
68/// Get the current git branch name
69pub fn get_current_branch(path: &Path) -> Result<String, GitError> {
70    let output = Command::new("git")
71        .arg("-C")
72        .arg(path)
73        .arg("rev-parse")
74        .arg("--abbrev-ref")
75        .arg("HEAD")
76        .output()?;
77
78    if !output.status.success() {
79        return Err(GitError::NotARepo);
80    }
81
82    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
83}
84
85/// Get the root of the git repository
86pub fn get_repo_root(path: &Path) -> Result<PathBuf, GitError> {
87    let output = Command::new("git")
88        .arg("-C")
89        .arg(path)
90        .arg("rev-parse")
91        .arg("--show-toplevel")
92        .output()?;
93
94    if !output.status.success() {
95        return Err(GitError::NotARepo);
96    }
97
98    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
99    Ok(PathBuf::from(root))
100}
101
102/// Get list of uncommitted changed files (staged and unstaged)
103pub fn get_uncommitted_changes(repo_path: &Path) -> Result<Vec<ChangedFile>, GitError> {
104    let mut changes = Vec::new();
105
106    // Get staged changes
107    let staged = Command::new("git")
108        .arg("-C")
109        .arg(repo_path)
110        .arg("diff")
111        .arg("--cached")
112        .arg("--name-status")
113        .output()?;
114
115    if staged.status.success() {
116        parse_name_status(&String::from_utf8_lossy(&staged.stdout), &mut changes);
117    }
118
119    // Get unstaged changes
120    let unstaged = Command::new("git")
121        .arg("-C")
122        .arg(repo_path)
123        .arg("diff")
124        .arg("--name-status")
125        .output()?;
126
127    if unstaged.status.success() {
128        parse_name_status(&String::from_utf8_lossy(&unstaged.stdout), &mut changes);
129    }
130
131    // Get untracked files
132    let untracked = Command::new("git")
133        .arg("-C")
134        .arg(repo_path)
135        .arg("ls-files")
136        .arg("--others")
137        .arg("--exclude-standard")
138        .output()?;
139
140    if untracked.status.success() {
141        for line in String::from_utf8_lossy(&untracked.stdout).lines() {
142            let line = line.trim();
143            if !line.is_empty() {
144                changes.push(ChangedFile {
145                    path: PathBuf::from(line),
146                    status: FileStatus::Untracked,
147                    old_path: None,
148                });
149            }
150        }
151    }
152
153    // Deduplicate by path
154    changes.sort_by(|a, b| a.path.cmp(&b.path));
155    changes.dedup_by(|a, b| a.path == b.path);
156
157    Ok(changes)
158}
159
160/// Get list of staged changed files (index vs HEAD)
161pub fn get_staged_changes(repo_path: &Path) -> Result<Vec<ChangedFile>, GitError> {
162    let output = Command::new("git")
163        .arg("-C")
164        .arg(repo_path)
165        .arg("diff")
166        .arg("--cached")
167        .arg("--name-status")
168        .output()?;
169
170    if !output.status.success() {
171        return Err(GitError::CommandFailed(
172            String::from_utf8_lossy(&output.stderr).to_string(),
173        ));
174    }
175
176    let mut changes = Vec::new();
177    parse_name_status(&String::from_utf8_lossy(&output.stdout), &mut changes);
178    Ok(changes)
179}
180
181/// Get changes between two commits or refs
182pub fn get_changes_between(
183    repo_path: &Path,
184    from: &str,
185    to: &str,
186) -> Result<Vec<ChangedFile>, GitError> {
187    let output = Command::new("git")
188        .arg("-C")
189        .arg(repo_path)
190        .arg("diff")
191        .arg("--name-status")
192        .arg(format!("{}..{}", from, to))
193        .output()?;
194
195    if !output.status.success() {
196        return Err(GitError::CommandFailed(
197            String::from_utf8_lossy(&output.stderr).to_string(),
198        ));
199    }
200
201    let mut changes = Vec::new();
202    parse_name_status(&String::from_utf8_lossy(&output.stdout), &mut changes);
203    Ok(changes)
204}
205
206/// Get changes between a commit and the staged index (commit vs index)
207pub fn get_changes_between_index(
208    repo_path: &Path,
209    from: &str,
210    reverse: bool,
211) -> Result<Vec<ChangedFile>, GitError> {
212    let mut cmd = Command::new("git");
213    cmd.arg("-C")
214        .arg(repo_path)
215        .arg("diff")
216        .arg("--cached")
217        .arg("--name-status");
218    if reverse {
219        cmd.arg("-R");
220    }
221    cmd.arg(from);
222
223    let output = cmd.output()?;
224
225    if !output.status.success() {
226        return Err(GitError::CommandFailed(
227            String::from_utf8_lossy(&output.stderr).to_string(),
228        ));
229    }
230
231    let mut changes = Vec::new();
232    parse_name_status(&String::from_utf8_lossy(&output.stdout), &mut changes);
233    Ok(changes)
234}
235
236/// Get recent commits with short stats
237pub fn get_recent_commits(repo_path: &Path, limit: usize) -> Result<Vec<CommitEntry>, GitError> {
238    let format = "%H%x1f%h%x1f%P%x1f%an%x1f%at%x1f%s";
239    let output = Command::new("git")
240        .arg("-C")
241        .arg(repo_path)
242        .arg("log")
243        .arg("-n")
244        .arg(limit.to_string())
245        .arg(format!("--pretty=format:{format}"))
246        .arg("--shortstat")
247        .output()?;
248
249    if !output.status.success() {
250        return Err(GitError::CommandFailed(
251            String::from_utf8_lossy(&output.stderr).to_string(),
252        ));
253    }
254
255    let mut commits = Vec::new();
256    let mut last_idx: Option<usize> = None;
257
258    for line in String::from_utf8_lossy(&output.stdout).lines() {
259        let line = line.trim();
260        if line.is_empty() {
261            continue;
262        }
263        if line.contains('\u{1f}') {
264            let parts: Vec<&str> = line.split('\u{1f}').collect();
265            if parts.len() < 6 {
266                continue;
267            }
268            let parents = if parts[2].trim().is_empty() {
269                Vec::new()
270            } else {
271                parts[2].split_whitespace().map(|s| s.to_string()).collect()
272            };
273            let author_time = parts[4].trim().parse::<i64>().ok();
274            commits.push(CommitEntry {
275                id: parts[0].to_string(),
276                short_id: parts[1].to_string(),
277                parents,
278                author: parts[3].to_string(),
279                author_time,
280                summary: parts[5].to_string(),
281                stats: None,
282            });
283            last_idx = Some(commits.len() - 1);
284            continue;
285        }
286
287        if let Some(stats) = parse_shortstat(line) {
288            if let Some(idx) = last_idx {
289                commits[idx].stats = Some(stats);
290            }
291        }
292    }
293
294    Ok(commits)
295}
296
297/// Get the content of a file at a specific commit
298pub fn get_file_at_commit(repo_path: &Path, commit: &str, file: &Path) -> Result<String, GitError> {
299    let output = Command::new("git")
300        .arg("-C")
301        .arg(repo_path)
302        .arg("show")
303        .arg(format!("{}:{}", commit, file.display()))
304        .output()?;
305
306    if !output.status.success() {
307        return Err(GitError::CommandFailed(
308            String::from_utf8_lossy(&output.stderr).to_string(),
309        ));
310    }
311
312    Ok(String::from_utf8_lossy(&output.stdout).to_string())
313}
314
315pub fn get_file_at_commit_bytes(
316    repo_path: &Path,
317    commit: &str,
318    file: &Path,
319) -> Result<Vec<u8>, GitError> {
320    let output = Command::new("git")
321        .arg("-C")
322        .arg(repo_path)
323        .arg("show")
324        .arg(format!("{}:{}", commit, file.display()))
325        .output()?;
326
327    if !output.status.success() {
328        return Err(GitError::CommandFailed(
329            String::from_utf8_lossy(&output.stderr).to_string(),
330        ));
331    }
332
333    Ok(output.stdout)
334}
335
336pub fn get_file_at_commit_size(repo_path: &Path, commit: &str, file: &Path) -> Option<u64> {
337    let output = Command::new("git")
338        .arg("-C")
339        .arg(repo_path)
340        .arg("cat-file")
341        .arg("-s")
342        .arg(format!("{}:{}", commit, file.display()))
343        .output()
344        .ok()?;
345
346    if !output.status.success() {
347        return None;
348    }
349
350    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
351}
352
353/// Get the staged content of a file
354pub fn get_staged_content(repo_path: &Path, file: &Path) -> Result<String, GitError> {
355    let output = Command::new("git")
356        .arg("-C")
357        .arg(repo_path)
358        .arg("show")
359        .arg(format!(":{}", file.display()))
360        .output()?;
361
362    if !output.status.success() {
363        // File might not be staged, try HEAD
364        return get_file_at_commit(repo_path, "HEAD", file);
365    }
366
367    Ok(String::from_utf8_lossy(&output.stdout).to_string())
368}
369
370pub fn get_staged_content_bytes(repo_path: &Path, file: &Path) -> Result<Vec<u8>, GitError> {
371    let output = Command::new("git")
372        .arg("-C")
373        .arg(repo_path)
374        .arg("show")
375        .arg(format!(":{}", file.display()))
376        .output()?;
377
378    if !output.status.success() {
379        return get_file_at_commit_bytes(repo_path, "HEAD", file);
380    }
381
382    Ok(output.stdout)
383}
384
385pub fn get_staged_content_size(repo_path: &Path, file: &Path) -> Option<u64> {
386    let output = Command::new("git")
387        .arg("-C")
388        .arg(repo_path)
389        .arg("cat-file")
390        .arg("-s")
391        .arg(format!(":{}", file.display()))
392        .output()
393        .ok()?;
394
395    if !output.status.success() {
396        return None;
397    }
398
399    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
400}
401
402pub fn get_head_content_bytes(repo_path: &Path, file: &Path) -> Result<Vec<u8>, GitError> {
403    get_file_at_commit_bytes(repo_path, "HEAD", file)
404}
405
406/// Get the HEAD content of a file
407pub fn get_head_content(repo_path: &Path, file: &Path) -> Result<String, GitError> {
408    get_file_at_commit(repo_path, "HEAD", file)
409}
410
411fn parse_name_status(output: &str, changes: &mut Vec<ChangedFile>) {
412    for line in output.lines() {
413        let line = line.trim();
414        if line.is_empty() {
415            continue;
416        }
417
418        let parts: Vec<&str> = line.split('\t').collect();
419        if parts.is_empty() {
420            continue;
421        }
422
423        let status_char = parts[0].chars().next().unwrap_or(' ');
424        let status = match status_char {
425            'M' => FileStatus::Modified,
426            'A' => FileStatus::Added,
427            'D' => FileStatus::Deleted,
428            'R' => FileStatus::Renamed,
429            _ => continue,
430        };
431
432        if parts.len() >= 2 {
433            let path = PathBuf::from(parts.last().unwrap());
434            let old_path = if status == FileStatus::Renamed && parts.len() >= 3 {
435                Some(PathBuf::from(parts[1]))
436            } else {
437                None
438            };
439
440            changes.push(ChangedFile {
441                path,
442                status,
443                old_path,
444            });
445        }
446    }
447}
448
449fn parse_shortstat(line: &str) -> Option<CommitStats> {
450    if !line.contains("file changed") && !line.contains("files changed") {
451        return None;
452    }
453
454    let mut files_changed = 0usize;
455    let mut insertions = 0usize;
456    let mut deletions = 0usize;
457
458    for part in line.split(',') {
459        let part = part.trim();
460        let count = part
461            .split_whitespace()
462            .next()
463            .and_then(|s| s.parse::<usize>().ok())
464            .unwrap_or(0);
465        if part.contains("file changed") || part.contains("files changed") {
466            files_changed = count;
467        } else if part.contains("insertion") {
468            insertions = count;
469        } else if part.contains("deletion") {
470            deletions = count;
471        }
472    }
473
474    Some(CommitStats {
475        files_changed,
476        insertions,
477        deletions,
478    })
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn test_parse_name_status() {
487        let output = "M\tsrc/main.rs\nA\tsrc/new.rs\nD\tsrc/old.rs\n";
488        let mut changes = Vec::new();
489        parse_name_status(output, &mut changes);
490
491        assert_eq!(changes.len(), 3);
492        assert_eq!(changes[0].status, FileStatus::Modified);
493        assert_eq!(changes[1].status, FileStatus::Added);
494        assert_eq!(changes[2].status, FileStatus::Deleted);
495    }
496}