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 = git_changed_files(project, &["ls-files", "--others", "--exclude-standard"])
77            .context("Failed to list untracked files")?;
78        files.extend(staged_files);
79        files.extend(untracked);
80        files.sort();
81        files.dedup();
82        (files, "git:uncommitted".to_string())
83    } else {
84        let range = format!("{}...HEAD", base_ref);
85        let files = git_changed_files(project, &["diff", "--name-only", &range])
86            .context("Failed to list base-ref changes")?;
87        (files, format!("git:{}...HEAD", base_ref))
88    };
89
90    // Filter files to only those matching the target language's extensions.
91    let valid_extensions = language.extensions();
92    let changed_files: Vec<PathBuf> = raw_files
93        .into_iter()
94        .filter(|f| {
95            f.extension()
96                .and_then(|e| e.to_str())
97                .map(|ext| {
98                    let dotted = format!(".{}", ext);
99                    valid_extensions.contains(&dotted.as_str())
100                })
101                .unwrap_or(false)
102        })
103        .collect();
104
105    // Filter out paths matching .tldrignore patterns (e.g. corpus/, vendor/).
106    let changed_files = tldr_core::callgraph::filter_tldrignored(project, changed_files);
107
108    Ok(ChangeDetectionResult {
109        changed_files,
110        detection_method,
111    })
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use tempfile::TempDir;
118
119    /// Helper: initialize a git repo with an initial commit in a temp directory.
120    fn init_git_repo() -> TempDir {
121        let tmp = TempDir::new().expect("create temp dir");
122        let dir = tmp.path();
123
124        Command::new("git")
125            .args(["init"])
126            .current_dir(dir)
127            .output()
128            .expect("git init");
129
130        Command::new("git")
131            .args(["config", "user.email", "test@test.com"])
132            .current_dir(dir)
133            .output()
134            .expect("git config email");
135
136        Command::new("git")
137            .args(["config", "user.name", "Test"])
138            .current_dir(dir)
139            .output()
140            .expect("git config name");
141
142        // Create an initial commit so HEAD exists
143        std::fs::write(dir.join("README.md"), "# test\n").expect("write readme");
144        Command::new("git")
145            .args(["add", "."])
146            .current_dir(dir)
147            .output()
148            .expect("git add");
149        Command::new("git")
150            .args(["commit", "-m", "init"])
151            .current_dir(dir)
152            .output()
153            .expect("git commit");
154
155        tmp
156    }
157
158    #[test]
159    fn test_detect_changes_no_changes_returns_empty() {
160        let tmp = init_git_repo();
161        let result =
162            detect_changes(tmp.path(), "HEAD", false, &Language::Rust).expect("detect_changes");
163
164        assert!(
165            result.changed_files.is_empty(),
166            "Expected no changed files in a clean repo, got: {:?}",
167            result.changed_files
168        );
169        assert_eq!(result.detection_method, "git:uncommitted");
170    }
171
172    #[test]
173    fn test_detect_changes_staged_method() {
174        let tmp = init_git_repo();
175        let result =
176            detect_changes(tmp.path(), "HEAD", true, &Language::Rust).expect("detect_changes");
177
178        assert_eq!(result.detection_method, "git:staged");
179    }
180
181    #[test]
182    fn test_detect_changes_base_ref_method() {
183        let tmp = init_git_repo();
184        // Create a branch named "main" so the base ref is valid
185        Command::new("git")
186            .args(["branch", "main"])
187            .current_dir(tmp.path())
188            .output()
189            .expect("git branch main");
190
191        let result =
192            detect_changes(tmp.path(), "main", false, &Language::Python).expect("detect_changes");
193
194        assert_eq!(result.detection_method, "git:main...HEAD");
195    }
196
197    #[test]
198    fn test_detect_changes_filters_by_language() {
199        let tmp = init_git_repo();
200        let dir = tmp.path();
201
202        // Create uncommitted files of different languages
203        std::fs::write(dir.join("hello.rs"), "fn main() {}\n").expect("write rs");
204        std::fs::write(dir.join("hello.py"), "print('hi')\n").expect("write py");
205        std::fs::write(dir.join("hello.js"), "console.log('hi')\n").expect("write js");
206
207        // Stage them all (so git sees them as changes)
208        Command::new("git")
209            .args(["add", "."])
210            .current_dir(dir)
211            .output()
212            .expect("git add");
213
214        // Detect only Rust changes
215        let result =
216            detect_changes(dir, "HEAD", true, &Language::Rust).expect("detect_changes rust");
217
218        // Only .rs files should appear
219        for f in &result.changed_files {
220            assert_eq!(
221                f.extension().and_then(|e| e.to_str()),
222                Some("rs"),
223                "Expected only .rs files, got: {}",
224                f.display()
225            );
226        }
227        assert!(
228            !result.changed_files.is_empty(),
229            "Expected at least one .rs file in changed_files"
230        );
231
232        // Detect only Python changes
233        let result =
234            detect_changes(dir, "HEAD", true, &Language::Python).expect("detect_changes python");
235
236        for f in &result.changed_files {
237            assert_eq!(
238                f.extension().and_then(|e| e.to_str()),
239                Some("py"),
240                "Expected only .py files, got: {}",
241                f.display()
242            );
243        }
244        assert!(
245            !result.changed_files.is_empty(),
246            "Expected at least one .py file in changed_files"
247        );
248    }
249
250    #[test]
251    fn test_detect_changes_uncommitted_finds_unstaged() {
252        let tmp = init_git_repo();
253        let dir = tmp.path();
254
255        // Modify a tracked file (create it first, commit, then modify)
256        let rs_file = dir.join("lib.rs");
257        std::fs::write(&rs_file, "pub fn old() {}\n").expect("write rs");
258        Command::new("git")
259            .args(["add", "lib.rs"])
260            .current_dir(dir)
261            .output()
262            .expect("git add");
263        Command::new("git")
264            .args(["commit", "-m", "add lib"])
265            .current_dir(dir)
266            .output()
267            .expect("git commit");
268
269        // Now modify it without staging
270        std::fs::write(&rs_file, "pub fn new_version() {}\n").expect("overwrite rs");
271
272        let result = detect_changes(dir, "HEAD", false, &Language::Rust).expect("detect_changes");
273
274        assert_eq!(result.detection_method, "git:uncommitted");
275        assert!(
276            result.changed_files.iter().any(|f| {
277                f.file_name()
278                    .and_then(|n| n.to_str())
279                    .map(|n| n == "lib.rs")
280                    .unwrap_or(false)
281            }),
282            "Expected lib.rs in changed files, got: {:?}",
283            result.changed_files
284        );
285    }
286
287    #[test]
288    fn test_detect_changes_ignores_non_matching_extensions() {
289        let tmp = init_git_repo();
290        let dir = tmp.path();
291
292        // Create only non-Rust files
293        std::fs::write(dir.join("app.py"), "x = 1\n").expect("write py");
294        std::fs::write(dir.join("app.js"), "var x = 1;\n").expect("write js");
295        Command::new("git")
296            .args(["add", "."])
297            .current_dir(dir)
298            .output()
299            .expect("git add");
300
301        let result = detect_changes(dir, "HEAD", true, &Language::Rust).expect("detect_changes");
302
303        assert!(
304            result.changed_files.is_empty(),
305            "Expected no Rust files when only .py and .js were changed, got: {:?}",
306            result.changed_files
307        );
308    }
309
310    #[test]
311    fn test_change_detection_result_fields() {
312        let result = ChangeDetectionResult {
313            changed_files: vec![PathBuf::from("src/main.rs")],
314            detection_method: "git:staged".to_string(),
315        };
316        assert_eq!(result.changed_files.len(), 1);
317        assert_eq!(result.detection_method, "git:staged");
318    }
319
320    #[test]
321    fn test_detect_changes_respects_tldrignore() {
322        let tmp = init_git_repo();
323        let dir = tmp.path();
324
325        // Create files in corpus/ (should be ignored) and src/ (should survive)
326        std::fs::create_dir_all(dir.join("corpus")).unwrap();
327        std::fs::create_dir_all(dir.join("src")).unwrap();
328        std::fs::write(dir.join("corpus/vendored.py"), "x = 1\n").unwrap();
329        std::fs::write(dir.join("src/main.py"), "y = 2\n").unwrap();
330
331        // Create .tldrignore excluding corpus/
332        std::fs::write(dir.join(".tldrignore"), "corpus/\n").unwrap();
333
334        // Stage all files
335        Command::new("git")
336            .args(["add", "."])
337            .current_dir(dir)
338            .output()
339            .expect("git add");
340
341        let result = detect_changes(dir, "HEAD", true, &Language::Python).expect("detect_changes");
342
343        // corpus/vendored.py should be excluded, only src/main.py remains
344        assert!(
345            !result
346                .changed_files
347                .iter()
348                .any(|f| { f.to_string_lossy().contains("corpus") }),
349            "corpus/ files should be excluded by .tldrignore, got: {:?}",
350            result.changed_files
351        );
352        assert!(
353            result.changed_files.iter().any(|f| {
354                f.file_name()
355                    .and_then(|n| n.to_str())
356                    .map(|n| n == "main.py")
357                    .unwrap_or(false)
358            }),
359            "src/main.py should be present, got: {:?}",
360            result.changed_files
361        );
362    }
363}