codeberg_cli/types/
git.rs1use 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 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 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 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}