codeberg_cli/types/
git.rs

1use std::path::PathBuf;
2use std::str::FromStr;
3
4use git2::{Cred, FetchOptions, Remote, RemoteCallbacks, Repository};
5use miette::{Context, IntoDiagnostic};
6use url::Url;
7
8#[derive(Debug, Clone)]
9pub struct OwnerRepo {
10    pub owner: String,
11    pub repo: String,
12}
13
14impl FromStr for OwnerRepo {
15    type Err = miette::Error;
16
17    fn from_str(s: &str) -> Result<Self, Self::Err> {
18        let (owner, repo) = s.split_once('/').ok_or_else(|| {
19            miette::miette!("Please provide the repository in the format OWNER/REPO.")
20        })?;
21        Ok(OwnerRepo {
22            owner: owner.to_owned(),
23            repo: repo.to_owned(),
24        })
25    }
26}
27
28/// Sometimes it should be enough to just have a repo and we infer the owner. This is what this
29/// enum is for. It will fallback to owner and repo
30#[derive(Debug, Clone)]
31pub enum MaybeOwnerRepo {
32    /// The user is inferred from the API ... whoever is logged in
33    ImplicitOwner(String),
34    /// The user is explicitly stated
35    ExplicitOwner(OwnerRepo),
36}
37
38impl FromStr for MaybeOwnerRepo {
39    type Err = miette::Error;
40
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        Ok(s.split_once("/")
43            .map(|(owner, repo)| OwnerRepo {
44                owner: owner.to_string(),
45                repo: repo.to_string(),
46            })
47            .map(Self::ExplicitOwner)
48            .unwrap_or(Self::ImplicitOwner(s.to_string())))
49    }
50}
51
52pub struct Git {
53    pub repo: Option<Repository>,
54}
55
56impl std::fmt::Display for OwnerRepo {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        write!(f, "{owner}/{repo}", owner = self.owner, repo = self.repo)
59    }
60}
61
62impl Default for Git {
63    fn default() -> Self {
64        Self {
65            repo: Repository::discover("./.").ok(),
66        }
67    }
68}
69
70impl Git {
71    pub fn clone(url: Url, destination: impl AsRef<str>) -> miette::Result<()> {
72        let destination = std::path::Path::new(destination.as_ref());
73        println!(
74            "Cloning from '{url}' into '{destination}'",
75            destination = destination.display()
76        );
77        let mut callbacks = RemoteCallbacks::new();
78
79        callbacks.credentials(|_url, username_from_url, allowed| {
80            if allowed.is_ssh_key() {
81                Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
82            } else if allowed.is_user_pass_plaintext() {
83                Cred::default()
84            } else {
85                Err(git2::Error::from_str("no supported auth method"))
86            }
87        });
88
89        let mut fetch_opts = FetchOptions::new();
90        fetch_opts.remote_callbacks(callbacks);
91
92        git2::build::RepoBuilder::new()
93            .fetch_options(fetch_opts)
94            .clone(url.as_str(), destination)
95            .map_err(|e| {
96                miette::miette!(
97                help = format!(
98                    "Does the directory {dir} exist locally?\nDoes the repository exist remotely?",
99                    dir = destination.display()
100                ),
101                "{e}"
102            )
103            .context("Cloning repository failed!")
104            })?;
105        Ok(())
106    }
107
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// try to query the `origin` remote
113    pub fn origin(&self) -> Option<Remote<'_>> {
114        self.repo.as_ref().and_then(|repo| {
115            let remotes_list = repo.remotes().ok()?;
116            remotes_list
117                .into_iter()
118                .flatten()
119                .find_map(|remote| repo.find_remote(remote).ok())
120        })
121    }
122
123    pub fn remotes(&self) -> miette::Result<Vec<Remote<'_>>> {
124        let repo = self
125            .repo
126            .as_ref()
127            .context("No repository found in the current path even though one is needed!")?;
128        let remotes = repo
129            .remotes()
130            .into_diagnostic()?
131            .into_iter()
132            .filter_map(|remote| {
133                let remote = remote?;
134                let remote = repo.find_remote(remote).ok()?;
135                Some(remote)
136            })
137            .collect::<Vec<_>>();
138        Ok(remotes)
139    }
140
141    pub fn owner_repo(&self) -> miette::Result<OwnerRepo> {
142        let remotes = self
143            .remotes()?
144            .into_iter()
145            .filter_map(|remote| {
146                let mut git_url = remote.url().map(PathBuf::from)?;
147                // expect urls like
148                //
149                // - git@codeberg.org:UserName/RepoName.git
150                // - https://codeberg.org/UserName/RepoName.git
151                let repo = git_url
152                    .file_name()?
153                    .to_str()?
154                    .trim_end_matches(".git")
155                    .to_owned();
156                git_url.pop();
157
158                let https_owner = git_url
159                    .file_name()?
160                    .to_str()
161                    .filter(|owner| !owner.contains(":"))
162                    .map(|owner| owner.to_owned());
163                let ssh_owner = git_url
164                    .to_str()?
165                    .split_once(":")
166                    .map(|(_junk, owner)| owner.to_owned());
167                let remote_name = remote.name()?.to_owned();
168                Some((remote_name, https_owner.or(ssh_owner)?, repo))
169            })
170            .inspect(|x| tracing::debug!("{x:?}"))
171            .collect::<Vec<_>>();
172
173        remotes
174            .iter()
175            .find_map(|(name, owner, repo)| (*name == "origin").then_some((owner, repo)))
176            .or_else(|| remotes.first().map(|(_, owner, repo)| (owner, repo)))
177            .map(|(owner, repo)| OwnerRepo {
178                owner: owner.clone(),
179                repo: repo.clone(),
180            })
181            .context("Couldn't find owner and repo")
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_owner_repo_valid_parsing() {
191        let result = OwnerRepo::from_str("owner/repo").unwrap();
192        assert_eq!(result.owner, "owner");
193        assert_eq!(result.repo, "repo");
194    }
195
196    #[test]
197    fn test_owner_repo_with_hyphens() {
198        let result = OwnerRepo::from_str("my-owner/my-repo").unwrap();
199        assert_eq!(result.owner, "my-owner");
200        assert_eq!(result.repo, "my-repo");
201    }
202
203    #[test]
204    fn test_owner_repo_with_underscores() {
205        let result = OwnerRepo::from_str("my_owner/my_repo").unwrap();
206        assert_eq!(result.owner, "my_owner");
207        assert_eq!(result.repo, "my_repo");
208    }
209
210    #[test]
211    fn test_owner_repo_invalid_no_slash() {
212        let result = OwnerRepo::from_str("invalid");
213        assert!(result.is_err());
214        assert_eq!(
215            result.unwrap_err().to_string(),
216            "Please provide the repository in the format OWNER/REPO."
217        );
218    }
219
220    #[test]
221    fn test_owner_repo_invalid_multiple_slashes() {
222        let result = OwnerRepo::from_str("owner/repo/extra").unwrap();
223        // split_once splits on the first '/', so "extra" becomes part of the repo name
224        assert_eq!(result.owner, "owner");
225        assert_eq!(result.repo, "repo/extra");
226    }
227
228    #[test]
229    fn test_owner_repo_empty_owner() {
230        let result = OwnerRepo::from_str("/repo").unwrap();
231        assert_eq!(result.owner, "");
232        assert_eq!(result.repo, "repo");
233    }
234
235    #[test]
236    fn test_owner_repo_empty_repo() {
237        let result = OwnerRepo::from_str("owner/").unwrap();
238        assert_eq!(result.owner, "owner");
239        assert_eq!(result.repo, "");
240    }
241}