Skip to main content

ctx/
gitutil.rs

1//! Shared git helpers for quality commands.
2//!
3//! All helpers shell out to `git` (matching the style of [`crate::diff`]) and
4//! operate on the process working directory. Paths returned by these helpers
5//! are relative to the working directory (the repo prefix is stripped), so
6//! they line up with index-relative paths in `.ctx/codebase.sqlite`.
7
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10use std::process::{Command, Output};
11
12use crate::error::{CtxError, Result};
13
14/// Check if the current directory is inside a git repository.
15pub fn is_git_repo() -> bool {
16    is_git_repo_in(Path::new("."))
17}
18
19/// Get the path of the current directory relative to the repository root.
20///
21/// Returns `""` at the repo root, or a prefix like `"sub/dir/"` (with a
22/// trailing slash) inside a subdirectory.
23pub fn repo_prefix() -> Result<String> {
24    repo_prefix_in(Path::new("."))
25}
26
27/// Get the set of files changed relative to `reference`.
28///
29/// The result is the union of:
30/// - `git diff --name-only <reference>...HEAD` (committed changes since the
31///   merge base with `reference`)
32/// - `git diff --name-only HEAD` (uncommitted working-tree changes)
33/// - `git ls-files --others --exclude-standard` (untracked files)
34///
35/// Paths are relative to the current directory; files outside it are dropped.
36pub fn changed_files_against(reference: &str) -> Result<HashSet<String>> {
37    changed_files_against_in(Path::new("."), reference)
38}
39
40/// Count how many commits touched each file since `since` (a `git log --since`
41/// date spec, e.g. `"6 months ago"` or `"2025-01-01"`).
42///
43/// Paths are relative to the current directory; only files under it are
44/// counted.
45pub fn churn_since(since: &str) -> Result<HashMap<String, u32>> {
46    churn_since_in(Path::new("."), since)
47}
48
49/// Get the contents of `path` (relative to the current directory) at
50/// `reference`, or `None` if the file does not exist at that revision.
51pub fn show_file(reference: &str, path: &str) -> Result<Option<String>> {
52    show_file_in(Path::new("."), reference, path)
53}
54
55// ============================================================================
56// Directory-explicit implementations (used directly by tests)
57// ============================================================================
58
59/// Dir-explicit variant of [`is_git_repo`], for commands and tests that
60/// operate on an explicit project root instead of the process cwd.
61pub 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
76/// Dir-explicit variant of [`changed_files_against`], for commands and tests
77/// that operate on an explicit project root instead of the process cwd.
78pub 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    // Committed changes since the merge base with the reference.
87    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    // Uncommitted (staged + unstaged) changes.
93    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    // Untracked files (--full-name makes paths repo-root-relative like diff).
98    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
138/// Count how many commits touched each file since `since`, optionally
139/// anchored with `--until=<until>` (any `git log` date spec).
140///
141/// Like [`churn_since`] but dir-explicit and with an optional upper bound,
142/// so historical snapshots can measure churn as of a commit's date instead
143/// of wall-clock now.
144pub 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
173/// The current HEAD commit as `(full sha, committer date)`.
174///
175/// The committer date is strict ISO 8601 (`git log --format=%cI`, e.g.
176/// `2026-07-09T12:00:00+02:00`).
177pub 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
191/// First-parent commit shas in `range` (e.g. `abc123..HEAD`), oldest first.
192pub 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
206/// Whether the working tree has uncommitted changes (staged, unstaged, or
207/// untracked), per `git status --porcelain`.
208pub 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
218/// Dir-explicit variant of [`show_file`], for commands and tests that
219/// operate on an explicit project root instead of the process cwd.
220pub 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    // "REF:./path" makes git resolve the path relative to the current
226    // directory, matching index-relative paths.
227    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    // Reuse the shared error mapping for bad revisions and other failures.
239    stdout_or_err(output, Some(reference)).map(Some)
240}
241
242// ============================================================================
243// Helpers
244// ============================================================================
245
246/// Run a git command in `dir`, returning the raw output.
247fn run_git(dir: &Path, args: &[&str]) -> Result<Output> {
248    Ok(Command::new("git").args(args).current_dir(dir).output()?)
249}
250
251/// Convert a git `Output` into its stdout, mapping failures to `CtxError`.
252///
253/// If `revision` is given, revision-related failures map to
254/// [`CtxError::InvalidRevision`] with that revision.
255fn 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
276/// Parse `git log --format= --name-only` output into per-path commit counts.
277///
278/// Blank lines (commit separators) are skipped; each non-blank line is a path
279/// that was touched by one commit.
280fn 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
292/// Strip the repo prefix from a repo-root-relative path.
293///
294/// Returns `None` for paths outside the prefix (i.e. outside the current
295/// directory).
296fn 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
304/// Add each non-blank, prefix-local path in `raw` to `files`.
305fn 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        // Uncommitted modification + untracked file.
370        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        // Only files under sub/, with the prefix stripped.
396        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        // Unbounded: all three commits count.
448        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        // Anchored at mid-2021: the 2022 commit is excluded.
453        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        // Not a repo -> error.
478        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        // Untracked file counts as dirty.
512        repo.write("b.rs", "fn b() {}");
513        assert!(is_dirty_in(&repo.root).unwrap());
514
515        // Modified tracked file counts as dirty.
516        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        // Missing file at the revision -> None.
532        let missing = show_file_in(&repo.root, "HEAD", "src/nope.rs").unwrap();
533        assert!(missing.is_none());
534
535        // Bad revision -> error.
536        let err = show_file_in(&repo.root, "no-such-ref", "src/a.rs").unwrap_err();
537        assert!(matches!(err, CtxError::InvalidRevision(_)));
538    }
539}