1use std::path::Path;
4
5use git2::{BranchType, Oid, RepositoryState, Signature};
6
7use crate::error::{Error, Result};
8
9pub struct Repository {
11 inner: git2::Repository,
12}
13
14impl Repository {
15 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
20 let inner = git2::Repository::discover(path)?;
21 Ok(Self { inner })
22 }
23
24 pub fn open_current() -> Result<Self> {
29 Self::open(".")
30 }
31
32 #[must_use]
34 pub fn workdir(&self) -> Option<&Path> {
35 self.inner.workdir()
36 }
37
38 #[must_use]
40 pub fn git_dir(&self) -> &Path {
41 self.inner.path()
42 }
43
44 #[must_use]
46 pub fn state(&self) -> RepositoryState {
47 self.inner.state()
48 }
49
50 #[must_use]
52 pub fn is_rebasing(&self) -> bool {
53 matches!(
54 self.state(),
55 RepositoryState::Rebase
56 | RepositoryState::RebaseInteractive
57 | RepositoryState::RebaseMerge
58 )
59 }
60
61 pub fn current_branch(&self) -> Result<String> {
68 let head = self.inner.head()?;
69 if !head.is_branch() {
70 return Err(Error::DetachedHead);
71 }
72
73 head.shorthand()
74 .map(String::from)
75 .ok_or(Error::DetachedHead)
76 }
77
78 pub fn branch_commit(&self, branch_name: &str) -> Result<Oid> {
83 let branch = self
84 .inner
85 .find_branch(branch_name, BranchType::Local)
86 .map_err(|_| Error::BranchNotFound(branch_name.into()))?;
87
88 branch
89 .get()
90 .target()
91 .ok_or_else(|| Error::BranchNotFound(branch_name.into()))
92 }
93
94 pub fn remote_branch_commit(&self, branch_name: &str) -> Result<Oid> {
99 let ref_name = format!("refs/remotes/origin/{branch_name}");
100 let reference = self
101 .inner
102 .find_reference(&ref_name)
103 .map_err(|_| Error::BranchNotFound(format!("origin/{branch_name}")))?;
104
105 reference
106 .target()
107 .ok_or_else(|| Error::BranchNotFound(format!("origin/{branch_name}")))
108 }
109
110 pub fn create_branch(&self, name: &str) -> Result<Oid> {
115 let head_commit = self.inner.head()?.peel_to_commit()?;
116 let branch = self.inner.branch(name, &head_commit, false)?;
117
118 branch
119 .get()
120 .target()
121 .ok_or_else(|| Error::BranchNotFound(name.into()))
122 }
123
124 pub fn checkout(&self, branch_name: &str) -> Result<()> {
129 let branch = self
130 .inner
131 .find_branch(branch_name, BranchType::Local)
132 .map_err(|_| Error::BranchNotFound(branch_name.into()))?;
133
134 let reference = branch.get();
135 let object = reference.peel(git2::ObjectType::Commit)?;
136
137 self.inner.checkout_tree(&object, None)?;
138 self.inner.set_head(&format!("refs/heads/{branch_name}"))?;
139
140 Ok(())
141 }
142
143 pub fn list_branches(&self) -> Result<Vec<String>> {
148 let branches = self.inner.branches(Some(BranchType::Local))?;
149
150 let names: Vec<String> = branches
151 .filter_map(std::result::Result::ok)
152 .filter_map(|(b, _)| b.name().ok().flatten().map(String::from))
153 .collect();
154
155 Ok(names)
156 }
157
158 #[must_use]
160 pub fn branch_exists(&self, name: &str) -> bool {
161 self.inner.find_branch(name, BranchType::Local).is_ok()
162 }
163
164 pub fn delete_branch(&self, name: &str) -> Result<()> {
169 let mut branch = self.inner.find_branch(name, BranchType::Local)?;
170 branch.delete()?;
171 Ok(())
172 }
173
174 pub fn is_clean(&self) -> Result<bool> {
184 let mut opts = git2::StatusOptions::new();
185 opts.include_untracked(false)
186 .include_ignored(false)
187 .include_unmodified(false)
188 .exclude_submodules(true);
189 let statuses = self.inner.statuses(Some(&mut opts))?;
190
191 for entry in statuses.iter() {
193 let status = entry.status();
194 if status.intersects(
196 git2::Status::INDEX_NEW
197 | git2::Status::INDEX_MODIFIED
198 | git2::Status::INDEX_DELETED
199 | git2::Status::INDEX_RENAMED
200 | git2::Status::INDEX_TYPECHANGE
201 | git2::Status::WT_MODIFIED
202 | git2::Status::WT_DELETED
203 | git2::Status::WT_TYPECHANGE
204 | git2::Status::WT_RENAMED,
205 ) {
206 return Ok(false);
207 }
208 }
209 Ok(true)
210 }
211
212 pub fn require_clean(&self) -> Result<()> {
217 if self.is_clean()? {
218 Ok(())
219 } else {
220 Err(Error::DirtyWorkingDirectory)
221 }
222 }
223
224 pub fn find_commit(&self, oid: Oid) -> Result<git2::Commit<'_>> {
231 Ok(self.inner.find_commit(oid)?)
232 }
233
234 pub fn merge_base(&self, one: Oid, two: Oid) -> Result<Oid> {
239 Ok(self.inner.merge_base(one, two)?)
240 }
241
242 pub fn count_commits_between(&self, from: Oid, to: Oid) -> Result<usize> {
247 let mut revwalk = self.inner.revwalk()?;
248 revwalk.push(to)?;
249 revwalk.hide(from)?;
250
251 Ok(revwalk.count())
252 }
253
254 pub fn reset_branch(&self, branch_name: &str, target: Oid) -> Result<()> {
261 let commit = self.inner.find_commit(target)?;
262 let reference_name = format!("refs/heads/{branch_name}");
263
264 self.inner.reference(
265 &reference_name,
266 target,
267 true, &format!("rung: reset to {}", &target.to_string()[..8]),
269 )?;
270
271 if self.current_branch().ok().as_deref() == Some(branch_name) {
273 self.inner
274 .reset(commit.as_object(), git2::ResetType::Hard, None)?;
275 }
276
277 Ok(())
278 }
279
280 pub fn signature(&self) -> Result<Signature<'_>> {
287 Ok(self.inner.signature()?)
288 }
289
290 pub fn rebase_onto(&self, target: Oid) -> Result<()> {
299 let workdir = self.workdir().ok_or(Error::NotARepository)?;
300
301 let output = std::process::Command::new("git")
302 .args(["rebase", &target.to_string()])
303 .current_dir(workdir)
304 .output()
305 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
306
307 if output.status.success() {
308 return Ok(());
309 }
310
311 if self.is_rebasing() {
313 let conflicts = self.conflicting_files()?;
314 return Err(Error::RebaseConflict(conflicts));
315 }
316
317 let stderr = String::from_utf8_lossy(&output.stderr);
318 Err(Error::RebaseFailed(stderr.to_string()))
319 }
320
321 pub fn rebase_onto_from(&self, new_base: Oid, old_base: Oid) -> Result<()> {
330 let workdir = self.workdir().ok_or(Error::NotARepository)?;
331
332 let output = std::process::Command::new("git")
333 .args([
334 "rebase",
335 "--onto",
336 &new_base.to_string(),
337 &old_base.to_string(),
338 ])
339 .current_dir(workdir)
340 .output()
341 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
342
343 if output.status.success() {
344 return Ok(());
345 }
346
347 if self.is_rebasing() {
349 let conflicts = self.conflicting_files()?;
350 return Err(Error::RebaseConflict(conflicts));
351 }
352
353 let stderr = String::from_utf8_lossy(&output.stderr);
354 Err(Error::RebaseFailed(stderr.to_string()))
355 }
356
357 pub fn conflicting_files(&self) -> Result<Vec<String>> {
362 let statuses = self.inner.statuses(None)?;
363 let conflicts: Vec<String> = statuses
364 .iter()
365 .filter(|s| s.status().is_conflicted())
366 .filter_map(|s| s.path().map(String::from))
367 .collect();
368 Ok(conflicts)
369 }
370
371 pub fn rebase_abort(&self) -> Result<()> {
376 let workdir = self.workdir().ok_or(Error::NotARepository)?;
377
378 let output = std::process::Command::new("git")
379 .args(["rebase", "--abort"])
380 .current_dir(workdir)
381 .output()
382 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
383
384 if output.status.success() {
385 Ok(())
386 } else {
387 let stderr = String::from_utf8_lossy(&output.stderr);
388 Err(Error::RebaseFailed(stderr.to_string()))
389 }
390 }
391
392 pub fn rebase_continue(&self) -> Result<()> {
397 let workdir = self.workdir().ok_or(Error::NotARepository)?;
398
399 let output = std::process::Command::new("git")
400 .args(["rebase", "--continue"])
401 .current_dir(workdir)
402 .output()
403 .map_err(|e| Error::RebaseFailed(e.to_string()))?;
404
405 if output.status.success() {
406 return Ok(());
407 }
408
409 if self.is_rebasing() {
411 let conflicts = self.conflicting_files()?;
412 return Err(Error::RebaseConflict(conflicts));
413 }
414
415 let stderr = String::from_utf8_lossy(&output.stderr);
416 Err(Error::RebaseFailed(stderr.to_string()))
417 }
418
419 pub fn origin_url(&self) -> Result<String> {
426 let remote = self
427 .inner
428 .find_remote("origin")
429 .map_err(|_| Error::RemoteNotFound("origin".into()))?;
430
431 remote
432 .url()
433 .map(String::from)
434 .ok_or_else(|| Error::RemoteNotFound("origin".into()))
435 }
436
437 pub fn parse_github_remote(url: &str) -> Result<(String, String)> {
446 if let Some(rest) = url.strip_prefix("git@github.com:") {
448 let path = rest.strip_suffix(".git").unwrap_or(rest);
449 if let Some((owner, repo)) = path.split_once('/') {
450 return Ok((owner.to_string(), repo.to_string()));
451 }
452 }
453
454 if let Some(rest) = url
456 .strip_prefix("https://github.com/")
457 .or_else(|| url.strip_prefix("http://github.com/"))
458 {
459 let path = rest.strip_suffix(".git").unwrap_or(rest);
460 if let Some((owner, repo)) = path.split_once('/') {
461 return Ok((owner.to_string(), repo.to_string()));
462 }
463 }
464
465 Err(Error::InvalidRemoteUrl(url.to_string()))
466 }
467
468 pub fn push(&self, branch: &str, force: bool) -> Result<()> {
473 let workdir = self.workdir().ok_or(Error::NotARepository)?;
474
475 let mut args = vec!["push", "-u", "origin", branch];
476 if force {
477 args.insert(1, "--force-with-lease");
478 }
479
480 let output = std::process::Command::new("git")
481 .args(&args)
482 .current_dir(workdir)
483 .output()
484 .map_err(|e| Error::PushFailed(e.to_string()))?;
485
486 if output.status.success() {
487 Ok(())
488 } else {
489 let stderr = String::from_utf8_lossy(&output.stderr);
490 Err(Error::PushFailed(stderr.to_string()))
491 }
492 }
493
494 pub fn fetch(&self, branch: &str) -> Result<()> {
499 let workdir = self.workdir().ok_or(Error::NotARepository)?;
500
501 let output = std::process::Command::new("git")
502 .args(["fetch", "origin", branch])
503 .current_dir(workdir)
504 .output()
505 .map_err(|e| Error::FetchFailed(e.to_string()))?;
506
507 if output.status.success() {
508 Ok(())
509 } else {
510 let stderr = String::from_utf8_lossy(&output.stderr);
511 Err(Error::FetchFailed(stderr.to_string()))
512 }
513 }
514
515 pub fn pull_ff(&self) -> Result<()> {
523 let workdir = self.workdir().ok_or(Error::NotARepository)?;
524
525 let output = std::process::Command::new("git")
526 .args(["pull", "--ff-only"])
527 .current_dir(workdir)
528 .output()
529 .map_err(|e| Error::FetchFailed(e.to_string()))?;
530
531 if output.status.success() {
532 Ok(())
533 } else {
534 let stderr = String::from_utf8_lossy(&output.stderr);
535 Err(Error::FetchFailed(stderr.to_string()))
536 }
537 }
538
539 #[must_use]
545 pub const fn inner(&self) -> &git2::Repository {
546 &self.inner
547 }
548}
549
550impl std::fmt::Debug for Repository {
551 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552 f.debug_struct("Repository")
553 .field("path", &self.git_dir())
554 .finish()
555 }
556}
557
558#[cfg(test)]
559#[allow(clippy::unwrap_used)]
560mod tests {
561 use super::*;
562 use std::fs;
563 use tempfile::TempDir;
564
565 fn init_test_repo() -> (TempDir, Repository) {
566 let temp = TempDir::new().unwrap();
567 let repo = git2::Repository::init(temp.path()).unwrap();
568
569 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
571 let tree_id = repo.index().unwrap().write_tree().unwrap();
572 let tree = repo.find_tree(tree_id).unwrap();
573 repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
574 .unwrap();
575 drop(tree);
576
577 let wrapped = Repository { inner: repo };
578 (temp, wrapped)
579 }
580
581 #[test]
582 fn test_current_branch() {
583 let (_temp, repo) = init_test_repo();
584 let branch = repo.current_branch().unwrap();
586 assert!(branch == "main" || branch == "master");
587 }
588
589 #[test]
590 fn test_create_and_checkout_branch() {
591 let (_temp, repo) = init_test_repo();
592
593 repo.create_branch("feature/test").unwrap();
594 assert!(repo.branch_exists("feature/test"));
595
596 repo.checkout("feature/test").unwrap();
597 assert_eq!(repo.current_branch().unwrap(), "feature/test");
598 }
599
600 #[test]
601 fn test_is_clean() {
602 let (temp, repo) = init_test_repo();
603
604 assert!(repo.is_clean().unwrap());
605
606 fs::write(temp.path().join("test.txt"), "initial").unwrap();
608 {
609 let mut index = repo.inner.index().unwrap();
610 index.add_path(std::path::Path::new("test.txt")).unwrap();
611 index.write().unwrap();
612 let tree_id = index.write_tree().unwrap();
613 let tree = repo.inner.find_tree(tree_id).unwrap();
614 let parent = repo.inner.head().unwrap().peel_to_commit().unwrap();
615 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
616 repo.inner
617 .commit(Some("HEAD"), &sig, &sig, "Add test file", &tree, &[&parent])
618 .unwrap();
619 }
620
621 assert!(repo.is_clean().unwrap());
623
624 fs::write(temp.path().join("test.txt"), "modified").unwrap();
626 assert!(!repo.is_clean().unwrap());
627 }
628
629 #[test]
630 fn test_list_branches() {
631 let (_temp, repo) = init_test_repo();
632
633 repo.create_branch("feature/a").unwrap();
634 repo.create_branch("feature/b").unwrap();
635
636 let branches = repo.list_branches().unwrap();
637 assert!(branches.len() >= 3); assert!(branches.iter().any(|b| b == "feature/a"));
639 assert!(branches.iter().any(|b| b == "feature/b"));
640 }
641}