1use crate::error::{Error, Result};
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6pub fn is_git_repo(path: &Path) -> bool {
9 Command::new("git")
10 .arg("rev-parse")
11 .arg("--git-dir")
12 .current_dir(path)
13 .output()
14 .map(|o| o.status.success())
15 .unwrap_or(false)
16}
17
18pub fn validate_git_ref(git_ref: &str) -> Result<()> {
24 if git_ref.is_empty() {
25 return Err(Error::InvalidArgument(
26 "git ref must not be empty".to_string(),
27 ));
28 }
29 if git_ref.starts_with("--") {
30 return Err(Error::InvalidArgument(format!(
31 "invalid git ref '{}': refs may not begin with '--'",
32 git_ref
33 )));
34 }
35 let valid = git_ref
36 .chars()
37 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '-' | '_'));
38 if !valid {
39 return Err(Error::InvalidArgument(format!(
40 "invalid git ref '{}': only alphanumeric characters and '.', '/', '-', '_' are allowed",
41 git_ref
42 )));
43 }
44 Ok(())
45}
46
47pub fn git_changed_files(repo_root: &Path, git_ref: &str) -> Result<HashSet<PathBuf>> {
53 validate_git_ref(git_ref)?;
54 let mut result = HashSet::new();
55
56 let unstaged = run_git_diff(repo_root, git_ref, false)?;
58 result.extend(unstaged);
59
60 let staged = run_git_diff(repo_root, git_ref, true)?;
62 result.extend(staged);
63
64 Ok(result)
65}
66
67fn run_git_diff(repo_root: &Path, git_ref: &str, cached: bool) -> Result<HashSet<PathBuf>> {
68 let mut cmd = Command::new("git");
69 cmd.arg("diff").arg("--name-only");
70 if cached {
71 cmd.arg("--cached");
72 }
73 cmd.arg(git_ref);
74 cmd.current_dir(repo_root);
75
76 let output = cmd.output().map_err(|e| {
77 if e.kind() == std::io::ErrorKind::NotFound {
78 Error::InvalidArgument("'git' command not found — is git installed?".to_string())
79 } else {
80 Error::InvalidArgument(format!("failed to spawn git: {}", e))
81 }
82 })?;
83
84 if !output.status.success() {
85 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
86 if stderr.to_lowercase().contains("not a git repository") {
87 return Err(Error::InvalidArgument(
88 "git diff failed: not a git repository".to_string(),
89 ));
90 }
91 return Err(Error::InvalidArgument(format!(
92 "invalid git ref '{}': {}",
93 git_ref, stderr
94 )));
95 }
96
97 let stdout = String::from_utf8_lossy(&output.stdout);
98 let paths = stdout
99 .lines()
100 .map(str::trim)
101 .filter(|l| !l.is_empty())
102 .map(PathBuf::from)
103 .collect();
104
105 Ok(paths)
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn git_available() -> bool {
113 Command::new("git")
114 .arg("--version")
115 .output()
116 .map(|o| o.status.success())
117 .unwrap_or(false)
118 }
119
120 #[test]
121 fn test_is_git_repo_true() {
122 if !git_available() {
123 return;
124 }
125 let tmp = tempfile::tempdir().unwrap();
129 let init_ok = Command::new("git")
130 .args(["init"])
131 .current_dir(tmp.path())
132 .output()
133 .map(|o| o.status.success())
134 .unwrap_or(false);
135 if !init_ok {
136 return;
138 }
139 assert!(is_git_repo(tmp.path()));
140 }
141
142 #[test]
143 fn test_is_git_repo_false() {
144 let tmp = tempfile::tempdir().unwrap();
145 assert!(!is_git_repo(tmp.path()));
146 }
147
148 fn init_git_repo_with_commit(dir: &std::path::Path) -> bool {
151 let run = |args: &[&str]| {
152 Command::new("git")
153 .args(args)
154 .current_dir(dir)
155 .output()
156 .map(|o| o.status.success())
157 .unwrap_or(false)
158 };
159 run(&["init"])
161 && run(&["config", "user.email", "test@example.com"])
162 && run(&["config", "user.name", "Test"])
163 && {
164 std::fs::write(dir.join("init.txt"), b"init").is_ok()
166 }
167 && run(&["add", "."])
168 && run(&["commit", "-m", "init"])
169 }
170
171 #[test]
172 fn test_validate_git_ref_valid() {
173 assert!(validate_git_ref("HEAD").is_ok());
174 assert!(validate_git_ref("main").is_ok());
175 assert!(validate_git_ref("origin/main").is_ok());
176 assert!(validate_git_ref("v1.2.3").is_ok());
177 assert!(validate_git_ref("abc1234").is_ok());
178 assert!(validate_git_ref("feat/my-feature").is_ok());
179 }
180
181 #[test]
182 fn test_validate_git_ref_invalid() {
183 assert!(validate_git_ref("").is_err());
184 assert!(validate_git_ref("--help").is_err());
185 assert!(validate_git_ref("HEAD^").is_err());
186 assert!(validate_git_ref("HEAD~1").is_err());
187 assert!(validate_git_ref("@{-1}").is_err());
188 assert!(validate_git_ref("refs:heads/main").is_err());
189 }
190
191 #[test]
192 fn test_git_changed_files_invalid_ref() {
193 if !git_available() {
194 return;
195 }
196 let tmp = tempfile::tempdir().unwrap();
197 if !init_git_repo_with_commit(tmp.path()) {
198 return;
199 }
200 let result = git_changed_files(tmp.path(), "nonexistent-ref-xyz-abc-999");
201 assert!(result.is_err());
202 }
203
204 #[test]
205 fn test_git_changed_files_head_returns_hashset() {
206 if !git_available() {
207 return;
208 }
209 let tmp = tempfile::tempdir().unwrap();
210 if !init_git_repo_with_commit(tmp.path()) {
211 return;
212 }
213 let result = git_changed_files(tmp.path(), "HEAD");
214 assert!(result.is_ok());
215 let _set: HashSet<PathBuf> = result.unwrap();
217 }
218}