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/// Dir-explicit variant of [`show_file`], for commands and tests that
139/// operate on an explicit project root instead of the process cwd.
140pub 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    // "REF:./path" makes git resolve the path relative to the current
146    // directory, matching index-relative paths.
147    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    // Reuse the shared error mapping for bad revisions and other failures.
159    stdout_or_err(output, Some(reference)).map(Some)
160}
161
162// ============================================================================
163// Helpers
164// ============================================================================
165
166/// Run a git command in `dir`, returning the raw output.
167fn run_git(dir: &Path, args: &[&str]) -> Result<Output> {
168    Ok(Command::new("git").args(args).current_dir(dir).output()?)
169}
170
171/// Convert a git `Output` into its stdout, mapping failures to `CtxError`.
172///
173/// If `revision` is given, revision-related failures map to
174/// [`CtxError::InvalidRevision`] with that revision.
175fn 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
196/// Parse `git log --format= --name-only` output into per-path commit counts.
197///
198/// Blank lines (commit separators) are skipped; each non-blank line is a path
199/// that was touched by one commit.
200fn 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
212/// Strip the repo prefix from a repo-root-relative path.
213///
214/// Returns `None` for paths outside the prefix (i.e. outside the current
215/// directory).
216fn 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
224/// Add each non-blank, prefix-local path in `raw` to `files`.
225fn 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        // Uncommitted modification + untracked file.
290        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        // Only files under sub/, with the prefix stripped.
316        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        // Missing file at the revision -> None.
371        let missing = show_file_in(&repo.root, "HEAD", "src/nope.rs").unwrap();
372        assert!(missing.is_none());
373
374        // Bad revision -> error.
375        let err = show_file_in(&repo.root, "no-such-ref", "src/a.rs").unwrap_err();
376        assert!(matches!(err, CtxError::InvalidRevision(_)));
377    }
378}