Use BTreeMap for lockfile plugins
Name is the key instead of a field. BTreeMap serializes sorted,
so the explicit sort in save() is no longer needed. Selective update
simplifies from retain+push to insert.

Assisted-by: Claude Opus 4.6 via pi
change vxqmstvvmuozzspzvnxvlrtukmknlyus
commit 25d40d37eba087acddda0380f7deb3b88b18d65c
author Alpha Chen <alpha@kejadlen.dev>
date
parent wuosruzk
diff --git a/src/git.rs b/src/git.rs
index fca878e..44e80b6 100644
--- a/src/git.rs
+++ b/src/git.rs
@@ -122,7 +122,7 @@ pub fn prune_worktrees(repos_dir: &Path, lockfile: &crate::lockfile::Lockfile) -
 
     // Build a map: repo_path -> set of used rev prefixes
     let mut used: HashMap<PathBuf, HashSet<String>> = HashMap::new();
-    for plugin in &lockfile.plugins {
+    for plugin in lockfile.plugins.values() {
         let rp = repo_path(repos_dir, &plugin.src);
         used.entry(rp)
             .or_default()
diff --git a/src/lockfile.rs b/src/lockfile.rs
index 1c97c4d..cbe306f 100644
--- a/src/lockfile.rs
+++ b/src/lockfile.rs
@@ -1,15 +1,16 @@
+use std::collections::BTreeMap;
+
 use color_eyre::eyre::{Context, Result};
 use serde::{Deserialize, Serialize};
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct Lockfile {
     #[serde(default)]
-    pub plugins: Vec<LockedPlugin>,
+    pub plugins: BTreeMap<String, LockedPlugin>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct LockedPlugin {
-    pub name: String,
     pub src: String,
     pub path: String,
     pub rev: String,
@@ -26,9 +27,7 @@ pub fn load() -> Result<Lockfile> {
 
 pub fn save(lockfile: &Lockfile) -> Result<()> {
     let path = crate::paths::lockfile_path()?;
-    let mut sorted = lockfile.clone();
-    sorted.plugins.sort_by(|a, b| a.name.cmp(&b.name));
-    let contents = serde_yml::to_string(&sorted).context("failed to serialize lockfile")?;
+    let contents = serde_yml::to_string(lockfile).context("failed to serialize lockfile")?;
     std::fs::write(&path, contents)
         .with_context(|| format!("failed to write lockfile: {}", path.display()))?;
     Ok(())
diff --git a/src/main.rs b/src/main.rs
index e26645a..9eee922 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -76,7 +76,12 @@ fn do_update(names: &[String]) -> Result<()> {
     let all_plugins = config::load().context("failed to load config")?;
 
     let (plugins, mut lockfile) = if names.is_empty() {
-        (all_plugins, lockfile::Lockfile { plugins: vec![] })
+        (
+            all_plugins,
+            lockfile::Lockfile {
+                plugins: Default::default(),
+            },
+        )
     } else {
         let known: std::collections::HashSet<&str> =
             all_plugins.iter().map(|p| p.name.as_str()).collect();
@@ -121,17 +126,14 @@ fn do_update(names: &[String]) -> Result<()> {
         let path = source.trim_start_matches("./").to_string();
         info!("  found at {}", path);
 
-        // Remove existing entry for this plugin if doing selective update
-        if !names.is_empty() {
-            lockfile.plugins.retain(|p| p.name != plugin.name);
-        }
-
-        lockfile.plugins.push(lockfile::LockedPlugin {
-            name: plugin.name.clone(),
-            src: plugin.src.clone(),
-            path,
-            rev,
-        });
+        lockfile.plugins.insert(
+            plugin.name.clone(),
+            lockfile::LockedPlugin {
+                src: plugin.src.clone(),
+                path,
+                rev,
+            },
+        );
     }
 
     lockfile::save(&lockfile).context("failed to save lockfile")?;
@@ -146,7 +148,7 @@ fn do_install() -> Result<()> {
     let skills_dir = paths::skills_dir()?;
     let cache_dir = paths::cache_dir();
 
-    for plugin in &lockfile.plugins {
+    for (name, plugin) in &lockfile.plugins {
         let repo_path = git::repo_path(&repos_dir, &plugin.src);
         let wt_path = git::ensure_worktree(&repo_path, &plugin.rev)?;
 
@@ -154,7 +156,7 @@ fn do_install() -> Result<()> {
         if !skill_source.is_dir() {
             bail!(
                 "plugin '{}': path '{}' does not exist in repo at rev {}",
-                plugin.name,
+                name,
                 plugin.path,
                 &plugin.rev[..12]
             );
@@ -164,12 +166,12 @@ fn do_install() -> Result<()> {
         if !skill_source.join("SKILL.md").is_file() {
             bail!(
                 "plugin '{}': path '{}' has no SKILL.md — only skills are supported",
-                plugin.name,
+                name,
                 plugin.path
             );
         }
 
-        let symlink_path = skills_dir.join(&plugin.name);
+        let symlink_path = skills_dir.join(name);
 
         // Remove existing symlink if present
         if symlink_path.symlink_metadata().is_ok() {
@@ -189,13 +191,9 @@ fn do_install() -> Result<()> {
             )
         })?;
 
-        info!("installed {} -> {}", plugin.name, skill_source.display());
+        info!("installed {} -> {}", name, skill_source.display());
     }
 
-    // Clean up stale pinch-managed symlinks
-    let locked_names: std::collections::HashSet<_> =
-        lockfile.plugins.iter().map(|p| p.name.as_str()).collect();
-
     for entry in std::fs::read_dir(&skills_dir)? {
         let entry = entry?;
         let path = entry.path();
@@ -211,7 +209,7 @@ fn do_install() -> Result<()> {
         // Only touch symlinks pointing into our cache
         if let Ok(target) = std::fs::read_link(&path)
             && target.starts_with(&cache_dir)
-            && !locked_names.contains(name_str.as_ref())
+            && !lockfile.plugins.contains_key(name_str.as_ref())
         {
             std::fs::remove_file(&path)?;
             info!("removed stale symlink: {}", name_str);