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