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
483 diff.foreach(
485 &mut |delta, _| {
486 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
487 let change_type = match delta.status() {
488 git2::Delta::Added => ChangeType::Added,
489 git2::Delta::Modified => ChangeType::Modified,
490 git2::Delta::Deleted => ChangeType::Deleted,
491 _ => return true, };
493
494 let should_exclude = should_exclude_file(path);
495
496 branch_files.push(StagedFile {
497 path: path.to_string(),
498 change_type,
499 diff: String::new(), content: None,
501 content_excluded: should_exclude,
502 });
503 }
504 true
505 },
506 None,
507 None,
508 None,
509 )?;
510
511 for file in &mut branch_files {
513 if file.content_excluded {
514 file.diff = String::from("[Content excluded]");
515 continue;
516 }
517
518 let mut diff_options = git2::DiffOptions::new();
519 diff_options.pathspec(&file.path);
520
521 let file_diff = repo.diff_tree_to_tree(
522 Some(&base_tree),
523 Some(&target_tree),
524 Some(&mut diff_options),
525 )?;
526
527 let mut diff_string = String::new();
528 file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
529 let origin = match line.origin() {
530 '+' | '-' | ' ' => line.origin(),
531 _ => ' ',
532 };
533 diff_string.push(origin);
534 diff_string.push_str(&String::from_utf8_lossy(line.content()));
535 true
536 })?;
537
538 if is_binary_diff(&diff_string) {
539 file.diff = "[Binary file changed]".to_string();
540 } else {
541 file.diff = diff_string;
542 }
543
544 if matches!(file.change_type, ChangeType::Added | ChangeType::Modified)
546 && let Ok(entry) = target_tree.get_path(std::path::Path::new(&file.path))
547 && let Ok(object) = entry.to_object(repo)
548 && let Some(blob) = object.as_blob()
549 && let Ok(content) = std::str::from_utf8(blob.content())
550 {
551 file.content = Some(content.to_string());
552 }
553 }
554
555 log_debug!(
556 "Found {} files changed between branches (using merge-base)",
557 branch_files.len()
558 );
559 Ok(branch_files)
560}
561
562pub fn extract_branch_diff_info(
564 repo: &Repository,
565 base_branch: &str,
566 target_branch: &str,
567) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
568 let display_branch = format!("{base_branch} -> {target_branch}");
570
571 let base_commit = repo.revparse_single(base_branch)?.peel_to_commit()?;
573 let target_commit = repo.revparse_single(target_branch)?.peel_to_commit()?;
574
575 let merge_base_oid = repo.merge_base(base_commit.id(), target_commit.id())?;
577 log_debug!("Using merge-base {} for commit history", merge_base_oid);
578
579 let mut revwalk = repo.revwalk()?;
580 revwalk.push(target_commit.id())?;
581 revwalk.hide(merge_base_oid)?; let recent_commits: Result<Vec<RecentCommit>> = revwalk
584 .take(10) .map(|oid| {
586 let oid = oid?;
587 let commit = repo.find_commit(oid)?;
588 let author = commit.author();
589 Ok(RecentCommit {
590 hash: oid.to_string(),
591 message: commit.message().unwrap_or_default().to_string(),
592 author: author.name().unwrap_or_default().to_string(),
593 timestamp: commit.time().seconds().to_string(),
594 })
595 })
596 .collect();
597
598 let recent_commits = recent_commits?;
599
600 let diff_files = get_branch_diff_files(repo, base_branch, target_branch)?;
602 let file_paths: Vec<String> = diff_files.iter().map(|file| file.path.clone()).collect();
603
604 Ok((display_branch, recent_commits, file_paths))
605}
606
607pub fn get_commits_for_pr(repo: &Repository, from: &str, to: &str) -> Result<Vec<String>> {
619 log_debug!("Getting commits for PR between {} and {}", from, to);
620
621 let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
622 let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
623
624 let mut revwalk = repo.revwalk()?;
625 revwalk.push(to_commit.id())?;
626 revwalk.hide(from_commit.id())?;
627
628 let commits: Result<Vec<String>> = revwalk
629 .map(|oid| {
630 let oid = oid?;
631 let commit = repo.find_commit(oid)?;
632 let message = commit.message().unwrap_or_default();
633 let title = message.lines().next().unwrap_or_default();
635 Ok(format!("{}: {}", &oid.to_string()[..7], title))
636 })
637 .collect();
638
639 let mut result = commits?;
640 result.reverse(); log_debug!("Found {} commits for PR", result.len());
643 Ok(result)
644}
645
646pub fn get_commit_range_files(repo: &Repository, from: &str, to: &str) -> Result<Vec<StagedFile>> {
658 log_debug!("Getting files changed in commit range: {} -> {}", from, to);
659
660 let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
662 let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
663
664 let from_tree = from_commit.tree()?;
665 let to_tree = to_commit.tree()?;
666
667 let mut range_files = Vec::new();
668
669 let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
671
672 diff.foreach(
674 &mut |delta, _| {
675 if let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) {
676 let change_type = match delta.status() {
677 git2::Delta::Added => ChangeType::Added,
678 git2::Delta::Modified => ChangeType::Modified,
679 git2::Delta::Deleted => ChangeType::Deleted,
680 _ => return true, };
682
683 let should_exclude = should_exclude_file(path);
684
685 range_files.push(StagedFile {
686 path: path.to_string(),
687 change_type,
688 diff: String::new(), content: None,
690 content_excluded: should_exclude,
691 });
692 }
693 true
694 },
695 None,
696 None,
697 None,
698 )?;
699
700 for file in &mut range_files {
702 if file.content_excluded {
703 file.diff = String::from("[Content excluded]");
704 continue;
705 }
706
707 let mut diff_options = git2::DiffOptions::new();
708 diff_options.pathspec(&file.path);
709
710 let file_diff =
711 repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_options))?;
712
713 let mut diff_string = String::new();
714 file_diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
715 let origin = match line.origin() {
716 '+' | '-' | ' ' => line.origin(),
717 _ => ' ',
718 };
719 diff_string.push(origin);
720 diff_string.push_str(&String::from_utf8_lossy(line.content()));
721 true
722 })?;
723
724 if is_binary_diff(&diff_string) {
725 file.diff = "[Binary file changed]".to_string();
726 } else {
727 file.diff = diff_string;
728 }
729
730 if matches!(file.change_type, ChangeType::Added | ChangeType::Modified)
732 && let Ok(entry) = to_tree.get_path(std::path::Path::new(&file.path))
733 && let Ok(object) = entry.to_object(repo)
734 && let Some(blob) = object.as_blob()
735 && let Ok(content) = std::str::from_utf8(blob.content())
736 {
737 file.content = Some(content.to_string());
738 }
739 }
740
741 log_debug!("Found {} files changed in commit range", range_files.len());
742 Ok(range_files)
743}
744
745pub fn extract_commit_range_info(
747 repo: &Repository,
748 from: &str,
749 to: &str,
750) -> Result<(String, Vec<RecentCommit>, Vec<String>)> {
751 let display_range = format!("{from}..{to}");
753
754 let recent_commits: Result<Vec<RecentCommit>> =
756 get_commits_between_with_callback(repo, from, to, |commit| Ok(commit.clone()));
757 let recent_commits = recent_commits?;
758
759 let range_files = get_commit_range_files(repo, from, to)?;
761 let file_paths: Vec<String> = range_files.iter().map(|file| file.path.clone()).collect();
762
763 Ok((display_range, recent_commits, file_paths))
764}