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 struct RepoStatus {
335 pub operation: Option<git2::RepositoryState>,
336
337 pub empty: bool,
338
339 pub remotes: Vec<RemoteName>,
340
341 pub head: Option<BranchName>,
342
343 pub changes: Option<RepoChanges>,
344
345 pub worktrees: usize,
346
347 pub submodules: Option<Vec<(SubmoduleName, SubmoduleStatus)>>,
348
349 pub branches: Vec<(BranchName, Option<(BranchName, RemoteTrackingStatus)>)>,
350}
351
352impl RepoStatus {
353 fn clean(&self) -> bool {
354 match self.changes {
355 None => true,
356 Some(ref changes) => {
357 changes.files_new == 0 && changes.files_deleted == 0 && changes.files_modified == 0
358 }
359 }
360 }
361}
362
363pub fn detect_remote_type(remote_url: &RemoteUrl) -> Result<RemoteType, Error> {
364 let remote_url = remote_url.as_str();
365
366 #[expect(clippy::missing_panics_doc, reason = "regex is valid")]
367 let git_regex = regex::Regex::new(r"^[a-zA-Z]+@.*$").expect("regex is valid");
368 if remote_url.starts_with("ssh://") {
369 return Ok(RemoteType::Ssh);
370 }
371 #[expect(
372 clippy::case_sensitive_file_extension_comparisons,
373 reason = "the extension is always lower case"
374 )]
375 if git_regex.is_match(remote_url) && remote_url.ends_with(".git") {
376 return Ok(RemoteType::Ssh);
377 }
378 if remote_url.starts_with("https://") {
379 return Ok(RemoteType::Https);
380 }
381 if remote_url.starts_with("file://") {
382 return Ok(RemoteType::File);
383 }
384 if remote_url.starts_with("http://") {
385 return Err(Error::UnsupportedHttpRemote);
386 }
387 if remote_url.starts_with("git://") {
388 return Err(Error::UnsupportedGitRemote);
389 }
390 Err(Error::UnimplementedRemoteProtocol)
391}
392
393pub struct RepoHandle(git2::Repository);
394pub struct Branch<'a>(git2::Branch<'a>);
395
396impl RepoHandle {
397 pub fn open(path: &Path) -> Result<Self, Error> {
398 Self::open_with_worktree_setup(path, WorktreeSetup::NoWorktree)
399 }
400
401 pub fn open_with_worktree_setup(
402 path: &Path,
403 worktree_setup: WorktreeSetup,
404 ) -> Result<Self, Error> {
405 let open_func = if worktree_setup.is_worktree() {
406 git2::Repository::open_bare
407 } else {
408 git2::Repository::open
409 };
410 let path = if worktree_setup.is_worktree() {
411 path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY)
412 } else {
413 path.to_path_buf()
414 };
415 match open_func(path) {
416 Ok(r) => Ok(Self(r)),
417 Err(e) => match e.code() {
418 git2::ErrorCode::NotFound => Err(Error::RepoNotFound),
419 _ => Err(Error::Libgit(e)),
420 },
421 }
422 }
423
424 pub fn path(&self) -> Result<&Path, Error> {
425 Ok(path::from_std_path(self.0.path())?)
426 }
427
428 pub fn commondir(&self) -> Result<&Path, Error> {
429 Ok(path::from_std_path(self.0.commondir())?)
430 }
431
432 pub fn stash(&self) -> Result<(), Error> {
433 let head_branch = self.head_branch()?;
434 let head = head_branch.commit()?;
435 let author = head.author();
436
437 let mut repo = Self::open(self.path()?)?;
444 repo.0
445 .stash_save2(&author, None, Some(git2::StashFlags::INCLUDE_UNTRACKED))?;
446 Ok(())
447 }
448
449 pub fn stash_pop(&self) -> Result<(), Error> {
450 let mut repo = Self::open(self.path()?)?;
451 repo.0.stash_pop(
452 0,
453 Some(git2::StashApplyOptions::new().reinstantiate_index()),
454 )?;
455 Ok(())
456 }
457
458 pub fn rename_remote(&self, remote: &RemoteHandle, new_name: &RemoteName) -> Result<(), Error> {
459 let failed_refspecs = self
460 .0
461 .remote_rename(remote.name()?.as_str(), new_name.as_str())?;
462
463 if !failed_refspecs.is_empty() {
464 return Err(Error::RefspecRenameFailed);
465 }
466
467 Ok(())
468 }
469
470 pub fn graph_ahead_behind(
471 &self,
472 local_branch: &Branch,
473 remote_branch: &Branch,
474 ) -> Result<(usize, usize), Error> {
475 Ok(self.0.graph_ahead_behind(
476 local_branch.commit()?.id().0,
477 remote_branch.commit()?.id().0,
478 )?)
479 }
480
481 pub fn head_branch(&self) -> Result<Branch<'_>, Error> {
482 let head = self.0.head()?;
483 if !head.is_branch() {
484 return Err(Error::NoBranchCheckedOut);
485 }
486 let branch = self
489 .find_local_branch(&BranchName::new(
490 head.shorthand()
491 .map_err(|_err| Error::BranchNameNotUtf8)?
492 .to_owned(),
493 ))?
494 .ok_or(Error::BranchNotFound)?;
495 Ok(branch)
496 }
497
498 pub fn remote_set_url(&self, name: &RemoteName, url: &RemoteUrl) -> Result<(), Error> {
499 Ok(self.0.remote_set_url(name.as_str(), url.as_str())?)
500 }
501
502 pub fn remote_delete(&self, name: &RemoteName) -> Result<(), Error> {
503 Ok(self.0.remote_delete(name.as_str())?)
504 }
505
506 pub fn is_empty(&self) -> Result<bool, Error> {
507 Ok(self.0.is_empty()?)
508 }
509
510 pub fn is_bare(&self) -> bool {
511 self.0.is_bare()
512 }
513
514 pub fn remotes(&self) -> Result<Vec<RemoteName>, Error> {
515 self.0
516 .remotes()?
517 .iter()
518 .map(|name| {
519 name.map_err(|_err| Error::RemoteNameNotUtf8)
520 .and_then(|name| {
521 name.ok_or(Error::RemoteNameEmpty)
522 .map(|s| RemoteName::new(s.to_owned()))
523 })
524 })
525 .collect()
526 }
527
528 pub fn new_remote(&self, name: &RemoteName, url: &RemoteUrl) -> Result<(), Error> {
529 self.0.remote(name.as_str(), url.as_str())?;
530 Ok(())
531 }
532
533 pub fn fetchall(&self) -> Result<(), Error> {
534 for remote in self.remotes()? {
535 self.fetch(&remote)?;
536 }
537 Ok(())
538 }
539
540 pub fn local_branches(&self) -> Result<Vec<Branch<'_>>, Error> {
541 self.0
542 .branches(Some(git2::BranchType::Local))?
543 .map(|branch| Ok(Branch(branch?.0)))
544 .collect::<Result<Vec<Branch>, Error>>()
545 }
546
547 pub fn remote_branches(&self) -> Result<Vec<Branch<'_>>, Error> {
548 self.0
549 .branches(Some(git2::BranchType::Remote))?
550 .map(|branch| Ok(Branch(branch?.0)))
551 .collect::<Result<Vec<Branch>, Error>>()
552 }
553
554 pub fn fetch(&self, remote_name: &RemoteName) -> Result<(), Error> {
555 let mut remote = self.0.find_remote(remote_name.as_str())?;
556
557 let mut fetch_options = git2::FetchOptions::new();
558 fetch_options.remote_callbacks(get_remote_callbacks());
559
560 for refspec in &remote.fetch_refspecs()? {
561 remote.fetch(
562 &[refspec
563 .map_err(|_err| Error::RemoteNameNotUtf8)?
564 .ok_or(Error::RemoteNameEmpty)?],
565 Some(&mut fetch_options),
566 None,
567 )?;
568 }
569 Ok(())
570 }
571
572 pub fn init(path: &Path, worktree_setup: WorktreeSetup) -> Result<Self, Error> {
573 let repo = if worktree_setup.is_worktree() {
574 git2::Repository::init_bare(path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY))?
575 } else {
576 git2::Repository::init(path)?
577 };
578
579 let repo = Self(repo);
580
581 if worktree_setup.is_worktree() {
582 repo.set_config_push(GitPushDefaultSetting::Upstream)?;
583 }
584
585 Ok(repo)
586 }
587
588 pub fn config(&self) -> Result<git2::Config, Error> {
589 Ok(self.0.config()?)
590 }
591
592 pub fn prune_worktree(&self, name: &WorktreeName) -> Result<(), Error> {
593 let worktree = self.0.find_worktree(name.as_str())?;
594 worktree.prune(None)?;
595 Ok(())
596 }
597
598 pub fn find_remote_branch(
599 &self,
600 remote_name: &RemoteName,
601 branch_name: &BranchName,
602 ) -> Result<Option<Branch<'_>>, Error> {
603 match self.0.find_branch(
604 &format!("{}/{}", remote_name.as_str(), branch_name.as_str()),
605 git2::BranchType::Remote,
606 ) {
607 Ok(branch) => Ok(Some(Branch(branch))),
608 Err(e) => match e.code() {
609 git2::ErrorCode::NotFound => Ok(None),
610 _ => Err(e.into()),
611 },
612 }
613 }
614
615 pub fn find_local_branch(&self, name: &BranchName) -> Result<Option<Branch<'_>>, Error> {
616 match self.0.find_branch(name.as_str(), git2::BranchType::Local) {
617 Ok(branch) => Ok(Some(Branch(branch))),
618 Err(e) => match e.code() {
619 git2::ErrorCode::NotFound => Ok(None),
620 _ => Err(e.into()),
621 },
622 }
623 }
624
625 pub fn create_branch(&self, name: &BranchName, target: &Commit) -> Result<Branch<'_>, Error> {
626 Ok(Branch(self.0.branch(name.as_str(), &target.0, false)?))
627 }
628
629 pub fn make_bare(&self, value: bool) -> Result<(), Error> {
630 let mut config = self.config()?;
631
632 config
633 .set_bool(GIT_CONFIG_BARE_KEY.as_str(), value)
634 .map_err(|error| Error::GitConfigSetError {
635 key: GIT_CONFIG_BARE_KEY,
636 error: error.to_string(),
637 })
638 }
639
640 pub fn convert_to_worktree(&self, root_dir: &Path) -> Result<(), WorktreeConversionError> {
646 if let Some(changes) = self
647 .status(WorktreeSetup::NoWorktree)
648 .map_err(|e| WorktreeConversionError::RepoError(e))?
649 .changes
650 {
651 return Err(WorktreeConversionError::Changes(changes));
652 }
653
654 if self
655 .has_untracked_files(WorktreeSetup::NoWorktree)
656 .map_err(|e| WorktreeConversionError::RepoError(e))?
657 {
658 return Err(WorktreeConversionError::Ignored);
659 }
660
661 std::fs::rename(".git", worktree::GIT_MAIN_WORKTREE_DIRECTORY).map_err(|error| {
662 WorktreeConversionError::RenameError(format!("Error moving .git directory: {error}"))
663 })?;
664
665 for entry in root_dir
666 .read_dir_utf8()
667 .map_err(|err| WorktreeConversionError::OpenDirectoryError(err))?
668 {
669 match entry {
670 Ok(entry) => {
671 if entry.file_name() == worktree::GIT_MAIN_WORKTREE_DIRECTORY {
672 continue;
673 }
674 if entry.path().is_file() || entry.path().is_symlink() {
675 if let Err(error) = std::fs::remove_file(entry.path()) {
676 return Err(WorktreeConversionError::RemoveError {
677 path: entry.into_path(),
678 error,
679 });
680 }
681 } else if let Err(error) = std::fs::remove_dir_all(entry.path()) {
682 return Err(WorktreeConversionError::RemoveError {
683 path: entry.into_path(),
684 error,
685 });
686 }
687 }
688 Err(error) => {
689 return Err(WorktreeConversionError::ReadDirectoryError(error));
690 }
691 }
692 }
693
694 let worktree_repo = WorktreeRepoHandle::open(root_dir)
695 .map_err(|error| WorktreeConversionError::RepoError(error))?;
696
697 worktree_repo
698 .as_repo()
699 .make_bare(true)
700 .map_err(|error| WorktreeConversionError::RepoError(error))?;
701
702 worktree_repo
703 .as_repo()
704 .set_config_push(GitPushDefaultSetting::Upstream)
705 .map_err(|error| WorktreeConversionError::RepoError(error))?;
706
707 Ok(())
708 }
709
710 pub fn set_config_push(&self, value: GitPushDefaultSetting) -> Result<(), Error> {
711 let mut config = self.config()?;
712
713 config
714 .set_str(
715 GIT_CONFIG_PUSH_DEFAULT,
716 match value {
717 GitPushDefaultSetting::Upstream => "upstream",
718 },
719 )
720 .map_err(|error| Error::GitConfigSetError {
721 key: GIT_CONFIG_BARE_KEY,
722 error: error.to_string(),
723 })
724 }
725
726 pub fn has_untracked_files(&self, worktree_setup: WorktreeSetup) -> Result<bool, Error> {
727 if worktree_setup.is_worktree() {
728 Err(Error::GettingChangesFromBareWorktree)
729 } else {
730 let statuses = self
731 .0
732 .statuses(Some(git2::StatusOptions::new().include_ignored(true)))?;
733
734 for status in statuses.iter() {
735 let status_bits = status.status();
736 if status_bits.intersects(git2::Status::IGNORED) {
737 return Ok(true);
738 }
739 }
740
741 Ok(false)
742 }
743 }
744
745 pub fn status(&self, worktree_setup: WorktreeSetup) -> Result<RepoStatus, Error> {
746 let operation = match self.0.state() {
747 git2::RepositoryState::Clean => None,
748 state => Some(state),
749 };
750
751 let empty = self.is_empty()?;
752
753 let remotes = self
754 .0
755 .remotes()?
756 .iter()
757 .map(|repo_name| {
758 repo_name
759 .map_err(|_err| Error::RemoteNameNotUtf8)
760 .and_then(|s| {
761 s.ok_or(Error::RemoteNameEmpty)
762 .map(|s| RemoteName::new(s.to_owned()))
763 })
764 })
765 .collect::<Result<Vec<RemoteName>, Error>>()?;
766
767 let head = if worktree_setup.is_worktree() || empty {
768 None
769 } else {
770 Some(self.head_branch()?.name()?)
771 };
772
773 let changes = if worktree_setup.is_worktree() {
774 None
775 } else {
776 let statuses = self.0.statuses(Some(
777 git2::StatusOptions::new()
778 .include_ignored(false)
779 .include_untracked(true),
780 ))?;
781
782 if statuses.is_empty() {
783 None
784 } else {
785 let mut files_new: usize = 0;
786 let mut files_modified: usize = 0;
787 let mut files_deleted: usize = 0;
788 for status in statuses.iter() {
789 let status_bits = status.status();
790 if status_bits.intersects(
791 git2::Status::INDEX_MODIFIED
792 | git2::Status::INDEX_RENAMED
793 | git2::Status::INDEX_TYPECHANGE
794 | git2::Status::WT_MODIFIED
795 | git2::Status::WT_RENAMED
796 | git2::Status::WT_TYPECHANGE,
797 ) {
798 files_modified = files_modified.saturating_add(1);
799 } else if status_bits.intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW)
800 {
801 files_new = files_new.saturating_add(1);
802 } else if status_bits
803 .intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED)
804 {
805 files_deleted = files_deleted.saturating_add(1);
806 }
807 }
808
809 #[expect(clippy::missing_panics_doc, reason = "panicking due to bug")]
810 {
811 assert!(
812 ((files_new, files_modified, files_deleted) != (0, 0, 0)),
813 "is_empty() returned true, but no file changes were detected. This is a bug!"
814 );
815 }
816
817 Some(RepoChanges {
818 files_new,
819 files_modified,
820 files_deleted,
821 })
822 }
823 };
824
825 let worktrees = self.0.worktrees()?.len();
826
827 let submodules = if worktree_setup.is_worktree() {
828 None
829 } else {
830 let mut submodules = Vec::new();
831 for submodule in self.0.submodules()? {
832 let submodule_name = SubmoduleName::new(
833 submodule
834 .name()
835 .map_err(|_err| Error::SubmoduleNameNotUtf8)?
836 .to_owned(),
837 );
838
839 let submodule_status;
840 let status = self
841 .0
842 .submodule_status(submodule_name.as_str(), git2::SubmoduleIgnore::None)?;
843
844 if status.intersects(
845 git2::SubmoduleStatus::WD_INDEX_MODIFIED
846 | git2::SubmoduleStatus::WD_WD_MODIFIED
847 | git2::SubmoduleStatus::WD_UNTRACKED,
848 ) {
849 submodule_status = SubmoduleStatus::Changed;
850 } else if status.is_wd_uninitialized() {
851 submodule_status = SubmoduleStatus::Uninitialized;
852 } else if status.is_wd_modified() {
853 submodule_status = SubmoduleStatus::OutOfDate;
854 } else {
855 submodule_status = SubmoduleStatus::Clean;
856 }
857
858 submodules.push((submodule_name, submodule_status));
859 }
860 Some(submodules)
861 };
862
863 let mut branches = Vec::new();
864 for branch in self.0.branches(Some(git2::BranchType::Local))? {
865 let (local_branch, _branch_type) = branch?;
866 let branch_name = BranchName::new(
867 local_branch
868 .name()
869 .map_err(|e| Error::CannotGetBranchName { inner: e })?
870 .ok_or(Error::BranchNameNotUtf8)?
871 .to_owned(),
872 );
873 let remote_branch = match local_branch.upstream() {
874 Ok(remote_branch) => {
875 let remote_branch_name = BranchName::new(
876 remote_branch
877 .name()
878 .map_err(|e| Error::CannotGetBranchName { inner: e })?
879 .ok_or(Error::BranchNameNotUtf8)?
880 .to_owned(),
881 );
882
883 let (ahead, behind) = self.0.graph_ahead_behind(
884 local_branch.get().peel_to_commit()?.id(),
885 remote_branch.get().peel_to_commit()?.id(),
886 )?;
887
888 let remote_tracking_status = match (ahead, behind) {
889 (0, 0) => RemoteTrackingStatus::UpToDate,
890 (0, d) => RemoteTrackingStatus::Behind(d),
891 (d, 0) => RemoteTrackingStatus::Ahead(d),
892 (d1, d2) => RemoteTrackingStatus::Diverged(d1, d2),
893 };
894 Some((remote_branch_name, remote_tracking_status))
895 }
896 Err(_) => None,
898 };
899 branches.push((branch_name, remote_branch));
900 }
901
902 Ok(RepoStatus {
903 operation,
904 empty,
905 remotes,
906 head,
907 changes,
908 worktrees,
909 submodules,
910 branches,
911 })
912 }
913
914 pub fn get_remote_default_branch(
915 &self,
916 remote_name: &RemoteName,
917 ) -> Result<Option<Branch<'_>>, Error> {
918 if let Some(mut remote) = self.find_remote(remote_name)? {
921 if remote.connected() {
922 let remote = remote; if let Ok(remote_default_branch) = remote.default_branch() {
924 return Ok(Some(
925 self.find_local_branch(&remote_default_branch)?
926 .ok_or(Error::BranchNotFound)?,
927 ));
928 }
929 }
930 }
931
932 match self.find_remote_branch(remote_name, &BranchName::new("HEAD".to_owned()))? {
935 Some(remote_head) => {
936 if let Some(pointer_name) = remote_head
937 .as_reference()
938 .symbolic_target()
939 .map_err(|_err| Error::RemoteHeadNotUtf8)?
940 {
941 if let Some(local_branch_name) =
942 pointer_name.strip_prefix(&format!("refs/remotes/{remote_name}/"))
943 {
944 Ok(Some(
945 self.find_local_branch(&BranchName(local_branch_name.to_owned()))?
946 .ok_or(Error::BranchNotFound)?,
947 ))
948 } else {
949 Err(Error::InvalidRemoteHeadPointer {
950 name: pointer_name.to_owned(),
951 })
952 }
953 } else {
954 Err(Error::RemoteHeadNoSymbolicTarget)
955 }
956 }
957 None => Ok(None),
958 }
959 }
960
961 pub fn default_branch(&self) -> Result<Branch<'_>, Error> {
962 let remotes = self.remotes()?;
975
976 if remotes.len() == 1 {
977 #[expect(clippy::missing_panics_doc, reason = "see expect() message")]
978 let remote_name = &remotes.first().expect("checked for len above");
979 if let Some(default_branch) = self.get_remote_default_branch(remote_name)? {
980 return Ok(default_branch);
981 }
982 } else {
983 let mut default_branches: Vec<Branch> = vec![];
984 for remote_name in remotes {
985 if let Some(default_branch) = self.get_remote_default_branch(&remote_name)? {
986 default_branches.push(default_branch);
987 }
988 }
989
990 if !default_branches.is_empty()
991 && (default_branches.len() == 1
992 || default_branches
993 .iter()
994 .map(Branch::name)
995 .collect::<Result<Vec<BranchName>, Error>>()?
996 .windows(2)
997 .all(
998 #[expect(
999 clippy::missing_asserts_for_indexing,
1000 clippy::indexing_slicing,
1001 reason = "windows function always returns two elements"
1002 )]
1003 |branch_names| branch_names[0] == branch_names[1],
1004 ))
1005 {
1006 return Ok(default_branches.remove(0));
1007 }
1008 }
1009
1010 for branch_name in &["main", "master"] {
1011 if let Ok(branch) = self.0.find_branch(branch_name, git2::BranchType::Local) {
1012 return Ok(Branch(branch));
1013 }
1014 }
1015
1016 Err(Error::NoDefaultBranch)
1017 }
1018
1019 pub fn find_remote(&self, remote_name: &RemoteName) -> Result<Option<RemoteHandle<'_>>, Error> {
1025 let remotes = self.0.remotes()?;
1026
1027 if !remotes
1028 .iter()
1029 .map(|remote| {
1030 remote
1031 .map_err(|_err| Error::RemoteNameNotUtf8)
1032 .and_then(|name| name.ok_or(Error::RemoteNameEmpty))
1033 })
1034 .collect::<Result<Vec<_>, Error>>()?
1035 .into_iter()
1036 .any(|remote| remote == remote_name.as_str())
1037 {
1038 return Ok(None);
1039 }
1040
1041 Ok(Some(RemoteHandle(
1042 self.0.find_remote(remote_name.as_str())?,
1043 )))
1044 }
1045}
1046
1047pub struct RemoteHandle<'a>(git2::Remote<'a>);
1048pub struct Commit<'a>(git2::Commit<'a>);
1049pub struct Oid(git2::Oid);
1050
1051impl Oid {
1052 pub fn hex_string(&self) -> String {
1053 self.0.to_string()
1054 }
1055}
1056
1057impl Commit<'_> {
1058 pub fn id(&self) -> Oid {
1059 Oid(self.0.id())
1060 }
1061
1062 pub(self) fn author(&self) -> git2::Signature<'_> {
1063 self.0.author()
1064 }
1065}
1066
1067impl<'a> Branch<'a> {
1068 pub fn to_commit(self) -> Result<Commit<'a>, Error> {
1069 Ok(Commit(self.0.into_reference().peel_to_commit()?))
1070 }
1071
1072 pub fn commit(&self) -> Result<Commit<'_>, Error> {
1073 Ok(Commit(self.0.get().peel_to_commit()?))
1074 }
1075
1076 pub fn commit_owned(self) -> Result<Commit<'a>, Error> {
1077 Ok(Commit(self.0.into_reference().peel_to_commit()?))
1078 }
1079
1080 pub fn set_upstream(
1081 &mut self,
1082 remote_name: &RemoteName,
1083 branch_name: &BranchName,
1084 ) -> Result<(), Error> {
1085 self.0.set_upstream(Some(&format!(
1086 "{}/{}",
1087 remote_name.as_str(),
1088 branch_name.as_str()
1089 )))?;
1090 Ok(())
1091 }
1092
1093 pub fn name(&self) -> Result<BranchName, Error> {
1094 Ok(BranchName::new(
1095 self.0.name()?.ok_or(Error::BranchNameNotUtf8)?.to_owned(),
1096 ))
1097 }
1098
1099 pub fn upstream(&self) -> Result<Option<Branch<'_>>, Error> {
1100 let branch = self.0.upstream();
1101 match branch {
1102 Ok(branch) => Ok(Some(Branch(branch))),
1103 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
1104 Err(err) => Err(err.into()),
1105 }
1106 }
1107
1108 pub fn delete(mut self) -> Result<(), Error> {
1109 Ok(self.0.delete()?)
1110 }
1111
1112 pub fn basename(&self) -> Result<BranchName, Error> {
1113 let name = self.name()?;
1114 if let Some((_prefix, basename)) = name.as_str().split_once('/') {
1115 Ok(BranchName::new(basename.to_owned()))
1116 } else {
1117 Ok(name)
1118 }
1119 }
1120
1121 fn as_reference(&self) -> &git2::Reference<'_> {
1123 self.0.get()
1124 }
1125}
1126
1127fn get_remote_callbacks() -> git2::RemoteCallbacks<'static> {
1128 let mut callbacks = git2::RemoteCallbacks::new();
1129 callbacks.push_update_reference(|_, status| {
1130 if let Some(message) = status {
1131 return Err(git2::Error::new(
1132 git2::ErrorCode::GenericError,
1133 git2::ErrorClass::None,
1134 message,
1135 ));
1136 }
1137 Ok(())
1138 });
1139
1140 callbacks.credentials(|_url, username_from_url, _allowed_types| {
1141 #[expect(clippy::panic, reason = "there is no good way to bubble up that error")]
1142 let Some(username) = username_from_url else {
1143 panic!("Could not get username. This is a bug")
1144 };
1145 git2::Cred::ssh_key_from_agent(username)
1146 });
1147
1148 callbacks
1149}
1150
1151impl RemoteHandle<'_> {
1152 pub fn url(&self) -> Result<RemoteUrl, Error> {
1153 Ok(RemoteUrl::new(
1154 self.0
1155 .url()
1156 .map_err(|_err| Error::RemoteNameNotUtf8)?
1157 .to_owned(),
1158 ))
1159 }
1160
1161 pub fn name(&self) -> Result<RemoteName, Error> {
1162 Ok(RemoteName::new(
1163 self.0
1164 .name()
1165 .map_err(|_err| Error::RemoteNameNotUtf8)?
1166 .ok_or(Error::RemoteNameEmpty)?
1167 .to_owned(),
1168 ))
1169 }
1170
1171 pub fn connected(&mut self) -> bool {
1172 self.0.connected()
1173 }
1174
1175 pub fn default_branch(&self) -> Result<BranchName, Error> {
1176 Ok(BranchName(
1177 self.0
1178 .default_branch()?
1179 .as_str()
1180 .map_err(|_err| Error::RemoteBranchNameNotUtf8)?
1181 .to_owned(),
1182 ))
1183 }
1184
1185 pub fn is_pushable(&self) -> Result<bool, Error> {
1186 let remote_type = detect_remote_type(&RemoteUrl::new(
1187 self.0
1188 .url()
1189 .map_err(|_err| Error::RemoteNameNotUtf8)?
1190 .to_owned(),
1191 ))?;
1192 Ok(matches!(remote_type, RemoteType::Ssh | RemoteType::File))
1193 }
1194
1195 pub fn push(
1196 &mut self,
1197 local_branch_name: &BranchName,
1198 remote_branch_name: &BranchName,
1199 _repo: &RepoHandle,
1200 ) -> Result<(), Error> {
1201 if !self.is_pushable()? {
1202 return Err(Error::NonPushableRemote);
1203 }
1204
1205 let mut push_options = git2::PushOptions::new();
1206 push_options.remote_callbacks(get_remote_callbacks());
1207
1208 let push_refspec = format!(
1209 "+refs/heads/{}:refs/heads/{}",
1210 local_branch_name.as_str(),
1211 remote_branch_name.as_str()
1212 );
1213 self.0
1214 .push(&[push_refspec], Some(&mut push_options))
1215 .map_err(|error| Error::PushFailed {
1216 local_branch: local_branch_name.clone(),
1217 remote_name: match self.name() {
1218 Ok(name) => name,
1219 Err(e) => return e,
1220 },
1221 remote_url: match self.url() {
1222 Ok(url) => url,
1223 Err(e) => return e,
1224 },
1225 message: error.to_string(),
1226 })?;
1227 Ok(())
1228 }
1229}
1230
1231pub fn clone_repo(
1232 remote: &Remote,
1233 path: &Path,
1234 worktree_setup: WorktreeSetup,
1235) -> Result<(), Error> {
1236 let clone_target = if worktree_setup.is_worktree() {
1237 path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY)
1238 } else {
1239 path.to_path_buf()
1240 };
1241
1242 match remote.remote_type {
1243 RemoteType::Https | RemoteType::File => {
1244 let mut builder = git2::build::RepoBuilder::new();
1245
1246 let fetchopts = git2::FetchOptions::new();
1247
1248 builder.bare(worktree_setup.is_worktree());
1249 builder.fetch_options(fetchopts);
1250
1251 builder.clone(remote.url.as_str(), clone_target.as_std_path())?;
1252 }
1253 RemoteType::Ssh => {
1254 let mut fo = git2::FetchOptions::new();
1255 fo.remote_callbacks(get_remote_callbacks());
1256
1257 let mut builder = git2::build::RepoBuilder::new();
1258 builder.bare(worktree_setup.is_worktree());
1259 builder.fetch_options(fo);
1260
1261 builder.clone(remote.url.as_str(), clone_target.as_std_path())?;
1262 }
1263 }
1264
1265 let repo = RepoHandle::open(&clone_target)?;
1266
1267 if worktree_setup.is_worktree() {
1268 repo.set_config_push(GitPushDefaultSetting::Upstream)?;
1269 }
1270
1271 if remote.name != RemoteName::new("origin".to_owned()) {
1272 #[expect(clippy::missing_panics_doc, reason = "see expect() message")]
1273 let origin = repo
1274 .find_remote(&RemoteName::new("origin".to_owned()))?
1275 .expect("the remote will always exist after a successful clone");
1276 repo.rename_remote(&origin, &remote.name)?;
1277 }
1278
1279 for remote_branch in repo.remote_branches()? {
1282 let local_branch_name = remote_branch.basename()?;
1283
1284 if repo.find_local_branch(&local_branch_name).is_ok() {
1285 continue;
1286 }
1287
1288 if local_branch_name.as_str() == "HEAD" {
1290 continue;
1291 }
1292
1293 let mut local_branch = repo.create_branch(&local_branch_name, &remote_branch.commit()?)?;
1294 local_branch.set_upstream(&remote.name, &local_branch_name)?;
1295 }
1296
1297 if let Ok(mut active_branch) = repo.head_branch() {
1300 active_branch.set_upstream(&remote.name, &active_branch.name()?)?;
1301 }
1302
1303 Ok(())
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308 use super::*;
1309
1310 #[test]
1311 fn check_ssh_remote() -> Result<(), Error> {
1312 assert_eq!(
1313 detect_remote_type(&RemoteUrl::new("ssh://git@example.com".to_owned()))?,
1314 RemoteType::Ssh
1315 );
1316 assert_eq!(
1317 detect_remote_type(&RemoteUrl::new("git@example.git".to_owned()))?,
1318 RemoteType::Ssh
1319 );
1320 Ok(())
1321 }
1322
1323 #[test]
1324 fn check_https_remote() -> Result<(), Error> {
1325 assert_eq!(
1326 detect_remote_type(&RemoteUrl::new("https://example.com".to_owned()))?,
1327 RemoteType::Https
1328 );
1329 assert_eq!(
1330 detect_remote_type(&RemoteUrl::new("https://example.com/test.git".to_owned()))?,
1331 RemoteType::Https
1332 );
1333 Ok(())
1334 }
1335
1336 #[test]
1337 fn check_file_remote() -> Result<(), Error> {
1338 assert_eq!(
1339 detect_remote_type(&RemoteUrl::new("file:///somedir".to_owned()))?,
1340 RemoteType::File
1341 );
1342 Ok(())
1343 }
1344
1345 #[test]
1346 fn check_invalid_remotes() {
1347 assert!(matches!(
1348 detect_remote_type(&RemoteUrl::new("https//example.com".to_owned())),
1349 Err(Error::UnimplementedRemoteProtocol)
1350 ));
1351 assert!(matches!(
1352 detect_remote_type(&RemoteUrl::new("https:example.com".to_owned())),
1353 Err(Error::UnimplementedRemoteProtocol)
1354 ));
1355 assert!(matches!(
1356 detect_remote_type(&RemoteUrl::new("ssh//example.com".to_owned())),
1357 Err(Error::UnimplementedRemoteProtocol)
1358 ));
1359 assert!(matches!(
1360 detect_remote_type(&RemoteUrl::new("ssh:example.com".to_owned())),
1361 Err(Error::UnimplementedRemoteProtocol)
1362 ));
1363 assert!(matches!(
1364 detect_remote_type(&RemoteUrl::new("git@example.com".to_owned())),
1365 Err(Error::UnimplementedRemoteProtocol)
1366 ));
1367 }
1368
1369 #[test]
1370 fn check_unsupported_protocol_http() {
1371 assert!(matches!(
1372 detect_remote_type(&RemoteUrl::new("http://example.com".to_owned())),
1373 Err(Error::UnsupportedHttpRemote)
1374 ));
1375 }
1376
1377 #[test]
1378 fn check_unsupported_protocol_git() {
1379 assert!(matches!(
1380 detect_remote_type(&RemoteUrl::new("git://example.com".to_owned())),
1381 Err(Error::UnsupportedGitRemote)
1382 ));
1383 }
1384
1385 #[test]
1386 fn repo_check_fullname() {
1387 let with_namespace = Repo {
1388 name: RepoName::new("name".to_owned()),
1389 namespace: Some(RepoNamespace::new("namespace".to_owned())),
1390 worktree_setup: WorktreeSetup::NoWorktree,
1391 remotes: Vec::new(),
1392 };
1393
1394 let without_namespace = Repo {
1395 name: RepoName::new("name".to_owned()),
1396 namespace: None,
1397 worktree_setup: WorktreeSetup::NoWorktree,
1398 remotes: Vec::new(),
1399 };
1400
1401 assert_eq!(
1402 with_namespace.fullname(),
1403 RepoName::new("namespace/name".to_owned())
1404 );
1405 assert_eq!(
1406 without_namespace.fullname(),
1407 RepoName::new("name".to_owned())
1408 );
1409 }
1410}