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