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