Marketplace: item diff, manager, refresh and update detection

Adds diff::item_diff (similar), MarketplaceManager with snapshots,
persisted sync reports, the gh-login slot and a repo lock held across
fetches (pre-flight F11a), refresh_marketplace, load_cached_snapshot,
compute_updates, pins_by_marketplace, head_for, and the GitFixture test
helper on top of git::test_support (F3). AppState gains marketplace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 09:17:17 -07:00
co-authored by Claude Opus 5.5
parent 51490a534e
commit 9a1833d792
6 changed files with 859 additions and 1 deletions
+7
View File
@@ -5576,6 +5576,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "0.3.11" version = "0.3.11"
@@ -6569,6 +6575,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"similar",
"tar", "tar",
"tauri", "tauri",
"tauri-build", "tauri-build",
+1
View File
@@ -43,6 +43,7 @@ zeroize = "1"
# container. Already in the tree transitively (reqwest), and the point of # container. Already in the tree transitively (reqwest), and the point of
# using it rather than hand-rolling is parity with the frontend's `new URL()`. # using it rather than hand-rolling is parity with the frontend's `new URL()`.
url = "2" url = "2"
similar = "2"
# Marketplace repos are fetched on the host into a bare cache (spec §3). # Marketplace repos are fetched on the host into a bare cache (spec §3).
# Blocking client + rustls: no git binary or OpenSSL needed on the host. # Blocking client + rustls: no git binary or OpenSSL needed on the host.
gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] } gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] }
+9
View File
@@ -49,6 +49,7 @@ pub struct AppState {
/// preview is not actually binding on what gets applied. /// preview is not actually binding on what gets applied.
pub pending_settings_import: pub pending_settings_import:
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>, Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
pub marketplace: Arc<marketplace::MarketplaceManager>,
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -225,6 +226,13 @@ pub fn run() {
let exec_manager = Arc::new(ExecSessionManager::new()); let exec_manager = Arc::new(ExecSessionManager::new());
let auth_bridge = Arc::new(AuthBridgeManager::new()); let auth_bridge = Arc::new(AuthBridgeManager::new());
let lifecycle = Arc::new(Lifecycle::new()); let lifecycle = Arc::new(Lifecycle::new());
let marketplace = Arc::new(marketplace::MarketplaceManager::new(
dirs::data_dir()
.map(|d| d.join("triple-c"))
.unwrap_or_else(|| std::env::temp_dir().join("triple-c")),
));
let marketplace_setup = marketplace.clone();
let _ = &marketplace_setup;
// Clone Arcs for the setup closure (web terminal auto-start) // Clone Arcs for the setup closure (web terminal auto-start)
let projects_store_setup = projects_store.clone(); let projects_store_setup = projects_store.clone();
@@ -243,6 +251,7 @@ pub fn run() {
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
lifecycle, lifecycle,
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)), pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
marketplace,
}) })
.manage(file_viewer::registry::ViewerRegistry::default()) .manage(file_viewer::registry::ViewerRegistry::default())
.setup(move |app| { .setup(move |app| {
+198
View File
@@ -0,0 +1,198 @@
//! Text diff of one item between two commits, for the "Update" review.
use std::collections::BTreeMap;
use std::path::Path;
use similar::TextDiff;
use super::catalog::{item_files, ItemFile};
use super::tree::GitTree;
use crate::models::marketplace::{FileChange, FileDiff, ItemKind};
/// Files of `kind`/`key` at `commit`, or an empty list when the item does not
/// exist (or is not installable) at that commit — a removal upstream then reads
/// as every file removed rather than as an error.
fn files_at(
repo_path: &Path,
kind: ItemKind,
key: &str,
commit: &str,
) -> Result<Vec<ItemFile>, String> {
let tree = GitTree::open(repo_path, commit)?;
Ok(item_files(&tree, kind, key).unwrap_or_default())
}
pub fn item_diff(
repo_path: &Path,
kind: ItemKind,
key: &str,
from_commit: &str,
to_commit: &str,
) -> Result<Vec<FileDiff>, String> {
let old = files_at(repo_path, kind, key, from_commit)?;
let new = files_at(repo_path, kind, key, to_commit)?;
Ok(diff_files(&old, &new))
}
fn as_text(data: &[u8]) -> Option<&str> {
if data.contains(&0) {
return None;
}
std::str::from_utf8(data).ok()
}
fn unified(path: &str, old: &str, new: &str) -> String {
TextDiff::from_lines(old, new)
.unified_diff()
.context_radius(3)
.header(&format!("a/{path}"), &format!("b/{path}"))
.to_string()
}
/// Per-file diff, sorted by path; files identical in content and mode are left out.
pub(crate) fn diff_files(old: &[ItemFile], new: &[ItemFile]) -> Vec<FileDiff> {
let old: BTreeMap<&str, &ItemFile> = old.iter().map(|f| (f.rel_path.as_str(), f)).collect();
let new: BTreeMap<&str, &ItemFile> = new.iter().map(|f| (f.rel_path.as_str(), f)).collect();
let mut paths: Vec<&str> = old.keys().chain(new.keys()).copied().collect();
paths.sort_unstable();
paths.dedup();
let mut out = Vec::new();
for path in paths {
match (old.get(path), new.get(path)) {
(Some(o), Some(n)) => {
if o.data == n.data && o.executable == n.executable {
continue;
}
let text = match (as_text(&o.data), as_text(&n.data)) {
(Some(a), Some(b)) => {
let mut s = String::new();
if o.executable != n.executable {
s.push_str(&format!(
"# executable: {} -> {}\n",
o.executable, n.executable
));
}
s.push_str(&unified(path, a, b));
Some(s)
}
_ => None,
};
out.push(FileDiff {
path: path.to_string(),
change: FileChange::Modified,
unified: text,
});
}
(Some(o), None) => out.push(FileDiff {
path: path.to_string(),
change: FileChange::Removed,
unified: as_text(&o.data).map(|a| unified(path, a, "")),
}),
(None, Some(n)) => out.push(FileDiff {
path: path.to_string(),
change: FileChange::Added,
unified: as_text(&n.data).map(|b| unified(path, "", b)),
}),
(None, None) => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::marketplace::git;
use crate::marketplace::test_support::GitFixture;
fn f(path: &str, text: &str, executable: bool) -> ItemFile {
ItemFile {
rel_path: path.to_string(),
data: text.as_bytes().to_vec(),
executable,
}
}
#[test]
fn unchanged_files_are_omitted_and_changes_are_classified() {
let old = vec![
f("a.md", "one\n", false),
f("gone.sh", "x\n", true),
f("same", "s\n", false),
];
let new = vec![
f("a.md", "two\n", false),
f("new.txt", "n\n", false),
f("same", "s\n", false),
];
let diffs = diff_files(&old, &new);
let summary: Vec<(&str, FileChange)> = diffs
.iter()
.map(|d| (d.path.as_str(), d.change.clone()))
.collect();
assert_eq!(
summary,
vec![
("a.md", FileChange::Modified),
("gone.sh", FileChange::Removed),
("new.txt", FileChange::Added),
]
);
let a = diffs[0].unified.as_deref().unwrap();
assert!(a.contains("-one") && a.contains("+two"), "{a}");
}
#[test]
fn binary_files_have_no_text_diff() {
let old = vec![ItemFile {
rel_path: "b.bin".into(),
data: vec![0, 1, 2],
executable: false,
}];
let new = vec![ItemFile {
rel_path: "b.bin".into(),
data: vec![0, 1, 3],
executable: false,
}];
let diffs = diff_files(&old, &new);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].unified, None);
}
#[test]
fn an_executable_bit_change_is_reported() {
let old = vec![f("run.sh", "echo\n", false)];
let new = vec![f("run.sh", "echo\n", true)];
let diffs = diff_files(&old, &new);
assert_eq!(diffs.len(), 1);
assert!(diffs[0]
.unified
.as_deref()
.unwrap()
.contains("executable: false -> true"));
}
#[test]
fn item_diff_reads_both_commits_from_the_cache() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
fx.write(
"hooks/notify-on-stop/notify.sh",
"#!/bin/sh\ncurl https://example.invalid\n",
);
let c2 = fx.commit("change hook");
let data = tempfile::tempdir().unwrap();
let repo = git::cache_path(data.path(), "m1");
git::fetch(&repo, &fx.url(), None, None).unwrap();
let diffs = item_diff(&repo, ItemKind::Hook, "notify-on-stop", &c1, &c2).unwrap();
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].path, "notify.sh");
assert!(diffs[0]
.unified
.as_deref()
.unwrap()
.contains("+curl https://example.invalid"));
}
}
+554 -1
View File
@@ -1,6 +1,559 @@
//! Marketplace support — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`. //! Marketplaces: git repos of agents, skills, commands, hooks and plugins that
//! are fetched on the host and synced into containers. See
//! `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
pub mod auth; pub mod auth;
pub mod catalog; pub mod catalog;
pub mod diff;
pub mod git; pub mod git;
pub mod tree; pub mod tree;
#[cfg(test)]
pub(crate) mod test_support;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tokio::sync::oneshot;
use crate::models::marketplace::{
CatalogItem, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot, SyncReport,
};
use crate::models::{AppSettings, Project};
use catalog::{item_fingerprint, parse_catalog};
use tree::GitTree;
/// Emitted after every container sync, payload `{ project_id, report }`.
pub const SYNC_FINISHED_EVENT: &str = "marketplace-sync-finished";
pub struct MarketplaceManager {
data_root: PathBuf,
snapshots: Mutex<HashMap<String, MarketplaceSnapshot>>,
reports: Mutex<HashMap<String, SyncReport>>,
gh_login_cancel: tokio::sync::Mutex<Option<oneshot::Sender<()>>>,
/// Serialises writers of the bare caches (fetch, pins, cache removal) so
/// concurrent refreshes never race on gix ref locks (pre-flight F11a).
repo_lock: tokio::sync::Mutex<()>,
}
/// Project ids become file names; anything outside this set is not persisted.
fn safe_file_stem(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
impl MarketplaceManager {
/// `data_root` is `<data_dir>/triple-c`.
pub fn new(data_root: PathBuf) -> Self {
Self {
data_root,
snapshots: Mutex::new(HashMap::new()),
reports: Mutex::new(HashMap::new()),
gh_login_cancel: tokio::sync::Mutex::new(None),
repo_lock: tokio::sync::Mutex::new(()),
}
}
pub fn data_root(&self) -> &Path {
&self.data_root
}
/// Hold while writing to any marketplace cache (fetch, `git::set_pins`,
/// removing a cache).
pub fn repo_lock(&self) -> &tokio::sync::Mutex<()> {
&self.repo_lock
}
pub fn snapshot(&self, marketplace_id: &str) -> Option<MarketplaceSnapshot> {
self.snapshots.lock().unwrap().get(marketplace_id).cloned()
}
pub fn put_snapshot(&self, snap: MarketplaceSnapshot) {
self.snapshots
.lock()
.unwrap()
.insert(snap.marketplace_id.clone(), snap);
}
pub fn remove_snapshot(&self, marketplace_id: &str) {
self.snapshots.lock().unwrap().remove(marketplace_id);
}
fn report_path(&self, project_id: &str) -> PathBuf {
self.data_root
.join("marketplace-sync")
.join(format!("{project_id}.json"))
}
pub fn report(&self, project_id: &str) -> Option<SyncReport> {
if let Some(r) = self.reports.lock().unwrap().get(project_id) {
return Some(r.clone());
}
if !safe_file_stem(project_id) {
return None;
}
let text = std::fs::read_to_string(self.report_path(project_id)).ok()?;
let report: SyncReport = serde_json::from_str(&text).ok()?;
self.reports
.lock()
.unwrap()
.insert(project_id.to_string(), report.clone());
Some(report)
}
pub fn put_report(&self, project_id: &str, report: SyncReport) {
self.reports
.lock()
.unwrap()
.insert(project_id.to_string(), report.clone());
if !safe_file_stem(project_id) {
return;
}
let path = self.report_path(project_id);
let write = || -> std::io::Result<()> {
std::fs::create_dir_all(path.parent().unwrap())?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(&report).unwrap_or_default())?;
std::fs::rename(&tmp, &path)
};
if let Err(e) = write() {
log::warn!(
"Could not persist the marketplace sync report for {}: {}",
project_id,
e
);
}
}
/// Claim (`Some`) or release (`None`) the single gh-login slot. Claiming
/// fails while another login holds it.
pub async fn set_gh_login_cancel(&self, tx: Option<oneshot::Sender<()>>) -> bool {
let mut slot = self.gh_login_cancel.lock().await;
match tx {
Some(tx) => {
if slot.is_some() {
return false;
}
*slot = Some(tx);
true
}
None => {
*slot = None;
true
}
}
}
pub async fn cancel_gh_login(&self) {
if let Some(tx) = self.gh_login_cancel.lock().await.take() {
let _ = tx.send(());
}
}
}
/// Head commit for a marketplace: the in-memory snapshot's, else the cache's.
pub fn head_for(mgr: &MarketplaceManager, m: &Marketplace) -> Option<String> {
mgr.snapshot(&m.id).and_then(|s| s.head_commit).or_else(|| {
git::cached_head(&git::cache_path(mgr.data_root(), &m.id))
.ok()
.flatten()
})
}
fn parse_at(repo: &Path, commit: &str) -> Result<Vec<CatalogItem>, String> {
let tree = GitTree::open(repo, commit)?;
Ok(parse_catalog(&tree))
}
/// Snapshot from the cache alone (no network): startup, and after an install
/// when nothing is in memory. `fetched_at` stays `None`.
pub fn load_cached_snapshot(
mgr: &MarketplaceManager,
marketplace: &Marketplace,
) -> MarketplaceSnapshot {
let repo = git::cache_path(mgr.data_root(), &marketplace.id);
let mut snap = MarketplaceSnapshot {
marketplace_id: marketplace.id.clone(),
..Default::default()
};
match git::cached_head(&repo) {
Ok(Some(head)) => match parse_at(&repo, &head) {
Ok(items) => {
snap.head_commit = Some(head);
snap.items = items;
}
Err(e) => snap.fetch_error = Some(format!("The cached copy could not be read: {e}")),
},
Ok(None) => {}
Err(e) => snap.fetch_error = Some(format!("The cached copy could not be read: {e}")),
}
snap
}
/// Keep the previous items and head (in memory, else from the cache) and
/// record why this refresh failed.
fn failed_snapshot(
mgr: &MarketplaceManager,
m: &Marketplace,
message: String,
) -> MarketplaceSnapshot {
let mut snap = mgr
.snapshot(&m.id)
.unwrap_or_else(|| load_cached_snapshot(mgr, m));
snap.fetch_error = Some(message);
mgr.put_snapshot(snap.clone());
snap
}
/// Refresh one marketplace: resolve the credential, fetch (blocking task, under
/// the repo lock), parse the catalog at head and store the snapshot. On failure
/// the previous items and head are kept and `fetch_error` is set.
pub async fn refresh_marketplace(
mgr: &MarketplaceManager,
settings: &AppSettings,
marketplace_id: &str,
) -> MarketplaceSnapshot {
let Some(m) = settings
.marketplaces
.iter()
.find(|m| m.id == marketplace_id)
.cloned()
else {
return MarketplaceSnapshot {
marketplace_id: marketplace_id.to_string(),
fetch_error: Some("This marketplace is no longer configured.".to_string()),
..Default::default()
};
};
let account = m
.account_id
.as_ref()
.and_then(|id| settings.marketplace_accounts.iter().find(|a| &a.id == id))
.cloned();
let cred = match &account {
Some(a) => match auth::resolve_credential(a).await {
Ok(c) => Some(c),
Err(e) => return failed_snapshot(mgr, &m, e),
},
None => None,
};
let repo = git::cache_path(mgr.data_root(), &m.id);
let (url, branch) = (m.url.clone(), m.branch.clone());
let joined = {
let _repo_guard = mgr.repo_lock.lock().await;
tokio::task::spawn_blocking(move || {
let head = git::fetch(&repo, &url, branch.as_deref(), cred)?;
let items = parse_at(&repo, &head).map_err(git::FetchError::Other)?;
Ok::<_, git::FetchError>((head, items))
})
.await
};
match joined {
Ok(Ok((head, items))) => {
let snap = MarketplaceSnapshot {
marketplace_id: m.id.clone(),
head_commit: Some(head),
fetched_at: Some(chrono::Utc::now().to_rfc3339()),
fetch_error: None,
items,
};
mgr.put_snapshot(snap.clone());
snap
}
Ok(Err(e)) => failed_snapshot(
mgr,
&m,
auth::describe_fetch_error(&e, account.as_ref(), &m.url),
),
Err(e) => failed_snapshot(mgr, &m, format!("The refresh task failed: {e}")),
}
}
fn item_changed(repo: &Path, inst: &MarketplaceInstall, head: &str) -> Result<bool, String> {
let old = GitTree::open(repo, &inst.commit)?;
let new = GitTree::open(repo, head)?;
Ok(item_fingerprint(&old, inst.kind, &inst.key)?
!= item_fingerprint(&new, inst.kind, &inst.key)?)
}
/// Every install (global + all projects) whose item fingerprint at head
/// differs from its pin. Installs whose pin is not in the cache are skipped.
pub fn compute_updates(
mgr: &MarketplaceManager,
settings: &AppSettings,
projects: &[Project],
) -> Vec<ItemUpdate> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
let all = settings
.global_marketplace_installs
.iter()
.chain(projects.iter().flat_map(|p| p.marketplace_installs.iter()));
for inst in all {
if !seen.insert((inst.item_ref(), inst.commit.clone())) {
continue;
}
let Some(m) = settings
.marketplaces
.iter()
.find(|m| m.id == inst.marketplace_id)
else {
continue;
};
let Some(head) = head_for(mgr, m) else {
continue;
};
if head == inst.commit {
continue;
}
let repo = git::cache_path(mgr.data_root(), &m.id);
match item_changed(&repo, inst, &head) {
Ok(true) => out.push(ItemUpdate {
item: inst.item_ref(),
pinned: inst.commit.clone(),
head,
}),
Ok(false) => {}
Err(e) => log::debug!("Update check skipped for {}: {}", inst.key, e),
}
}
out
}
/// All commits referenced by installs, per marketplace (for `git::set_pins`).
pub fn pins_by_marketplace(
settings: &AppSettings,
projects: &[Project],
) -> HashMap<String, Vec<String>> {
let mut map: HashMap<String, BTreeSet<String>> = HashMap::new();
let all = settings
.global_marketplace_installs
.iter()
.chain(projects.iter().flat_map(|p| p.marketplace_installs.iter()));
for inst in all {
map.entry(inst.marketplace_id.clone())
.or_default()
.insert(inst.commit.clone());
}
map.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::marketplace::test_support::GitFixture;
use crate::models::marketplace::{ItemKind, Marketplace, MarketplaceInstall};
fn settings_with(url: &str) -> AppSettings {
let mut s = AppSettings::default();
s.marketplaces.push(Marketplace {
id: "m1".into(),
name: "Test".into(),
url: url.into(),
branch: None,
account_id: None,
});
s
}
fn install(kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall {
MarketplaceInstall {
marketplace_id: "m1".into(),
kind,
key: key.into(),
commit: commit.into(),
}
}
#[tokio::test]
async fn refresh_parses_the_catalog_at_head() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let snap = refresh_marketplace(&mgr, &settings_with(&fx.url()), "m1").await;
assert_eq!(snap.fetch_error, None);
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
assert!(snap.fetched_at.is_some());
let mut keys: Vec<String> = snap
.items
.iter()
.map(|i| format!("{:?}:{}", i.kind, i.key))
.collect();
keys.sort();
assert_eq!(
keys,
vec![
"Agent:code-reviewer",
"Command:example-command",
"Hook:notify-on-stop",
"Plugin:example-plugin",
"Skill:example-skill",
]
);
assert_eq!(mgr.snapshot("m1"), Some(snap));
}
#[tokio::test]
async fn refresh_failure_keeps_snapshot() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
let url = fx.url();
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let settings = settings_with(&url);
let first = refresh_marketplace(&mgr, &settings, "m1").await;
assert_eq!(first.fetch_error, None);
drop(fx); // the source repository disappears (offline, deleted, …)
let second = refresh_marketplace(&mgr, &settings, "m1").await;
assert!(second.fetch_error.is_some(), "expected a fetch error");
assert_eq!(second.head_commit.as_deref(), Some(c1.as_str()));
assert_eq!(second.items, first.items);
assert_eq!(second.fetched_at, first.fetched_at);
}
#[tokio::test]
async fn refresh_waits_for_the_repo_lock() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let settings = settings_with(&fx.url());
let guard = mgr.repo_lock().lock().await;
let blocked = tokio::time::timeout(
std::time::Duration::from_millis(300),
refresh_marketplace(&mgr, &settings, "m1"),
)
.await;
assert!(blocked.is_err(), "refresh must not fetch while the repo lock is held");
assert!(
!git::cache_path(data.path(), "m1").exists(),
"nothing may touch the cache while the lock is held"
);
drop(guard);
let snap = refresh_marketplace(&mgr, &settings, "m1").await;
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
}
#[tokio::test]
async fn concurrent_refreshes_all_succeed() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let settings = settings_with(&fx.url());
let (a, b, c) = tokio::join!(
refresh_marketplace(&mgr, &settings, "m1"),
refresh_marketplace(&mgr, &settings, "m1"),
refresh_marketplace(&mgr, &settings, "m1"),
);
for snap in [a, b, c] {
assert_eq!(snap.fetch_error, None);
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
}
}
#[tokio::test]
async fn cached_snapshot_loads_without_network() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
let settings = settings_with(&fx.url());
{
let mgr = MarketplaceManager::new(data.path().to_path_buf());
refresh_marketplace(&mgr, &settings, "m1").await;
}
drop(fx);
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let snap = load_cached_snapshot(&mgr, &settings.marketplaces[0]);
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
assert_eq!(snap.items.len(), 5);
assert_eq!(snap.fetch_error, None);
}
#[tokio::test]
async fn only_items_whose_own_files_changed_have_updates() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
fx.write(
"agents/code-reviewer.md",
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview harder.\n",
);
let c2 = fx.commit("tweak agent");
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let mut settings = settings_with(&fx.url());
settings.global_marketplace_installs = vec![
install(ItemKind::Agent, "code-reviewer", &c1),
install(ItemKind::Hook, "notify-on-stop", &c1),
];
let mut project = crate::models::Project::new("p".into(), vec![]);
project.marketplace_installs = vec![install(ItemKind::Skill, "example-skill", &c1)];
refresh_marketplace(&mgr, &settings, "m1").await;
let updates = compute_updates(&mgr, &settings, &[project]);
assert_eq!(updates.len(), 1, "{updates:?}");
assert_eq!(updates[0].item.key, "code-reviewer");
assert_eq!(updates[0].pinned, c1);
assert_eq!(updates[0].head, c2);
}
#[test]
fn pins_are_grouped_and_deduplicated_per_marketplace() {
let a = "a".repeat(40);
let b = "b".repeat(40);
let mut settings = settings_with("https://example.invalid/r.git");
settings.global_marketplace_installs = vec![
install(ItemKind::Agent, "x", &b),
install(ItemKind::Hook, "y", &a),
];
let mut project = crate::models::Project::new("p".into(), vec![]);
project.marketplace_installs = vec![install(ItemKind::Agent, "z", &a)];
let pins = pins_by_marketplace(&settings, &[project]);
assert_eq!(pins.get("m1"), Some(&vec![a.clone(), b.clone()]));
}
#[test]
fn reports_are_persisted_per_project() {
let data = tempfile::tempdir().unwrap();
let report = SyncReport {
installed: vec!["agent:x".into()],
..Default::default()
};
MarketplaceManager::new(data.path().to_path_buf()).put_report("proj-1", report.clone());
let fresh = MarketplaceManager::new(data.path().to_path_buf());
assert_eq!(fresh.report("proj-1"), Some(report));
assert_eq!(fresh.report("proj-2"), None);
}
#[tokio::test]
async fn only_one_gh_login_may_hold_the_cancel_slot() {
let mgr = MarketplaceManager::new(std::env::temp_dir());
let (tx1, rx1) = tokio::sync::oneshot::channel();
let (tx2, _rx2) = tokio::sync::oneshot::channel();
assert!(mgr.set_gh_login_cancel(Some(tx1)).await);
assert!(!mgr.set_gh_login_cancel(Some(tx2)).await);
mgr.cancel_gh_login().await;
assert!(rx1.await.is_ok(), "cancel must signal the running login");
let (tx3, _rx3) = tokio::sync::oneshot::channel();
assert!(
mgr.set_gh_login_cancel(Some(tx3)).await,
"slot is free after cancel"
);
}
}
@@ -0,0 +1,90 @@
//! Test-only helpers: throwaway git repositories built with the `git` CLI, so
//! marketplace code is exercised against real git objects over `file://`.
//! The git plumbing itself lives in [`super::git::test_support`] (one copy).
use std::fs;
use super::git::test_support::{file_url, git, git_available};
pub struct GitFixture {
pub dir: tempfile::TempDir,
}
impl GitFixture {
/// `None` (with a note on stderr) when `git` is not installed; callers skip.
pub fn new() -> Option<Self> {
if !git_available() {
eprintln!("skipping: git is not installed");
return None;
}
let dir = tempfile::tempdir().expect("tempdir");
git(dir.path(), &["init", "-q", "-b", "main"]);
Some(Self { dir })
}
pub fn url(&self) -> String {
file_url(self.dir.path())
}
pub fn write(&self, path: &str, contents: &str) -> &Self {
let p = self.dir.path().join(path);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, contents).unwrap();
self
}
pub fn write_exec(&self, path: &str, contents: &str) -> &Self {
self.write(path, contents);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let p = self.dir.path().join(path);
fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap();
}
self
}
/// Commit everything and return the new commit id (40 hex).
pub fn commit(&self, message: &str) -> String {
git(self.dir.path(), &["add", "-A"]);
git(
self.dir.path(),
&["commit", "-q", "--allow-empty", "-m", message],
);
git(self.dir.path(), &["rev-parse", "HEAD"])
}
/// A repo with one item of every kind, committed. Returns the commit.
pub fn with_all_kinds(&self) -> String {
self.write(
"agents/code-reviewer.md",
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview the diff.\n",
)
.write(
"skills/example-skill/SKILL.md",
"---\nname: example-skill\ndescription: An example skill\n---\nDo the thing.\n",
)
.write(
"commands/example-command.md",
"---\ndescription: An example command\n---\nRun the example.\n",
)
.write(
"hooks/notify-on-stop/hook.json",
r#"{"name":"notify-on-stop","description":"Ping on stop","hooks":{"Stop":[{"hooks":[{"type":"command","command":"${HOOK_DIR}/notify.sh"}]}]}}"#,
)
.write_exec("hooks/notify-on-stop/notify.sh", "#!/bin/sh\necho done\n")
.write(
"plugins/.claude-plugin/marketplace.json",
r#"{"name":"upstream","owner":{"name":"Test"},"plugins":[{"name":"example-plugin","source":"./example-plugin","description":"An example plugin"}]}"#,
)
.write(
"plugins/example-plugin/.claude-plugin/plugin.json",
r#"{"name":"example-plugin","version":"0.1.0"}"#,
)
.write(
"plugins/example-plugin/skills/hello/SKILL.md",
"---\nname: hello\ndescription: Says hello\n---\nSay hello.\n",
);
self.commit("all kinds")
}
}