Skip to main content

codeberg_cli/types/
git.rs

1use std::path::PathBuf;
2use std::str::FromStr;
3
4use git2::{Cred, FetchOptions, PushOptions, 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::new(".")
65    }
66}
67
68/// This function constructs callbacks which are used to authenticate with the remote. This is
69/// necessary to push/pull private repositories
70fn basic_auth_callbacks() -> RemoteCallbacks<'static> {
71    let mut callbacks = RemoteCallbacks::new();
72
73    callbacks.credentials(|_url, username_from_url, allowed| {
74        if allowed.is_ssh_key() {
75            Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
76        } else if allowed.is_user_pass_plaintext() {
77            Cred::default()
78        } else {
79            Err(git2::Error::from_str(
80                miette::miette!(
81                    help = "Try SSH or User/Pass authentication",
82                    "No supported authentication method found, but one was required!"
83                )
84                .to_string()
85                .as_str(),
86            ))
87        }
88    });
89
90    callbacks
91}
92
93impl Git {
94    pub fn clone(url: Url, destination: impl AsRef<str>) -> miette::Result<()> {
95        let destination = std::path::Path::new(destination.as_ref());
96        println!(
97            "Cloning from '{url}' into '{destination}'",
98            destination = destination.display()
99        );
100
101        let mut fetch_opts = FetchOptions::new();
102        fetch_opts.remote_callbacks(basic_auth_callbacks());
103
104        git2::build::RepoBuilder::new()
105            .fetch_options(fetch_opts)
106            .clone(url.as_str(), destination)
107            .map_err(|e| {
108                miette::miette!(
109                help = format!(
110                    "Does the directory {dir} exist locally?\nDoes the repository exist remotely?",
111                    dir = destination.display()
112                ),
113                "{e}"
114            )
115            .context("Cloning repository failed!")
116            })?;
117        Ok(())
118    }
119
120    pub fn new(path: impl AsRef<std::path::Path>) -> Self {
121        Self {
122            repo: Repository::discover(path).ok(),
123        }
124    }
125
126    /// try to query the `origin` remote
127    pub fn origin(&self) -> Option<Remote<'_>> {
128        self.repo.as_ref().and_then(|repo| {
129            let remotes_list = repo.remotes().ok()?;
130            remotes_list
131                .into_iter()
132                .flatten()
133                .find_map(|remote| repo.find_remote(remote).ok())
134        })
135    }
136
137    pub fn remotes(&self) -> miette::Result<Vec<Remote<'_>>> {
138        let repo = self
139            .repo
140            .as_ref()
141            .context("No repository found in the current path even though one is needed!")?;
142        let remotes = repo
143            .remotes()
144            .into_diagnostic()?
145            .into_iter()
146            .filter_map(|remote| {
147                let remote = remote?;
148                let remote = repo.find_remote(remote).ok()?;
149                Some(remote)
150            })
151            .collect::<Vec<_>>();
152        Ok(remotes)
153    }
154
155    pub fn owner_repo(&self) -> miette::Result<OwnerRepo> {
156        let remotes = self
157            .remotes()?
158            .into_iter()
159            .filter_map(|remote| {
160                let mut git_url = remote.url().map(PathBuf::from)?;
161                // expect urls like
162                //
163                // - git@codeberg.org:UserName/RepoName.git
164                // - https://codeberg.org/UserName/RepoName.git
165                let repo = git_url
166                    .file_name()?
167                    .to_str()?
168                    .trim_end_matches(".git")
169                    .to_owned();
170                git_url.pop();
171
172                let https_owner = git_url
173                    .file_name()?
174                    .to_str()
175                    .filter(|owner| !owner.contains(":"))
176                    .map(|owner| owner.to_owned());
177                let ssh_owner = git_url
178                    .to_str()?
179                    .split_once(":")
180                    .map(|(_junk, owner)| owner.to_owned());
181                let remote_name = remote.name()?.to_owned();
182                Some((remote_name, https_owner.or(ssh_owner)?, repo))
183            })
184            .inspect(|x| tracing::debug!("{x:?}"))
185            .collect::<Vec<_>>();
186
187        remotes
188            .iter()
189            .find_map(|(name, owner, repo)| (*name == "origin").then_some((owner, repo)))
190            .or_else(|| remotes.first().map(|(_, owner, repo)| (owner, repo)))
191            .map(|(owner, repo)| OwnerRepo {
192                owner: owner.clone(),
193                repo: repo.clone(),
194            })
195            .context("Couldn't find owner and repo")
196    }
197
198    /// setup a new remote and push the current branch to that remote
199    pub fn push(&self, remote_name: impl Into<Option<String>>, url: &Url) -> miette::Result<()> {
200        const FALLBACK_REMOTE_NAMES: [&str; 2] = ["origin", "origin-new"];
201        let repo = self.repo.as_ref().context("No repository detected in ")?;
202        let mut remote = remote_name
203            .into()
204            .as_deref()
205            .into_iter()
206            .chain(FALLBACK_REMOTE_NAMES)
207            .find_map(|name| {
208                let remote = repo.remote(name, url.as_str()).ok()?;
209                println!("Set remote {name} to {url}", url = url.as_str());
210                Some(remote)
211            })
212            .wrap_err(miette::miette!(
213                help =
214                    "Try to pick a remote name interactively or rename on of the existing remotes",
215                "Remote names 'origin' and 'new_origin' already exist!"
216            ))?;
217
218        let mut push_opts = PushOptions::default();
219        push_opts.remote_callbacks(basic_auth_callbacks());
220
221        let head = repo.head().into_diagnostic()?;
222        let branch = head
223            .shorthand()
224            .context("No branch name found for current git HEAD")?;
225
226        let refspec = format!("refs/heads/{0}:refs/heads/{0}", branch);
227        remote
228            .push(&[refspec], Some(&mut push_opts))
229            .into_diagnostic()?;
230        Ok(())
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_owner_repo_valid_parsing() {
240        let result = OwnerRepo::from_str("owner/repo").unwrap();
241        assert_eq!(result.owner, "owner");
242        assert_eq!(result.repo, "repo");
243    }
244
245    #[test]
246    fn test_owner_repo_with_hyphens() {
247        let result = OwnerRepo::from_str("my-owner/my-repo").unwrap();
248        assert_eq!(result.owner, "my-owner");
249        assert_eq!(result.repo, "my-repo");
250    }
251
252    #[test]
253    fn test_owner_repo_with_underscores() {
254        let result = OwnerRepo::from_str("my_owner/my_repo").unwrap();
255        assert_eq!(result.owner, "my_owner");
256        assert_eq!(result.repo, "my_repo");
257    }
258
259    #[test]
260    fn test_owner_repo_invalid_no_slash() {
261        let result = OwnerRepo::from_str("invalid");
262        assert!(result.is_err());
263        assert_eq!(
264            result.unwrap_err().to_string(),
265            "Please provide the repository in the format OWNER/REPO."
266        );
267    }
268
269    #[test]
270    fn test_owner_repo_invalid_multiple_slashes() {
271        let result = OwnerRepo::from_str("owner/repo/extra").unwrap();
272        // split_once splits on the first '/', so "extra" becomes part of the repo name
273        assert_eq!(result.owner, "owner");
274        assert_eq!(result.repo, "repo/extra");
275    }
276
277    #[test]
278    fn test_owner_repo_empty_owner() {
279        let result = OwnerRepo::from_str("/repo").unwrap();
280        assert_eq!(result.owner, "");
281        assert_eq!(result.repo, "repo");
282    }
283
284    #[test]
285    fn test_owner_repo_empty_repo() {
286        let result = OwnerRepo::from_str("owner/").unwrap();
287        assert_eq!(result.owner, "owner");
288        assert_eq!(result.repo, "");
289    }
290}