Scope marketplaces per-file, use bare clones with worktrees
Marketplace definitions are now per-file so the same name can appear
at different versions across config files. Repos are bare-cloned with
worktrees per rev so multiple versions coexist for symlinks.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/README.md b/README.md
index 1054b90..7bbd8ee 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,12 @@ anything else.
A **skill** is a directory containing a `SKILL.md` file (and optionally other
files). Claude and pi discover skills by scanning `~/.claude/skills/<name>/`.
+A **marketplace** is a git repo that contains one or more plugins, described by
+a `.claude-plugin/marketplace.json` manifest.
+
A **package** is a git repo that contains one or more skills. The repo is cloned
-into pinch's cache; individual skills are symlinked into `~/.claude/skills/`.
+(bare) into pinch's cache; worktrees are created per revision so multiple
+versions can coexist. Individual skills are symlinked into `~/.claude/skills/`.
## Directory layout
@@ -26,20 +30,25 @@ into pinch's cache; individual skills are symlinked into `~/.claude/skills/`.
~/.cache/pinch/ # managed by pinch
repos/
github.com/
- user/repo/ # bare or full clone
+ user/repo.git/ # bare clone
+ pinch-worktrees/
+ abc123def456/ # worktree at rev abc123def456
+ def789012345/ # worktree at rev def789012345
~/.local/share/pinch/
pinch-lock.yml # lockfile
~/.claude/skills/
- jj -> ~/.cache/pinch/repos/github.com/user/repo/skills/jj
- gh-pr -> ~/.cache/pinch/repos/github.com/user/repo/skills/gh-pr
+ jj -> ~/.cache/pinch/repos/github.com/user/repo.git/pinch-worktrees/abc123def456/plugins/jj/skills/jj
+ gh-pr -> ~/.cache/pinch/repos/github.com/user/repo.git/pinch-worktrees/def789012345/plugins/gh-pr/skills/gh-pr
```
## Config (manifest)
Config files live in `~/.config/pinch/conf.d/`. All `.yml` files are merged
-alphabetically. Duplicate skill names across files are a hard error.
+alphabetically. Duplicate plugin names across files are a hard error.
+Marketplace definitions are scoped per-file — the same marketplace name can
+appear in different files at different versions.
```yml
marketplaces:
diff --git a/src/config.rs b/src/config.rs
index 3cd8625..34f09bc 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -36,11 +36,9 @@ pub fn load() -> Result<Vec<Plugin>> {
bail!("no .yml files found in {}", config_dir.display());
}
- let mut marketplaces: BTreeMap<String, MarketplaceEntry> = BTreeMap::new();
let mut plugins = Vec::new();
let mut seen_names = HashSet::new();
- // First pass: collect all marketplaces
for entry in &entries {
let path = entry.path();
let contents = std::fs::read_to_string(&path)
@@ -48,25 +46,8 @@ pub fn load() -> Result<Vec<Plugin>> {
let file: ConfigFile = serde_yml::from_str(&contents)
.with_context(|| format!("failed to parse {}", path.display()))?;
- for (name, mkt) in file.marketplaces {
- if marketplaces.contains_key(&name) {
- bail!(
- "duplicate marketplace '{}' (found in {})",
- name,
- path.display()
- );
- }
- marketplaces.insert(name, mkt);
- }
- }
-
- // Second pass: resolve plugins
- for entry in &entries {
- let path = entry.path();
- let contents = std::fs::read_to_string(&path)
- .with_context(|| format!("failed to read {}", path.display()))?;
- let file: ConfigFile = serde_yml::from_str(&contents)
- .with_context(|| format!("failed to parse {}", path.display()))?;
+ // Marketplaces are scoped to this file
+ let marketplaces = &file.marketplaces;
for (name, entry) in file.plugins {
if !seen_names.insert(name.clone()) {
@@ -81,9 +62,10 @@ pub fn load() -> Result<Vec<Plugin>> {
Some(mkt_name) => {
let mkt = marketplaces.get(&mkt_name).ok_or_else(|| {
color_eyre::eyre::eyre!(
- "plugin '{}' references unknown marketplace '{}'",
+ "plugin '{}' references unknown marketplace '{}' (in {})",
name,
- mkt_name
+ mkt_name,
+ path.display()
)
})?;
(
@@ -122,17 +104,13 @@ pub fn load() -> Result<Vec<Plugin>> {
struct MarketplaceEntry {
src: String,
version: String,
- /// Override path to marketplace.json within the repo.
manifest: Option<String>,
}
#[derive(Deserialize)]
struct PluginEntry {
- /// Git repo URL. Required if no marketplace.
src: Option<String>,
- /// Git version (branch/tag). Required if no marketplace (optional override with marketplace).
version: Option<String>,
- /// Name of a marketplace defined in the config.
marketplace: Option<String>,
}
diff --git a/src/git.rs b/src/git.rs
index a96574f..dc49149 100644
--- a/src/git.rs
+++ b/src/git.rs
@@ -3,19 +3,25 @@ use std::process::Command;
use color_eyre::eyre::{Context, Result, bail};
-/// Derive a local repo path from a remote URL.
-/// e.g. "https://github.com/user/repo" -> repos_dir/github.com/user/repo
+/// Derive a bare repo path from a remote URL.
+/// e.g. "https://github.com/user/repo" -> repos_dir/github.com/user/repo.git
pub fn repo_path(repos_dir: &Path, src: &str) -> PathBuf {
let url = src
.strip_prefix("https://")
.or_else(|| src.strip_prefix("http://"))
.unwrap_or(src)
.trim_end_matches(".git");
- repos_dir.join(url)
+ repos_dir.join(format!("{url}.git"))
+}
+
+/// Path to a worktree for a specific revision.
+/// e.g. repos_dir/github.com/user/repo.git/worktrees/<rev_short>
+pub fn worktree_path(repo_path: &Path, rev: &str) -> PathBuf {
+ repo_path.join("pinch-worktrees").join(&rev[..12])
}
pub fn clone_or_fetch(repo_path: &Path, src: &str) -> Result<()> {
- if repo_path.join(".git").is_dir() {
+ if repo_path.join("HEAD").is_file() {
let status = Command::new("git")
.args(["fetch", "--quiet", "origin"])
.current_dir(repo_path)
@@ -28,7 +34,7 @@ pub fn clone_or_fetch(repo_path: &Path, src: &str) -> Result<()> {
std::fs::create_dir_all(repo_path)
.with_context(|| format!("failed to create {}", repo_path.display()))?;
let status = Command::new("git")
- .args(["clone", "--quiet", src, "."])
+ .args(["clone", "--quiet", "--bare", src, "."])
.current_dir(repo_path)
.status()
.context("failed to run git clone")?;
@@ -40,6 +46,7 @@ pub fn clone_or_fetch(repo_path: &Path, src: &str) -> Result<()> {
}
pub fn resolve_ref(repo_path: &Path, git_ref: &str) -> Result<String> {
+ // Try origin/<ref> first (branch)
let output = Command::new("git")
.args(["rev-parse", &format!("origin/{git_ref}")])
.current_dir(repo_path)
@@ -68,14 +75,42 @@ pub fn resolve_ref(repo_path: &Path, git_ref: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
-pub fn checkout(repo_path: &Path, rev: &str) -> Result<()> {
- let status = Command::new("git")
- .args(["checkout", "--quiet", "--detach", rev])
- .current_dir(repo_path)
- .status()
- .context("failed to run git checkout")?;
- if !status.success() {
- bail!("git checkout failed for {} in {}", rev, repo_path.display());
+/// Ensure a worktree exists at the given rev and return its path.
+pub fn ensure_worktree(repo_path: &Path, rev: &str) -> Result<PathBuf> {
+ let wt_path = worktree_path(repo_path, rev);
+
+ if wt_path.join(".git").exists() {
+ // Worktree already exists, make sure it's at the right rev
+ let status = Command::new("git")
+ .args(["checkout", "--quiet", "--detach", rev])
+ .current_dir(&wt_path)
+ .status()
+ .context("failed to run git checkout in worktree")?;
+ if !status.success() {
+ bail!("git checkout failed for {} in {}", rev, wt_path.display());
+ }
+ } else {
+ std::fs::create_dir_all(wt_path.parent().unwrap())?;
+ let status = Command::new("git")
+ .args([
+ "worktree",
+ "add",
+ "--quiet",
+ "--detach",
+ &wt_path.to_string_lossy(),
+ rev,
+ ])
+ .current_dir(repo_path)
+ .status()
+ .context("failed to run git worktree add")?;
+ if !status.success() {
+ bail!(
+ "git worktree add failed for {} in {}",
+ rev,
+ repo_path.display()
+ );
+ }
}
- Ok(())
+
+ Ok(wt_path)
}
diff --git a/src/main.rs b/src/main.rs
index 17e19f5..b62878a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -105,13 +105,10 @@ fn do_update(names: &[String]) -> Result<()> {
let rev = git::resolve_ref(&repo_path, &plugin.version)?;
info!(" {} -> {}", plugin.version, &rev[..12]);
- // Checkout to read marketplace.json and discover skill path
- git::checkout(&repo_path, &rev)?;
- let source = marketplace::find_plugin_source(
- &repo_path,
- &plugin.name,
- plugin.marketplace.as_deref(),
- )?;
+ // Create worktree to read marketplace.json and discover skill path
+ let wt_path = git::ensure_worktree(&repo_path, &rev)?;
+ let source =
+ marketplace::find_plugin_source(&wt_path, &plugin.name, plugin.marketplace.as_deref())?;
let path = source.trim_start_matches("./").to_string();
info!(" found at {}", path);
@@ -145,9 +142,9 @@ fn do_install() -> Result<()> {
for plugin in &lockfile.plugins {
let repo_path = git::repo_path(&repos_dir, &plugin.src);
- git::checkout(&repo_path, &plugin.rev)?;
+ let wt_path = git::ensure_worktree(&repo_path, &plugin.rev)?;
- let skill_source = repo_path.join(&plugin.path);
+ let skill_source = wt_path.join(&plugin.path);
if !skill_source.is_dir() {
bail!(
"plugin '{}': path '{}' does not exist in repo at rev {}",