1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5#[derive(Debug)]
6pub enum GitError {
7 NotInstalled,
8 NotARepo,
9 CommandFailed(String),
10}
11
12impl std::fmt::Display for GitError {
13 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14 match self {
15 GitError::NotInstalled => write!(f, "git is not installed"),
16 GitError::NotARepo => write!(f, "not a git repository"),
17 GitError::CommandFailed(msg) => write!(f, "git command failed: {}", msg),
18 }
19 }
20}
21
22impl std::error::Error for GitError {}
23
24#[derive(Debug, Default)]
25pub struct GitStatus {
26 pub dirty: HashSet<String>,
27 pub staged: HashSet<String>,
28 pub unstaged: HashSet<String>,
29}
30
31pub fn git_status(root: &Path) -> Result<GitStatus, GitError> {
32 let output = Command::new("git")
33 .arg("status")
34 .arg("--porcelain")
35 .current_dir(root)
36 .output()
37 .map_err(|_| GitError::NotInstalled)?;
38
39 if !output.status.success() {
40 let stderr = String::from_utf8_lossy(&output.stderr);
41 if stderr.contains("not a git repository") {
42 return Err(GitError::NotARepo);
43 }
44 return Err(GitError::CommandFailed(stderr.to_string()));
45 }
46
47 let stdout = String::from_utf8_lossy(&output.stdout);
48 let mut status = GitStatus::default();
49
50 for line in stdout.lines() {
51 if line.len() < 4 {
52 continue;
53 }
54
55 let x = line.chars().next().unwrap();
56 let y = line.chars().nth(1).unwrap();
57 let file = line[3..].trim().to_string();
58
59 let is_staged = x != ' ' && x != '?';
60 let is_unstaged = y != ' ' && y != '?';
61 let is_untracked = x == '?' && y == '?';
62
63 if is_staged {
64 status.staged.insert(file.clone());
65 }
66 if is_unstaged {
67 status.unstaged.insert(file.clone());
68 }
69 if is_staged || is_unstaged || is_untracked {
70 status.dirty.insert(file);
71 }
72 }
73
74 Ok(status)
75}
76
77pub fn ls_files(root: &Path) -> Result<Vec<PathBuf>, GitError> {
78 let output = Command::new("git")
79 .arg("ls-files")
80 .arg("--cached")
81 .arg("--others")
82 .arg("--exclude-standard")
83 .current_dir(root)
84 .output()
85 .map_err(|_| GitError::NotInstalled)?;
86
87 if !output.status.success() {
88 let stderr = String::from_utf8_lossy(&output.stderr);
89 if stderr.contains("not a git repository") {
90 return Err(GitError::NotARepo);
91 }
92 return Err(GitError::CommandFailed(stderr.to_string()));
93 }
94
95 let stdout = String::from_utf8_lossy(&output.stdout);
96 let files = stdout
97 .lines()
98 .map(|line| PathBuf::from(line.trim()))
99 .collect();
100
101 Ok(files)
102}
103
104pub fn repo_root(path: &Path) -> Result<PathBuf, GitError> {
105 let output = Command::new("git")
106 .arg("rev-parse")
107 .arg("--show-toplevel")
108 .current_dir(path)
109 .output()
110 .map_err(|_| GitError::NotInstalled)?;
111
112 if !output.status.success() {
113 let stderr = String::from_utf8_lossy(&output.stderr);
114 if stderr.contains("not a git repository") {
115 return Err(GitError::NotARepo);
116 }
117 return Err(GitError::CommandFailed(stderr.to_string()));
118 }
119
120 let stdout = String::from_utf8_lossy(&output.stdout);
121 let root = PathBuf::from(stdout.trim());
122
123 Ok(root)
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use std::env;
130
131 #[test]
132 fn test_repo_root() {
133 let cwd = env::current_dir().unwrap();
134 let root = repo_root(&cwd);
135 assert!(root.is_ok());
136 }
137
138 #[test]
139 fn test_ls_files() {
140 let cwd = env::current_dir().unwrap();
141 let files = ls_files(&cwd);
142 assert!(files.is_ok());
143 }
144
145 #[test]
146 fn test_git_status() {
147 let cwd = env::current_dir().unwrap();
148 let status = git_status(&cwd);
149 assert!(status.is_ok());
150 }
151}