Skip to main content

cfgd_core/sources/
mod.rs

1// Sources — multi-source config management
2// Manages fetching, caching, and tracking external config sources (git repos).
3// Dependency rules: depends only on config/, output/, errors/. Must NOT import
4// files/, packages/, secrets/, reconciler/, providers/.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use git2::{FetchOptions, RemoteCallbacks, Repository};
10use semver::{Version, VersionReq};
11
12use crate::config::{
13    ConfigSourceDocument, OriginSpec, OriginType, ProfileDocument, SourceSpec, parse_config_source,
14};
15use crate::errors::{Result, SourceError};
16use crate::output::{Printer, Role};
17
18const SOURCE_MANIFEST_FILE: &str = "cfgd-source.yaml";
19const PROFILES_DIR: &str = "profiles";
20
21/// Cached state for a single config source.
22#[derive(Debug, Clone)]
23pub struct CachedSource {
24    pub name: String,
25    pub origin_url: String,
26    pub origin_branch: String,
27    pub local_path: PathBuf,
28    pub manifest: ConfigSourceDocument,
29    pub last_commit: Option<String>,
30    pub last_fetched: Option<String>,
31}
32
33/// Manager for multiple config sources — handles fetching, caching, version checking.
34pub struct SourceManager {
35    cache_dir: PathBuf,
36    sources: HashMap<String, CachedSource>,
37    /// When true, skip signature verification even if a source requires it.
38    allow_unsigned: bool,
39}
40
41impl SourceManager {
42    /// Create a new SourceManager using the given cache directory.
43    pub fn new(cache_dir: &Path) -> Self {
44        Self {
45            cache_dir: cache_dir.to_path_buf(),
46            sources: HashMap::new(),
47            allow_unsigned: false,
48        }
49    }
50
51    /// Set whether to allow unsigned source content (bypasses signature verification).
52    pub fn set_allow_unsigned(&mut self, allow: bool) {
53        self.allow_unsigned = allow;
54    }
55
56    /// Default cache directory: ~/.local/share/cfgd/sources/
57    pub fn default_cache_dir() -> Result<PathBuf> {
58        let base = directories::BaseDirs::new().ok_or_else(|| SourceError::CacheError {
59            message: "cannot determine home directory".into(),
60        })?;
61        Ok(base.data_local_dir().join("cfgd").join("sources"))
62    }
63
64    /// Load all sources from config, fetching if needed.
65    /// Returns an error if sources were specified but none loaded successfully.
66    pub fn load_sources(&mut self, sources: &[SourceSpec], printer: &Printer) -> Result<()> {
67        let mut loaded = 0;
68        for spec in sources {
69            match self.load_source(spec, printer) {
70                Ok(()) => loaded += 1,
71                Err(e) => {
72                    printer.status_simple(
73                        Role::Warn,
74                        format!("Failed to load source '{}': {}", spec.name, e),
75                    );
76                }
77            }
78        }
79        if !sources.is_empty() && loaded == 0 {
80            return Err(SourceError::GitError {
81                name: "all".to_string(),
82                message: "all sources failed to load".to_string(),
83            }
84            .into());
85        }
86        Ok(())
87    }
88
89    /// Load a single source — clone or fetch, parse manifest, check version.
90    pub fn load_source(&mut self, spec: &SourceSpec, printer: &Printer) -> Result<()> {
91        crate::validate_no_traversal(std::path::Path::new(&spec.name)).map_err(|e| {
92            SourceError::GitError {
93                name: spec.name.clone(),
94                message: format!("invalid source name: {e}"),
95            }
96        })?;
97
98        // Reject local file URLs to prevent local filesystem access from composed sources.
99        // CFGD_ALLOW_LOCAL_SOURCES bypasses this for dev/test environments only.
100        let url_lower = spec.origin.url.to_lowercase();
101        let allow_local = std::env::var("CFGD_ALLOW_LOCAL_SOURCES").is_ok();
102        if !allow_local && (url_lower.starts_with("file://") || url_lower.starts_with('/')) {
103            return Err(SourceError::GitError {
104                name: spec.name.clone(),
105                message: "local file:// URLs and absolute paths are not allowed as source origins"
106                    .to_string(),
107            }
108            .into());
109        }
110
111        let source_dir = self.cache_dir.join(&spec.name);
112
113        if source_dir.exists() {
114            self.fetch_source(spec, &source_dir, printer)?;
115        } else {
116            self.clone_source(spec, &source_dir, printer)?;
117        }
118
119        let manifest = self.parse_manifest(&spec.name, &source_dir)?;
120
121        // Signature verification: if the source requires signed commits, verify HEAD
122        self.verify_commit_signature(&spec.name, &source_dir, &manifest.spec.policy.constraints)?;
123
124        // Version pinning check
125        if let Some(ref pin) = spec.sync.pin_version {
126            self.check_version_pin(&spec.name, &manifest, pin)?;
127        }
128
129        let last_commit = Self::head_commit(&source_dir);
130
131        let cached = CachedSource {
132            name: spec.name.clone(),
133            origin_url: spec.origin.url.clone(),
134            origin_branch: spec.origin.branch.clone(),
135            local_path: source_dir,
136            manifest,
137            last_commit,
138            last_fetched: Some(crate::utc_now_iso8601()),
139        };
140
141        self.sources.insert(spec.name.clone(), cached);
142        Ok(())
143    }
144
145    /// Fetch (pull) updates for an already-cloned source.
146    fn fetch_source(&self, spec: &SourceSpec, source_dir: &Path, printer: &Printer) -> Result<()> {
147        let to_git_err = |e: git2::Error| SourceError::GitError {
148            name: spec.name.clone(),
149            message: e.to_string(),
150        };
151
152        // Try git CLI first with live progress output.
153        let mut cmd = crate::git_cmd_safe(
154            Some(&spec.origin.url),
155            Some(spec.origin.ssh_strict_host_key_checking),
156        );
157        cmd.args([
158            "-C",
159            &source_dir.display().to_string(),
160            "fetch",
161            "origin",
162            &spec.origin.branch,
163        ]);
164        // Ensure stderr is captured (git progress goes to stderr)
165        cmd.stdout(std::process::Stdio::piped());
166        cmd.stderr(std::process::Stdio::piped());
167
168        let label = format!("Fetching source '{}'", spec.name);
169        let cli_result = printer.run(&mut cmd, &label);
170        let cli_ok = matches!(&cli_result, Ok(output) if output.status.success());
171
172        if !cli_ok {
173            // Fall back to libgit2 with spinner
174            let spinner = printer.spinner(format!("Fetching source '{}' (libgit2)...", spec.name));
175
176            let repo = Repository::open(source_dir).map_err(to_git_err)?;
177
178            let mut remote = repo.find_remote("origin").map_err(to_git_err)?;
179
180            let mut fo = FetchOptions::new();
181            let mut callbacks = RemoteCallbacks::new();
182            callbacks.credentials(crate::git_ssh_credentials);
183            fo.remote_callbacks(callbacks);
184
185            let fetch_result = remote
186                .fetch(&[&spec.origin.branch], Some(&mut fo), None)
187                .map_err(|e| SourceError::FetchFailed {
188                    name: spec.name.clone(),
189                    message: e.to_string(),
190                });
191
192            match &fetch_result {
193                Ok(_) => {
194                    let _ = spinner.finish_ok(format!("Fetched source '{}' (libgit2)", spec.name));
195                }
196                Err(e) => {
197                    let _ = spinner
198                        .finish_fail(format!("Failed to fetch source '{}' (libgit2)", spec.name))
199                        .detail(crate::output::collapse_to_subject_line(e));
200                }
201            }
202            fetch_result?;
203        }
204
205        // Fast-forward to FETCH_HEAD
206        let repo = Repository::open(source_dir).map_err(to_git_err)?;
207
208        let fetch_head = repo.find_reference("FETCH_HEAD").map_err(to_git_err)?;
209        let fetch_commit = repo
210            .reference_to_annotated_commit(&fetch_head)
211            .map_err(to_git_err)?;
212
213        let (analysis, _) = repo.merge_analysis(&[&fetch_commit]).map_err(to_git_err)?;
214
215        if analysis.is_fast_forward() {
216            let refname = format!("refs/heads/{}", spec.origin.branch);
217            if let Ok(mut reference) = repo.find_reference(&refname) {
218                reference
219                    .set_target(fetch_commit.id(), "cfgd source fetch")
220                    .map_err(to_git_err)?;
221            }
222            repo.set_head(&refname).map_err(to_git_err)?;
223            repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
224                .map_err(to_git_err)?;
225        }
226
227        Ok(())
228    }
229
230    /// Clone a new source repo. Tries git CLI first (respects system credential
231    /// helpers and SSH config), falls back to libgit2.
232    fn clone_source(&self, spec: &SourceSpec, source_dir: &Path, printer: &Printer) -> Result<()> {
233        // Ensure parent dir exists but not source_dir itself (git clone creates it)
234        if let Some(parent) = source_dir.parent() {
235            std::fs::create_dir_all(parent).map_err(|e| SourceError::CacheError {
236                message: format!("cannot create cache dir: {}", e),
237            })?;
238        }
239
240        // Try git CLI first with live progress output.
241        // --depth=1: only fetch latest commit (limits repo size for DoS protection)
242        // --no-recurse-submodules: prevent malicious submodule URLs (SSRF, credential theft)
243        // --single-branch: only fetch the target branch
244        let mut cmd = crate::git_cmd_safe(
245            Some(&spec.origin.url),
246            Some(spec.origin.ssh_strict_host_key_checking),
247        );
248        cmd.args([
249            "clone",
250            "--depth=1",
251            "--single-branch",
252            "--no-recurse-submodules",
253            "--branch",
254            &spec.origin.branch,
255            &spec.origin.url,
256            &source_dir.display().to_string(),
257        ]);
258        cmd.stdout(std::process::Stdio::piped());
259        cmd.stderr(std::process::Stdio::piped());
260
261        let label = format!("Cloning source '{}'", spec.name);
262        let cli_result = printer.run(&mut cmd, &label);
263        if matches!(&cli_result, Ok(output) if output.status.success()) {
264            // Restrict cloned directory to owner-only access
265            let _ = crate::set_file_permissions(source_dir, 0o700);
266            return Ok(());
267        }
268
269        // Clean up partial clone before libgit2 retry
270        let _ = std::fs::remove_dir_all(source_dir);
271
272        // Fall back to libgit2 with spinner
273        let spinner = printer.spinner(format!("Cloning source '{}' (libgit2)...", spec.name));
274
275        let mut fo = FetchOptions::new();
276        if spec.origin.url.starts_with("git@") || spec.origin.url.starts_with("ssh://") {
277            let mut callbacks = RemoteCallbacks::new();
278            callbacks.credentials(crate::git_ssh_credentials);
279            fo.remote_callbacks(callbacks);
280        }
281
282        // Shallow clone with depth 1, disable submodule init
283        fo.depth(1);
284        let mut builder = git2::build::RepoBuilder::new();
285        builder.fetch_options(fo);
286        builder.branch(&spec.origin.branch);
287
288        let clone_result =
289            builder
290                .clone(&spec.origin.url, source_dir)
291                .map_err(|e| SourceError::FetchFailed {
292                    name: spec.name.clone(),
293                    message: e.to_string(),
294                });
295
296        match &clone_result {
297            Ok(_) => {
298                let _ = spinner.finish_ok(format!("Cloned source '{}' (libgit2)", spec.name));
299            }
300            Err(e) => {
301                let _ = spinner
302                    .finish_fail(format!("Failed to clone source '{}' (libgit2)", spec.name))
303                    .detail(crate::output::collapse_to_subject_line(e));
304            }
305        }
306        clone_result?;
307
308        // Restrict cloned directory to owner-only access
309        let _ = crate::set_file_permissions(source_dir, 0o700);
310
311        Ok(())
312    }
313
314    /// Parse the ConfigSource manifest from a source directory.
315    pub fn parse_manifest(&self, name: &str, source_dir: &Path) -> Result<ConfigSourceDocument> {
316        read_manifest(name, source_dir)
317    }
318
319    /// Verify the HEAD commit of a source repo has a valid GPG or SSH signature.
320    /// Checks `allow_unsigned` on this SourceManager and `require_signed_commits`
321    /// on the constraints before delegating to `verify_head_signature`.
322    pub fn verify_commit_signature(
323        &self,
324        name: &str,
325        source_dir: &Path,
326        constraints: &crate::config::SourceConstraints,
327    ) -> Result<()> {
328        if !constraints.require_signed_commits {
329            return Ok(());
330        }
331
332        if self.allow_unsigned {
333            tracing::info!(
334                source = %name,
335                "Signature verification skipped for source '{}' (allow-unsigned is set)",
336                name
337            );
338            return Ok(());
339        }
340
341        verify_head_signature(name, source_dir)
342    }
343
344    /// Check version pin against source manifest version.
345    fn check_version_pin(
346        &self,
347        name: &str,
348        manifest: &ConfigSourceDocument,
349        pin: &str,
350    ) -> Result<()> {
351        let version_str = manifest.metadata.version.as_deref().unwrap_or("0.0.0");
352
353        let version = Version::parse(version_str).map_err(|e| SourceError::InvalidManifest {
354            name: name.to_string(),
355            message: format!("invalid semver '{}': {}", version_str, e),
356        })?;
357
358        // Support tilde (~2) as shorthand for ~2.0.0
359        let normalized_pin = normalize_semver_pin(pin);
360        let req = VersionReq::parse(&normalized_pin).map_err(|_| SourceError::VersionMismatch {
361            name: name.to_string(),
362            version: version_str.to_string(),
363            pin: pin.to_string(),
364        })?;
365
366        if !req.matches(&version) {
367            return Err(SourceError::VersionMismatch {
368                name: name.to_string(),
369                version: version_str.to_string(),
370                pin: pin.to_string(),
371            }
372            .into());
373        }
374
375        Ok(())
376    }
377
378    /// Get the HEAD commit hash for a repo.
379    fn head_commit(source_dir: &Path) -> Option<String> {
380        let repo = Repository::open(source_dir).ok()?;
381        let head = repo.head().ok()?;
382        head.target().map(|oid| oid.to_string())
383    }
384
385    /// Get a cached source by name.
386    pub fn get(&self, name: &str) -> Option<&CachedSource> {
387        self.sources.get(name)
388    }
389
390    /// Get all cached sources.
391    pub fn all_sources(&self) -> &HashMap<String, CachedSource> {
392        &self.sources
393    }
394
395    /// Load a profile from a source's profiles directory.
396    pub fn load_source_profile(
397        &self,
398        source_name: &str,
399        profile_name: &str,
400    ) -> Result<ProfileDocument> {
401        let cached = self
402            .sources
403            .get(source_name)
404            .ok_or_else(|| SourceError::NotFound {
405                name: source_name.to_string(),
406            })?;
407
408        let profile_path = cached
409            .local_path
410            .join(PROFILES_DIR)
411            .join(format!("{}.yaml", profile_name));
412
413        if !profile_path.exists() {
414            return Err(SourceError::ProfileNotFound {
415                name: source_name.to_string(),
416                profile: profile_name.to_string(),
417            }
418            .into());
419        }
420
421        crate::config::load_profile(&profile_path)
422    }
423
424    /// Get the source profiles directory path.
425    pub fn source_profiles_dir(&self, source_name: &str) -> Result<PathBuf> {
426        let cached = self
427            .sources
428            .get(source_name)
429            .ok_or_else(|| SourceError::NotFound {
430                name: source_name.to_string(),
431            })?;
432        Ok(cached.local_path.join(PROFILES_DIR))
433    }
434
435    /// Get the source files directory path.
436    pub fn source_files_dir(&self, source_name: &str) -> Result<PathBuf> {
437        let cached = self
438            .sources
439            .get(source_name)
440            .ok_or_else(|| SourceError::NotFound {
441                name: source_name.to_string(),
442            })?;
443        Ok(cached.local_path.join("files"))
444    }
445
446    /// Remove a source from cache.
447    pub fn remove_source(&mut self, name: &str) -> Result<()> {
448        let cached = self
449            .sources
450            .remove(name)
451            .ok_or_else(|| SourceError::NotFound {
452                name: name.to_string(),
453            })?;
454
455        if cached.local_path.exists() {
456            std::fs::remove_dir_all(&cached.local_path).map_err(|e| SourceError::CacheError {
457                message: format!("failed to remove cache for '{}': {}", name, e),
458            })?;
459        }
460
461        Ok(())
462    }
463
464    /// Build a SourceSpec for adding a new source.
465    pub fn build_source_spec(name: &str, url: &str, profile: Option<&str>) -> SourceSpec {
466        SourceSpec {
467            name: name.to_string(),
468            origin: OriginSpec {
469                origin_type: OriginType::Git,
470                url: url.to_string(),
471                branch: "master".to_string(),
472                auth: None,
473                ssh_strict_host_key_checking: Default::default(),
474            },
475            subscription: crate::config::SubscriptionSpec {
476                profile: profile.map(|s| s.to_string()),
477                ..Default::default()
478            },
479            sync: Default::default(),
480        }
481    }
482}
483
484/// Read and parse a cfgd-source.yaml manifest from a directory.
485fn read_manifest(name: &str, source_dir: &Path) -> Result<ConfigSourceDocument> {
486    let manifest_path = source_dir.join(SOURCE_MANIFEST_FILE);
487    if !manifest_path.exists() {
488        return Err(SourceError::InvalidManifest {
489            name: name.to_string(),
490            message: format!("{} not found", SOURCE_MANIFEST_FILE),
491        }
492        .into());
493    }
494
495    let contents =
496        std::fs::read_to_string(&manifest_path).map_err(|e| SourceError::InvalidManifest {
497            name: name.to_string(),
498            message: e.to_string(),
499        })?;
500
501    let doc = parse_config_source(&contents).map_err(|e| SourceError::InvalidManifest {
502        name: name.to_string(),
503        message: e.to_string(),
504    })?;
505
506    if doc.spec.provides.profiles.is_empty() && doc.spec.provides.profile_details.is_empty() {
507        return Err(SourceError::NoProfiles {
508            name: name.to_string(),
509        }
510        .into());
511    }
512
513    Ok(doc)
514}
515
516/// Check if a directory contains a cfgd-source.yaml manifest.
517/// Returns Ok(Some(doc)) if found and valid, Ok(None) if not present,
518/// Err if file exists but is invalid.
519pub fn detect_source_manifest(dir: &Path) -> Result<Option<ConfigSourceDocument>> {
520    let manifest_path = dir.join(SOURCE_MANIFEST_FILE);
521    if !manifest_path.exists() {
522        return Ok(None);
523    }
524    let name = dir
525        .file_name()
526        .and_then(|n| n.to_str())
527        .unwrap_or("unknown");
528    read_manifest(name, dir).map(Some)
529}
530
531/// Verify that the HEAD commit of a git repo has a valid GPG or SSH signature.
532/// Uses `git log --format=%G?` to check signature status. Returns Ok(()) if the
533/// signature is valid (G) or valid with unknown trust (U). Returns an error for
534/// unsigned, bad, expired, revoked, or unverifiable signatures.
535pub fn verify_head_signature(name: &str, repo_dir: &Path) -> Result<()> {
536    if !crate::command_available("git") {
537        return Err(SourceError::SignatureVerificationFailed {
538            name: name.to_string(),
539            message: "git CLI is required for signature verification but is not available on PATH"
540                .into(),
541        }
542        .into());
543    }
544
545    let output = crate::command_output_with_timeout(
546        std::process::Command::new("git")
547            .args([
548                "-C",
549                &repo_dir.display().to_string(),
550                "log",
551                "--format=%G?",
552                "-1",
553            ])
554            .stdout(std::process::Stdio::piped())
555            .stderr(std::process::Stdio::piped()),
556        crate::COMMAND_TIMEOUT,
557    )
558    .map_err(|e| SourceError::SignatureVerificationFailed {
559        name: name.to_string(),
560        message: format!("failed to run git: {}", e),
561    })?;
562
563    if !output.status.success() {
564        return Err(SourceError::SignatureVerificationFailed {
565            name: name.to_string(),
566            message: format!(
567                "git log failed (exit {}): {}",
568                output.status.code().unwrap_or(-1),
569                crate::stderr_lossy_trimmed(&output)
570            ),
571        }
572        .into());
573    }
574
575    let status = crate::stdout_lossy_trimmed(&output);
576    classify_signature_status(name, &status)
577}
578
579/// Map a `git log --format=%G?` status code to a `Result`.
580///
581/// Status codes per `git-log(1)`:
582/// - `G`: good (valid) signature
583/// - `U`: good signature with unknown validity (untrusted key)
584/// - `N`: no signature
585/// - `B`: bad signature
586/// - `E`: signature cannot be checked
587/// - `X`: good signature that has expired
588/// - `Y`: good signature made by an expired key
589/// - `R`: good signature made by a revoked key
590///
591/// `G` and `U` are accepted (cfgd treats untrusted-key as "key not in keyring
592/// yet" rather than a hard failure). Anything else is a verification failure.
593pub(super) fn classify_signature_status(name: &str, status: &str) -> Result<()> {
594    match status {
595        "G" | "U" => {
596            tracing::info!(
597                source = %name,
598                "Source '{}' HEAD commit signature verified (status: {})",
599                name, status
600            );
601            Ok(())
602        }
603        "N" => Err(SourceError::SignatureVerificationFailed {
604            name: name.to_string(),
605            message: "HEAD commit is not signed — source requires signed commits".into(),
606        }
607        .into()),
608        "B" => Err(SourceError::SignatureVerificationFailed {
609            name: name.to_string(),
610            message: "HEAD commit has a bad (invalid) signature".into(),
611        }
612        .into()),
613        "E" => Err(SourceError::SignatureVerificationFailed {
614            name: name.to_string(),
615            message: "signature cannot be checked — ensure the signing key is imported".into(),
616        }
617        .into()),
618        "X" | "Y" => Err(SourceError::SignatureVerificationFailed {
619            name: name.to_string(),
620            message: "HEAD commit signature or signing key has expired".into(),
621        }
622        .into()),
623        "R" => Err(SourceError::SignatureVerificationFailed {
624            name: name.to_string(),
625            message: "HEAD commit was signed with a revoked key".into(),
626        }
627        .into()),
628        other => Err(SourceError::SignatureVerificationFailed {
629            name: name.to_string(),
630            message: format!("unexpected signature status '{}' from git", other),
631        }
632        .into()),
633    }
634}
635
636/// Normalize shorthand semver pins.
637/// `~2` means "any 2.x.x" (maps to `>=2.0.0, <3.0.0`, i.e., caret semantics).
638/// `~2.1` means "any 2.1.x" (maps to `~2.1.0`, tilde semantics).
639/// `~2.1.0` is passed through directly.
640/// `^N` uses caret semantics as-is.
641fn normalize_semver_pin(pin: &str) -> String {
642    let trimmed = pin.trim();
643
644    if let Some(rest) = trimmed.strip_prefix('~') {
645        let dots = rest.matches('.').count();
646        match dots {
647            // ~2 -> ^2.0.0 (user means "any v2")
648            0 => format!("^{}.0.0", rest),
649            // ~2.1 -> ~2.1.0 (user means "any 2.1.x")
650            1 => format!("~{}.0", rest),
651            _ => trimmed.to_string(),
652        }
653    } else if let Some(rest) = trimmed.strip_prefix('^') {
654        let dots = rest.matches('.').count();
655        match dots {
656            0 => format!("^{}.0.0", rest),
657            1 => format!("^{}.0", rest),
658            _ => trimmed.to_string(),
659        }
660    } else {
661        trimmed.to_string()
662    }
663}
664
665/// Clone a git repo with git CLI (with live progress), falling back to libgit2.
666/// Returns Ok(()) on success, Err with description on failure.
667pub fn git_clone_with_fallback(
668    url: &str,
669    target: &Path,
670    printer: &Printer,
671) -> std::result::Result<(), String> {
672    // Try git CLI first with live progress output.
673    let mut cmd = crate::git_cmd_safe(Some(url), None);
674    cmd.args([
675        "clone",
676        "--depth=1",
677        "--no-recurse-submodules",
678        url,
679        &target.display().to_string(),
680    ]);
681    cmd.stdout(std::process::Stdio::piped());
682    cmd.stderr(std::process::Stdio::piped());
683
684    let label = format!("Cloning {}", url);
685    let cli_result = printer.run(&mut cmd, &label);
686    if matches!(&cli_result, Ok(output) if output.status.success()) {
687        return Ok(());
688    }
689
690    // Clean up partial clone before libgit2 retry
691    let _ = std::fs::remove_dir_all(target);
692    let _ = std::fs::create_dir_all(target);
693
694    // Fall back to libgit2 with spinner
695    let spinner = printer.spinner("Cloning (libgit2)...");
696
697    let mut fetch_opts = git2::FetchOptions::new();
698    fetch_opts.depth(1);
699    if url.starts_with("git@") || url.starts_with("ssh://") {
700        let mut callbacks = git2::RemoteCallbacks::new();
701        callbacks.credentials(crate::git_ssh_credentials);
702        fetch_opts.remote_callbacks(callbacks);
703    }
704    let mut builder = git2::build::RepoBuilder::new();
705    builder.fetch_options(fetch_opts);
706
707    let result = builder
708        .clone(url, target)
709        .map(|_| ())
710        .map_err(|e| format!("Failed to clone {}: {}", url, e));
711
712    match &result {
713        Ok(_) => {
714            let _ = spinner.finish_ok(format!("Cloned {} (libgit2)", url));
715        }
716        Err(msg) => {
717            let _ = spinner
718                .finish_fail(format!("Failed to clone {} (libgit2)", url))
719                .detail(crate::output::collapse_to_subject_line(msg));
720        }
721    }
722    result
723}
724
725#[cfg(test)]
726mod tests;