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 date: String,
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%ad%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("--date=format:%Y-%m-%d %H:%M")
246        .arg(format!("--pretty=format:{format}"))
247        .arg("--shortstat")
248        .output()?;
249
250    if !output.status.success() {
251        return Err(GitError::CommandFailed(
252            String::from_utf8_lossy(&output.stderr).to_string(),
253        ));
254    }
255
256    let mut commits = Vec::new();
257    let mut last_idx: Option<usize> = None;
258
259    for line in String::from_utf8_lossy(&output.stdout).lines() {
260        let line = line.trim();
261        if line.is_empty() {
262            continue;
263        }
264        if line.contains('\u{1f}') {
265            let parts: Vec<&str> = line.split('\u{1f}').collect();
266            if parts.len() < 6 {
267                continue;
268            }
269            let parents = if parts[2].trim().is_empty() {
270                Vec::new()
271            } else {
272                parts[2].split_whitespace().map(|s| s.to_string()).collect()
273            };
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                date: parts[4].to_string(),
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
315/// Get the staged content of a file
316pub fn get_staged_content(repo_path: &Path, file: &Path) -> Result<String, GitError> {
317    let output = Command::new("git")
318        .arg("-C")
319        .arg(repo_path)
320        .arg("show")
321        .arg(format!(":{}", file.display()))
322        .output()?;
323
324    if !output.status.success() {
325        // File might not be staged, try HEAD
326        return get_file_at_commit(repo_path, "HEAD", file);
327    }
328
329    Ok(String::from_utf8_lossy(&output.stdout).to_string())
330}
331
332/// Get the HEAD content of a file
333pub fn get_head_content(repo_path: &Path, file: &Path) -> Result<String, GitError> {
334    get_file_at_commit(repo_path, "HEAD", file)
335}
336
337fn parse_name_status(output: &str, changes: &mut Vec<ChangedFile>) {
338    for line in output.lines() {
339        let line = line.trim();
340        if line.is_empty() {
341            continue;
342        }
343
344        let parts: Vec<&str> = line.split('\t').collect();
345        if parts.is_empty() {
346            continue;
347        }
348
349        let status_char = parts[0].chars().next().unwrap_or(' ');
350        let status = match status_char {
351            'M' => FileStatus::Modified,
352            'A' => FileStatus::Added,
353            'D' => FileStatus::Deleted,
354            'R' => FileStatus::Renamed,
355            _ => continue,
356        };
357
358        if parts.len() >= 2 {
359            let path = PathBuf::from(parts.last().unwrap());
360            let old_path = if status == FileStatus::Renamed && parts.len() >= 3 {
361                Some(PathBuf::from(parts[1]))
362            } else {
363                None
364            };
365
366            changes.push(ChangedFile {
367                path,
368                status,
369                old_path,
370            });
371        }
372    }
373}
374
375fn parse_shortstat(line: &str) -> Option<CommitStats> {
376    if !line.contains("file changed") && !line.contains("files changed") {
377        return None;
378    }
379
380    let mut files_changed = 0usize;
381    let mut insertions = 0usize;
382    let mut deletions = 0usize;
383
384    for part in line.split(',') {
385        let part = part.trim();
386        let count = part
387            .split_whitespace()
388            .next()
389            .and_then(|s| s.parse::<usize>().ok())
390            .unwrap_or(0);
391        if part.contains("file changed") || part.contains("files changed") {
392            files_changed = count;
393        } else if part.contains("insertion") {
394            insertions = count;
395        } else if part.contains("deletion") {
396            deletions = count;
397        }
398    }
399
400    Some(CommitStats {
401        files_changed,
402        insertions,
403        deletions,
404    })
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn test_parse_name_status() {
413        let output = "M\tsrc/main.rs\nA\tsrc/new.rs\nD\tsrc/old.rs\n";
414        let mut changes = Vec::new();
415        parse_name_status(output, &mut changes);
416
417        assert_eq!(changes.len(), 3);
418        assert_eq!(changes[0].status, FileStatus::Modified);
419        assert_eq!(changes[1].status, FileStatus::Added);
420        assert_eq!(changes[2].status, FileStatus::Deleted);
421    }
422}