1use std::path::Path;
4
5use git2::{BranchType, Oid, RepositoryState, Signature};
6
7use crate::error::{Error, Result};
8use crate::traits::GitOps;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ConflictPrediction {
16 pub commit: Oid,
18 pub commit_summary: String,
20 pub conflicting_files: Vec<String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum RemoteDivergence {
30 InSync,
32 Ahead {
34 commits: usize,
36 },
37 Behind {
39 commits: usize,
41 },
42 Diverged {
44 ahead: usize,
46 behind: usize,
48 },
49 NoRemote,
51}
52
53pub struct Repository {
55 inner: git2::Repository,
56}
57
58impl Repository {
59 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
64 let inner = git2::Repository::discover(path)?;
65 Ok(Self { inner })
66 }
67
68 pub fn open_current() -> Result<Self> {
73 Self::open(".")
74 }
75
76 #[must_use]
78 pub fn workdir(&self) -> Option<&Path> {
79 self.inner.workdir()
80 }
81
82 #[must_use]
84 pub fn git_dir(&self) -> &Path {
85 self.inner.path()
86 }
87
88 #[must_use]
90 pub fn state(&self) -> RepositoryState {
91 self.inner.state()
92 }
93
94 #[must_use]
96 pub fn is_rebasing(&self) -> bool {
97 matches!(
98 self.state(),
99 RepositoryState::Rebase
100 | RepositoryState::RebaseInteractive
101 | RepositoryState::RebaseMerge
102 )
103 }
104
105 pub fn head_detached(&self) -> Result<bool> {
110 let head = self.inner.head()?;
111 Ok(!head.is_branch())
112 }
113
114 pub fn current_branch(&self) -> Result<String> {
121 let head = self.inner.head()?;
122 if !head.is_branch() {
123 return Err(Error::DetachedHead);
124 }
125
126 head.shorthand()
127 .map(String::from)
128 .ok_or(Error::DetachedHead)
129 }
130
131 pub fn branch_commit(&self, branch_name: &str) -> Result<Oid> {
136 let branch = self
137 .inner
138 .find_branch(branch_name, BranchType::Local)
139 .map_err(|_| Error::BranchNotFound(branch_name.into()))?;
140
141 branch
142 .get()
143 .target()
144 .ok_or_else(|| Error::BranchNotFound(branch_name.into()))
145 }
146
147 pub fn remote_branch_commit(&self, branch_name: &str) -> Result<Oid> {
154 let remote_ref = self
156 .branch_upstream_ref(branch_name)
157 .unwrap_or_else(|| format!("refs/remotes/origin/{branch_name}"));
158
159 let reference = self
160 .inner
161 .find_reference(&remote_ref)
162 .map_err(|_| Error::BranchNotFound(remote_ref.clone()))?;
163
164 reference.target().ok_or(Error::BranchNotFound(remote_ref))
165 }
166
167 fn branch_upstream_ref(&self, branch_name: &str) -> Option<String> {
173 let refname = format!("refs/heads/{branch_name}");
174 let upstream_buf = self.inner.branch_upstream_name(&refname).ok()?;
175 upstream_buf.as_str().map(String::from)
176 }
177
178 pub fn create_branch(&self, name: &str) -> Result<Oid> {
183 let head_commit = self.inner.head()?.peel_to_commit()?;
184 let branch = self.inner.branch(name, &head_commit, false)?;
185
186 branch
187 .get()
188 .target()
189 .ok_or_else(|| Error::BranchNotFound(name.into()))
190 }
191
192 pub fn checkout(&self, branch_name: &str) -> Result<()> {
197 let branch = self
198 .inner
199 .find_branch(branch_name, BranchType::Local)
200 .map_err(|_| Error::BranchNotFound(branch_name.into()))?;
201
202 let reference = branch.get();
203 let object = reference.peel(git2::ObjectType::Commit)?;
204
205 self.inner.checkout_tree(&object, None)?;
206 self.inner.set_head(&format!("refs/heads/{branch_name}"))?;
207
208 Ok(())
209 }
210
211 pub fn list_branches(&self) -> Result<Vec<String>> {
216 let branches = self.inner.branches(Some(BranchType::Local))?;
217
218 let names: Vec<String> = branches
219 .filter_map(std::result::Result::ok)
220 .filter_map(|(b, _)| b.name().ok().flatten().map(String::from))
221 .collect();
222
223 Ok(names)
224 }
225
226 #[must_use]
228 pub fn branch_exists(&self, name: &str) -> bool {
229 self.inner.find_branch(name, BranchType::Local).is_ok()
230 }
231
232 pub fn delete_branch(&self, name: &str) -> Result<()> {
237 let mut branch = self.inner.find_branch(name, BranchType::Local)?;
238 branch.delete()?;
239 Ok(())
240 }
241
242 pub fn is_clean(&self) -> Result<bool> {
252 let mut opts = git2::StatusOptions::new();
253 opts.include_untracked(false)
254 .include_ignored(false)
255 .include_unmodified(false)
256 .exclude_submodules(true);
257 let statuses = self.inner.statuses(Some(&mut opts))?;
258
259 for entry in statuses.iter() {
261 let status = entry.status();
262 if status.intersects(
264 git2::Status::INDEX_NEW
265 | git2::Status::INDEX_MODIFIED
266 | git2::Status::INDEX_DELETED
267 | git2::Status::INDEX_RENAMED
268 | git2::Status::INDEX_TYPECHANGE
269 | git2::Status::WT_MODIFIED
270 | git2::Status::WT_DELETED
271 | git2::Status::WT_TYPECHANGE
272 | git2::Status::WT_RENAMED,
273 ) {
274 return Ok(false);
275 }
276 }
277 Ok(true)
278 }
279
280 pub fn require_clean(&self) -> Result<()> {
285 if self.is_clean()? {
286 Ok(())
287 } else {
288 Err(Error::DirtyWorkingDirectory)
289 }
290 }
291
292 pub fn stage_all(&self) -> Result<()> {
301 let workdir = self.workdir().ok_or(Error::NotARepository)?;
302
303 let output = std::process::Command::new("git")
304 .args(["add", "-A"])
305 .current_dir(workdir)
306 .output()
307 .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
308
309 if output.status.success() {
310 Ok(())
311 } else {
312 let stderr = String::from_utf8_lossy(&output.stderr);
313 Err(Error::Git2(git2::Error::from_str(&stderr)))
314 }
315 }
316
317 pub fn has_staged_changes(&self) -> Result<bool> {
322 let mut opts = git2::StatusOptions::new();
323 opts.include_untracked(false)
324 .include_ignored(false)
325 .include_unmodified(false);
326 let statuses = self.inner.statuses(Some(&mut opts))?;
327
328 for entry in statuses.iter() {
329 let status = entry.status();
330 if status.intersects(
331 git2::Status::INDEX_NEW
332 | git2::Status::INDEX_MODIFIED
333 | git2::Status::INDEX_DELETED
334 | git2::Status::INDEX_RENAMED
335 | git2::Status::INDEX_TYPECHANGE,
336 ) {
337 return Ok(true);
338 }
339 }
340 Ok(false)
341 }
342
343 pub fn create_commit(&self, message: &str) -> Result<Oid> {
350 let sig = self.signature()?;
351 let mut index = self.inner.index()?;
352 index.read(false)?;
354 let tree_id = index.write_tree()?;
355 let tree = self.inner.find_tree(tree_id)?;
356
357 let oid = match self.inner.head().and_then(|h| h.peel_to_commit()) {
359 Ok(parent) => {
360 self.inner
361 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?
362 }
363 Err(_) => {
364 self.inner
366 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?
367 }
368 };
369
370 Ok(oid)
371 }
372
373 pub fn amend_commit(&self, new_message: Option<&str>) -> Result<Oid> {
381 let workdir = self.workdir().ok_or(Error::NotARepository)?;
382
383 let mut args = vec!["commit", "--amend"];
384
385 match new_message {
386 Some(msg) => args.extend(["-m", msg]),
387 None => args.push("--no-edit"),
388 }
389
390 let output = std::process::Command::new("git")
391 .args(&args)
392 .current_dir(workdir)
393 .output()
394 .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
395
396 if output.status.success() {
397 let head = self.inner.head()?;
399 Ok(head.peel_to_commit()?.id())
400 } else {
401 let stderr = String::from_utf8_lossy(&output.stderr);
402 Err(Error::Git2(git2::Error::from_str(&stderr)))
403 }
404 }
405
406 pub fn find_commit(&self, oid: Oid) -> Result<git2::Commit<'_>> {
413 Ok(self.inner.find_commit(oid)?)
414 }
415
416 pub fn branch_commit_message(&self, branch_name: &str) -> Result<String> {
421 let oid = self.branch_commit(branch_name)?;
422 let commit = self.inner.find_commit(oid)?;
423 commit
424 .message()
425 .map(String::from)
426 .ok_or_else(|| Error::Git2(git2::Error::from_str("commit has no message")))
427 }
428
429 pub fn merge_base(&self, one: Oid, two: Oid) -> Result<Oid> {
434 Ok(self.inner.merge_base(one, two)?)
435 }
436
437 pub fn count_commits_between(&self, from: Oid, to: Oid) -> Result<usize> {
442 let mut revwalk = self.inner.revwalk()?;
443 revwalk.push(to)?;
444 revwalk.hide(from)?;
445
446 Ok(revwalk.count())
447 }
448
449 pub fn commits_between(&self, from: Oid, to: Oid) -> Result<Vec<Oid>> {
454 let mut revwalk = self.inner.revwalk()?;
455 revwalk.push(to)?;
456 revwalk.hide(from)?;
457
458 let mut commits = Vec::new();
459 for oid in revwalk {
460 let oid = oid?;
461 commits.push(oid);
462 }
463
464 Ok(commits)
465 }
466
467 pub fn reset_branch(&self, branch_name: &str, target: Oid) -> Result<()> {
474 let commit = self.inner.find_commit(target)?;
475 let reference_name = format!("refs/heads/{branch_name}");
476
477 let target_str = target.to_string();
478 let short_sha = target_str.get(..8).unwrap_or(&target_str);
479 self.inner.reference(
480 &reference_name,
481 target,
482 true, &format!("rung: reset to {short_sha}"),
484 )?;
485
486 if self.current_branch().ok().as_deref() == Some(branch_name) {
488 self.inner
489 .reset(commit.as_object(), git2::ResetType::Hard, None)?;
490 }
491
492 Ok(())
493 }
494
495 pub fn signature(&self) -> Result<Signature<'_>> {
502 Ok(self.inner.signature()?)
503 }
504
505 pub fn rebase_onto(&self, target: Oid) -> Result<()> {
514 let workdir = self.workdir().ok_or(Error::NotARepository)?;
515
516 let output = std::process::Command::new("git")
517 .args(["rebase", &target.to_string()])
518 .current_dir(workdir)
519 .output()
520 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
521
522 if output.status.success() {
523 return Ok(());
524 }
525
526 if self.is_rebasing() {
528 let conflicts = self.conflicting_files()?;
529 return Err(Error::RebaseConflict(conflicts));
530 }
531
532 let stderr = String::from_utf8_lossy(&output.stderr);
533 Err(Error::RebaseFailed(stderr.to_string()))
534 }
535
536 pub fn rebase_onto_from(&self, new_base: Oid, old_base: Oid) -> Result<()> {
545 let workdir = self.workdir().ok_or(Error::NotARepository)?;
546
547 let output = std::process::Command::new("git")
548 .args([
549 "rebase",
550 "--onto",
551 &new_base.to_string(),
552 &old_base.to_string(),
553 ])
554 .current_dir(workdir)
555 .output()
556 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
557
558 if output.status.success() {
559 return Ok(());
560 }
561
562 if self.is_rebasing() {
564 let conflicts = self.conflicting_files()?;
565 return Err(Error::RebaseConflict(conflicts));
566 }
567
568 let stderr = String::from_utf8_lossy(&output.stderr);
569 Err(Error::RebaseFailed(stderr.to_string()))
570 }
571
572 pub fn conflicting_files(&self) -> Result<Vec<String>> {
577 let statuses = self.inner.statuses(None)?;
578 let conflicts: Vec<String> = statuses
579 .iter()
580 .filter(|s| s.status().is_conflicted())
581 .filter_map(|s| s.path().map(String::from))
582 .collect();
583 Ok(conflicts)
584 }
585
586 pub fn predict_rebase_conflicts(
603 &self,
604 branch: &str,
605 onto: Oid,
606 ) -> Result<Vec<ConflictPrediction>> {
607 let workdir = self.workdir().ok_or(Error::NotARepository)?;
608 let branch_commit = self.branch_commit(branch)?;
609 let merge_base = self.merge_base(branch_commit, onto)?;
610
611 let commits = self.commits_between(merge_base, branch_commit)?;
613
614 if commits.is_empty() {
615 return Ok(Vec::new());
616 }
617
618 let mut predictions = Vec::new();
619
620 let mut current_base = onto;
622
623 for commit_oid in commits.iter().rev() {
625 let commit = self.inner.find_commit(*commit_oid)?;
626
627 if commit.parent_count() != 1 {
629 continue;
630 }
631
632 let parent_oid = commit.parent_id(0)?;
633
634 let output = std::process::Command::new("git")
640 .args([
641 "merge-tree",
642 "--write-tree",
643 &format!("--merge-base={parent_oid}"),
644 ¤t_base.to_string(),
645 &commit_oid.to_string(),
646 ])
647 .current_dir(workdir)
648 .output()
649 .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
650
651 let stdout = String::from_utf8_lossy(&output.stdout);
653 let mut lines = stdout.lines();
654
655 let first_line = lines.next();
659 let result_tree = first_line.and_then(|line| Oid::from_str(line.trim()).ok());
660
661 let result_tree = result_tree.ok_or_else(|| {
663 Error::Git2(git2::Error::from_str(&format!(
664 "failed to parse merge-tree output for commit {}: expected tree OID on first line, got: {:?}",
665 commit_oid,
666 first_line.unwrap_or("<empty output>")
667 )))
668 })?;
669
670 if !output.status.success() {
672 let mut conflicting_files = Vec::new();
673
674 for line in lines {
677 if let Some(rest) = line.strip_prefix("CONFLICT") {
678 if let Some(idx) = rest.find(" in ") {
680 let filename = rest[idx + 4..].trim().to_string();
681 if !conflicting_files.contains(&filename) {
682 conflicting_files.push(filename);
683 }
684 }
685 }
686 }
687
688 if conflicting_files.is_empty() {
690 conflicting_files.push("<conflict detected>".to_string());
691 }
692
693 let summary = commit.summary().unwrap_or("").to_string();
694
695 predictions.push(ConflictPrediction {
696 commit: *commit_oid,
697 commit_summary: summary,
698 conflicting_files,
699 });
700 }
701
702 current_base = result_tree;
706 }
707
708 Ok(predictions)
709 }
710
711 pub fn rebase_abort(&self) -> Result<()> {
716 let workdir = self.workdir().ok_or(Error::NotARepository)?;
717
718 let output = std::process::Command::new("git")
719 .args(["rebase", "--abort"])
720 .current_dir(workdir)
721 .output()
722 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
723
724 if output.status.success() {
725 Ok(())
726 } else {
727 let stderr = String::from_utf8_lossy(&output.stderr);
728 Err(Error::RebaseFailed(stderr.to_string()))
729 }
730 }
731
732 pub fn rebase_continue(&self) -> Result<()> {
737 let workdir = self.workdir().ok_or(Error::NotARepository)?;
738
739 let output = std::process::Command::new("git")
740 .args(["rebase", "--continue"])
741 .current_dir(workdir)
742 .output()
743 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
744
745 if output.status.success() {
746 return Ok(());
747 }
748
749 if self.is_rebasing() {
751 let conflicts = self.conflicting_files()?;
752 return Err(Error::RebaseConflict(conflicts));
753 }
754
755 let stderr = String::from_utf8_lossy(&output.stderr);
756 Err(Error::RebaseFailed(stderr.to_string()))
757 }
758
759 pub fn remote_divergence(&self, branch: &str) -> Result<RemoteDivergence> {
772 let local = self.branch_commit(branch)?;
773
774 let remote = match self.remote_branch_commit(branch) {
776 Ok(oid) => oid,
777 Err(Error::BranchNotFound(_)) => return Ok(RemoteDivergence::NoRemote),
778 Err(e) => return Err(e),
779 };
780
781 if local == remote {
782 return Ok(RemoteDivergence::InSync);
783 }
784
785 let (ahead, behind) = match self.inner.graph_ahead_behind(local, remote) {
788 Ok(counts) => counts,
789 Err(e) if e.code() == git2::ErrorCode::NotFound => (0, 0),
790 Err(e) => return Err(Error::Git2(e)),
791 };
792
793 if ahead == 0 && behind == 0 {
795 return Ok(RemoteDivergence::Diverged {
796 ahead: self.count_all_commits(local)?,
797 behind: self.count_all_commits(remote)?,
798 });
799 }
800
801 Ok(match (ahead, behind) {
802 (a, 0) => RemoteDivergence::Ahead { commits: a },
803 (0, b) => RemoteDivergence::Behind { commits: b },
804 (a, b) => RemoteDivergence::Diverged {
805 ahead: a,
806 behind: b,
807 },
808 })
809 }
810
811 fn count_all_commits(&self, from: Oid) -> Result<usize> {
815 let mut revwalk = self.inner.revwalk()?;
816 revwalk.push(from)?;
817 Ok(revwalk.count())
818 }
819
820 pub fn origin_url(&self) -> Result<String> {
825 let remote = self
826 .inner
827 .find_remote("origin")
828 .map_err(|_| Error::RemoteNotFound("origin".into()))?;
829
830 remote
831 .url()
832 .map(String::from)
833 .ok_or_else(|| Error::RemoteNotFound("origin".into()))
834 }
835
836 #[must_use]
841 pub fn detect_default_branch(&self) -> Option<String> {
842 let reference = self.inner.find_reference("refs/remotes/origin/HEAD").ok()?;
844
845 let resolved = reference.resolve().ok()?;
847 let name = resolved.name()?;
848
849 name.strip_prefix("refs/remotes/origin/").map(String::from)
851 }
852
853 pub fn parse_github_remote(url: &str) -> Result<(String, String)> {
862 if let Some(rest) = url.strip_prefix("git@github.com:") {
864 let path = rest.strip_suffix(".git").unwrap_or(rest);
865 if let Some((owner, repo)) = path.split_once('/') {
866 return Ok((owner.to_string(), repo.to_string()));
867 }
868 }
869
870 if let Some(rest) = url
872 .strip_prefix("https://github.com/")
873 .or_else(|| url.strip_prefix("http://github.com/"))
874 {
875 let path = rest.strip_suffix(".git").unwrap_or(rest);
876 if let Some((owner, repo)) = path.split_once('/') {
877 return Ok((owner.to_string(), repo.to_string()));
878 }
879 }
880
881 Err(Error::InvalidRemoteUrl(url.to_string()))
882 }
883
884 pub fn push(&self, branch: &str, force: bool) -> Result<()> {
889 let workdir = self.workdir().ok_or(Error::NotARepository)?;
890
891 let mut args = vec!["push", "-u", "origin", branch];
892 if force {
893 args.insert(1, "--force-with-lease");
894 }
895
896 let output = std::process::Command::new("git")
897 .args(&args)
898 .current_dir(workdir)
899 .output()
900 .map_err(|e| Error::PushFailed(e.to_string()))?;
901
902 if output.status.success() {
903 Ok(())
904 } else {
905 let stderr = String::from_utf8_lossy(&output.stderr);
906 Err(Error::PushFailed(stderr.to_string()))
907 }
908 }
909
910 pub fn fetch_all(&self) -> Result<()> {
915 let workdir = self.workdir().ok_or(Error::NotARepository)?;
916
917 let output = std::process::Command::new("git")
918 .args(["fetch", "origin", "--prune"])
919 .current_dir(workdir)
920 .output()
921 .map_err(|e| Error::FetchFailed(e.to_string()))?;
922
923 if output.status.success() {
924 Ok(())
925 } else {
926 let stderr = String::from_utf8_lossy(&output.stderr);
927 Err(Error::FetchFailed(stderr.to_string()))
928 }
929 }
930
931 pub fn fetch(&self, branch: &str) -> Result<()> {
936 let workdir = self.workdir().ok_or(Error::NotARepository)?;
937
938 let refspec = format!("{branch}:refs/heads/{branch}");
941 let output = std::process::Command::new("git")
942 .args(["fetch", "origin", &refspec])
943 .current_dir(workdir)
944 .output()
945 .map_err(|e| Error::FetchFailed(e.to_string()))?;
946
947 if output.status.success() {
948 Ok(())
949 } else {
950 let stderr = String::from_utf8_lossy(&output.stderr);
951 Err(Error::FetchFailed(stderr.to_string()))
952 }
953 }
954
955 pub fn pull_ff(&self) -> Result<()> {
963 let workdir = self.workdir().ok_or(Error::NotARepository)?;
964
965 let output = std::process::Command::new("git")
966 .args(["pull", "--ff-only"])
967 .current_dir(workdir)
968 .output()
969 .map_err(|e| Error::FetchFailed(e.to_string()))?;
970
971 if output.status.success() {
972 Ok(())
973 } else {
974 let stderr = String::from_utf8_lossy(&output.stderr);
975 Err(Error::FetchFailed(stderr.to_string()))
976 }
977 }
978
979 #[must_use]
985 pub const fn inner(&self) -> &git2::Repository {
986 &self.inner
987 }
988}
989
990impl std::fmt::Debug for Repository {
991 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
992 f.debug_struct("Repository")
993 .field("path", &self.git_dir())
994 .finish()
995 }
996}
997
998impl GitOps for Repository {
1001 fn workdir(&self) -> Option<&Path> {
1002 Self::workdir(self)
1003 }
1004
1005 fn current_branch(&self) -> Result<String> {
1006 Self::current_branch(self)
1007 }
1008
1009 fn head_detached(&self) -> Result<bool> {
1010 Self::head_detached(self)
1011 }
1012
1013 fn is_rebasing(&self) -> bool {
1014 Self::is_rebasing(self)
1015 }
1016
1017 fn branch_exists(&self, name: &str) -> bool {
1018 Self::branch_exists(self, name)
1019 }
1020
1021 fn create_branch(&self, name: &str) -> Result<Oid> {
1022 Self::create_branch(self, name)
1023 }
1024
1025 fn checkout(&self, branch: &str) -> Result<()> {
1026 Self::checkout(self, branch)
1027 }
1028
1029 fn delete_branch(&self, name: &str) -> Result<()> {
1030 Self::delete_branch(self, name)
1031 }
1032
1033 fn list_branches(&self) -> Result<Vec<String>> {
1034 Self::list_branches(self)
1035 }
1036
1037 fn branch_commit(&self, branch: &str) -> Result<Oid> {
1038 Self::branch_commit(self, branch)
1039 }
1040
1041 fn remote_branch_commit(&self, branch: &str) -> Result<Oid> {
1042 Self::remote_branch_commit(self, branch)
1043 }
1044
1045 fn branch_commit_message(&self, branch: &str) -> Result<String> {
1046 Self::branch_commit_message(self, branch)
1047 }
1048
1049 fn merge_base(&self, one: Oid, two: Oid) -> Result<Oid> {
1050 Self::merge_base(self, one, two)
1051 }
1052
1053 fn commits_between(&self, from: Oid, to: Oid) -> Result<Vec<Oid>> {
1054 Self::commits_between(self, from, to)
1055 }
1056
1057 fn count_commits_between(&self, from: Oid, to: Oid) -> Result<usize> {
1058 Self::count_commits_between(self, from, to)
1059 }
1060
1061 fn is_clean(&self) -> Result<bool> {
1062 Self::is_clean(self)
1063 }
1064
1065 fn require_clean(&self) -> Result<()> {
1066 Self::require_clean(self)
1067 }
1068
1069 fn stage_all(&self) -> Result<()> {
1070 Self::stage_all(self)
1071 }
1072
1073 fn has_staged_changes(&self) -> Result<bool> {
1074 Self::has_staged_changes(self)
1075 }
1076
1077 fn create_commit(&self, message: &str) -> Result<Oid> {
1078 Self::create_commit(self, message)
1079 }
1080
1081 fn amend_commit(&self, new_message: Option<&str>) -> Result<Oid> {
1082 Self::amend_commit(self, new_message)
1083 }
1084
1085 fn rebase_onto(&self, target: Oid) -> Result<()> {
1086 Self::rebase_onto(self, target)
1087 }
1088
1089 fn rebase_onto_from(&self, onto: Oid, from: Oid) -> Result<()> {
1090 Self::rebase_onto_from(self, onto, from)
1091 }
1092
1093 fn conflicting_files(&self) -> Result<Vec<String>> {
1094 Self::conflicting_files(self)
1095 }
1096
1097 fn predict_rebase_conflicts(&self, branch: &str, onto: Oid) -> Result<Vec<ConflictPrediction>> {
1098 Self::predict_rebase_conflicts(self, branch, onto)
1099 }
1100
1101 fn rebase_abort(&self) -> Result<()> {
1102 Self::rebase_abort(self)
1103 }
1104
1105 fn rebase_continue(&self) -> Result<()> {
1106 Self::rebase_continue(self)
1107 }
1108
1109 fn origin_url(&self) -> Result<String> {
1110 Self::origin_url(self)
1111 }
1112
1113 fn remote_divergence(&self, branch: &str) -> Result<RemoteDivergence> {
1114 Self::remote_divergence(self, branch)
1115 }
1116
1117 fn detect_default_branch(&self) -> Option<String> {
1118 Self::detect_default_branch(self)
1119 }
1120
1121 fn push(&self, branch: &str, force: bool) -> Result<()> {
1122 Self::push(self, branch, force)
1123 }
1124
1125 fn fetch_all(&self) -> Result<()> {
1126 Self::fetch_all(self)
1127 }
1128
1129 fn fetch(&self, branch: &str) -> Result<()> {
1130 Self::fetch(self, branch)
1131 }
1132
1133 fn pull_ff(&self) -> Result<()> {
1134 Self::pull_ff(self)
1135 }
1136
1137 fn reset_branch(&self, branch: &str, commit: Oid) -> Result<()> {
1138 Self::reset_branch(self, branch, commit)
1139 }
1140}
1141
1142#[cfg(test)]
1143#[allow(clippy::unwrap_used)]
1144mod tests {
1145 use super::*;
1146 use std::fs;
1147 use tempfile::TempDir;
1148
1149 fn init_test_repo() -> (TempDir, Repository) {
1150 let temp = TempDir::new().unwrap();
1151 let repo = git2::Repository::init(temp.path()).unwrap();
1152
1153 let mut config = repo.config().unwrap();
1155 config.set_str("user.name", "Test").unwrap();
1156 config.set_str("user.email", "test@example.com").unwrap();
1157
1158 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1160 let tree_id = repo.index().unwrap().write_tree().unwrap();
1161 let tree = repo.find_tree(tree_id).unwrap();
1162 repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
1163 .unwrap();
1164 drop(tree);
1165
1166 let wrapped = Repository { inner: repo };
1167 (temp, wrapped)
1168 }
1169
1170 #[test]
1171 fn test_current_branch() {
1172 let (_temp, repo) = init_test_repo();
1173 let branch = repo.current_branch().unwrap();
1175 assert!(branch == "main" || branch == "master");
1176 }
1177
1178 #[test]
1179 fn test_create_and_checkout_branch() {
1180 let (_temp, repo) = init_test_repo();
1181
1182 repo.create_branch("feature/test").unwrap();
1183 assert!(repo.branch_exists("feature/test"));
1184
1185 repo.checkout("feature/test").unwrap();
1186 assert_eq!(repo.current_branch().unwrap(), "feature/test");
1187 }
1188
1189 #[test]
1190 fn test_is_clean() {
1191 let (temp, repo) = init_test_repo();
1192
1193 assert!(repo.is_clean().unwrap());
1194
1195 fs::write(temp.path().join("test.txt"), "initial").unwrap();
1197 {
1198 let mut index = repo.inner.index().unwrap();
1199 index.add_path(std::path::Path::new("test.txt")).unwrap();
1200 index.write().unwrap();
1201 let tree_id = index.write_tree().unwrap();
1202 let tree = repo.inner.find_tree(tree_id).unwrap();
1203 let parent = repo.inner.head().unwrap().peel_to_commit().unwrap();
1204 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1205 repo.inner
1206 .commit(Some("HEAD"), &sig, &sig, "Add test file", &tree, &[&parent])
1207 .unwrap();
1208 }
1209
1210 assert!(repo.is_clean().unwrap());
1212
1213 fs::write(temp.path().join("test.txt"), "modified").unwrap();
1215 assert!(!repo.is_clean().unwrap());
1216 }
1217
1218 #[test]
1219 fn test_list_branches() {
1220 let (_temp, repo) = init_test_repo();
1221
1222 repo.create_branch("feature/a").unwrap();
1223 repo.create_branch("feature/b").unwrap();
1224
1225 let branches = repo.list_branches().unwrap();
1226 assert!(branches.len() >= 3); assert!(branches.iter().any(|b| b == "feature/a"));
1228 assert!(branches.iter().any(|b| b == "feature/b"));
1229 }
1230
1231 #[test]
1232 fn test_amend_commit_preserves_message() {
1233 let (temp, repo) = init_test_repo();
1234
1235 fs::write(temp.path().join("test.txt"), "initial").unwrap();
1237 repo.stage_all().unwrap();
1238 repo.create_commit("Original message").unwrap();
1239
1240 let original_msg = repo
1241 .branch_commit_message(&repo.current_branch().unwrap())
1242 .unwrap();
1243 assert!(original_msg.starts_with("Original message"));
1244
1245 fs::write(temp.path().join("test.txt"), "modified").unwrap();
1247 repo.stage_all().unwrap();
1248 repo.amend_commit(None).unwrap();
1249
1250 let amended_msg = repo
1252 .branch_commit_message(&repo.current_branch().unwrap())
1253 .unwrap();
1254 assert!(amended_msg.starts_with("Original message"));
1255 }
1256
1257 #[test]
1258 fn test_amend_commit_with_new_message() {
1259 let (temp, repo) = init_test_repo();
1260
1261 fs::write(temp.path().join("test.txt"), "initial").unwrap();
1263 repo.stage_all().unwrap();
1264 repo.create_commit("Original message").unwrap();
1265
1266 fs::write(temp.path().join("test.txt"), "modified").unwrap();
1268 repo.stage_all().unwrap();
1269 repo.amend_commit(Some("Updated message")).unwrap();
1270
1271 let amended_msg = repo
1273 .branch_commit_message(&repo.current_branch().unwrap())
1274 .unwrap();
1275 assert!(amended_msg.starts_with("Updated message"));
1276 }
1277
1278 #[test]
1279 fn test_amend_commit_includes_staged_changes() {
1280 let (temp, repo) = init_test_repo();
1281
1282 fs::write(temp.path().join("test.txt"), "initial").unwrap();
1284 repo.stage_all().unwrap();
1285 let first_commit = repo.create_commit("First commit").unwrap();
1286
1287 fs::write(temp.path().join("test.txt"), "modified").unwrap();
1289 repo.stage_all().unwrap();
1290 let amended_commit = repo.amend_commit(None).unwrap();
1291
1292 assert_ne!(first_commit, amended_commit);
1294
1295 assert!(repo.is_clean().unwrap());
1297 }
1298
1299 fn create_commit_with_file(
1303 temp: &TempDir,
1304 repo: &Repository,
1305 filename: &str,
1306 content: &str,
1307 message: &str,
1308 ) -> Oid {
1309 fs::write(temp.path().join(filename), content).unwrap();
1310 repo.stage_all().unwrap();
1311 repo.create_commit(message).unwrap()
1312 }
1313
1314 fn force_checkout(repo: &Repository, branch_name: &str) {
1316 let branch = repo
1317 .inner
1318 .find_branch(branch_name, BranchType::Local)
1319 .unwrap();
1320 let reference = branch.get();
1321 let object = reference.peel(git2::ObjectType::Commit).unwrap();
1322
1323 let mut checkout_opts = git2::build::CheckoutBuilder::new();
1324 checkout_opts.force();
1325
1326 repo.inner
1327 .checkout_tree(&object, Some(&mut checkout_opts))
1328 .unwrap();
1329 repo.inner
1330 .set_head(&format!("refs/heads/{branch_name}"))
1331 .unwrap();
1332 }
1333
1334 #[test]
1335 fn test_predict_rebase_conflicts_no_conflicts() {
1336 let (temp, repo) = init_test_repo();
1337 let main_branch = repo.current_branch().unwrap();
1338
1339 create_commit_with_file(&temp, &repo, "file.txt", "main content", "Main commit");
1341
1342 repo.create_branch("feature").unwrap();
1344 repo.checkout("feature").unwrap();
1345
1346 create_commit_with_file(
1348 &temp,
1349 &repo,
1350 "feature.txt",
1351 "feature content",
1352 "Feature commit",
1353 );
1354
1355 repo.checkout(&main_branch).unwrap();
1357 let main_tip = repo.branch_commit(&main_branch).unwrap();
1358
1359 let predictions = repo.predict_rebase_conflicts("feature", main_tip).unwrap();
1361 assert!(predictions.is_empty(), "Expected no conflicts");
1362 }
1363
1364 #[test]
1365 fn test_predict_rebase_conflicts_with_conflict() {
1366 let (temp, repo) = init_test_repo();
1367 let main_branch = repo.current_branch().unwrap();
1368
1369 create_commit_with_file(&temp, &repo, "shared.txt", "original\n", "Initial shared");
1371
1372 let base_commit = repo.branch_commit(&main_branch).unwrap();
1374
1375 repo.create_branch("feature").unwrap();
1377 repo.checkout("feature").unwrap();
1378
1379 let feature_commit = create_commit_with_file(
1381 &temp,
1382 &repo,
1383 "shared.txt",
1384 "feature modification\n",
1385 "Feature changes shared",
1386 );
1387
1388 force_checkout(&repo, &main_branch);
1391
1392 let main_tip = create_commit_with_file(
1393 &temp,
1394 &repo,
1395 "shared.txt",
1396 "main modification\n",
1397 "Main changes shared",
1398 );
1399
1400 let merge_base = repo.merge_base(feature_commit, main_tip).unwrap();
1402 assert_eq!(
1403 merge_base, base_commit,
1404 "Merge base should be the original shared commit"
1405 );
1406
1407 let commits = repo.commits_between(merge_base, feature_commit).unwrap();
1408 assert_eq!(commits.len(), 1, "Should have 1 commit to replay");
1409
1410 let predictions = repo.predict_rebase_conflicts("feature", main_tip).unwrap();
1412 assert!(
1413 !predictions.is_empty(),
1414 "Expected conflicts, got {predictions:?}"
1415 );
1416 assert!(
1417 predictions[0]
1418 .conflicting_files
1419 .iter()
1420 .any(|f| f == "shared.txt"),
1421 "Expected shared.txt to conflict"
1422 );
1423 }
1424
1425 #[test]
1426 fn test_predict_rebase_conflicts_multiple_commits() {
1427 let (temp, repo) = init_test_repo();
1428 let main_branch = repo.current_branch().unwrap();
1429
1430 create_commit_with_file(
1432 &temp,
1433 &repo,
1434 "file.txt",
1435 "line 1\nline 2\nline 3\n",
1436 "Initial",
1437 );
1438
1439 let base_commit = repo.branch_commit(&main_branch).unwrap();
1440
1441 repo.create_branch("feature").unwrap();
1443 repo.checkout("feature").unwrap();
1444
1445 create_commit_with_file(
1447 &temp,
1448 &repo,
1449 "file.txt",
1450 "line 1 modified\nline 2\nline 3\n",
1451 "Commit 1",
1452 );
1453 create_commit_with_file(
1454 &temp,
1455 &repo,
1456 "other.txt",
1457 "other content\n",
1458 "Commit 2 - different file",
1459 );
1460
1461 force_checkout(&repo, &main_branch);
1464 repo.reset_branch(&main_branch, base_commit).unwrap();
1465 force_checkout(&repo, &main_branch);
1466
1467 create_commit_with_file(
1468 &temp,
1469 &repo,
1470 "file.txt",
1471 "line 1 from main\nline 2\nline 3\n",
1472 "Main modifies line 1",
1473 );
1474
1475 let main_tip = repo.branch_commit(&main_branch).unwrap();
1476
1477 let predictions = repo.predict_rebase_conflicts("feature", main_tip).unwrap();
1479
1480 assert!(
1482 !predictions.is_empty(),
1483 "Expected at least one conflicting commit"
1484 );
1485 }
1486
1487 #[test]
1488 fn test_predict_rebase_conflicts_already_synced() {
1489 let (temp, repo) = init_test_repo();
1490 let main_branch = repo.current_branch().unwrap();
1491
1492 create_commit_with_file(&temp, &repo, "file.txt", "content", "Main commit");
1494
1495 repo.create_branch("feature").unwrap();
1497
1498 let main_tip = repo.branch_commit(&main_branch).unwrap();
1500 let predictions = repo.predict_rebase_conflicts("feature", main_tip).unwrap();
1501 assert!(
1502 predictions.is_empty(),
1503 "Already synced should have no predictions"
1504 );
1505 }
1506
1507 #[test]
1515 fn test_predict_rebase_conflicts_tree_chaining() {
1516 let (temp, repo) = init_test_repo();
1517 let main_branch = repo.current_branch().unwrap();
1518
1519 create_commit_with_file(
1521 &temp,
1522 &repo,
1523 "shared.txt",
1524 "line 1\nline 2\nline 3\n",
1525 "Initial shared file",
1526 );
1527
1528 let base_commit = repo.branch_commit(&main_branch).unwrap();
1529
1530 repo.create_branch("feature").unwrap();
1532 repo.checkout("feature").unwrap();
1533
1534 create_commit_with_file(
1536 &temp,
1537 &repo,
1538 "feature_only.txt",
1539 "feature content\n",
1540 "Add feature-only file",
1541 );
1542
1543 create_commit_with_file(
1545 &temp,
1546 &repo,
1547 "shared.txt",
1548 "line 1\nline 2 from feature\nline 3\n",
1549 "Modify shared line 2",
1550 );
1551
1552 force_checkout(&repo, &main_branch);
1554 repo.reset_branch(&main_branch, base_commit).unwrap();
1555 force_checkout(&repo, &main_branch);
1556
1557 create_commit_with_file(
1558 &temp,
1559 &repo,
1560 "shared.txt",
1561 "line 1\nline 2 from main\nline 3\n",
1562 "Main modifies line 2",
1563 );
1564
1565 let main_tip = repo.branch_commit(&main_branch).unwrap();
1566
1567 let predictions = repo.predict_rebase_conflicts("feature", main_tip).unwrap();
1569
1570 assert_eq!(
1574 predictions.len(),
1575 1,
1576 "Expected exactly one conflicting commit (the second one). \
1577 Got {} conflicts. This indicates tree chaining may be broken.",
1578 predictions.len()
1579 );
1580
1581 assert!(
1583 predictions[0]
1584 .commit_summary
1585 .contains("Modify shared line 2"),
1586 "Expected the second commit to conflict, got: {}",
1587 predictions[0].commit_summary
1588 );
1589
1590 assert!(
1592 predictions[0]
1593 .conflicting_files
1594 .contains(&"shared.txt".to_string()),
1595 "Expected shared.txt to be the conflicting file"
1596 );
1597 }
1598}