Skip to main content

timebomb/
git.rs

1use crate::error::{Error, Result};
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6/// Returns true if `path` is inside a git repository (i.e. `git rev-parse`
7/// succeeds in that directory).
8pub 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
18/// Validate that a git ref contains only safe characters.
19///
20/// Permits alphanumerics plus `.`, `/`, `-`, `_` — the characters needed for
21/// branch names, tags, and SHAs.  Rejects leading `--` (option injection) and
22/// any special git syntax characters (`@`, `^`, `~`, `:`).
23pub 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
47/// Run `git diff --name-only <git_ref>` from `repo_root` and return the set
48/// of relative paths of changed files.
49///
50/// Returns an error if git is not available, the repo_root is not a git repo,
51/// or the ref is invalid.
52pub 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    // Run unstaged diff
57    let unstaged = run_git_diff(repo_root, git_ref, false)?;
58    result.extend(unstaged);
59
60    // Run staged (cached) diff
61    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        // Create a fresh temp directory and initialise a git repo in it so
126        // this test is environment-independent (the project directory itself
127        // may not be inside a git repo in all CI/sandbox environments).
128        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            // git init failed for some reason — skip gracefully.
137            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    /// Initialise a bare-minimum git repo in `dir` with one commit so that
149    /// HEAD and diff commands are usable.  Returns false if anything fails.
150    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        // Initialise and create at least one commit so HEAD exists.
160        run(&["init"])
161            && run(&["config", "user.email", "test@example.com"])
162            && run(&["config", "user.name", "Test"])
163            && {
164                // Create an empty file and commit it.
165                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        // Result is a HashSet (possibly empty if there are no diffs against HEAD)
216        let _set: HashSet<PathBuf> = result.unwrap();
217    }
218}