Skip to main content

gitkraft_core/features/log/
ops.rs

1//! Log browsing and commit search operations.
2
3use anyhow::{Context, Result};
4use git2::Repository;
5
6use crate::features::commits::ops::build_ref_map;
7use crate::features::commits::CommitInfo;
8
9/// Retrieve the commit log, optionally filtered by author and/or message
10/// substring. Each commit's [`CommitInfo::refs`] is populated with branch /
11/// tag labels.
12pub fn get_log(
13    repo: &Repository,
14    max_count: usize,
15    filter_author: Option<&str>,
16    filter_message: Option<&str>,
17) -> Result<Vec<CommitInfo>> {
18    let ref_map = build_ref_map(repo);
19
20    let mut revwalk = repo.revwalk().context("failed to create revwalk")?;
21    revwalk.push_head().context("failed to push HEAD")?;
22    revwalk
23        .set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)
24        .context("failed to set sorting")?;
25
26    let author_lower = filter_author.map(|s| s.to_lowercase());
27    let message_lower = filter_message.map(|s| s.to_lowercase());
28
29    let mut results = Vec::with_capacity(max_count.min(256));
30
31    for oid_result in revwalk {
32        if results.len() >= max_count {
33            break;
34        }
35
36        let oid = oid_result.context("revwalk iteration error")?;
37        let commit = repo
38            .find_commit(oid)
39            .context("failed to find commit during log walk")?;
40
41        if let Some(ref needle) = author_lower {
42            let author = commit.author();
43            let name = author.name().unwrap_or("").to_lowercase();
44            let email = author.email().unwrap_or("").to_lowercase();
45            if !name.contains(needle.as_str()) && !email.contains(needle.as_str()) {
46                continue;
47            }
48        }
49
50        if let Some(ref needle) = message_lower {
51            let msg = commit.message().unwrap_or("").to_lowercase();
52            if !msg.contains(needle.as_str()) {
53                continue;
54            }
55        }
56
57        let mut info = CommitInfo::from_git2_commit(&commit);
58        if let Some(refs) = ref_map.get(&oid) {
59            info.refs = refs.clone();
60        }
61        results.push(info);
62    }
63
64    Ok(results)
65}
66
67/// Free-text search across commit summary, full message, author name, author
68/// email, and OID.
69///
70/// Returns at most `max_count` commits where `query` appears (case-insensitive)
71/// in any of those fields.
72pub fn search_commits(repo: &Repository, query: &str, max_count: usize) -> Result<Vec<CommitInfo>> {
73    let needle = query.to_lowercase();
74
75    let mut revwalk = repo.revwalk().context("failed to create revwalk")?;
76    revwalk.push_head().context("failed to push HEAD")?;
77    revwalk
78        .set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)
79        .context("failed to set sorting")?;
80
81    let mut results = Vec::with_capacity(max_count.min(256));
82
83    for oid_result in revwalk {
84        if results.len() >= max_count {
85            break;
86        }
87
88        let oid = oid_result.context("revwalk iteration error")?;
89        let commit = repo
90            .find_commit(oid)
91            .context("failed to find commit during search")?;
92
93        let summary = commit.summary().unwrap_or("").to_lowercase();
94        let message = commit.message().unwrap_or("").to_lowercase();
95        let author = commit.author();
96        let author_name = author.name().unwrap_or("").to_lowercase();
97        let author_email = author.email().unwrap_or("").to_lowercase();
98        let oid_str = oid.to_string();
99
100        if summary.contains(&needle)
101            || message.contains(&needle)
102            || author_name.contains(&needle)
103            || author_email.contains(&needle)
104            || oid_str.contains(&needle)
105        {
106            results.push(CommitInfo::from_git2_commit(&commit));
107        }
108    }
109
110    Ok(results)
111}
112
113/// Return commits that touched `file_path`, newest-first, up to `max_count`.
114///
115/// A commit "touches" a file if the file appears in its diff against the
116/// first parent (or against the empty tree for root commits).
117pub fn file_history(
118    repo: &Repository,
119    file_path: &str,
120    max_count: usize,
121) -> Result<Vec<CommitInfo>> {
122    let mut revwalk = repo.revwalk().context("failed to create revwalk")?;
123    revwalk.push_head().context("failed to push HEAD")?;
124    revwalk
125        .set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)
126        .context("failed to set sorting")?;
127
128    let mut results = Vec::new();
129
130    for oid_result in revwalk {
131        if results.len() >= max_count {
132            break;
133        }
134        let oid = oid_result.context("revwalk iteration error")?;
135        let commit = repo
136            .find_commit(oid)
137            .context("failed to find commit during file history walk")?;
138
139        if commit_touches_file(repo, &commit, file_path) {
140            results.push(CommitInfo::from_git2_commit(&commit));
141        }
142    }
143
144    Ok(results)
145}
146
147/// Return `true` if `commit` introduces any change to `file_path` relative to
148/// its first parent (or the empty tree for a root commit).
149fn commit_touches_file(repo: &Repository, commit: &git2::Commit<'_>, file_path: &str) -> bool {
150    let tree = match commit.tree() {
151        Ok(t) => t,
152        Err(_) => return false,
153    };
154    let parent_tree = commit.parents().next().and_then(|p| p.tree().ok());
155
156    let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) {
157        Ok(d) => d,
158        Err(_) => return false,
159    };
160
161    diff.deltas().any(|d| {
162        d.new_file()
163            .path()
164            .and_then(|p| p.to_str())
165            .map(|s| s == file_path)
166            .unwrap_or(false)
167            || d.old_file()
168                .path()
169                .and_then(|p| p.to_str())
170                .map(|s| s == file_path)
171                .unwrap_or(false)
172    })
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::path::Path;
179
180    fn setup_repo_with_commits() -> (tempfile::TempDir, Repository) {
181        let dir = tempfile::tempdir().unwrap();
182        let repo = Repository::init(dir.path()).unwrap();
183
184        {
185            let mut config = repo.config().unwrap();
186            config.set_str("user.name", "Alice Test").unwrap();
187            config.set_str("user.email", "alice@example.com").unwrap();
188        }
189
190        // First commit
191        let c1 = {
192            std::fs::write(dir.path().join("a.txt"), "aaa\n").unwrap();
193            let mut index = repo.index().unwrap();
194            index.add_path(Path::new("a.txt")).unwrap();
195            index.write().unwrap();
196            let tree_oid = index.write_tree().unwrap();
197            let tree = repo.find_tree(tree_oid).unwrap();
198            let sig = repo.signature().unwrap();
199            repo.commit(Some("HEAD"), &sig, &sig, "first commit", &tree, &[])
200                .unwrap()
201        };
202
203        // Second commit by a different "author"
204        {
205            let commit1 = repo.find_commit(c1).unwrap();
206            std::fs::write(dir.path().join("b.txt"), "bbb\n").unwrap();
207            let mut index2 = repo.index().unwrap();
208            index2.add_path(Path::new("b.txt")).unwrap();
209            index2.write().unwrap();
210            let tree_oid2 = index2.write_tree().unwrap();
211            let tree2 = repo.find_tree(tree_oid2).unwrap();
212            let sig2 = git2::Signature::now("Bob Builder", "bob@example.com").unwrap();
213            repo.commit(
214                Some("HEAD"),
215                &sig2,
216                &sig2,
217                "second commit by Bob",
218                &tree2,
219                &[&commit1],
220            )
221            .unwrap();
222        }
223
224        (dir, repo)
225    }
226
227    #[test]
228    fn get_log_no_filters() {
229        let (_dir, repo) = setup_repo_with_commits();
230        let log = get_log(&repo, 100, None, None).unwrap();
231        assert_eq!(log.len(), 2);
232        // Newest first
233        assert_eq!(log[0].summary, "second commit by Bob");
234        assert_eq!(log[1].summary, "first commit");
235    }
236
237    #[test]
238    fn get_log_filter_author() {
239        let (_dir, repo) = setup_repo_with_commits();
240        let log = get_log(&repo, 100, Some("bob"), None).unwrap();
241        assert_eq!(log.len(), 1);
242        assert_eq!(log[0].summary, "second commit by Bob");
243    }
244
245    #[test]
246    fn get_log_filter_message() {
247        let (_dir, repo) = setup_repo_with_commits();
248        let log = get_log(&repo, 100, None, Some("first")).unwrap();
249        assert_eq!(log.len(), 1);
250        assert_eq!(log[0].summary, "first commit");
251    }
252
253    #[test]
254    fn get_log_respects_max_count() {
255        let (_dir, repo) = setup_repo_with_commits();
256        let log = get_log(&repo, 1, None, None).unwrap();
257        assert_eq!(log.len(), 1);
258    }
259
260    #[test]
261    fn search_commits_by_author() {
262        let (_dir, repo) = setup_repo_with_commits();
263        let results = search_commits(&repo, "alice", 100).unwrap();
264        assert_eq!(results.len(), 1);
265        assert_eq!(results[0].summary, "first commit");
266    }
267
268    #[test]
269    fn search_commits_by_message() {
270        let (_dir, repo) = setup_repo_with_commits();
271        let results = search_commits(&repo, "second", 100).unwrap();
272        assert_eq!(results.len(), 1);
273        assert_eq!(results[0].author_name, "Bob Builder");
274    }
275
276    #[test]
277    fn search_commits_case_insensitive() {
278        let (_dir, repo) = setup_repo_with_commits();
279        let results = search_commits(&repo, "BOB", 100).unwrap();
280        assert_eq!(results.len(), 1);
281    }
282
283    #[test]
284    fn search_commits_empty_query_returns_all() {
285        let (_dir, repo) = setup_repo_with_commits();
286        let results = search_commits(&repo, "", 100).unwrap();
287        // Empty string matches everything
288        assert_eq!(results.len(), 2);
289    }
290
291    #[test]
292    fn search_commits_no_match() {
293        let (_dir, repo) = setup_repo_with_commits();
294        let results = search_commits(&repo, "zzzznonexistent", 100).unwrap();
295        assert!(results.is_empty());
296    }
297
298    #[test]
299    fn file_history_returns_only_touching_commits() {
300        let (dir, repo) = setup_repo_with_commits();
301
302        // Add a third commit that only touches a.txt
303        {
304            let head = repo.head().unwrap().peel_to_commit().unwrap();
305            std::fs::write(dir.path().join("a.txt"), "updated\n").unwrap();
306            let mut index = repo.index().unwrap();
307            index.add_path(std::path::Path::new("a.txt")).unwrap();
308            index.write().unwrap();
309            let tree_oid = index.write_tree().unwrap();
310            let tree = repo.find_tree(tree_oid).unwrap();
311            let sig = repo.signature().unwrap();
312            repo.commit(
313                Some("HEAD"),
314                &sig,
315                &sig,
316                "update a.txt only",
317                &tree,
318                &[&head],
319            )
320            .unwrap();
321        }
322
323        // a.txt was touched in commit 1 ("first commit") and commit 3
324        let hist = file_history(&repo, "a.txt", 100).unwrap();
325        assert_eq!(hist.len(), 2, "expected 2 commits touching a.txt");
326        assert_eq!(hist[0].summary, "update a.txt only");
327        assert_eq!(hist[1].summary, "first commit");
328
329        // b.txt was only touched in commit 2
330        let hist_b = file_history(&repo, "b.txt", 100).unwrap();
331        assert_eq!(hist_b.len(), 1);
332        assert_eq!(hist_b[0].summary, "second commit by Bob");
333    }
334
335    #[test]
336    fn file_history_respects_max_count() {
337        let (dir, repo) = setup_repo_with_commits();
338
339        // Add second touch of a.txt
340        {
341            let head = repo.head().unwrap().peel_to_commit().unwrap();
342            std::fs::write(dir.path().join("a.txt"), "v2\n").unwrap();
343            let mut index = repo.index().unwrap();
344            index.add_path(std::path::Path::new("a.txt")).unwrap();
345            index.write().unwrap();
346            let tree_oid = index.write_tree().unwrap();
347            let tree = repo.find_tree(tree_oid).unwrap();
348            let sig = repo.signature().unwrap();
349            repo.commit(Some("HEAD"), &sig, &sig, "touch a again", &tree, &[&head])
350                .unwrap();
351        }
352
353        let hist = file_history(&repo, "a.txt", 1).unwrap();
354        assert_eq!(hist.len(), 1);
355    }
356
357    #[test]
358    fn file_history_nonexistent_file_returns_empty() {
359        let (_dir, repo) = setup_repo_with_commits();
360        let hist = file_history(&repo, "nonexistent.txt", 100).unwrap();
361        assert!(hist.is_empty());
362    }
363}