1use crate::{Result, TestGapError};
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6pub fn get_changed_files(analysis_root: &Path, base_ref: &str) -> Result<HashSet<PathBuf>> {
16 let git_root = find_git_root(analysis_root)?;
17
18 let mut changed_repo_relative = HashSet::new();
19
20 let output = Command::new("git")
24 .args(["diff", "--name-only", base_ref])
25 .current_dir(&git_root)
26 .output()
27 .map_err(|e| TestGapError::Config(format!("failed to run git diff: {e}")))?;
28
29 if !output.status.success() {
30 let stderr = String::from_utf8_lossy(&output.stderr);
31 return Err(TestGapError::Config(format!(
32 "git diff --name-only {base_ref} failed: {stderr}"
33 )));
34 }
35
36 collect_lines(&output.stdout, &mut changed_repo_relative);
37
38 let output = Command::new("git")
40 .args(["diff", "--name-only"])
41 .current_dir(&git_root)
42 .output()
43 .map_err(|e| TestGapError::Config(format!("failed to run git diff: {e}")))?;
44
45 if output.status.success() {
46 collect_lines(&output.stdout, &mut changed_repo_relative);
47 }
48
49 let output = Command::new("git")
51 .args(["ls-files", "--others", "--exclude-standard"])
52 .current_dir(&git_root)
53 .output()
54 .map_err(|e| TestGapError::Config(format!("failed to run git ls-files: {e}")))?;
55
56 if output.status.success() {
57 collect_lines(&output.stdout, &mut changed_repo_relative);
58 }
59
60 let prefix = analysis_root
63 .strip_prefix(&git_root)
64 .unwrap_or(Path::new(""));
65
66 let mut result = HashSet::new();
67 for repo_path in changed_repo_relative {
68 if let Ok(rel) = repo_path.strip_prefix(prefix) {
69 result.insert(rel.to_path_buf());
70 }
71 }
73
74 Ok(result)
75}
76
77pub fn resolve_default_branch(start: &Path) -> Result<String> {
82 let output = Command::new("git")
84 .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
85 .current_dir(start)
86 .output();
87
88 if let Ok(ref out) = output {
89 if out.status.success() {
90 let full_ref = String::from_utf8_lossy(&out.stdout).trim().to_string();
91 if let Some(branch) = full_ref.strip_prefix("refs/remotes/") {
93 return Ok(branch.to_string());
94 }
95 }
96 }
97
98 for candidate in &["main", "master"] {
100 let output = Command::new("git")
101 .args(["rev-parse", "--verify", candidate])
102 .current_dir(start)
103 .output();
104 if let Ok(ref out) = output {
105 if out.status.success() {
106 return Ok((*candidate).to_string());
107 }
108 }
109 }
110
111 Err(TestGapError::Config(
112 "could not detect default branch (tried origin/HEAD, main, master)".into(),
113 ))
114}
115
116fn find_git_root(start: &Path) -> Result<PathBuf> {
118 let output = Command::new("git")
119 .args(["rev-parse", "--show-toplevel"])
120 .current_dir(start)
121 .output()
122 .map_err(|e| {
123 TestGapError::Config(format!("not a git repository (failed to run git): {e}"))
124 })?;
125
126 if !output.status.success() {
127 let stderr = String::from_utf8_lossy(&output.stderr);
128 return Err(TestGapError::Config(format!(
129 "not a git repository: {stderr}"
130 )));
131 }
132
133 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
134 Ok(PathBuf::from(root))
135}
136
137fn collect_lines(stdout: &[u8], set: &mut HashSet<PathBuf>) {
138 for line in String::from_utf8_lossy(stdout).lines() {
139 let line = line.trim();
140 if !line.is_empty() {
141 set.insert(PathBuf::from(line));
142 }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn init_repo(dir: &Path) {
151 Command::new("git")
152 .args(["init"])
153 .current_dir(dir)
154 .output()
155 .unwrap();
156 Command::new("git")
157 .args(["config", "user.email", "test@test.com"])
158 .current_dir(dir)
159 .output()
160 .unwrap();
161 Command::new("git")
162 .args(["config", "user.name", "Test"])
163 .current_dir(dir)
164 .output()
165 .unwrap();
166 }
167
168 #[test]
169 fn invalid_ref_returns_error() {
170 let dir = tempfile::tempdir().unwrap();
171 init_repo(dir.path());
172 Command::new("git")
173 .args(["commit", "--allow-empty", "-m", "init"])
174 .current_dir(dir.path())
175 .output()
176 .unwrap();
177
178 let result = get_changed_files(dir.path(), "nonexistent-ref-abc123");
179 assert!(result.is_err(), "expected error for invalid ref");
180 }
181
182 #[test]
183 fn detects_modified_file() {
184 let dir = tempfile::tempdir().unwrap();
185 init_repo(dir.path());
186
187 std::fs::write(dir.path().join("hello.rs"), "fn main() {}").unwrap();
188 Command::new("git")
189 .args(["add", "."])
190 .current_dir(dir.path())
191 .output()
192 .unwrap();
193 Command::new("git")
194 .args(["commit", "-m", "initial"])
195 .current_dir(dir.path())
196 .output()
197 .unwrap();
198
199 std::fs::write(dir.path().join("hello.rs"), "fn main() { println!(); }").unwrap();
201
202 let changed = get_changed_files(dir.path(), "HEAD").unwrap();
203 assert!(
204 changed.contains(&PathBuf::from("hello.rs")),
205 "expected hello.rs in changed set, got: {changed:?}"
206 );
207 }
208
209 #[test]
210 fn subdirectory_returns_relative_to_analysis_root() {
211 let dir = tempfile::tempdir().unwrap();
212 init_repo(dir.path());
213
214 let sub = dir.path().join("crates").join("mylib").join("src");
216 std::fs::create_dir_all(&sub).unwrap();
217 std::fs::write(sub.join("lib.rs"), "pub fn foo() {}").unwrap();
218 std::fs::write(dir.path().join("root.rs"), "fn root() {}").unwrap();
219
220 Command::new("git")
221 .args(["add", "."])
222 .current_dir(dir.path())
223 .output()
224 .unwrap();
225 Command::new("git")
226 .args(["commit", "-m", "initial"])
227 .current_dir(dir.path())
228 .output()
229 .unwrap();
230
231 std::fs::write(sub.join("lib.rs"), "pub fn foo() { 42 }").unwrap();
233
234 let analysis_root = dir.path().join("crates").join("mylib");
236 let changed = get_changed_files(&analysis_root, "HEAD").unwrap();
237
238 assert!(
240 changed.contains(&PathBuf::from("src/lib.rs")),
241 "expected src/lib.rs relative to analysis root, got: {changed:?}"
242 );
243 assert!(
245 !changed.contains(&PathBuf::from("root.rs")),
246 "root.rs should be excluded (outside analysis root)"
247 );
248 }
249
250 #[test]
251 fn non_git_directory_returns_clear_error() {
252 let dir = tempfile::tempdir().unwrap();
253 let result = get_changed_files(dir.path(), "main");
254 assert!(result.is_err());
255 let err = result.unwrap_err().to_string();
256 assert!(
257 err.contains("not a git repository"),
258 "expected clear error message, got: {err}"
259 );
260 }
261}