gitkraft_core/features/log/
ops.rs1use anyhow::{Context, Result};
4use git2::Repository;
5
6use crate::features::commits::CommitInfo;
7
8pub 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 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 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
63pub 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
109pub 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
143fn 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 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 {
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 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 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 {
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 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 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 {
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}