1070 lines
36 KiB
Rust
1070 lines
36 KiB
Rust
// 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 (`<hash>.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/<name>` 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/<name>` 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<Self, FetchError> {
|
|
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<PathBuf, FetchError> {
|
|
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<Vec<String>, 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<Vec<u8>, 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<Vec<u8>, 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<String, FetchError> {
|
|
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<Vec<u8>, FetchError> {
|
|
self.run_git_inner(Some(cwd), args, url_tag)
|
|
}
|
|
|
|
fn run_git_inner(
|
|
&self,
|
|
cwd: Option<&Path>,
|
|
args: &[&OsStr],
|
|
url_tag: &str,
|
|
) -> Result<Vec<u8>, 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<PathBuf, FetchError> {
|
|
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
|
|
/// `<repo>.git` and `<repo>` 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 `<repo>.git` ≡ `<repo>` 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<fs::File>,
|
|
}
|
|
|
|
impl LockGuard {
|
|
fn acquire(path: &Path) -> Result<Self, FetchError> {
|
|
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<OsString> = 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<u8>,
|
|
stderr: Vec<u8>,
|
|
}
|
|
|
|
fn run_with_timeout(
|
|
mut cmd: Command,
|
|
timeout: Duration,
|
|
url_tag: &str,
|
|
) -> Result<CapturedOutput, FetchError> {
|
|
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<R: Read>(mut r: R) -> Vec<u8> {
|
|
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)"
|
|
);
|
|
}
|
|
}
|