Skip to main content

testgap_core/
git_diff.rs

1use crate::{Result, TestGapError};
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6/// Returns the set of changed files as paths relative to `analysis_root`.
7///
8/// Resolves the git repo root internally so this works correctly even when
9/// `analysis_root` is a subdirectory of the repository (monorepo case).
10///
11/// Combines:
12/// - `git diff --name-only <base_ref>` (committed changes vs base)
13/// - `git diff --name-only` (unstaged changes)
14/// - `git ls-files --others --exclude-standard` (untracked files)
15pub 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    // All git commands run from the repo root so paths are consistently repo-relative.
21
22    // Committed changes relative to base ref
23    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    // Unstaged changes in working tree
39    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    // Untracked files (also from repo root for consistency)
50    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    // Convert repo-relative paths to analysis-root-relative paths.
61    // If analysis_root == git_root, prefix is empty and paths pass through unchanged.
62    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        // Paths outside the analysis root are silently ignored
72    }
73
74    Ok(result)
75}
76
77/// Resolve the default branch name for a repository.
78///
79/// Tries `origin/HEAD` first (works after `git clone`), then falls back to
80/// checking if `main` or `master` exist as local branches.
81pub fn resolve_default_branch(start: &Path) -> Result<String> {
82    // Try origin/HEAD (set by git clone)
83    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            // "refs/remotes/origin/main" → "origin/main"
92            if let Some(branch) = full_ref.strip_prefix("refs/remotes/") {
93                return Ok(branch.to_string());
94            }
95        }
96    }
97
98    // Fallback: check if main or master branches exist
99    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
116/// Find the git repository root from a starting directory.
117fn 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        // Modify the file
200        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        // Create a subdirectory structure like a monorepo
215        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        // Modify the file in the subdirectory
232        std::fs::write(sub.join("lib.rs"), "pub fn foo() { 42 }").unwrap();
233
234        // Analyze from the subdirectory, not repo root
235        let analysis_root = dir.path().join("crates").join("mylib");
236        let changed = get_changed_files(&analysis_root, "HEAD").unwrap();
237
238        // Should contain the path relative to the analysis root, not repo root
239        assert!(
240            changed.contains(&PathBuf::from("src/lib.rs")),
241            "expected src/lib.rs relative to analysis root, got: {changed:?}"
242        );
243        // root.rs is outside analysis root, should NOT appear
244        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}