Files
Kigi-CLI/crates/codegen/kigi-agent/src/plugins/git_install.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

1227 lines
42 KiB
Rust

//! Git-based plugin installation.
//!
//! Handles cloning repos, copying local directories into the managed
//! `installed-plugins` snapshot (not live symlinks), discovering plugins within
//! installed sources, and URL/path parsing. Trusted / user-home local installs
//! are re-copied at session spawn / reload by [`super::local_refresh`].
use std::path::{Path, PathBuf};
use std::process::Command;
use super::install_registry::{
InstallError, InstallKind, InstallRegistry, InstalledRepo, RepoPlugin,
};
use super::manifest::{ManifestLoadResult, load_manifest, name_from_dirname};
/// Source of a plugin installation.
#[derive(Debug, Clone)]
pub enum InstallSource {
/// Remote git repo — will be cloned.
Git {
url: String,
git_ref: Option<String>,
git_sha: Option<String>,
subdir: Option<String>,
},
/// Local directory — will be copied into the managed install snapshot.
Local {
path: PathBuf,
subdir: Option<String>,
},
}
/// Result of installing a source.
pub struct InstallResult {
pub repo_key: String,
pub repo_path: PathBuf,
pub plugins: Vec<DiscoveredPlugin>,
pub commit: Option<String>,
}
/// A plugin discovered within an installed source.
pub struct DiscoveredPlugin {
pub name: String,
pub subdir: Option<String>,
pub version: Option<String>,
}
/// Parse an install source string into an `InstallSource`.
///
/// Supports:
/// - `https://github.com/user/repo` — Git HTTPS
/// - `https://github.com/user/repo@v1.0.0` — Git with ref
/// - `https://github.com/user/repo#subdir` — Git with subdirectory
/// - `git@github.com:user/repo.git` — Git SSH
/// - `user/repo` — GitHub shorthand (expands to `https://github.com/user/repo`)
/// - `user/repo@v1.0.0` — GitHub shorthand with ref
/// - `user/repo#subdir` — GitHub shorthand with subdirectory
/// - `/path/to/dir` or `./relative` or `~/dir` — Local
/// - `/path/to/dir#subdir` — Local with subdirectory
pub fn parse_install_source(input: &str, cwd: &Path) -> InstallSource {
// Split on # for subdir
let (main, subdir) = match input.rsplit_once('#') {
Some((m, s)) if !s.is_empty() => (m, Some(s.to_string())),
_ => (input, None),
};
// Detect if this is a URL or local path
if main.contains("://") || main.contains("git@") {
// Git URL — split on @ for ref (but not the git@ prefix)
let (url, git_ref) = if main.starts_with("git@") {
// SSH URL: git@host:user/repo.git@ref
// The @ in git@ is part of the URL, look for @ after the first :
if let Some(colon_pos) = main.find(':') {
let after_colon = &main[colon_pos + 1..];
if let Some(at_pos) = after_colon.rfind('@') {
let url = format!("{}:{}", &main[..colon_pos], &after_colon[..at_pos]);
let git_ref = after_colon[at_pos + 1..].to_string();
(url, Some(git_ref))
} else {
(main.to_string(), None)
}
} else {
(main.to_string(), None)
}
} else {
// HTTPS URL: https://host/user/repo@ref
match main.rsplit_once('@') {
Some((url, r)) if !r.is_empty() => (url.to_string(), Some(r.to_string())),
_ => (main.to_string(), None),
}
};
InstallSource::Git {
url,
git_ref,
git_sha: None,
subdir,
}
} else if is_github_shorthand(main) {
// GitHub shorthand: owner/repo or owner/repo@ref
let (owner_repo, git_ref) = match main.rsplit_once('@') {
Some((or, r)) if !r.is_empty() => (or, Some(r.to_string())),
_ => (main, None),
};
InstallSource::Git {
url: format!("https://github.com/{owner_repo}"),
git_ref,
git_sha: None,
subdir,
}
} else {
// Local path
let path = if let Some(stripped) = main.strip_prefix("~/") {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(stripped)
} else {
let p = PathBuf::from(main);
if p.is_relative() { cwd.join(p) } else { p }
};
InstallSource::Local { path, subdir }
}
}
/// Check if a string looks like a GitHub `owner/repo` shorthand.
///
/// Returns `true` for strings like `user/repo` or `user/repo@v1.0`
/// that should be expanded to `https://github.com/user/repo`.
/// Avoids false positives for local paths (`/abs`, `./rel`, `~/home`).
fn is_github_shorthand(s: &str) -> bool {
// Local path indicators.
if s.starts_with('/') || s.starts_with('.') || s.starts_with('~') {
return false;
}
// Strip optional @ref suffix before checking the owner/repo pattern.
let base = match s.rsplit_once('@') {
Some((b, r)) if !r.is_empty() => b,
_ => s,
};
// Must be exactly owner/repo (two non-empty segments separated by one /).
let parts: Vec<&str> = base.splitn(3, '/').collect();
parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty()
}
fn repo_source_id(source: &InstallSource) -> String {
match source {
InstallSource::Git { url, subdir, .. } => match subdir {
Some(sub) => format!("{url}#{sub}"),
None => url.clone(),
},
InstallSource::Local { path, subdir } => {
let path_str = path.to_str().unwrap_or("local");
match subdir {
Some(sub) => format!("{path_str}#{sub}"),
None => path_str.to_string(),
}
}
}
}
/// Install a plugin source (clone or symlink) and discover plugins.
pub fn install_from_source(
source: &InstallSource,
registry: &InstallRegistry,
) -> Result<InstallResult, InstallError> {
let source_id = repo_source_id(source);
let repo_key = InstallRegistry::repo_key(&source_id);
// Check if already installed
if registry.get_repo(&repo_key).is_some() {
return Err(InstallError::AlreadyInstalled { key: repo_key });
}
let install_dir = registry.install_dir().to_path_buf();
std::fs::create_dir_all(&install_dir).map_err(|e| InstallError::Io {
path: install_dir.clone(),
source: e,
})?;
let repo_path = install_dir.join(&repo_key);
let (_kind, commit) = match source {
InstallSource::Git {
url,
git_ref,
git_sha,
subdir,
} => {
clone_repo(url, git_ref.as_deref(), git_sha.as_deref(), &repo_path)?;
let commit = read_head_commit(&repo_path);
let kind = InstallKind::Git {
url: url.clone(),
git_ref: git_ref.clone(),
commit: commit.clone().unwrap_or_default(),
subdir: subdir.clone(),
};
(kind, commit)
}
InstallSource::Local { path, subdir } => {
if !path.is_dir() {
return Err(InstallError::InstallFailed {
detail: format!("local path is not a directory: {}", path.display()),
});
}
// Deliberate full copy (not a symlink): isolates the install from
// later source edits/deletion and keeps uninstall a simple dir remove;
// local_refresh re-copies trusted sources to pick up new components.
copy_dir_recursive(path, &repo_path).map_err(|e| InstallError::Io {
path: repo_path.clone(),
source: e,
})?;
let kind = InstallKind::Local {
source_path: path.clone(),
subdir: subdir.clone(),
};
(kind, None)
}
};
// Discover plugins
let subdir = match source {
InstallSource::Git { subdir, .. } | InstallSource::Local { subdir, .. } => subdir.clone(),
};
let plugins = discover_plugins_in_dir(&repo_path, subdir.as_deref())?;
if plugins.is_empty() {
// Clean up — no valid plugins found
let _ = remove_repo_path(&repo_path);
return Err(InstallError::InstallFailed {
detail: "no plugins found in the source (no plugin.json or convention components)"
.to_string(),
});
}
Ok(InstallResult {
repo_key,
repo_path,
plugins,
commit,
})
}
/// Clone a git repo using the `git` CLI (supports shallow clone, SSH, etc.;
/// optionally SHA-pinned via `git_sha`).
fn clone_repo(
url: &str,
git_ref: Option<&str>,
git_sha: Option<&str>,
target: &Path,
) -> Result<(), InstallError> {
if let Some(sha) = git_sha {
if git_ref.is_some() {
tracing::debug!(?git_ref, sha, "git_sha takes precedence over git_ref");
}
return clone_repo_at_sha(url, sha, target);
}
let mut cmd = Command::new("git");
kigi_tools::util::detach_std_command(&mut cmd);
cmd.arg("clone").arg("--depth").arg("1");
cmd.stdin(std::process::Stdio::null());
cmd.envs(kigi_tools::util::pager_env());
if let Some(r) = git_ref {
cmd.arg("--branch").arg(r);
}
cmd.arg(url).arg(target);
tracing::info!(url = url, target = %target.display(), "cloning plugin repo");
let output = cmd.output().map_err(|e| InstallError::InstallFailed {
detail: format!("failed to run git clone: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Clean up partial clone
let _ = std::fs::remove_dir_all(target);
return Err(InstallError::InstallFailed {
detail: format!(
"git clone failed (exit {}):\n{stderr}",
output.status.code().unwrap_or(-1)
),
});
}
Ok(())
}
fn clone_repo_at_sha(url: &str, sha: &str, target: &Path) -> Result<(), InstallError> {
if sha.is_empty() {
return Err(InstallError::InstallFailed {
detail: "empty SHA provided for pinned clone".into(),
});
}
tracing::info!(url = url, sha = sha, target = %target.display(), "cloning plugin repo at SHA");
std::fs::create_dir_all(target).map_err(|e| InstallError::Io {
path: target.to_path_buf(),
source: e,
})?;
let wrap_fail = |detail: String| {
let _ = std::fs::remove_dir_all(target);
InstallError::InstallFailed { detail }
};
run_git_in(target, &["init", "--quiet"]).map_err(wrap_fail)?;
run_git_in(target, &["remote", "add", "origin", url]).map_err(wrap_fail)?;
run_git_in(target, &["fetch", "--depth", "1", "origin", sha])
.map_err(|d| wrap_fail(format!("fetch-by-sha failed: {d}")))?;
run_git_in(target, &["checkout", "--quiet", "FETCH_HEAD"]).map_err(wrap_fail)?;
let head = read_head_commit(target).ok_or_else(|| {
let _ = std::fs::remove_dir_all(target);
InstallError::InstallFailed {
detail: "could not read HEAD commit after SHA-pinned clone".into(),
}
})?;
if !head.eq_ignore_ascii_case(sha) {
let _ = std::fs::remove_dir_all(target);
return Err(InstallError::ShaMismatch {
expected: sha.to_string(),
actual: head,
});
}
Ok(())
}
fn run_git_in(cwd: &Path, args: &[&str]) -> Result<(), String> {
run_git_in_capture(cwd, args).map(|_| ())
}
fn run_git_in_capture(cwd: &Path, args: &[&str]) -> Result<std::process::Output, String> {
let mut cmd = Command::new("git");
kigi_tools::util::detach_std_command(&mut cmd);
cmd.args(args)
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.envs(kigi_tools::util::pager_env());
let output = cmd
.output()
.map_err(|e| format!("failed to run git {}: {e}", args.first().unwrap_or(&"")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"git {} failed (exit {}):\n{stderr}",
args.first().unwrap_or(&""),
output.status.code().unwrap_or(-1)
));
}
Ok(output)
}
/// Read the HEAD commit SHA from a git repo.
fn read_head_commit(repo_path: &Path) -> Option<String> {
run_git_in_capture(repo_path, &["rev-parse", "HEAD"])
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
/// Remove a repo path (handles both symlinks and directories).
pub fn remove_repo_path(path: &Path) -> Result<(), InstallError> {
if path.is_symlink() {
std::fs::remove_file(path).map_err(|e| InstallError::Io {
path: path.to_path_buf(),
source: e,
})?;
} else if path.is_dir() {
std::fs::remove_dir_all(path).map_err(|e| InstallError::Io {
path: path.to_path_buf(),
source: e,
})?;
}
Ok(())
}
/// Clean up plugin data directories for all plugins in a repo.
///
/// Each plugin has a data dir at `~/.kigi/plugin-data/<plugin_id>/`.
/// This iterates all plugins in the repo and removes their data dirs.
pub fn cleanup_plugin_data(repo: &InstalledRepo, scope: super::discovery::PluginScope) {
let plugin_data_base = kigi_config::kigi_home().join("plugin-data");
for (plugin_name, repo_plugin) in &repo.plugins {
let plugin_root = match &repo_plugin.subdir {
Some(sub) => repo.path.join(sub),
None => repo.path.clone(),
};
let id = super::discovery::PluginId::new(scope, &plugin_root, plugin_name);
let data_dir = plugin_data_base.join(&id.0);
if data_dir.is_dir() {
tracing::info!(
plugin = plugin_name,
data_dir = %data_dir.display(),
"cleaning up plugin data directory"
);
let _ = std::fs::remove_dir_all(&data_dir);
}
}
}
/// Discover plugins within a directory.
///
/// Discovery logic:
/// 1. If `subdir` is specified, only look in that subdirectory
/// 2. If root has plugin.json or convention components, it's a single plugin
/// 3. Otherwise, scan immediate subdirectories for plugins
pub(super) fn discover_plugins_in_dir(
root: &Path,
subdir: Option<&str>,
) -> Result<Vec<DiscoveredPlugin>, InstallError> {
let scan_root = match subdir {
Some(s) => {
let candidate = Path::new(s);
if candidate.is_absolute()
|| candidate.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
{
return Err(InstallError::InstallFailed {
detail: format!("subdirectory '{s}' escapes the source root"),
});
}
let sub_path = root.join(s);
if !sub_path.is_dir() {
return Err(InstallError::InstallFailed {
detail: format!("subdirectory '{s}' not found in source"),
});
}
sub_path
}
None => root.to_path_buf(),
};
// Check if scan_root itself is a plugin
if let Some(plugin) = try_load_plugin(&scan_root, subdir) {
return Ok(vec![plugin]);
}
// Scan immediate subdirectories
let mut plugins = Vec::new();
if let Ok(entries) = std::fs::read_dir(&scan_root) {
for entry in entries.flatten() {
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
let entry_path = entry.path();
let entry_name = entry.file_name().to_str().unwrap_or_default().to_string();
let sub = match subdir {
Some(s) => Some(format!("{s}/{entry_name}")),
None => Some(entry_name),
};
if let Some(plugin) = try_load_plugin(&entry_path, sub.as_deref()) {
plugins.push(plugin);
}
}
}
}
Ok(plugins)
}
/// Try to load a plugin from a directory.
///
/// Returns `Some` if the directory contains a plugin.json or convention components.
fn try_load_plugin(dir: &Path, subdir: Option<&str>) -> Option<DiscoveredPlugin> {
// Try loading manifest
match load_manifest(dir) {
Ok(ManifestLoadResult::Found(manifest)) => {
return Some(DiscoveredPlugin {
name: manifest.name.clone(),
subdir: subdir.map(|s| s.to_string()),
version: manifest.version.clone(),
});
}
Ok(ManifestLoadResult::NotFound) => {}
Err(_) => {}
}
// Check convention components
let has_skills = dir.join("skills").is_dir();
let has_agents = dir.join("agents").is_dir();
let has_mcp = dir.join(".mcp.json").is_file();
let has_hooks = dir.join("hooks").join("hooks.json").is_file();
if has_skills || has_agents || has_mcp || has_hooks {
let name = name_from_dirname(dir)?;
Some(DiscoveredPlugin {
name,
subdir: subdir.map(|s| s.to_string()),
version: None,
})
} else {
None
}
}
/// Build an `InstalledRepo` from an install result and the original source.
pub fn build_installed_repo(result: &InstallResult, source: &InstallSource) -> InstalledRepo {
let kind = match source {
InstallSource::Git {
url,
git_ref,
git_sha,
subdir,
} => InstallKind::Git {
url: url.clone(),
git_ref: git_sha.clone().or_else(|| git_ref.clone()),
commit: result.commit.clone().unwrap_or_default(),
subdir: subdir.clone(),
},
InstallSource::Local { path, subdir } => InstallKind::Local {
source_path: path.clone(),
subdir: subdir.clone(),
},
};
let now = chrono::Utc::now().to_rfc3339();
InstalledRepo {
kind,
installed_at: now.clone(),
updated_at: now,
path: result.repo_path.clone(),
plugins: repo_plugin_map(&result.plugins),
}
}
/// Build the `InstalledRepo.plugins` map from discovered plugins.
pub fn repo_plugin_map(
plugins: &[DiscoveredPlugin],
) -> std::collections::HashMap<String, RepoPlugin> {
plugins
.iter()
.map(|p| {
(
p.name.clone(),
RepoPlugin {
subdir: p.subdir.clone(),
version: p.version.clone(),
},
)
})
.collect()
}
/// Result of updating a repo.
pub struct UpdateResult {
pub repo_key: String,
pub old_commit: Option<String>,
pub new_commit: Option<String>,
pub changed: bool,
pub plugins: Vec<DiscoveredPlugin>,
}
/// Status of an update attempt.
pub enum UpdateStatus {
/// Repo updated successfully.
Updated(UpdateResult),
/// Repo is pinned to a tag or commit — no automatic update.
Pinned { ref_name: String },
/// Local install — explicit update is a no-op; session spawn / reload uses
/// [`super::local_refresh`] to re-copy trusted sources (install is a full
/// directory copy, not a live symlink).
LiveLocal,
}
/// Update an installed repo by fetching latest changes.
///
/// Update semantics (from design decision #6):
/// - Branch installs: `git fetch` + fast-forward to remote branch head
/// - Tag installs: pinned — no-op
/// - Commit installs: pinned — no-op
/// - Local installs: no-op (explicit update); [`super::local_refresh`] re-copies on session spawn / reload
pub fn update_repo(repo_key: &str, repo: &InstalledRepo) -> Result<UpdateStatus, InstallError> {
match &repo.kind {
InstallKind::Local { .. } => Ok(UpdateStatus::LiveLocal),
InstallKind::Git {
git_ref,
commit,
subdir,
..
} => {
// Check if pinned
if let Some(r) = git_ref {
// Heuristic: if the ref looks like a commit hash (40 hex chars)
// or a version tag (starts with v and contains dots), it's pinned.
let is_tag_or_commit = r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit())
|| (r.starts_with('v') && r.contains('.'));
if is_tag_or_commit {
return Ok(UpdateStatus::Pinned {
ref_name: r.clone(),
});
}
}
let old_commit = Some(commit.clone());
// Git fetch + pull
let repo_path = &repo.path;
if !repo_path.is_dir() {
return Err(InstallError::InstallFailed {
detail: format!("repo directory not found: {}", repo_path.display()),
});
}
let mut cmd = Command::new("git");
kigi_tools::util::detach_std_command(&mut cmd);
cmd.args(["pull", "--ff-only"])
.current_dir(repo_path)
.stdin(std::process::Stdio::null())
.envs(kigi_tools::util::pager_env());
let output = cmd.output().map_err(|e| InstallError::InstallFailed {
detail: format!("failed to run git pull: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(InstallError::InstallFailed {
detail: format!(
"git pull failed (exit {}):\n{stderr}",
output.status.code().unwrap_or(-1)
),
});
}
let new_commit = read_head_commit(repo_path);
let changed = old_commit.as_deref() != new_commit.as_deref();
// Re-discover plugins: the pull may bring new ones
let plugins = discover_plugins_in_dir(repo_path, subdir.as_deref())?;
Ok(UpdateStatus::Updated(UpdateResult {
repo_key: repo_key.to_string(),
old_commit,
new_commit,
changed,
plugins,
}))
}
}
}
/// Recursively copy a directory tree.
///
/// Symlinks are **not** followed: directory symlinks are skipped (not recursed),
/// file symlinks are skipped (not materialized as target contents). This keeps
/// refresh/install snapshots from pulling secrets outside the source tree.
/// Shared by install and refresh, so the skip applies to install-time copies too.
pub(super) fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
let meta = std::fs::symlink_metadata(&src_path)?;
if meta.file_type().is_symlink() {
tracing::debug!(path = %src_path.display(), "copy_dir_recursive: skipping symlink (not followed)");
continue;
}
if meta.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else if meta.is_file() {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_key_distinct_per_git_subdir_and_bare_unchanged() {
let url = "https://github.com/acme/agent-skills.git";
let bare = InstallSource::Git {
url: url.to_string(),
git_ref: None,
git_sha: None,
subdir: None,
};
let acme = InstallSource::Git {
url: url.to_string(),
git_ref: None,
git_sha: None,
subdir: Some("plugins/acme".to_string()),
};
let cloud = InstallSource::Git {
url: url.to_string(),
git_ref: None,
git_sha: None,
subdir: Some("plugins/acme-cloud".to_string()),
};
let key_bare = InstallRegistry::repo_key(&repo_source_id(&bare));
let key_acme = InstallRegistry::repo_key(&repo_source_id(&acme));
let key_cloud = InstallRegistry::repo_key(&repo_source_id(&cloud));
assert_ne!(key_acme, key_cloud);
assert_ne!(key_bare, key_acme);
assert_eq!(key_bare, InstallRegistry::repo_key(url));
}
#[test]
fn repo_key_distinct_per_local_subdir_and_bare_unchanged() {
let path_str = "/Users/me/workspace";
let bare = InstallSource::Local {
path: PathBuf::from(path_str),
subdir: None,
};
let acme = InstallSource::Local {
path: PathBuf::from(path_str),
subdir: Some("plugins/acme".to_string()),
};
let cloud = InstallSource::Local {
path: PathBuf::from(path_str),
subdir: Some("plugins/acme-cloud".to_string()),
};
let key_bare = InstallRegistry::repo_key(&repo_source_id(&bare));
let key_acme = InstallRegistry::repo_key(&repo_source_id(&acme));
let key_cloud = InstallRegistry::repo_key(&repo_source_id(&cloud));
assert_ne!(key_acme, key_cloud);
assert_ne!(key_bare, key_acme);
assert_eq!(key_bare, InstallRegistry::repo_key(path_str));
}
#[test]
fn parse_https_url() {
let source = parse_install_source("https://github.com/user/repo", Path::new("/tmp"));
match source {
InstallSource::Git {
url,
git_ref,
git_sha,
subdir,
} => {
assert_eq!(url, "https://github.com/user/repo");
assert!(git_ref.is_none());
assert!(git_sha.is_none());
assert!(subdir.is_none());
}
_ => panic!("expected Git"),
}
}
#[test]
fn parse_https_url_with_ref() {
let source = parse_install_source("https://github.com/user/repo@v1.0.0", Path::new("/tmp"));
match source {
InstallSource::Git { url, git_ref, .. } => {
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(git_ref.as_deref(), Some("v1.0.0"));
}
_ => panic!("expected Git"),
}
}
#[test]
fn parse_https_url_with_subdir() {
let source =
parse_install_source("https://github.com/user/repo#my-plugin", Path::new("/tmp"));
match source {
InstallSource::Git { url, subdir, .. } => {
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(subdir.as_deref(), Some("my-plugin"));
}
_ => panic!("expected Git"),
}
}
#[test]
fn parse_https_url_with_ref_and_subdir() {
let source = parse_install_source(
"https://github.com/user/repo@main#deploy",
Path::new("/tmp"),
);
match source {
InstallSource::Git {
url,
git_ref,
subdir,
..
} => {
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(git_ref.as_deref(), Some("main"));
assert_eq!(subdir.as_deref(), Some("deploy"));
}
_ => panic!("expected Git"),
}
}
#[test]
fn parse_ssh_url() {
let source = parse_install_source("git@github.com:user/my-plugin.git", Path::new("/tmp"));
match source {
InstallSource::Git {
url,
git_ref,
subdir,
..
} => {
assert_eq!(url, "git@github.com:user/my-plugin.git");
assert!(git_ref.is_none());
assert!(subdir.is_none());
}
_ => panic!("expected Git"),
}
}
#[test]
fn parse_github_shorthand() {
let source = parse_install_source("user/repo", Path::new("/tmp"));
match source {
InstallSource::Git {
url,
git_ref,
subdir,
..
} => {
assert_eq!(url, "https://github.com/user/repo");
assert!(git_ref.is_none());
assert!(subdir.is_none());
}
_ => panic!("expected Git"),
}
}
#[test]
fn parse_github_shorthand_with_ref() {
let source = parse_install_source("user/repo@v1.0.0", Path::new("/tmp"));
match source {
InstallSource::Git { url, git_ref, .. } => {
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(git_ref.as_deref(), Some("v1.0.0"));
}
_ => panic!("expected Git"),
}
}
#[test]
fn parse_github_shorthand_with_subdir() {
let source = parse_install_source("user/repo#my-plugin", Path::new("/tmp"));
match source {
InstallSource::Git { url, subdir, .. } => {
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(subdir.as_deref(), Some("my-plugin"));
}
_ => panic!("expected Git"),
}
}
#[test]
fn parse_github_shorthand_with_ref_and_subdir() {
let source = parse_install_source("user/repo@main#deploy", Path::new("/tmp"));
match source {
InstallSource::Git {
url,
git_ref,
subdir,
..
} => {
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(git_ref.as_deref(), Some("main"));
assert_eq!(subdir.as_deref(), Some("deploy"));
}
_ => panic!("expected Git"),
}
}
#[test]
fn parse_github_shorthand_not_deep_path() {
// Three segments like "a/b/c" should be treated as a local path, not shorthand.
let source = parse_install_source("a/b/c", Path::new("/tmp"));
match source {
InstallSource::Local { .. } => {}
_ => panic!("expected Local for deep relative path"),
}
}
#[test]
fn parse_local_absolute_path() {
let source = parse_install_source("/home/user/my-plugin", Path::new("/tmp"));
match source {
InstallSource::Local { path, subdir } => {
assert_eq!(path, PathBuf::from("/home/user/my-plugin"));
assert!(subdir.is_none());
}
_ => panic!("expected Local"),
}
}
#[test]
fn parse_local_relative_path() {
let source = parse_install_source("./my-plugin", Path::new("/projects"));
match source {
InstallSource::Local { path, subdir } => {
assert_eq!(path, PathBuf::from("/projects/my-plugin"));
assert!(subdir.is_none());
}
_ => panic!("expected Local"),
}
}
#[test]
fn parse_local_with_subdir() {
let source = parse_install_source("/home/user/workspace#my-plugin", Path::new("/tmp"));
match source {
InstallSource::Local { path, subdir } => {
assert_eq!(path, PathBuf::from("/home/user/workspace"));
assert_eq!(subdir.as_deref(), Some("my-plugin"));
}
_ => panic!("expected Local"),
}
}
#[test]
fn discover_single_plugin_at_root() {
let tmp = tempfile::tempdir().unwrap();
let plugin_dir = tmp.path();
std::fs::create_dir_all(plugin_dir.join("skills")).unwrap();
let plugins = discover_plugins_in_dir(plugin_dir, None).unwrap();
assert_eq!(plugins.len(), 1);
}
#[test]
fn discover_multiple_plugins_in_subdirs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Create two plugin subdirectories
std::fs::create_dir_all(root.join("linter/skills")).unwrap();
std::fs::create_dir_all(root.join("formatter/agents")).unwrap();
// Non-plugin dir (should be ignored)
std::fs::create_dir_all(root.join("docs")).unwrap();
let plugins = discover_plugins_in_dir(root, None).unwrap();
assert_eq!(plugins.len(), 2);
let names: Vec<_> = plugins.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"linter"));
assert!(names.contains(&"formatter"));
}
#[test]
fn discover_with_subdir_selector() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("packages/plugin-a/skills")).unwrap();
std::fs::create_dir_all(root.join("packages/plugin-b/skills")).unwrap();
// Only discover plugin-a
let plugins = discover_plugins_in_dir(root, Some("packages/plugin-a")).unwrap();
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "plugin-a");
}
#[test]
fn discover_plugins_in_dir_rejects_escaping_subdir() {
let tmp = tempfile::tempdir().unwrap();
for bad in ["../escape", "/etc"] {
match discover_plugins_in_dir(tmp.path(), Some(bad)) {
Err(InstallError::InstallFailed { detail }) => {
assert!(detail.contains("escapes"), "got detail: {detail}");
}
Err(e) => panic!("expected escapes InstallFailed, got: {e:?}"),
Ok(_) => panic!("expected escapes error for subdir {bad:?}, got Ok"),
}
}
}
#[test]
fn discover_with_manifest() {
let tmp = tempfile::tempdir().unwrap();
let plugin_dir = tmp.path();
std::fs::write(
plugin_dir.join("plugin.json"),
r#"{"name": "my-tool", "version": "1.2.0"}"#,
)
.unwrap();
let plugins = discover_plugins_in_dir(plugin_dir, None).unwrap();
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "my-tool");
assert_eq!(plugins[0].version.as_deref(), Some("1.2.0"));
}
fn git_available() -> bool {
Command::new("git")
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn run_git_test(cwd: &Path, args: &[&str]) {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(cwd)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.stdin(std::process::Stdio::null());
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn make_local_repo() -> (tempfile::TempDir, String) {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
run_git_test(repo, &["init", "--initial-branch=main", "--quiet"]);
std::fs::write(repo.join("plugin.json"), r#"{"name":"pinned-plugin"}"#).unwrap();
run_git_test(repo, &["add", "plugin.json"]);
run_git_test(repo, &["commit", "-m", "initial", "--quiet"]);
run_git_test(repo, &["config", "uploadpack.allowAnySHA1InWant", "true"]);
let sha = read_head_commit(repo).expect("read HEAD of local repo");
(tmp, sha)
}
#[test]
fn clone_at_correct_sha_succeeds() {
if !git_available() {
eprintln!("skipping: `git` binary not available in test sandbox");
return;
}
let (repo, sha) = make_local_repo();
let dest = tempfile::tempdir().unwrap();
let url = format!("file://{}", repo.path().display());
clone_repo_at_sha(&url, &sha, dest.path()).expect("clone at correct sha should succeed");
assert_eq!(read_head_commit(dest.path()).as_deref(), Some(sha.as_str()));
assert!(dest.path().join("plugin.json").exists());
}
#[test]
fn clone_at_wrong_sha_returns_mismatch() {
if !git_available() {
eprintln!("skipping: `git` binary not available in test sandbox");
return;
}
let (repo, _real_sha) = make_local_repo();
let bogus_sha = "0000000000000000000000000000000000000000";
let dest = tempfile::tempdir().unwrap();
let url = format!("file://{}", repo.path().display());
let err = clone_repo_at_sha(&url, bogus_sha, dest.path())
.expect_err("clone at bogus sha should fail");
match err {
InstallError::InstallFailed { detail } => {
assert!(
detail.contains("fetch-by-sha"),
"expected fetch-by-sha error, got: {detail}"
);
}
other => panic!("expected InstallFailed, got: {other:?}"),
}
assert!(!dest.path().join(".git").exists());
}
#[test]
fn clone_at_sha_handles_short_sha_via_mismatch() {
if !git_available() {
eprintln!("skipping: `git` binary not available in test sandbox");
return;
}
let (repo, sha) = make_local_repo();
let short = &sha[..7];
let dest = tempfile::tempdir().unwrap();
let url = format!("file://{}", repo.path().display());
let err = clone_repo_at_sha(&url, short, dest.path())
.expect_err("short sha should fail verification");
match err {
InstallError::ShaMismatch { expected, actual } => {
assert_eq!(expected, short);
assert_eq!(actual, sha);
}
InstallError::InstallFailed { .. } => {}
other => panic!("expected ShaMismatch or InstallFailed, got: {other:?}"),
}
}
#[test]
fn update_repo_rediscovers_plugin_at_stored_subdir() {
if !git_available() {
eprintln!("skipping: `git` binary not available in test sandbox");
return;
}
use std::collections::HashMap;
let origin = tempfile::tempdir().unwrap();
run_git_test(origin.path(), &["init", "--initial-branch=main", "--quiet"]);
let plugin_dir = origin.path().join("plugins").join("acme");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(
plugin_dir.join("plugin.json"),
r#"{"name":"acme","version":"1.0.0"}"#,
)
.unwrap();
run_git_test(origin.path(), &["add", "-A"]);
run_git_test(origin.path(), &["commit", "-m", "init", "--quiet"]);
let install = tempfile::tempdir().unwrap();
let repo_path = install.path().join("acme-deadbeef");
let origin_url = format!("file://{}", origin.path().display());
run_git_test(
install.path(),
&[
"clone",
"--quiet",
origin_url.as_str(),
repo_path.to_str().unwrap(),
],
);
let commit = read_head_commit(&repo_path).expect("read HEAD of clone");
let repo = InstalledRepo {
kind: InstallKind::Git {
url: origin_url,
git_ref: Some("main".to_string()),
commit,
subdir: Some("plugins/acme".to_string()),
},
installed_at: String::new(),
updated_at: String::new(),
path: repo_path,
plugins: HashMap::from([(
"acme".to_string(),
RepoPlugin {
subdir: Some("plugins/acme".to_string()),
version: Some("1.0.0".to_string()),
},
)]),
};
match update_repo("acme-deadbeef", &repo).expect("update should succeed") {
UpdateStatus::Updated(result) => {
assert_eq!(result.plugins.len(), 1);
assert_eq!(result.plugins[0].name, "acme");
assert_eq!(result.plugins[0].subdir.as_deref(), Some("plugins/acme"));
}
_ => panic!("expected UpdateStatus::Updated"),
}
}
#[test]
fn update_repo_errors_when_stored_subdir_missing() {
if !git_available() {
eprintln!("skipping: `git` binary not available in test sandbox");
return;
}
use std::collections::HashMap;
let origin = tempfile::tempdir().unwrap();
run_git_test(origin.path(), &["init", "--initial-branch=main", "--quiet"]);
let plugin_dir = origin.path().join("plugins").join("acme");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(
plugin_dir.join("plugin.json"),
r#"{"name":"acme","version":"1.0.0"}"#,
)
.unwrap();
run_git_test(origin.path(), &["add", "-A"]);
run_git_test(origin.path(), &["commit", "-m", "init", "--quiet"]);
let install = tempfile::tempdir().unwrap();
let repo_path = install.path().join("acme-deadbeef");
let origin_url = format!("file://{}", origin.path().display());
run_git_test(
install.path(),
&[
"clone",
"--quiet",
origin_url.as_str(),
repo_path.to_str().unwrap(),
],
);
let commit = read_head_commit(&repo_path).expect("read HEAD of clone");
let repo = InstalledRepo {
kind: InstallKind::Git {
url: origin_url,
git_ref: Some("main".to_string()),
commit,
subdir: Some("plugins/does-not-exist".to_string()),
},
installed_at: String::new(),
updated_at: String::new(),
path: repo_path,
plugins: HashMap::from([(
"acme".to_string(),
RepoPlugin {
subdir: Some("plugins/does-not-exist".to_string()),
version: Some("1.0.0".to_string()),
},
)]),
};
match update_repo("acme-deadbeef", &repo) {
Err(InstallError::InstallFailed { .. }) => {}
Err(e) => panic!("expected InstallFailed, got {e:?}"),
Ok(_) => panic!("expected InstallFailed when stored subdir is missing, got Ok"),
}
}
}