1use std::path::{Path, PathBuf};
41use std::process::Command;
42
43use crate::{EngineError, Result};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum PrStatus {
48 Open,
50 Merged,
52 Closed,
54}
55
56#[derive(Debug)]
61pub struct GitRepo {
62 workdir: PathBuf,
63}
64
65impl GitRepo {
66 pub fn new(workdir: impl Into<PathBuf>) -> Self {
80 Self {
81 workdir: workdir.into(),
82 }
83 }
84
85 pub fn workdir(&self) -> &Path {
87 &self.workdir
88 }
89
90 fn git_cmd(&self) -> Command {
97 let mut cmd = Command::new("git");
98 cmd.current_dir(&self.workdir);
99 cmd.env_remove("GIT_DIR");
102 cmd.env_remove("GIT_WORK_TREE");
103 cmd.env_remove("GIT_INDEX_FILE");
104 cmd.env_remove("GIT_OBJECT_DIRECTORY");
105 cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES");
106 if let Some(parent) = self.workdir.parent() {
108 cmd.env("GIT_CEILING_DIRECTORIES", parent);
109 }
110 cmd
111 }
112
113 pub fn current_branch(&self) -> Result<String> {
132 let output = self
133 .git_cmd()
134 .args(["rev-parse", "--abbrev-ref", "HEAD"])
135 .output()
136 .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
137
138 if !output.status.success() {
139 let stderr = String::from_utf8_lossy(&output.stderr);
140 return Err(EngineError::git_error(format!(
141 "failed to get current branch: {stderr}"
142 )));
143 }
144
145 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
146 }
147
148 pub fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
169 let flag = if force { "-D" } else { "-d" };
170 let output = self
171 .git_cmd()
172 .args(["branch", flag, name])
173 .output()
174 .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
175
176 if !output.status.success() {
177 let stderr = String::from_utf8_lossy(&output.stderr);
178 return Err(EngineError::git_error(format!(
179 "failed to delete branch '{name}': {stderr}"
180 )));
181 }
182
183 Ok(())
184 }
185
186 pub fn detect_default_branch(&self) -> String {
201 let output = self.git_cmd().args(["remote", "show", "origin"]).output();
203
204 if let Ok(output) = output
205 && output.status.success()
206 {
207 let stdout = String::from_utf8_lossy(&output.stdout);
208 for line in stdout.lines() {
209 if line.contains("HEAD branch:")
210 && let Some(branch) = line.split(':').nth(1)
211 {
212 return branch.trim().to_string();
213 }
214 }
215 }
216
217 let branches = self
219 .git_cmd()
220 .args(["branch", "--list", "main", "master"])
221 .output();
222
223 if let Ok(output) = branches {
224 let stdout = String::from_utf8_lossy(&output.stdout);
225 if stdout.contains("main") {
226 return "main".to_string();
227 }
228 if stdout.contains("master") {
229 return "master".to_string();
230 }
231 }
232
233 "main".to_string()
235 }
236
237 pub fn create_worktree(&self, path: &Path, branch: &str) -> Result<()> {
261 let output = self
262 .git_cmd()
263 .args(["worktree", "add", "-b", branch])
264 .arg(path)
265 .output()
266 .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
267
268 if !output.status.success() {
269 let stderr = String::from_utf8_lossy(&output.stderr);
270 return Err(EngineError::git_error(format!(
271 "failed to create worktree at '{}': {stderr}",
272 path.display()
273 )));
274 }
275
276 Ok(())
277 }
278
279 pub fn remove_worktree(&self, path: &str, force: bool) -> Result<()> {
300 let mut args = vec!["worktree", "remove"];
301 if force {
302 args.push("--force");
303 }
304 args.push(path);
305
306 let output = self
307 .git_cmd()
308 .args(&args)
309 .output()
310 .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
311
312 if !output.status.success() {
313 let stderr = String::from_utf8_lossy(&output.stderr);
314 return Err(EngineError::git_error(format!(
315 "failed to remove worktree '{path}': {stderr}"
316 )));
317 }
318
319 Ok(())
320 }
321
322 pub fn head_short_sha(&self) -> Result<Option<String>> {
345 let output = self
346 .git_cmd()
347 .args(["rev-parse", "--short", "HEAD"])
348 .output()
349 .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
350
351 if !output.status.success() {
352 let stderr = String::from_utf8_lossy(&output.stderr);
353 if stderr.contains("unknown revision")
355 || stderr.contains("bad revision")
356 || stderr.contains("Needed a single revision")
357 {
358 return Ok(None);
359 }
360 return Err(EngineError::git_error(format!(
361 "failed to get HEAD SHA: {stderr}"
362 )));
363 }
364
365 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
366 if sha.is_empty() {
367 Ok(None)
368 } else {
369 Ok(Some(sha))
370 }
371 }
372
373 pub fn add(&self, path: &str) -> Result<()> {
393 let output = self
394 .git_cmd()
395 .args(["add", path])
396 .output()
397 .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
398
399 if !output.status.success() {
400 let stderr = String::from_utf8_lossy(&output.stderr);
401 return Err(EngineError::git_error(format!(
402 "failed to stage '{path}': {stderr}"
403 )));
404 }
405
406 Ok(())
407 }
408
409 pub fn commit(&self, message: &str) -> Result<()> {
430 let output = self
431 .git_cmd()
432 .args(["commit", "-m", message])
433 .output()
434 .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
435
436 if !output.status.success() {
437 let stderr = String::from_utf8_lossy(&output.stderr);
438 return Err(EngineError::git_error(format!(
439 "failed to commit: {stderr}"
440 )));
441 }
442
443 Ok(())
444 }
445
446 pub fn push(&self) -> Result<()> {
462 let output = self
463 .git_cmd()
464 .args(["push"])
465 .output()
466 .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
467
468 if !output.status.success() {
469 let stderr = String::from_utf8_lossy(&output.stderr);
470 return Err(EngineError::git_error(format!("failed to push: {stderr}")));
471 }
472
473 Ok(())
474 }
475}
476
477#[derive(Debug)]
481pub struct GitHub {
482 workdir: PathBuf,
483}
484
485impl GitHub {
486 pub fn new(workdir: impl Into<PathBuf>) -> Self {
500 Self {
501 workdir: workdir.into(),
502 }
503 }
504
505 pub fn pr_status(&self, branch: &str) -> Result<Option<PrStatus>> {
532 let output = Command::new("gh")
533 .current_dir(&self.workdir)
534 .args(["pr", "view", branch, "--json", "state", "-q", ".state"])
535 .output()
536 .map_err(|e| EngineError::github_error(format!("failed to execute gh: {e}")))?;
537
538 if !output.status.success() {
539 let stderr = String::from_utf8_lossy(&output.stderr);
540 if stderr.contains("no pull requests found")
542 || stderr.contains("Could not resolve")
543 || stderr.contains("no open pull requests")
544 {
545 return Ok(None);
546 }
547 return Err(EngineError::github_error(format!(
548 "failed to get PR status for '{branch}': {stderr}"
549 )));
550 }
551
552 let state = String::from_utf8_lossy(&output.stdout)
553 .trim()
554 .to_uppercase();
555
556 match state.as_str() {
557 "OPEN" => Ok(Some(PrStatus::Open)),
558 "MERGED" => Ok(Some(PrStatus::Merged)),
559 "CLOSED" => Ok(Some(PrStatus::Closed)),
560 _ => Ok(None),
561 }
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use std::fs;
568 use std::process::Command;
569
570 use tempfile::TempDir;
571
572 use super::*;
573
574 fn git_cmd(temp_dir: &TempDir) -> Command {
580 let mut cmd = Command::new("git");
581 cmd.current_dir(temp_dir.path());
582 cmd.env_remove("GIT_DIR");
585 cmd.env_remove("GIT_WORK_TREE");
586 cmd.env_remove("GIT_INDEX_FILE");
587 cmd.env_remove("GIT_OBJECT_DIRECTORY");
588 cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES");
589 if let Some(parent) = temp_dir.path().parent() {
591 cmd.env("GIT_CEILING_DIRECTORIES", parent);
592 }
593 cmd
594 }
595
596 fn create_test_repo() -> (TempDir, GitRepo) {
599 let temp_dir = TempDir::new().expect("failed to create temp dir");
600
601 git_cmd(&temp_dir)
603 .args(["init"])
604 .output()
605 .expect("failed to init git repo");
606
607 git_cmd(&temp_dir)
609 .args(["config", "user.email", "test@example.com"])
610 .output()
611 .expect("failed to set user email");
612
613 git_cmd(&temp_dir)
614 .args(["config", "user.name", "Test User"])
615 .output()
616 .expect("failed to set user name");
617
618 git_cmd(&temp_dir)
620 .args(["config", "core.hooksPath", "/dev/null"])
621 .output()
622 .expect("failed to disable hooks");
623
624 let repo = GitRepo::new(temp_dir.path());
625 (temp_dir, repo)
626 }
627
628 fn create_test_repo_with_commit() -> (TempDir, GitRepo) {
630 let (temp_dir, repo) = create_test_repo();
631
632 let file_path = temp_dir.path().join("README.md");
634 fs::write(&file_path, "# Test Repository").expect("failed to write file");
635
636 repo.add(".").expect("failed to stage file");
637 repo.commit("Initial commit").expect("failed to commit");
638
639 (temp_dir, repo)
640 }
641
642 #[test]
645 fn test_should_create_git_repo_with_path() {
646 let repo = GitRepo::new("/path/to/repo");
647 assert_eq!(repo.workdir(), Path::new("/path/to/repo"));
648 }
649
650 #[test]
651 fn test_should_create_git_repo_from_pathbuf() {
652 let path = PathBuf::from("/another/path");
653 let repo = GitRepo::new(path);
654 assert_eq!(repo.workdir(), Path::new("/another/path"));
655 }
656
657 #[test]
660 fn test_should_get_current_branch_on_main() {
661 let (_temp_dir, repo) = create_test_repo_with_commit();
662
663 let branch = repo.current_branch().expect("failed to get current branch");
665 assert!(
666 branch == "main" || branch == "master",
667 "Expected 'main' or 'master', got '{branch}'"
668 );
669 }
670
671 #[test]
672 fn test_should_fail_get_branch_in_non_git_directory() {
673 let temp_dir = TempDir::new().expect("failed to create temp dir");
674 let repo = GitRepo::new(temp_dir.path());
675
676 let result = repo.current_branch();
677 assert!(result.is_err());
678 }
679
680 #[test]
681 fn test_should_delete_branch() {
682 let (temp_dir, repo) = create_test_repo_with_commit();
683
684 git_cmd(&temp_dir)
686 .args(["branch", "feature/test-branch"])
687 .output()
688 .expect("failed to create branch");
689
690 let result = repo.delete_branch("feature/test-branch", false);
692 assert!(result.is_ok());
693
694 let output = git_cmd(&temp_dir)
696 .args(["branch", "--list", "feature/test-branch"])
697 .output()
698 .expect("failed to list branches");
699 let stdout = String::from_utf8_lossy(&output.stdout);
700 assert!(!stdout.contains("feature/test-branch"));
701 }
702
703 #[test]
704 fn test_should_force_delete_unmerged_branch() {
705 let (temp_dir, repo) = create_test_repo_with_commit();
706
707 git_cmd(&temp_dir)
709 .args(["checkout", "-b", "feature/unmerged"])
710 .output()
711 .expect("failed to create branch");
712
713 let file_path = temp_dir.path().join("new_file.txt");
715 fs::write(&file_path, "content").expect("failed to write file");
716 repo.add("new_file.txt").expect("failed to stage file");
717 repo.commit("Unmerged commit").expect("failed to commit");
718
719 let main_branch = repo.detect_default_branch();
721 git_cmd(&temp_dir)
722 .args(["checkout", &main_branch])
723 .output()
724 .expect("failed to checkout main");
725
726 let result = repo.delete_branch("feature/unmerged", false);
728 assert!(result.is_err());
729
730 let result = repo.delete_branch("feature/unmerged", true);
732 assert!(result.is_ok());
733 }
734
735 #[test]
736 fn test_should_fail_delete_nonexistent_branch() {
737 let (_temp_dir, repo) = create_test_repo_with_commit();
738
739 let result = repo.delete_branch("nonexistent-branch", false);
740 assert!(result.is_err());
741 }
742
743 #[test]
744 fn test_should_detect_default_branch_as_main_or_master() {
745 let (_temp_dir, repo) = create_test_repo_with_commit();
746
747 let default_branch = repo.detect_default_branch();
748 assert!(
749 default_branch == "main" || default_branch == "master",
750 "Expected 'main' or 'master', got '{default_branch}'"
751 );
752 }
753
754 #[test]
757 fn test_should_create_and_remove_worktree() {
758 let (temp_dir, repo) = create_test_repo_with_commit();
759
760 let worktree_path = temp_dir.path().join("worktree-test");
762 let result = repo.create_worktree(&worktree_path, "feature/worktree-test");
763 assert!(result.is_ok(), "Failed to create worktree: {result:?}");
764
765 assert!(worktree_path.exists());
767 assert!(worktree_path.join(".git").exists());
768
769 let result = repo.remove_worktree(worktree_path.to_str().unwrap(), false);
771 assert!(result.is_ok(), "Failed to remove worktree: {result:?}");
772 }
773
774 #[test]
775 fn test_should_fail_create_worktree_with_existing_branch() {
776 let (temp_dir, repo) = create_test_repo_with_commit();
777
778 git_cmd(&temp_dir)
780 .args(["branch", "existing-branch"])
781 .output()
782 .expect("failed to create branch");
783
784 let worktree_path = temp_dir.path().join("worktree-fail");
786 let result = repo.create_worktree(&worktree_path, "existing-branch");
787 assert!(result.is_err());
788 }
789
790 #[test]
791 fn test_should_force_remove_worktree_with_changes() {
792 let (temp_dir, repo) = create_test_repo_with_commit();
793
794 let worktree_path = temp_dir.path().join("worktree-dirty");
796 repo.create_worktree(&worktree_path, "feature/dirty-worktree")
797 .expect("failed to create worktree");
798
799 let file_path = worktree_path.join("dirty-file.txt");
801 fs::write(&file_path, "uncommitted content").expect("failed to write file");
802
803 let mut cmd = Command::new("git");
805 cmd.current_dir(&worktree_path);
806 if let Some(parent) = worktree_path.parent() {
807 cmd.env("GIT_CEILING_DIRECTORIES", parent);
808 }
809 cmd.args(["add", "dirty-file.txt"])
810 .output()
811 .expect("failed to stage file");
812
813 let result = repo.remove_worktree(worktree_path.to_str().unwrap(), false);
815 assert!(result.is_err());
816
817 let result = repo.remove_worktree(worktree_path.to_str().unwrap(), true);
819 assert!(result.is_ok());
820 }
821
822 #[test]
825 fn test_should_return_none_for_head_sha_in_empty_repo() {
826 let (_temp_dir, repo) = create_test_repo();
827
828 let result = repo.head_short_sha().expect("unexpected error");
829 assert!(result.is_none());
830 }
831
832 #[test]
833 fn test_should_return_sha_after_commit() {
834 let (_temp_dir, repo) = create_test_repo_with_commit();
835
836 let sha = repo
837 .head_short_sha()
838 .expect("failed to get sha")
839 .expect("expected some sha");
840 assert!(!sha.is_empty());
841 assert!(sha.len() >= 7);
843 }
844
845 #[test]
846 fn test_should_stage_and_commit_file() {
847 let (temp_dir, repo) = create_test_repo_with_commit();
848
849 let file_path = temp_dir.path().join("new_feature.rs");
851 fs::write(&file_path, "fn main() {}").expect("failed to write file");
852
853 repo.add("new_feature.rs").expect("failed to stage");
855 repo.commit("Add new feature").expect("failed to commit");
856
857 let output = git_cmd(&temp_dir)
859 .args(["log", "--oneline", "-1"])
860 .output()
861 .expect("failed to run git log");
862 let log = String::from_utf8_lossy(&output.stdout);
863 assert!(log.contains("Add new feature"));
864 }
865
866 #[test]
867 fn test_should_stage_multiple_files_with_pattern() {
868 let (temp_dir, repo) = create_test_repo_with_commit();
869
870 fs::write(temp_dir.path().join("file1.txt"), "content1").expect("failed to write file1");
872 fs::write(temp_dir.path().join("file2.txt"), "content2").expect("failed to write file2");
873
874 repo.add(".").expect("failed to stage");
876 repo.commit("Add multiple files").expect("failed to commit");
877
878 let output = git_cmd(&temp_dir)
880 .args(["ls-files"])
881 .output()
882 .expect("failed to list files");
883 let files = String::from_utf8_lossy(&output.stdout);
884 assert!(files.contains("file1.txt"));
885 assert!(files.contains("file2.txt"));
886 }
887
888 #[test]
889 fn test_should_fail_commit_with_nothing_staged() {
890 let (_temp_dir, repo) = create_test_repo_with_commit();
891
892 let result = repo.commit("Empty commit");
894 assert!(result.is_err());
895 }
896
897 #[test]
898 fn test_should_fail_add_nonexistent_file() {
899 let (_temp_dir, repo) = create_test_repo_with_commit();
900
901 let result = repo.add("nonexistent-file.txt");
903 assert!(result.is_err());
904 }
905
906 #[test]
909 fn test_should_fail_operations_on_non_git_directory() {
910 let temp_dir = TempDir::new().expect("failed to create temp dir");
911 let repo = GitRepo::new(temp_dir.path());
912
913 assert!(repo.current_branch().is_err());
915 assert!(repo.head_short_sha().is_err());
916 assert!(repo.add(".").is_err());
917 assert!(repo.commit("test").is_err());
918 }
919}