Skip to main content

comindly_core/
git.rs

1//! comindly-git — git change detection for incremental indexing.
2//!
3//! Uses `git2` (vendored libgit2, statically linked → single binary preserved). At index time
4//! the working tree and `.git` are present; in CI indexing runs on a pushed commit, so
5//! incremental indexing diffs the last-indexed commit against the current HEAD and re-processes
6//! only what changed (added / modified / deleted files).
7
8use std::path::Path;
9
10use anyhow::{Context, Result};
11use git2::{Delta, Repository};
12
13/// Files that changed between two commits, split by how the index should treat them.
14#[derive(Debug, Default, Clone)]
15pub struct ChangeSet {
16    /// New files → parse and add.
17    pub added: Vec<String>,
18    /// Changed files → re-parse and replace their symbols/edges.
19    pub modified: Vec<String>,
20    /// Removed files → drop their symbols/edges.
21    pub deleted: Vec<String>,
22}
23
24impl ChangeSet {
25    pub fn total(&self) -> usize {
26        self.added.len() + self.modified.len() + self.deleted.len()
27    }
28
29    /// Files whose current content must be (re)parsed: added + modified.
30    pub fn to_parse(&self) -> impl Iterator<Item = &String> {
31        self.added.iter().chain(self.modified.iter())
32    }
33
34    /// Files whose old symbols must be dropped: modified + deleted.
35    pub fn to_drop(&self) -> impl Iterator<Item = &String> {
36        self.modified.iter().chain(self.deleted.iter())
37    }
38}
39
40fn path_string(p: Option<&Path>) -> Option<String> {
41    p.map(|p| p.to_string_lossy().replace('\\', "/"))
42}
43
44/// The current HEAD commit SHA of the repo at `repo_path` — stored as the index version so the
45/// next run knows where to diff from.
46pub fn head_commit(repo_path: &Path) -> Result<String> {
47    let repo = Repository::open(repo_path)
48        .with_context(|| format!("open git repo at {}", repo_path.display()))?;
49    let commit = repo.head()?.peel_to_commit()?;
50    Ok(commit.id().to_string())
51}
52
53/// Files changed between `base_sha` and current HEAD. Renames are recorded as a delete of the
54/// old path plus an add of the new path (so downstream drop/add logic stays simple).
55pub fn changed_files(repo_path: &Path, base_sha: &str) -> Result<ChangeSet> {
56    let repo = Repository::open(repo_path)
57        .with_context(|| format!("open git repo at {}", repo_path.display()))?;
58    let base_tree = repo
59        .revparse_single(base_sha)
60        .with_context(|| format!("resolve base commit {base_sha}"))?
61        .peel_to_commit()?
62        .tree()?;
63    let head_tree = repo.head()?.peel_to_commit()?.tree()?;
64
65    let mut opts = git2::DiffOptions::new();
66    let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))?;
67
68    let mut cs = ChangeSet::default();
69    for d in diff.deltas() {
70        let new_p = path_string(d.new_file().path());
71        let old_p = path_string(d.old_file().path());
72        match d.status() {
73            Delta::Added | Delta::Copied => cs.added.extend(new_p),
74            Delta::Deleted => cs.deleted.extend(old_p),
75            Delta::Modified | Delta::Typechange => cs.modified.extend(new_p),
76            Delta::Renamed => {
77                cs.deleted.extend(old_p);
78                cs.added.extend(new_p);
79            }
80            _ => {}
81        }
82    }
83    Ok(cs)
84}
85
86/// One commit's file changes (first-parent history).
87#[derive(Debug, Clone)]
88pub struct CommitFiles {
89    pub sha: String,
90    pub subject: String,
91    /// Commit time, seconds since the epoch.
92    pub time: i64,
93    /// The merge request a merge commit names ("See merge request group/repo!123").
94    pub mr: Option<u64>,
95    pub added: Vec<String>,
96    pub changed: Vec<String>,
97}
98
99/// The last `limit` first-parent commits with their added and changed files.
100pub fn history(repo_path: &Path, limit: usize) -> Result<Vec<CommitFiles>> {
101    let repo = Repository::open(repo_path)
102        .with_context(|| format!("open git repo at {}", repo_path.display()))?;
103    let mut walk = repo.revwalk()?;
104    walk.push_head()?;
105    walk.simplify_first_parent()?;
106    let mut out = Vec::new();
107    for oid in walk.take(limit) {
108        let commit = repo.find_commit(oid?)?;
109        let tree = commit.tree()?;
110        let parent = commit.parents().next().map(|p| p.tree()).transpose()?;
111        let diff = repo.diff_tree_to_tree(parent.as_ref(), Some(&tree), None)?;
112        let mut c = CommitFiles {
113            sha: commit.id().to_string(),
114            subject: commit
115                .summary()
116                .ok()
117                .flatten()
118                .unwrap_or_default()
119                .to_string(),
120            time: commit.time().seconds(),
121            mr: commit.message().ok().and_then(merge_request),
122            added: Vec::new(),
123            changed: Vec::new(),
124        };
125        for d in diff.deltas() {
126            let Some(p) = path_string(d.new_file().path()) else {
127                continue;
128            };
129            if matches!(d.status(), Delta::Added | Delta::Copied | Delta::Renamed) {
130                c.added.push(p.clone());
131            }
132            if !matches!(d.status(), Delta::Deleted) {
133                c.changed.push(p);
134            }
135        }
136        out.push(c);
137    }
138    Ok(out)
139}
140
141/// The merge or pull request a commit message names: GitLab's "See merge request group/repo!N",
142/// GitHub's "Merge pull request #N from …" or a squash title ending in "(#N)".
143fn merge_request(msg: &str) -> Option<u64> {
144    if let Some(line) = msg.lines().find(|l| l.starts_with("See merge request ")) {
145        return line.rsplit_once('!')?.1.trim().parse().ok();
146    }
147    let first = msg.lines().next()?.trim_end();
148    if let Some(rest) = first.strip_prefix("Merge pull request #") {
149        return rest.split_whitespace().next()?.parse().ok();
150    }
151    first.strip_suffix(')')?.rsplit_once("(#")?.1.parse().ok()
152}
153
154/// A changed file and its changed new-side line ranges.
155pub type FileHunks = (String, Vec<(u32, u32)>);
156
157/// Uncommitted changes (staged and unstaged) against `base`.
158pub fn working_changes(repo_path: &Path, base: &str) -> Result<Vec<FileHunks>> {
159    let repo = Repository::open(repo_path)
160        .with_context(|| format!("open git repo at {}", repo_path.display()))?;
161    let tree = repo.revparse_single(base)?.peel_to_tree()?;
162    let mut opts = git2::DiffOptions::new();
163    opts.include_untracked(true).recurse_untracked_dirs(true);
164    let diff = repo.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts))?;
165    let out: std::cell::RefCell<Vec<FileHunks>> = Default::default();
166    diff.foreach(
167        &mut |d, _| {
168            if let Some(p) = path_string(d.new_file().path()) {
169                out.borrow_mut().push((p, Vec::new()));
170            }
171            true
172        },
173        None,
174        Some(&mut |d, h| {
175            let mut v = out.borrow_mut();
176            if let (Some(p), Some(last)) = (path_string(d.new_file().path()), v.last_mut()) {
177                if last.0 == p {
178                    let start = h.new_start().max(1);
179                    last.1.push((start, start + h.new_lines().max(1) - 1));
180                }
181            }
182            true
183        }),
184        None,
185    )?;
186    Ok(out.into_inner())
187}
188
189#[cfg(test)]
190mod tests {
191    #[test]
192    fn merge_request_from_merge_commit() {
193        let msg = "Merge branch 'x' into 'main'\n\nFix it\n\nSee merge request team/orders!476";
194        assert_eq!(super::merge_request(msg), Some(476));
195        assert_eq!(
196            super::merge_request("Merge pull request #12 from a/b\n\nFix"),
197            Some(12)
198        );
199        assert_eq!(
200            super::merge_request("Fix the retry (#34)\n\nbody"),
201            Some(34)
202        );
203        assert_eq!(super::merge_request("Fix it"), None);
204    }
205}