Skip to main content

tldr_cli/commands/bugbot/
changes.rs

1//! Git change detection for bugbot
2//!
3//! Detects files changed via git, filtered to the target language.
4//! Uses direct `git` commands to list changed files -- no call graph needed.
5
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result};
10
11use tldr_core::Language;
12
13/// Result of detecting changed files in the project.
14#[derive(Debug, Clone)]
15pub struct ChangeDetectionResult {
16    /// Files that changed and match the target language.
17    pub changed_files: Vec<PathBuf>,
18    /// How changes were detected (e.g. "git:staged", "git:uncommitted").
19    pub detection_method: String,
20}
21
22/// Run a git command in `project` and return the listed file paths.
23///
24/// Each non-empty line of stdout is joined with `project` to form an absolute path.
25fn git_changed_files(project: &Path, args: &[&str]) -> Result<Vec<PathBuf>> {
26    let output = Command::new("git")
27        .args(args)
28        .current_dir(project)
29        .output()
30        .context("Failed to run git")?;
31
32    if !output.status.success() {
33        let stderr = String::from_utf8_lossy(&output.stderr);
34        anyhow::bail!("git command failed: {}", stderr);
35    }
36
37    let stdout = String::from_utf8_lossy(&output.stdout);
38    Ok(stdout
39        .lines()
40        .filter(|l| !l.is_empty())
41        .map(|l| project.join(l))
42        .collect())
43}
44
45/// Detect changed files in `project`, filtered to the given `language`.
46///
47/// # Arguments
48/// * `project` - Project root directory (must be inside a git repo)
49/// * `base_ref` - Git base reference (e.g. "HEAD", "main", "origin/main")
50/// * `staged` - If true, only consider staged changes; otherwise all uncommitted
51/// * `language` - Only return files matching this language's extensions
52///
53/// # Detection Method
54/// - `staged == true`  => `"git:staged"`
55/// - `staged == false` and `base_ref == "HEAD"` => `"git:uncommitted"`
56/// - `staged == false` and `base_ref != "HEAD"` => `"git:{base_ref}...HEAD"`
57///
58/// # Returns
59/// A `ChangeDetectionResult` with the filtered file list and the detection method string.
60pub fn detect_changes(
61    project: &Path,
62    base_ref: &str,
63    staged: bool,
64    language: &Language,
65) -> Result<ChangeDetectionResult> {
66    let (raw_files, detection_method) = if staged {
67        let files = git_changed_files(project, &["diff", "--name-only", "--staged"])
68            .context("Failed to list staged changes")?;
69        (files, "git:staged".to_string())
70    } else if base_ref == "HEAD" {
71        // Uncommitted = modified tracked + staged + untracked
72        let mut files = git_changed_files(project, &["diff", "--name-only", "HEAD"])
73            .context("Failed to list uncommitted changes")?;
74        let staged_files = git_changed_files(project, &["diff", "--name-only", "--staged"])
75            .context("Failed to list staged changes")?;
76        let untracked =
77            git_changed_files(project, &["ls-files", "--others", "--exclude-standard"])
78                .context("Failed to list untracked files")?;
79        files.extend(staged_files);
80        files.extend(untracked);
81        files.sort();
82        files.dedup();
83        (files, "git:uncommitted".to_string())
84    } else {
85        let range = format!("{}...HEAD", base_ref);
86        let files = git_changed_files(project, &["diff", "--name-only", &range])
87            .context("Failed to list base-ref changes")?;
88        (files, format!("git:{}...HEAD", base_ref))
89    };
90
91    // Filter files to only those matching the target language's extensions.
92    let valid_extensions = language.extensions();
93    let changed_files: Vec<PathBuf> = raw_files
94        .into_iter()
95        .filter(|f| {
96            f.extension()
97                .and_then(|e| e.to_str())
98                .map(|ext| {
99                    let dotted = format!(".{}", ext);
100                    valid_extensions.contains(&dotted.as_str())
101                })
102                .unwrap_or(false)
103        })
104        .collect();
105
106    // Filter out paths matching .tldrignore patterns (e.g. corpus/, vendor/).
107    let changed_files = tldr_core::callgraph::filter_tldrignored(project, changed_files);
108
109    Ok(ChangeDetectionResult {
110        changed_files,
111        detection_method,
112    })
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use tempfile::TempDir;
119
120    /// Helper: initialize a git repo with an initial commit in a temp directory.
121    fn init_git_repo() -> TempDir {
122        let tmp = TempDir::new().expect("create temp dir");
123        let dir = tmp.path();
124
125        Command::new("git")
126            .args(["init"])
127            .current_dir(dir)
128            .output()
129            .expect("git init");
130
131        Command::new("git")
132            .args(["config", "user.email", "test@test.com"])
133            .current_dir(dir)
134            .output()
135            .expect("git config email");
136
137        Command::new("git")
138            .args(["config", "user.name", "Test"])
139            .current_dir(dir)
140            .output()
141            .expect("git config name");
142
143        // Create an initial commit so HEAD exists
144        std::fs::write(dir.join("README.md"), "# test\n").expect("write readme");
145        Command::new("git")
146            .args(["add", "."])
147            .current_dir(dir)
148            .output()
149            .expect("git add");
150        Command::new("git")
151            .args(["commit", "-m", "init"])
152            .current_dir(dir)
153            .output()
154            .expect("git commit");
155
156        tmp
157    }
158
159    #[test]
160    fn test_detect_changes_no_changes_returns_empty() {
161        let tmp = init_git_repo();
162        let result =
163            detect_changes(tmp.path(), "HEAD", false, &Language::Rust).expect("detect_changes");
164
165        assert!(
166            result.changed_files.is_empty(),
167            "Expected no changed files in a clean repo, got: {:?}",
168            result.changed_files
169        );
170        assert_eq!(result.detection_method, "git:uncommitted");
171    }
172
173    #[test]
174    fn test_detect_changes_staged_method() {
175        let tmp = init_git_repo();
176        let result =
177            detect_changes(tmp.path(), "HEAD", true, &Language::Rust).expect("detect_changes");
178
179        assert_eq!(result.detection_method, "git:staged");
180    }
181
182    #[test]
183    fn test_detect_changes_base_ref_method() {
184        let tmp = init_git_repo();
185        // Create a branch named "main" so the base ref is valid
186        Command::new("git")
187            .args(["branch", "main"])
188            .current_dir(tmp.path())
189            .output()
190            .expect("git branch main");
191
192        let result =
193            detect_changes(tmp.path(), "main", false, &Language::Python).expect("detect_changes");
194
195        assert_eq!(result.detection_method, "git:main...HEAD");
196    }
197
198    #[test]
199    fn test_detect_changes_filters_by_language() {
200        let tmp = init_git_repo();
201        let dir = tmp.path();
202
203        // Create uncommitted files of different languages
204        std::fs::write(dir.join("hello.rs"), "fn main() {}\n").expect("write rs");
205        std::fs::write(dir.join("hello.py"), "print('hi')\n").expect("write py");
206        std::fs::write(dir.join("hello.js"), "console.log('hi')\n").expect("write js");
207
208        // Stage them all (so git sees them as changes)
209        Command::new("git")
210            .args(["add", "."])
211            .current_dir(dir)
212            .output()
213            .expect("git add");
214
215        // Detect only Rust changes
216        let result =
217            detect_changes(dir, "HEAD", true, &Language::Rust).expect("detect_changes rust");
218
219        // Only .rs files should appear
220        for f in &result.changed_files {
221            assert_eq!(
222                f.extension().and_then(|e| e.to_str()),
223                Some("rs"),
224                "Expected only .rs files, got: {}",
225                f.display()
226            );
227        }
228        assert!(
229            !result.changed_files.is_empty(),
230            "Expected at least one .rs file in changed_files"
231        );
232
233        // Detect only Python changes
234        let result =
235            detect_changes(dir, "HEAD", true, &Language::Python).expect("detect_changes python");
236
237        for f in &result.changed_files {
238            assert_eq!(
239                f.extension().and_then(|e| e.to_str()),
240                Some("py"),
241                "Expected only .py files, got: {}",
242                f.display()
243            );
244        }
245        assert!(
246            !result.changed_files.is_empty(),
247            "Expected at least one .py file in changed_files"
248        );
249    }
250
251    #[test]
252    fn test_detect_changes_uncommitted_finds_unstaged() {
253        let tmp = init_git_repo();
254        let dir = tmp.path();
255
256        // Modify a tracked file (create it first, commit, then modify)
257        let rs_file = dir.join("lib.rs");
258        std::fs::write(&rs_file, "pub fn old() {}\n").expect("write rs");
259        Command::new("git")
260            .args(["add", "lib.rs"])
261            .current_dir(dir)
262            .output()
263            .expect("git add");
264        Command::new("git")
265            .args(["commit", "-m", "add lib"])
266            .current_dir(dir)
267            .output()
268            .expect("git commit");
269
270        // Now modify it without staging
271        std::fs::write(&rs_file, "pub fn new_version() {}\n").expect("overwrite rs");
272
273        let result =
274            detect_changes(dir, "HEAD", false, &Language::Rust).expect("detect_changes");
275
276        assert_eq!(result.detection_method, "git:uncommitted");
277        assert!(
278            result.changed_files.iter().any(|f| {
279                f.file_name()
280                    .and_then(|n| n.to_str())
281                    .map(|n| n == "lib.rs")
282                    .unwrap_or(false)
283            }),
284            "Expected lib.rs in changed files, got: {:?}",
285            result.changed_files
286        );
287    }
288
289    #[test]
290    fn test_detect_changes_ignores_non_matching_extensions() {
291        let tmp = init_git_repo();
292        let dir = tmp.path();
293
294        // Create only non-Rust files
295        std::fs::write(dir.join("app.py"), "x = 1\n").expect("write py");
296        std::fs::write(dir.join("app.js"), "var x = 1;\n").expect("write js");
297        Command::new("git")
298            .args(["add", "."])
299            .current_dir(dir)
300            .output()
301            .expect("git add");
302
303        let result =
304            detect_changes(dir, "HEAD", true, &Language::Rust).expect("detect_changes");
305
306        assert!(
307            result.changed_files.is_empty(),
308            "Expected no Rust files when only .py and .js were changed, got: {:?}",
309            result.changed_files
310        );
311    }
312
313    #[test]
314    fn test_change_detection_result_fields() {
315        let result = ChangeDetectionResult {
316            changed_files: vec![PathBuf::from("src/main.rs")],
317            detection_method: "git:staged".to_string(),
318        };
319        assert_eq!(result.changed_files.len(), 1);
320        assert_eq!(result.detection_method, "git:staged");
321    }
322
323    #[test]
324    fn test_detect_changes_respects_tldrignore() {
325        let tmp = init_git_repo();
326        let dir = tmp.path();
327
328        // Create files in corpus/ (should be ignored) and src/ (should survive)
329        std::fs::create_dir_all(dir.join("corpus")).unwrap();
330        std::fs::create_dir_all(dir.join("src")).unwrap();
331        std::fs::write(dir.join("corpus/vendored.py"), "x = 1\n").unwrap();
332        std::fs::write(dir.join("src/main.py"), "y = 2\n").unwrap();
333
334        // Create .tldrignore excluding corpus/
335        std::fs::write(dir.join(".tldrignore"), "corpus/\n").unwrap();
336
337        // Stage all files
338        Command::new("git")
339            .args(["add", "."])
340            .current_dir(dir)
341            .output()
342            .expect("git add");
343
344        let result =
345            detect_changes(dir, "HEAD", true, &Language::Python).expect("detect_changes");
346
347        // corpus/vendored.py should be excluded, only src/main.py remains
348        assert!(
349            !result.changed_files.iter().any(|f| {
350                f.to_string_lossy().contains("corpus")
351            }),
352            "corpus/ files should be excluded by .tldrignore, got: {:?}",
353            result.changed_files
354        );
355        assert!(
356            result.changed_files.iter().any(|f| {
357                f.file_name()
358                    .and_then(|n| n.to_str())
359                    .map(|n| n == "main.py")
360                    .unwrap_or(false)
361            }),
362            "src/main.py should be present, got: {:?}",
363            result.changed_files
364        );
365    }
366}