1use std::{
2 fmt, iter,
3 path::{Path, PathBuf},
4};
5
6use git2::Repository;
7use thiserror::Error;
8
9use super::{
10 BranchName, RemoteName, RemoteUrl, SubmoduleName, Warning, config,
11 output::{print_action, print_success},
12 path,
13 worktree::{self, WorktreeName},
14};
15
16const GIT_CONFIG_BARE_KEY: GitConfigKey = GitConfigKey("core.bare");
17const GIT_CONFIG_PUSH_DEFAULT: &str = "push.default";
18
19#[derive(Debug, PartialEq, Eq)]
20pub enum RemoteType {
21 Ssh,
22 Https,
23 File,
24}
25
26impl From<config::RemoteType> for RemoteType {
27 fn from(value: config::RemoteType) -> Self {
28 match value {
29 config::RemoteType::Ssh => Self::Ssh,
30 config::RemoteType::Https => Self::Https,
31 config::RemoteType::File => Self::File,
32 }
33 }
34}
35
36impl From<RemoteType> for config::RemoteType {
37 fn from(value: RemoteType) -> Self {
38 match value {
39 RemoteType::Ssh => Self::Ssh,
40 RemoteType::Https => Self::Https,
41 RemoteType::File => Self::File,
42 }
43 }
44}
45
46#[derive(Debug, Error)]
47pub enum WorktreeRemoveFailureReason {
48 #[error("Changes found")]
49 Changes(String),
50 #[error("{}", .0)]
51 Error(String),
52 #[error("Worktree is not merged")]
53 NotMerged(String),
54}
55
56#[derive(Debug, Error)]
57pub enum WorktreeConversionFailureReason {
58 #[error("Changes found")]
59 Changes,
60 #[error("Ignored files found")]
61 Ignored,
62 #[error("{}", .0)]
63 Error(String),
64}
65
66#[derive(Clone, Copy)]
67pub enum GitPushDefaultSetting {
68 Upstream,
69}
70
71#[derive(Debug)]
72pub struct GitConfigKey(&'static str);
73
74impl GitConfigKey {
75 fn as_str(&self) -> &str {
76 self.0
77 }
78}
79
80impl fmt::Display for GitConfigKey {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 write!(f, "{}", self.0)
83 }
84}
85
86#[derive(Debug, Error)]
87pub enum Error {
88 #[error("Error reading configuration file \"{:?}\": {}", .path, .message)]
89 ReadConfig { message: String, path: PathBuf },
90 #[error("Error parsing configuration file \"{:?}\": {}", .path, .message)]
91 ParseConfig { message: String, path: PathBuf },
92 #[error(transparent)]
93 Libgit(#[from] git2::Error),
94 #[error(transparent)]
95 Io(#[from] std::io::Error),
96 #[error(transparent)]
97 Config(#[from] config::Error),
98 #[error("Repository not found")]
99 NotFound,
100 #[error("Could not determine default branch")]
101 NoDefaultBranch,
102 #[error("Failed getting default branch name: {}", .message)]
103 ErrorDefaultBranch { message: String },
104 #[error("Remotes using HTTP protocol are not supported")]
105 UnsupportedHttpRemote,
106 #[error("Remotes using git protocol are not supported")]
107 UnsupportedGitRemote,
108 #[error("The remote URL starts with an unimplemented protocol")]
109 UnimplementedRemoteProtocol,
110 #[error("Some non-default refspecs could not be renamed")]
111 RefspecRenameFailed,
112 #[error("No branch checked out")]
113 NoBranchCheckedOut,
114 #[error("Could not set {key}: {error}", key = .key, error = .error)]
115 GitConfigSetError { key: GitConfigKey, error: String },
116 #[error(transparent)]
117 WorktreeConversionFailure(WorktreeConversionFailureReason),
118 #[error(transparent)]
119 WorktreeRemovalFailure(WorktreeRemoveFailureReason),
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(
125 "Pushing {} to {} ({}) failed: {}",
126 .local_branch,
127 .remote_name,
128 .remote_url,
129 .message
130 )]
131 PushFailed {
132 local_branch: BranchName,
133 remote_name: RemoteName,
134 remote_url: RemoteUrl,
135 message: String,
136 },
137 #[error(transparent)]
138 Path(#[from] path::Error),
139 #[error("Branch name is not valid utf-8")]
140 BranchNameNotUtf8,
141 #[error("Remote name is not valid utf-8")]
142 RemoteNameNotUtf8,
143 #[error("Remote branch name is not valid utf-8")]
144 RemoteBranchNameNotUtf8,
145 #[error("Worktree name is not valid utf-8")]
146 WorktreeNameNotUtf8,
147 #[error("Submodule name is not valid utf-8")]
148 SubmoduleNameNotUtf8,
149 #[error("Submodule name is not valid utf-8")]
150 CannotGetBranchName {
151 #[source]
152 inner: git2::Error,
153 },
154 #[error("Remote HEAD ({}) pointer is invalid", .name)]
155 InvalidRemoteHeadPointer { name: String },
156 #[error("Remote HEAD does not point to a symbolic target")]
157 RemoteHeadNoSymbolicTarget,
158}
159
160#[derive(Debug)]
161pub struct Remote {
162 pub name: RemoteName,
163 pub url: RemoteUrl,
164 pub remote_type: RemoteType,
165}
166
167impl From<config::Remote> for Remote {
168 fn from(other: config::Remote) -> Self {
169 Self {
170 name: RemoteName::new(other.name),
171 url: RemoteUrl::new(other.url),
172 remote_type: other.remote_type.into(),
173 }
174 }
175}
176
177impl From<Remote> for config::Remote {
178 fn from(other: Remote) -> Self {
179 Self {
180 name: other.name.into_string(),
181 url: other.url.into_string(),
182 remote_type: other.remote_type.into(),
183 }
184 }
185}
186
187#[derive(Clone, Debug, PartialEq, Eq)]
188pub struct ProjectName(String);
189
190impl ProjectName {
191 pub fn new(from: String) -> Self {
192 Self(from)
193 }
194
195 pub fn into_string(self) -> String {
196 self.0
197 }
198
199 pub fn as_str(&self) -> &str {
200 &self.0
201 }
202}
203
204impl fmt::Display for ProjectName {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 write!(f, "{}", self.0)
207 }
208}
209
210#[derive(Debug)]
211pub struct ProjectNamespace(String);
212
213impl ProjectNamespace {
214 pub fn new(from: String) -> Self {
215 Self(from)
216 }
217
218 pub fn into_string(self) -> String {
219 self.0
220 }
221
222 pub fn as_str(&self) -> &str {
223 &self.0
224 }
225}
226
227#[derive(Debug)]
228pub struct Repo {
229 pub name: ProjectName,
230 pub namespace: Option<ProjectNamespace>,
231 pub worktree_setup: bool,
232 pub remotes: Vec<Remote>,
233}
234
235impl From<config::Repo> for Repo {
236 fn from(other: config::Repo) -> Self {
237 let (namespace, name) = if let Some((namespace, name)) = other.name.rsplit_once('/') {
238 (Some(namespace.to_owned()), name.to_owned())
239 } else {
240 (None, other.name)
241 };
242
243 Self {
244 name: ProjectName::new(name),
245 namespace: namespace.map(ProjectNamespace::new),
246 worktree_setup: other.worktree_setup,
247 remotes: other
248 .remotes
249 .map(|remotes| remotes.into_iter().map(Into::into).collect())
250 .unwrap_or_else(|| Vec::new()),
251 }
252 }
253}
254
255impl From<Repo> for config::Repo {
256 fn from(other: Repo) -> Self {
257 Self {
258 name: other.name.into_string(),
259 worktree_setup: other.worktree_setup,
260 remotes: Some(other.remotes.into_iter().map(Into::into).collect()),
261 }
262 }
263}
264
265impl Repo {
266 pub fn fullname(&self) -> ProjectName {
267 match self.namespace {
268 Some(ref namespace) => {
269 ProjectName(format!("{}/{}", namespace.as_str(), self.name.as_str()))
270 }
271 None => ProjectName(self.name.as_str().to_owned()),
272 }
273 }
274
275 pub fn remove_namespace(&mut self) {
276 self.namespace = None;
277 }
278}
279
280pub struct TrackingConfig {
281 pub default: bool,
282 pub default_remote: RemoteName,
283 pub default_remote_prefix: Option<String>,
284}
285
286impl From<config::TrackingConfig> for TrackingConfig {
287 fn from(other: config::TrackingConfig) -> Self {
288 Self {
289 default: other.default,
290 default_remote: RemoteName::new(other.default_remote),
291 default_remote_prefix: other.default_remote_prefix,
292 }
293 }
294}
295
296pub struct WorktreeRootConfig {
297 pub persistent_branches: Option<Vec<BranchName>>,
298 pub track: Option<TrackingConfig>,
299}
300
301impl From<config::WorktreeRootConfig> for WorktreeRootConfig {
302 fn from(other: config::WorktreeRootConfig) -> Self {
303 Self {
304 persistent_branches: other
305 .persistent_branches
306 .map(|branches| branches.into_iter().map(BranchName::new).collect()),
307 track: other.track.map(Into::into),
308 }
309 }
310}
311
312pub struct RepoChanges {
313 pub files_new: usize,
314 pub files_modified: usize,
315 pub files_deleted: usize,
316}
317
318pub enum SubmoduleStatus {
319 Clean,
320 Uninitialized,
321 Changed,
322 OutOfDate,
323}
324
325pub enum RemoteTrackingStatus {
326 UpToDate,
327 Ahead(usize),
328 Behind(usize),
329 Diverged(usize, usize),
330}
331
332pub struct RepoStatus {
333 pub operation: Option<git2::RepositoryState>,
334
335 pub empty: bool,
336
337 pub remotes: Vec<RemoteName>,
338
339 pub head: Option<BranchName>,
340
341 pub changes: Option<RepoChanges>,
342
343 pub worktrees: usize,
344
345 pub submodules: Option<Vec<(SubmoduleName, SubmoduleStatus)>>,
346
347 pub branches: Vec<(BranchName, Option<(BranchName, RemoteTrackingStatus)>)>,
348}
349
350pub struct Worktree {
351 name: WorktreeName,
352}
353
354impl Worktree {
355 pub fn new(name: &str) -> Self {
356 Self {
357 name: WorktreeName::new(name.to_owned()),
358 }
359 }
360
361 pub fn name(&self) -> &WorktreeName {
362 &self.name
363 }
364
365 pub fn forward_branch(&self, rebase: bool, stash: bool) -> Result<Option<Warning>, Error> {
366 let repo = RepoHandle::open(Path::new(&self.name.as_str()), false)?;
367
368 if let Ok(remote_branch) = repo
369 .find_local_branch(&BranchName::new(self.name.as_str().to_owned()))?
370 .ok_or(Error::NotFound)?
371 .upstream()
372 {
373 let status = repo.status(false)?;
374 let mut stashed_changes = false;
375
376 if !status.clean() {
377 if stash {
378 repo.stash()?;
379 stashed_changes = true;
380 } else {
381 return Ok(Some(Warning(String::from("Worktree contains changes"))));
382 }
383 }
384
385 let unstash = || -> Result<(), Error> {
386 if stashed_changes {
387 repo.stash_pop()?;
388 }
389 Ok(())
390 };
391
392 let remote_annotated_commit = repo
393 .0
394 .find_annotated_commit(remote_branch.commit()?.id().0)?;
395
396 if rebase {
397 let mut rebase = repo.0.rebase(
398 None, Some(&remote_annotated_commit),
400 None, Some(&mut git2::RebaseOptions::new()),
402 )?;
403
404 while let Some(operation) = rebase.next() {
405 let operation = operation?;
406
407 let rebased_commit = repo.0.find_commit(operation.id())?;
410 let committer = rebased_commit.committer();
411
412 let mut index = repo.0.index()?;
415 index.add_all(iter::once("."), git2::IndexAddOption::CHECK_PATHSPEC, None)?;
416
417 if let Err(error) = rebase.commit(None, &committer, None) {
418 if error.code() == git2::ErrorCode::Applied {
419 continue;
420 }
421 rebase.abort()?;
422 unstash()?;
423 return Err(error.into());
424 }
425 }
426
427 rebase.finish(None)?;
428 } else {
429 let (analysis, _preference) = repo.0.merge_analysis(&[&remote_annotated_commit])?;
430
431 if analysis.is_up_to_date() {
432 unstash()?;
433 return Ok(None);
434 }
435 if !analysis.is_fast_forward() {
436 unstash()?;
437 return Ok(Some(Warning(String::from(
438 "Worktree cannot be fast forwarded",
439 ))));
440 }
441
442 repo.0.reset(
443 remote_branch.commit()?.0.as_object(),
444 git2::ResetType::Hard,
445 Some(git2::build::CheckoutBuilder::new().safe()),
446 )?;
447 }
448 unstash()?;
449 } else {
450 return Ok(Some(Warning(String::from(
451 "No remote branch to rebase onto",
452 ))));
453 }
454
455 Ok(None)
456 }
457
458 pub fn rebase_onto_default(
459 &self,
460 config: &Option<WorktreeRootConfig>,
461 stash: bool,
462 ) -> Result<Option<Warning>, Error> {
463 let repo = RepoHandle::open(Path::new(&self.name.as_str()), false)?;
464
465 let guess_default_branch = || repo.default_branch()?.name();
466
467 let default_branch_name = match *config {
468 None => guess_default_branch()?,
469 Some(ref config) => match config.persistent_branches {
470 None => guess_default_branch()?,
471 Some(ref persistent_branches) => {
472 if let Some(branch) = persistent_branches.first() {
473 branch.clone()
474 } else {
475 guess_default_branch()?
476 }
477 }
478 },
479 };
480
481 let status = repo.status(false)?;
482 let mut stashed_changes = false;
483
484 if !status.clean() {
485 if stash {
486 repo.stash()?;
487 stashed_changes = true;
488 } else {
489 return Ok(Some(Warning("Worktree contains changes".to_owned())));
490 }
491 }
492
493 let unstash = || -> Result<(), Error> {
494 if stashed_changes {
495 repo.stash_pop()?;
496 }
497 Ok(())
498 };
499
500 let base_branch = repo
501 .find_local_branch(&default_branch_name)?
502 .ok_or(Error::NotFound)?;
503 let base_annotated_commit = repo.0.find_annotated_commit(base_branch.commit()?.id().0)?;
504
505 let mut rebase = repo.0.rebase(
506 None, Some(&base_annotated_commit),
508 None, Some(&mut git2::RebaseOptions::new()),
510 )?;
511
512 while let Some(operation) = rebase.next() {
513 let operation = operation?;
514
515 let rebased_commit = repo.0.find_commit(operation.id())?;
518 let committer = rebased_commit.committer();
519
520 let mut index = repo.0.index()?;
523 index.add_all(iter::once("."), git2::IndexAddOption::CHECK_PATHSPEC, None)?;
524
525 if let Err(error) = rebase.commit(None, &committer, None) {
526 if error.code() == git2::ErrorCode::Applied {
527 continue;
528 }
529 rebase.abort()?;
530 unstash()?;
531 return Err(error.into());
532 }
533 }
534
535 rebase.finish(None)?;
536 unstash()?;
537 Ok(None)
538 }
539}
540
541impl RepoStatus {
542 fn clean(&self) -> bool {
543 match self.changes {
544 None => true,
545 Some(ref changes) => {
546 changes.files_new == 0 && changes.files_deleted == 0 && changes.files_modified == 0
547 }
548 }
549 }
550}
551
552pub fn detect_remote_type(remote_url: &RemoteUrl) -> Result<RemoteType, Error> {
553 let remote_url = remote_url.as_str();
554
555 #[expect(clippy::missing_panics_doc, reason = "regex is valid")]
556 let git_regex = regex::Regex::new(r"^[a-zA-Z]+@.*$").expect("regex is valid");
557 if remote_url.starts_with("ssh://") {
558 return Ok(RemoteType::Ssh);
559 }
560 #[expect(
561 clippy::case_sensitive_file_extension_comparisons,
562 reason = "the extension is always lower case"
563 )]
564 if git_regex.is_match(remote_url) && remote_url.ends_with(".git") {
565 return Ok(RemoteType::Ssh);
566 }
567 if remote_url.starts_with("https://") {
568 return Ok(RemoteType::Https);
569 }
570 if remote_url.starts_with("file://") {
571 return Ok(RemoteType::File);
572 }
573 if remote_url.starts_with("http://") {
574 return Err(Error::UnsupportedHttpRemote);
575 }
576 if remote_url.starts_with("git://") {
577 return Err(Error::UnsupportedGitRemote);
578 }
579 Err(Error::UnimplementedRemoteProtocol)
580}
581
582pub struct RepoHandle(git2::Repository);
583pub struct Branch<'a>(git2::Branch<'a>);
584
585impl RepoHandle {
586 pub fn open(path: &Path, is_worktree: bool) -> Result<Self, Error> {
587 let open_func = if is_worktree {
588 Repository::open_bare
589 } else {
590 Repository::open
591 };
592 let path = if is_worktree {
593 path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY)
594 } else {
595 path.to_path_buf()
596 };
597 match open_func(path) {
598 Ok(r) => Ok(Self(r)),
599 Err(e) => match e.code() {
600 git2::ErrorCode::NotFound => Err(Error::NotFound),
601 _ => Err(Error::Libgit(e)),
602 },
603 }
604 }
605
606 pub fn stash(&self) -> Result<(), Error> {
607 let head_branch = self.head_branch()?;
608 let head = head_branch.commit()?;
609 let author = head.author();
610
611 let mut repo = Self::open(self.0.path(), false)?;
618 repo.0
619 .stash_save2(&author, None, Some(git2::StashFlags::INCLUDE_UNTRACKED))?;
620 Ok(())
621 }
622
623 pub fn stash_pop(&self) -> Result<(), Error> {
624 let mut repo = Self::open(self.0.path(), false)?;
625 repo.0.stash_pop(
626 0,
627 Some(git2::StashApplyOptions::new().reinstantiate_index()),
628 )?;
629 Ok(())
630 }
631
632 pub fn rename_remote(&self, remote: &RemoteHandle, new_name: &RemoteName) -> Result<(), Error> {
633 let failed_refspecs = self
634 .0
635 .remote_rename(remote.name()?.as_str(), new_name.as_str())?;
636
637 if !failed_refspecs.is_empty() {
638 return Err(Error::RefspecRenameFailed);
639 }
640
641 Ok(())
642 }
643
644 pub fn graph_ahead_behind(
645 &self,
646 local_branch: &Branch,
647 remote_branch: &Branch,
648 ) -> Result<(usize, usize), Error> {
649 Ok(self.0.graph_ahead_behind(
650 local_branch.commit()?.id().0,
651 remote_branch.commit()?.id().0,
652 )?)
653 }
654
655 pub fn head_branch(&self) -> Result<Branch<'_>, Error> {
656 let head = self.0.head()?;
657 if !head.is_branch() {
658 return Err(Error::NoBranchCheckedOut);
659 }
660 let branch = self
663 .find_local_branch(&BranchName::new(
664 head.shorthand().ok_or(Error::BranchNameNotUtf8)?.to_owned(),
665 ))?
666 .ok_or(Error::NotFound)?;
667 Ok(branch)
668 }
669
670 pub fn remote_set_url(&self, name: &RemoteName, url: &RemoteUrl) -> Result<(), Error> {
671 Ok(self.0.remote_set_url(name.as_str(), url.as_str())?)
672 }
673
674 pub fn remote_delete(&self, name: &RemoteName) -> Result<(), Error> {
675 Ok(self.0.remote_delete(name.as_str())?)
676 }
677
678 pub fn is_empty(&self) -> Result<bool, Error> {
679 Ok(self.0.is_empty()?)
680 }
681
682 pub fn is_bare(&self) -> bool {
683 self.0.is_bare()
684 }
685
686 pub fn new_worktree(
687 &self,
688 name: &str,
689 directory: &Path,
690 target_branch: &Branch,
691 ) -> Result<(), Error> {
692 self.0.worktree(
693 name,
694 directory,
695 Some(git2::WorktreeAddOptions::new().reference(Some(target_branch.as_reference()))),
696 )?;
697 Ok(())
698 }
699
700 pub fn remotes(&self) -> Result<Vec<RemoteName>, Error> {
701 self.0
702 .remotes()?
703 .iter()
704 .map(|name| {
705 name.ok_or(Error::RemoteNameNotUtf8)
706 .map(|s| RemoteName::new(s.to_owned()))
707 })
708 .collect()
709 }
710
711 pub fn new_remote(&self, name: &RemoteName, url: &RemoteUrl) -> Result<(), Error> {
712 self.0.remote(name.as_str(), url.as_str())?;
713 Ok(())
714 }
715
716 pub fn fetchall(&self) -> Result<(), Error> {
717 for remote in self.remotes()? {
718 self.fetch(&remote)?;
719 }
720 Ok(())
721 }
722
723 pub fn local_branches(&self) -> Result<Vec<Branch<'_>>, Error> {
724 self.0
725 .branches(Some(git2::BranchType::Local))?
726 .map(|branch| Ok(Branch(branch?.0)))
727 .collect::<Result<Vec<Branch>, Error>>()
728 }
729
730 pub fn remote_branches(&self) -> Result<Vec<Branch<'_>>, Error> {
731 self.0
732 .branches(Some(git2::BranchType::Remote))?
733 .map(|branch| Ok(Branch(branch?.0)))
734 .collect::<Result<Vec<Branch>, Error>>()
735 }
736
737 pub fn fetch(&self, remote_name: &RemoteName) -> Result<(), Error> {
738 let mut remote = self.0.find_remote(remote_name.as_str())?;
739
740 let mut fetch_options = git2::FetchOptions::new();
741 fetch_options.remote_callbacks(get_remote_callbacks());
742
743 for refspec in &remote.fetch_refspecs()? {
744 remote.fetch(
745 &[refspec.ok_or(Error::RemoteNameNotUtf8)?],
746 Some(&mut fetch_options),
747 None,
748 )?;
749 }
750 Ok(())
751 }
752
753 pub fn init(path: &Path, is_worktree: bool) -> Result<Self, Error> {
754 let repo = if is_worktree {
755 Repository::init_bare(path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY))?
756 } else {
757 Repository::init(path)?
758 };
759
760 let repo = Self(repo);
761
762 if is_worktree {
763 repo.set_config_push(GitPushDefaultSetting::Upstream)?;
764 }
765
766 Ok(repo)
767 }
768
769 pub fn config(&self) -> Result<git2::Config, Error> {
770 Ok(self.0.config()?)
771 }
772
773 pub fn find_worktree(&self, name: &WorktreeName) -> Result<(), Error> {
774 self.0.find_worktree(name.as_str())?;
775 Ok(())
776 }
777
778 pub fn prune_worktree(&self, name: &WorktreeName) -> Result<(), Error> {
779 let worktree = self.0.find_worktree(name.as_str())?;
780 worktree.prune(None)?;
781 Ok(())
782 }
783
784 pub fn find_remote_branch(
785 &self,
786 remote_name: &RemoteName,
787 branch_name: &BranchName,
788 ) -> Result<Branch<'_>, Error> {
789 Ok(Branch(self.0.find_branch(
790 &format!("{}/{}", remote_name.as_str(), branch_name.as_str()),
791 git2::BranchType::Remote,
792 )?))
793 }
794
795 pub fn find_local_branch(&self, name: &BranchName) -> Result<Option<Branch<'_>>, Error> {
796 match self.0.find_branch(name.as_str(), git2::BranchType::Local) {
797 Ok(branch) => Ok(Some(Branch(branch))),
798 Err(e) => match e.code() {
799 git2::ErrorCode::NotFound => Ok(None),
800 _ => Err(e.into()),
801 },
802 }
803 }
804
805 pub fn create_branch(&self, name: &BranchName, target: &Commit) -> Result<Branch<'_>, Error> {
806 Ok(Branch(self.0.branch(name.as_str(), &target.0, false)?))
807 }
808
809 pub fn make_bare(&self, value: bool) -> Result<(), Error> {
810 let mut config = self.config()?;
811
812 config
813 .set_bool(GIT_CONFIG_BARE_KEY.as_str(), value)
814 .map_err(|error| Error::GitConfigSetError {
815 key: GIT_CONFIG_BARE_KEY,
816 error: error.to_string(),
817 })
818 }
819
820 pub fn convert_to_worktree(&self, root_dir: &Path) -> Result<(), Error> {
821 if self
822 .status(false)
823 .map_err(|e| {
824 Error::WorktreeConversionFailure(WorktreeConversionFailureReason::Error(
825 e.to_string(),
826 ))
827 })?
828 .changes
829 .is_some()
830 {
831 return Err(Error::WorktreeConversionFailure(
832 WorktreeConversionFailureReason::Changes,
833 ));
834 }
835
836 if self.has_untracked_files(false).map_err(|e| {
837 Error::WorktreeConversionFailure(WorktreeConversionFailureReason::Error(e.to_string()))
838 })? {
839 return Err(Error::WorktreeConversionFailure(
840 WorktreeConversionFailureReason::Ignored,
841 ));
842 }
843
844 std::fs::rename(".git", worktree::GIT_MAIN_WORKTREE_DIRECTORY).map_err(|error| {
845 Error::WorktreeConversionFailure(WorktreeConversionFailureReason::Error(format!(
846 "Error moving .git directory: {error}"
847 )))
848 })?;
849
850 for entry in match std::fs::read_dir(root_dir) {
851 Ok(iterator) => iterator,
852 Err(error) => {
853 return Err(Error::WorktreeConversionFailure(
854 WorktreeConversionFailureReason::Error(format!(
855 "Opening directory failed: {error}"
856 )),
857 ));
858 }
859 } {
860 match entry {
861 Ok(entry) => {
862 let path = entry.path();
863 #[expect(
864 clippy::missing_panics_doc,
865 reason = "the path will ALWAYS have a file component"
866 )]
867 if path.file_name().expect("path has a file name component")
868 == worktree::GIT_MAIN_WORKTREE_DIRECTORY
869 {
870 continue;
871 }
872 if path.is_file() || path.is_symlink() {
873 if let Err(error) = std::fs::remove_file(&path) {
874 return Err(Error::WorktreeConversionFailure(
875 WorktreeConversionFailureReason::Error(format!(
876 "Failed removing {error}"
877 )),
878 ));
879 }
880 } else if let Err(error) = std::fs::remove_dir_all(&path) {
881 return Err(Error::WorktreeConversionFailure(
882 WorktreeConversionFailureReason::Error(format!(
883 "Failed removing {error}"
884 )),
885 ));
886 }
887 }
888 Err(error) => {
889 return Err(Error::WorktreeConversionFailure(
890 WorktreeConversionFailureReason::Error(format!(
891 "Error getting directory entry: {error}"
892 )),
893 ));
894 }
895 }
896 }
897
898 let worktree_repo = Self::open(root_dir, true).map_err(|error| {
899 Error::WorktreeConversionFailure(WorktreeConversionFailureReason::Error(format!(
900 "Opening newly converted repository failed: {error}"
901 )))
902 })?;
903
904 worktree_repo.make_bare(true).map_err(|error| {
905 Error::WorktreeConversionFailure(WorktreeConversionFailureReason::Error(format!(
906 "Error: {error}"
907 )))
908 })?;
909
910 worktree_repo
911 .set_config_push(GitPushDefaultSetting::Upstream)
912 .map_err(|error| {
913 Error::WorktreeConversionFailure(WorktreeConversionFailureReason::Error(format!(
914 "Error: {error}"
915 )))
916 })?;
917
918 Ok(())
919 }
920
921 pub fn set_config_push(&self, value: GitPushDefaultSetting) -> Result<(), Error> {
922 let mut config = self.config()?;
923
924 config
925 .set_str(
926 GIT_CONFIG_PUSH_DEFAULT,
927 match value {
928 GitPushDefaultSetting::Upstream => "upstream",
929 },
930 )
931 .map_err(|error| Error::GitConfigSetError {
932 key: GIT_CONFIG_BARE_KEY,
933 error: error.to_string(),
934 })
935 }
936
937 pub fn has_untracked_files(&self, is_worktree: bool) -> Result<bool, Error> {
938 if is_worktree {
939 Err(Error::GettingChangesFromBareWorktree)
940 } else {
941 let statuses = self
942 .0
943 .statuses(Some(git2::StatusOptions::new().include_ignored(true)))?;
944
945 for status in statuses.iter() {
946 let status_bits = status.status();
947 if status_bits.intersects(git2::Status::IGNORED) {
948 return Ok(true);
949 }
950 }
951
952 Ok(false)
953 }
954 }
955
956 pub fn status(&self, is_worktree: bool) -> Result<RepoStatus, Error> {
957 let operation = match self.0.state() {
958 git2::RepositoryState::Clean => None,
959 state => Some(state),
960 };
961
962 let empty = self.is_empty()?;
963
964 let remotes = self
965 .0
966 .remotes()?
967 .iter()
968 .map(|repo_name| {
969 repo_name
970 .ok_or(Error::WorktreeNameNotUtf8)
971 .map(|s| RemoteName::new(s.to_owned()))
972 })
973 .collect::<Result<Vec<RemoteName>, Error>>()?;
974
975 let head = if is_worktree || empty {
976 None
977 } else {
978 Some(self.head_branch()?.name()?)
979 };
980
981 let changes = if is_worktree {
982 None
983 } else {
984 let statuses = self.0.statuses(Some(
985 git2::StatusOptions::new()
986 .include_ignored(false)
987 .include_untracked(true),
988 ))?;
989
990 if statuses.is_empty() {
991 None
992 } else {
993 let mut files_new: usize = 0;
994 let mut files_modified: usize = 0;
995 let mut files_deleted: usize = 0;
996 for status in statuses.iter() {
997 let status_bits = status.status();
998 if status_bits.intersects(
999 git2::Status::INDEX_MODIFIED
1000 | git2::Status::INDEX_RENAMED
1001 | git2::Status::INDEX_TYPECHANGE
1002 | git2::Status::WT_MODIFIED
1003 | git2::Status::WT_RENAMED
1004 | git2::Status::WT_TYPECHANGE,
1005 ) {
1006 files_modified = files_modified.saturating_add(1);
1007 } else if status_bits.intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW)
1008 {
1009 files_new = files_new.saturating_add(1);
1010 } else if status_bits
1011 .intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED)
1012 {
1013 files_deleted = files_deleted.saturating_add(1);
1014 }
1015 }
1016
1017 #[expect(clippy::missing_panics_doc, reason = "panicking due to bug")]
1018 {
1019 assert!(
1020 ((files_new, files_modified, files_deleted) != (0, 0, 0)),
1021 "is_empty() returned true, but no file changes were detected. This is a bug!"
1022 );
1023 }
1024
1025 Some(RepoChanges {
1026 files_new,
1027 files_modified,
1028 files_deleted,
1029 })
1030 }
1031 };
1032
1033 let worktrees = self.0.worktrees()?.len();
1034
1035 let submodules = if is_worktree {
1036 None
1037 } else {
1038 let mut submodules = Vec::new();
1039 for submodule in self.0.submodules()? {
1040 let submodule_name = SubmoduleName::new(
1041 submodule
1042 .name()
1043 .ok_or(Error::SubmoduleNameNotUtf8)?
1044 .to_owned(),
1045 );
1046
1047 let submodule_status;
1048 let status = self
1049 .0
1050 .submodule_status(submodule_name.as_str(), git2::SubmoduleIgnore::None)?;
1051
1052 if status.intersects(
1053 git2::SubmoduleStatus::WD_INDEX_MODIFIED
1054 | git2::SubmoduleStatus::WD_WD_MODIFIED
1055 | git2::SubmoduleStatus::WD_UNTRACKED,
1056 ) {
1057 submodule_status = SubmoduleStatus::Changed;
1058 } else if status.is_wd_uninitialized() {
1059 submodule_status = SubmoduleStatus::Uninitialized;
1060 } else if status.is_wd_modified() {
1061 submodule_status = SubmoduleStatus::OutOfDate;
1062 } else {
1063 submodule_status = SubmoduleStatus::Clean;
1064 }
1065
1066 submodules.push((submodule_name, submodule_status));
1067 }
1068 Some(submodules)
1069 };
1070
1071 let mut branches = Vec::new();
1072 for branch in self.0.branches(Some(git2::BranchType::Local))? {
1073 let (local_branch, _branch_type) = branch?;
1074 let branch_name = BranchName::new(
1075 local_branch
1076 .name()
1077 .map_err(|e| Error::CannotGetBranchName { inner: e })?
1078 .ok_or(Error::BranchNameNotUtf8)?
1079 .to_owned(),
1080 );
1081 let remote_branch = match local_branch.upstream() {
1082 Ok(remote_branch) => {
1083 let remote_branch_name = BranchName::new(
1084 remote_branch
1085 .name()
1086 .map_err(|e| Error::CannotGetBranchName { inner: e })?
1087 .ok_or(Error::BranchNameNotUtf8)?
1088 .to_owned(),
1089 );
1090
1091 let (ahead, behind) = self.0.graph_ahead_behind(
1092 local_branch.get().peel_to_commit()?.id(),
1093 remote_branch.get().peel_to_commit()?.id(),
1094 )?;
1095
1096 let remote_tracking_status = match (ahead, behind) {
1097 (0, 0) => RemoteTrackingStatus::UpToDate,
1098 (0, d) => RemoteTrackingStatus::Behind(d),
1099 (d, 0) => RemoteTrackingStatus::Ahead(d),
1100 (d1, d2) => RemoteTrackingStatus::Diverged(d1, d2),
1101 };
1102 Some((remote_branch_name, remote_tracking_status))
1103 }
1104 Err(_) => None,
1106 };
1107 branches.push((branch_name, remote_branch));
1108 }
1109
1110 Ok(RepoStatus {
1111 operation,
1112 empty,
1113 remotes,
1114 head,
1115 changes,
1116 worktrees,
1117 submodules,
1118 branches,
1119 })
1120 }
1121
1122 pub fn get_remote_default_branch(
1123 &self,
1124 remote_name: &RemoteName,
1125 ) -> Result<Option<Branch<'_>>, Error> {
1126 if let Some(mut remote) = self.find_remote(remote_name)? {
1129 if remote.connected() {
1130 let remote = remote; if let Ok(remote_default_branch) = remote.default_branch() {
1132 return Ok(Some(
1133 self.find_local_branch(&remote_default_branch)?
1134 .ok_or(Error::NotFound)?,
1135 ));
1136 }
1137 }
1138 }
1139
1140 if let Ok(remote_head) =
1143 self.find_remote_branch(remote_name, &BranchName::new("HEAD".to_owned()))
1144 {
1145 if let Some(pointer_name) = remote_head.as_reference().symbolic_target() {
1146 if let Some(local_branch_name) =
1147 pointer_name.strip_prefix(&format!("refs/remotes/{remote_name}/"))
1148 {
1149 return Ok(Some(
1150 self.find_local_branch(&BranchName(local_branch_name.to_owned()))?
1151 .ok_or(Error::NotFound)?,
1152 ));
1153 } else {
1154 return Err(Error::InvalidRemoteHeadPointer {
1155 name: pointer_name.to_owned(),
1156 });
1157 }
1158 } else {
1159 return Err(Error::RemoteHeadNoSymbolicTarget);
1160 }
1161 }
1162 Ok(None)
1163 }
1164
1165 pub fn default_branch(&self) -> Result<Branch<'_>, Error> {
1166 let remotes = self.remotes()?;
1179
1180 if remotes.len() == 1 {
1181 #[expect(clippy::missing_panics_doc, reason = "see expect() message")]
1182 let remote_name = &remotes.first().expect("checked for len above");
1183 if let Some(default_branch) = self.get_remote_default_branch(remote_name)? {
1184 return Ok(default_branch);
1185 }
1186 } else {
1187 let mut default_branches: Vec<Branch> = vec![];
1188 for remote_name in remotes {
1189 if let Some(default_branch) = self.get_remote_default_branch(&remote_name)? {
1190 default_branches.push(default_branch);
1191 }
1192 }
1193
1194 if !default_branches.is_empty()
1195 && (default_branches.len() == 1
1196 || default_branches
1197 .iter()
1198 .map(Branch::name)
1199 .collect::<Result<Vec<BranchName>, Error>>()?
1200 .windows(2)
1201 .all(
1202 #[expect(
1203 clippy::missing_asserts_for_indexing,
1204 clippy::indexing_slicing,
1205 reason = "windows function always returns two elements"
1206 )]
1207 |branch_names| branch_names[0] == branch_names[1],
1208 ))
1209 {
1210 return Ok(default_branches.remove(0));
1211 }
1212 }
1213
1214 for branch_name in &["main", "master"] {
1215 if let Ok(branch) = self.0.find_branch(branch_name, git2::BranchType::Local) {
1216 return Ok(Branch(branch));
1217 }
1218 }
1219
1220 Err(Error::NoDefaultBranch)
1221 }
1222
1223 pub fn find_remote(&self, remote_name: &RemoteName) -> Result<Option<RemoteHandle<'_>>, Error> {
1229 let remotes = self.0.remotes()?;
1230
1231 if !remotes
1232 .iter()
1233 .map(|remote| remote.ok_or(Error::RemoteNameNotUtf8))
1234 .collect::<Result<Vec<_>, Error>>()?
1235 .into_iter()
1236 .any(|remote| remote == remote_name.as_str())
1237 {
1238 return Ok(None);
1239 }
1240
1241 Ok(Some(RemoteHandle(
1242 self.0.find_remote(remote_name.as_str())?,
1243 )))
1244 }
1245
1246 pub fn get_worktrees(&self) -> Result<Vec<Worktree>, Error> {
1247 Ok(self
1248 .0
1249 .worktrees()?
1250 .iter()
1251 .map(|remote| remote.ok_or(Error::WorktreeNameNotUtf8))
1252 .collect::<Result<Vec<_>, Error>>()?
1253 .into_iter()
1254 .map(Worktree::new)
1255 .collect())
1256 }
1257
1258 pub fn remove_worktree(
1259 &self,
1260 base_dir: &Path,
1261 worktree_name: &WorktreeName,
1262 worktree_dir: &Path,
1263 force: bool,
1264 worktree_config: Option<&WorktreeRootConfig>,
1265 ) -> Result<(), Error> {
1266 let fullpath = base_dir.join(worktree_dir);
1267
1268 if !fullpath.exists() {
1269 return Err(Error::WorktreeRemovalFailure(
1270 WorktreeRemoveFailureReason::Error(format!("{worktree_name} does not exist")),
1271 ));
1272 }
1273 let worktree_repo = Self::open(&fullpath, false).map_err(|error| {
1274 Error::WorktreeRemovalFailure(WorktreeRemoveFailureReason::Error(format!(
1275 "Error opening repo: {error}"
1276 )))
1277 })?;
1278
1279 let local_branch = worktree_repo.head_branch().map_err(|error| {
1280 Error::WorktreeRemovalFailure(WorktreeRemoveFailureReason::Error(format!(
1281 "Failed getting head branch: {error}"
1282 )))
1283 })?;
1284
1285 let branch_name = local_branch.name().map_err(|error| {
1286 Error::WorktreeRemovalFailure(WorktreeRemoveFailureReason::Error(format!(
1287 "Failed getting name of branch: {error}"
1288 )))
1289 })?;
1290
1291 if branch_name.as_str() != worktree_name.as_str() {
1292 return Err(Error::WorktreeRemovalFailure(
1293 WorktreeRemoveFailureReason::Error(format!(
1294 "Branch \"{}\" is checked out in worktree \"{}\", this does not look correct",
1295 &branch_name,
1296 &worktree_dir.display(),
1297 )),
1298 ));
1299 }
1300
1301 let branch = worktree_repo
1302 .find_local_branch(&branch_name)
1303 .map_err(|e| {
1304 Error::WorktreeRemovalFailure(WorktreeRemoveFailureReason::Error(e.to_string()))
1305 })?
1306 .ok_or(Error::NotFound)?;
1307
1308 if !force {
1309 let status = worktree_repo.status(false).map_err(|e| {
1310 Error::WorktreeRemovalFailure(WorktreeRemoveFailureReason::Error(e.to_string()))
1311 })?;
1312 if status.changes.is_some() {
1313 return Err(Error::WorktreeRemovalFailure(
1314 WorktreeRemoveFailureReason::Changes(String::from("Changes found in worktree")),
1315 ));
1316 }
1317
1318 let mut is_merged_into_persistent_branch = false;
1319 let mut has_persistent_branches = false;
1320 if let Some(config) = worktree_config {
1321 if let Some(branches) = config.persistent_branches.as_ref() {
1322 has_persistent_branches = true;
1323 for persistent_branch in branches {
1324 let persistent_branch = worktree_repo
1325 .find_local_branch(persistent_branch)
1326 .map_err(|e| {
1327 Error::WorktreeRemovalFailure(WorktreeRemoveFailureReason::Error(
1328 e.to_string(),
1329 ))
1330 })?
1331 .ok_or(Error::NotFound)?;
1332
1333 let (ahead, _behind) =
1334 worktree_repo.graph_ahead_behind(&branch, &persistent_branch)?;
1335
1336 if ahead == 0 {
1337 is_merged_into_persistent_branch = true;
1338 }
1339 }
1340 }
1341 }
1342
1343 if has_persistent_branches && !is_merged_into_persistent_branch {
1344 return Err(Error::WorktreeRemovalFailure(
1345 WorktreeRemoveFailureReason::NotMerged(format!(
1346 "Branch {worktree_name} is not merged into any persistent branches"
1347 )),
1348 ));
1349 }
1350
1351 if !has_persistent_branches {
1352 match branch.upstream() {
1353 Ok(remote_branch) => {
1354 let (ahead, behind) =
1355 worktree_repo.graph_ahead_behind(&branch, &remote_branch)?;
1356
1357 if (ahead, behind) != (0, 0) {
1358 return Err(Error::WorktreeRemovalFailure(
1359 WorktreeRemoveFailureReason::Changes(format!(
1360 "Branch {worktree_name} is not in line with remote branch"
1361 )),
1362 ));
1363 }
1364 }
1365 Err(_) => {
1366 return Err(Error::WorktreeRemovalFailure(
1367 WorktreeRemoveFailureReason::Changes(format!(
1368 "No remote tracking branch for branch {worktree_name} found"
1369 )),
1370 ));
1371 }
1372 }
1373 }
1374 }
1375
1376 if let Err(e) = std::fs::remove_dir_all(&fullpath) {
1381 return Err(Error::WorktreeRemovalFailure(
1382 WorktreeRemoveFailureReason::Error(format!(
1383 "Error deleting {}: {}",
1384 &worktree_dir.display(),
1385 e
1386 )),
1387 ));
1388 }
1389
1390 if let Some(current_dir) = worktree_dir.parent() {
1391 for current_dir in current_dir.ancestors() {
1392 let current_dir = base_dir.join(current_dir);
1393 if current_dir
1394 .read_dir()
1395 .map_err(|error| {
1396 Error::WorktreeRemovalFailure(WorktreeRemoveFailureReason::Error(format!(
1397 "Error reading {}: {}",
1398 ¤t_dir.display(),
1399 error
1400 )))
1401 })?
1402 .next()
1403 .is_none()
1404 {
1405 if let Err(e) = std::fs::remove_dir(¤t_dir) {
1406 return Err(Error::WorktreeRemovalFailure(
1407 WorktreeRemoveFailureReason::Error(format!(
1408 "Error deleting {}: {}",
1409 &worktree_dir.display(),
1410 e
1411 )),
1412 ));
1413 }
1414 } else {
1415 break;
1416 }
1417 }
1418 }
1419
1420 self.prune_worktree(worktree_name).map_err(|e| {
1421 Error::WorktreeRemovalFailure(WorktreeRemoveFailureReason::Error(e.to_string()))
1422 })?;
1423 branch.delete().map_err(|e| {
1424 Error::WorktreeRemovalFailure(WorktreeRemoveFailureReason::Error(e.to_string()))
1425 })?;
1426
1427 Ok(())
1428 }
1429
1430 pub fn cleanup_worktrees(&self, directory: &Path) -> Result<Vec<Warning>, Error> {
1431 let mut warnings = Vec::new();
1432
1433 let worktrees = self.get_worktrees()?;
1434
1435 let config: Option<WorktreeRootConfig> =
1436 config::read_worktree_root_config(directory)?.map(Into::into);
1437
1438 let guess_default_branch = || self.default_branch()?.name();
1439
1440 let default_branch_name = match config {
1441 None => guess_default_branch()?,
1442 Some(ref config) => match config.persistent_branches.as_ref() {
1443 None => guess_default_branch()?,
1444 Some(persistent_branches) => {
1445 if let Some(branch) = persistent_branches.first() {
1446 branch.clone()
1447 } else {
1448 guess_default_branch()?
1449 }
1450 }
1451 },
1452 };
1453
1454 for worktree in worktrees
1455 .iter()
1456 .filter(|worktree| worktree.name().as_str() != default_branch_name.as_str())
1457 .filter(|worktree| match config {
1458 None => true,
1459 Some(ref config) => match config.persistent_branches.as_ref() {
1460 None => true,
1461 Some(branches) => !branches
1462 .iter()
1463 .any(|branch| branch.as_str() == worktree.name().as_str()),
1464 },
1465 })
1466 {
1467 let repo_dir = &directory.join(worktree.name().as_str());
1468 if repo_dir.exists() {
1469 match self.remove_worktree(
1470 directory,
1471 worktree.name(),
1472 Path::new(worktree.name().as_str()),
1473 false,
1474 config.as_ref(),
1475 ) {
1476 Ok(()) => print_success(&format!("Worktree {} deleted", &worktree.name())),
1477 Err(error) => match error {
1478 Error::WorktreeRemovalFailure(ref removal_error) => match *removal_error {
1479 WorktreeRemoveFailureReason::Changes(ref changes) => {
1480 warnings.push(Warning(format!(
1481 "Changes found in {}: {}, skipping",
1482 &worktree.name(),
1483 &changes
1484 )));
1485 }
1486 WorktreeRemoveFailureReason::NotMerged(ref message) => {
1487 warnings.push(Warning(message.clone()));
1488 }
1489 WorktreeRemoveFailureReason::Error(_) => {
1490 return Err(error);
1491 }
1492 },
1493 _ => return Err(error),
1494 },
1495 }
1496 } else {
1497 warnings.push(Warning(format!(
1498 "Worktree {} does not have a directory",
1499 &worktree.name()
1500 )));
1501 }
1502 }
1503 Ok(warnings)
1504 }
1505
1506 pub fn find_unmanaged_worktrees(&self, directory: &Path) -> Result<Vec<PathBuf>, Error> {
1507 let worktrees = self.get_worktrees()?;
1508
1509 let mut unmanaged_worktrees = Vec::new();
1510 for entry in std::fs::read_dir(directory)? {
1511 #[expect(clippy::missing_panics_doc, reason = "see expect() message")]
1512 let dirname = path::path_as_string(
1513 entry?
1514 .path()
1515 .strip_prefix(directory)
1516 .expect("each entry is guaranteed to have the prefix"),
1519 )?;
1520
1521 let config: Option<WorktreeRootConfig> =
1522 config::read_worktree_root_config(directory)?.map(Into::into);
1523
1524 let guess_default_branch = || {
1525 self.default_branch()
1526 .map_err(|error| format!("Failed getting default branch: {error}"))?
1527 .name()
1528 .map_err(|error| format!("Failed getting default branch name: {error}"))
1529 };
1530
1531 let default_branch_name = match config {
1532 None => guess_default_branch().ok(),
1533 Some(ref config) => match config.persistent_branches.as_ref() {
1534 None => guess_default_branch().ok(),
1535 Some(persistent_branches) => {
1536 if let Some(branch) = persistent_branches.first() {
1537 Some(branch.clone())
1538 } else {
1539 guess_default_branch().ok()
1540 }
1541 }
1542 },
1543 };
1544
1545 if dirname == worktree::GIT_MAIN_WORKTREE_DIRECTORY {
1546 continue;
1547 }
1548
1549 if dirname == config::WORKTREE_CONFIG_FILE_NAME {
1550 continue;
1551 }
1552 if let Some(default_branch_name) = default_branch_name {
1553 if dirname == default_branch_name.as_str() {
1554 continue;
1555 }
1556 }
1557 if !&worktrees
1558 .iter()
1559 .any(|worktree| worktree.name().as_str() == dirname)
1560 {
1561 unmanaged_worktrees.push(PathBuf::from(dirname));
1562 }
1563 }
1564 Ok(unmanaged_worktrees)
1565 }
1566
1567 pub fn detect_worktree(path: &Path) -> bool {
1568 path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY).exists()
1569 }
1570}
1571
1572pub struct RemoteHandle<'a>(git2::Remote<'a>);
1573pub struct Commit<'a>(git2::Commit<'a>);
1574pub struct Oid(git2::Oid);
1575
1576impl Oid {
1577 pub fn hex_string(&self) -> String {
1578 self.0.to_string()
1579 }
1580}
1581
1582impl Commit<'_> {
1583 pub fn id(&self) -> Oid {
1584 Oid(self.0.id())
1585 }
1586
1587 pub(self) fn author(&self) -> git2::Signature<'_> {
1588 self.0.author()
1589 }
1590}
1591
1592impl<'a> Branch<'a> {
1593 pub fn to_commit(self) -> Result<Commit<'a>, Error> {
1594 Ok(Commit(self.0.into_reference().peel_to_commit()?))
1595 }
1596}
1597
1598impl<'a> Branch<'a> {
1599 pub fn commit(&self) -> Result<Commit<'_>, Error> {
1600 Ok(Commit(self.0.get().peel_to_commit()?))
1601 }
1602
1603 pub fn commit_owned(self) -> Result<Commit<'a>, Error> {
1604 Ok(Commit(self.0.into_reference().peel_to_commit()?))
1605 }
1606
1607 pub fn set_upstream(
1608 &mut self,
1609 remote_name: &RemoteName,
1610 branch_name: &BranchName,
1611 ) -> Result<(), Error> {
1612 self.0.set_upstream(Some(&format!(
1613 "{}/{}",
1614 remote_name.as_str(),
1615 branch_name.as_str()
1616 )))?;
1617 Ok(())
1618 }
1619
1620 pub fn name(&self) -> Result<BranchName, Error> {
1621 Ok(BranchName::new(
1622 self.0.name()?.ok_or(Error::BranchNameNotUtf8)?.to_owned(),
1623 ))
1624 }
1625
1626 pub fn upstream(&self) -> Result<Branch<'_>, Error> {
1627 Ok(Branch(self.0.upstream()?))
1628 }
1629
1630 pub fn delete(mut self) -> Result<(), Error> {
1631 Ok(self.0.delete()?)
1632 }
1633
1634 pub fn basename(&self) -> Result<BranchName, Error> {
1635 let name = self.name()?;
1636 if let Some((_prefix, basename)) = name.as_str().split_once('/') {
1637 Ok(BranchName::new(basename.to_owned()))
1638 } else {
1639 Ok(name)
1640 }
1641 }
1642
1643 fn as_reference(&self) -> &git2::Reference<'_> {
1645 self.0.get()
1646 }
1647}
1648
1649fn get_remote_callbacks() -> git2::RemoteCallbacks<'static> {
1650 let mut callbacks = git2::RemoteCallbacks::new();
1651 callbacks.push_update_reference(|_, status| {
1652 if let Some(message) = status {
1653 return Err(git2::Error::new(
1654 git2::ErrorCode::GenericError,
1655 git2::ErrorClass::None,
1656 message,
1657 ));
1658 }
1659 Ok(())
1660 });
1661
1662 callbacks.credentials(|_url, username_from_url, _allowed_types| {
1663 #[expect(clippy::panic, reason = "there is no good way to bubble up that error")]
1664 let Some(username) = username_from_url else {
1665 panic!("Could not get username. This is a bug")
1666 };
1667 git2::Cred::ssh_key_from_agent(username)
1668 });
1669
1670 callbacks
1671}
1672
1673impl RemoteHandle<'_> {
1674 pub fn url(&self) -> Result<RemoteUrl, Error> {
1675 Ok(RemoteUrl::new(
1676 self.0.url().ok_or(Error::RemoteNameNotUtf8)?.to_owned(),
1677 ))
1678 }
1679
1680 pub fn name(&self) -> Result<RemoteName, Error> {
1681 Ok(RemoteName::new(
1682 self.0.name().ok_or(Error::RemoteNameNotUtf8)?.to_owned(),
1683 ))
1684 }
1685
1686 pub fn connected(&mut self) -> bool {
1687 self.0.connected()
1688 }
1689
1690 pub fn default_branch(&self) -> Result<BranchName, Error> {
1691 Ok(BranchName(
1692 self.0
1693 .default_branch()?
1694 .as_str()
1695 .ok_or(Error::RemoteBranchNameNotUtf8)?
1696 .to_owned(),
1697 ))
1698 }
1699
1700 pub fn is_pushable(&self) -> Result<bool, Error> {
1701 let remote_type = detect_remote_type(&RemoteUrl::new(
1702 self.0.url().ok_or(Error::RemoteNameNotUtf8)?.to_owned(),
1703 ))?;
1704 Ok(matches!(remote_type, RemoteType::Ssh | RemoteType::File))
1705 }
1706
1707 pub fn push(
1708 &mut self,
1709 local_branch_name: &BranchName,
1710 remote_branch_name: &BranchName,
1711 _repo: &RepoHandle,
1712 ) -> Result<(), Error> {
1713 if !self.is_pushable()? {
1714 return Err(Error::NonPushableRemote);
1715 }
1716
1717 let mut push_options = git2::PushOptions::new();
1718 push_options.remote_callbacks(get_remote_callbacks());
1719
1720 let push_refspec = format!(
1721 "+refs/heads/{}:refs/heads/{}",
1722 local_branch_name.as_str(),
1723 remote_branch_name.as_str()
1724 );
1725 self.0
1726 .push(&[push_refspec], Some(&mut push_options))
1727 .map_err(|error| Error::PushFailed {
1728 local_branch: local_branch_name.clone(),
1729 remote_name: match self.name() {
1730 Ok(name) => name,
1731 Err(e) => return e,
1732 },
1733 remote_url: match self.url() {
1734 Ok(url) => url,
1735 Err(e) => return e,
1736 },
1737 message: error.to_string(),
1738 })?;
1739 Ok(())
1740 }
1741}
1742
1743pub fn clone_repo(
1744 remote: &Remote,
1745 path: &Path,
1746 is_worktree: bool,
1747) -> Result<(), Box<dyn std::error::Error>> {
1748 let clone_target = if is_worktree {
1749 path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY)
1750 } else {
1751 path.to_path_buf()
1752 };
1753
1754 print_action(&format!(
1755 "Cloning into \"{}\" from \"{}\"",
1756 &clone_target.display(),
1757 &remote.url
1758 ));
1759 match remote.remote_type {
1760 RemoteType::Https | RemoteType::File => {
1761 let mut builder = git2::build::RepoBuilder::new();
1762
1763 let fetchopts = git2::FetchOptions::new();
1764
1765 builder.bare(is_worktree);
1766 builder.fetch_options(fetchopts);
1767
1768 builder.clone(remote.url.as_str(), &clone_target)?;
1769 }
1770 RemoteType::Ssh => {
1771 let mut fo = git2::FetchOptions::new();
1772 fo.remote_callbacks(get_remote_callbacks());
1773
1774 let mut builder = git2::build::RepoBuilder::new();
1775 builder.bare(is_worktree);
1776 builder.fetch_options(fo);
1777
1778 builder.clone(remote.url.as_str(), &clone_target)?;
1779 }
1780 }
1781
1782 let repo = RepoHandle::open(&clone_target, false)?;
1783
1784 if is_worktree {
1785 repo.set_config_push(GitPushDefaultSetting::Upstream)?;
1786 }
1787
1788 if remote.name != RemoteName::new("origin".to_owned()) {
1789 #[expect(clippy::missing_panics_doc, reason = "see expect() message")]
1790 let origin = repo
1791 .find_remote(&RemoteName::new("origin".to_owned()))?
1792 .expect("the remote will always exist after a successful clone");
1793 repo.rename_remote(&origin, &remote.name)?;
1794 }
1795
1796 for remote_branch in repo.remote_branches()? {
1799 let local_branch_name = remote_branch.basename()?;
1800
1801 if repo.find_local_branch(&local_branch_name).is_ok() {
1802 continue;
1803 }
1804
1805 if local_branch_name.as_str() == "HEAD" {
1807 continue;
1808 }
1809
1810 let mut local_branch = repo.create_branch(&local_branch_name, &remote_branch.commit()?)?;
1811 local_branch.set_upstream(&remote.name, &local_branch_name)?;
1812 }
1813
1814 if let Ok(mut active_branch) = repo.head_branch() {
1817 active_branch.set_upstream(&remote.name, &active_branch.name()?)?;
1818 }
1819
1820 Ok(())
1821}
1822
1823#[cfg(test)]
1824mod tests {
1825 use super::*;
1826
1827 #[test]
1828 fn check_ssh_remote() -> Result<(), Error> {
1829 assert_eq!(
1830 detect_remote_type(&RemoteUrl::new("ssh://git@example.com".to_owned()))?,
1831 RemoteType::Ssh
1832 );
1833 assert_eq!(
1834 detect_remote_type(&RemoteUrl::new("git@example.git".to_owned()))?,
1835 RemoteType::Ssh
1836 );
1837 Ok(())
1838 }
1839
1840 #[test]
1841 fn check_https_remote() -> Result<(), Error> {
1842 assert_eq!(
1843 detect_remote_type(&RemoteUrl::new("https://example.com".to_owned()))?,
1844 RemoteType::Https
1845 );
1846 assert_eq!(
1847 detect_remote_type(&RemoteUrl::new("https://example.com/test.git".to_owned()))?,
1848 RemoteType::Https
1849 );
1850 Ok(())
1851 }
1852
1853 #[test]
1854 fn check_file_remote() -> Result<(), Error> {
1855 assert_eq!(
1856 detect_remote_type(&RemoteUrl::new("file:///somedir".to_owned()))?,
1857 RemoteType::File
1858 );
1859 Ok(())
1860 }
1861
1862 #[test]
1863 fn check_invalid_remotes() {
1864 assert!(matches!(
1865 detect_remote_type(&RemoteUrl::new("https//example.com".to_owned())),
1866 Err(Error::UnimplementedRemoteProtocol)
1867 ));
1868 assert!(matches!(
1869 detect_remote_type(&RemoteUrl::new("https:example.com".to_owned())),
1870 Err(Error::UnimplementedRemoteProtocol)
1871 ));
1872 assert!(matches!(
1873 detect_remote_type(&RemoteUrl::new("ssh//example.com".to_owned())),
1874 Err(Error::UnimplementedRemoteProtocol)
1875 ));
1876 assert!(matches!(
1877 detect_remote_type(&RemoteUrl::new("ssh:example.com".to_owned())),
1878 Err(Error::UnimplementedRemoteProtocol)
1879 ));
1880 assert!(matches!(
1881 detect_remote_type(&RemoteUrl::new("git@example.com".to_owned())),
1882 Err(Error::UnimplementedRemoteProtocol)
1883 ));
1884 }
1885
1886 #[test]
1887 fn check_unsupported_protocol_http() {
1888 assert!(matches!(
1889 detect_remote_type(&RemoteUrl::new("http://example.com".to_owned())),
1890 Err(Error::UnsupportedHttpRemote)
1891 ));
1892 }
1893
1894 #[test]
1895 fn check_unsupported_protocol_git() {
1896 assert!(matches!(
1897 detect_remote_type(&RemoteUrl::new("git://example.com".to_owned())),
1898 Err(Error::UnsupportedGitRemote)
1899 ));
1900 }
1901
1902 #[test]
1903 fn repo_check_fullname() {
1904 let with_namespace = Repo {
1905 name: ProjectName::new("name".to_owned()),
1906 namespace: Some(ProjectNamespace::new("namespace".to_owned())),
1907 worktree_setup: false,
1908 remotes: Vec::new(),
1909 };
1910
1911 let without_namespace = Repo {
1912 name: ProjectName::new("name".to_owned()),
1913 namespace: None,
1914 worktree_setup: false,
1915 remotes: Vec::new(),
1916 };
1917
1918 assert_eq!(
1919 with_namespace.fullname(),
1920 ProjectName::new("namespace/name".to_owned())
1921 );
1922 assert_eq!(
1923 without_namespace.fullname(),
1924 ProjectName::new("name".to_owned())
1925 );
1926 }
1927}