// packages/fetcher.rs --- Git-binary trampoline + content-addressed cache. //! Git fetcher with content-addressed cache (T M7.2, spec //! §sec:packages-future). //! //! Wraps the `git` binary in a deterministic-environment trampoline and //! maintains a per-URL bare-mirror clone cache under //! `$XDG_CACHE_HOME/pmacs/git/`. The cache is keyed by the normalized //! URL hash (see [`normalize_url`]) so the same repository addressed //! two ways (`https://github.com/foo/bar.git` vs //! `https://github.com/foo/bar`) shares one cache entry. //! //! ## Why shell out to `git` //! //! Project posture is `forbid(unsafe_code)`; `git2` (libgit2 bindings) //! is C-with-`unsafe` underneath. Shelling out matches the M6 PTY //! trampoline pattern and inherits the user's git configuration //! (credential helpers, signing, hooks, SSH agent) for free. //! `coreutils`, `/bin/sh`, and `stty` are already documented as //! runtime requirements; `git` joins that list for package operations. //! //! ## Environment hygiene //! //! All git invocations go through [`git_command`], which: //! //! - Removes inherited `GIT_*` environment variables (parent-set //! options that could subtly alter behavior). The deterministic env //! matters most for failure-mode reproducibility: if a developer's //! `GIT_DIR` leaks into a fetch, the resulting confusion is hard to //! debug. //! - Sets `GIT_TERMINAL_PROMPT=0` so an unauthenticated repo fails //! fast instead of hanging on a credential prompt. //! - Sets `GIT_CONFIG_NOSYSTEM=1` to ignore `/etc/gitconfig`. Corporate //! machines occasionally inject HTTP proxies or refspec rewrites //! here that would otherwise leak in. //! - Sets `LC_ALL=C` so error-message parsing is locale-stable. //! //! `HOME`, `SSH_AUTH_SOCK`, and `PATH` are kept (so user credentials //! work transparently). //! //! ## Concurrent access //! //! Two pmacs processes installing packages from the same upstream //! must not collide. Each cache entry is gated by a `flock(2)` on a //! sibling lock file (`.git.lock`); the lock covers the //! clone-or-fetch operation. Same approach as M5.5's daemon socket //! lockfile. //! //! ## Timeouts //! //! Each git invocation runs under a wall-clock deadline (default 60s, //! configurable via [`Fetcher::with_timeout`]). On expiry the child //! is `kill`'d and the call returns [`FetchError::Timeout`]. stdout //! and stderr are drained from concurrent reader threads to avoid //! pipe-buffer deadlock. use std::ffi::{OsStr, OsString}; use std::fs; use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus, Stdio}; use std::thread; use std::time::{Duration, Instant}; use thiserror::Error; // --------------------------------------------------------------------------- // RefSpec // --------------------------------------------------------------------------- /// A reference to resolve against a fetched repository. #[derive(Debug, Clone, Eq, PartialEq)] pub enum RefSpec { /// Tag name (resolved through `refs/tags/` and unwrapped to /// the underlying commit via `^{commit}`). Tag(String), /// Commit-ish: full or short hash. Validated via `rev-parse`. Commit(String), /// Branch name (resolved through `refs/heads/` to the /// branch's current HEAD at fetch time). Branch(String), } impl RefSpec { /// The argument passed to `git rev-parse` to resolve this spec to a /// commit hash. The `^{commit}` suffix unwraps annotated tags. fn rev_parse_arg(&self) -> String { match self { Self::Tag(t) => format!("refs/tags/{t}^{{commit}}"), Self::Commit(c) => format!("{c}^{{commit}}"), Self::Branch(b) => format!("refs/heads/{b}^{{commit}}"), } } /// Human-readable form for error messages. fn display(&self) -> String { match self { Self::Tag(t) => format!("tag `{t}`"), Self::Commit(c) => format!("commit `{c}`"), Self::Branch(b) => format!("branch `{b}`"), } } } // --------------------------------------------------------------------------- // Fetcher // --------------------------------------------------------------------------- /// Fetch / cache / resolve operations against a content-addressed /// directory. /// /// One `Fetcher` per editor instance is sufficient; the on-disk cache /// is the durable state. Multiple `Fetcher` instances pointing at the /// same cache directory share its content (the cross-instance reuse /// test in this module verifies that property). #[derive(Debug, Clone)] pub struct Fetcher { cache_dir: PathBuf, timeout: Duration, } const DEFAULT_TIMEOUT: Duration = Duration::from_mins(1); impl Fetcher { /// Construct a fetcher with an explicit cache directory. The /// directory is created on demand; callers do not need to mkdir /// it themselves. #[must_use] pub fn with_cache_dir(cache_dir: PathBuf) -> Self { Self { cache_dir, timeout: DEFAULT_TIMEOUT, } } /// Construct a fetcher rooted at `$XDG_CACHE_HOME/pmacs/git/` (or /// `$HOME/.cache/pmacs/git/` if `XDG_CACHE_HOME` is unset). pub fn from_xdg() -> Result { Ok(Self::with_cache_dir(xdg_cache_root()?)) } /// Override the per-invocation wall-clock timeout. Builder-style. #[must_use] pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self } /// Return the on-disk cache root. Useful for tests and logging. #[must_use] pub fn cache_dir(&self) -> &Path { &self.cache_dir } /// Fetch (clone or update) `url` and return the path to the local /// bare-mirror clone. /// /// First call clones; subsequent calls run `git fetch --prune /// --tags`. The directory survives across `Fetcher` instances and /// processes (durable on-disk cache). pub fn fetch(&self, url: &str) -> Result { let normalized = normalize_url(url); let hash = fnv1a_hex(&normalized); let repo_path = self.cache_dir.join(format!("{hash}.git")); let lock_path = self.cache_dir.join(format!("{hash}.git.lock")); fs::create_dir_all(&self.cache_dir).map_err(|e| FetchError::CacheIo { path: self.cache_dir.clone(), source: e, })?; let _guard = LockGuard::acquire(&lock_path)?; if repo_path.join("HEAD").exists() { // Cache hit: refresh refs. self.run_git_in( &repo_path, &[ OsStr::new("fetch"), OsStr::new("--prune"), OsStr::new("--tags"), OsStr::new("origin"), ], url, )?; } else { // Cache miss: mirror clone. The output directory must not // exist (git refuses to clone into a non-empty directory). if repo_path.exists() { fs::remove_dir_all(&repo_path).map_err(|e| FetchError::CacheIo { path: repo_path.clone(), source: e, })?; } self.run_git( &[ OsStr::new("clone"), OsStr::new("--mirror"), OsStr::new("--quiet"), OsStr::new(url), repo_path.as_os_str(), ], url, )?; } Ok(repo_path) } /// List the tag names present in a previously-fetched bare repo. /// /// Returns one entry per tag (short name; no `refs/tags/` prefix). /// Order matches `git`'s output, which is alphabetic for /// `for-each-ref` --- callers that need semver ordering must filter /// and sort themselves. pub fn list_tags(&self, repo_path: &Path) -> Result, FetchError> { let stdout = self.run_git_in_capturing( repo_path, &[ OsStr::new("for-each-ref"), OsStr::new("--format=%(refname:short)"), OsStr::new("refs/tags"), ], "", )?; let s = String::from_utf8_lossy(&stdout); Ok(s.lines() .map(str::trim) .filter(|l| !l.is_empty()) .map(str::to_string) .collect()) } /// Stream a commit's tree as a tar archive into `out`. The archive /// is rooted at the commit's top-level (i.e., entries have no /// extra path prefix); pair with a tar extractor whose CWD is the /// install destination. /// /// Used by the installer to materialize a commit without a /// `.git`-bearing worktree --- the install dir is self-contained, /// so wiping the bare cache later does not break installed packages. pub fn archive_commit(&self, repo_path: &Path, commit: &str) -> Result, FetchError> { self.run_git_in_capturing( repo_path, &[ OsStr::new("archive"), OsStr::new("--format=tar"), OsStr::new(commit), ], "", ) } /// Read a single file's contents at a specific commit, without /// materializing the rest of the tree. Used by the installer to /// pull `pmacs.toml` for the manifest before deciding the install /// directory's name. pub fn show_blob( &self, repo_path: &Path, commit: &str, path: &str, ) -> Result, FetchError> { let spec = format!("{commit}:{path}"); self.run_git_in_capturing(repo_path, &[OsStr::new("show"), OsStr::new(&spec)], "") } /// Resolve a [`RefSpec`] against a previously-fetched repository, /// returning the full 40-char commit hash. pub fn resolve(&self, repo_path: &Path, refspec: &RefSpec) -> Result { let arg = refspec.rev_parse_arg(); let output = self.run_git_in_capturing( repo_path, &[ OsStr::new("rev-parse"), OsStr::new("--verify"), OsStr::new(&arg), ], // Empty url tag --- this call is local-only. "", ); match output { Ok(stdout) => { let hash = String::from_utf8_lossy(&stdout).trim().to_string(); if hash.len() != 40 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(FetchError::RefNotFound { repo: repo_path.to_path_buf(), refspec: refspec.display(), cause: format!("rev-parse returned `{hash}` (expected 40-char SHA)"), }); } Ok(hash) } Err(FetchError::GitInvocation { stderr, .. }) => Err(FetchError::RefNotFound { repo: repo_path.to_path_buf(), refspec: refspec.display(), cause: stderr, }), Err(e) => Err(e), } } // -- internal git invocation helpers ----------------------------------- fn run_git(&self, args: &[&OsStr], url_tag: &str) -> Result<(), FetchError> { self.run_git_inner(None, args, url_tag).map(|_| ()) } fn run_git_in(&self, cwd: &Path, args: &[&OsStr], url_tag: &str) -> Result<(), FetchError> { self.run_git_inner(Some(cwd), args, url_tag).map(|_| ()) } fn run_git_in_capturing( &self, cwd: &Path, args: &[&OsStr], url_tag: &str, ) -> Result, FetchError> { self.run_git_inner(Some(cwd), args, url_tag) } fn run_git_inner( &self, cwd: Option<&Path>, args: &[&OsStr], url_tag: &str, ) -> Result, FetchError> { let mut cmd = git_command(); if let Some(d) = cwd { cmd.current_dir(d); } for a in args { cmd.arg(a); } let CapturedOutput { status, stdout, stderr, } = run_with_timeout(cmd, self.timeout, url_tag)?; if status.success() { Ok(stdout) } else { let stderr = String::from_utf8_lossy(&stderr).into_owned(); Err(FetchError::GitInvocation { url: url_tag.to_string(), stderr, }) } } } // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- /// Errors raised by [`Fetcher`] operations. Every variant names either /// the offending URL or the offending repository path so the caller's /// error log identifies the work that failed. #[derive(Debug, Error)] pub enum FetchError { /// `$HOME` and `$XDG_CACHE_HOME` were both unset. #[error("cannot resolve XDG cache directory: HOME and XDG_CACHE_HOME are both unset")] NoCacheHome, /// I/O error creating, reading, or removing a cache directory. #[error("cache I/O error at `{path}`: {source}")] CacheIo { /// Path that the operation was acting on. path: PathBuf, /// Underlying I/O error. #[source] source: io::Error, }, /// `git` was not found on `PATH`. #[error( "the `git` binary was not found on PATH. \ Pmacs's package operations require git as a runtime dependency." )] GitNotFound, /// `git` exited with a non-zero status. `stderr` is captured and /// surfaced verbatim to the caller. #[error("git invocation failed for `{url}`: {stderr}")] GitInvocation { /// The URL the operation was acting on (empty if N/A). url: String, /// Verbatim stderr from the failed git invocation. stderr: String, }, /// Spawning `git` itself failed for a reason other than `NotFound` /// (e.g., permissions, ulimit). #[error("could not spawn git for `{url}`: {source}")] GitSpawn { /// The URL the operation was acting on. url: String, /// Underlying I/O error. #[source] source: io::Error, }, /// Wall-clock timeout exceeded during a git invocation. #[error("git operation for `{url}` timed out after {after:?}")] Timeout { /// The URL the operation was acting on. url: String, /// The timeout that was exceeded. after: Duration, }, /// A ref was requested but could not be resolved in the cache. /// The most common cause: upstream removed the tag/branch since /// the last fetch. #[error("ref {refspec} not found in `{repo}`: {cause}")] RefNotFound { /// Path of the repo that was searched. repo: PathBuf, /// The refspec that failed to resolve, formatted for humans. refspec: String, /// The underlying cause from `git rev-parse`. cause: String, }, /// `flock(2)` on the cache lockfile failed. #[error("could not acquire cache lock at `{path}`: {cause}")] LockFailed { /// Path of the lockfile. path: PathBuf, /// Underlying error description. cause: String, }, } // --------------------------------------------------------------------------- // XDG cache directory // --------------------------------------------------------------------------- fn xdg_cache_root() -> Result { if let Some(dir) = std::env::var_os("XDG_CACHE_HOME") { let p: PathBuf = dir.into(); if !p.as_os_str().is_empty() { return Ok(p.join("pmacs").join("git")); } } if let Some(home) = std::env::var_os("HOME") { let p: PathBuf = home.into(); if !p.as_os_str().is_empty() { return Ok(p.join(".cache").join("pmacs").join("git")); } } Err(FetchError::NoCacheHome) } // --------------------------------------------------------------------------- // URL normalization & hashing // --------------------------------------------------------------------------- /// Normalize a URL for cache-key derivation. /// /// Rules: /// 1. Strip trailing `/` characters. /// 2. Strip trailing `.git`, but only for URL forms where /// `.git` and `` are conventionally the same upstream: /// `https://...`, `git://...`, and SSH shorthand /// (`user@host:path`). For `file://` URLs, `ssh://` URLs, and /// bare local paths the `.git` is left in place: `/tmp/foo.git` /// and `/tmp/foo` are typically distinct trees on disk (e.g., a /// bare mirror next to a working clone), and conflating their /// cache keys would mix their refs. /// 3. Lowercase the host portion (between `://` and the next `/` or /// `:`). Path stays case-sensitive. /// /// SSH shorthand has no `://` anchor; we still strip its trailing /// `.git` (the equivalence is real for hosting forges), but we do /// not lowercase its host portion (less risk of a trailing-token /// false match in unusual forms). #[must_use] pub fn normalize_url(url: &str) -> String { let mut u = url.trim_end_matches('/').to_string(); if dot_git_strip_applies(&u) && let Some(s) = u.strip_suffix(".git") { u = s.to_string(); } if let Some(scheme_end) = u.find("://") { let host_start = scheme_end + 3; let after_host = u[host_start..] .find(['/', ':']) .map_or(u.len(), |i| host_start + i); let host_lower = u[host_start..after_host].to_ascii_lowercase(); u = format!("{}{host_lower}{}", &u[..host_start], &u[after_host..]); } u } /// True iff the conventional `.git` ≡ `` equivalence /// applies to this URL form. See [`normalize_url`] for the full /// rationale. fn dot_git_strip_applies(u: &str) -> bool { if u.starts_with("https://") || u.starts_with("git://") { return true; } // SSH shorthand: `user@host:path`. Distinguished from URL forms // by the `@` appearing before any `:` and no `://` prefix. if !u.contains("://") && let Some(at) = u.find('@') && let Some(colon) = u.find(':') && at < colon { return true; } false } /// 64-bit FNV-1a as 16 hex characters. Deterministic across processes, /// non-cryptographic but collision-resistant enough for a cache key. fn fnv1a_hex(s: &str) -> String { let mut h: u64 = 0xcbf2_9ce4_8422_2325; for &b in s.as_bytes() { h ^= u64::from(b); h = h.wrapping_mul(0x0000_0100_0000_01b3); } format!("{h:016x}") } // --------------------------------------------------------------------------- // LockGuard --- per-cache-entry flock(2) // --------------------------------------------------------------------------- /// Holds an exclusive flock on a per-cache-entry lock file. The lock is /// released when the guard is dropped. struct LockGuard { _flock: nix::fcntl::Flock, } impl LockGuard { fn acquire(path: &Path) -> Result { let file = fs::OpenOptions::new() .create(true) .read(true) .write(true) .truncate(false) .open(path) .map_err(|e| FetchError::LockFailed { path: path.to_path_buf(), cause: format!("open: {e}"), })?; let flock = nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive).map_err( |(_, errno)| FetchError::LockFailed { path: path.to_path_buf(), cause: format!("flock: {errno}"), }, )?; Ok(Self { _flock: flock }) } } // --------------------------------------------------------------------------- // git_command --- deterministic environment trampoline // --------------------------------------------------------------------------- fn git_command() -> Command { let mut cmd = Command::new("git"); // Drop inherited GIT_* env vars that could subtly alter behavior. let inherited: Vec = std::env::vars_os() .map(|(k, _)| k) .filter(|k| k.to_string_lossy().starts_with("GIT_")) .collect(); for k in inherited { cmd.env_remove(k); } cmd.env("GIT_TERMINAL_PROMPT", "0"); cmd.env("GIT_CONFIG_NOSYSTEM", "1"); cmd.env("LC_ALL", "C"); cmd } // --------------------------------------------------------------------------- // run_with_timeout --- spawn + wall-clock deadline + concurrent pipe drain // --------------------------------------------------------------------------- #[derive(Debug)] struct CapturedOutput { status: ExitStatus, stdout: Vec, stderr: Vec, } fn run_with_timeout( mut cmd: Command, timeout: Duration, url_tag: &str, ) -> Result { cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); let child = cmd.spawn().map_err(|e| match e.kind() { io::ErrorKind::NotFound => FetchError::GitNotFound, _ => FetchError::GitSpawn { url: url_tag.to_string(), source: e, }, })?; let mut child: Child = child; // Drain stdout / stderr concurrently to avoid pipe-buffer deadlock // on commands that produce more than 64KB of output. let stdout_handle = child.stdout.take().expect("piped stdout always present"); let stderr_handle = child.stderr.take().expect("piped stderr always present"); let stdout_thread = thread::spawn(move || drain_to_vec(stdout_handle)); let stderr_thread = thread::spawn(move || drain_to_vec(stderr_handle)); let started = Instant::now(); let status = loop { match child.try_wait() { Ok(Some(s)) => break s, Ok(None) => { if started.elapsed() > timeout { let _ = child.kill(); let _ = child.wait(); return Err(FetchError::Timeout { url: url_tag.to_string(), after: timeout, }); } thread::sleep(Duration::from_millis(50)); } Err(e) => { return Err(FetchError::GitSpawn { url: url_tag.to_string(), source: e, }); } } }; let stdout = stdout_thread.join().unwrap_or_default(); let stderr = stderr_thread.join().unwrap_or_default(); Ok(CapturedOutput { status, stdout, stderr, }) } fn drain_to_vec(mut r: R) -> Vec { let mut buf = Vec::new(); let _ = r.read_to_end(&mut buf); buf } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use std::process::Command as StdCommand; use tempfile::TempDir; // -- URL normalization --------------------------------------------------- #[test] fn normalize_strips_trailing_dot_git() { assert_eq!( normalize_url("https://github.com/foo/bar.git"), "https://github.com/foo/bar" ); } #[test] fn normalize_strips_trailing_slash() { assert_eq!( normalize_url("https://example.com/foo/"), "https://example.com/foo" ); } #[test] fn normalize_lowercases_host_only() { assert_eq!( normalize_url("https://GitHub.com/Foo/Bar"), "https://github.com/Foo/Bar" ); } #[test] fn normalize_collapses_dual_address_to_one_key() { let a = fnv1a_hex(&normalize_url("https://github.com/foo/bar.git")); let b = fnv1a_hex(&normalize_url("https://github.com/foo/bar")); let c = fnv1a_hex(&normalize_url("https://GitHub.com/foo/bar/")); assert_eq!(a, b); assert_eq!(b, c); } #[test] fn normalize_passes_ssh_shorthand_through() { // SSH shorthand has no `://` anchor; we leave it alone (and // accept that two cache entries result if a user mixes forms). let u = "git@github.com:foo/bar.git"; let n = normalize_url(u); // Trailing .git stripped, but host case unchanged. assert_eq!(n, "git@github.com:foo/bar"); } #[test] fn normalize_keeps_dot_git_for_file_scheme() { // `file:///tmp/foo.git` and `file:///tmp/foo` are typically // distinct on-disk trees (e.g., a bare mirror next to a // working clone). Conflating their cache keys would mix // their refs. The `.git` strip applies only to // `https://`/`git://`/SSH shorthand --- the URL forms where // the equivalence is a real upstream convention. let bare = normalize_url("file:///tmp/foo.git"); let work = normalize_url("file:///tmp/foo"); assert_ne!(bare, work); assert_ne!(fnv1a_hex(&bare), fnv1a_hex(&work)); } #[test] fn normalize_keeps_dot_git_for_ssh_url_scheme() { // `ssh://host/path/foo.git` is *not* the GitHub shorthand // form; for self-hosted SSH-over-git there is no convention // that `.git` and the bare name are the same upstream. Keep // them distinct. let with_git = normalize_url("ssh://git@example.org/foo.git"); let without = normalize_url("ssh://git@example.org/foo"); assert_ne!(with_git, without); } #[test] fn normalize_keeps_dot_git_for_bare_path() { // A bare filesystem path likewise: `/tmp/foo.git` and // `/tmp/foo` are different directory entries. let bare = normalize_url("/tmp/foo.git"); let work = normalize_url("/tmp/foo"); assert_ne!(bare, work); } // -- Fetcher tests against local file:// repos -------------------------- /// Build a bare repo with a configurable history. Returns its path. /// The repo has commits, a tag `v1.0.0`, and a branch `feature`. fn make_bare_repo() -> (TempDir, PathBuf) { let td = tempfile::tempdir().unwrap(); let work = td.path().join("work"); let bare = td.path().join("upstream.git"); // Create the working repo and history. run_git_test(&[OsStr::new("init"), work.as_os_str()], None); run_git_test( &[ OsStr::new("-C"), work.as_os_str(), OsStr::new("config"), OsStr::new("user.email"), OsStr::new("test@example.com"), ], None, ); run_git_test( &[ OsStr::new("-C"), work.as_os_str(), OsStr::new("config"), OsStr::new("user.name"), OsStr::new("Tester"), ], None, ); // Avoid relying on whatever default branch the local git uses. run_git_test( &[ OsStr::new("-C"), work.as_os_str(), OsStr::new("checkout"), OsStr::new("-b"), OsStr::new("main"), ], None, ); std::fs::write(work.join("README"), "first\n").unwrap(); run_git_test( &[ OsStr::new("-C"), work.as_os_str(), OsStr::new("add"), OsStr::new("README"), ], None, ); run_git_test( &[ OsStr::new("-C"), work.as_os_str(), OsStr::new("commit"), OsStr::new("-m"), OsStr::new("first"), ], None, ); run_git_test( &[ OsStr::new("-C"), work.as_os_str(), OsStr::new("tag"), OsStr::new("v1.0.0"), ], None, ); run_git_test( &[ OsStr::new("-C"), work.as_os_str(), OsStr::new("checkout"), OsStr::new("-b"), OsStr::new("feature"), ], None, ); std::fs::write(work.join("README"), "second\n").unwrap(); run_git_test( &[ OsStr::new("-C"), work.as_os_str(), OsStr::new("commit"), OsStr::new("-am"), OsStr::new("second"), ], None, ); // Push into a bare upstream. run_git_test( &[ OsStr::new("clone"), OsStr::new("--bare"), work.as_os_str(), bare.as_os_str(), ], None, ); (td, bare) } fn run_git_test(args: &[&OsStr], cwd: Option<&Path>) { let mut cmd = StdCommand::new("git"); if let Some(d) = cwd { cmd.current_dir(d); } for a in args { cmd.arg(a); } cmd.env("GIT_TERMINAL_PROMPT", "0"); cmd.env("LC_ALL", "C"); let out = cmd.output().unwrap_or_else(|e| panic!("git spawn: {e}")); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); } fn file_url(p: &Path) -> String { format!("file://{}", p.display()) } // ---- Acceptance bullets ----------------------------------------------- #[test] fn fetch_clones_into_cache() { let (_td, bare) = make_bare_repo(); let cache = tempfile::tempdir().unwrap(); let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf()); let url = file_url(&bare); let repo = fetcher.fetch(&url).unwrap(); assert!(repo.join("HEAD").exists(), "bare clone should have HEAD"); // Cache hash is deterministic. let expected_hash = fnv1a_hex(&normalize_url(&url)); assert_eq!( repo.file_name().unwrap(), format!("{expected_hash}.git").as_str() ); } #[test] fn fetch_twice_does_not_reclone_via_sentinel() { let (_td, bare) = make_bare_repo(); let cache = tempfile::tempdir().unwrap(); let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf()); let url = file_url(&bare); let repo1 = fetcher.fetch(&url).unwrap(); // Drop a sentinel inside the cached bare clone. If the second // fetch reclones, it must blow this away to do so. let sentinel = repo1.join("PMACS_TEST_SENTINEL"); std::fs::write(&sentinel, b"hello").unwrap(); let repo2 = fetcher.fetch(&url).unwrap(); assert_eq!(repo1, repo2, "same URL should map to same path"); assert!( sentinel.exists(), "second fetch must reuse cache, not reclone (sentinel removed)" ); } #[test] fn cache_survives_across_fetcher_instances() { let (_td, bare) = make_bare_repo(); let cache = tempfile::tempdir().unwrap(); let url = file_url(&bare); // First fetcher clones. let f1 = Fetcher::with_cache_dir(cache.path().to_path_buf()); let repo1 = f1.fetch(&url).unwrap(); let sentinel = repo1.join("PMACS_TEST_SENTINEL"); std::fs::write(&sentinel, b"hello").unwrap(); drop(f1); // Second, fresh fetcher pointing at the same cache dir. let f2 = Fetcher::with_cache_dir(cache.path().to_path_buf()); let repo2 = f2.fetch(&url).unwrap(); assert_eq!(repo1, repo2); assert!( sentinel.exists(), "cache should be durable across Fetcher instances" ); } #[test] fn resolve_tag_returns_commit_hash() { let (_td, bare) = make_bare_repo(); let cache = tempfile::tempdir().unwrap(); let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf()); let repo = fetcher.fetch(&file_url(&bare)).unwrap(); let hash = fetcher .resolve(&repo, &RefSpec::Tag("v1.0.0".into())) .unwrap(); assert_eq!(hash.len(), 40); assert!(hash.bytes().all(|b| b.is_ascii_hexdigit())); } #[test] fn resolve_branch_returns_branch_head() { let (_td, bare) = make_bare_repo(); let cache = tempfile::tempdir().unwrap(); let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf()); let repo = fetcher.fetch(&file_url(&bare)).unwrap(); let main_hash = fetcher .resolve(&repo, &RefSpec::Branch("main".into())) .unwrap(); let feature_hash = fetcher .resolve(&repo, &RefSpec::Branch("feature".into())) .unwrap(); assert_ne!( main_hash, feature_hash, "different branches should resolve to different commits" ); } #[test] fn resolve_commit_validates_existing_hash() { let (_td, bare) = make_bare_repo(); let cache = tempfile::tempdir().unwrap(); let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf()); let repo = fetcher.fetch(&file_url(&bare)).unwrap(); let main = fetcher .resolve(&repo, &RefSpec::Branch("main".into())) .unwrap(); let resolved = fetcher .resolve(&repo, &RefSpec::Commit(main.clone())) .unwrap(); assert_eq!(resolved, main); } #[test] fn resolve_missing_ref_errors_with_useful_message() { let (_td, bare) = make_bare_repo(); let cache = tempfile::tempdir().unwrap(); let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf()); let repo = fetcher.fetch(&file_url(&bare)).unwrap(); let err = fetcher .resolve(&repo, &RefSpec::Tag("nonexistent".into())) .unwrap_err(); let msg = err.to_string(); assert!(matches!(err, FetchError::RefNotFound { .. })); assert!( msg.contains("nonexistent"), "error should name the ref: {msg}" ); } #[test] fn fetch_after_upstream_tag_removed_surfaces_at_resolve() { // Test the nasty case: user pinned 0.1.0 last week, upstream // rewrote history and removed the tag. Fetch succeeds (the // remote is reachable) but the subsequent resolve fails // cleanly --- the cache isn't corrupted, the error is clear. let (td, bare) = make_bare_repo(); let cache = tempfile::tempdir().unwrap(); let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf()); let url = file_url(&bare); let repo = fetcher.fetch(&url).unwrap(); // Confirm the tag is initially resolvable. fetcher .resolve(&repo, &RefSpec::Tag("v1.0.0".into())) .unwrap(); // Delete the tag from the bare upstream. run_git_test( &[ OsStr::new("-C"), bare.as_os_str(), OsStr::new("tag"), OsStr::new("-d"), OsStr::new("v1.0.0"), ], None, ); // Re-fetch (prunes the tag locally). fetcher.fetch(&url).unwrap(); // Now resolution fails with a useful message. let err = fetcher .resolve(&repo, &RefSpec::Tag("v1.0.0".into())) .unwrap_err(); assert!(matches!(err, FetchError::RefNotFound { .. })); let _ = td; } #[test] fn fetch_with_invalid_url_errors_naming_address() { let cache = tempfile::tempdir().unwrap(); let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf()); let bad_url = "file:///definitely/not/a/real/path/at/all.git"; let err = fetcher.fetch(bad_url).unwrap_err(); match &err { FetchError::GitInvocation { url, .. } => { assert_eq!(url, bad_url); } other => panic!("expected GitInvocation, got {other:?}"), } } #[test] fn timeout_kills_long_running_command() { // Exercise run_with_timeout directly against `sleep` so the // test is deterministic regardless of how fast git happens to // be on the host. `sleep` is in coreutils, already a documented // runtime test dependency. A 100ms timeout against a 5-second // sleep is comfortably outside flake territory. let mut cmd = Command::new("sleep"); cmd.arg("5"); let started = Instant::now(); let err = run_with_timeout(cmd, Duration::from_millis(100), "test://timeout").unwrap_err(); let elapsed = started.elapsed(); assert!( matches!(err, FetchError::Timeout { .. }), "expected Timeout, got {err:?}" ); // Sanity: we exited well before the 5s sleep would have ended. assert!( elapsed < Duration::from_secs(2), "timeout fired but took {elapsed:?} (> 2s)" ); } }