use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::config::{Marketplace, MarketplaceSource};
use crate::git;
use crate::paths::expand_tilde;
#[must_use]
pub fn marketplace_cache_root(cache_dir: &Path) -> PathBuf {
cache_dir.join("marketplaces")
}
#[must_use]
pub fn marketplace_path(cache_dir: &Path, name: &str) -> PathBuf {
marketplace_cache_root(cache_dir).join(name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceState {
pub install_location: PathBuf,
pub head: Option<String>,
}
pub trait GitBackend {
fn clone(&self, source: &str, dest: &Path) -> Result<()>;
fn pull(&self, repo: &Path) -> Result<()>;
fn head(&self, repo: &Path) -> Option<String>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemGit;
impl GitBackend for SystemGit {
fn clone(&self, source: &str, dest: &Path) -> Result<()> {
git_clone(source, dest)
}
fn pull(&self, repo: &Path) -> Result<()> {
git_pull(repo)
}
fn head(&self, repo: &Path) -> Option<String> {
git_head(repo)
}
}
pub fn sync_marketplace(
cache_dir: &Path,
m: &Marketplace,
refresh: bool,
) -> Result<MarketplaceState> {
sync_marketplace_with(cache_dir, m, refresh, &SystemGit)
}
pub fn sync_marketplace_with(
cache_dir: &Path,
m: &Marketplace,
refresh: bool,
git: &dyn GitBackend,
) -> Result<MarketplaceState> {
match m.classify_source() {
MarketplaceSource::Path => sync_path(m),
MarketplaceSource::Git => sync_git(cache_dir, m, refresh, git),
}
}
fn sync_path(m: &Marketplace) -> Result<MarketplaceState> {
let expanded = expand_tilde(&m.source);
let path = PathBuf::from(&expanded);
if !path.exists() {
return Err(anyhow::anyhow!(
"marketplace '{}': path source does not exist: {}",
m.name,
path.display()
));
}
let canonical = std::fs::canonicalize(&path).with_context(|| {
format!(
"marketplace '{}': canonicalizing path source {}",
m.name,
path.display()
)
})?;
Ok(MarketplaceState {
install_location: canonical,
head: None,
})
}
fn sync_git(
cache_dir: &Path,
m: &Marketplace,
refresh: bool,
git: &dyn GitBackend,
) -> Result<MarketplaceState> {
reject_unsafe_source(&m.source)?;
let dest = marketplace_path(cache_dir, &m.name);
if dest.join(".git").exists() {
if refresh {
git.pull(&dest)?;
}
} else {
std::fs::create_dir_all(marketplace_cache_root(cache_dir))
.context("creating marketplace cache root")?;
git.clone(&m.source, &dest)
.with_context(|| format!("cloning marketplace '{}' from {}", m.name, m.source))?;
}
let head = git.head(&dest);
Ok(MarketplaceState {
install_location: dest,
head,
})
}
fn reject_unsafe_source(source: &str) -> Result<()> {
if source.starts_with('-') {
return Err(anyhow::anyhow!(
"marketplace source may not start with '-': {source}"
));
}
if source.starts_with("ext::") || source.starts_with("fd::") {
return Err(anyhow::anyhow!(
"marketplace source uses a disallowed git transport: {source}"
));
}
Ok(())
}
fn git_clone(source: &str, dest: &Path) -> Result<()> {
let status = git::secure_git()
.args(["clone", "--depth", "1", "--", source])
.arg(dest)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("spawning git clone")?;
if !status.success() {
anyhow::bail!("git clone failed for {source}");
}
Ok(())
}
fn git_pull(repo: &Path) -> Result<()> {
let fetch_status = git::secure_git()
.args(["fetch", "--depth", "1"])
.current_dir(repo)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("spawning git fetch")?;
if !fetch_status.success() {
anyhow::bail!(
"git fetch failed at {} (network or remote error)",
repo.display()
);
}
let reset_status = git::secure_git()
.args(["reset", "--hard", "@{u}"])
.current_dir(repo)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("spawning git reset")?;
if !reset_status.success() {
tracing::debug!(
"marketplace refresh did not fast-forward at {}; keeping current checkout",
repo.display()
);
}
Ok(())
}
fn git_head(repo: &Path) -> Option<String> {
let output = git::secure_git()
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let sha = String::from_utf8(output.stdout).ok()?.trim().to_string();
if sha.is_empty() { None } else { Some(sha) }
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn path_source_resolves_in_place() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("my-plugins");
std::fs::create_dir(&src).unwrap();
let m = Marketplace {
name: "local".into(),
source: src.to_string_lossy().into_owned(),
};
let cache = tempfile::tempdir().unwrap();
let state = sync_marketplace(cache.path(), &m, false).unwrap();
assert_eq!(state.head, None);
assert_eq!(
std::fs::canonicalize(&state.install_location).unwrap(),
std::fs::canonicalize(&src).unwrap()
);
}
#[test]
fn missing_path_source_errors() {
let m = Marketplace {
name: "gone".into(),
source: "/nonexistent/path/to/marketplace".into(),
};
let cache = tempfile::tempdir().unwrap();
assert!(sync_marketplace(cache.path(), &m, false).is_err());
}
#[test]
fn cache_paths_are_under_marketplaces_dir() {
let root = Path::new("/cache");
assert_eq!(
marketplace_path(root, "superpowers"),
PathBuf::from("/cache/marketplaces/superpowers")
);
}
#[test]
fn git_config_flags_protect_against_hooks() {
use crate::git::GIT_CONFIG_FLAGS;
let flags = GIT_CONFIG_FLAGS;
assert_eq!(
flags,
&[
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=/dev/null"
]
);
}
}