grm/
repo.rs

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, // use HEAD
399                    Some(&remote_annotated_commit),
400                    None, // figure out the base yourself, libgit2!
401                    Some(&mut git2::RebaseOptions::new()),
402                )?;
403
404                while let Some(operation) = rebase.next() {
405                    let operation = operation?;
406
407                    // This is required to preserve the commiter of the rebased
408                    // commits, which is the expected behavior.
409                    let rebased_commit = repo.0.find_commit(operation.id())?;
410                    let committer = rebased_commit.committer();
411
412                    // This is effectively adding all files to the index explicitly.
413                    // Normal files are already staged, but changed submodules are not.
414                    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, // use HEAD
507            Some(&base_annotated_commit),
508            None, // figure out the base yourself, libgit2!
509            Some(&mut git2::RebaseOptions::new()),
510        )?;
511
512        while let Some(operation) = rebase.next() {
513            let operation = operation?;
514
515            // This is required to preserve the commiter of the rebased
516            // commits, which is the expected behavior.
517            let rebased_commit = repo.0.find_commit(operation.id())?;
518            let committer = rebased_commit.committer();
519
520            // This is effectively adding all files to the index explicitly.
521            // Normal files are already staged, but changed submodules are not.
522            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        // This is honestly quite horrible. The problem is that all stash operations
612        // expect a mutable reference (as they, well, mutate the repo after
613        // all). But we are heavily using immutable references a lot with this
614        // struct. I'm really not sure how to best solve this. Right now, we
615        // just open the repo AGAIN. It is safe, as we are only accessing the stash
616        // with the second reference, so there are no cross effects. But it just smells.
617        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        // unwrap() is safe here, as we can be certain that a branch with that
661        // name exists
662        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 => no remote branch
1105                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        // libgit2's `git_remote_default_branch()` and `Remote::default_branch()`
1127        // need an actual connection to the remote, so they may fail.
1128        if let Some(mut remote) = self.find_remote(remote_name)? {
1129            if remote.connected() {
1130                let remote = remote; // unmut
1131                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        // Note that <remote>/HEAD only exists after a normal clone, there is no way to
1141        // get the remote HEAD afterwards. So this is a "best effort" approach.
1142        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        // This is a bit of a guessing game.
1167        //
1168        // In the best case, there is only one remote. Then, we can check <remote>/HEAD
1169        // to get the default remote branch.
1170        //
1171        // If there are multiple remotes, we first check whether they all have the same
1172        // <remote>/HEAD branch. If yes, good! If not, we use whatever "origin" uses, if
1173        // that exists. If it does not, there is no way to reliably get a remote
1174        // default branch.
1175        //
1176        // In this case, we just try to guess a local branch from a list. If even that
1177        // does not work, well, bad luck.
1178        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    // Looks like there is no distinguishing between the error cases
1224    // "no such remote" and "failed to get remote for some reason".
1225    // May be a good idea to handle this explicitly, by returning a
1226    // Result<Option<RemoteHandle>, Error> instead, Returning Ok(None)
1227    // on "not found" and Err() on an actual error.
1228    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        // worktree_dir is a relative path, starting from base_dir. We walk it
1377        // upwards (from subdirectory to parent directories) and remove each
1378        // component, in case it is empty. Only the leaf directory can be
1379        // removed unconditionally (as it contains the worktree itself).
1380        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                            &current_dir.display(),
1399                            error
1400                        )))
1401                    })?
1402                    .next()
1403                    .is_none()
1404                {
1405                    if let Err(e) = std::fs::remove_dir(&current_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                    // that unwrap() is safe as each entry is
1517                    // guaranteed to be a subentry of &directory
1518                    .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    // only used internally in this module, exposes libgit2 details
1644    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    // Initialize local branches. For all remote branches, we set up local
1797    // tracking branches with the same name (just without the remote prefix).
1798    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        // Ignore <remote>/HEAD, as this is not something we can check out
1806        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 there is no head_branch, we most likely cloned an empty repository and
1815    // there is no point in setting any upstreams.
1816    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}