Skip to main content

harn_hostlib/scanner/
git.rs

1//! Git-backed scanner inputs.
2//!
3//! The scanner core consumes Git through this capability boundary so tests
4//! can exercise tracked-file and churn behavior without depending on the
5//! ambient checkout, Git hooks, fsmonitor, or hook-time environment state.
6
7use std::collections::BTreeMap;
8use std::path::Path;
9use std::process::Command;
10
11/// Git data needed by the scanner.
12pub trait GitCapabilities {
13    /// Return tracked and untracked file paths relative to `root`.
14    fn list_files(&self, root: &Path) -> Option<Vec<String>>;
15
16    /// Return normalized 0..1 churn scores keyed by paths relative to `root`.
17    fn churn_scores(&self, root: &Path) -> BTreeMap<String, f64>;
18}
19
20/// Production [`GitCapabilities`] implementation backed by the `git` CLI.
21#[derive(Debug, Default)]
22pub struct CliGitCapabilities;
23
24impl GitCapabilities for CliGitCapabilities {
25    fn list_files(&self, root: &Path) -> Option<Vec<String>> {
26        if !has_git_repository_marker(root) {
27            return None;
28        }
29
30        let mut cmd = Command::new("git");
31        super::strip_ambient_git_env(&mut cmd);
32        // `-c core.quotepath=false` keeps non-ASCII paths as literal UTF-8
33        // instead of C-quoted (`"src/caf\303\251.rs"`); `-z` NUL-delimits the
34        // list so paths containing embedded newlines still round-trip.
35        let output = cmd
36            .args([
37                "-c",
38                "core.quotepath=false",
39                "-C",
40                root.to_str()?,
41                "ls-files",
42                "--cached",
43                "--others",
44                "--exclude-standard",
45                "-z",
46            ])
47            .output()
48            .ok()?;
49        if !output.status.success() {
50            return None;
51        }
52        let stdout = String::from_utf8(output.stdout).ok()?;
53        let entries: Vec<String> = stdout
54            .split('\0')
55            .filter(|entry| !entry.is_empty())
56            .map(str::to_string)
57            .collect();
58        if entries.is_empty() {
59            None
60        } else {
61            Some(entries)
62        }
63    }
64
65    fn churn_scores(&self, root: &Path) -> BTreeMap<String, f64> {
66        if !has_git_repository_marker(root) {
67            return BTreeMap::new();
68        }
69
70        let mut cmd = Command::new("git");
71        super::strip_ambient_git_env(&mut cmd);
72        // `-c core.quotepath=false` keeps non-ASCII paths literal so they match
73        // the tracked-file paths instead of coming back C-quoted. `--name-only`
74        // is newline-framed, so `-z` is not used here.
75        let output = cmd
76            .args([
77                "-c",
78                "core.quotepath=false",
79                "-C",
80                match root.to_str() {
81                    Some(s) => s,
82                    None => return BTreeMap::new(),
83                },
84                "log",
85                "--since=90.days",
86                "--name-only",
87                "--pretty=format:",
88            ])
89            .output();
90        let output = match output {
91            Ok(o) if o.status.success() => o,
92            _ => return BTreeMap::new(),
93        };
94        let stdout = match String::from_utf8(output.stdout) {
95            Ok(s) => s,
96            Err(_) => return BTreeMap::new(),
97        };
98
99        let mut counts: BTreeMap<String, usize> = BTreeMap::new();
100        for line in stdout.lines() {
101            let trimmed = line.trim();
102            if trimmed.is_empty() {
103                continue;
104            }
105            *counts.entry(trimmed.to_string()).or_insert(0) += 1;
106        }
107
108        let max = counts.values().copied().max().unwrap_or(1).max(1) as f64;
109        counts
110            .into_iter()
111            .map(|(file, count)| (file, count as f64 / max))
112            .collect()
113    }
114}
115
116/// Returns true when `root` is inside a Git worktree based on local marker files.
117///
118/// This deliberately avoids shelling out to `git rev-parse`: the default
119/// capability uses this predicate to decide whether spawning Git is appropriate.
120fn has_git_repository_marker(root: &Path) -> bool {
121    root.ancestors().any(|dir| dir.join(".git").exists())
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::fs;
128    use std::path::Path;
129    use tempfile::tempdir;
130
131    fn tempdir_outside_ambient_repo() -> tempfile::TempDir {
132        let ambient = std::env::current_dir().expect("cwd");
133        let temp_root = std::env::temp_dir();
134        let ambient_repo = ambient
135            .ancestors()
136            .find(|path| path.join(".git").exists())
137            .unwrap_or(ambient.as_path());
138        if !temp_root.starts_with(ambient_repo) {
139            return tempdir().unwrap();
140        }
141        let parent = ambient_repo
142            .parent()
143            .unwrap_or_else(|| Path::new("/"))
144            .join(".harn-hostlib-test-tmp");
145        fs::create_dir_all(&parent).unwrap();
146        tempfile::Builder::new()
147            .prefix("harn-hostlib-git-")
148            .tempdir_in(parent)
149            .unwrap()
150    }
151
152    #[test]
153    fn marker_detection_handles_plain_and_worktree_git_markers() {
154        let tmp = tempdir_outside_ambient_repo();
155        let root = tmp.path();
156
157        assert!(!has_git_repository_marker(root));
158
159        fs::write(root.join(".git"), "gitdir: /tmp/example\n").unwrap();
160        assert!(has_git_repository_marker(root));
161        assert!(has_git_repository_marker(&root.join("nested")));
162    }
163
164    /// Run a git command with a hermetic identity so tests do not depend on or
165    /// mutate ambient user config.
166    fn git_in(repo: &Path, args: &[&str]) {
167        let status = Command::new("git")
168            .args([
169                "-c",
170                "user.email=test@example.com",
171                "-c",
172                "user.name=Test",
173                "-c",
174                "commit.gpgsign=false",
175                "-c",
176                "init.defaultBranch=main",
177                "-C",
178            ])
179            .arg(repo)
180            .args(args)
181            .env("GIT_CONFIG_GLOBAL", "/dev/null")
182            .env("GIT_CONFIG_SYSTEM", "/dev/null")
183            .status()
184            .expect("spawn git");
185        assert!(status.success(), "git {args:?} failed");
186    }
187
188    #[test]
189    fn list_files_returns_literal_non_ascii_paths() {
190        let tmp = tempdir_outside_ambient_repo();
191        let root = tmp.path();
192        git_in(root, &["init"]);
193        // A tracked path with non-ASCII bytes would come back C-quoted
194        // (`"src/caf\303\251.rs"`) without `core.quotepath=false`, so it would
195        // never match the real on-disk path.
196        fs::create_dir_all(root.join("src")).unwrap();
197        fs::write(root.join("src/café.rs"), b"fn main() {}\n").unwrap();
198        git_in(root, &["add", "."]);
199
200        let files = CliGitCapabilities.list_files(root).expect("some files");
201        assert!(
202            files.iter().any(|f| f == "src/café.rs"),
203            "expected literal UTF-8 path, got {files:?}"
204        );
205        // No entry should carry surrounding quotes or backslash escapes.
206        assert!(
207            files
208                .iter()
209                .all(|f| !f.starts_with('"') && !f.contains('\\')),
210            "found C-quoted path in {files:?}"
211        );
212    }
213
214    #[test]
215    fn churn_scores_key_literal_non_ascii_paths() {
216        let tmp = tempdir_outside_ambient_repo();
217        let root = tmp.path();
218        git_in(root, &["init"]);
219        fs::create_dir_all(root.join("src")).unwrap();
220        fs::write(root.join("src/café.rs"), b"fn main() {}\n").unwrap();
221        git_in(root, &["add", "."]);
222        git_in(root, &["commit", "-m", "seed"]);
223
224        let scores = CliGitCapabilities.churn_scores(root);
225        assert!(
226            scores.contains_key("src/café.rs"),
227            "expected literal UTF-8 key, got {:?}",
228            scores.keys().collect::<Vec<_>>()
229        );
230    }
231}