Skip to main content

grm/repo/
mod.rs

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