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
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 {
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 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 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 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}