Skip to main content

git_cache/
lib.rs

1use std::collections::HashMap;
2use std::ffi::OsStr;
3use std::io::BufRead;
4use std::sync::atomic::AtomicBool;
5use std::thread;
6use std::{fs::File, process::Command};
7
8use anyhow::{anyhow, bail, Context as _, Error, Result};
9use camino::{Utf8Path, Utf8PathBuf};
10use clap::{Arg, ArgAction, ArgMatches, ValueHint};
11use crossbeam::channel::Sender;
12use gix_config::file::init::Options;
13use gix_config::file::Metadata;
14use rayon::{prelude::*, ThreadPoolBuilder};
15
16pub struct GitCache {
17    cache_base_dir: Utf8PathBuf,
18}
19
20pub struct ScpScheme<'a> {
21    _user: &'a str,
22    host: &'a str,
23    path: &'a str,
24}
25
26impl<'a> TryFrom<&'a str> for ScpScheme<'a> {
27    type Error = anyhow::Error;
28
29    fn try_from(value: &'a str) -> std::result::Result<Self, Self::Error> {
30        if let Some((at_pos, colon_pos)) = url_split_scp_scheme(value) {
31            let (_user, rest) = value.split_at(at_pos);
32            let (host, path) = rest.split_at(colon_pos - at_pos);
33
34            // splitting like above keeps the split character (`@` and `:`), so chop that off, too.
35            let (_, host) = host.split_at(1);
36            let (_, path) = path.split_at(1);
37
38            Ok(ScpScheme { _user, host, path })
39        } else {
40            Err(anyhow!("url does not parse as git scp scheme"))
41        }
42    }
43}
44
45impl GitCache {
46    pub fn new(cache_base_dir: Utf8PathBuf) -> Result<Self, Error> {
47        std::fs::create_dir_all(&cache_base_dir)
48            .with_context(|| format!("creating git cache base directory {cache_base_dir}"))?;
49
50        Ok(Self { cache_base_dir })
51    }
52
53    pub fn cloner(&self) -> GitCacheClonerBuilder {
54        let mut cloner = GitCacheClonerBuilder::default();
55        cloner.cache_base_dir(self.cache_base_dir.clone());
56        cloner
57    }
58
59    pub fn prefetcher(&self) -> GitCachePrefetcherBuilder {
60        let mut prefetcher = GitCachePrefetcherBuilder::default();
61        prefetcher.cache_base_dir(self.cache_base_dir.clone());
62        prefetcher
63    }
64}
65
66#[macro_use]
67extern crate derive_builder;
68
69#[derive(Builder)]
70pub struct GitCacheCloner {
71    cache_base_dir: Utf8PathBuf,
72    #[builder(setter(custom))]
73    repository_url: String,
74    #[builder(default = "true")]
75    cached: bool,
76    #[builder(default)]
77    update: bool,
78    #[builder(default)]
79    target_path: Option<Utf8PathBuf>,
80    #[builder(default)]
81    sparse_paths: Option<Vec<String>>,
82    #[builder(default)]
83    recurse_submodules: Option<Vec<String>>,
84    #[builder(default)]
85    recurse_all_submodules: bool,
86    #[builder(default)]
87    shallow_submodules: bool,
88    #[builder(default)]
89    commit: Option<String>,
90    #[builder(default)]
91    extra_clone_args: Option<Vec<String>>,
92    #[builder(default)]
93    jobs: Option<usize>,
94}
95
96impl GitCacheClonerBuilder {
97    pub fn repository_url(&mut self, url: String) -> &mut Self {
98        if self.cached.is_none() {
99            self.cached = Some(!repo_is_local(&url));
100        }
101        self.repository_url = Some(url);
102        self
103    }
104
105    pub fn do_clone(&mut self) -> Result<(), Error> {
106        self.build()
107            .expect("GitCacheCloner builder correctly set up")
108            .do_clone()
109    }
110    pub fn extra_clone_args_from_matches(&mut self, matches: &ArgMatches) -> &mut Self {
111        self.extra_clone_args(Some(get_pass_through_args(matches)))
112    }
113}
114
115/// returns `true` if the git repo url points to a local path
116///
117/// This function tries to mimic Git's notion of a local repository.
118///
119/// Some things to watch out for:
120/// - this does not take bundles into account
121fn repo_is_local(url: &str) -> bool {
122    if let Ok(url) = url::Url::parse(url) {
123        url.scheme() == "file"
124    } else {
125        (url.starts_with("./") || url.starts_with('/'))
126            || (!url_is_scp_scheme(url))
127            || std::path::Path::new(url).exists()
128    }
129}
130
131fn url_split_scp_scheme(url: &str) -> Option<(usize, usize)> {
132    let at = url.find('@');
133    let colon = url.find(':');
134
135    if let Some(colon_pos) = colon {
136        if let Some(at_pos) = at {
137            if at_pos < colon_pos {
138                return Some((at_pos, colon_pos));
139            }
140        }
141    }
142    None
143}
144
145fn url_is_scp_scheme(url: &str) -> bool {
146    url_split_scp_scheme(url).is_some()
147}
148
149impl GitCacheCloner {
150    fn do_clone(&self) -> Result<(), Error> {
151        let repository = &self.repository_url;
152        let wanted_commit = self.commit.as_ref();
153        let target_path;
154
155        if self.cached {
156            let cache_repo = GitCacheRepo::new(&self.cache_base_dir, &self.repository_url);
157            target_path = cache_repo.target_path(self.target_path.as_ref())?;
158
159            let mut lock = cache_repo.lockfile()?;
160            {
161                let _lock = lock.write()?;
162                if !cache_repo.mirror()? {
163                    let try_update =
164                        wanted_commit.is_some_and(|commit| !cache_repo.has_commit(commit).unwrap());
165
166                    if self.update || try_update {
167                        println!("git-cache: updating cache for {repository}...");
168                        cache_repo.update()?;
169                    }
170
171                    if let Some(commit) = wanted_commit {
172                        if try_update && !cache_repo.has_commit(commit)? {
173                            bail!("git-cache: {repository} does not contain commit {commit}");
174                        }
175                    }
176                }
177            }
178            {
179                let _lock = lock.read()?;
180                cache_repo.clone(target_path.as_str(), self.extra_clone_args.as_ref())?;
181            }
182        } else {
183            target_path =
184                target_path_from_url_maybe(&self.repository_url, self.target_path.as_ref())?;
185
186            direct_clone(
187                &self.repository_url,
188                target_path.as_str(),
189                self.extra_clone_args.as_ref(),
190            )?;
191        }
192
193        let target_repo = GitRepo {
194            path: target_path.clone(),
195        };
196
197        if let Some(commit) = wanted_commit {
198            target_repo.set_config("advice.detachedHead", "false")?;
199            target_repo.checkout(commit)?;
200        }
201        if let Some(sparse_paths) = self.sparse_paths.as_ref() {
202            target_repo.sparse_checkout(sparse_paths)?;
203        }
204
205        if self.recurse_all_submodules || self.recurse_submodules.is_some() {
206            let filter = if !self.recurse_all_submodules {
207                self.recurse_submodules.clone()
208            } else {
209                None
210            };
211
212            let cache = self.cache()?;
213
214            let jobs = self.jobs.unwrap_or(1);
215
216            static RAYON_CONFIGURED: AtomicBool = AtomicBool::new(false);
217
218            if !RAYON_CONFIGURED.swap(true, std::sync::atomic::Ordering::AcqRel) {
219                let _ = ThreadPoolBuilder::new().num_threads(jobs).build_global();
220            }
221
222            target_repo
223                .get_submodules(filter)?
224                .par_iter()
225                .map(|submodule| {
226                    println!(
227                        "git-cache: cloning {} into {}...",
228                        submodule.url, submodule.path
229                    );
230                    target_repo.clone_submodule(
231                        submodule,
232                        &cache,
233                        self.shallow_submodules,
234                        self.update,
235                    )
236                })
237                .collect::<Result<Vec<_>, _>>()?;
238        };
239
240        Ok(())
241    }
242
243    pub fn cache(&self) -> Result<GitCache, anyhow::Error> {
244        GitCache::new(self.cache_base_dir.clone())
245    }
246}
247
248#[derive(Builder)]
249#[builder(build_fn(validate = "Self::validate"))]
250pub struct GitCachePrefetcher {
251    cache_base_dir: Utf8PathBuf,
252    repository_urls: Vec<String>,
253    #[builder(default)]
254    update: bool,
255    #[builder(default)]
256    recurse_all_submodules: bool,
257    #[builder(default)]
258    jobs: Option<usize>,
259}
260
261impl GitCachePrefetcherBuilder {
262    pub fn validate(&self) -> Result<(), String> {
263        if let Some(urls) = &self.repository_urls {
264            for url in urls {
265                if repo_is_local(&url) {
266                    return Err(format!(
267                        "can only cache remote repositories, '{url}' is local"
268                    ));
269                }
270            }
271        }
272        Ok(())
273    }
274
275    pub fn do_prefetch(&mut self) -> Result<(), Error> {
276        self.build()
277            .expect("GitCachePrefetcher builder correctly set up")
278            .do_prefetch()
279    }
280}
281
282enum Prefetch {
283    Done,
284    Url(String),
285}
286
287impl GitCachePrefetcher {
288    fn do_prefetch(&self) -> Result<(), Error> {
289        let (sender, receiver) = crossbeam::channel::unbounded::<String>();
290        let (sender2, receiver2) = crossbeam::channel::unbounded::<Prefetch>();
291
292        let mut handles = Vec::new();
293
294        let n_workers = self.jobs.unwrap_or(1);
295
296        for _ in 0..n_workers {
297            let r = receiver.clone();
298            let cache_base_dir = self.cache_base_dir.clone();
299            let recurse = self.recurse_all_submodules;
300            let update = self.update;
301            let sender2 = sender2.clone();
302
303            let handle = thread::spawn(move || {
304                for repository_url in r.iter() {
305                    if let Err(e) =
306                        prefetch_url(&repository_url, &cache_base_dir, update, recurse, &sender2)
307                    {
308                        println!("git-cache: error prefetching {repository_url}: {e}");
309                    }
310                }
311            });
312            handles.push(handle);
313        }
314
315        for repository_url in &self.repository_urls {
316            let _ = sender2.send(Prefetch::Url(repository_url.clone()));
317        }
318
319        let mut left = 0usize;
320        let mut total = 0;
321        for prefetch in receiver2 {
322            match prefetch {
323                Prefetch::Done => left -= 1,
324                Prefetch::Url(url) => {
325                    left += 1;
326                    total += 1;
327                    let _ = sender.send(url);
328                }
329            }
330            if left == 0 {
331                break;
332            }
333        }
334
335        // Close the channel
336        drop(sender);
337
338        // Wait for all threads to finish
339        for handle in handles {
340            handle.join().unwrap();
341        }
342
343        println!("git-cache: finished pre-fetching {total} repositories.");
344
345        Ok(())
346    }
347
348    pub fn cache(&self) -> Result<GitCache, anyhow::Error> {
349        GitCache::new(self.cache_base_dir.clone())
350    }
351}
352
353pub struct GitRepo {
354    path: Utf8PathBuf,
355}
356
357pub struct GitCacheRepo {
358    url: String,
359    repo: GitRepo,
360}
361
362impl GitRepo {
363    fn git(&self) -> std::process::Command {
364        let mut command = Command::new("git");
365        command.arg("-C").arg(&self.path);
366
367        command
368    }
369
370    fn is_initialized(&self) -> Result<bool> {
371        Ok(self.path.is_dir()
372            && matches!(
373                self.git()
374                    .arg("rev-parse")
375                    .arg("--git-dir")
376                    .output()?
377                    .stdout
378                    .as_slice(),
379                b".\n" | b".git\n"
380            ))
381    }
382
383    fn has_commit(&self, commit: &str) -> Result<bool> {
384        Ok(self
385            .git()
386            .arg("cat-file")
387            .arg("-e")
388            .arg(format!("{}^{{commit}}", commit))
389            .status()?
390            .success())
391    }
392
393    fn set_config(&self, key: &str, value: &str) -> Result<()> {
394        self.git()
395            .arg("config")
396            .arg(key)
397            .arg(value)
398            .status()?
399            .success()
400            .true_or(anyhow!("cannot set configuration value"))
401    }
402
403    fn checkout(&self, commit: &str) -> Result<()> {
404        self.git()
405            .arg("checkout")
406            .arg(commit)
407            .status()?
408            .success()
409            .true_or(anyhow!("error checking out commit"))
410    }
411
412    fn submodule_commits(&self) -> Result<HashMap<String, String>> {
413        let output = self.git().arg("submodule").arg("status").output()?;
414
415        let res = output
416            .stdout
417            .lines()
418            .map(|line| line.unwrap())
419            .map(|line| {
420                // ` f47ce7b5fbbb3aa43d33d2be1f6cd3746b13d5bf some/path`
421                let commit = line[1..41].to_string();
422                let path = line[42..].to_string();
423                (path, commit)
424            })
425            .collect::<HashMap<String, String>>();
426        Ok(res)
427    }
428
429    fn sparse_checkout<I, S>(&self, sparse_paths: I) -> std::result::Result<(), anyhow::Error>
430    where
431        I: IntoIterator<Item = S>,
432        S: AsRef<OsStr>,
433    {
434        self.git()
435            .arg("sparse-checkout")
436            .arg("set")
437            .arg("--no-cone")
438            .arg("--skip-checks")
439            .args(sparse_paths)
440            .status()?
441            .success()
442            .true_or(anyhow!("error setting up sparse checkout"))
443    }
444
445    fn get_submodules(
446        &self,
447        filter: Option<Vec<String>>,
448    ) -> std::result::Result<Vec<SubmoduleSpec>, anyhow::Error> {
449        use gix_config::File;
450        let mut path = self.path.clone();
451        path.push(".gitmodules");
452
453        if !path.exists() {
454            return Ok(Vec::new());
455        }
456
457        let gitconfig = File::from_path_no_includes(path.into(), gix_config::Source::Api)?;
458        let gitmodules = gitconfig.sections_by_name("submodule");
459
460        if gitmodules.is_none() {
461            return Ok(Vec::new());
462        }
463
464        let submodule_commits = self.submodule_commits()?;
465
466        let mut submodules = Vec::new();
467        for module in gitmodules.unwrap() {
468            let path = module.body().value("path");
469            let url = module.body().value("url");
470            let branch = module.body().value("branch").map(|b| b.to_string());
471
472            if path.is_none() || url.is_none() {
473                eprintln!("git-cache: submodule missing path or url");
474                continue;
475            }
476            let path = path.unwrap().into_owned().to_string();
477            let url = url.unwrap().into_owned().to_string();
478
479            let commit = submodule_commits.get(&path);
480
481            if commit.is_none() {
482                eprintln!("git-cache: could not find submodule commit for path `{path}`");
483            }
484
485            if let Some(filter) = filter.as_ref() {
486                if !filter.contains(&path) {
487                    continue;
488                }
489            }
490
491            submodules.push(SubmoduleSpec::new(
492                path,
493                url,
494                commit.unwrap().clone(),
495                branch,
496            ));
497        }
498
499        Ok(submodules)
500    }
501
502    fn clone_submodule(
503        &self,
504        submodule: &SubmoduleSpec,
505        cache: &GitCache,
506        shallow_submodules: bool,
507        update: bool,
508    ) -> std::result::Result<(), anyhow::Error> {
509        let submodule_path = self.path.join(&submodule.path);
510
511        let mut cloner = cache.cloner();
512
513        cloner
514            .repository_url(submodule.url.clone())
515            .target_path(Some(submodule_path))
516            .recurse_all_submodules(true)
517            .shallow_submodules(shallow_submodules)
518            .commit(Some(submodule.commit.clone()))
519            .update(update);
520
521        // if let Some(branch) = submodule.branch {
522        //     cloner.extra_clone_args(Some(vec!["--branch".into(), branch]));
523        // }
524
525        cloner.do_clone()?;
526
527        self.init_submodule(&submodule.path)?;
528
529        Ok(())
530    }
531
532    fn init_submodule(&self, path: &str) -> std::result::Result<(), anyhow::Error> {
533        self.git()
534            .arg("submodule")
535            .arg("init")
536            .arg("--")
537            .arg(path)
538            .status()?
539            .success()
540            .true_or(anyhow!("error initializing submodule"))
541    }
542}
543
544impl GitCacheRepo {
545    pub fn new(base_path: &Utf8Path, url: &str) -> Self {
546        let mut path = base_path.to_path_buf();
547        path.push(Self::repo_path_from_url(url));
548        Self {
549            repo: GitRepo { path },
550            url: url.to_string(),
551        }
552    }
553
554    fn mirror(&self) -> Result<bool> {
555        if !self.repo.is_initialized()? {
556            println!("git-cache: cloning {} into cache...", self.url);
557            std::fs::create_dir_all(&self.repo.path)?;
558            Command::new("git")
559                .arg("clone")
560                .arg("--mirror")
561                .arg("--")
562                .arg(&self.url)
563                .arg(&self.repo.path)
564                .status()?
565                .success()
566                .true_or(anyhow!("error mirroring repository"))?;
567
568            Ok(true)
569        } else {
570            Ok(false)
571        }
572    }
573
574    fn update(&self) -> Result<()> {
575        self.repo
576            .git()
577            .arg("remote")
578            .arg("update")
579            .status()?
580            .success()
581            .true_or(anyhow!("error updating repository"))
582    }
583
584    // # Panics
585    // This panics when called on an invalid or local URL, which shouldn't happen.
586    fn repo_path_from_url(url: &str) -> Utf8PathBuf {
587        let mut path = if let Ok(url) = url::Url::parse(url) {
588            assert!(url.scheme() != "file");
589            let (_, path) = url.path().split_at(1);
590            Utf8PathBuf::from(url.host_str().unwrap()).join(path)
591        } else if let Ok(scp_scheme) = ScpScheme::try_from(url) {
592            Utf8PathBuf::from(scp_scheme.host).join(scp_scheme.path)
593        } else {
594            unreachable!("shouldn't be here");
595        };
596        path.set_extension("git");
597
598        path
599    }
600
601    fn clone(&self, target_path: &str, pass_through_args: Option<&Vec<String>>) -> Result<()> {
602        direct_clone(self.repo.path.as_str(), target_path, pass_through_args)?;
603
604        Command::new("git")
605            .arg("-C")
606            .arg(target_path)
607            .arg("remote")
608            .arg("set-url")
609            .arg("origin")
610            .arg(&self.url)
611            .status()?
612            .success()
613            .true_or(anyhow!("error updating remote url"))?;
614        Ok(())
615    }
616
617    pub fn target_path(&self, target_path: Option<&Utf8PathBuf>) -> Result<Utf8PathBuf> {
618        target_path_from_url_maybe(&self.url, target_path)
619    }
620
621    // fn is_initialized(&self) -> std::result::Result<bool, anyhow::Error> {
622    //     self.repo.is_initialized()
623    // }
624
625    fn has_commit(&self, commit: &str) -> std::result::Result<bool, anyhow::Error> {
626        self.repo.has_commit(commit)
627    }
628
629    fn lockfile(&self) -> Result<fd_lock::RwLock<File>> {
630        let base_path = self.repo.path.parent().unwrap();
631        std::fs::create_dir_all(&base_path)
632            .with_context(|| format!("creating repo base path '{base_path}'"))?;
633
634        let lock_path = self.repo.path.with_extension("git.lock");
635        Ok(fd_lock::RwLock::new(
636            std::fs::File::create(&lock_path)
637                .with_context(|| format!("creating lock file \"{lock_path}\""))?,
638        ))
639    }
640
641    fn get_submodules(&self) -> std::result::Result<Vec<String>, anyhow::Error> {
642        let output = self
643            .repo
644            .git()
645            .arg("show")
646            .arg("HEAD:.gitmodules")
647            .output()?;
648
649        let data = output.stdout;
650        let gitconfig =
651            gix_config::File::from_bytes_no_includes(&data, Metadata::api(), Options::default())?;
652        let gitmodules = gitconfig.sections_by_name("submodule");
653
654        if let Some(gitmodules) = gitmodules {
655            Ok(gitmodules
656                .filter_map(|submodule| submodule.body().value("url").map(|cow| cow.to_string()))
657                .collect())
658        } else {
659            return Ok(vec![]);
660        }
661    }
662}
663
664fn direct_clone(
665    repo: &str,
666    target_path: &str,
667    pass_through_args: Option<&Vec<String>>,
668) -> Result<(), Error> {
669    let mut clone_cmd = Command::new("git");
670    clone_cmd.arg("clone").arg("--shared");
671    if let Some(args) = pass_through_args {
672        clone_cmd.args(args);
673    }
674    clone_cmd
675        .arg("--")
676        .arg(repo)
677        .arg(target_path)
678        .status()?
679        .success()
680        .true_or(anyhow!("cloning failed"))?;
681    Ok(())
682}
683
684fn prefetch_url(
685    repository_url: &str,
686    cache_base_dir: &Utf8Path,
687    update: bool,
688    recurse: bool,
689    sender: &Sender<Prefetch>,
690) -> Result<(), Error> {
691    scopeguard::defer! {
692        let _ = sender.send(Prefetch::Done);
693    }
694
695    let cache_repo = GitCacheRepo::new(cache_base_dir, repository_url);
696
697    let mut lock = cache_repo.lockfile()?;
698    {
699        let _lock = lock.write()?;
700        if !cache_repo.mirror()? {
701            if update {
702                println!("git-cache: updating cache for {repository_url}...");
703                cache_repo.update()?;
704            }
705        }
706    }
707
708    if recurse {
709        let _lock = lock.read()?;
710        for url in cache_repo.get_submodules()? {
711            println!("git-cache: {repository_url} getting submodule: {url}");
712            let _ = sender.send(Prefetch::Url(url));
713        }
714    }
715
716    Ok(())
717}
718
719fn target_path_from_url_maybe(
720    url: &str,
721    target_path: Option<&Utf8PathBuf>,
722) -> Result<Utf8PathBuf, Error> {
723    target_path.map(shellexpand::tilde);
724
725    let url_path = Utf8PathBuf::from(url);
726    let url_path_filename = Utf8PathBuf::from(url_path.file_name().unwrap());
727    let target_path = target_path.unwrap_or(&url_path_filename);
728
729    if !target_path.is_clone_target()? {
730        return Err(anyhow!(
731            "fatal: destination path '{target_path}' already exists and is not an empty directory."
732        ));
733    }
734
735    Ok(target_path.clone())
736}
737
738pub fn clap_git_cache_dir_arg() -> Arg {
739    Arg::new("git_cache_dir")
740        .short('c')
741        .long("cache-dir")
742        .help("git cache base directory")
743        .required(false)
744        .default_value("~/.gitcache")
745        .value_parser(clap::value_parser!(Utf8PathBuf))
746        .value_hint(ValueHint::DirPath)
747        .env("GIT_CACHE_DIR")
748        .num_args(1)
749}
750
751pub fn clap_clone_command(name: &'static str) -> clap::Command {
752    use clap::Command;
753    Command::new(name)
754        .about("clone repository")
755        .arg(
756            Arg::new("repository")
757                .help("repository to clone")
758                .required(true),
759        )
760        .arg(
761            Arg::new("target_path")
762                .help("target path")
763                .required(false)
764                .value_parser(clap::value_parser!(Utf8PathBuf))
765                .value_hint(ValueHint::DirPath),
766        )
767        .arg(
768            Arg::new("update")
769                .short('U')
770                .long("update")
771                .action(ArgAction::SetTrue)
772                .help("force update of cached repo"),
773        )
774        .arg(
775            Arg::new("commit")
776                .long("commit")
777                .value_name("HASH")
778                .conflicts_with("branch")
779                .help("check out specific commit"),
780        )
781        .arg(
782            Arg::new("sparse-add")
783                .long("sparse-add")
784                .value_name("PATH")
785                .conflicts_with("branch")
786                .action(ArgAction::Append)
787                .help("do a sparse checkout, keep PATH"),
788        )
789        .arg(
790            Arg::new("recurse-submodules")
791                .long("recurse-submodules")
792                .value_name("pathspec")
793                .action(ArgAction::Append)
794                .num_args(0..=1)
795                .require_equals(true)
796                .help("recursively clone submodules"),
797        )
798        .arg(
799            Arg::new("shallow-submodules")
800                .long("shallow-submodules")
801                .action(ArgAction::SetTrue)
802                .overrides_with("no-shallow-submodules")
803                .help("shallow-clone submodules"),
804        )
805        .arg(
806            Arg::new("no-shallow-submodules")
807                .long("no-shallow-submodules")
808                .action(ArgAction::SetTrue)
809                .overrides_with("shallow-submodules")
810                .help("don't shallow-clone submodules"),
811        )
812        .arg(
813            Arg::new("jobs")
814                .long("jobs")
815                .short('j')
816                .help("The number of submodules fetched at the same time.")
817                .num_args(1)
818                .value_parser(clap::value_parser!(usize)),
819        )
820        .args(pass_through_args())
821        .after_help(
822            "These regular \"git clone\" options are passed through:\n
823        [--template=<template-directory>]
824        [-l] [-s] [--no-hardlinks] [-q] [-n] [--bare] [--mirror]
825        [-o <name>] [-b <name>] [-u <upload-pack>] [--reference <repository>]
826        [--dissociate] [--separate-git-dir <git-dir>]
827        [--depth <depth>] [--[no-]single-branch] [--no-tags]
828        [--recurse-submodules[=<pathspec>]] [--[no-]shallow-submodules]
829        [--[no-]remote-submodules] [--jobs <n>] [--sparse] [--[no-]reject-shallow]
830        [--filter=<filter> [--also-filter-submodules]]",
831        )
832}
833
834pub fn clap_prefetch_command(name: &'static str) -> clap::Command {
835    use clap::Command;
836    Command::new(name)
837        .about("pre-fetch repositories into the cache")
838        .arg(
839            Arg::new("repositories")
840                .help("repositories to prefetch")
841                .required(true)
842                .num_args(1..),
843        )
844        .arg(
845            Arg::new("update")
846                .short('U')
847                .long("update")
848                .action(ArgAction::SetTrue)
849                .help("force update of already cached repo(s)"),
850        )
851        .arg(
852            Arg::new("recurse-submodules")
853                .long("recurse-submodules")
854                .short('r')
855                .action(ArgAction::SetTrue)
856                .help("recursively prefetch submodules"),
857        )
858        .arg(
859            Arg::new("jobs")
860                .long("jobs")
861                .short('j')
862                .help("The number of reposititories fetched at the same time.")
863                .num_args(1)
864                .value_parser(clap::value_parser!(usize)),
865        )
866}
867
868fn pass_through_args() -> Vec<Arg> {
869    let mut args = Vec::new();
870
871    // short w/o arg
872    for (short, long) in [
873        ('l', "local"),
874        //        ('n', "no-checkout"),
875        ('q', "quiet"),
876        ('s', "shared"),
877        ('v', "verbose"),
878    ]
879    .into_iter()
880    {
881        args.push(
882            Arg::new(long)
883                .short(short)
884                .long(long)
885                .hide(true)
886                .action(ArgAction::SetTrue),
887        );
888    }
889
890    //
891    args.push(
892        Arg::new("no-checkout")
893            .short('n')
894            .long("no-checkout")
895            .hide(true)
896            .num_args(0)
897            .default_value_if("commit", clap::builder::ArgPredicate::IsPresent, "true"),
898    );
899
900    args.push(
901        Arg::new("sparse")
902            .long("sparse")
903            .hide(true)
904            .num_args(0)
905            .default_value_if("sparse-add", clap::builder::ArgPredicate::IsPresent, "true"),
906    );
907
908    // short with arg
909    for (short, long) in [
910        ('b', "branch"),
911        ('c', "config"),
912        ('o', "origin"),
913        ('u', "upload-pack"),
914    ]
915    .into_iter()
916    {
917        args.push(
918            Arg::new(long)
919                .short(short)
920                .long(long)
921                .num_args(1)
922                .hide(true),
923        );
924    }
925
926    // long w/o arg
927    for id in [
928        "also-filter-submodules",
929        "bare",
930        "dissociate",
931        "mirror",
932        "no-hardlinks",
933        "no-reject-shallow",
934        "no-remote-submodules",
935        "no-single-branch",
936        "no-tags",
937        "reject-shallow",
938        "remote-submodules",
939        "single-branch",
940    ]
941    .into_iter()
942    {
943        args.push(Arg::new(id).long(id).action(ArgAction::SetTrue).hide(true));
944    }
945
946    // long with arg always
947    for id in [
948        "bundle-uri",
949        "depth",
950        "filter",
951        "reference",
952        "reference-if-able",
953        "separate-git-dir",
954        "shallow-exclude",
955        "shallow-since",
956        "template",
957    ]
958    .into_iter()
959    {
960        args.push(Arg::new(id).long(id).num_args(1).hide(true));
961    }
962
963    args
964}
965
966fn get_pass_through_args(matches: &ArgMatches) -> Vec<String> {
967    let mut args = Vec::new();
968    // w/o arg
969    for id in [
970        "local",
971        "no-checkout",
972        "quiet",
973        "shared",
974        "verbose",
975        "also-filter-submodules",
976        "bare",
977        "dissociate",
978        "mirror",
979        "no-hardlinks",
980        "no-reject-shallow",
981        "no-remote-submodules",
982        "no-single-branch",
983        "no-tags",
984        "reject-shallow",
985        "remote-submodules",
986        "single-branch",
987        "sparse",
988    ]
989    .into_iter()
990    {
991        if matches.get_flag(id) {
992            args.push(format!("--{id}"));
993        }
994    }
995
996    // with arg always
997    for id in [
998        "branch",
999        "bundle-uri",
1000        "config",
1001        "depth",
1002        "filter",
1003        "origin",
1004        "reference",
1005        "reference-if-able",
1006        "separate-git-dir",
1007        "shallow-exclude",
1008        "shallow-since",
1009        "template",
1010        "upload-pack",
1011    ]
1012    .into_iter()
1013    {
1014        if let Some(occurrences) = matches.get_occurrences::<String>(id) {
1015            for occurrence in occurrences.flatten() {
1016                args.push(format!("--{id}"));
1017                args.push(occurrence.clone());
1018            }
1019        }
1020    }
1021
1022    args
1023}
1024
1025trait CanCloneInto {
1026    fn is_clone_target(&self) -> Result<bool, Error>;
1027}
1028
1029impl CanCloneInto for camino::Utf8Path {
1030    fn is_clone_target(&self) -> Result<bool, Error> {
1031        Ok((!self.exists()) || (self.is_dir() && { self.read_dir()?.next().is_none() }))
1032    }
1033}
1034
1035trait TrueOr {
1036    fn true_or(self, error: Error) -> Result<()>;
1037}
1038
1039impl TrueOr for bool {
1040    fn true_or(self, error: Error) -> Result<()> {
1041        if self {
1042            Ok(())
1043        } else {
1044            Err(error)
1045        }
1046    }
1047}
1048
1049#[derive(Debug, Clone)]
1050struct SubmoduleSpec {
1051    path: String,
1052    url: String,
1053    #[allow(dead_code)]
1054    branch: Option<String>,
1055    commit: String,
1056}
1057
1058impl SubmoduleSpec {
1059    pub fn new(path: String, url: String, commit: String, branch: Option<String>) -> Self {
1060        Self {
1061            path,
1062            url,
1063            commit,
1064            branch,
1065        }
1066    }
1067}