Skip to main content

greplm_core/
git.rs

1//! Git time-travel intelligence.
2//!
3//! Lightweight, on-demand history queries backed by the `git` CLI: line blame,
4//! the commit history of a symbol's line range, and what changed since a
5//! revision. Nothing here is stored in the index — keeping indexing fast — but
6//! [`head`] records the current commit so callers can detect a branch switch.
7
8use std::path::Path;
9use std::process::Command;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15/// One blamed line: the commit and author that last touched it.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct BlameLine {
18    pub path: String,
19    pub line: u32,
20    pub commit: String,
21    pub author: String,
22    /// Author time, unix seconds.
23    pub author_time: u64,
24    pub summary: String,
25    pub content: String,
26}
27
28/// One commit in a history listing.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Commit {
31    pub commit: String,
32    pub author: String,
33    pub author_time: u64,
34    pub summary: String,
35}
36
37/// A path changed relative to a revision.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ChangedFile {
40    pub path: String,
41    /// Single-letter git status (M, A, D, R, ...).
42    pub status: String,
43}
44
45/// Run `git` in `root` and return stdout on success.
46fn git(root: &Path, args: &[&str]) -> Result<String> {
47    let output = Command::new("git")
48        .arg("-C")
49        .arg(root)
50        .args(args)
51        .output()
52        .map_err(|e| Error::other(format!("failed to run git: {e}")))?;
53    if !output.status.success() {
54        let err = String::from_utf8_lossy(&output.stderr);
55        return Err(Error::other(format!(
56            "git {} failed: {}",
57            args.join(" "),
58            err.trim()
59        )));
60    }
61    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
62}
63
64/// True if `root` is inside a git work tree.
65pub fn is_repo(root: &Path) -> bool {
66    git(root, &["rev-parse", "--is-inside-work-tree"])
67        .map(|s| s.trim() == "true")
68        .unwrap_or(false)
69}
70
71/// The current commit sha and branch name, if `root` is a repo.
72pub fn head(root: &Path) -> Option<(String, String)> {
73    let sha = git(root, &["rev-parse", "HEAD"]).ok()?.trim().to_string();
74    let branch = git(root, &["rev-parse", "--abbrev-ref", "HEAD"])
75        .ok()
76        .map(|s| s.trim().to_string())
77        .unwrap_or_default();
78    Some((sha, branch))
79}
80
81/// Blame a single 1-based line of `rel_path`.
82pub fn blame(root: &Path, rel_path: &str, line: u32) -> Result<BlameLine> {
83    let range = format!("{line},{line}");
84    let out = git(
85        root,
86        &["blame", "-L", &range, "--porcelain", "--", rel_path],
87    )?;
88    parse_blame(&out, rel_path, line)
89        .ok_or_else(|| Error::other(format!("could not blame {rel_path}:{line}")))
90}
91
92fn parse_blame(out: &str, rel_path: &str, line: u32) -> Option<BlameLine> {
93    let mut commit = String::new();
94    let mut author = String::new();
95    let mut author_time = 0u64;
96    let mut summary = String::new();
97    let mut content = String::new();
98    for (i, l) in out.lines().enumerate() {
99        if i == 0 {
100            commit = l.split_whitespace().next().unwrap_or("").to_string();
101        } else if let Some(rest) = l.strip_prefix("author ") {
102            author = rest.to_string();
103        } else if let Some(rest) = l.strip_prefix("author-time ") {
104            author_time = rest.trim().parse().unwrap_or(0);
105        } else if let Some(rest) = l.strip_prefix("summary ") {
106            summary = rest.to_string();
107        } else if let Some(rest) = l.strip_prefix('\t') {
108            content = rest.to_string();
109        }
110    }
111    if commit.is_empty() {
112        return None;
113    }
114    Some(BlameLine {
115        path: rel_path.to_string(),
116        line,
117        commit: short_sha(&commit),
118        author,
119        author_time,
120        summary,
121        content,
122    })
123}
124
125/// Commits that touched lines `[start, end]` of `rel_path`, newest first.
126pub fn line_history(
127    root: &Path,
128    rel_path: &str,
129    start: u32,
130    end: u32,
131    limit: usize,
132) -> Result<Vec<Commit>> {
133    let lspec = format!("{start},{end}:{rel_path}");
134    let out = git(
135        root,
136        &[
137            "log",
138            "-L",
139            &lspec,
140            "--no-patch",
141            &format!("--max-count={limit}"),
142            "--format=%H%x09%an%x09%at%x09%s",
143        ],
144    )?;
145    Ok(parse_commits(&out))
146}
147
148/// Commits that touched `rel_path`, newest first.
149pub fn file_history(root: &Path, rel_path: &str, limit: usize) -> Result<Vec<Commit>> {
150    let out = git(
151        root,
152        &[
153            "log",
154            &format!("--max-count={limit}"),
155            "--format=%H%x09%an%x09%at%x09%s",
156            "--",
157            rel_path,
158        ],
159    )?;
160    Ok(parse_commits(&out))
161}
162
163fn parse_commits(out: &str) -> Vec<Commit> {
164    let mut commits = Vec::new();
165    for l in out.lines() {
166        let parts: Vec<&str> = l.splitn(4, '\t').collect();
167        if parts.len() == 4 {
168            commits.push(Commit {
169                commit: short_sha(parts[0]),
170                author: parts[1].to_string(),
171                author_time: parts[2].trim().parse().unwrap_or(0),
172                summary: parts[3].to_string(),
173            });
174        }
175    }
176    commits
177}
178
179/// Files changed relative to `rev` (e.g. a branch, tag, or `HEAD~5`).
180pub fn changed_since(root: &Path, rev: &str) -> Result<Vec<ChangedFile>> {
181    let out = git(root, &["diff", "--name-status", rev, "--"])?;
182    let mut files = Vec::new();
183    for l in out.lines() {
184        let mut it = l.split('\t');
185        let status = it.next().unwrap_or("").to_string();
186        // For renames git emits "R100\told\tnew"; take the final path.
187        let path = it.next_back().unwrap_or("").to_string();
188        if !path.is_empty() {
189            files.push(ChangedFile {
190                path,
191                status: status.chars().next().map(String::from).unwrap_or_default(),
192            });
193        }
194    }
195    Ok(files)
196}
197
198fn short_sha(sha: &str) -> String {
199    sha.chars().take(12).collect()
200}