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