rust_filesearch/fs/
git.rs

1#[cfg(feature = "git")]
2use crate::errors::{FsError, Result};
3#[cfg(feature = "git")]
4use crate::models::Entry;
5#[cfg(feature = "git")]
6use std::collections::HashMap;
7#[cfg(feature = "git")]
8use std::path::{Path, PathBuf};
9#[cfg(feature = "git")]
10use std::process::Command;
11
12#[cfg(feature = "git")]
13/// Git file status
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum GitStatus {
16    Untracked,
17    Modified,
18    Staged,
19    Deleted,
20    Renamed,
21    Unmerged,
22    Ignored,
23    Clean,
24}
25
26#[cfg(feature = "git")]
27impl GitStatus {
28    pub fn from_porcelain_code(code: &str) -> Self {
29        match code {
30            "??" => GitStatus::Untracked,
31            "M " | " M" | "MM" => GitStatus::Modified,
32            "A " | " A" | "AM" => GitStatus::Staged,
33            "D " | " D" => GitStatus::Deleted,
34            "R " | " R" => GitStatus::Renamed,
35            "U " | " U" | "UU" | "AA" | "DD" => GitStatus::Unmerged,
36            "!!" => GitStatus::Ignored,
37            _ => GitStatus::Clean,
38        }
39    }
40
41    pub fn to_str(&self) -> &'static str {
42        match self {
43            GitStatus::Untracked => "untracked",
44            GitStatus::Modified => "modified",
45            GitStatus::Staged => "staged",
46            GitStatus::Deleted => "deleted",
47            GitStatus::Renamed => "renamed",
48            GitStatus::Unmerged => "conflict",
49            GitStatus::Ignored => "ignored",
50            GitStatus::Clean => "clean",
51        }
52    }
53}
54
55#[cfg(feature = "git")]
56/// Extended entry with git status
57#[derive(Debug, Clone)]
58pub struct GitEntry {
59    pub entry: Entry,
60    pub status: GitStatus,
61}
62
63#[cfg(feature = "git")]
64/// Get git status for all files in a repository
65pub fn get_git_status(repo_path: &Path) -> Result<HashMap<PathBuf, GitStatus>> {
66    // Run git status --porcelain
67    let output = Command::new("git")
68        .args(["status", "--porcelain", "-uall"])
69        .current_dir(repo_path)
70        .output()
71        .map_err(|e| FsError::IoError {
72            context: "Failed to run git status command".to_string(),
73            source: e,
74        })?;
75
76    if !output.status.success() {
77        return Err(FsError::InvalidFormat {
78            format: format!(
79                "Git command failed: {}",
80                String::from_utf8_lossy(&output.stderr)
81            ),
82        });
83    }
84
85    let mut status_map = HashMap::new();
86    let stdout = String::from_utf8_lossy(&output.stdout);
87
88    for line in stdout.lines() {
89        if line.len() < 4 {
90            continue;
91        }
92
93        let status_code = &line[0..2];
94        let file_path = line[3..].trim();
95
96        // Handle renames (format: "R  old_name -> new_name")
97        let file_path = if let Some(idx) = file_path.find(" -> ") {
98            &file_path[idx + 4..]
99        } else {
100            file_path
101        };
102
103        let status = GitStatus::from_porcelain_code(status_code);
104        let path = repo_path.join(file_path);
105
106        status_map.insert(path, status);
107    }
108
109    Ok(status_map)
110}
111
112#[cfg(feature = "git")]
113/// Check if a path is within a git repository
114pub fn is_git_repo(path: &Path) -> bool {
115    Command::new("git")
116        .args(["rev-parse", "--git-dir"])
117        .current_dir(path)
118        .output()
119        .map(|output| output.status.success())
120        .unwrap_or(false)
121}
122
123#[cfg(feature = "git")]
124/// Get files changed since a specific ref (branch/commit/tag)
125pub fn get_changed_since(repo_path: &Path, since_ref: &str) -> Result<Vec<PathBuf>> {
126    let output = Command::new("git")
127        .args(["diff", "--name-only", &format!("{}..HEAD", since_ref)])
128        .current_dir(repo_path)
129        .output()
130        .map_err(|e| FsError::IoError {
131            context: format!("Failed to get git diff since {}", since_ref),
132            source: e,
133        })?;
134
135    if !output.status.success() {
136        return Err(FsError::InvalidFormat {
137            format: format!(
138                "Git diff command failed: {}",
139                String::from_utf8_lossy(&output.stderr)
140            ),
141        });
142    }
143
144    let stdout = String::from_utf8_lossy(&output.stdout);
145    let paths = stdout
146        .lines()
147        .map(|line| repo_path.join(line.trim()))
148        .collect();
149
150    Ok(paths)
151}
152
153#[cfg(feature = "git")]
154/// Enrich entries with git status information
155pub fn enrich_with_git_status(entries: &[Entry], repo_path: &Path) -> Result<Vec<GitEntry>> {
156    let status_map = get_git_status(repo_path)?;
157
158    let git_entries = entries
159        .iter()
160        .map(|entry| {
161            let status = status_map
162                .get(&entry.path)
163                .copied()
164                .unwrap_or(GitStatus::Clean);
165
166            GitEntry {
167                entry: entry.clone(),
168                status,
169            }
170        })
171        .collect();
172
173    Ok(git_entries)
174}
175
176#[cfg(test)]
177#[cfg(feature = "git")]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_git_status_from_code() {
183        assert_eq!(GitStatus::from_porcelain_code("??"), GitStatus::Untracked);
184        assert_eq!(GitStatus::from_porcelain_code("M "), GitStatus::Modified);
185        assert_eq!(GitStatus::from_porcelain_code(" M"), GitStatus::Modified);
186        assert_eq!(GitStatus::from_porcelain_code("A "), GitStatus::Staged);
187        assert_eq!(GitStatus::from_porcelain_code("D "), GitStatus::Deleted);
188        assert_eq!(GitStatus::from_porcelain_code("UU"), GitStatus::Unmerged);
189    }
190
191    #[test]
192    fn test_git_status_to_str() {
193        assert_eq!(GitStatus::Untracked.to_str(), "untracked");
194        assert_eq!(GitStatus::Modified.to_str(), "modified");
195        assert_eq!(GitStatus::Staged.to_str(), "staged");
196        assert_eq!(GitStatus::Clean.to_str(), "clean");
197    }
198}