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    pub added: Vec<String>,
92    pub changed: Vec<String>,
93}
94
95/// The last `limit` first-parent commits with their added and changed files.
96pub fn history(repo_path: &Path, limit: usize) -> Result<Vec<CommitFiles>> {
97    let repo = Repository::open(repo_path)
98        .with_context(|| format!("open git repo at {}", repo_path.display()))?;
99    let mut walk = repo.revwalk()?;
100    walk.push_head()?;
101    walk.simplify_first_parent()?;
102    let mut out = Vec::new();
103    for oid in walk.take(limit) {
104        let commit = repo.find_commit(oid?)?;
105        let tree = commit.tree()?;
106        let parent = commit.parents().next().map(|p| p.tree()).transpose()?;
107        let diff = repo.diff_tree_to_tree(parent.as_ref(), Some(&tree), None)?;
108        let mut c = CommitFiles {
109            sha: commit.id().to_string(),
110            subject: commit
111                .summary()
112                .ok()
113                .flatten()
114                .unwrap_or_default()
115                .to_string(),
116            added: Vec::new(),
117            changed: Vec::new(),
118        };
119        for d in diff.deltas() {
120            let Some(p) = path_string(d.new_file().path()) else {
121                continue;
122            };
123            if matches!(d.status(), Delta::Added | Delta::Copied | Delta::Renamed) {
124                c.added.push(p.clone());
125            }
126            if !matches!(d.status(), Delta::Deleted) {
127                c.changed.push(p);
128            }
129        }
130        out.push(c);
131    }
132    Ok(out)
133}
134
135/// A changed file and its changed new-side line ranges.
136pub type FileHunks = (String, Vec<(u32, u32)>);
137
138/// Uncommitted changes (staged and unstaged) against `base`.
139pub fn working_changes(repo_path: &Path, base: &str) -> Result<Vec<FileHunks>> {
140    let repo = Repository::open(repo_path)
141        .with_context(|| format!("open git repo at {}", repo_path.display()))?;
142    let tree = repo.revparse_single(base)?.peel_to_tree()?;
143    let mut opts = git2::DiffOptions::new();
144    opts.include_untracked(true).recurse_untracked_dirs(true);
145    let diff = repo.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts))?;
146    let out: std::cell::RefCell<Vec<FileHunks>> = Default::default();
147    diff.foreach(
148        &mut |d, _| {
149            if let Some(p) = path_string(d.new_file().path()) {
150                out.borrow_mut().push((p, Vec::new()));
151            }
152            true
153        },
154        None,
155        Some(&mut |d, h| {
156            let mut v = out.borrow_mut();
157            if let (Some(p), Some(last)) = (path_string(d.new_file().path()), v.last_mut()) {
158                if last.0 == p {
159                    let start = h.new_start().max(1);
160                    last.1.push((start, start + h.new_lines().max(1) - 1));
161                }
162            }
163            true
164        }),
165        None,
166    )?;
167    Ok(out.into_inner())
168}