1use crate::context::{ChangeType, RecentCommit, StagedFile};
2use crate::git::utils::{is_binary_diff, should_exclude_file};
3use crate::log_debug;
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 amend_commit(repo: &Repository, message: &str, is_remote: bool) -> Result<CommitResult> {
121 if is_remote {
122 return Err(anyhow!(
123 "Cannot amend a commit in a remote repository in read-only mode"
124 ));
125 }
126
127 let signature = repo.signature()?;
128 let mut index = repo.index()?;
129 let tree_id = index.write_tree()?;
130 let tree = repo.find_tree(tree_id)?;
131
132 let head_commit = repo.head()?.peel_to_commit()?;
134
135 let commit_oid = head_commit.amend(
137 Some("HEAD"), Some(&signature), Some(&signature), None, Some(message), Some(&tree), )?;
144
145 let branch_name = repo.head()?.shorthand().unwrap_or("HEAD").to_string();
146 let commit = repo.find_commit(commit_oid)?;
147 let short_hash = commit.id().to_string()[..7].to_string();
148
149 let mut files_changed = 0;
151 let mut insertions = 0;
152 let mut deletions = 0;
153 let new_files = Vec::new();
154
155 let parent_tree = if head_commit.parent_count() > 0 {
157 Some(head_commit.parent(0)?.tree()?)
158 } else {
159 None
160 };
161 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
162
163 diff.print(git2::DiffFormat::NameStatus, |_, _, line| {
164 files_changed += 1;
165 if line.origin() == '+' {
166 insertions += 1;
167 } else if line.origin() == '-' {
168 deletions += 1;
169 }
170 true
171 })?;
172
173 log_debug!(
174 "Amended commit {} -> {} with {} files changed",
175 &head_commit.id().to_string()[..7],
176 short_hash,
177 files_changed
178 );
179
180 Ok(CommitResult {
181 branch: branch_name,
182 commit_hash: short_hash,
183 files_changed,
184 insertions,
185 deletions,
186 new_files,
187 })
188}
189
190pub fn get_head_commit_message(repo: &Repository) -> Result<String> {
200 let head_commit = repo.head()?.peel_to_commit()?;
201 Ok(head_commit.message().unwrap_or_default().to_string())
202}
203
204pub fn get_commits_between_with_callback<T, F>(
217 repo: &Repository,
218 from: &str,
219 to: &str,
220 mut callback: F,
221) -> Result<Vec<T>>
222where
223 F: FnMut(&RecentCommit) -> Result<T>,
224{
225 let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
226 let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
227
228 let mut revwalk = repo.revwalk()?;
229 revwalk.push(to_commit.id())?;
230 revwalk.hide(from_commit.id())?;
231
232 revwalk
233 .filter_map(std::result::Result::ok)
234 .map(|id| {
235 let commit = repo.find_commit(id)?;
236 let recent_commit = RecentCommit {
237 hash: commit.id().to_string(),
238 message: commit.message().unwrap_or_default().to_string(),
239 author: commit.author().name().unwrap_or_default().to_string(),
240 timestamp: commit.time().seconds().to_string(),
241 };
242 callback(&recent_commit)
243 })
244 .collect()
245}
246
247pub fn get_commit_files(repo: &Repository, commit_id: &str) -> Result<Vec<StagedFile>> {
258 log_debug!("Getting files for commit: {}", commit_id);
259
260 let obj = repo.revparse_single(commit_id)?;
262 let commit = obj.peel_to_commit()?;
263
264 let commit_tree = commit.tree()?;
265 let parent_commit = if commit.parent_count() > 0 {
266 Some(commit.parent(0)?)
267 } else {
268 None
269 };
270
271 let parent_tree = parent_commit.map(|c| c.tree()).transpose()?;
272
273 let mut commit_files = Vec::new();
274
275 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
276
277 diff.foreach(
279 &mut |delta, _| {
280 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
281 let change_type = match delta.status() {
282 git2::Delta::Added => ChangeType::Added,
283 git2::Delta::Modified => ChangeType::Modified,
284 git2::Delta::Deleted => ChangeType::Deleted,
285 _ => return true, };
287
288 let should_exclude = should_exclude_file(path);
289
290 commit_files.push(StagedFile {
291 path: path.to_string(),
292 change_type,
293 diff: String::new(), content: None,
295 content_excluded: should_exclude,
296 });
297 }
298 true
299 },
300 None,
301 None,
302 None,
303 )?;
304
305 for file in &mut commit_files {
307 if file.content_excluded {
308 file.diff = String::from("[Content excluded]");
309 continue;
310 }
311
312 let mut diff_options = git2::DiffOptions::new();
313 diff_options.pathspec(&file.path);
314
315 let file_diff = repo.diff_tree_to_tree(
316 parent_tree.as_ref(),
317 Some(&commit_tree),
318 Some(&mut diff_options),
319 )?;
320
321 let mut diff_string = String::new();
322 file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
323 let origin = match line.origin() {
324 '+' | '-' | ' ' => line.origin(),
325 _ => ' ',
326 };
327 diff_string.push(origin);
328 diff_string.push_str(&String::from_utf8_lossy(line.content()));
329 true
330 })?;
331
332 if is_binary_diff(&diff_string) {
333 file.diff = "[Binary file changed]".to_string();
334 } else {
335 file.diff = diff_string;
336 }
337 }
338
339 log_debug!("Found {} files in commit", commit_files.len());
340 Ok(commit_files)
341}
342
343pub fn extract_commit_info(repo: &Repository, commit_id: &str, branch: &str) -> Result<CommitInfo> {
345 let obj = repo.revparse_single(commit_id)?;
347 let commit = obj.peel_to_commit()?;
348
349 let commit_author = commit.author();
351 let author_name = commit_author.name().unwrap_or_default().to_string();
352 let commit_message = commit.message().unwrap_or_default().to_string();
353 let commit_time = commit.time().seconds().to_string();
354 let commit_hash = commit.id().to_string();
355
356 let recent_commit = RecentCommit {
358 hash: commit_hash,
359 message: commit_message,
360 author: author_name,
361 timestamp: commit_time,
362 };
363
364 let file_paths = get_file_paths_for_commit(repo, commit_id)?;
366
367 Ok(CommitInfo {
368 branch: branch.to_string(),
369 commit: recent_commit,
370 file_paths,
371 })
372}
373
374pub fn get_file_paths_for_commit(repo: &Repository, commit_id: &str) -> Result<Vec<String>> {
376 let obj = repo.revparse_single(commit_id)?;
378 let commit = obj.peel_to_commit()?;
379
380 let commit_tree = commit.tree()?;
381 let parent_commit = if commit.parent_count() > 0 {
382 Some(commit.parent(0)?)
383 } else {
384 None
385 };
386
387 let parent_tree = parent_commit.map(|c| c.tree()).transpose()?;
388
389 let mut file_paths = Vec::new();
390
391 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
393
394 diff.foreach(
396 &mut |delta, _| {
397 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
398 match delta.status() {
399 git2::Delta::Added | git2::Delta::Modified | git2::Delta::Deleted => {
400 file_paths.push(path.to_string());
401 }
402 _ => {} }
404 }
405 true
406 },
407 None,
408 None,
409 None,
410 )?;
411
412 Ok(file_paths)
413}
414
415pub fn get_commit_date(repo: &Repository, commit_ish: &str) -> Result<String> {
426 let obj = repo.revparse_single(commit_ish)?;
428 let commit = obj.peel_to_commit()?;
429
430 let time = commit.time();
432
433 let datetime = chrono::DateTime::<chrono::Utc>::from_timestamp(time.seconds(), 0)
435 .ok_or_else(|| anyhow!("Invalid timestamp"))?;
436
437 Ok(datetime.format("%Y-%m-%d").to_string())
439}
440
441pub fn get_branch_diff_files(
453 repo: &Repository,
454 base_branch: &str,
455 target_branch: &str,
456) -> Result<Vec<StagedFile>> {
457 log_debug!(
458 "Getting files changed between branches: {} -> {}",
459 base_branch,
460 target_branch
461 );
462
463 let base_commit = repo.revparse_single(base_branch)?.peel_to_commit()?;
465 let target_commit = repo.revparse_single(target_branch)?.peel_to_commit()?;
466
467 let merge_base_oid = repo.merge_base(base_commit.id(), target_commit.id())?;
470 let merge_base_commit = repo.find_commit(merge_base_oid)?;
471
472 log_debug!("Using merge-base {} for comparison", merge_base_oid);
473
474 let base_tree = merge_base_commit.tree()?;
475 let target_tree = target_commit.tree()?;
476
477 let mut branch_files = Vec::new();
478
479 let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&target_tree), None)?;
482 diff.foreach(
483 &mut |delta, _| collect_delta_file(&delta, &mut branch_files),
484 None,
485 None,
486 None,
487 )?;
488
489 for file in &mut branch_files {
491 populate_branch_file(repo, &base_tree, &target_tree, file)?;
492 }
493
494 log_debug!(
495 "Found {} files changed between branches (using merge-base)",
496 branch_files.len()
497 );
498 Ok(branch_files)
499}
500
501fn collect_delta_file(delta: &git2::DiffDelta<'_>, branch_files: &mut Vec<StagedFile>) -> bool {
502 if let Some(file) = staged_file_from_delta(delta) {
503 branch_files.push(file);
504 }
505 true
506}
507
508fn staged_file_from_delta(delta: &git2::DiffDelta<'_>) -> Option<StagedFile> {
509 let path = delta.new_file().path()?.to_str()?;
510 let change_type = change_type_from_delta(delta.status())?;
511
512 Some(StagedFile {
513 path: path.to_string(),
514 change_type,
515 diff: String::new(),
516 content: None,
517 content_excluded: should_exclude_file(path),
518 })
519}
520
521fn change_type_from_delta(delta: git2::Delta) -> Option<ChangeType> {
522 match delta {
523 git2::Delta::Added => Some(ChangeType::Added),
524 git2::Delta::Modified => Some(ChangeType::Modified),
525 git2::Delta::Deleted => Some(ChangeType::Deleted),
526 _ => None,
527 }
528}
529
530fn populate_branch_file(
531 repo: &Repository,
532 base_tree: &git2::Tree<'_>,
533 target_tree: &git2::Tree<'_>,
534 file: &mut StagedFile,
535) -> Result<()> {
536 file.diff = branch_file_diff(repo, base_tree, target_tree, file)?;
537 file.content = branch_file_content(repo, target_tree, file);
538 Ok(())
539}
540
541fn branch_file_diff(
542 repo: &Repository,
543 base_tree: &git2::Tree<'_>,
544 target_tree: &git2::Tree<'_>,
545 file: &StagedFile,
546) -> Result<String> {
547 if file.content_excluded {
548 return Ok(String::from("[Content excluded]"));
549 }
550
551 let mut diff_options = git2::DiffOptions::new();
552 diff_options.pathspec(&file.path);
553
554 let file_diff =
555 repo.diff_tree_to_tree(Some(base_tree), Some(target_tree), Some(&mut diff_options))?;
556 let mut diff_string = String::new();
557 file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
558 diff_string.push(patch_line_origin(line.origin()));
559 diff_string.push_str(&String::from_utf8_lossy(line.content()));
560 true
561 })?;
562
563 if is_binary_diff(&diff_string) {
564 Ok("[Binary file changed]".to_string())
565 } else {
566 Ok(diff_string)
567 }
568}
569
570fn patch_line_origin(origin: char) -> char {
571 match origin {
572 '+' | '-' | ' ' => origin,
573 _ => ' ',
574 }
575}
576
577fn branch_file_content(
578 repo: &Repository,
579 target_tree: &git2::Tree<'_>,
580 file: &StagedFile,
581) -> Option<String> {
582 if !matches!(file.change_type, ChangeType::Added | ChangeType::Modified) {
583 return None;
584 }
585
586 target_tree
587 .get_path(std::path::Path::new(&file.path))
588 .ok()
589 .and_then(|entry| entry.to_object(repo).ok())
590 .and_then(|object| object.as_blob().map(|blob| blob.content().to_vec()))
591 .and_then(|content| String::from_utf8(content).ok())
592}
593
594pub fn extract_branch_diff_info(
596 repo: &Repository,
597 base_branch: &str,
598 target_branch: &str,
599) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
600 let display_branch = format!("{base_branch} -> {target_branch}");
602
603 let base_commit = repo.revparse_single(base_branch)?.peel_to_commit()?;
605 let target_commit = repo.revparse_single(target_branch)?.peel_to_commit()?;
606
607 let merge_base_oid = repo.merge_base(base_commit.id(), target_commit.id())?;
609 log_debug!("Using merge-base {} for commit history", merge_base_oid);
610
611 let mut revwalk = repo.revwalk()?;
612 revwalk.push(target_commit.id())?;
613 revwalk.hide(merge_base_oid)?; let recent_commits: Result<Vec<RecentCommit>> = revwalk
616 .take(10) .map(|oid| {
618 let oid = oid?;
619 let commit = repo.find_commit(oid)?;
620 let author = commit.author();
621 Ok(RecentCommit {
622 hash: oid.to_string(),
623 message: commit.message().unwrap_or_default().to_string(),
624 author: author.name().unwrap_or_default().to_string(),
625 timestamp: commit.time().seconds().to_string(),
626 })
627 })
628 .collect();
629
630 let recent_commits = recent_commits?;
631
632 let diff_files = get_branch_diff_files(repo, base_branch, target_branch)?;
634 let file_paths: Vec<String> = diff_files.iter().map(|file| file.path.clone()).collect();
635
636 Ok((display_branch, recent_commits, file_paths))
637}
638
639pub fn get_commits_for_pr(repo: &Repository, from: &str, to: &str) -> Result<Vec<String>> {
651 log_debug!("Getting commits for PR between {} and {}", from, to);
652
653 let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
654 let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
655
656 let mut revwalk = repo.revwalk()?;
657 revwalk.push(to_commit.id())?;
658 revwalk.hide(from_commit.id())?;
659
660 let commits: Result<Vec<String>> = revwalk
661 .map(|oid| {
662 let oid = oid?;
663 let commit = repo.find_commit(oid)?;
664 let message = commit.message().unwrap_or_default();
665 let title = message.lines().next().unwrap_or_default();
667 Ok(format!("{}: {}", &oid.to_string()[..7], title))
668 })
669 .collect();
670
671 let mut result = commits?;
672 result.reverse(); log_debug!("Found {} commits for PR", result.len());
675 Ok(result)
676}
677
678pub fn get_commit_range_files(repo: &Repository, from: &str, to: &str) -> Result<Vec<StagedFile>> {
690 log_debug!("Getting files changed in commit range: {} -> {}", from, to);
691
692 let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
694 let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
695
696 let from_tree = from_commit.tree()?;
697 let to_tree = to_commit.tree()?;
698
699 let mut range_files = Vec::new();
700
701 let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
703
704 diff.foreach(
706 &mut |delta, _| {
707 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
708 let change_type = match delta.status() {
709 git2::Delta::Added => ChangeType::Added,
710 git2::Delta::Modified => ChangeType::Modified,
711 git2::Delta::Deleted => ChangeType::Deleted,
712 _ => return true, };
714
715 let should_exclude = should_exclude_file(path);
716
717 range_files.push(StagedFile {
718 path: path.to_string(),
719 change_type,
720 diff: String::new(), content: None,
722 content_excluded: should_exclude,
723 });
724 }
725 true
726 },
727 None,
728 None,
729 None,
730 )?;
731
732 for file in &mut range_files {
734 if file.content_excluded {
735 file.diff = String::from("[Content excluded]");
736 continue;
737 }
738
739 let mut diff_options = git2::DiffOptions::new();
740 diff_options.pathspec(&file.path);
741
742 let file_diff =
743 repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_options))?;
744
745 let mut diff_string = String::new();
746 file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
747 let origin = match line.origin() {
748 '+' | '-' | ' ' => line.origin(),
749 _ => ' ',
750 };
751 diff_string.push(origin);
752 diff_string.push_str(&String::from_utf8_lossy(line.content()));
753 true
754 })?;
755
756 if is_binary_diff(&diff_string) {
757 file.diff = "[Binary file changed]".to_string();
758 } else {
759 file.diff = diff_string;
760 }
761
762 if matches!(file.change_type, ChangeType::Added | ChangeType::Modified)
764 && let Ok(entry) = to_tree.get_path(std::path::Path::new(&file.path))
765 && let Ok(object) = entry.to_object(repo)
766 && let Some(blob) = object.as_blob()
767 && let Ok(content) = std::str::from_utf8(blob.content())
768 {
769 file.content = Some(content.to_string());
770 }
771 }
772
773 log_debug!("Found {} files changed in commit range", range_files.len());
774 Ok(range_files)
775}
776
777pub fn extract_commit_range_info(
779 repo: &Repository,
780 from: &str,
781 to: &str,
782) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
783 let display_range = format!("{from}..{to}");
785
786 let recent_commits: Result<Vec<RecentCommit>> =
788 get_commits_between_with_callback(repo, from, to, |commit| Ok(commit.clone()));
789 let recent_commits = recent_commits?;
790
791 let range_files = get_commit_range_files(repo, from, to)?;
793 let file_paths: Vec<String> = range_files.iter().map(|file| file.path.clone()).collect();
794
795 Ok((display_range, recent_commits, file_paths))
796}