1use colored::*;
2use flate2::read::GzDecoder;
3use indicatif::{ProgressBar, ProgressStyle};
4use regex::Regex;
5use std::{error::Error, fmt, path::PathBuf, process::Command};
6use tar::Archive;
7
8#[derive(Debug, PartialEq)]
9enum Host {
10 GitHub,
11 GitLab(String),
12 BitBucket,
13}
14
15#[derive(Debug, PartialEq)]
16struct Repo {
17 host: Host,
18 owner: String,
19 name: String,
20 subdir: Option<String>,
21 gitref: Option<String>,
22}
23
24impl Repo {
25 pub fn url(&self) -> String {
26 match &self.host {
27 Host::GitHub => format!("https://github.com/{}/{}", self.owner, self.name),
28 Host::GitLab(domain) => format!("https://{}/{}/{}", domain, self.owner, self.name),
29 Host::BitBucket => format!("https://bitbucket.org/{}/{}", self.owner, self.name),
30 }
31 }
32}
33
34impl fmt::Display for Repo {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 let owner = self.owner.bold().underline();
37 let mut project = self.name.as_str().to_string();
38 if let Some(subdir) = self.subdir.as_ref() {
39 project = format!("{project}/{subdir}")
40 }
41 if let Some(gitref) = self.gitref.as_ref() {
42 project = format!("{project}#{gitref}")
43 }
44 let project = project.red();
45 let host = match self.host {
46 Host::GitHub => "GitHub".blue(),
47 Host::GitLab(_) => "GitLab".blue(),
48 Host::BitBucket => "BitBucket".blue(),
49 };
50 write!(f, "{}/{} from {}", owner, project, host)
51 }
52}
53
54pub fn degit(src: &str, dest: &str) {
55 let repo = parse(src).unwrap();
56 match download(repo, PathBuf::from(dest)) {
57 Err(x) => println!("{}", x),
58 _ => (),
59 }
60}
61
62fn parse(src: &str) -> Result<Repo, Box<dyn Error>> {
63 let re = Regex::new(
64 r"^(?:(?:https://)?([^:/]+\.[^:/]+)/|git@([^:/]+)[:/]|([^/]+):)?([^/\s]+)/([^/\s#]+)((?:/[^/\s#]+)+)?(?:/)?(?:#(.+))?"
65 ).unwrap();
66
67 let captures = re
68 .captures(src)
69 .ok_or_else(|| format!("Could not parse src: {src}"))?;
70
71 let host = if let Some(domain) = captures.get(1).or_else(|| captures.get(2)) {
73 match domain.as_str() {
74 "github.com" => Host::GitHub,
75 "bitbucket.org" => Host::BitBucket,
76 other => Host::GitLab(other.to_string()),
77 }
78 } else if let Some(shorthand) = captures.get(3) {
79 match shorthand.as_str() {
80 "github" | "gh" => Host::GitHub,
81 "gitlab" | "gl" => Host::GitLab("gitlab.com".to_string()),
82 "bitbucket" | "bb" => Host::BitBucket,
83 other => Host::GitLab(other.to_string()),
84 }
85 } else {
86 Host::GitHub
87 };
88
89 let res = Repo {
90 host,
91 owner: captures.get(4).unwrap().as_str().to_string(),
92 name: captures
93 .get(5)
94 .unwrap()
95 .as_str()
96 .trim_end_matches(".git")
97 .to_string(),
98 subdir: captures
99 .get(6)
100 .map(|m| m.as_str().trim_start_matches('/').to_string()),
101 gitref: captures.get(7).map(|m| m.as_str().to_string()),
102 };
103 return Ok(res);
104}
105
106fn download(repo: Repo, dest: PathBuf) -> Result<(), Box<dyn Error>> {
107 let hash = get_hash(&repo)?;
108 let url = repo.url();
109 let url = match &repo.host {
110 Host::GitHub => format!("{url}/archive/{hash}.tar.gz"),
111 Host::GitLab(_) => format!("{url}/-/archive/{hash}/{}-{hash}.tar.gz", repo.name),
112 Host::BitBucket => format!("{url}/get/{hash}.tar.gz"),
113 };
114 let client = reqwest::blocking::Client::new();
115
116 let request = client.get(&url).send().unwrap();
117 match request.status() {
118 reqwest::StatusCode::OK => (),
119 reqwest::StatusCode::UNAUTHORIZED => {
120 Err("Could not find repository.")?;
121 }
122 s => Err(format!("Received response status: {:?}", s))?,
123 };
124
125 let total_size = request.content_length();
126
127 let pb = match total_size {
128 Some(x) => {
129 let p = ProgressBar::new(x);
130 p.set_style(ProgressStyle::default_bar()
131 .template("> {wide_msg}\n{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
132 .expect("failed template")
133 .progress_chars("#>-"));
134 p
135 }
136 None => {
137 let p = ProgressBar::new_spinner();
138 p
139 }
140 };
141
142 println!("Downloading {} to {}", repo, dest.display());
143 let tar = GzDecoder::new(pb.wrap_read(request));
146 let mut archive = Archive::new(tar);
147 archive
148 .entries()?
149 .filter_map(|e| e.ok())
150 .filter_map(|mut entry| -> Option<Result<PathBuf, Box<dyn Error>>> {
151 let path = match entry.path() {
152 Ok(p) => p,
153 Err(e) => return Some(Err(e.into())),
154 };
155
156 let path = match path.strip_prefix(path.components().next().unwrap()) {
158 Ok(p) => p.to_owned(),
159 Err(e) => return Some(Err(e.into())),
160 };
161
162 let final_path = if let Some(subdir) = &repo.subdir {
164 if !path.starts_with(subdir) {
165 return None;
166 }
167 match path.strip_prefix(subdir) {
168 Ok(p) => p.to_owned(),
169 Err(e) => return Some(Err(e.into())),
170 }
171 } else {
172 path
173 };
174
175 match entry.unpack(dest.join(&final_path)) {
176 Ok(_) => Some(Ok(final_path)),
177 Err(e) => Some(Err(e.into())),
178 }
179 })
180 .filter_map(|e| e.ok())
181 .for_each(|x| pb.set_message(format!("{}", x.to_string_lossy())));
182
183 pb.finish_with_message("Done...");
185 Ok(())
186}
187
188pub fn validate_src(src: &str) -> Result<(), String> {
189 parse(&src).map(|_| ()).map_err(|x| x.to_string())
190}
191
192pub fn validate_dest(dest: &str) -> Result<(), String> {
193 let path = PathBuf::from(dest);
194 if path.exists() {
195 if path.is_dir() {
196 let count = std::fs::read_dir(&path).map_err(|x| x.to_string())?.count();
197 if count != 0 {
198 Err(format!("Directory is not empty: {}", path.display()))?
199 }
200 } else {
201 Err("Destination is not a directory.")?
202 }
203 }
204 let mut realpath = {
205 if path.is_relative() {
206 let mut realpath = std::fs::canonicalize(std::path::Path::new(".")).unwrap();
207
208 for c in path.components() {
209 match c {
211 std::path::Component::ParentDir => {
212 realpath = realpath.parent().unwrap().to_path_buf()
213 }
214 std::path::Component::Normal(c) => realpath.push(c),
215 _ => (),
216 }
217 }
218 realpath
219 } else {
220 path
221 }
222 };
223 while !realpath.exists() {
224 realpath.pop();
225 }
226 if std::fs::metadata(&realpath)
227 .unwrap()
228 .permissions()
229 .readonly()
230 {
231 Err("Directory is read-only.")?
232 }
233 Ok(())
236}
237
238#[derive(Debug, Clone, PartialEq)]
239enum RefType {
240 Head,
241 Branch,
242 Tag,
243 Other(String),
244}
245
246#[derive(Debug, Clone, PartialEq)]
247struct GitRef {
248 ref_type: RefType,
249 name: String,
250 hash: String,
251}
252
253fn fetch_refs(repo: &Repo) -> Result<Vec<GitRef>, Box<dyn Error>> {
254 let output = Command::new("git")
255 .arg("ls-remote")
256 .arg(&repo.url())
257 .output()?;
258
259 if !output.status.success() {
260 let stderr = String::from_utf8_lossy(&output.stderr);
261 return Err(format!("could not fetch remote {}: {}", repo.url(), stderr).into());
262 }
263
264 let stdout = String::from_utf8_lossy(&output.stdout);
265 let re = Regex::new(r"refs/(\w+)/(.+)").unwrap();
266
267 stdout
268 .lines()
269 .filter(|line| !line.is_empty())
270 .map(|row| {
271 let parts: Vec<&str> = row.split('\t').collect();
272 if parts.len() != 2 {
273 return Err(format!("could not parse git ref: {row}").into());
274 }
275
276 let hash = parts[0].to_string();
277 let git_ref = parts[1];
278
279 if git_ref == "HEAD" {
280 return Ok(GitRef {
281 ref_type: RefType::Head,
282 name: git_ref.to_string(),
283 hash,
284 });
285 }
286
287 let captures = re
288 .captures(git_ref)
289 .ok_or_else(|| format!("could not parse {git_ref}"))?;
290
291 let ref_type_str = captures.get(1).unwrap().as_str();
292 let name = captures.get(2).unwrap().as_str().to_string();
293
294 let ref_type = match ref_type_str {
295 "heads" => RefType::Branch,
296 "tags" => RefType::Tag,
297 "refs" => RefType::Other("ref".to_string()),
298 other => RefType::Other(other.to_string()),
299 };
300
301 Ok(GitRef {
302 ref_type,
303 name,
304 hash,
305 })
306 })
307 .collect()
308}
309
310fn get_hash(repo: &Repo) -> Result<String, Box<dyn Error>> {
311 let refs = fetch_refs(repo)?;
312
313 if repo.gitref.is_none() || repo.gitref.as_deref() == Some("HEAD") {
315 if let Some(head_ref) = refs.iter().find(|r| r.ref_type == RefType::Head) {
316 return Ok(head_ref.hash.clone());
317 }
318 }
319
320 if let Some(gitref) = &repo.gitref {
322 for r in &refs {
323 if &r.name == gitref || r.hash.starts_with(gitref.as_str()) {
324 return Ok(r.hash.clone());
325 }
326 }
327
328 return Err(format!("Reference not found: {gitref}").into());
329 }
330
331 Err("Reference not found.".into())
332}
333
334#[cfg(test)]
335mod tests;