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