fix: make plugin update atomic + skip submodule recursion by gzb1128 · Pull Request #15 · gzb1128/opencode-plugin · GitHub
Skip to content

fix: make plugin update atomic + skip submodule recursion - #15

Merged
gzb1128 merged 2 commits into
mainfrom
fix/corrupted-clone-recovery
Jun 17, 2026
Merged

fix: make plugin update atomic + skip submodule recursion#15
gzb1128 merged 2 commits into
mainfrom
fix/corrupted-clone-recovery

Conversation

@gzb1128

@gzb1128 gzb1128 commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Summary

Two related fixes that make plugin update robust against failures that previously left users with no plugin and no recovery path.

1. Atomic plugin update with rollback on download failure

Problem: plugin update ran Remove() then Install() in sequence. If Install failed (network error, git clone failure, disk full), the old version was already gone — symlinks removed, cache deleted, install record wiped. The user had to manually reinstall.

Fix: Replace with installer.Update() which does a two-phase commit:

Stage Action Failure behavior
1. materialize Rename old cache to .update-backup, clone/copy new version into the original path Rename .update-backup back; old plugin fully preserved
2. swap Remove old symlinks/MCP, create new symlinks/MCP, overwrite install record Local FS ops only; warnings on non-fatal errors
3. cleanup Remove .update-backup, CleanupOldVersions for stale siblings

Network I/O only happens in Stage 1, before any destructive operation. Verified live: superpowers SSH-auth failure during update rolled back cleanly (✓ Rolled back to previous version).

Detail: EvalSymlinks(oldCachePath) is captured before the rename, because symlink targets are stored in evaluated form (macOS /var/private/var) and the rename breaks later EvalSymlinks lookups in RemoveSymlinks.

2. Skip submodule recursion when cloning plugin source

Problem: superpowers repo declares a submodule with an SSH URL:

[submodule "evals"]
    url = git@github.com:prime-radiant-inc/superpowers-evals.git

RecurseSubmodules: git.DefaultSubmoduleRecursionDepth made go-git try to clone that submodule via its built-in SSH client (no ssh-agent access) → ssh: handshake failed → entire plugin update failed.

Fix: Drop RecurseSubmodules from cloneGitSource and cloneGitSubdirSource. Default (NoRecurseSubmodules) applies. Plugin runtime content (skills/commands/agents/MCP) never lives in submodules — they are typically evals/test fixtures.

Verification

  • New unit tests (4): version bump + symlink swap, materialize-failure rollback, same-version content refresh, disabled-state preservation
  • go test ./... — all pass, including e2e
  • go vet ./..., go build ./... — clean
  • Manual: live plugin update on 5 plugins — superpowers rolled back on SSH failure (pre-fix behavior was data loss), then updated cleanly after submodule fix landed (14 skills installed)

Test plan

  • go test ./internal/plugin/... passes locally
  • opencode-plugin plugin update <some-plugin> with healthy source — version bumps, old cache cleaned
  • opencode-plugin plugin update <some-plugin> with broken source (delete source dir or simulate network failure) — old plugin still works after error
  • opencode-plugin plugin update (bulk) — continues past failures, reports Updated N, M failed

gzb1128 added 2 commits June 17, 2026 11:21
plugin update used to call Remove() then Install() in sequence. If the
Install step failed (network error, git clone failure, disk full), the
old version was already gone — symlinks removed, cache deleted, install
record wiped — leaving the user with no plugin and no recovery path
except manual reinstall.

Replace with installer.Update() which does a two-phase commit:

  Stage 1 (materialize): rename old cache to .update-backup, then
    clone/copy the new version into the original cache path. If this
    fails, rename .update-backup back — old plugin fully preserved.
  Stage 2 (swap): remove old symlinks/MCP, create new symlinks/MCP,
    overwrite install record. Local filesystem ops only.
  Stage 3 (cleanup): remove .update-backup and any other stale version
    caches via CleanupOldVersions.

EvalSymlinks the old cache path before the rename so RemoveSymlinks
can lexically match symlink targets (macOS resolves /var to /private/var
when symlinks are created, but the rename breaks later EvalSymlinks).

Add 4 tests covering version bump + symlink swap, materialize-failure
rollback, same-version content refresh, and disabled-state preservation.
Some plugin repos declare submodules with SSH URLs (e.g. superpowers
vendors prime-radiant-inc/superpowers-evals via git@github.com:...).
go-git's PlainClone with RecurseSubmodules tried to fetch those via its
built-in SSH client, which has no access to ssh-agent and failed with
'ssh: handshake failed: ssh: unable to authenticate'. The whole plugin
clone failed, blocking updates.

Plugin runtime content (skills/commands/agents/MCP) never lives in
submodules — they are typically evals or test fixtures. Drop
RecurseSubmodules from cloneGitSource and cloneGitSubdirSource so the
default NoRecurseSubmodules applies.
@gzb1128
gzb1128 merged commit 0d72043 into main Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant