1use std::fmt;
2
3use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
4use thiserror::Error;
5
6use super::{Warning, config, path};
7
8mod remote;
9mod worktree;
10
11pub use remote::{RemoteName, RemoteType, RemoteUrl};
12pub use worktree::{
13 CleanupWorktreeError, CleanupWorktreeWarningReason, Error as WorktreeError,
14 GIT_MAIN_WORKTREE_DIRECTORY, TrackingSelection, Worktree, WorktreeConversionError,
15 WorktreeName, WorktreeRemoveError, WorktreeRepoHandle, WorktreeRootConfig, WorktreeSetup,
16 WorktreeValidationError,
17};
18
19const GIT_CONFIG_BARE_KEY: GitConfigKey = GitConfigKey("core.bare");
20const GIT_CONFIG_PUSH_DEFAULT: &str = "push.default";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct BranchName(String);
24
25impl fmt::Display for BranchName {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 write!(f, "{}", self.0)
28 }
29}
30
31impl BranchName {
32 pub fn new(from: String) -> Self {
33 Self(from)
34 }
35
36 pub fn as_str(&self) -> &str {
37 &self.0
38 }
39
40 pub fn into_string(self) -> String {
41 self.0
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SubmoduleName(String);
47
48impl fmt::Display for SubmoduleName {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 write!(f, "{}", self.0)
51 }
52}
53
54impl SubmoduleName {
55 pub fn new(from: String) -> Self {
56 Self(from)
57 }
58
59 pub fn as_str(&self) -> &str {
60 &self.0
61 }
62
63 pub fn into_string(self) -> String {
64 self.0
65 }
66}
67
68#[derive(Clone, Copy)]
69pub enum GitPushDefaultSetting {
70 Upstream,
71}
72
73#[derive(Debug)]
74pub struct GitConfigKey(&'static str);
75
76impl GitConfigKey {
77 fn as_str(&self) -> &str {
78 self.0
79 }
80}
81
82impl fmt::Display for GitConfigKey {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 write!(f, "{}", self.0)
85 }
86}
87
88#[derive(Debug, Error)]
89pub enum Error {
90 #[error("Error reading configuration file \"{path}\": {message}")]
91 ReadConfig { message: String, path: PathBuf },
92 #[error("Error parsing configuration file \"{path}\": {message}")]
93 ParseConfig { message: String, path: PathBuf },
94 #[error(transparent)]
95 Libgit(#[from] git2::Error),
96 #[error(transparent)]
97 Io(#[from] std::io::Error),
98 #[error(transparent)]
99 Config(#[from] config::Error),
100 #[error("Branch not found")]
101 BranchNotFound,
102 #[error("Repo not found")]
103 RepoNotFound,
104 #[error("Could not determine default branch")]
105 NoDefaultBranch,
106 #[error("Failed getting default branch name: {message}")]
107 ErrorDefaultBranch { message: String },
108 #[error("Remotes using HTTP protocol are not supported")]
109 UnsupportedHttpRemote,
110 #[error("Remotes using git protocol are not supported")]
111 UnsupportedGitRemote,
112 #[error("The remote URL starts with an unimplemented protocol")]
113 UnimplementedRemoteProtocol,
114 #[error("Some non-default refspecs could not be renamed")]
115 RefspecRenameFailed,
116 #[error("No branch checked out")]
117 NoBranchCheckedOut,
118 #[error("Could not set {key}: {error}")]
119 GitConfigSetError { key: GitConfigKey, error: String },
120 #[error("Cannot get changes as this is a bare worktree repository")]
121 GettingChangesFromBareWorktree,
122 #[error("Trying to push to a non-pushable remote")]
123 NonPushableRemote,
124 #[error("Pushing \"{local_branch}\" to \"{remote_name}\" ({remote_url}) failed: {message}")]
125 PushFailed {
126 local_branch: BranchName,
127 remote_name: RemoteName,
128 remote_url: RemoteUrl,
129 message: String,
130 },
131 #[error(transparent)]
132 Path(#[from] path::Error),
133 #[error("Branch name is not valid utf-8")]
134 BranchNameNotUtf8,
135 #[error("Remote name is not valid utf-8")]
136 RemoteNameNotUtf8,
137 #[error("Remote name is empty")]
138 RemoteNameEmpty,
139 #[error("Remote branch name is not valid utf-8")]
140 RemoteBranchNameNotUtf8,
141 #[error("Submodule name is not valid utf-8")]
142 SubmoduleNameNotUtf8,
143 #[error("Submodule name is not valid utf-8")]
144 CannotGetBranchName {
145 #[source]
146 inner: git2::Error,
147 },
148 #[error("Remote HEAD ({name}) pointer is invalid")]
149 InvalidRemoteHeadPointer { name: String },
150 #[error("Remote HEAD does not point to a symbolic target")]
151 RemoteHeadNoSymbolicTarget,
152 #[error("Remote HEAD is not valid utf-8")]
153 RemoteHeadNotUtf8,
154}
155
156#[derive(Debug)]
157pub struct Remote {
158 pub name: RemoteName,
159 pub url: RemoteUrl,
160 pub remote_type: RemoteType,
161}
162
163impl From<config::Remote> for Remote {
164 fn from(other: config::Remote) -> Self {
165 Self {
166 name: RemoteName::new(other.name),
167 url: RemoteUrl::new(other.url),
168 remote_type: other.remote_type.into(),
169 }
170 }
171}
172
173impl From<Remote> for config::Remote {
174 fn from(other: Remote) -> Self {
175 Self {
176 name: other.name.into_string(),
177 url: other.url.into_string(),
178 remote_type: other.remote_type.into(),
179 }
180 }
181}
182
183#[derive(Clone, Debug, PartialEq, Eq)]
184pub struct RepoName(String);
185
186impl RepoName {
187 pub fn new(from: String) -> Self {
188 Self(from)
189 }
190
191 pub fn into_string(self) -> String {
192 self.0
193 }
194
195 pub fn as_str(&self) -> &str {
196 &self.0
197 }
198}
199
200impl fmt::Display for RepoName {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 write!(f, "{}", self.0)
203 }
204}
205
206#[derive(Debug)]
207pub struct RepoNamespace(String);
208
209impl RepoNamespace {
210 pub fn new(from: String) -> Self {
211 Self(from)
212 }
213
214 pub fn into_string(self) -> String {
215 self.0
216 }
217
218 pub fn as_str(&self) -> &str {
219 &self.0
220 }
221}
222
223#[derive(Debug)]
224pub struct Repo {
225 pub name: RepoName,
226 pub namespace: Option<RepoNamespace>,
227 pub worktree_setup: WorktreeSetup,
228 pub remotes: Vec<Remote>,
229}
230
231impl From<config::Repo> for Repo {
232 fn from(other: config::Repo) -> Self {
233 let (namespace, name) = if let Some((namespace, name)) = other.name.rsplit_once('/') {
234 (Some(namespace.to_owned()), name.to_owned())
235 } else {
236 (None, other.name)
237 };
238
239 Self {
240 name: RepoName::new(name),
241 namespace: namespace.map(RepoNamespace::new),
242 worktree_setup: other.worktree_setup.into(),
243 remotes: other.remotes.map_or_else(Vec::new, |remotes| {
244 remotes.into_iter().map(Into::into).collect()
245 }),
246 }
247 }
248}
249
250impl From<Repo> for config::Repo {
251 fn from(other: Repo) -> Self {
252 Self {
253 name: other.name.into_string(),
254 worktree_setup: other.worktree_setup.is_worktree(),
255 remotes: Some(other.remotes.into_iter().map(Into::into).collect()),
256 }
257 }
258}
259
260impl Repo {
261 pub fn fullname(&self) -> RepoName {
262 match self.namespace {
263 Some(ref namespace) => {
264 RepoName(format!("{}/{}", namespace.as_str(), self.name.as_str()))
265 }
266 None => RepoName(self.name.as_str().to_owned()),
267 }
268 }
269
270 pub fn remove_namespace(&mut self) {
271 self.namespace = None;
272 }
273}
274
275#[derive(Debug, Clone, Copy)]
276pub struct RepoChanges {
277 pub files_new: usize,
278 pub files_modified: usize,
279 pub files_deleted: usize,
280}
281
282impl fmt::Display for RepoChanges {
283 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284 if self.files_new == 0 && self.files_modified == 0 && self.files_deleted == 0 {
285 write!(f, "no changes")
286 } else {
287 #[expect(
288 clippy::useless_let_if_seq,
289 reason = "Clearer to set started in the beginning and then modify it in each block"
290 )]
291 {
292 let mut started = false;
293
294 if self.files_new > 0 {
295 write!(f, "{} new", self.files_new)?;
296 started = true;
297 }
298
299 if self.files_modified > 0 {
300 if started {
301 write!(f, ", ")?;
302 }
303 write!(f, "{} modified", self.files_modified)?;
304 started = true;
305 }
306
307 if self.files_deleted > 0 {
308 if started {
309 write!(f, ", ")?;
310 }
311 write!(f, "{} deleted", self.files_deleted)?;
312 }
313
314 Ok(())
315 }
316 }
317 }
318}
319
320pub enum SubmoduleStatus {
321 Clean,
322 Uninitialized,
323 Changed,
324 OutOfDate,
325}
326
327pub enum RemoteTrackingStatus {
328 UpToDate,
329 Ahead(usize),
330 Behind(usize),
331 Diverged(usize, usize),
332}
333
334pub enum UpstreamDetection {
337 Set(RemoteName),
340 Ambiguous(Vec<RemoteName>),
343}
344
345pub struct RepoStatus {
346 pub operation: Option<git2::RepositoryState>,
347
348 pub empty: bool,
349
350 pub remotes: Vec<RemoteName>,
351
352 pub head: Option<BranchName>,
353
354 pub changes: Option<RepoChanges>,
355
356 pub worktrees: usize,
357
358 pub submodules: Option<Vec<(SubmoduleName, SubmoduleStatus)>>,
359
360 pub branches: Vec<(BranchName, Option<(BranchName, RemoteTrackingStatus)>)>,
361}
362
363impl RepoStatus {
364 fn clean(&self) -> bool {
366 match self.changes {
367 None => true,
368 Some(ref changes) => {
369 changes.files_new == 0 && changes.files_deleted == 0 && changes.files_modified == 0
370 }
371 }
372 }
373
374 pub fn dirty(&self) -> bool {
386 !self.clean()
387 || self.branches.iter().any(|branch| {
388 !matches!(
389 branch.1,
390 Some((
391 _,
392 RemoteTrackingStatus::UpToDate | RemoteTrackingStatus::Behind(_)
393 ))
394 )
395 })
396 }
397}
398
399pub fn detect_remote_type(remote_url: &RemoteUrl) -> Result<RemoteType, Error> {
400 let remote_url = remote_url.as_str();
401
402 #[expect(clippy::missing_panics_doc, reason = "regex is valid")]
403 let git_regex = regex::Regex::new(r"^[a-zA-Z]+@.*$").expect("regex is valid");
404 if remote_url.starts_with("ssh://") {
405 return Ok(RemoteType::Ssh);
406 }
407 #[expect(
408 clippy::case_sensitive_file_extension_comparisons,
409 reason = "the extension is always lower case"
410 )]
411 if git_regex.is_match(remote_url) && remote_url.ends_with(".git") {
412 return Ok(RemoteType::Ssh);
413 }
414 if remote_url.starts_with("https://") {
415 return Ok(RemoteType::Https);
416 }
417 if remote_url.starts_with("file://") {
418 return Ok(RemoteType::File);
419 }
420 if remote_url.starts_with("http://") {
421 return Err(Error::UnsupportedHttpRemote);
422 }
423 if remote_url.starts_with("git://") {
424 return Err(Error::UnsupportedGitRemote);
425 }
426 Err(Error::UnimplementedRemoteProtocol)
427}
428
429pub struct RepoHandle(git2::Repository);
430pub struct Branch<'a>(git2::Branch<'a>);
431
432impl RepoHandle {
433 pub fn open(path: &Path) -> Result<Self, Error> {
434 Self::open_with_worktree_setup(path, WorktreeSetup::NoWorktree)
435 }
436
437 pub fn open_with_worktree_setup(
438 path: &Path,
439 worktree_setup: WorktreeSetup,
440 ) -> Result<Self, Error> {
441 let open_func = if worktree_setup.is_worktree() {
442 git2::Repository::open_bare
443 } else {
444 git2::Repository::open
445 };
446 let path = if worktree_setup.is_worktree() {
447 path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY)
448 } else {
449 path.to_path_buf()
450 };
451 match open_func(path) {
452 Ok(r) => Ok(Self(r)),
453 Err(e) => match e.code() {
454 git2::ErrorCode::NotFound => Err(Error::RepoNotFound),
455 _ => Err(Error::Libgit(e)),
456 },
457 }
458 }
459
460 pub fn path(&self) -> Result<&Path, Error> {
461 Ok(path::from_std_path(self.0.path())?)
462 }
463
464 pub fn commondir(&self) -> Result<&Path, Error> {
465 Ok(path::from_std_path(self.0.commondir())?)
466 }
467
468 pub fn stash(&self) -> Result<(), Error> {
469 let head_branch = self.head_branch()?;
470 let head = head_branch.commit()?;
471 let author = head.author();
472
473 let mut repo = Self::open(self.path()?)?;
480 repo.0
481 .stash_save2(&author, None, Some(git2::StashFlags::INCLUDE_UNTRACKED))?;
482 Ok(())
483 }
484
485 pub fn stash_pop(&self) -> Result<(), Error> {
486 let mut repo = Self::open(self.path()?)?;
487 repo.0.stash_pop(
488 0,
489 Some(git2::StashApplyOptions::new().reinstantiate_index()),
490 )?;
491 Ok(())
492 }
493
494 pub fn rename_remote(&self, remote: &RemoteHandle, new_name: &RemoteName) -> Result<(), Error> {
495 let failed_refspecs = self
496 .0
497 .remote_rename(remote.name()?.as_str(), new_name.as_str())?;
498
499 if !failed_refspecs.is_empty() {
500 return Err(Error::RefspecRenameFailed);
501 }
502
503 Ok(())
504 }
505
506 pub fn graph_ahead_behind(
507 &self,
508 local_branch: &Branch,
509 remote_branch: &Branch,
510 ) -> Result<(usize, usize), Error> {
511 Ok(self.0.graph_ahead_behind(
512 local_branch.commit()?.id().0,
513 remote_branch.commit()?.id().0,
514 )?)
515 }
516
517 pub fn head_branch(&self) -> Result<Branch<'_>, Error> {
518 let head = self.0.head()?;
519 if !head.is_branch() {
520 return Err(Error::NoBranchCheckedOut);
521 }
522 let branch = self
525 .find_local_branch(&BranchName::new(
526 head.shorthand()
527 .map_err(|_err| Error::BranchNameNotUtf8)?
528 .to_owned(),
529 ))?
530 .ok_or(Error::BranchNotFound)?;
531 Ok(branch)
532 }
533
534 pub fn remote_set_url(&self, name: &RemoteName, url: &RemoteUrl) -> Result<(), Error> {
535 Ok(self.0.remote_set_url(name.as_str(), url.as_str())?)
536 }
537
538 pub fn remote_delete(&self, name: &RemoteName) -> Result<(), Error> {
539 Ok(self.0.remote_delete(name.as_str())?)
540 }
541
542 pub fn is_empty(&self) -> Result<bool, Error> {
543 Ok(self.0.is_empty()?)
544 }
545
546 pub fn is_bare(&self) -> bool {
547 self.0.is_bare()
548 }
549
550 pub fn remotes(&self) -> Result<Vec<RemoteName>, Error> {
551 self.0
552 .remotes()?
553 .iter()
554 .map(|name| {
555 name.map_err(|_err| Error::RemoteNameNotUtf8)
556 .and_then(|name| {
557 name.ok_or(Error::RemoteNameEmpty)
558 .map(|s| RemoteName::new(s.to_owned()))
559 })
560 })
561 .collect()
562 }
563
564 pub fn new_remote(&self, name: &RemoteName, url: &RemoteUrl) -> Result<(), Error> {
565 self.0.remote(name.as_str(), url.as_str())?;
566 Ok(())
567 }
568
569 pub fn fetchall(&self) -> Result<(), Error> {
570 for remote in self.remotes()? {
571 self.fetch(&remote)?;
572 }
573 Ok(())
574 }
575
576 pub fn local_branches(&self) -> Result<Vec<Branch<'_>>, Error> {
577 self.0
578 .branches(Some(git2::BranchType::Local))?
579 .map(|branch| Ok(Branch(branch?.0)))
580 .collect::<Result<Vec<Branch>, Error>>()
581 }
582
583 pub fn remote_branches(&self) -> Result<Vec<Branch<'_>>, Error> {
584 self.0
585 .branches(Some(git2::BranchType::Remote))?
586 .map(|branch| Ok(Branch(branch?.0)))
587 .collect::<Result<Vec<Branch>, Error>>()
588 }
589
590 pub fn fetch(&self, remote_name: &RemoteName) -> Result<(), Error> {
591 let mut remote = self.0.find_remote(remote_name.as_str())?;
592
593 let mut fetch_options = git2::FetchOptions::new();
594 fetch_options.remote_callbacks(get_remote_callbacks());
595
596 for refspec in &remote.fetch_refspecs()? {
597 remote.fetch(
598 &[refspec
599 .map_err(|_err| Error::RemoteNameNotUtf8)?
600 .ok_or(Error::RemoteNameEmpty)?],
601 Some(&mut fetch_options),
602 None,
603 )?;
604 }
605 Ok(())
606 }
607
608 pub fn init(path: &Path, worktree_setup: WorktreeSetup) -> Result<Self, Error> {
609 let repo = if worktree_setup.is_worktree() {
610 git2::Repository::init_bare(path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY))?
611 } else {
612 git2::Repository::init(path)?
613 };
614
615 let repo = Self(repo);
616
617 if worktree_setup.is_worktree() {
618 repo.set_config_push(GitPushDefaultSetting::Upstream)?;
619 }
620
621 Ok(repo)
622 }
623
624 pub fn config(&self) -> Result<git2::Config, Error> {
625 Ok(self.0.config()?)
626 }
627
628 pub fn prune_worktree(&self, name: &WorktreeName) -> Result<(), Error> {
629 let worktree = self.0.find_worktree(name.as_str())?;
630 worktree.prune(None)?;
631 Ok(())
632 }
633
634 pub fn find_remote_branch(
635 &self,
636 remote_name: &RemoteName,
637 branch_name: &BranchName,
638 ) -> Result<Option<Branch<'_>>, Error> {
639 match self.0.find_branch(
640 &format!("{}/{}", remote_name.as_str(), branch_name.as_str()),
641 git2::BranchType::Remote,
642 ) {
643 Ok(branch) => Ok(Some(Branch(branch))),
644 Err(e) => match e.code() {
645 git2::ErrorCode::NotFound => Ok(None),
646 _ => Err(e.into()),
647 },
648 }
649 }
650
651 pub fn find_local_branch(&self, name: &BranchName) -> Result<Option<Branch<'_>>, Error> {
652 match self.0.find_branch(name.as_str(), git2::BranchType::Local) {
653 Ok(branch) => Ok(Some(Branch(branch))),
654 Err(e) => match e.code() {
655 git2::ErrorCode::NotFound => Ok(None),
656 _ => Err(e.into()),
657 },
658 }
659 }
660
661 pub fn create_branch(&self, name: &BranchName, target: &Commit) -> Result<Branch<'_>, Error> {
662 Ok(Branch(self.0.branch(name.as_str(), &target.0, false)?))
663 }
664
665 pub fn set_missing_upstreams(&self) -> Result<Vec<(BranchName, UpstreamDetection)>, Error> {
677 let remotes = self.remotes()?;
678 let mut detections = Vec::new();
679
680 for mut branch in self.local_branches()? {
681 if branch.upstream()?.is_some() {
682 continue;
683 }
684
685 let branch_name = branch.name()?;
686
687 let mut candidates = Vec::new();
688 for remote in &remotes {
689 if self.find_remote_branch(remote, &branch_name)?.is_some() {
690 candidates.push(remote.clone());
691 }
692 }
693
694 if candidates.len() > 1 {
695 detections.push((branch_name, UpstreamDetection::Ambiguous(candidates)));
696 } else if let Some(remote) = candidates.into_iter().next() {
697 branch.set_upstream(&remote, &branch_name)?;
698 detections.push((branch_name, UpstreamDetection::Set(remote)));
699 }
700 }
701
702 Ok(detections)
703 }
704
705 pub fn make_bare(&self, value: bool) -> Result<(), Error> {
706 let mut config = self.config()?;
707
708 config
709 .set_bool(GIT_CONFIG_BARE_KEY.as_str(), value)
710 .map_err(|error| Error::GitConfigSetError {
711 key: GIT_CONFIG_BARE_KEY,
712 error: error.to_string(),
713 })
714 }
715
716 pub fn convert_to_worktree(&self, root_dir: &Path) -> Result<(), WorktreeConversionError> {
722 if let Some(changes) = self
723 .status(WorktreeSetup::NoWorktree)
724 .map_err(|e| WorktreeConversionError::RepoError(e))?
725 .changes
726 {
727 return Err(WorktreeConversionError::Changes(changes));
728 }
729
730 if self
731 .has_untracked_files(WorktreeSetup::NoWorktree)
732 .map_err(|e| WorktreeConversionError::RepoError(e))?
733 {
734 return Err(WorktreeConversionError::Ignored);
735 }
736
737 std::fs::rename(".git", worktree::GIT_MAIN_WORKTREE_DIRECTORY).map_err(|error| {
738 WorktreeConversionError::RenameError(format!("Error moving .git directory: {error}"))
739 })?;
740
741 for entry in root_dir
742 .read_dir_utf8()
743 .map_err(|err| WorktreeConversionError::OpenDirectoryError(err))?
744 {
745 match entry {
746 Ok(entry) => {
747 if entry.file_name() == worktree::GIT_MAIN_WORKTREE_DIRECTORY {
748 continue;
749 }
750 if entry.path().is_file() || entry.path().is_symlink() {
751 if let Err(error) = std::fs::remove_file(entry.path()) {
752 return Err(WorktreeConversionError::RemoveError {
753 path: entry.into_path(),
754 error,
755 });
756 }
757 } else if let Err(error) = std::fs::remove_dir_all(entry.path()) {
758 return Err(WorktreeConversionError::RemoveError {
759 path: entry.into_path(),
760 error,
761 });
762 }
763 }
764 Err(error) => {
765 return Err(WorktreeConversionError::ReadDirectoryError(error));
766 }
767 }
768 }
769
770 let worktree_repo = WorktreeRepoHandle::open(root_dir)
771 .map_err(|error| WorktreeConversionError::RepoError(error))?;
772
773 worktree_repo
774 .as_repo()
775 .make_bare(true)
776 .map_err(|error| WorktreeConversionError::RepoError(error))?;
777
778 worktree_repo
779 .as_repo()
780 .set_config_push(GitPushDefaultSetting::Upstream)
781 .map_err(|error| WorktreeConversionError::RepoError(error))?;
782
783 Ok(())
784 }
785
786 pub fn set_config_push(&self, value: GitPushDefaultSetting) -> Result<(), Error> {
787 let mut config = self.config()?;
788
789 config
790 .set_str(
791 GIT_CONFIG_PUSH_DEFAULT,
792 match value {
793 GitPushDefaultSetting::Upstream => "upstream",
794 },
795 )
796 .map_err(|error| Error::GitConfigSetError {
797 key: GIT_CONFIG_BARE_KEY,
798 error: error.to_string(),
799 })
800 }
801
802 pub fn has_untracked_files(&self, worktree_setup: WorktreeSetup) -> Result<bool, Error> {
803 if worktree_setup.is_worktree() {
804 Err(Error::GettingChangesFromBareWorktree)
805 } else {
806 let statuses = self
807 .0
808 .statuses(Some(git2::StatusOptions::new().include_ignored(true)))?;
809
810 for status in statuses.iter() {
811 let status_bits = status.status();
812 if status_bits.intersects(git2::Status::IGNORED) {
813 return Ok(true);
814 }
815 }
816
817 Ok(false)
818 }
819 }
820
821 pub fn status(&self, worktree_setup: WorktreeSetup) -> Result<RepoStatus, Error> {
822 let operation = match self.0.state() {
823 git2::RepositoryState::Clean => None,
824 state => Some(state),
825 };
826
827 let empty = self.is_empty()?;
828
829 let remotes = self
830 .0
831 .remotes()?
832 .iter()
833 .map(|repo_name| {
834 repo_name
835 .map_err(|_err| Error::RemoteNameNotUtf8)
836 .and_then(|s| {
837 s.ok_or(Error::RemoteNameEmpty)
838 .map(|s| RemoteName::new(s.to_owned()))
839 })
840 })
841 .collect::<Result<Vec<RemoteName>, Error>>()?;
842
843 let head = if worktree_setup.is_worktree() || empty {
844 None
845 } else {
846 Some(self.head_branch()?.name()?)
847 };
848
849 let changes = if worktree_setup.is_worktree() {
850 None
851 } else {
852 let statuses = self.0.statuses(Some(
853 git2::StatusOptions::new()
854 .include_ignored(false)
855 .include_untracked(true),
856 ))?;
857
858 if statuses.is_empty() {
859 None
860 } else {
861 let mut files_new: usize = 0;
862 let mut files_modified: usize = 0;
863 let mut files_deleted: usize = 0;
864 for status in statuses.iter() {
865 let status_bits = status.status();
866 if status_bits.intersects(
867 git2::Status::INDEX_MODIFIED
868 | git2::Status::INDEX_RENAMED
869 | git2::Status::INDEX_TYPECHANGE
870 | git2::Status::WT_MODIFIED
871 | git2::Status::WT_RENAMED
872 | git2::Status::WT_TYPECHANGE,
873 ) {
874 files_modified = files_modified.saturating_add(1);
875 } else if status_bits.intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW)
876 {
877 files_new = files_new.saturating_add(1);
878 } else if status_bits
879 .intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED)
880 {
881 files_deleted = files_deleted.saturating_add(1);
882 }
883 }
884
885 #[expect(clippy::missing_panics_doc, reason = "panicking due to bug")]
886 {
887 assert!(
888 ((files_new, files_modified, files_deleted) != (0, 0, 0)),
889 "is_empty() returned true, but no file changes were detected. This is a bug!"
890 );
891 }
892
893 Some(RepoChanges {
894 files_new,
895 files_modified,
896 files_deleted,
897 })
898 }
899 };
900
901 let worktrees = self.0.worktrees()?.len();
902
903 let submodules = if worktree_setup.is_worktree() {
904 None
905 } else {
906 let mut submodules = Vec::new();
907 for submodule in self.0.submodules()? {
908 let submodule_name = SubmoduleName::new(
909 submodule
910 .name()
911 .map_err(|_err| Error::SubmoduleNameNotUtf8)?
912 .to_owned(),
913 );
914
915 let submodule_status;
916 let status = self
917 .0
918 .submodule_status(submodule_name.as_str(), git2::SubmoduleIgnore::None)?;
919
920 if status.intersects(
921 git2::SubmoduleStatus::WD_INDEX_MODIFIED
922 | git2::SubmoduleStatus::WD_WD_MODIFIED
923 | git2::SubmoduleStatus::WD_UNTRACKED,
924 ) {
925 submodule_status = SubmoduleStatus::Changed;
926 } else if status.is_wd_uninitialized() {
927 submodule_status = SubmoduleStatus::Uninitialized;
928 } else if status.is_wd_modified() {
929 submodule_status = SubmoduleStatus::OutOfDate;
930 } else {
931 submodule_status = SubmoduleStatus::Clean;
932 }
933
934 submodules.push((submodule_name, submodule_status));
935 }
936 Some(submodules)
937 };
938
939 let mut branches = Vec::new();
940 for branch in self.0.branches(Some(git2::BranchType::Local))? {
941 let (local_branch, _branch_type) = branch?;
942 let branch_name = BranchName::new(
943 local_branch
944 .name()
945 .map_err(|e| Error::CannotGetBranchName { inner: e })?
946 .ok_or(Error::BranchNameNotUtf8)?
947 .to_owned(),
948 );
949 let remote_branch = match local_branch.upstream() {
950 Ok(remote_branch) => {
951 let remote_branch_name = BranchName::new(
952 remote_branch
953 .name()
954 .map_err(|e| Error::CannotGetBranchName { inner: e })?
955 .ok_or(Error::BranchNameNotUtf8)?
956 .to_owned(),
957 );
958
959 let (ahead, behind) = self.0.graph_ahead_behind(
960 local_branch.get().peel_to_commit()?.id(),
961 remote_branch.get().peel_to_commit()?.id(),
962 )?;
963
964 let remote_tracking_status = match (ahead, behind) {
965 (0, 0) => RemoteTrackingStatus::UpToDate,
966 (0, d) => RemoteTrackingStatus::Behind(d),
967 (d, 0) => RemoteTrackingStatus::Ahead(d),
968 (d1, d2) => RemoteTrackingStatus::Diverged(d1, d2),
969 };
970 Some((remote_branch_name, remote_tracking_status))
971 }
972 Err(_) => None,
974 };
975 branches.push((branch_name, remote_branch));
976 }
977
978 Ok(RepoStatus {
979 operation,
980 empty,
981 remotes,
982 head,
983 changes,
984 worktrees,
985 submodules,
986 branches,
987 })
988 }
989
990 pub fn get_remote_default_branch(
991 &self,
992 remote_name: &RemoteName,
993 ) -> Result<Option<Branch<'_>>, Error> {
994 if let Some(mut remote) = self.find_remote(remote_name)? {
997 if remote.connected() {
998 let remote = remote; if let Ok(remote_default_branch) = remote.default_branch() {
1000 return Ok(Some(
1001 self.find_local_branch(&remote_default_branch)?
1002 .ok_or(Error::BranchNotFound)?,
1003 ));
1004 }
1005 }
1006 }
1007
1008 match self.find_remote_branch(remote_name, &BranchName::new("HEAD".to_owned()))? {
1011 Some(remote_head) => {
1012 if let Some(pointer_name) = remote_head
1013 .as_reference()
1014 .symbolic_target()
1015 .map_err(|_err| Error::RemoteHeadNotUtf8)?
1016 {
1017 if let Some(local_branch_name) =
1018 pointer_name.strip_prefix(&format!("refs/remotes/{remote_name}/"))
1019 {
1020 Ok(Some(
1021 self.find_local_branch(&BranchName(local_branch_name.to_owned()))?
1022 .ok_or(Error::BranchNotFound)?,
1023 ))
1024 } else {
1025 Err(Error::InvalidRemoteHeadPointer {
1026 name: pointer_name.to_owned(),
1027 })
1028 }
1029 } else {
1030 Err(Error::RemoteHeadNoSymbolicTarget)
1031 }
1032 }
1033 None => Ok(None),
1034 }
1035 }
1036
1037 pub fn default_branch(&self) -> Result<Branch<'_>, Error> {
1038 let remotes = self.remotes()?;
1051
1052 if remotes.len() == 1 {
1053 #[expect(clippy::missing_panics_doc, reason = "see expect() message")]
1054 let remote_name = &remotes.first().expect("checked for len above");
1055 if let Some(default_branch) = self.get_remote_default_branch(remote_name)? {
1056 return Ok(default_branch);
1057 }
1058 } else {
1059 let mut default_branches: Vec<Branch> = vec![];
1060 for remote_name in remotes {
1061 if let Some(default_branch) = self.get_remote_default_branch(&remote_name)? {
1062 default_branches.push(default_branch);
1063 }
1064 }
1065
1066 if !default_branches.is_empty()
1067 && (default_branches.len() == 1
1068 || default_branches
1069 .iter()
1070 .map(Branch::name)
1071 .collect::<Result<Vec<BranchName>, Error>>()?
1072 .windows(2)
1073 .all(
1074 #[expect(
1075 clippy::missing_asserts_for_indexing,
1076 clippy::indexing_slicing,
1077 reason = "windows function always returns two elements"
1078 )]
1079 |branch_names| branch_names[0] == branch_names[1],
1080 ))
1081 {
1082 return Ok(default_branches.remove(0));
1083 }
1084 }
1085
1086 for branch_name in &["main", "master"] {
1087 if let Ok(branch) = self.0.find_branch(branch_name, git2::BranchType::Local) {
1088 return Ok(Branch(branch));
1089 }
1090 }
1091
1092 Err(Error::NoDefaultBranch)
1093 }
1094
1095 pub fn find_remote(&self, remote_name: &RemoteName) -> Result<Option<RemoteHandle<'_>>, Error> {
1101 let remotes = self.0.remotes()?;
1102
1103 if !remotes
1104 .iter()
1105 .map(|remote| {
1106 remote
1107 .map_err(|_err| Error::RemoteNameNotUtf8)
1108 .and_then(|name| name.ok_or(Error::RemoteNameEmpty))
1109 })
1110 .collect::<Result<Vec<_>, Error>>()?
1111 .into_iter()
1112 .any(|remote| remote == remote_name.as_str())
1113 {
1114 return Ok(None);
1115 }
1116
1117 Ok(Some(RemoteHandle(
1118 self.0.find_remote(remote_name.as_str())?,
1119 )))
1120 }
1121}
1122
1123pub struct RemoteHandle<'a>(git2::Remote<'a>);
1124pub struct Commit<'a>(git2::Commit<'a>);
1125pub struct Oid(git2::Oid);
1126
1127impl Oid {
1128 pub fn hex_string(&self) -> String {
1129 self.0.to_string()
1130 }
1131}
1132
1133impl Commit<'_> {
1134 pub fn id(&self) -> Oid {
1135 Oid(self.0.id())
1136 }
1137
1138 pub(self) fn author(&self) -> git2::Signature<'_> {
1139 self.0.author()
1140 }
1141}
1142
1143impl<'a> Branch<'a> {
1144 pub fn to_commit(self) -> Result<Commit<'a>, Error> {
1145 Ok(Commit(self.0.into_reference().peel_to_commit()?))
1146 }
1147
1148 pub fn commit(&self) -> Result<Commit<'_>, Error> {
1149 Ok(Commit(self.0.get().peel_to_commit()?))
1150 }
1151
1152 pub fn commit_owned(self) -> Result<Commit<'a>, Error> {
1153 Ok(Commit(self.0.into_reference().peel_to_commit()?))
1154 }
1155
1156 pub fn set_upstream(
1157 &mut self,
1158 remote_name: &RemoteName,
1159 branch_name: &BranchName,
1160 ) -> Result<(), Error> {
1161 self.0.set_upstream(Some(&format!(
1162 "{}/{}",
1163 remote_name.as_str(),
1164 branch_name.as_str()
1165 )))?;
1166 Ok(())
1167 }
1168
1169 pub fn name(&self) -> Result<BranchName, Error> {
1170 Ok(BranchName::new(
1171 self.0.name()?.ok_or(Error::BranchNameNotUtf8)?.to_owned(),
1172 ))
1173 }
1174
1175 pub fn upstream(&self) -> Result<Option<Branch<'_>>, Error> {
1176 let branch = self.0.upstream();
1177 match branch {
1178 Ok(branch) => Ok(Some(Branch(branch))),
1179 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
1180 Err(err) => Err(err.into()),
1181 }
1182 }
1183
1184 pub fn delete(mut self) -> Result<(), Error> {
1185 Ok(self.0.delete()?)
1186 }
1187
1188 pub fn basename(&self) -> Result<BranchName, Error> {
1189 let name = self.name()?;
1190 if let Some((_prefix, basename)) = name.as_str().split_once('/') {
1191 Ok(BranchName::new(basename.to_owned()))
1192 } else {
1193 Ok(name)
1194 }
1195 }
1196
1197 fn as_reference(&self) -> &git2::Reference<'_> {
1199 self.0.get()
1200 }
1201}
1202
1203fn get_remote_callbacks() -> git2::RemoteCallbacks<'static> {
1204 let mut callbacks = git2::RemoteCallbacks::new();
1205 callbacks.push_update_reference(|_, status| {
1206 if let Some(message) = status {
1207 return Err(git2::Error::new(
1208 git2::ErrorCode::GenericError,
1209 git2::ErrorClass::None,
1210 message,
1211 ));
1212 }
1213 Ok(())
1214 });
1215
1216 callbacks.credentials(|_url, username_from_url, _allowed_types| {
1217 #[expect(clippy::panic, reason = "there is no good way to bubble up that error")]
1218 let Some(username) = username_from_url else {
1219 panic!("Could not get username. This is a bug")
1220 };
1221 git2::Cred::ssh_key_from_agent(username)
1222 });
1223
1224 callbacks
1225}
1226
1227impl RemoteHandle<'_> {
1228 pub fn url(&self) -> Result<RemoteUrl, Error> {
1229 Ok(RemoteUrl::new(
1230 self.0
1231 .url()
1232 .map_err(|_err| Error::RemoteNameNotUtf8)?
1233 .to_owned(),
1234 ))
1235 }
1236
1237 pub fn name(&self) -> Result<RemoteName, Error> {
1238 Ok(RemoteName::new(
1239 self.0
1240 .name()
1241 .map_err(|_err| Error::RemoteNameNotUtf8)?
1242 .ok_or(Error::RemoteNameEmpty)?
1243 .to_owned(),
1244 ))
1245 }
1246
1247 pub fn connected(&mut self) -> bool {
1248 self.0.connected()
1249 }
1250
1251 pub fn default_branch(&self) -> Result<BranchName, Error> {
1252 Ok(BranchName(
1253 self.0
1254 .default_branch()?
1255 .as_str()
1256 .map_err(|_err| Error::RemoteBranchNameNotUtf8)?
1257 .to_owned(),
1258 ))
1259 }
1260
1261 pub fn is_pushable(&self) -> Result<bool, Error> {
1262 let remote_type = detect_remote_type(&RemoteUrl::new(
1263 self.0
1264 .url()
1265 .map_err(|_err| Error::RemoteNameNotUtf8)?
1266 .to_owned(),
1267 ))?;
1268 Ok(matches!(remote_type, RemoteType::Ssh | RemoteType::File))
1269 }
1270
1271 pub fn push(
1272 &mut self,
1273 local_branch_name: &BranchName,
1274 remote_branch_name: &BranchName,
1275 _repo: &RepoHandle,
1276 ) -> Result<(), Error> {
1277 if !self.is_pushable()? {
1278 return Err(Error::NonPushableRemote);
1279 }
1280
1281 let mut push_options = git2::PushOptions::new();
1282 push_options.remote_callbacks(get_remote_callbacks());
1283
1284 let push_refspec = format!(
1285 "+refs/heads/{}:refs/heads/{}",
1286 local_branch_name.as_str(),
1287 remote_branch_name.as_str()
1288 );
1289 self.0
1290 .push(&[push_refspec], Some(&mut push_options))
1291 .map_err(|error| Error::PushFailed {
1292 local_branch: local_branch_name.clone(),
1293 remote_name: match self.name() {
1294 Ok(name) => name,
1295 Err(e) => return e,
1296 },
1297 remote_url: match self.url() {
1298 Ok(url) => url,
1299 Err(e) => return e,
1300 },
1301 message: error.to_string(),
1302 })?;
1303 Ok(())
1304 }
1305}
1306
1307pub fn clone_repo(
1308 remote: &Remote,
1309 path: &Path,
1310 worktree_setup: WorktreeSetup,
1311) -> Result<(), Error> {
1312 let clone_target = if worktree_setup.is_worktree() {
1313 path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY)
1314 } else {
1315 path.to_path_buf()
1316 };
1317
1318 match remote.remote_type {
1319 RemoteType::Https | RemoteType::File => {
1320 let mut builder = git2::build::RepoBuilder::new();
1321
1322 let fetchopts = git2::FetchOptions::new();
1323
1324 builder.bare(worktree_setup.is_worktree());
1325 builder.fetch_options(fetchopts);
1326
1327 builder.clone(remote.url.as_str(), clone_target.as_std_path())?;
1328 }
1329 RemoteType::Ssh => {
1330 let mut fo = git2::FetchOptions::new();
1331 fo.remote_callbacks(get_remote_callbacks());
1332
1333 let mut builder = git2::build::RepoBuilder::new();
1334 builder.bare(worktree_setup.is_worktree());
1335 builder.fetch_options(fo);
1336
1337 builder.clone(remote.url.as_str(), clone_target.as_std_path())?;
1338 }
1339 }
1340
1341 let repo = RepoHandle::open(&clone_target)?;
1342
1343 if worktree_setup.is_worktree() {
1344 repo.set_config_push(GitPushDefaultSetting::Upstream)?;
1345 }
1346
1347 if remote.name != RemoteName::new("origin".to_owned()) {
1348 #[expect(clippy::missing_panics_doc, reason = "see expect() message")]
1349 let origin = repo
1350 .find_remote(&RemoteName::new("origin".to_owned()))?
1351 .expect("the remote will always exist after a successful clone");
1352 repo.rename_remote(&origin, &remote.name)?;
1353 }
1354
1355 for remote_branch in repo.remote_branches()? {
1358 let local_branch_name = remote_branch.basename()?;
1359
1360 if repo.find_local_branch(&local_branch_name).is_ok() {
1361 continue;
1362 }
1363
1364 if local_branch_name.as_str() == "HEAD" {
1366 continue;
1367 }
1368
1369 let mut local_branch = repo.create_branch(&local_branch_name, &remote_branch.commit()?)?;
1370 local_branch.set_upstream(&remote.name, &local_branch_name)?;
1371 }
1372
1373 if let Ok(mut active_branch) = repo.head_branch() {
1376 active_branch.set_upstream(&remote.name, &active_branch.name()?)?;
1377 }
1378
1379 Ok(())
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 use super::*;
1385
1386 #[test]
1387 fn check_ssh_remote() -> Result<(), Error> {
1388 assert_eq!(
1389 detect_remote_type(&RemoteUrl::new("ssh://git@example.com".to_owned()))?,
1390 RemoteType::Ssh
1391 );
1392 assert_eq!(
1393 detect_remote_type(&RemoteUrl::new("git@example.git".to_owned()))?,
1394 RemoteType::Ssh
1395 );
1396 Ok(())
1397 }
1398
1399 #[test]
1400 fn check_https_remote() -> Result<(), Error> {
1401 assert_eq!(
1402 detect_remote_type(&RemoteUrl::new("https://example.com".to_owned()))?,
1403 RemoteType::Https
1404 );
1405 assert_eq!(
1406 detect_remote_type(&RemoteUrl::new("https://example.com/test.git".to_owned()))?,
1407 RemoteType::Https
1408 );
1409 Ok(())
1410 }
1411
1412 #[test]
1413 fn check_file_remote() -> Result<(), Error> {
1414 assert_eq!(
1415 detect_remote_type(&RemoteUrl::new("file:///somedir".to_owned()))?,
1416 RemoteType::File
1417 );
1418 Ok(())
1419 }
1420
1421 #[test]
1422 fn check_invalid_remotes() {
1423 assert!(matches!(
1424 detect_remote_type(&RemoteUrl::new("https//example.com".to_owned())),
1425 Err(Error::UnimplementedRemoteProtocol)
1426 ));
1427 assert!(matches!(
1428 detect_remote_type(&RemoteUrl::new("https:example.com".to_owned())),
1429 Err(Error::UnimplementedRemoteProtocol)
1430 ));
1431 assert!(matches!(
1432 detect_remote_type(&RemoteUrl::new("ssh//example.com".to_owned())),
1433 Err(Error::UnimplementedRemoteProtocol)
1434 ));
1435 assert!(matches!(
1436 detect_remote_type(&RemoteUrl::new("ssh:example.com".to_owned())),
1437 Err(Error::UnimplementedRemoteProtocol)
1438 ));
1439 assert!(matches!(
1440 detect_remote_type(&RemoteUrl::new("git@example.com".to_owned())),
1441 Err(Error::UnimplementedRemoteProtocol)
1442 ));
1443 }
1444
1445 #[test]
1446 fn check_unsupported_protocol_http() {
1447 assert!(matches!(
1448 detect_remote_type(&RemoteUrl::new("http://example.com".to_owned())),
1449 Err(Error::UnsupportedHttpRemote)
1450 ));
1451 }
1452
1453 #[test]
1454 fn check_unsupported_protocol_git() {
1455 assert!(matches!(
1456 detect_remote_type(&RemoteUrl::new("git://example.com".to_owned())),
1457 Err(Error::UnsupportedGitRemote)
1458 ));
1459 }
1460
1461 #[test]
1462 fn repo_check_fullname() {
1463 let with_namespace = Repo {
1464 name: RepoName::new("name".to_owned()),
1465 namespace: Some(RepoNamespace::new("namespace".to_owned())),
1466 worktree_setup: WorktreeSetup::NoWorktree,
1467 remotes: Vec::new(),
1468 };
1469
1470 let without_namespace = Repo {
1471 name: RepoName::new("name".to_owned()),
1472 namespace: None,
1473 worktree_setup: WorktreeSetup::NoWorktree,
1474 remotes: Vec::new(),
1475 };
1476
1477 assert_eq!(
1478 with_namespace.fullname(),
1479 RepoName::new("namespace/name".to_owned())
1480 );
1481 assert_eq!(
1482 without_namespace.fullname(),
1483 RepoName::new("name".to_owned())
1484 );
1485 }
1486}