1use std::collections::{HashMap, HashSet};
9use std::path::Path;
10use std::process::{Command, Output};
11
12use crate::error::{CtxError, Result};
13
14pub fn is_git_repo() -> bool {
16 is_git_repo_in(Path::new("."))
17}
18
19pub fn repo_prefix() -> Result<String> {
24 repo_prefix_in(Path::new("."))
25}
26
27pub fn changed_files_against(reference: &str) -> Result<HashSet<String>> {
37 changed_files_against_in(Path::new("."), reference)
38}
39
40pub fn churn_since(since: &str) -> Result<HashMap<String, u32>> {
46 churn_since_in(Path::new("."), since)
47}
48
49pub fn show_file(reference: &str, path: &str) -> Result<Option<String>> {
52 show_file_in(Path::new("."), reference, path)
53}
54
55pub fn is_git_repo_in(dir: &Path) -> bool {
62 Command::new("git")
63 .args(["rev-parse", "--git-dir"])
64 .current_dir(dir)
65 .output()
66 .map(|o| o.status.success())
67 .unwrap_or(false)
68}
69
70fn repo_prefix_in(dir: &Path) -> Result<String> {
71 let output = run_git(dir, &["rev-parse", "--show-prefix"])?;
72 let stdout = stdout_or_err(output, None)?;
73 Ok(stdout.trim_end_matches(['\n', '\r']).to_string())
74}
75
76pub fn changed_files_against_in(dir: &Path, reference: &str) -> Result<HashSet<String>> {
79 if !is_git_repo_in(dir) {
80 return Err(CtxError::NotGitRepo);
81 }
82
83 let prefix = repo_prefix_in(dir)?;
84 let mut files = HashSet::new();
85
86 let range = format!("{}...HEAD", reference);
88 let output = run_git(dir, &["diff", "--name-only", &range])?;
89 let committed = stdout_or_err(output, Some(reference))?;
90 collect_paths(&committed, &prefix, &mut files);
91
92 let output = run_git(dir, &["diff", "--name-only", "HEAD"])?;
94 let uncommitted = stdout_or_err(output, None)?;
95 collect_paths(&uncommitted, &prefix, &mut files);
96
97 let output = run_git(
99 dir,
100 &["ls-files", "--others", "--exclude-standard", "--full-name"],
101 )?;
102 let untracked = stdout_or_err(output, None)?;
103 collect_paths(&untracked, &prefix, &mut files);
104
105 Ok(files)
106}
107
108fn churn_since_in(dir: &Path, since: &str) -> Result<HashMap<String, u32>> {
109 if !is_git_repo_in(dir) {
110 return Err(CtxError::NotGitRepo);
111 }
112
113 let prefix = repo_prefix_in(dir)?;
114 let since_arg = format!("--since={}", since);
115 let output = run_git(
116 dir,
117 &[
118 "log",
119 &since_arg,
120 "--format=",
121 "--name-only",
122 "--no-renames",
123 "--",
124 ".",
125 ],
126 )?;
127 let log = stdout_or_err(output, None)?;
128
129 let mut churn = HashMap::new();
130 for (path, count) in parse_name_only_log(&log) {
131 if let Some(local) = strip_repo_prefix(&path, &prefix) {
132 *churn.entry(local).or_insert(0) += count;
133 }
134 }
135 Ok(churn)
136}
137
138pub fn show_file_in(dir: &Path, reference: &str, path: &str) -> Result<Option<String>> {
141 if !is_git_repo_in(dir) {
142 return Err(CtxError::NotGitRepo);
143 }
144
145 let spec = format!("{}:./{}", reference, path);
148 let output = run_git(dir, &["show", &spec])?;
149
150 if output.status.success() {
151 return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
152 }
153
154 let stderr = String::from_utf8_lossy(&output.stderr);
155 if stderr.contains("does not exist") || stderr.contains("exists on disk, but not in") {
156 return Ok(None);
157 }
158 stdout_or_err(output, Some(reference)).map(Some)
160}
161
162fn run_git(dir: &Path, args: &[&str]) -> Result<Output> {
168 Ok(Command::new("git").args(args).current_dir(dir).output()?)
169}
170
171fn stdout_or_err(output: Output, revision: Option<&str>) -> Result<String> {
176 if output.status.success() {
177 return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
178 }
179
180 let stderr = String::from_utf8_lossy(&output.stderr);
181 if stderr.contains("not a git repository") {
182 return Err(CtxError::NotGitRepo);
183 }
184 if let Some(rev) = revision {
185 if stderr.contains("unknown revision")
186 || stderr.contains("bad revision")
187 || stderr.contains("invalid object name")
188 || stderr.contains("bad object")
189 {
190 return Err(CtxError::InvalidRevision(rev.to_string()));
191 }
192 }
193 Err(CtxError::git(stderr.trim().to_string()))
194}
195
196fn parse_name_only_log(s: &str) -> HashMap<String, u32> {
201 let mut counts = HashMap::new();
202 for line in s.lines() {
203 let line = line.trim_end_matches('\r');
204 if line.trim().is_empty() {
205 continue;
206 }
207 *counts.entry(line.to_string()).or_insert(0) += 1;
208 }
209 counts
210}
211
212fn strip_repo_prefix(path: &str, prefix: &str) -> Option<String> {
217 if prefix.is_empty() {
218 Some(path.to_string())
219 } else {
220 path.strip_prefix(prefix).map(|p| p.to_string())
221 }
222}
223
224fn collect_paths(raw: &str, prefix: &str, files: &mut HashSet<String>) {
226 for line in raw.lines() {
227 let line = line.trim_end_matches('\r');
228 if line.trim().is_empty() {
229 continue;
230 }
231 if let Some(local) = strip_repo_prefix(line, prefix) {
232 files.insert(local);
233 }
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::testutil::GitRepo;
241
242 #[test]
243 fn test_parse_name_only_log() {
244 let log = "src/a.rs\nsrc/b.rs\n\nsrc/a.rs\n\n\nsrc/c.rs\n";
245 let counts = parse_name_only_log(log);
246 assert_eq!(counts.len(), 3);
247 assert_eq!(counts.get("src/a.rs"), Some(&2));
248 assert_eq!(counts.get("src/b.rs"), Some(&1));
249 assert_eq!(counts.get("src/c.rs"), Some(&1));
250 }
251
252 #[test]
253 fn test_parse_name_only_log_empty() {
254 assert!(parse_name_only_log("").is_empty());
255 assert!(parse_name_only_log("\n\n\n").is_empty());
256 }
257
258 #[test]
259 fn test_strip_repo_prefix() {
260 assert_eq!(
261 strip_repo_prefix("src/a.rs", ""),
262 Some("src/a.rs".to_string())
263 );
264 assert_eq!(
265 strip_repo_prefix("sub/src/a.rs", "sub/"),
266 Some("src/a.rs".to_string())
267 );
268 assert_eq!(strip_repo_prefix("other/a.rs", "sub/"), None);
269 }
270
271 #[test]
272 fn test_is_git_repo() {
273 let dir = tempfile::tempdir().unwrap();
274 assert!(!is_git_repo_in(dir.path()));
275
276 let repo = GitRepo::init(dir.path());
277 assert!(is_git_repo_in(&repo.root));
278 }
279
280 #[test]
281 fn test_changed_files_against() {
282 let dir = tempfile::tempdir().unwrap();
283 let repo = GitRepo::init(dir.path());
284 repo.commit_file("src/a.rs", "fn a() {}", "initial");
285
286 repo.branch("feature");
287 repo.commit_file("src/b.rs", "fn b() {}", "add b");
288
289 repo.write("src/a.rs", "fn a() { /* changed */ }");
291 repo.write("src/c.rs", "fn c() {}");
292
293 let changed = changed_files_against_in(&repo.root, "main").unwrap();
294 assert_eq!(changed.len(), 3);
295 assert!(changed.contains("src/a.rs"));
296 assert!(changed.contains("src/b.rs"));
297 assert!(changed.contains("src/c.rs"));
298 }
299
300 #[test]
301 fn test_changed_files_strips_prefix_in_subdir() {
302 let dir = tempfile::tempdir().unwrap();
303 let repo = GitRepo::init(dir.path());
304 repo.write("top.rs", "fn top() {}");
305 repo.write("sub/x.rs", "fn x() {}");
306 repo.commit_all("initial");
307
308 repo.branch("feature");
309 repo.write("top.rs", "fn top() { /* changed */ }");
310 repo.write("sub/x.rs", "fn x() { /* changed */ }");
311 repo.commit_all("change both");
312
313 let subdir = repo.root.join("sub");
314 let changed = changed_files_against_in(&subdir, "main").unwrap();
315 assert_eq!(changed.len(), 1);
317 assert!(changed.contains("x.rs"));
318 }
319
320 #[test]
321 fn test_changed_files_bad_reference() {
322 let dir = tempfile::tempdir().unwrap();
323 let repo = GitRepo::init(dir.path());
324 repo.commit_file("a.rs", "fn a() {}", "initial");
325
326 let err = changed_files_against_in(&repo.root, "no-such-ref").unwrap_err();
327 assert!(
328 matches!(err, CtxError::InvalidRevision(ref r) if r == "no-such-ref"),
329 "expected InvalidRevision, got: {}",
330 err
331 );
332 }
333
334 #[test]
335 fn test_changed_files_not_a_repo() {
336 let dir = tempfile::tempdir().unwrap();
337 let err = changed_files_against_in(dir.path(), "main").unwrap_err();
338 assert!(matches!(err, CtxError::NotGitRepo));
339 }
340
341 #[test]
342 fn test_churn_since() {
343 let dir = tempfile::tempdir().unwrap();
344 let repo = GitRepo::init(dir.path());
345 repo.commit_file("src/a.rs", "v1", "one");
346 repo.commit_file("src/a.rs", "v2", "two");
347 repo.commit_file("src/b.rs", "v1", "three");
348
349 let churn = churn_since_in(&repo.root, "2000-01-01").unwrap();
350 assert_eq!(churn.get("src/a.rs"), Some(&2));
351 assert_eq!(churn.get("src/b.rs"), Some(&1));
352 }
353
354 #[test]
355 fn test_churn_since_not_a_repo() {
356 let dir = tempfile::tempdir().unwrap();
357 let err = churn_since_in(dir.path(), "1 week ago").unwrap_err();
358 assert!(matches!(err, CtxError::NotGitRepo));
359 }
360
361 #[test]
362 fn test_show_file() {
363 let dir = tempfile::tempdir().unwrap();
364 let repo = GitRepo::init(dir.path());
365 repo.commit_file("src/a.rs", "fn a() {}", "initial");
366
367 let content = show_file_in(&repo.root, "HEAD", "src/a.rs").unwrap();
368 assert_eq!(content.as_deref(), Some("fn a() {}"));
369
370 let missing = show_file_in(&repo.root, "HEAD", "src/nope.rs").unwrap();
372 assert!(missing.is_none());
373
374 let err = show_file_in(&repo.root, "no-such-ref", "src/a.rs").unwrap_err();
376 assert!(matches!(err, CtxError::InvalidRevision(_)));
377 }
378}