1use crate::error::{Autom8Error, Result};
2use std::process::Command;
3
4pub fn is_git_repo() -> bool {
6 Command::new("git")
7 .args(["rev-parse", "--git-dir"])
8 .output()
9 .map(|o| o.status.success())
10 .unwrap_or(false)
11}
12
13pub fn current_branch() -> Result<String> {
15 let output = Command::new("git")
16 .args(["rev-parse", "--abbrev-ref", "HEAD"])
17 .output()?;
18
19 if !output.status.success() {
20 return Err(Autom8Error::GitError(
21 String::from_utf8_lossy(&output.stderr).to_string(),
22 ));
23 }
24
25 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
26}
27
28pub fn branch_exists(branch: &str) -> Result<bool> {
30 let local = Command::new("git")
32 .args([
33 "show-ref",
34 "--verify",
35 "--quiet",
36 &format!("refs/heads/{}", branch),
37 ])
38 .output()?;
39
40 if local.status.success() {
41 return Ok(true);
42 }
43
44 let remote = Command::new("git")
46 .args([
47 "show-ref",
48 "--verify",
49 "--quiet",
50 &format!("refs/remotes/origin/{}", branch),
51 ])
52 .output()?;
53
54 Ok(remote.status.success())
55}
56
57pub fn ensure_branch(branch: &str) -> Result<()> {
59 let current = current_branch()?;
60
61 if current == branch {
62 return Ok(());
63 }
64
65 if branch_exists(branch)? {
66 checkout(branch)?;
68 } else {
69 create_and_checkout(branch)?;
71 }
72
73 Ok(())
74}
75
76pub fn checkout(branch: &str) -> Result<()> {
78 let output = Command::new("git").args(["checkout", branch]).output()?;
79
80 if !output.status.success() {
81 return Err(Autom8Error::GitError(format!(
82 "Failed to checkout branch '{}': {}",
83 branch,
84 String::from_utf8_lossy(&output.stderr)
85 )));
86 }
87
88 Ok(())
89}
90
91fn create_and_checkout(branch: &str) -> Result<()> {
93 let output = Command::new("git")
94 .args(["checkout", "-b", branch])
95 .output()?;
96
97 if !output.status.success() {
98 return Err(Autom8Error::GitError(format!(
99 "Failed to create branch '{}': {}",
100 branch,
101 String::from_utf8_lossy(&output.stderr)
102 )));
103 }
104
105 Ok(())
106}
107
108pub fn is_clean() -> Result<bool> {
110 let output = Command::new("git")
111 .args(["status", "--porcelain"])
112 .output()?;
113
114 if !output.status.success() {
115 return Err(Autom8Error::GitError(
116 String::from_utf8_lossy(&output.stderr).to_string(),
117 ));
118 }
119
120 Ok(output.stdout.is_empty())
121}
122
123pub fn latest_commit_short() -> Result<String> {
125 let output = Command::new("git")
126 .args(["rev-parse", "--short", "HEAD"])
127 .output()?;
128
129 if !output.status.success() {
130 return Err(Autom8Error::GitError(
131 String::from_utf8_lossy(&output.stderr).to_string(),
132 ));
133 }
134
135 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum DiffStatus {
145 Added,
147 Modified,
149 Deleted,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct DiffEntry {
156 pub path: std::path::PathBuf,
158 pub additions: u32,
160 pub deletions: u32,
162 pub status: DiffStatus,
164}
165
166impl DiffEntry {
167 pub fn from_numstat_line(line: &str) -> Option<Self> {
174 let parts: Vec<&str> = line.split('\t').collect();
175 if parts.len() != 3 {
176 return None;
177 }
178
179 let additions = parts[0].parse().unwrap_or(0);
180 let deletions = parts[1].parse().unwrap_or(0);
181 let path = std::path::PathBuf::from(parts[2]);
182
183 let status = if deletions == 0 && additions > 0 {
186 DiffStatus::Modified
188 } else if additions == 0 && deletions > 0 {
189 DiffStatus::Modified
191 } else {
192 DiffStatus::Modified
193 };
194
195 Some(DiffEntry {
196 path,
197 additions,
198 deletions,
199 status,
200 })
201 }
202
203 fn parse_name_status_line(line: &str) -> Option<(std::path::PathBuf, DiffStatus)> {
209 let parts: Vec<&str> = line.split('\t').collect();
210 if parts.is_empty() {
211 return None;
212 }
213
214 let status_char = parts[0].chars().next()?;
215 let status = match status_char {
216 'A' => DiffStatus::Added,
217 'D' => DiffStatus::Deleted,
218 'M' | 'R' | 'C' | 'T' => DiffStatus::Modified,
219 _ => DiffStatus::Modified,
220 };
221
222 let path = if status_char == 'R' || status_char == 'C' {
224 parts.get(2).map(|p| std::path::PathBuf::from(*p))?
225 } else {
226 parts.get(1).map(|p| std::path::PathBuf::from(*p))?
227 };
228
229 Some((path, status))
230 }
231}
232
233pub fn get_head_commit() -> Result<String> {
239 let output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
240
241 if !output.status.success() {
242 return Err(Autom8Error::GitError(
243 String::from_utf8_lossy(&output.stderr).trim().to_string(),
244 ));
245 }
246
247 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
248}
249
250pub fn get_diff_since(base_commit: &str) -> Result<Vec<DiffEntry>> {
262 if !is_git_repo() {
264 return Ok(Vec::new());
265 }
266
267 let numstat_output = Command::new("git")
269 .args(["diff", "--numstat", base_commit])
270 .output()?;
271
272 let name_status_output = Command::new("git")
274 .args(["diff", "--name-status", base_commit])
275 .output()?;
276
277 if !numstat_output.status.success() || !name_status_output.status.success() {
279 return Ok(Vec::new());
280 }
281
282 let name_status_stdout = String::from_utf8_lossy(&name_status_output.stdout);
284 let status_map: std::collections::HashMap<std::path::PathBuf, DiffStatus> = name_status_stdout
285 .lines()
286 .filter_map(DiffEntry::parse_name_status_line)
287 .collect();
288
289 let numstat_stdout = String::from_utf8_lossy(&numstat_output.stdout);
291 let entries: Vec<DiffEntry> = numstat_stdout
292 .lines()
293 .filter(|line| !line.is_empty())
294 .filter_map(|line| {
295 let mut entry = DiffEntry::from_numstat_line(line)?;
296 if let Some(status) = status_map.get(&entry.path) {
298 entry.status = status.clone();
299 }
300 Some(entry)
301 })
302 .collect();
303
304 Ok(entries)
305}
306
307pub fn get_uncommitted_changes() -> Result<Vec<DiffEntry>> {
316 if !is_git_repo() {
318 return Ok(Vec::new());
319 }
320
321 let numstat_output = Command::new("git")
323 .args(["diff", "HEAD", "--numstat"])
324 .output()?;
325
326 let name_status_output = Command::new("git")
328 .args(["diff", "HEAD", "--name-status"])
329 .output()?;
330
331 if !numstat_output.status.success() || !name_status_output.status.success() {
333 return Ok(Vec::new());
334 }
335
336 let name_status_stdout = String::from_utf8_lossy(&name_status_output.stdout);
338 let status_map: std::collections::HashMap<std::path::PathBuf, DiffStatus> = name_status_stdout
339 .lines()
340 .filter_map(DiffEntry::parse_name_status_line)
341 .collect();
342
343 let numstat_stdout = String::from_utf8_lossy(&numstat_output.stdout);
345 let entries: Vec<DiffEntry> = numstat_stdout
346 .lines()
347 .filter(|line| !line.is_empty())
348 .filter_map(|line| {
349 let mut entry = DiffEntry::from_numstat_line(line)?;
350 if let Some(status) = status_map.get(&entry.path) {
351 entry.status = status.clone();
352 }
353 Some(entry)
354 })
355 .collect();
356
357 Ok(entries)
358}
359
360pub fn get_new_files_since(base_commit: &str) -> Result<Vec<std::path::PathBuf>> {
371 if !is_git_repo() {
373 return Ok(Vec::new());
374 }
375
376 let output = Command::new("git")
378 .args(["diff", "--name-only", "--diff-filter=A", base_commit])
379 .output()?;
380
381 if !output.status.success() {
383 return Ok(Vec::new());
384 }
385
386 let stdout = String::from_utf8_lossy(&output.stdout);
387 let files: Vec<std::path::PathBuf> = stdout
388 .lines()
389 .filter(|line| !line.is_empty())
390 .map(std::path::PathBuf::from)
391 .collect();
392
393 Ok(files)
394}
395
396#[derive(Debug, Clone, PartialEq)]
402pub struct CommitInfo {
403 pub short_hash: String,
405 pub full_hash: String,
407 pub message: String,
409 pub author: String,
411 pub date: String,
413}
414
415pub fn get_branch_commits(base_branch: &str) -> Result<Vec<CommitInfo>> {
428 let output = Command::new("git")
431 .args([
432 "log",
433 &format!("{}..HEAD", base_branch),
434 "--no-merges",
435 "--pretty=format:%H|%h|%s|%an|%ai",
436 ])
437 .output()?;
438
439 if !output.status.success() {
440 let stderr = String::from_utf8_lossy(&output.stderr);
441 return Err(Autom8Error::GitError(format!(
442 "Failed to get branch commits: {}",
443 stderr.trim()
444 )));
445 }
446
447 let stdout = String::from_utf8_lossy(&output.stdout);
448 let commits: Vec<CommitInfo> = stdout
449 .lines()
450 .filter(|line| !line.is_empty())
451 .filter_map(|line| {
452 let parts: Vec<&str> = line.splitn(5, '|').collect();
453 if parts.len() >= 5 {
454 Some(CommitInfo {
455 full_hash: parts[0].to_string(),
456 short_hash: parts[1].to_string(),
457 message: parts[2].to_string(),
458 author: parts[3].to_string(),
459 date: parts[4].to_string(),
460 })
461 } else {
462 None
463 }
464 })
465 .collect();
466
467 Ok(commits)
468}
469
470pub fn detect_base_branch() -> Result<String> {
478 if branch_exists("main")? {
480 return Ok("main".to_string());
481 }
482
483 if branch_exists("master")? {
485 return Ok("master".to_string());
486 }
487
488 let output = Command::new("git")
490 .args(["remote", "show", "origin"])
491 .output();
492
493 if let Ok(out) = output {
494 if out.status.success() {
495 let stdout = String::from_utf8_lossy(&out.stdout);
496 for line in stdout.lines() {
498 if line.contains("HEAD branch:") {
499 if let Some(branch) = line.split(':').nth(1) {
500 return Ok(branch.trim().to_string());
501 }
502 }
503 }
504 }
505 }
506
507 Ok("main".to_string())
509}
510
511pub fn get_current_branch_commits() -> Result<Vec<CommitInfo>> {
519 let base_branch = detect_base_branch()?;
520 get_branch_commits(&base_branch)
521}
522
523pub fn get_commit_diff(commit_hash: &str) -> Result<String> {
532 let output = Command::new("git")
533 .args(["show", "--format=", commit_hash])
534 .output()?;
535
536 if !output.status.success() {
537 let stderr = String::from_utf8_lossy(&output.stderr);
538 return Err(Autom8Error::GitError(format!(
539 "Failed to get commit diff: {}",
540 stderr.trim()
541 )));
542 }
543
544 Ok(String::from_utf8_lossy(&output.stdout).to_string())
545}
546
547#[derive(Debug, Clone, PartialEq)]
549pub enum PushResult {
550 Success,
552 AlreadyUpToDate,
554 Error(String),
556}
557
558#[derive(Debug, Clone, PartialEq)]
560pub enum CommitResult {
561 Success(String),
563 NothingToCommit,
565 Error(String),
567}
568
569pub fn has_uncommitted_changes() -> Result<bool> {
577 let output = Command::new("git")
578 .args(["status", "--porcelain"])
579 .output()?;
580
581 if !output.status.success() {
582 return Err(Autom8Error::GitError(
583 String::from_utf8_lossy(&output.stderr).to_string(),
584 ));
585 }
586
587 Ok(!output.stdout.is_empty())
589}
590
591pub fn stage_all_changes() -> Result<()> {
595 let output = Command::new("git").args(["add", "-A"]).output()?;
596
597 if !output.status.success() {
598 return Err(Autom8Error::GitError(format!(
599 "Failed to stage changes: {}",
600 String::from_utf8_lossy(&output.stderr)
601 )));
602 }
603
604 Ok(())
605}
606
607pub fn create_commit(message: &str) -> Result<CommitResult> {
617 let output = Command::new("git")
618 .args(["commit", "-m", message])
619 .output()?;
620
621 let stderr = String::from_utf8_lossy(&output.stderr);
622 let stdout = String::from_utf8_lossy(&output.stdout);
623
624 if output.status.success() {
625 let hash = latest_commit_short().unwrap_or_else(|_| "unknown".to_string());
627 return Ok(CommitResult::Success(hash));
628 }
629
630 let combined = format!("{} {}", stdout, stderr);
632 if combined.to_lowercase().contains("nothing to commit")
633 || combined.to_lowercase().contains("no changes added")
634 {
635 return Ok(CommitResult::NothingToCommit);
636 }
637
638 Ok(CommitResult::Error(stderr.trim().to_string()))
639}
640
641pub fn commit_and_push_pr_fixes(
657 pr_number: u32,
658 commit_enabled: bool,
659 push_enabled: bool,
660) -> Result<(Option<CommitResult>, Option<PushResult>)> {
661 if !commit_enabled {
663 return Ok((None, None));
664 }
665
666 if !has_uncommitted_changes()? {
668 return Ok((Some(CommitResult::NothingToCommit), None));
669 }
670
671 stage_all_changes()?;
673
674 let commit_message = format!(
676 "fix: address PR #{} review feedback\n\nApply fixes based on PR review comments.",
677 pr_number
678 );
679 let commit_result = create_commit(&commit_message)?;
680
681 let push_result = match (&commit_result, push_enabled) {
683 (CommitResult::Success(_), true) => {
684 let branch = current_branch()?;
685 Some(push_branch(&branch)?)
686 }
687 _ => None,
688 };
689
690 Ok((Some(commit_result), push_result))
691}
692
693pub fn push_branch(branch: &str) -> Result<PushResult> {
707 let output = Command::new("git")
708 .args(["push", "--set-upstream", "origin", branch])
709 .output()?;
710
711 let stderr = String::from_utf8_lossy(&output.stderr);
712 let stdout = String::from_utf8_lossy(&output.stdout);
713
714 if output.status.success() {
715 if stderr.contains("Everything up-to-date") {
717 return Ok(PushResult::AlreadyUpToDate);
718 }
719 return Ok(PushResult::Success);
720 }
721
722 let error_msg = if stderr.is_empty() {
724 stdout.trim().to_string()
725 } else {
726 stderr.trim().to_string()
727 };
728
729 if error_msg.contains("non-fast-forward")
731 || error_msg.contains("rejected")
732 || error_msg.contains("failed to push")
733 {
734 let force_output = Command::new("git")
736 .args([
737 "push",
738 "--force-with-lease",
739 "--set-upstream",
740 "origin",
741 branch,
742 ])
743 .output()?;
744
745 if force_output.status.success() {
746 return Ok(PushResult::Success);
747 }
748
749 let force_stderr = String::from_utf8_lossy(&force_output.stderr);
750 return Ok(PushResult::Error(format!(
751 "Failed to push branch (even with --force-with-lease): {}",
752 force_stderr.trim()
753 )));
754 }
755
756 Ok(PushResult::Error(error_msg))
757}
758
759pub fn get_merge_base(base_branch: &str) -> Result<String> {
775 let output = Command::new("git")
776 .args(["merge-base", base_branch, "HEAD"])
777 .output()?;
778
779 if !output.status.success() {
780 let stderr = String::from_utf8_lossy(&output.stderr);
781 return Err(Autom8Error::GitError(format!(
782 "Failed to find merge-base with '{}': {}",
783 base_branch,
784 stderr.trim()
785 )));
786 }
787
788 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
789}
790
791pub fn get_merge_base_auto() -> Result<String> {
799 let base_branch = detect_base_branch()?;
800 get_merge_base(&base_branch)
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806
807 #[test]
812 fn test_diff_entry_from_numstat_line_basic() {
813 let line = "10\t5\tsrc/lib.rs";
814 let entry = DiffEntry::from_numstat_line(line);
815
816 assert!(entry.is_some());
817 let entry = entry.unwrap();
818 assert_eq!(entry.path, std::path::PathBuf::from("src/lib.rs"));
819 assert_eq!(entry.additions, 10);
820 assert_eq!(entry.deletions, 5);
821 }
822
823 #[test]
824 fn test_diff_entry_from_numstat_line_binary_file() {
825 let line = "-\t-\timage.png";
827 let entry = DiffEntry::from_numstat_line(line).unwrap();
828
829 assert_eq!(entry.path, std::path::PathBuf::from("image.png"));
830 assert_eq!(entry.additions, 0);
831 assert_eq!(entry.deletions, 0);
832 }
833
834 #[test]
835 fn test_diff_entry_from_numstat_line_path_with_spaces() {
836 let line = "5\t3\tpath/to/my file.rs";
837 let entry = DiffEntry::from_numstat_line(line).unwrap();
838
839 assert_eq!(entry.path, std::path::PathBuf::from("path/to/my file.rs"));
840 }
841
842 #[test]
843 fn test_diff_entry_from_numstat_line_invalid() {
844 assert!(DiffEntry::from_numstat_line("10\t5").is_none());
845 assert!(DiffEntry::from_numstat_line("").is_none());
846 }
847
848 #[test]
849 fn test_diff_entry_parse_name_status_variants() {
850 let (path, status) = DiffEntry::parse_name_status_line("A\tsrc/new_file.rs").unwrap();
852 assert_eq!(path, std::path::PathBuf::from("src/new_file.rs"));
853 assert_eq!(status, DiffStatus::Added);
854
855 let (path, status) = DiffEntry::parse_name_status_line("M\tsrc/changed.rs").unwrap();
857 assert_eq!(path, std::path::PathBuf::from("src/changed.rs"));
858 assert_eq!(status, DiffStatus::Modified);
859
860 let (path, status) = DiffEntry::parse_name_status_line("D\tsrc/removed.rs").unwrap();
862 assert_eq!(path, std::path::PathBuf::from("src/removed.rs"));
863 assert_eq!(status, DiffStatus::Deleted);
864
865 let (path, status) =
867 DiffEntry::parse_name_status_line("R100\told_name.rs\tnew_name.rs").unwrap();
868 assert_eq!(path, std::path::PathBuf::from("new_name.rs"));
869 assert_eq!(status, DiffStatus::Modified);
870
871 assert!(DiffEntry::parse_name_status_line("").is_none());
873 }
874
875 #[test]
880 fn test_commit_and_push_with_commit_disabled_returns_none() {
881 let result = commit_and_push_pr_fixes(123, false, false);
883 assert!(result.is_ok());
884 let (commit_result, push_result) = result.unwrap();
885 assert!(commit_result.is_none());
886 assert!(push_result.is_none());
887 }
888}