Skip to main content

cfgd_core/modules/
git.rs

1//! Git URL parsing and git clone/fetch/checkout operations for module file sources.
2
3use std::path::{Path, PathBuf};
4
5use crate::PathDisplayExt;
6use crate::errors::{ModuleError, Result};
7
8// ---------------------------------------------------------------------------
9// Git file source URL parsing
10// ---------------------------------------------------------------------------
11
12/// Parsed git file source URL.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct GitSource {
15    /// The repo URL (without tag/ref/subdir suffixes).
16    pub repo_url: String,
17    /// Tag to checkout (from `@tag` suffix).
18    pub tag: Option<String>,
19    /// Branch/ref to checkout (from `?ref=branch` suffix).
20    pub git_ref: Option<String>,
21    /// Subdirectory within the repo (from `//subdir` separator).
22    pub subdir: Option<String>,
23}
24
25/// Check whether a file source string is a git URL (not a local path).
26///
27/// `file://` URLs are rejected by default to keep remote-module sources to
28/// proper network protocols. Tests can opt into local-file sources by setting
29/// `CFGD_ALLOW_LOCAL_SOURCES=1` — same gate `sources/mod.rs` uses for the
30/// composed-sources path.
31pub fn is_git_source(source: &str) -> bool {
32    if source.starts_with("https://")
33        || source.starts_with("http://")
34        || source.starts_with("git@")
35        || source.starts_with("ssh://")
36    {
37        return true;
38    }
39    if source.starts_with("file://") && std::env::var("CFGD_ALLOW_LOCAL_SOURCES").is_ok() {
40        return true;
41    }
42    false
43}
44
45/// Parse a git file source URL into its components.
46///
47/// Supports:
48/// - `https://github.com/user/repo.git` — plain clone
49/// - `https://github.com/user/repo.git@v2.1.0` — pin to tag
50/// - `https://github.com/user/repo.git?ref=dev` — track branch
51/// - `https://github.com/user/repo.git//subdir` — subdirectory
52/// - `https://github.com/user/repo.git//subdir@v2.1.0` — subdir at tag
53/// - `git@github.com:user/repo.git@v2.1.0` — SSH with tag
54pub fn parse_git_source(source: &str) -> Result<GitSource> {
55    if !is_git_source(source) {
56        return Err(ModuleError::InvalidSpec {
57            name: source.to_string(),
58            message: "not a git URL".into(),
59        }
60        .into());
61    }
62
63    let mut url = source.to_string();
64    let mut tag = None;
65    let mut git_ref = None;
66    let mut subdir = None;
67
68    // Extract ?ref=... (must be done before @tag extraction since ? is unambiguous)
69    // Stop at // (subdir separator) so ?ref=dev//subdir works correctly
70    if let Some(ref_pos) = url.find("?ref=") {
71        let after_ref = &url[ref_pos + 5..];
72        let end = after_ref.find("//").unwrap_or(after_ref.len());
73        let ref_val = after_ref[..end].to_string();
74        let remainder = &after_ref[end..];
75        url = format!("{}{}", &url[..ref_pos], remainder);
76        git_ref = Some(ref_val);
77    }
78
79    // Extract //subdir (and possibly @tag after the subdir)
80    // Skip the :// scheme prefix when looking for // path separator
81    let search_start = url.find("://").map(|p| p + 3).unwrap_or(0);
82    if let Some(rel_pos) = url[search_start..].find("//") {
83        let subdir_pos = search_start + rel_pos;
84        let subdir_part = url[subdir_pos + 2..].to_string();
85        url = url[..subdir_pos].to_string();
86
87        // The subdir part may have @tag
88        if let Some(at_pos) = subdir_part.rfind('@') {
89            subdir = Some(subdir_part[..at_pos].to_string());
90            tag = Some(subdir_part[at_pos + 1..].to_string());
91        } else {
92            subdir = Some(subdir_part);
93        }
94    } else {
95        // No subdir — check for @tag on the URL itself
96        // For SSH URLs like git@github.com:user/repo.git@v2.1.0,
97        // we need to find the @tag *after* the .git suffix
98        if let Some(git_suffix_pos) = url.find(".git") {
99            let after_git = &url[git_suffix_pos + 4..];
100            if let Some(at_pos) = after_git.find('@') {
101                tag = Some(after_git[at_pos + 1..].to_string());
102                url = url[..git_suffix_pos + 4].to_string();
103            }
104        } else if let Some(at_pos) = url.rfind('@') {
105            // No .git in URL — look for last @ that isn't part of the protocol.
106            // For https/http/ssh://, skip past ://
107            // For git@, skip past the first @
108            let skip_to = if url.starts_with("git@") {
109                url.find('@').map(|p| p + 1).unwrap_or(0)
110            } else {
111                url.find("://").map(|p| p + 3).unwrap_or(0)
112            };
113            if at_pos > skip_to {
114                tag = Some(url[at_pos + 1..].to_string());
115                url = url[..at_pos].to_string();
116            }
117        }
118    }
119
120    Ok(GitSource {
121        repo_url: url,
122        tag,
123        git_ref,
124        subdir,
125    })
126}
127
128/// Compute the cache directory for a git source URL.
129/// Uses SHA-256 hash of the repo URL for uniqueness.
130pub fn git_cache_dir(cache_base: &Path, repo_url: &str) -> PathBuf {
131    let hash = crate::sha256_hex(repo_url.as_bytes());
132    cache_base.join(&hash[..32])
133}
134
135/// Default cache directory for module git sources.
136///
137/// Honors the thread-local test-home override (set via `with_test_home_guard`)
138/// so tests can redirect module cache writes off the real `~/.cache/cfgd/`.
139/// Without an override this falls through to `directories::BaseDirs` (XDG on
140/// Linux, `~/Library/Caches` on macOS, `AppData\Local` on Windows).
141pub fn default_module_cache_dir() -> Result<PathBuf> {
142    if let Some(home) = crate::util::test_home_override() {
143        return Ok(home.join(".cache").join("cfgd").join("modules"));
144    }
145    let base = directories::BaseDirs::new().ok_or_else(|| ModuleError::GitFetchFailed {
146        module: String::new(),
147        url: String::new(),
148        message: "cannot determine home directory".into(),
149    })?;
150    Ok(base.cache_dir().join("cfgd").join("modules"))
151}
152
153/// Resolve optional subdir within a cache directory with traversal validation.
154pub(super) fn resolve_subdir(
155    base: PathBuf,
156    subdir: &Option<String>,
157    module: &str,
158    url: &str,
159) -> Result<PathBuf> {
160    match subdir {
161        Some(sub) => {
162            crate::validate_no_traversal(std::path::Path::new(sub)).map_err(|_| {
163                ModuleError::GitFetchFailed {
164                    module: module.to_string(),
165                    url: url.to_string(),
166                    message: format!("subdir contains path traversal: {sub}"),
167                }
168            })?;
169            Ok(base.join(sub))
170        }
171        None => Ok(base),
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Git clone / fetch operations
177// ---------------------------------------------------------------------------
178
179/// Clone or fetch a git source to the cache, returning the local path.
180///
181/// If the repo is already cached, fetches updates. Otherwise, clones.
182/// Checks out the specified tag/ref if provided.
183pub fn fetch_git_source(
184    git_src: &GitSource,
185    cache_base: &Path,
186    module_name: &str,
187    printer: &crate::output::Printer,
188) -> Result<PathBuf> {
189    let cache_dir = git_cache_dir(cache_base, &git_src.repo_url);
190
191    if cache_dir.join(".git").exists() || cache_dir.join("HEAD").exists() {
192        fetch_existing_repo(&cache_dir, git_src, module_name, printer)?;
193    } else {
194        clone_repo(&cache_dir, git_src, module_name, printer)?;
195    }
196
197    checkout_ref(&cache_dir, git_src, module_name)?;
198
199    resolve_subdir(cache_dir, &git_src.subdir, module_name, &git_src.repo_url)
200}
201
202/// Open a git2 repo with a consistent error mapping.
203pub(super) fn open_repo(path: &Path, module: &str, url: &str) -> Result<git2::Repository> {
204    git2::Repository::open(path).map_err(|e| {
205        ModuleError::GitFetchFailed {
206            module: module.to_string(),
207            url: url.to_string(),
208            message: format!("cannot open repo: {e}"),
209        }
210        .into()
211    })
212}
213
214/// Build fetch options with SSH credential callback.
215fn git_fetch_options<'a>() -> git2::FetchOptions<'a> {
216    let mut callbacks = git2::RemoteCallbacks::new();
217    callbacks.credentials(crate::git_ssh_credentials);
218    let mut fetch_opts = git2::FetchOptions::new();
219    fetch_opts.remote_callbacks(callbacks);
220    fetch_opts
221}
222
223pub(super) fn clone_repo(
224    dest: &Path,
225    git_src: &GitSource,
226    module_name: &str,
227    printer: &crate::output::Printer,
228) -> Result<()> {
229    if let Some(parent) = dest.parent() {
230        std::fs::create_dir_all(parent).map_err(|e| ModuleError::GitFetchFailed {
231            module: module_name.to_string(),
232            url: git_src.repo_url.clone(),
233            message: format!("cannot create cache directory: {e}"),
234        })?;
235    }
236
237    // Try git CLI first with live progress output.
238    let mut cmd = crate::git_cmd_safe(Some(&git_src.repo_url), None);
239    cmd.args(["clone", &git_src.repo_url, &dest.display().to_string()]);
240    cmd.stdout(std::process::Stdio::piped());
241    cmd.stderr(std::process::Stdio::piped());
242
243    let label = format!("Cloning module '{}'", module_name);
244    let cli_result = printer.run(&mut cmd, &label);
245    if matches!(&cli_result, Ok(output) if output.status.success()) {
246        return Ok(());
247    }
248
249    // Clean up partial clone before libgit2 retry.
250    let _ = std::fs::remove_dir_all(dest);
251    if let Some(parent) = dest.parent() {
252        let _ = std::fs::create_dir_all(parent);
253    }
254
255    // Fall back to libgit2 with spinner.
256    let spinner = printer.spinner(format!("Cloning module '{}' (libgit2)...", module_name));
257
258    let result = git2::build::RepoBuilder::new()
259        .fetch_options(git_fetch_options())
260        .clone(&git_src.repo_url, dest)
261        .map_err(|e| ModuleError::GitFetchFailed {
262            module: module_name.to_string(),
263            url: git_src.repo_url.clone(),
264            message: e.to_string(),
265        });
266
267    match &result {
268        Ok(_) => {
269            let _ = spinner.finish_ok(format!("Cloned module '{}' (libgit2)", module_name));
270        }
271        Err(e) => {
272            let _ = spinner
273                .finish_fail(format!(
274                    "Failed to clone module '{}' (libgit2)",
275                    module_name
276                ))
277                .detail(crate::output::collapse_to_subject_line(e));
278        }
279    }
280    result?;
281
282    Ok(())
283}
284
285pub(super) fn fetch_existing_repo(
286    repo_path: &Path,
287    git_src: &GitSource,
288    module_name: &str,
289    printer: &crate::output::Printer,
290) -> Result<()> {
291    // Try git CLI first with live progress output.
292    let mut cmd = crate::git_cmd_safe(Some(&git_src.repo_url), None);
293    cmd.args(["-C", &repo_path.display().to_string(), "fetch", "origin"]);
294    cmd.stdout(std::process::Stdio::piped());
295    cmd.stderr(std::process::Stdio::piped());
296
297    let label = format!("Fetching module '{}'", module_name);
298    let cli_result = printer.run(&mut cmd, &label);
299    if matches!(&cli_result, Ok(output) if output.status.success()) {
300        return Ok(());
301    }
302
303    // Fall back to libgit2 with spinner.
304    let spinner = printer.spinner(format!("Fetching module '{}' (libgit2)...", module_name));
305
306    let repo = open_repo(repo_path, module_name, &git_src.repo_url)?;
307
308    let mut remote = repo
309        .find_remote("origin")
310        .map_err(|e| ModuleError::GitFetchFailed {
311            module: module_name.to_string(),
312            url: git_src.repo_url.clone(),
313            message: format!("no 'origin' remote: {e}"),
314        })?;
315
316    let refspecs: Vec<String> = remote
317        .refspecs()
318        .filter_map(|rs| rs.str().map(String::from))
319        .collect();
320    let refspec_strs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
321
322    let fetch_result = remote
323        .fetch(&refspec_strs, Some(&mut git_fetch_options()), None)
324        .map_err(|e| ModuleError::GitFetchFailed {
325            module: module_name.to_string(),
326            url: git_src.repo_url.clone(),
327            message: format!("fetch failed: {e}"),
328        });
329
330    match &fetch_result {
331        Ok(_) => {
332            let _ = spinner.finish_ok(format!("Fetched module '{}' (libgit2)", module_name));
333        }
334        Err(e) => {
335            let _ = spinner
336                .finish_fail(format!(
337                    "Failed to fetch module '{}' (libgit2)",
338                    module_name
339                ))
340                .detail(crate::output::collapse_to_subject_line(e));
341        }
342    }
343    fetch_result?;
344
345    Ok(())
346}
347
348fn checkout_ref(repo_path: &Path, git_src: &GitSource, module_name: &str) -> Result<()> {
349    let repo = open_repo(repo_path, module_name, &git_src.repo_url)?;
350
351    let target_ref = git_src.tag.as_deref().or(git_src.git_ref.as_deref());
352
353    let Some(ref_name) = target_ref else {
354        // No specific ref — stay on default branch
355        return Ok(());
356    };
357
358    // Try as a tag first, then as a branch
359    let obj = repo
360        .revparse_single(&format!("refs/tags/{ref_name}"))
361        .or_else(|_| repo.revparse_single(&format!("refs/remotes/origin/{ref_name}")))
362        .or_else(|_| repo.revparse_single(ref_name))
363        .map_err(|e| ModuleError::GitFetchFailed {
364            module: module_name.to_string(),
365            url: git_src.repo_url.clone(),
366            message: format!("cannot find ref '{ref_name}': {e}"),
367        })?;
368
369    // Peel to commit
370    let commit = obj
371        .peel_to_commit()
372        .map_err(|e| ModuleError::GitFetchFailed {
373            module: module_name.to_string(),
374            url: git_src.repo_url.clone(),
375            message: format!("ref '{ref_name}' does not point to a commit: {e}"),
376        })?;
377
378    repo.set_head_detached(commit.id())
379        .map_err(|e| ModuleError::GitFetchFailed {
380            module: module_name.to_string(),
381            url: git_src.repo_url.clone(),
382            message: format!("cannot detach HEAD to '{ref_name}': {e}"),
383        })?;
384
385    repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
386        .map_err(|e| ModuleError::GitFetchFailed {
387            module: module_name.to_string(),
388            url: git_src.repo_url.clone(),
389            message: format!("checkout failed for '{ref_name}': {e}"),
390        })?;
391
392    Ok(())
393}
394
395/// Get the HEAD commit SHA from a git repo.
396pub fn get_head_commit_sha(repo_path: &Path) -> Result<String> {
397    let path_str = repo_path.display_posix();
398    let repo = open_repo(repo_path, &path_str, &path_str)?;
399    let head = repo.head().map_err(|e| ModuleError::GitFetchFailed {
400        module: path_str.clone(),
401        url: path_str.clone(),
402        message: format!("cannot read HEAD: {e}"),
403    })?;
404    let commit = head
405        .peel_to_commit()
406        .map_err(|e| ModuleError::GitFetchFailed {
407            module: path_str.clone(),
408            url: path_str,
409            message: format!("HEAD is not a commit: {e}"),
410        })?;
411    Ok(commit.id().to_string())
412}
413
414/// Signature status for a git tag.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub enum TagSignatureStatus {
417    /// Lightweight tag — cannot carry a signature.
418    LightweightTag,
419    /// Annotated tag with no signature.
420    Unsigned,
421    /// Annotated tag with a GPG/SSH signature present.
422    SignaturePresent,
423    /// Tag not found.
424    TagNotFound,
425}
426
427/// Check whether a git tag has a GPG/SSH signature.
428///
429/// Detects signature presence via git2 (no shell-out required).
430/// Full GPG verification (cryptographic check) requires `git tag -v` which
431/// calls `gpg`; the CLI layer can do that if desired.
432pub fn check_tag_signature(
433    repo_path: &Path,
434    tag_name: &str,
435    module_name: &str,
436) -> Result<TagSignatureStatus> {
437    let repo = open_repo(repo_path, module_name, "")?;
438
439    let tag_ref = match repo.revparse_single(&format!("refs/tags/{tag_name}")) {
440        Ok(obj) => obj,
441        Err(_) => return Ok(TagSignatureStatus::TagNotFound),
442    };
443
444    let tag = match tag_ref.as_tag() {
445        Some(t) => t,
446        None => return Ok(TagSignatureStatus::LightweightTag),
447    };
448
449    let message = match tag.message() {
450        Some(m) => m,
451        None => return Ok(TagSignatureStatus::Unsigned),
452    };
453
454    if message.contains("-----BEGIN PGP SIGNATURE-----")
455        || message.contains("-----BEGIN SSH SIGNATURE-----")
456    {
457        Ok(TagSignatureStatus::SignaturePresent)
458    } else {
459        Ok(TagSignatureStatus::Unsigned)
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    // --- is_git_source ---
468
469    #[test]
470    fn is_git_source_accepts_https() {
471        assert!(is_git_source("https://github.com/user/repo.git"));
472    }
473
474    #[test]
475    fn is_git_source_accepts_http() {
476        assert!(is_git_source("http://example.com/repo"));
477    }
478
479    #[test]
480    fn is_git_source_accepts_ssh() {
481        assert!(is_git_source("ssh://git@github.com/user/repo.git"));
482    }
483
484    #[test]
485    fn is_git_source_accepts_git_at() {
486        assert!(is_git_source("git@github.com:user/repo.git"));
487    }
488
489    #[test]
490    fn is_git_source_rejects_local_path() {
491        assert!(!is_git_source("/home/user/dotfiles"));
492        assert!(!is_git_source("./local/path"));
493        assert!(!is_git_source("relative/path"));
494    }
495
496    #[test]
497    #[serial_test::serial]
498    fn is_git_source_rejects_file_url_by_default() {
499        let _guard = crate::test_helpers::EnvVarGuard::unset("CFGD_ALLOW_LOCAL_SOURCES");
500        assert!(!is_git_source("file:///tmp/repo"));
501    }
502
503    #[test]
504    #[serial_test::serial]
505    fn is_git_source_accepts_file_url_when_env_set() {
506        let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
507        assert!(is_git_source("file:///tmp/repo"));
508    }
509
510    // --- parse_git_source ---
511
512    #[test]
513    fn parse_plain_https_url() {
514        let gs = parse_git_source("https://github.com/user/repo.git").unwrap();
515        assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
516        assert_eq!(gs.tag, None);
517        assert_eq!(gs.git_ref, None);
518        assert_eq!(gs.subdir, None);
519    }
520
521    #[test]
522    fn parse_https_with_tag() {
523        let gs = parse_git_source("https://github.com/user/repo.git@v2.1.0").unwrap();
524        assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
525        assert_eq!(gs.tag.as_deref(), Some("v2.1.0"));
526    }
527
528    #[test]
529    fn parse_https_with_ref() {
530        let gs = parse_git_source("https://github.com/user/repo.git?ref=dev").unwrap();
531        assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
532        assert_eq!(gs.git_ref.as_deref(), Some("dev"));
533        assert_eq!(gs.tag, None);
534    }
535
536    #[test]
537    fn parse_https_with_subdir() {
538        let gs = parse_git_source("https://github.com/user/repo.git//configs/base").unwrap();
539        assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
540        assert_eq!(gs.subdir.as_deref(), Some("configs/base"));
541        assert_eq!(gs.tag, None);
542    }
543
544    #[test]
545    fn parse_https_with_subdir_and_tag() {
546        let gs = parse_git_source("https://github.com/user/repo.git//configs/base@v2.1.0").unwrap();
547        assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
548        assert_eq!(gs.subdir.as_deref(), Some("configs/base"));
549        assert_eq!(gs.tag.as_deref(), Some("v2.1.0"));
550    }
551
552    #[test]
553    fn parse_ssh_with_tag() {
554        let gs = parse_git_source("git@github.com:user/repo.git@v1.0.0").unwrap();
555        assert_eq!(gs.repo_url, "git@github.com:user/repo.git");
556        assert_eq!(gs.tag.as_deref(), Some("v1.0.0"));
557    }
558
559    #[test]
560    fn parse_ssh_plain() {
561        let gs = parse_git_source("git@github.com:user/repo.git").unwrap();
562        assert_eq!(gs.repo_url, "git@github.com:user/repo.git");
563        assert_eq!(gs.tag, None);
564        assert_eq!(gs.git_ref, None);
565    }
566
567    #[test]
568    fn parse_ref_with_subdir() {
569        let gs = parse_git_source("https://github.com/user/repo.git?ref=dev//subdir").unwrap();
570        assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
571        assert_eq!(gs.git_ref.as_deref(), Some("dev"));
572        assert_eq!(gs.subdir.as_deref(), Some("subdir"));
573    }
574
575    #[test]
576    fn parse_no_dot_git_with_tag() {
577        let gs = parse_git_source("https://github.com/user/repo@v3.0").unwrap();
578        assert_eq!(gs.repo_url, "https://github.com/user/repo");
579        assert_eq!(gs.tag.as_deref(), Some("v3.0"));
580    }
581
582    #[test]
583    fn parse_rejects_non_git_url() {
584        let err = parse_git_source("/local/path").expect_err("local path rejected");
585        let msg = err.to_string();
586        assert!(msg.contains("not a git URL"), "got: {msg}");
587    }
588
589    // --- git_cache_dir ---
590
591    #[test]
592    fn git_cache_dir_is_deterministic() {
593        let base = Path::new("/tmp/cache");
594        let d1 = git_cache_dir(base, "https://github.com/user/repo.git");
595        let d2 = git_cache_dir(base, "https://github.com/user/repo.git");
596        assert_eq!(d1, d2);
597    }
598
599    #[test]
600    fn git_cache_dir_differs_for_different_urls() {
601        let base = Path::new("/tmp/cache");
602        let d1 = git_cache_dir(base, "https://github.com/user/repo-a.git");
603        let d2 = git_cache_dir(base, "https://github.com/user/repo-b.git");
604        assert_ne!(d1, d2);
605    }
606
607    #[test]
608    fn git_cache_dir_uses_first_32_hex_chars() {
609        let base = Path::new("/cache");
610        let d = git_cache_dir(base, "https://example.com/repo");
611        let dir_name = d.file_name().unwrap().to_str().unwrap();
612        assert_eq!(dir_name.len(), 32);
613        assert!(dir_name.chars().all(|c| c.is_ascii_hexdigit()));
614    }
615
616    // --- resolve_subdir ---
617
618    #[test]
619    fn resolve_subdir_none_returns_base() {
620        let base = PathBuf::from("/cache/abc123");
621        let result = resolve_subdir(base.clone(), &None, "mod", "url").unwrap();
622        assert_eq!(result, base);
623    }
624
625    #[test]
626    fn resolve_subdir_appends_path() {
627        let base = PathBuf::from("/cache/abc123");
628        let result =
629            resolve_subdir(base.clone(), &Some("configs/base".into()), "mod", "url").unwrap();
630        assert_eq!(result, base.join("configs/base"));
631    }
632
633    #[test]
634    fn resolve_subdir_rejects_traversal() {
635        let base = PathBuf::from("/cache/abc123");
636        let err = resolve_subdir(base, &Some("../escape".into()), "mod", "url")
637            .expect_err("traversal rejected");
638        let msg = err.to_string();
639        assert!(
640            msg.contains("traversal"),
641            "error must mention traversal, got: {msg}"
642        );
643    }
644
645    // --- check_tag_signature (with tempdir git repo) ---
646
647    #[test]
648    fn check_tag_signature_returns_tag_not_found() {
649        let dir = tempfile::tempdir().unwrap();
650        git2::Repository::init(dir.path()).unwrap();
651        let result = check_tag_signature(dir.path(), "nonexistent", "test-mod").unwrap();
652        assert_eq!(result, TagSignatureStatus::TagNotFound);
653    }
654
655    #[test]
656    fn check_tag_signature_lightweight_tag() {
657        let dir = tempfile::tempdir().unwrap();
658        let repo = git2::Repository::init(dir.path()).unwrap();
659
660        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
661        let tree_id = repo.index().unwrap().write_tree().unwrap();
662        let tree = repo.find_tree(tree_id).unwrap();
663        let commit_oid = repo
664            .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
665            .unwrap();
666
667        let obj = repo.find_object(commit_oid, None).unwrap();
668        repo.tag_lightweight("v1.0.0", &obj, false).unwrap();
669
670        let result = check_tag_signature(dir.path(), "v1.0.0", "test-mod").unwrap();
671        assert_eq!(result, TagSignatureStatus::LightweightTag);
672    }
673
674    #[test]
675    fn check_tag_signature_annotated_unsigned() {
676        let dir = tempfile::tempdir().unwrap();
677        let repo = git2::Repository::init(dir.path()).unwrap();
678
679        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
680        let tree_id = repo.index().unwrap().write_tree().unwrap();
681        let tree = repo.find_tree(tree_id).unwrap();
682        let commit_oid = repo
683            .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
684            .unwrap();
685
686        let obj = repo.find_object(commit_oid, None).unwrap();
687        repo.tag("v2.0.0", &obj, &sig, "release v2.0.0", false)
688            .unwrap();
689
690        let result = check_tag_signature(dir.path(), "v2.0.0", "test-mod").unwrap();
691        assert_eq!(result, TagSignatureStatus::Unsigned);
692    }
693
694    // --- get_head_commit_sha ---
695
696    #[test]
697    fn get_head_commit_sha_returns_hex_hash() {
698        let dir = tempfile::tempdir().unwrap();
699        let repo = git2::Repository::init(dir.path()).unwrap();
700
701        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
702        let tree_id = repo.index().unwrap().write_tree().unwrap();
703        let tree = repo.find_tree(tree_id).unwrap();
704        let commit_oid = repo
705            .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
706            .unwrap();
707
708        let sha = get_head_commit_sha(dir.path()).unwrap();
709        assert_eq!(sha, commit_oid.to_string());
710        assert_eq!(sha.len(), 40);
711        assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
712    }
713
714    #[test]
715    fn get_head_commit_sha_errors_on_non_repo() {
716        let dir = tempfile::tempdir().unwrap();
717        let err = get_head_commit_sha(dir.path()).expect_err("non-repo must error");
718        let msg = err.to_string();
719        assert!(
720            msg.contains("cannot open repo"),
721            "error must mention repo open failure, got: {msg}"
722        );
723    }
724
725    // --- default_module_cache_dir ---
726
727    #[test]
728    fn default_module_cache_dir_with_test_home() {
729        let dir = tempfile::tempdir().unwrap();
730        let _guard = crate::with_test_home_guard(dir.path());
731        let cache = default_module_cache_dir().unwrap();
732        assert!(
733            cache.starts_with(dir.path()),
734            "cache dir must be under test home, got: {}",
735            cache.display()
736        );
737        assert!(
738            cache.ends_with("cfgd/modules"),
739            "must end with cfgd/modules, got: {}",
740            cache.display()
741        );
742    }
743
744    // --- parse_git_source: SSH @tag with no `.git` suffix ---
745
746    #[test]
747    fn parse_ssh_without_dot_git_with_tag() {
748        // git@host:user/repo@v9.9.9 — no `.git`, so the @tag handling
749        // falls through to the rfind('@') branch with skip_to past the
750        // first `@` of the SSH prefix.
751        let gs = parse_git_source("git@gitlab.example.com:user/repo@v9.9.9").unwrap();
752        assert_eq!(gs.repo_url, "git@gitlab.example.com:user/repo");
753        assert_eq!(gs.tag.as_deref(), Some("v9.9.9"));
754    }
755
756    #[test]
757    fn parse_https_no_dot_git_skips_to_scheme_for_at_lookup() {
758        // https with no `.git` and `@v3.0` — exercises the `://` skip path
759        // inside the no-`.git` branch.
760        let gs = parse_git_source("https://internal.host/proj@v3.0").unwrap();
761        assert_eq!(gs.repo_url, "https://internal.host/proj");
762        assert_eq!(gs.tag.as_deref(), Some("v3.0"));
763    }
764
765    #[test]
766    fn parse_url_with_no_at_in_path_returns_no_tag() {
767        // No `.git`, no `@` after the scheme — must produce repo_url=full URL,
768        // tag=None (the rfind('@') yields the scheme '@' but skip_to filters it).
769        let gs = parse_git_source("https://example.com/path/to/repo").unwrap();
770        assert_eq!(gs.repo_url, "https://example.com/path/to/repo");
771        assert_eq!(gs.tag, None);
772    }
773
774    // --- fetch_git_source: local file:// + tag checkout ---
775
776    fn build_local_fixture_repo() -> (tempfile::TempDir, String) {
777        let src = tempfile::tempdir().unwrap();
778        let repo = git2::Repository::init(src.path()).unwrap();
779        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
780        let tree_id = repo.index().unwrap().write_tree().unwrap();
781        let tree = repo.find_tree(tree_id).unwrap();
782        let _commit_oid = repo
783            .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
784            .unwrap();
785        // Tag the initial commit so checkout-by-tag tests have a target.
786        let head = repo.head().unwrap().target().unwrap();
787        let obj = repo.find_object(head, None).unwrap();
788        repo.tag_lightweight("v0.1.0", &obj, false).unwrap();
789        let url = crate::test_helpers::file_url(src.path());
790        (src, url)
791    }
792
793    #[test]
794    #[serial_test::serial]
795    fn fetch_git_source_clones_then_reuses_existing_cache_on_second_call() {
796        let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
797        let (_src, url) = build_local_fixture_repo();
798
799        let cache_base = tempfile::tempdir().unwrap();
800        let printer = crate::test_helpers::test_printer();
801
802        let git_src = parse_git_source(&url).unwrap();
803
804        // First call: clone branch.
805        let path1 = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer)
806            .expect("first fetch must clone successfully");
807        assert!(path1.join("HEAD").exists() || path1.join(".git").exists());
808
809        // Second call: fetch-existing branch (the cached dir already has .git/HEAD).
810        let path2 = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer)
811            .expect("second fetch must reuse cache and succeed");
812        assert_eq!(path1, path2, "cached path must be stable across calls");
813    }
814
815    #[test]
816    #[serial_test::serial]
817    fn fetch_git_source_with_tag_checks_out_tag() {
818        let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
819        let (_src, url) = build_local_fixture_repo();
820
821        let cache_base = tempfile::tempdir().unwrap();
822        let printer = crate::test_helpers::test_printer();
823
824        let url_with_tag = format!("{}@v0.1.0", url);
825        let git_src = parse_git_source(&url_with_tag).unwrap();
826        assert_eq!(git_src.tag.as_deref(), Some("v0.1.0"));
827
828        let result = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer);
829        assert!(
830            result.is_ok(),
831            "checkout-by-tag against local fixture must succeed: {:?}",
832            result.err()
833        );
834    }
835
836    #[test]
837    #[serial_test::serial]
838    fn fetch_git_source_with_missing_tag_returns_err() {
839        let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
840        let (_src, url) = build_local_fixture_repo();
841
842        let cache_base = tempfile::tempdir().unwrap();
843        let printer = crate::test_helpers::test_printer();
844
845        let url_with_tag = format!("{}@no-such-tag", url);
846        let git_src = parse_git_source(&url_with_tag).unwrap();
847
848        let err = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer)
849            .expect_err("missing tag must error");
850        let msg = err.to_string();
851        assert!(
852            msg.contains("cannot find ref") || msg.contains("no-such-tag"),
853            "error must mention missing ref, got: {msg}"
854        );
855    }
856
857    // --- open_repo: non-repo path error message ---
858
859    #[test]
860    fn open_repo_errors_on_non_repo() {
861        let dir = tempfile::tempdir().unwrap();
862        let result = open_repo(dir.path(), "mod", "url");
863        let err = match result {
864            Ok(_) => panic!("non-repo must error"),
865            Err(e) => e,
866        };
867        assert!(
868            err.to_string().contains("cannot open repo"),
869            "error must mention cannot open repo: {err}"
870        );
871    }
872
873    // --- check_tag_signature: signed-tag and no-message branches ---
874
875    #[test]
876    fn check_tag_signature_signature_present_pgp() {
877        let dir = tempfile::tempdir().unwrap();
878        let repo = git2::Repository::init(dir.path()).unwrap();
879        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
880        let tree_id = repo.index().unwrap().write_tree().unwrap();
881        let tree = repo.find_tree(tree_id).unwrap();
882        let commit_oid = repo
883            .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
884            .unwrap();
885        let obj = repo.find_object(commit_oid, None).unwrap();
886        // Embed a fake PGP signature footer inside the tag message — the
887        // detector is a substring match, no crypto verification.
888        let msg =
889            "release v3.0.0\n-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----\n";
890        repo.tag("v3.0.0", &obj, &sig, msg, false).unwrap();
891        let result = check_tag_signature(dir.path(), "v3.0.0", "mod").unwrap();
892        assert_eq!(result, TagSignatureStatus::SignaturePresent);
893    }
894
895    #[test]
896    fn check_tag_signature_signature_present_ssh() {
897        let dir = tempfile::tempdir().unwrap();
898        let repo = git2::Repository::init(dir.path()).unwrap();
899        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
900        let tree_id = repo.index().unwrap().write_tree().unwrap();
901        let tree = repo.find_tree(tree_id).unwrap();
902        let commit_oid = repo
903            .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
904            .unwrap();
905        let obj = repo.find_object(commit_oid, None).unwrap();
906        let msg = "release v4\n-----BEGIN SSH SIGNATURE-----\nfake\n-----END SSH SIGNATURE-----\n";
907        repo.tag("v4.0.0", &obj, &sig, msg, false).unwrap();
908        let result = check_tag_signature(dir.path(), "v4.0.0", "mod").unwrap();
909        assert_eq!(result, TagSignatureStatus::SignaturePresent);
910    }
911
912    // --- get_head_commit_sha: empty repo (no HEAD) ---
913
914    #[test]
915    fn get_head_commit_sha_returns_err_when_repo_has_no_head() {
916        let dir = tempfile::tempdir().unwrap();
917        // `git init` without any commits — there's no HEAD yet, so .head() errs.
918        git2::Repository::init(dir.path()).unwrap();
919        let err = get_head_commit_sha(dir.path()).expect_err("no HEAD must error");
920        let msg = err.to_string();
921        assert!(
922            msg.contains("cannot read HEAD") || msg.contains("cannot open repo"),
923            "error must mention HEAD or repo: {msg}"
924        );
925    }
926
927    // --- BareGitRepo-driven end-to-end tests ---
928    //
929    // These cover the clone + fetch + checkout + signature-detect pipeline by
930    // standing up a bare upstream and a working clone, without ever touching
931    // the network. They exercise multiple code paths per test for high
932    // coverage leverage.
933
934    #[test]
935    #[serial_test::serial]
936    fn fetch_git_source_with_bare_repo_branch_checks_out_branch() {
937        let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
938        let bare = crate::test_helpers::BareGitRepo::builder()
939            .commit("init", &[("README.md", "hello")])
940            .branch("feature", &[("feature.txt", "feature-data")])
941            .build();
942
943        let cache_base = tempfile::tempdir().expect("cache tempdir");
944        let printer = crate::test_helpers::test_printer();
945
946        // Use ?ref=feature so the checkout_ref branch lookup hits the
947        // `refs/remotes/origin/<branch>` arm after the tag-lookup misses.
948        let url_with_ref = format!("{}?ref=feature", bare.url());
949        let git_src = parse_git_source(&url_with_ref).expect("parse ref url");
950        assert_eq!(git_src.git_ref.as_deref(), Some("feature"));
951
952        let path = fetch_git_source(&git_src, cache_base.path(), "branchy", &printer)
953            .expect("fetch with branch checkout must succeed");
954
955        assert!(path.join("feature.txt").exists(), "branch file must exist");
956        assert_eq!(
957            std::fs::read_to_string(path.join("feature.txt")).unwrap(),
958            "feature-data"
959        );
960    }
961
962    #[test]
963    #[serial_test::serial]
964    fn fetch_git_source_with_bare_repo_tag_checks_out_tag() {
965        let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
966        let bare = crate::test_helpers::BareGitRepo::builder()
967            .commit("first", &[("a.txt", "first content")])
968            .tag("v1.0.0")
969            .build();
970
971        let cache_base = tempfile::tempdir().expect("cache tempdir");
972        let printer = crate::test_helpers::test_printer();
973
974        let url_with_tag = format!("{}@v1.0.0", bare.url());
975        let git_src = parse_git_source(&url_with_tag).expect("parse tag url");
976        assert_eq!(git_src.tag.as_deref(), Some("v1.0.0"));
977
978        let path = fetch_git_source(&git_src, cache_base.path(), "tagged", &printer)
979            .expect("fetch with tag checkout must succeed");
980        assert!(path.join("a.txt").exists());
981
982        // Subsequent call hits the fetch_existing_repo branch.
983        let path2 = fetch_git_source(&git_src, cache_base.path(), "tagged", &printer)
984            .expect("second fetch (fetch_existing_repo path) must succeed");
985        assert_eq!(path, path2);
986    }
987
988    #[test]
989    fn check_tag_signature_returns_unsigned_when_tag_has_no_message() {
990        // Build an annotated tag with an empty message. git2 lets us craft
991        // a tag with no message bytes, which exercises the `tag.message()` ->
992        // None branch (returns `Unsigned`).
993        let dir = tempfile::tempdir().unwrap();
994        let repo = git2::Repository::init(dir.path()).unwrap();
995        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
996        let tree_id = repo.index().unwrap().write_tree().unwrap();
997        let tree = repo.find_tree(tree_id).unwrap();
998        let commit_oid = repo
999            .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
1000            .unwrap();
1001        let obj = repo.find_object(commit_oid, None).unwrap();
1002        // Annotate with a single space — git2 requires a non-empty msg but our
1003        // detector treats it as unsigned (no PGP / SSH header).
1004        repo.tag("vNoSig", &obj, &sig, " ", false).unwrap();
1005
1006        let result = check_tag_signature(dir.path(), "vNoSig", "mod").unwrap();
1007        assert_eq!(result, TagSignatureStatus::Unsigned);
1008    }
1009
1010    #[test]
1011    #[serial_test::serial]
1012    fn default_module_cache_dir_test_home_uses_home_join() {
1013        // Confirms the test-home branch composes the path correctly.
1014        let dir = tempfile::tempdir().unwrap();
1015        let _guard = crate::with_test_home_guard(dir.path());
1016        let cache = default_module_cache_dir().expect("default_module_cache_dir under test-home");
1017        assert_eq!(
1018            cache,
1019            dir.path().join(".cache").join("cfgd").join("modules")
1020        );
1021    }
1022}