Skip to main content

degit_rs/
lib.rs

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    // Determine host from multiple formats that might match regex
72    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    // println!("{:#?}", request.content_length());
144
145    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            // Strip root directory (first component of tar archive)
157            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            // If subdirectory is specified, filter and strip it
163            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    // archive.unpack(dest).unwrap();
184    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                // println!("component: {:?}", c);
210                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    // println!("realpath: {:?}", realpath);
234
235    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 no gitref specified or it's "HEAD", return HEAD
314    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    // Otherwise search for matching ref
321    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;