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 churn_between_in(
145 dir: &Path,
146 since: &str,
147 until: Option<&str>,
148) -> Result<HashMap<String, u32>> {
149 if !is_git_repo_in(dir) {
150 return Err(CtxError::NotGitRepo);
151 }
152
153 let prefix = repo_prefix_in(dir)?;
154 let since_arg = format!("--since={}", since);
155 let until_arg = until.map(|u| format!("--until={}", u));
156 let mut args = vec!["log", &since_arg];
157 if let Some(ref until_arg) = until_arg {
158 args.push(until_arg);
159 }
160 args.extend(["--format=", "--name-only", "--no-renames", "--", "."]);
161 let output = run_git(dir, &args)?;
162 let log = stdout_or_err(output, None)?;
163
164 let mut churn = HashMap::new();
165 for (path, count) in parse_name_only_log(&log) {
166 if let Some(local) = strip_repo_prefix(&path, &prefix) {
167 *churn.entry(local).or_insert(0) += count;
168 }
169 }
170 Ok(churn)
171}
172
173pub fn head_commit_in(dir: &Path) -> Result<(String, String)> {
178 if !is_git_repo_in(dir) {
179 return Err(CtxError::NotGitRepo);
180 }
181
182 let output = run_git(dir, &["log", "-1", "--format=%H%x00%cI"])?;
183 let stdout = stdout_or_err(output, Some("HEAD"))?;
184 let line = stdout.trim();
185 let (sha, date) = line
186 .split_once('\0')
187 .ok_or_else(|| CtxError::git(format!("unexpected `git log -1` output: {:?}", line)))?;
188 Ok((sha.to_string(), date.to_string()))
189}
190
191pub fn rev_list_first_parent_in(dir: &Path, range: &str) -> Result<Vec<String>> {
193 if !is_git_repo_in(dir) {
194 return Err(CtxError::NotGitRepo);
195 }
196
197 let output = run_git(dir, &["rev-list", "--first-parent", "--reverse", range])?;
198 let stdout = stdout_or_err(output, Some(range))?;
199 Ok(stdout
200 .lines()
201 .map(|l| l.trim().to_string())
202 .filter(|l| !l.is_empty())
203 .collect())
204}
205
206pub fn is_dirty_in(dir: &Path) -> Result<bool> {
209 if !is_git_repo_in(dir) {
210 return Err(CtxError::NotGitRepo);
211 }
212
213 let output = run_git(dir, &["status", "--porcelain"])?;
214 let stdout = stdout_or_err(output, None)?;
215 Ok(stdout.lines().any(|l| !l.trim().is_empty()))
216}
217
218pub fn show_file_in(dir: &Path, reference: &str, path: &str) -> Result<Option<String>> {
221 if !is_git_repo_in(dir) {
222 return Err(CtxError::NotGitRepo);
223 }
224
225 let spec = format!("{}:./{}", reference, path);
228 let output = run_git(dir, &["show", &spec])?;
229
230 if output.status.success() {
231 return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
232 }
233
234 let stderr = String::from_utf8_lossy(&output.stderr);
235 if stderr.contains("does not exist") || stderr.contains("exists on disk, but not in") {
236 return Ok(None);
237 }
238 stdout_or_err(output, Some(reference)).map(Some)
240}
241
242fn run_git(dir: &Path, args: &[&str]) -> Result<Output> {
248 Ok(Command::new("git").args(args).current_dir(dir).output()?)
249}
250
251fn stdout_or_err(output: Output, revision: Option<&str>) -> Result<String> {
256 if output.status.success() {
257 return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
258 }
259
260 let stderr = String::from_utf8_lossy(&output.stderr);
261 if stderr.contains("not a git repository") {
262 return Err(CtxError::NotGitRepo);
263 }
264 if let Some(rev) = revision {
265 if stderr.contains("unknown revision")
266 || stderr.contains("bad revision")
267 || stderr.contains("invalid object name")
268 || stderr.contains("bad object")
269 {
270 return Err(CtxError::InvalidRevision(rev.to_string()));
271 }
272 }
273 Err(CtxError::git(stderr.trim().to_string()))
274}
275
276fn parse_name_only_log(s: &str) -> HashMap<String, u32> {
281 let mut counts = HashMap::new();
282 for line in s.lines() {
283 let line = line.trim_end_matches('\r');
284 if line.trim().is_empty() {
285 continue;
286 }
287 *counts.entry(line.to_string()).or_insert(0) += 1;
288 }
289 counts
290}
291
292fn strip_repo_prefix(path: &str, prefix: &str) -> Option<String> {
297 if prefix.is_empty() {
298 Some(path.to_string())
299 } else {
300 path.strip_prefix(prefix).map(|p| p.to_string())
301 }
302}
303
304fn collect_paths(raw: &str, prefix: &str, files: &mut HashSet<String>) {
306 for line in raw.lines() {
307 let line = line.trim_end_matches('\r');
308 if line.trim().is_empty() {
309 continue;
310 }
311 if let Some(local) = strip_repo_prefix(line, prefix) {
312 files.insert(local);
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::testutil::GitRepo;
321
322 #[test]
323 fn test_parse_name_only_log() {
324 let log = "src/a.rs\nsrc/b.rs\n\nsrc/a.rs\n\n\nsrc/c.rs\n";
325 let counts = parse_name_only_log(log);
326 assert_eq!(counts.len(), 3);
327 assert_eq!(counts.get("src/a.rs"), Some(&2));
328 assert_eq!(counts.get("src/b.rs"), Some(&1));
329 assert_eq!(counts.get("src/c.rs"), Some(&1));
330 }
331
332 #[test]
333 fn test_parse_name_only_log_empty() {
334 assert!(parse_name_only_log("").is_empty());
335 assert!(parse_name_only_log("\n\n\n").is_empty());
336 }
337
338 #[test]
339 fn test_strip_repo_prefix() {
340 assert_eq!(
341 strip_repo_prefix("src/a.rs", ""),
342 Some("src/a.rs".to_string())
343 );
344 assert_eq!(
345 strip_repo_prefix("sub/src/a.rs", "sub/"),
346 Some("src/a.rs".to_string())
347 );
348 assert_eq!(strip_repo_prefix("other/a.rs", "sub/"), None);
349 }
350
351 #[test]
352 fn test_is_git_repo() {
353 let dir = tempfile::tempdir().unwrap();
354 assert!(!is_git_repo_in(dir.path()));
355
356 let repo = GitRepo::init(dir.path());
357 assert!(is_git_repo_in(&repo.root));
358 }
359
360 #[test]
361 fn test_changed_files_against() {
362 let dir = tempfile::tempdir().unwrap();
363 let repo = GitRepo::init(dir.path());
364 repo.commit_file("src/a.rs", "fn a() {}", "initial");
365
366 repo.branch("feature");
367 repo.commit_file("src/b.rs", "fn b() {}", "add b");
368
369 repo.write("src/a.rs", "fn a() { /* changed */ }");
371 repo.write("src/c.rs", "fn c() {}");
372
373 let changed = changed_files_against_in(&repo.root, "main").unwrap();
374 assert_eq!(changed.len(), 3);
375 assert!(changed.contains("src/a.rs"));
376 assert!(changed.contains("src/b.rs"));
377 assert!(changed.contains("src/c.rs"));
378 }
379
380 #[test]
381 fn test_changed_files_strips_prefix_in_subdir() {
382 let dir = tempfile::tempdir().unwrap();
383 let repo = GitRepo::init(dir.path());
384 repo.write("top.rs", "fn top() {}");
385 repo.write("sub/x.rs", "fn x() {}");
386 repo.commit_all("initial");
387
388 repo.branch("feature");
389 repo.write("top.rs", "fn top() { /* changed */ }");
390 repo.write("sub/x.rs", "fn x() { /* changed */ }");
391 repo.commit_all("change both");
392
393 let subdir = repo.root.join("sub");
394 let changed = changed_files_against_in(&subdir, "main").unwrap();
395 assert_eq!(changed.len(), 1);
397 assert!(changed.contains("x.rs"));
398 }
399
400 #[test]
401 fn test_changed_files_bad_reference() {
402 let dir = tempfile::tempdir().unwrap();
403 let repo = GitRepo::init(dir.path());
404 repo.commit_file("a.rs", "fn a() {}", "initial");
405
406 let err = changed_files_against_in(&repo.root, "no-such-ref").unwrap_err();
407 assert!(
408 matches!(err, CtxError::InvalidRevision(ref r) if r == "no-such-ref"),
409 "expected InvalidRevision, got: {}",
410 err
411 );
412 }
413
414 #[test]
415 fn test_not_a_repo_errors() {
416 let dir = tempfile::tempdir().unwrap();
417 let err = changed_files_against_in(dir.path(), "main").unwrap_err();
418 assert!(matches!(err, CtxError::NotGitRepo));
419 let err = churn_since_in(dir.path(), "1 week ago").unwrap_err();
420 assert!(matches!(err, CtxError::NotGitRepo));
421 }
422
423 #[test]
424 fn test_churn_since() {
425 let dir = tempfile::tempdir().unwrap();
426 let repo = GitRepo::init(dir.path());
427 repo.commit_file("src/a.rs", "v1", "one");
428 repo.commit_file("src/a.rs", "v2", "two");
429 repo.commit_file("src/b.rs", "v1", "three");
430
431 let churn = churn_since_in(&repo.root, "2000-01-01").unwrap();
432 assert_eq!(churn.get("src/a.rs"), Some(&2));
433 assert_eq!(churn.get("src/b.rs"), Some(&1));
434 }
435
436 #[test]
437 fn test_churn_between() {
438 let dir = tempfile::tempdir().unwrap();
439 let repo = GitRepo::init(dir.path());
440 repo.write("src/a.rs", "v1");
441 repo.commit_all_with_date("one", "2020-01-01T12:00:00 +0000");
442 repo.write("src/a.rs", "v2");
443 repo.commit_all_with_date("two", "2021-01-01T12:00:00 +0000");
444 repo.write("src/b.rs", "v1");
445 repo.commit_all_with_date("three", "2022-01-01T12:00:00 +0000");
446
447 let churn = churn_between_in(&repo.root, "2000-01-01", None).unwrap();
449 assert_eq!(churn.get("src/a.rs"), Some(&2));
450 assert_eq!(churn.get("src/b.rs"), Some(&1));
451
452 let churn = churn_between_in(&repo.root, "2000-01-01", Some("2021-06-01")).unwrap();
454 assert_eq!(churn.get("src/a.rs"), Some(&2));
455 assert_eq!(churn.get("src/b.rs"), None);
456 }
457
458 #[test]
459 fn test_churn_between_not_a_repo() {
460 let dir = tempfile::tempdir().unwrap();
461 let err = churn_between_in(dir.path(), "1 week ago", None).unwrap_err();
462 assert!(matches!(err, CtxError::NotGitRepo));
463 }
464
465 #[test]
466 fn test_head_commit() {
467 let dir = tempfile::tempdir().unwrap();
468 let repo = GitRepo::init(dir.path());
469 repo.write("a.rs", "fn a() {}");
470 repo.commit_all_with_date("initial", "2020-01-02T03:04:05 +0000");
471
472 let (sha, date) = head_commit_in(&repo.root).unwrap();
473 assert_eq!(sha.len(), 40, "expected a full sha: {}", sha);
474 assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
475 assert!(date.starts_with("2020-01-02T03:04:05"), "date: {}", date);
476
477 let empty = tempfile::tempdir().unwrap();
479 let err = head_commit_in(empty.path()).unwrap_err();
480 assert!(matches!(err, CtxError::NotGitRepo));
481 }
482
483 #[test]
484 fn test_rev_list_first_parent() {
485 let dir = tempfile::tempdir().unwrap();
486 let repo = GitRepo::init(dir.path());
487 repo.commit_file("a.rs", "v1", "one");
488 let (first, _) = head_commit_in(&repo.root).unwrap();
489 repo.commit_file("a.rs", "v2", "two");
490 repo.commit_file("a.rs", "v3", "three");
491 let (head, _) = head_commit_in(&repo.root).unwrap();
492
493 let shas = rev_list_first_parent_in(&repo.root, &format!("{}..HEAD", first)).unwrap();
494 assert_eq!(shas.len(), 2, "expected the two commits after the first");
495 assert_eq!(shas.last(), Some(&head), "oldest-first order");
496
497 let err = rev_list_first_parent_in(&repo.root, "no-such..HEAD").unwrap_err();
498 assert!(matches!(
499 err,
500 CtxError::InvalidRevision(_) | CtxError::Git(_)
501 ));
502 }
503
504 #[test]
505 fn test_is_dirty() {
506 let dir = tempfile::tempdir().unwrap();
507 let repo = GitRepo::init(dir.path());
508 repo.commit_file("a.rs", "fn a() {}", "initial");
509 assert!(!is_dirty_in(&repo.root).unwrap());
510
511 repo.write("b.rs", "fn b() {}");
513 assert!(is_dirty_in(&repo.root).unwrap());
514
515 std::fs::remove_file(repo.root.join("b.rs")).unwrap();
517 assert!(!is_dirty_in(&repo.root).unwrap());
518 repo.write("a.rs", "fn a() { /* changed */ }");
519 assert!(is_dirty_in(&repo.root).unwrap());
520 }
521
522 #[test]
523 fn test_show_file() {
524 let dir = tempfile::tempdir().unwrap();
525 let repo = GitRepo::init(dir.path());
526 repo.commit_file("src/a.rs", "fn a() {}", "initial");
527
528 let content = show_file_in(&repo.root, "HEAD", "src/a.rs").unwrap();
529 assert_eq!(content.as_deref(), Some("fn a() {}"));
530
531 let missing = show_file_in(&repo.root, "HEAD", "src/nope.rs").unwrap();
533 assert!(missing.is_none());
534
535 let err = show_file_in(&repo.root, "no-such-ref", "src/a.rs").unwrap_err();
537 assert!(matches!(err, CtxError::InvalidRevision(_)));
538 }
539}