codeberg_cli/types/
git.rs1use 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#[derive(Debug, Clone)]
31pub enum MaybeOwnerRepo {
32 ImplicitOwner(String),
34 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
68fn 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 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 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 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 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}