Skip to main content

null_e/git/
status.rs

1//! Git status detection
2//!
3//! Check for uncommitted changes, untracked files, etc.
4
5use crate::core::GitStatus;
6use crate::error::{DevSweepError, Result};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10/// Check git status for a project directory
11///
12/// This uses the git command-line tool for reliability and compatibility.
13pub fn get_git_status(project_root: &Path) -> Result<Option<GitStatus>> {
14    // Check if this is a git repository
15    let git_dir = project_root.join(".git");
16    if !git_dir.exists() {
17        // Try to find parent git repo
18        let output = Command::new("git")
19            .args(["rev-parse", "--git-dir"])
20            .current_dir(project_root)
21            .output();
22
23        match output {
24            Ok(o) if o.status.success() => {
25                // Found a parent repo, continue
26            }
27            _ => return Ok(None), // Not a git repo
28        }
29    }
30
31    let mut status = GitStatus {
32        is_repo: true,
33        ..Default::default()
34    };
35
36    // Get current branch
37    if let Ok(output) = Command::new("git")
38        .args(["branch", "--show-current"])
39        .current_dir(project_root)
40        .output()
41    {
42        if output.status.success() {
43            let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
44            if !branch.is_empty() {
45                status.branch = Some(branch);
46            }
47        }
48    }
49
50    // Get remote URL
51    if let Ok(output) = Command::new("git")
52        .args(["remote", "get-url", "origin"])
53        .current_dir(project_root)
54        .output()
55    {
56        if output.status.success() {
57            let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
58            if !remote.is_empty() {
59                status.remote = Some(remote);
60            }
61        }
62    }
63
64    // Check for uncommitted changes (modified/staged files)
65    if let Ok(output) = Command::new("git")
66        .args(["status", "--porcelain"])
67        .current_dir(project_root)
68        .output()
69    {
70        if output.status.success() {
71            let status_output = String::from_utf8_lossy(&output.stdout);
72
73            for line in status_output.lines() {
74                if line.len() < 3 {
75                    continue;
76                }
77
78                let status_code = &line[..2];
79                let file_path = &line[3..];
80
81                // First character: staged changes, Second: unstaged changes
82                let first = status_code.chars().next().unwrap_or(' ');
83                let second = status_code.chars().nth(1).unwrap_or(' ');
84
85                // Check for untracked
86                if first == '?' && second == '?' {
87                    status.has_untracked = true;
88                } else {
89                    // Any other status means uncommitted changes
90                    if first != ' ' || second != ' ' {
91                        status.has_uncommitted = true;
92                        status.dirty_paths.push(PathBuf::from(file_path));
93                    }
94                }
95            }
96        }
97    }
98
99    // Check for stashed changes
100    if let Ok(output) = Command::new("git")
101        .args(["stash", "list"])
102        .current_dir(project_root)
103        .output()
104    {
105        if output.status.success() {
106            let stash_output = String::from_utf8_lossy(&output.stdout);
107            status.has_stashed = !stash_output.trim().is_empty();
108        }
109    }
110
111    Ok(Some(status))
112}
113
114/// Quick check if a path has uncommitted changes
115pub fn has_uncommitted_changes(path: &Path) -> Result<bool> {
116    match get_git_status(path)? {
117        Some(status) => Ok(status.has_uncommitted),
118        None => Ok(false),
119    }
120}
121
122/// Check if a specific file/directory is tracked by git
123pub fn is_git_tracked(repo_root: &Path, path: &Path) -> Result<bool> {
124    let relative = path
125        .strip_prefix(repo_root)
126        .unwrap_or(path)
127        .to_string_lossy();
128
129    let output = Command::new("git")
130        .args(["ls-files", &relative])
131        .current_dir(repo_root)
132        .output()
133        .map_err(|e| DevSweepError::Git(e.to_string()))?;
134
135    Ok(output.status.success() && !output.stdout.is_empty())
136}
137
138/// Find the git repository root for a path
139pub fn find_repo_root(path: &Path) -> Result<Option<PathBuf>> {
140    let output = Command::new("git")
141        .args(["rev-parse", "--show-toplevel"])
142        .current_dir(path)
143        .output();
144
145    match output {
146        Ok(o) if o.status.success() => {
147            let root = String::from_utf8_lossy(&o.stdout).trim().to_string();
148            Ok(Some(PathBuf::from(root)))
149        }
150        _ => Ok(None),
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use tempfile::TempDir;
158
159    fn init_git_repo(path: &Path) {
160        Command::new("git")
161            .args(["init"])
162            .current_dir(path)
163            .output()
164            .expect("git init failed");
165
166        Command::new("git")
167            .args(["config", "user.email", "test@test.com"])
168            .current_dir(path)
169            .output()
170            .ok();
171
172        Command::new("git")
173            .args(["config", "user.name", "Test"])
174            .current_dir(path)
175            .output()
176            .ok();
177    }
178
179    #[test]
180    fn test_non_git_repo() {
181        let temp = TempDir::new().unwrap();
182        let status = get_git_status(temp.path()).unwrap();
183        assert!(status.is_none());
184    }
185
186    #[test]
187    fn test_clean_repo() {
188        let temp = TempDir::new().unwrap();
189        init_git_repo(temp.path());
190
191        // Create and commit a file
192        std::fs::write(temp.path().join("test.txt"), "hello").unwrap();
193        Command::new("git")
194            .args(["add", "."])
195            .current_dir(temp.path())
196            .output()
197            .unwrap();
198        Command::new("git")
199            .args(["commit", "-m", "initial"])
200            .current_dir(temp.path())
201            .output()
202            .unwrap();
203
204        let status = get_git_status(temp.path()).unwrap().unwrap();
205        assert!(status.is_repo);
206        assert!(!status.has_uncommitted);
207        assert!(!status.has_untracked);
208    }
209
210    #[test]
211    fn test_uncommitted_changes() {
212        let temp = TempDir::new().unwrap();
213        init_git_repo(temp.path());
214
215        // Create and commit a file
216        std::fs::write(temp.path().join("test.txt"), "hello").unwrap();
217        Command::new("git")
218            .args(["add", "."])
219            .current_dir(temp.path())
220            .output()
221            .unwrap();
222        Command::new("git")
223            .args(["commit", "-m", "initial"])
224            .current_dir(temp.path())
225            .output()
226            .unwrap();
227
228        // Modify the file
229        std::fs::write(temp.path().join("test.txt"), "modified").unwrap();
230
231        let status = get_git_status(temp.path()).unwrap().unwrap();
232        assert!(status.has_uncommitted);
233    }
234
235    #[test]
236    fn test_untracked_files() {
237        let temp = TempDir::new().unwrap();
238        init_git_repo(temp.path());
239
240        // Create untracked file
241        std::fs::write(temp.path().join("new.txt"), "new file").unwrap();
242
243        let status = get_git_status(temp.path()).unwrap().unwrap();
244        assert!(status.has_untracked);
245    }
246
247    #[test]
248    fn test_has_uncommitted_changes_helper() {
249        let temp = TempDir::new().unwrap();
250
251        // Non-git repo should return false
252        assert!(!has_uncommitted_changes(temp.path()).unwrap());
253
254        // Init and create uncommitted changes
255        init_git_repo(temp.path());
256        std::fs::write(temp.path().join("test.txt"), "hello").unwrap();
257        Command::new("git")
258            .args(["add", "."])
259            .current_dir(temp.path())
260            .output()
261            .unwrap();
262
263        assert!(has_uncommitted_changes(temp.path()).unwrap());
264    }
265}