harn_hostlib/scanner/
git.rs1use std::collections::BTreeMap;
8use std::path::Path;
9use std::process::Command;
10
11pub trait GitCapabilities {
13 fn list_files(&self, root: &Path) -> Option<Vec<String>>;
15
16 fn churn_scores(&self, root: &Path) -> BTreeMap<String, f64>;
18}
19
20#[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 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 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
116fn 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 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 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 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}