1use std::path::Path;
4
5use git2::{BranchType, Oid, RepositoryState, Signature};
6
7use crate::error::{Error, Result};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum RemoteDivergence {
15 InSync,
17 Ahead {
19 commits: usize,
21 },
22 Behind {
24 commits: usize,
26 },
27 Diverged {
29 ahead: usize,
31 behind: usize,
33 },
34 NoRemote,
36}
37
38pub struct Repository {
40 inner: git2::Repository,
41}
42
43impl Repository {
44 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
49 let inner = git2::Repository::discover(path)?;
50 Ok(Self { inner })
51 }
52
53 pub fn open_current() -> Result<Self> {
58 Self::open(".")
59 }
60
61 #[must_use]
63 pub fn workdir(&self) -> Option<&Path> {
64 self.inner.workdir()
65 }
66
67 #[must_use]
69 pub fn git_dir(&self) -> &Path {
70 self.inner.path()
71 }
72
73 #[must_use]
75 pub fn state(&self) -> RepositoryState {
76 self.inner.state()
77 }
78
79 #[must_use]
81 pub fn is_rebasing(&self) -> bool {
82 matches!(
83 self.state(),
84 RepositoryState::Rebase
85 | RepositoryState::RebaseInteractive
86 | RepositoryState::RebaseMerge
87 )
88 }
89
90 pub fn head_detached(&self) -> Result<bool> {
95 let head = self.inner.head()?;
96 Ok(!head.is_branch())
97 }
98
99 pub fn current_branch(&self) -> Result<String> {
106 let head = self.inner.head()?;
107 if !head.is_branch() {
108 return Err(Error::DetachedHead);
109 }
110
111 head.shorthand()
112 .map(String::from)
113 .ok_or(Error::DetachedHead)
114 }
115
116 pub fn branch_commit(&self, branch_name: &str) -> Result<Oid> {
121 let branch = self
122 .inner
123 .find_branch(branch_name, BranchType::Local)
124 .map_err(|_| Error::BranchNotFound(branch_name.into()))?;
125
126 branch
127 .get()
128 .target()
129 .ok_or_else(|| Error::BranchNotFound(branch_name.into()))
130 }
131
132 pub fn remote_branch_commit(&self, branch_name: &str) -> Result<Oid> {
139 let remote_ref = self
141 .branch_upstream_ref(branch_name)
142 .unwrap_or_else(|| format!("refs/remotes/origin/{branch_name}"));
143
144 let reference = self
145 .inner
146 .find_reference(&remote_ref)
147 .map_err(|_| Error::BranchNotFound(remote_ref.clone()))?;
148
149 reference.target().ok_or(Error::BranchNotFound(remote_ref))
150 }
151
152 fn branch_upstream_ref(&self, branch_name: &str) -> Option<String> {
158 let refname = format!("refs/heads/{branch_name}");
159 let upstream_buf = self.inner.branch_upstream_name(&refname).ok()?;
160 upstream_buf.as_str().map(String::from)
161 }
162
163 pub fn create_branch(&self, name: &str) -> Result<Oid> {
168 let head_commit = self.inner.head()?.peel_to_commit()?;
169 let branch = self.inner.branch(name, &head_commit, false)?;
170
171 branch
172 .get()
173 .target()
174 .ok_or_else(|| Error::BranchNotFound(name.into()))
175 }
176
177 pub fn checkout(&self, branch_name: &str) -> Result<()> {
182 let branch = self
183 .inner
184 .find_branch(branch_name, BranchType::Local)
185 .map_err(|_| Error::BranchNotFound(branch_name.into()))?;
186
187 let reference = branch.get();
188 let object = reference.peel(git2::ObjectType::Commit)?;
189
190 self.inner.checkout_tree(&object, None)?;
191 self.inner.set_head(&format!("refs/heads/{branch_name}"))?;
192
193 Ok(())
194 }
195
196 pub fn list_branches(&self) -> Result<Vec<String>> {
201 let branches = self.inner.branches(Some(BranchType::Local))?;
202
203 let names: Vec<String> = branches
204 .filter_map(std::result::Result::ok)
205 .filter_map(|(b, _)| b.name().ok().flatten().map(String::from))
206 .collect();
207
208 Ok(names)
209 }
210
211 #[must_use]
213 pub fn branch_exists(&self, name: &str) -> bool {
214 self.inner.find_branch(name, BranchType::Local).is_ok()
215 }
216
217 pub fn delete_branch(&self, name: &str) -> Result<()> {
222 let mut branch = self.inner.find_branch(name, BranchType::Local)?;
223 branch.delete()?;
224 Ok(())
225 }
226
227 pub fn is_clean(&self) -> Result<bool> {
237 let mut opts = git2::StatusOptions::new();
238 opts.include_untracked(false)
239 .include_ignored(false)
240 .include_unmodified(false)
241 .exclude_submodules(true);
242 let statuses = self.inner.statuses(Some(&mut opts))?;
243
244 for entry in statuses.iter() {
246 let status = entry.status();
247 if status.intersects(
249 git2::Status::INDEX_NEW
250 | git2::Status::INDEX_MODIFIED
251 | git2::Status::INDEX_DELETED
252 | git2::Status::INDEX_RENAMED
253 | git2::Status::INDEX_TYPECHANGE
254 | git2::Status::WT_MODIFIED
255 | git2::Status::WT_DELETED
256 | git2::Status::WT_TYPECHANGE
257 | git2::Status::WT_RENAMED,
258 ) {
259 return Ok(false);
260 }
261 }
262 Ok(true)
263 }
264
265 pub fn require_clean(&self) -> Result<()> {
270 if self.is_clean()? {
271 Ok(())
272 } else {
273 Err(Error::DirtyWorkingDirectory)
274 }
275 }
276
277 pub fn stage_all(&self) -> Result<()> {
286 let workdir = self.workdir().ok_or(Error::NotARepository)?;
287
288 let output = std::process::Command::new("git")
289 .args(["add", "-A"])
290 .current_dir(workdir)
291 .output()
292 .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
293
294 if output.status.success() {
295 Ok(())
296 } else {
297 let stderr = String::from_utf8_lossy(&output.stderr);
298 Err(Error::Git2(git2::Error::from_str(&stderr)))
299 }
300 }
301
302 pub fn has_staged_changes(&self) -> Result<bool> {
307 let mut opts = git2::StatusOptions::new();
308 opts.include_untracked(false)
309 .include_ignored(false)
310 .include_unmodified(false);
311 let statuses = self.inner.statuses(Some(&mut opts))?;
312
313 for entry in statuses.iter() {
314 let status = entry.status();
315 if status.intersects(
316 git2::Status::INDEX_NEW
317 | git2::Status::INDEX_MODIFIED
318 | git2::Status::INDEX_DELETED
319 | git2::Status::INDEX_RENAMED
320 | git2::Status::INDEX_TYPECHANGE,
321 ) {
322 return Ok(true);
323 }
324 }
325 Ok(false)
326 }
327
328 pub fn create_commit(&self, message: &str) -> Result<Oid> {
335 let sig = self.signature()?;
336 let mut index = self.inner.index()?;
337 let tree_id = index.write_tree()?;
338 let tree = self.inner.find_tree(tree_id)?;
339
340 let oid = match self.inner.head().and_then(|h| h.peel_to_commit()) {
342 Ok(parent) => {
343 self.inner
344 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?
345 }
346 Err(_) => {
347 self.inner
349 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?
350 }
351 };
352
353 Ok(oid)
354 }
355
356 pub fn find_commit(&self, oid: Oid) -> Result<git2::Commit<'_>> {
363 Ok(self.inner.find_commit(oid)?)
364 }
365
366 pub fn branch_commit_message(&self, branch_name: &str) -> Result<String> {
371 let oid = self.branch_commit(branch_name)?;
372 let commit = self.inner.find_commit(oid)?;
373 commit
374 .message()
375 .map(String::from)
376 .ok_or_else(|| Error::Git2(git2::Error::from_str("commit has no message")))
377 }
378
379 pub fn merge_base(&self, one: Oid, two: Oid) -> Result<Oid> {
384 Ok(self.inner.merge_base(one, two)?)
385 }
386
387 pub fn count_commits_between(&self, from: Oid, to: Oid) -> Result<usize> {
392 let mut revwalk = self.inner.revwalk()?;
393 revwalk.push(to)?;
394 revwalk.hide(from)?;
395
396 Ok(revwalk.count())
397 }
398
399 pub fn commits_between(&self, from: Oid, to: Oid) -> Result<Vec<Oid>> {
404 let mut revwalk = self.inner.revwalk()?;
405 revwalk.push(to)?;
406 revwalk.hide(from)?;
407
408 let mut commits = Vec::new();
409 for oid in revwalk {
410 let oid = oid?;
411 commits.push(oid);
412 }
413
414 Ok(commits)
415 }
416
417 pub fn reset_branch(&self, branch_name: &str, target: Oid) -> Result<()> {
424 let commit = self.inner.find_commit(target)?;
425 let reference_name = format!("refs/heads/{branch_name}");
426
427 self.inner.reference(
428 &reference_name,
429 target,
430 true, &format!("rung: reset to {}", &target.to_string()[..8]),
432 )?;
433
434 if self.current_branch().ok().as_deref() == Some(branch_name) {
436 self.inner
437 .reset(commit.as_object(), git2::ResetType::Hard, None)?;
438 }
439
440 Ok(())
441 }
442
443 pub fn signature(&self) -> Result<Signature<'_>> {
450 Ok(self.inner.signature()?)
451 }
452
453 pub fn rebase_onto(&self, target: Oid) -> Result<()> {
462 let workdir = self.workdir().ok_or(Error::NotARepository)?;
463
464 let output = std::process::Command::new("git")
465 .args(["rebase", &target.to_string()])
466 .current_dir(workdir)
467 .output()
468 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
469
470 if output.status.success() {
471 return Ok(());
472 }
473
474 if self.is_rebasing() {
476 let conflicts = self.conflicting_files()?;
477 return Err(Error::RebaseConflict(conflicts));
478 }
479
480 let stderr = String::from_utf8_lossy(&output.stderr);
481 Err(Error::RebaseFailed(stderr.to_string()))
482 }
483
484 pub fn rebase_onto_from(&self, new_base: Oid, old_base: Oid) -> Result<()> {
493 let workdir = self.workdir().ok_or(Error::NotARepository)?;
494
495 let output = std::process::Command::new("git")
496 .args([
497 "rebase",
498 "--onto",
499 &new_base.to_string(),
500 &old_base.to_string(),
501 ])
502 .current_dir(workdir)
503 .output()
504 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
505
506 if output.status.success() {
507 return Ok(());
508 }
509
510 if self.is_rebasing() {
512 let conflicts = self.conflicting_files()?;
513 return Err(Error::RebaseConflict(conflicts));
514 }
515
516 let stderr = String::from_utf8_lossy(&output.stderr);
517 Err(Error::RebaseFailed(stderr.to_string()))
518 }
519
520 pub fn conflicting_files(&self) -> Result<Vec<String>> {
525 let statuses = self.inner.statuses(None)?;
526 let conflicts: Vec<String> = statuses
527 .iter()
528 .filter(|s| s.status().is_conflicted())
529 .filter_map(|s| s.path().map(String::from))
530 .collect();
531 Ok(conflicts)
532 }
533
534 pub fn rebase_abort(&self) -> Result<()> {
539 let workdir = self.workdir().ok_or(Error::NotARepository)?;
540
541 let output = std::process::Command::new("git")
542 .args(["rebase", "--abort"])
543 .current_dir(workdir)
544 .output()
545 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
546
547 if output.status.success() {
548 Ok(())
549 } else {
550 let stderr = String::from_utf8_lossy(&output.stderr);
551 Err(Error::RebaseFailed(stderr.to_string()))
552 }
553 }
554
555 pub fn rebase_continue(&self) -> Result<()> {
560 let workdir = self.workdir().ok_or(Error::NotARepository)?;
561
562 let output = std::process::Command::new("git")
563 .args(["rebase", "--continue"])
564 .current_dir(workdir)
565 .output()
566 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
567
568 if output.status.success() {
569 return Ok(());
570 }
571
572 if self.is_rebasing() {
574 let conflicts = self.conflicting_files()?;
575 return Err(Error::RebaseConflict(conflicts));
576 }
577
578 let stderr = String::from_utf8_lossy(&output.stderr);
579 Err(Error::RebaseFailed(stderr.to_string()))
580 }
581
582 pub fn remote_divergence(&self, branch: &str) -> Result<RemoteDivergence> {
595 let local = self.branch_commit(branch)?;
596
597 let remote = match self.remote_branch_commit(branch) {
599 Ok(oid) => oid,
600 Err(Error::BranchNotFound(_)) => return Ok(RemoteDivergence::NoRemote),
601 Err(e) => return Err(e),
602 };
603
604 if local == remote {
605 return Ok(RemoteDivergence::InSync);
606 }
607
608 let (ahead, behind) = match self.inner.graph_ahead_behind(local, remote) {
611 Ok(counts) => counts,
612 Err(e) if e.code() == git2::ErrorCode::NotFound => (0, 0),
613 Err(e) => return Err(Error::Git2(e)),
614 };
615
616 if ahead == 0 && behind == 0 {
618 return Ok(RemoteDivergence::Diverged {
619 ahead: self.count_all_commits(local)?,
620 behind: self.count_all_commits(remote)?,
621 });
622 }
623
624 Ok(match (ahead, behind) {
625 (a, 0) => RemoteDivergence::Ahead { commits: a },
626 (0, b) => RemoteDivergence::Behind { commits: b },
627 (a, b) => RemoteDivergence::Diverged {
628 ahead: a,
629 behind: b,
630 },
631 })
632 }
633
634 fn count_all_commits(&self, from: Oid) -> Result<usize> {
638 let mut revwalk = self.inner.revwalk()?;
639 revwalk.push(from)?;
640 Ok(revwalk.count())
641 }
642
643 pub fn origin_url(&self) -> Result<String> {
648 let remote = self
649 .inner
650 .find_remote("origin")
651 .map_err(|_| Error::RemoteNotFound("origin".into()))?;
652
653 remote
654 .url()
655 .map(String::from)
656 .ok_or_else(|| Error::RemoteNotFound("origin".into()))
657 }
658
659 #[must_use]
664 pub fn detect_default_branch(&self) -> Option<String> {
665 let reference = self.inner.find_reference("refs/remotes/origin/HEAD").ok()?;
667
668 let resolved = reference.resolve().ok()?;
670 let name = resolved.name()?;
671
672 name.strip_prefix("refs/remotes/origin/").map(String::from)
674 }
675
676 pub fn parse_github_remote(url: &str) -> Result<(String, String)> {
685 if let Some(rest) = url.strip_prefix("git@github.com:") {
687 let path = rest.strip_suffix(".git").unwrap_or(rest);
688 if let Some((owner, repo)) = path.split_once('/') {
689 return Ok((owner.to_string(), repo.to_string()));
690 }
691 }
692
693 if let Some(rest) = url
695 .strip_prefix("https://github.com/")
696 .or_else(|| url.strip_prefix("http://github.com/"))
697 {
698 let path = rest.strip_suffix(".git").unwrap_or(rest);
699 if let Some((owner, repo)) = path.split_once('/') {
700 return Ok((owner.to_string(), repo.to_string()));
701 }
702 }
703
704 Err(Error::InvalidRemoteUrl(url.to_string()))
705 }
706
707 pub fn push(&self, branch: &str, force: bool) -> Result<()> {
712 let workdir = self.workdir().ok_or(Error::NotARepository)?;
713
714 let mut args = vec!["push", "-u", "origin", branch];
715 if force {
716 args.insert(1, "--force-with-lease");
717 }
718
719 let output = std::process::Command::new("git")
720 .args(&args)
721 .current_dir(workdir)
722 .output()
723 .map_err(|e| Error::PushFailed(e.to_string()))?;
724
725 if output.status.success() {
726 Ok(())
727 } else {
728 let stderr = String::from_utf8_lossy(&output.stderr);
729 Err(Error::PushFailed(stderr.to_string()))
730 }
731 }
732
733 pub fn fetch(&self, branch: &str) -> Result<()> {
738 let workdir = self.workdir().ok_or(Error::NotARepository)?;
739
740 let refspec = format!("{branch}:refs/heads/{branch}");
743 let output = std::process::Command::new("git")
744 .args(["fetch", "origin", &refspec])
745 .current_dir(workdir)
746 .output()
747 .map_err(|e| Error::FetchFailed(e.to_string()))?;
748
749 if output.status.success() {
750 Ok(())
751 } else {
752 let stderr = String::from_utf8_lossy(&output.stderr);
753 Err(Error::FetchFailed(stderr.to_string()))
754 }
755 }
756
757 pub fn pull_ff(&self) -> Result<()> {
765 let workdir = self.workdir().ok_or(Error::NotARepository)?;
766
767 let output = std::process::Command::new("git")
768 .args(["pull", "--ff-only"])
769 .current_dir(workdir)
770 .output()
771 .map_err(|e| Error::FetchFailed(e.to_string()))?;
772
773 if output.status.success() {
774 Ok(())
775 } else {
776 let stderr = String::from_utf8_lossy(&output.stderr);
777 Err(Error::FetchFailed(stderr.to_string()))
778 }
779 }
780
781 #[must_use]
787 pub const fn inner(&self) -> &git2::Repository {
788 &self.inner
789 }
790}
791
792impl std::fmt::Debug for Repository {
793 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794 f.debug_struct("Repository")
795 .field("path", &self.git_dir())
796 .finish()
797 }
798}
799
800#[cfg(test)]
801#[allow(clippy::unwrap_used)]
802mod tests {
803 use super::*;
804 use std::fs;
805 use tempfile::TempDir;
806
807 fn init_test_repo() -> (TempDir, Repository) {
808 let temp = TempDir::new().unwrap();
809 let repo = git2::Repository::init(temp.path()).unwrap();
810
811 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
813 let tree_id = repo.index().unwrap().write_tree().unwrap();
814 let tree = repo.find_tree(tree_id).unwrap();
815 repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
816 .unwrap();
817 drop(tree);
818
819 let wrapped = Repository { inner: repo };
820 (temp, wrapped)
821 }
822
823 #[test]
824 fn test_current_branch() {
825 let (_temp, repo) = init_test_repo();
826 let branch = repo.current_branch().unwrap();
828 assert!(branch == "main" || branch == "master");
829 }
830
831 #[test]
832 fn test_create_and_checkout_branch() {
833 let (_temp, repo) = init_test_repo();
834
835 repo.create_branch("feature/test").unwrap();
836 assert!(repo.branch_exists("feature/test"));
837
838 repo.checkout("feature/test").unwrap();
839 assert_eq!(repo.current_branch().unwrap(), "feature/test");
840 }
841
842 #[test]
843 fn test_is_clean() {
844 let (temp, repo) = init_test_repo();
845
846 assert!(repo.is_clean().unwrap());
847
848 fs::write(temp.path().join("test.txt"), "initial").unwrap();
850 {
851 let mut index = repo.inner.index().unwrap();
852 index.add_path(std::path::Path::new("test.txt")).unwrap();
853 index.write().unwrap();
854 let tree_id = index.write_tree().unwrap();
855 let tree = repo.inner.find_tree(tree_id).unwrap();
856 let parent = repo.inner.head().unwrap().peel_to_commit().unwrap();
857 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
858 repo.inner
859 .commit(Some("HEAD"), &sig, &sig, "Add test file", &tree, &[&parent])
860 .unwrap();
861 }
862
863 assert!(repo.is_clean().unwrap());
865
866 fs::write(temp.path().join("test.txt"), "modified").unwrap();
868 assert!(!repo.is_clean().unwrap());
869 }
870
871 #[test]
872 fn test_list_branches() {
873 let (_temp, repo) = init_test_repo();
874
875 repo.create_branch("feature/a").unwrap();
876 repo.create_branch("feature/b").unwrap();
877
878 let branches = repo.list_branches().unwrap();
879 assert!(branches.len() >= 3); assert!(branches.iter().any(|b| b == "feature/a"));
881 assert!(branches.iter().any(|b| b == "feature/b"));
882 }
883}