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