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
change nwkmzmqzxkpmunnmrvvkxxrmlmztnxzp
commit a66c8d5806efe0e6d870d357f14a3b9971a98045
author Alpha Chen <alpha@kejadlen.dev>
date
parent rlovynnr
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 {}",