1use crate::core::context::{ChangeType, RecentCommit, StagedFile};
2use crate::debug;
3use crate::git::utils::is_binary_diff;
4use anyhow::{Context, Result, anyhow};
5use chrono;
6use git2::{FileMode, Repository, Status};
7
8#[derive(Debug)]
10pub struct CommitResult {
11 pub branch: String,
12 pub commit_hash: String,
13 pub files_changed: usize,
14 pub insertions: usize,
15 pub deletions: usize,
16 pub new_files: Vec<(String, FileMode)>,
17}
18
19#[derive(Debug)]
21pub struct CommitInfo {
22 pub branch: String,
23 pub commit: RecentCommit,
24 pub file_paths: Vec<String>,
25}
26
27pub fn commit(repo: &Repository, message: &str, is_remote: bool) -> Result<CommitResult> {
39 if is_remote {
40 return Err(anyhow!(
41 "Cannot commit to a remote repository in read-only mode"
42 ));
43 }
44
45 let signature = repo.signature()?;
46 let mut index = repo.index()?;
47 let tree_id = index.write_tree()?;
48 let tree = repo.find_tree(tree_id)?;
49 let parent_commit = repo.head()?.peel_to_commit()?;
50 let commit_oid = repo.commit(
51 Some("HEAD"),
52 &signature,
53 &signature,
54 message,
55 &tree,
56 &[&parent_commit],
57 )?;
58
59 let branch_name = repo.head()?.shorthand().unwrap_or("HEAD").to_string();
60 let commit = repo.find_commit(commit_oid)?;
61 let short_hash = commit.id().to_string()[..7].to_string();
62
63 let mut files_changed = 0;
64 let mut insertions = 0;
65 let mut deletions = 0;
66 let mut new_files = Vec::new();
67
68 let diff = repo.diff_tree_to_tree(Some(&parent_commit.tree()?), Some(&tree), None)?;
69
70 diff.print(git2::DiffFormat::NameStatus, |_, _, line| {
71 files_changed += 1;
72 if line.origin() == '+' {
73 insertions += 1;
74 } else if line.origin() == '-' {
75 deletions += 1;
76 }
77 true
78 })?;
79
80 let statuses = repo.statuses(None)?;
81 for entry in statuses.iter() {
82 if entry.status().contains(Status::INDEX_NEW) {
83 new_files.push((
84 entry.path().context("Could not get path")?.to_string(),
85 entry
86 .index_to_workdir()
87 .context("Could not get index to workdir")?
88 .new_file()
89 .mode(),
90 ));
91 }
92 }
93
94 Ok(CommitResult {
95 branch: branch_name,
96 commit_hash: short_hash,
97 files_changed,
98 insertions,
99 deletions,
100 new_files,
101 })
102}
103
104pub fn get_commits_between_with_callback<T, F>(
117 repo: &Repository,
118 from: &str,
119 to: &str,
120 mut callback: F,
121) -> Result<Vec<T>>
122where
123 F: FnMut(&RecentCommit) -> Result<T>,
124{
125 let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
126 let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
127
128 let mut revwalk = repo.revwalk()?;
129 revwalk.push(to_commit.id())?;
130 revwalk.hide(from_commit.id())?;
131
132 revwalk
133 .filter_map(std::result::Result::ok)
134 .map(|id| {
135 let commit = repo.find_commit(id)?;
136 let recent_commit = RecentCommit {
137 hash: commit.id().to_string(),
138 message: commit.message().unwrap_or_default().to_string(),
139 author: commit.author().name().unwrap_or_default().to_string(),
140 timestamp: commit.time().seconds().to_string(),
141 };
142 callback(&recent_commit)
143 })
144 .collect()
145}
146
147pub fn get_commit_files(repo: &Repository, commit_id: &str) -> Result<Vec<StagedFile>> {
158 debug!("Getting files for commit: {}", commit_id);
159
160 let obj = repo.revparse_single(commit_id)?;
162 let commit = obj.peel_to_commit()?;
163
164 let commit_tree = commit.tree()?;
165 let parent_commit = if commit.parent_count() > 0 {
166 Some(commit.parent(0)?)
167 } else {
168 None
169 };
170
171 let parent_tree = parent_commit.map(|c| c.tree()).transpose()?;
172
173 let mut commit_files = Vec::new();
174
175 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
176
177 diff.foreach(
179 &mut |delta, _| {
180 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
181 let change_type = match delta.status() {
182 git2::Delta::Added => ChangeType::Added,
183 git2::Delta::Modified => ChangeType::Modified,
184 git2::Delta::Deleted => ChangeType::Deleted,
185 _ => return true, };
187
188 let should_exclude = crate::file_analyzers::should_exclude_file(path);
189
190 commit_files.push(StagedFile {
191 path: path.to_string(),
192 change_type,
193 diff: String::new(), analysis: Vec::new(),
195 content: None,
196 content_excluded: should_exclude,
197 });
198 }
199 true
200 },
201 None,
202 None,
203 None,
204 )?;
205
206 for file in &mut commit_files {
208 if file.content_excluded {
209 file.diff = String::from("[Content excluded]");
210 file.analysis = vec!["[Analysis excluded]".to_string()];
211 continue;
212 }
213
214 let mut diff_options = git2::DiffOptions::new();
215 diff_options.pathspec(&file.path);
216
217 let file_diff = repo.diff_tree_to_tree(
218 parent_tree.as_ref(),
219 Some(&commit_tree),
220 Some(&mut diff_options),
221 )?;
222
223 let mut diff_string = String::new();
224 file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
225 let origin = match line.origin() {
226 '+' | '-' | ' ' => line.origin(),
227 _ => ' ',
228 };
229 diff_string.push(origin);
230 diff_string.push_str(&String::from_utf8_lossy(line.content()));
231 true
232 })?;
233
234 if is_binary_diff(&diff_string) {
235 file.diff = "[Binary file changed]".to_string();
236 } else {
237 file.diff = diff_string;
238 }
239
240 let analyzer = crate::file_analyzers::get_analyzer(&file.path);
241 file.analysis = analyzer.analyze(&file.path, file);
242 }
243
244 debug!("Found {} files in commit", commit_files.len());
245 Ok(commit_files)
246}
247
248pub fn extract_commit_info(repo: &Repository, commit_id: &str, branch: &str) -> Result<CommitInfo> {
250 let obj = repo.revparse_single(commit_id)?;
252 let commit = obj.peel_to_commit()?;
253
254 let commit_author = commit.author();
256 let author_name = commit_author.name().unwrap_or_default().to_string();
257 let commit_message = commit.message().unwrap_or_default().to_string();
258 let commit_time = commit.time().seconds().to_string();
259 let commit_hash = commit.id().to_string();
260
261 let recent_commit = RecentCommit {
263 hash: commit_hash,
264 message: commit_message,
265 author: author_name,
266 timestamp: commit_time,
267 };
268
269 let file_paths = get_file_paths_for_commit(repo, commit_id)?;
271
272 Ok(CommitInfo {
273 branch: branch.to_string(),
274 commit: recent_commit,
275 file_paths,
276 })
277}
278
279pub fn get_file_paths_for_commit(repo: &Repository, commit_id: &str) -> Result<Vec<String>> {
281 let obj = repo.revparse_single(commit_id)?;
283 let commit = obj.peel_to_commit()?;
284
285 let commit_tree = commit.tree()?;
286 let parent_commit = if commit.parent_count() > 0 {
287 Some(commit.parent(0)?)
288 } else {
289 None
290 };
291
292 let parent_tree = parent_commit.map(|c| c.tree()).transpose()?;
293
294 let mut file_paths = Vec::new();
295
296 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
298
299 diff.foreach(
301 &mut |delta, _| {
302 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
303 match delta.status() {
304 git2::Delta::Added | git2::Delta::Modified | git2::Delta::Deleted => {
305 file_paths.push(path.to_string());
306 }
307 _ => {} }
309 }
310 true
311 },
312 None,
313 None,
314 None,
315 )?;
316
317 Ok(file_paths)
318}
319
320pub fn get_commit_date(repo: &Repository, commit_ish: &str) -> Result<String> {
331 let obj = repo.revparse_single(commit_ish)?;
333 let commit = obj.peel_to_commit()?;
334
335 let time = commit.time();
337
338 let datetime = chrono::DateTime::<chrono::Utc>::from_timestamp(time.seconds(), 0)
340 .ok_or_else(|| anyhow!("Invalid timestamp"))?;
341
342 Ok(datetime.format("%Y-%m-%d").to_string())
344}
345
346pub fn get_branch_diff_files(
358 repo: &Repository,
359 base_branch: &str,
360 target_branch: &str,
361) -> Result<Vec<StagedFile>> {
362 debug!(
363 "Getting files changed between branches: {} -> {}",
364 base_branch, target_branch
365 );
366
367 let base_commit = resolve_branch(repo, base_branch)?;
369 let target_commit = repo.revparse_single(target_branch)?.peel_to_commit()?;
370
371 let merge_base_oid = repo.merge_base(base_commit.id(), target_commit.id())?;
374 let merge_base_commit = repo.find_commit(merge_base_oid)?;
375
376 debug!("Using merge-base {} for comparison", merge_base_oid);
377
378 let base_tree = merge_base_commit.tree()?;
379 let target_tree = target_commit.tree()?;
380
381 let mut branch_files = Vec::new();
382
383 let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&target_tree), None)?;
386
387 diff.foreach(
389 &mut |delta, _| {
390 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
391 let change_type = match delta.status() {
392 git2::Delta::Added => ChangeType::Added,
393 git2::Delta::Modified => ChangeType::Modified,
394 git2::Delta::Deleted => ChangeType::Deleted,
395 _ => return true, };
397
398 let should_exclude = crate::file_analyzers::should_exclude_file(path);
399
400 branch_files.push(StagedFile {
401 path: path.to_string(),
402 change_type,
403 diff: String::new(), analysis: Vec::new(),
405 content: None,
406 content_excluded: should_exclude,
407 });
408 }
409 true
410 },
411 None,
412 None,
413 None,
414 )?;
415
416 for file in &mut branch_files {
418 if file.content_excluded {
419 file.diff = String::from("[Content excluded]");
420 file.analysis = vec!["[Analysis excluded]".to_string()];
421 continue;
422 }
423
424 let mut diff_options = git2::DiffOptions::new();
425 diff_options.pathspec(&file.path);
426
427 let file_diff = repo.diff_tree_to_tree(
428 Some(&base_tree),
429 Some(&target_tree),
430 Some(&mut diff_options),
431 )?;
432
433 let mut diff_string = String::new();
434 file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
435 let origin = match line.origin() {
436 '+' | '-' | ' ' => line.origin(),
437 _ => ' ',
438 };
439 diff_string.push(origin);
440 diff_string.push_str(&String::from_utf8_lossy(line.content()));
441 true
442 })?;
443
444 if is_binary_diff(&diff_string) {
445 file.diff = "[Binary file changed]".to_string();
446 } else {
447 file.diff = diff_string;
448 }
449
450 if matches!(file.change_type, ChangeType::Added | ChangeType::Modified)
452 && let Ok(entry) = target_tree.get_path(std::path::Path::new(&file.path))
453 && let Ok(object) = entry.to_object(repo)
454 && let Some(blob) = object.as_blob()
455 && let Ok(content) = std::str::from_utf8(blob.content())
456 {
457 file.content = Some(content.to_string());
458 }
459
460 let analyzer = crate::file_analyzers::get_analyzer(&file.path);
461 file.analysis = analyzer.analyze(&file.path, file);
462 }
463
464 debug!(
465 "Found {} files changed between branches (using merge-base)",
466 branch_files.len()
467 );
468 Ok(branch_files)
469}
470
471pub fn extract_branch_diff_info(
473 repo: &Repository,
474 base_branch: &str,
475 target_branch: &str,
476) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
477 let display_branch = format!("{base_branch} -> {target_branch}");
479
480 let base_commit = resolve_branch_strict(repo, base_branch)?;
483 let target_commit = resolve_branch_strict(repo, target_branch)?;
484
485 let merge_base_oid = repo.merge_base(base_commit.id(), target_commit.id())?;
487 debug!("Using merge-base {} for commit history", merge_base_oid);
488
489 let mut revwalk = repo.revwalk()?;
490 revwalk.push(target_commit.id())?;
491 revwalk.hide(merge_base_oid)?; let recent_commits: Result<Vec<RecentCommit>> = revwalk
494 .take(10) .map(|oid| {
496 let oid = oid?;
497 let commit = repo.find_commit(oid)?;
498 let author = commit.author();
499 Ok(RecentCommit {
500 hash: oid.to_string(),
501 message: commit.message().unwrap_or_default().to_string(),
502 author: author.name().unwrap_or_default().to_string(),
503 timestamp: commit.time().seconds().to_string(),
504 })
505 })
506 .collect();
507
508 let recent_commits = recent_commits?;
509
510 let diff_files = get_branch_diff_files(repo, base_branch, target_branch)?;
512 let file_paths: Vec<String> = diff_files.iter().map(|file| file.path.clone()).collect();
513
514 Ok((display_branch, recent_commits, file_paths))
515}
516
517pub fn get_commits_for_pr(repo: &Repository, from: &str, to: &str) -> Result<Vec<String>> {
529 debug!("Getting commits for PR between {} and {}", from, to);
530
531 let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
532 let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
533
534 let mut revwalk = repo.revwalk()?;
535 revwalk.push(to_commit.id())?;
536 revwalk.hide(from_commit.id())?;
537
538 let commits: Result<Vec<String>> = revwalk
539 .map(|oid| {
540 let oid = oid?;
541 let commit = repo.find_commit(oid)?;
542 let message = commit.message().unwrap_or_default();
543 let title = message.lines().next().unwrap_or_default();
545 Ok(format!("{}: {}", &oid.to_string()[..7], title))
546 })
547 .collect();
548
549 let mut result = commits?;
550 result.reverse(); debug!("Found {} commits for PR", result.len());
553 Ok(result)
554}
555
556pub fn get_commit_range_files(repo: &Repository, from: &str, to: &str) -> Result<Vec<StagedFile>> {
568 debug!("Getting files changed in commit range: {} -> {}", from, to);
569
570 let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
572 let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
573
574 let from_tree = from_commit.tree()?;
575 let to_tree = to_commit.tree()?;
576
577 let mut range_files = Vec::new();
578
579 let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
581
582 diff.foreach(
584 &mut |delta, _| {
585 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
586 let change_type = match delta.status() {
587 git2::Delta::Added => ChangeType::Added,
588 git2::Delta::Modified => ChangeType::Modified,
589 git2::Delta::Deleted => ChangeType::Deleted,
590 _ => return true, };
592
593 let should_exclude = crate::file_analyzers::should_exclude_file(path);
594
595 range_files.push(StagedFile {
596 path: path.to_string(),
597 change_type,
598 diff: String::new(), analysis: Vec::new(),
600 content: None,
601 content_excluded: should_exclude,
602 });
603 }
604 true
605 },
606 None,
607 None,
608 None,
609 )?;
610
611 for file in &mut range_files {
613 if file.content_excluded {
614 file.diff = String::from("[Content excluded]");
615 file.analysis = vec!["[Analysis excluded]".to_string()];
616 continue;
617 }
618
619 let mut diff_options = git2::DiffOptions::new();
620 diff_options.pathspec(&file.path);
621
622 let file_diff =
623 repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_options))?;
624
625 let mut diff_string = String::new();
626 file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
627 let origin = match line.origin() {
628 '+' | '-' | ' ' => line.origin(),
629 _ => ' ',
630 };
631 diff_string.push(origin);
632 diff_string.push_str(&String::from_utf8_lossy(line.content()));
633 true
634 })?;
635
636 if is_binary_diff(&diff_string) {
637 file.diff = "[Binary file changed]".to_string();
638 } else {
639 file.diff = diff_string;
640 }
641
642 if matches!(file.change_type, ChangeType::Added | ChangeType::Modified)
644 && let Ok(entry) = to_tree.get_path(std::path::Path::new(&file.path))
645 && let Ok(object) = entry.to_object(repo)
646 && let Some(blob) = object.as_blob()
647 && let Ok(content) = std::str::from_utf8(blob.content())
648 {
649 file.content = Some(content.to_string());
650 }
651
652 let analyzer = crate::file_analyzers::get_analyzer(&file.path);
653 file.analysis = analyzer.analyze(&file.path, file);
654 }
655
656 debug!("Found {} files changed in commit range", range_files.len());
657 Ok(range_files)
658}
659
660pub fn extract_commit_range_info(
662 repo: &Repository,
663 from: &str,
664 to: &str,
665) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
666 let display_range = format!("{from}..{to}");
668
669 let recent_commits: Result<Vec<RecentCommit>> =
671 get_commits_between_with_callback(repo, from, to, |commit| Ok(commit.clone()));
672 let recent_commits = recent_commits?;
673
674 let range_files = get_commit_range_files(repo, from, to)?;
676 let file_paths: Vec<String> = range_files.iter().map(|file| file.path.clone()).collect();
677
678 Ok((display_range, recent_commits, file_paths))
679}
680
681fn resolve_branch_strict<'a>(
683 repo: &'a Repository,
684 branch_name: &'a str,
685) -> Result<git2::Commit<'a>> {
686 match repo.revparse_single(branch_name) {
687 Ok(obj) => Ok(obj.peel_to_commit()?),
688 Err(e) => Err(anyhow!(
689 "Could not resolve branch reference '{branch_name}': {e}"
690 )),
691 }
692}
693
694fn resolve_branch<'a>(repo: &'a Repository, branch_name: &'a str) -> Result<git2::Commit<'a>> {
696 if let Ok(obj) = repo.revparse_single(branch_name) {
698 Ok(obj.peel_to_commit()?)
699 } else {
700 debug!(
701 "Branch '{}' not found, trying common alternatives",
702 branch_name
703 );
704
705 let possible_branches = ["main", "master", "develop", "development"];
707
708 for &possible_branch in &possible_branches {
709 if possible_branch != branch_name {
710 match repo.revparse_single(possible_branch) {
711 Ok(obj) => {
712 debug!("Using alternative branch '{}'", possible_branch);
713 return Ok(obj.peel_to_commit()?);
714 }
715 Err(_) => {
716 debug!("Alternative branch '{}' not found", possible_branch);
717 }
718 }
719 }
720 }
721
722 Err(anyhow!(
723 "Could not resolve branch reference '{branch_name}'. Tried alternatives: {possible_branches:?}"
724 ))
725 }
726}