fix(cli): plugin upgrade re-applies through the real apply pipeline (#231) - #252
Open
spxrogers wants to merge 7 commits into
Open
fix(cli): plugin upgrade re-applies through the real apply pipeline (#231)#252spxrogers wants to merge 7 commits into
spxrogers wants to merge 7 commits into
Conversation
Pure extraction. `applyRun(cmd, home, dryRun, noGitBackup, agentsCSV)` becomes `runApplyPipeline(cmd, home, applyOpts)`, where `applyOpts` carries exactly `apply`'s three flags and its zero value is a plain real apply of every enabled agent with git backup on — the shape a caller that is not the `apply` command wants. `newApplyCmd` builds the options once and passes them down both the unlocked dry-run arm and the locked arm; the lock structure is untouched and no line of the pipeline body moves. No behaviour change: `apply`'s output, and the plugin path's, are byte-identical to the parent commit across five CLI fixture scenarios (stdout, stderr and exit status). No test is edited. Each field of the options struct is load-bearing — forcing `dryRun`, `noGitBackup` or `agentsCSV` inside the pipeline fails the existing dry-run, git-backup and `--agents` suites respectively. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…231) `plugin upgrade <id>` and `plugin upgrade --all` ended in a second, hand-maintained transcription of the apply pipeline, and the copy had fallen behind: it took no pre-apply git baseline and no post-apply checkpoint (#118/#143), so an upgrade overwrote ~/.claude, ~/.codex, … with nothing for `agentsync revert` to undo; it printed an unconditional "applied: N ops" for a run that wrote nothing; it never pruned collision backups; it had no zero-agents guard; and it printed no translation report. `reapplyAfterPluginChange` is now a six-line delegation to `runApplyPipeline(cmd, home, applyOpts{})` with one error wrap (`re-apply after plugin upgrade:`), so every apply-side invariant holds on the plugin path by construction. The plugin commands define none of `apply`'s three flags, so the zero options are the right ones: a real apply of every enabled agent, git backup governed by the configured mode. User-visible: the upgrade now announces its scope, takes the git baseline/checkpoint, reports removals and idempotent runs honestly, warns and exits 0 with no agents enabled, and prints the per-plugin translation report; under the default `prompt` git-backup mode an unattended run also prints apply's hint and baseline warning (CHANGELOG, user guide and the updating/rollback guides say so). Tests: the source guard is renamed to `TestApplyPipelineLoadsStateAfterSourceReload` and strengthened — half A holds the load-state-after-reload ordering against `runApplyPipeline`, half B requires `reapplyAfterPluginChange` to call `runApplyPipeline` and not `state.Load`/`render.Plan`/`render.Apply`/`state.Save` itself, so a hand-rolled re-apply fails by name. Two `plugin_verbs_test.go` assertions accept apply's honest `up to date:` headline and additionally require the translation report, which only the real pipeline emits. Two new behavioural pins: the upgrade's re-apply records a pre-apply baseline and a checkpoint (dropping the git-backup pass fails nothing without it), and it renders every enabled agent (`--agents` narrowing cannot leak in). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…luginsRun (#231) The 41-line `--lossless` block in pollPluginsRun was 24 lines of comment arguing why the targeting caveat is printed once, gated on the measured losses alone, and emitted between the two loops — all about the reporting, and the bulk of what made the function long. It moves verbatim into reportLosslessExclusions, a thin wrapper over filterSafeBumps, which stays the pure partition its own doc comment defends. The call site is one line; pollPluginsRun goes from 194 to 160 lines. No behaviour change, no test edit: the emission order the comment defends is already pinned by TestLossless_UnevaluableIsNotReportedAsLossy (moving the caveat after the unevaluable loop fails exactly that test). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
Three sites (computeFreshPluginSHAs, applyPluginBump, bumpIsLossy) each re-derived "the marketplace a stored plugin id names, or the sentinel" with a four-line splitPluginRef + `"" → "default"` idiom, next to a resolveMarketplaceName that already was that fold. They collapse to one line each over a new recordedMarketplace helper; the sentinel itself becomes the defaultMarketplace constant, and pluginUpgradeRun's `"default" → ""` site — the one inverse, where a real name is needed again — uses the constant and says so. No live "default" literal remains in production code; behaviour is byte-for-byte the same. Deliberately NOT fixed here: a bare-id install records "<id>@default", and the sentinel is never a key of the poll engine's fetched index (keyed by real marketplace names), so such a plugin is invisible to `plugin outdated` and `plugin upgrade --all`. That is a pre-existing bug, filed as #251; recordedMarketplace's doc comment says so rather than claiming the lookup works, and no test is added that would pin the broken path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…mpt documented, headline and prefix pinned (#231) Round 1 (four lenses on b18e8ba) found three ISSUEs and a set of NITs: - applyOpts's zero value was documented as "git backup on" in three places; it means "not opted out" — the [destination_directory_git_backup] mode still governs it, which is why an unattended run under the default `prompt` warns. apply.go, docs/architecture.md and docs/components.md say so now. - Under a TTY with the default mode, `plugin upgrade` can now ask apply's one-time "enable git backup for this dir?" question, and the answer is persisted to agentsync.toml. That is apply's policy applied identically, and it is now said in the CHANGELOG, the user guide and the updating guide rather than implied. - The `up to date: N ops, no changes` headline this PR ships was not pinned anywhere: the two plugin_verbs tests accepted either headline. The fixture's bump is version-only, so the headline is deterministic and is asserted exactly. The `re-apply after plugin upgrade:` error prefix is pinned on the partial-failure path, and the sentinel's on-disk form (`demo@default`) on a bare-id install. - recordedMarketplace's doc no longer says the sentinel is "never" a fetched-index key (a marketplace literally registered as "default" would be); defaultMarketplace is defaultMarketplaceSentinel, matching the repo's naming for values that identify nothing real. - The source guard runs its two halves as subtests, so a Fatal in one cannot hide the other, and half A now detects a truncated funcBody capture (a column-0 brace inside the function) by requiring the pipeline's last step in the captured text. - Prose: the collision warning now prints before the error check, as in apply (CHANGELOG); the two older Unreleased entries that describe the upgrade re-apply point at this change; concepts.md says "runs the full apply"; runApplyPipeline's doc drops a stale "split out" sentence and states the lock arrangement accurately; reportLosslessExclusions's doc leaves the measurement mechanism to bumpIsLossy; the reapply fixture no longer takes a boolean, the one test that needs git backup enables it itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…, honest needle, two plugin-path pins (#231) Round 2 (four lenses on 9a64e8e) found three ISSUEs, all prose or test-side, and a set of NITs: - The user guide and the updating guide said "answering it persists the mode"; only `yes` and `don't ask again` persist anything — `not now` asks again next run. Both now say exactly what the CHANGELOG says. - The source guard's truncation needle claimed PruneBackups is the pipeline's last step; the checkpoint, the headline and the translation report follow it. The needle is BuildReport — genuinely last, and behaviourally pinned by the translation-report asserts — and its message names the "moved or renamed" cause alongside the column-0 brace one. - Round 1's parenthetical inside the older `plugin upgrade re-applies` entry left that sentence claiming the re-apply is behaviour-identical to `update --apply` while pointing at the change that made it print more. The entry's tail now says what is true: it kept `update --apply`'s ending state, and #231 then made it apply's own pipeline. - Two CHANGELOG claims about the plugin path had no test: TestPluginUpgrade_WarnsWhenNoAgentsEnabled (warns and exits 0 instead of `applied: 0 ops`) and TestPluginUpgrade_PrunesOldBackups (the backup directory is pruned to DefaultBackupKeep after the re-apply). - The prefix pin reads the returned error only: the root command silences cobra's echo, so output can never carry it. - Prose: components.md states the whole zero-value claim; "callers must hold the global lock"; the sentinel const's doc drops its shout; ragged inserted lines reflowed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…es a cut anywhere, CHANGELOG tail complete (#231) Round 3 (four lenses on 00f05e9) found two prose ISSUEs and nits: - The source guard's truncation needle matched the dry-run branch's earlier BuildReport call, so a raw-string column-0 brace between the two BuildReport calls truncated the capture unnoticed (three lenses measured it). funcBody returns the text up to the first line that is just "}", so a complete capture ends with the function's own top-level `return nil`; the guard now requires exactly that, which catches a cut anywhere in the function. - The older `plugin upgrade re-applies` CHANGELOG entry's reworded tail said #231 made the re-apply "print more"; it also git-backs-up the destinations it overwrites (the Fixed bullet), so the tail says so. - The reapply fixture's doc counted "two tests" after round 2 added two; the rollback guide said "including the apply at the end of plugin upgrade" twice in ten lines; two reflow orphans; the plugin_verbs comments say the headline is asserted by its prefix and name the missing report as missing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #231. Fourth PR in the #226 code-quality series (after #240, #244, #247, #249). One bug fix on the
plugin upgradepath, one refactor that makes that class of bug impossible, and two tidies the issue asked for while in the file.1. One apply pipeline (commit 1,
3b5cd58,internal/cli/apply.goonly).applyRun's body becomesrunApplyPipeline(cmd, home, applyOpts), whereapplyOpts{dryRun, noGitBackup, agentsCSV}are exactlyapply's three flags and nothing else — a behaviour gate added there would recreate, one field at a time, the divergence this PR closes — and the zero value is a plain real apply of every enabled agent, not opted out of destination git backup (the[destination_directory_git_backup]mode still governs it). Zero test edits.applyis byte-identical, proven rather than argued: both binaries (main and commit 1) were run through five fixture scenarios / 48 steps with stdout, stderr and exit status captured per step (144 files),diff -rempty, from a working directory outside any repository carrying its own.agentsync/.2. The plugin re-apply is the pipeline (commit 2,
24f601f).reapplyAfterPluginChangewas a second, hand-maintained transcription of the pipeline that had already fallen behind: no pre-apply git baseline and no checkpoint (#118/#143), so an upgrade overwrote~/.claudewith nothing foragentsync revertto undo; an unconditionalapplied: N opsfor runs that wrote nothing; no backup pruning; no translation report. It is now a six-line delegation torunApplyPipeline(cmd, home, applyOpts{}). What the plugin path gains, each measured before→after on a fixture and documented: the git baseline + checkpoint (the Fixed bullet), thescope:line, the honest five-case headline (up to date: N ops, no changeson an idempotent upgrade), a warning instead ofapplied: 0 opswhen no agents are enabled, backup pruning, the per-plugin translation report, apply's collision wording (printed even when the apply then fails partway), onere-apply after plugin upgrade:error prefix — andapply's git-backup policy in both forms: interactively, the one-time "enable git backup for this dir?" question can now appear during an upgrade andyes/don't ask againpersists the mode toagentsync.toml; unattended under the defaultpromptmode, apply's hint andcould not take a pre-apply baselinewarning print on every run. What it must not gain:--dry-runand--agents. The latter is structurally inert (selectAgentsearly-returns unless the calling command reportsChanged("agents"), and no plugin command registers the flag); theapplyOpts.agentsCSVdoc names that seam and its one residual (mcp add --agentsis a same-named flag with a different meaning).plugin upgrade --no-git-backupis deliberately not added: the config knob is the documented answer for cron, and the non-TTY prompt fails closed.The source-text guard
TestReapplyLoadsStateAfterSourceReload(state must be loaded after the source reload that can run the subagent migration) is renamedTestApplyPipelineLoadsStateAfterSourceReload, re-pointed atrunApplyPipeline, split into two subtests, and gains a second half:reapplyAfterPluginChangemust callrunApplyPipelineand must notstate.Load/render.Plan/render.Apply/state.Saveon its own — a hand-rolled re-apply, the exact regression #231 closes, now fails by name on every count. Its capture is checked for truncation by requiring the function's own top-levelreturn nilat the end.3.
--losslessreporting extracted (commit 3,e168961):reportLosslessExclusionstakes the 41-line announce block out ofpollPluginsRun(194 → 160 lines);filterSafeBumpsstays a pure partition. No test edits; the emission order the comment defends was already pinned byTestLossless_UnevaluableIsNotReportedAsLossy.4. One spelling of the default-marketplace sentinel (commit 4,
b18e8ba). The issue's description of the idiom is wrong in a way that changes the fix: the tree has three copies of"" → "default"plus one inverse (pluginUpgradeRunmaps the sentinel back to""), andresolveMarketplaceNamealready existed. So: adefaultMarketplaceSentinelconst and arecordedMarketplace(storedID)helper; the inverse site uses the constant and says it is the inverse. Live"default"literals 6 → 0. Behaviour-preserving on purpose — measuring it surfaced a real, pre-existing bug (a bare-id-installed plugin is recorded asname@default, which the poll engine's fetched index never keys unless a marketplace is literally registered under that name, soplugin outdated/plugin upgrade --allnever see it), filed as #251 and cited from the helper's doc rather than papered over.Review provenance. The plan and execution spec were reviewed by a fresh reviewer, who replicated all four commits in probe clones and found two issues (the default-mode unattended warning was measured on the wrong fixture and undocumented; the sentinel helper's doc claimed something #251 disproves) — both fixed before execution.
Review loop (three rounds, four read-only lenses each — correctness, adversarial, API design, test rigor; commits 5–7). Round 1 on
b18e8ba: the zero-value doc said "git backup on" in three places; the interactive prompt (and its persisted config write) onplugin upgradewas undocumented; theup to date:headline was unpinned repo-wide; the sentinel helper's doc over-claimed; plus nits — fixed in9a64e8e. Round 2 on9a64e8e: the two guides' prompt sentence overclaimed fornot now; the guard's truncation needle rationale was false; round 1's parenthetical made an older CHANGELOG entry self-refuting; two CHANGELOG claims about the plugin path had no test — fixed in00f05e9(incl.TestPluginUpgrade_WarnsWhenNoAgentsEnabledandTestPluginUpgrade_PrunesOldBackups). Round 3 on00f05e9: the needle matched the dry-run branch's earlier call, so a truncation below it was invisible (three lenses measured it); the reworded CHANGELOG tail under-claimed; a stale count in the fixture doc — fixed in59c8aa1. Three of four lenses said ship it in round 3 and the remaining findings were text this loop itself wrote, so the loop stopped there with two rounds of budget unused. Declined across rounds, with reasons on the PR's ledger: a plugin-path interactive-prompt test (the prompt isapply's own path, pinned there; a copy would rebuild the fixture in packagecli), rewordinggitbackup.go's unattended hint, restructuringdocs/components.md's Key list (#235), and having the olderplugin_verbssetups adopt the new fixture.Type of change
Test plan
New (
internal/cli/plugin_reapply_pipeline_test.go, one fixture that deliberately never runsapplyso the upgrade's re-apply is the first write):TestPluginUpgrade_TakesGitBackupBaselineAndCheckpoint(with git backup on,~/.claudeis agentsync-owned with ≥2 commits, the oldest the pre-apply baseline),TestPluginUpgrade_RendersEveryEnabledAgent(two agents; both destinations carry the plugin's server),TestPluginUpgrade_WarnsWhenNoAgentsEnabled(warns, exits 0, neverapplied: 0 ops),TestPluginUpgrade_PrunesOldBackups(DefaultBackupKeep+3seeded backup dirs → exactlyDefaultBackupKeepafter the upgrade).Rewritten (not retired):
TestApplyPipelineLoadsStateAfterSourceReload(two subtests, above);TestPluginUpgradeAll_UpgradesAndReapplies/TestPluginUpgradeID_Reappliesassert theup to date:headline (the fixture's bump is version-only, so it is deterministic) and require the translation-report lineplugin: demo@test-mp-v, which only the real pipeline emits;TestPluginPoll_UpgradeAllPartialFailureRescuesStatepins there-apply after plugin upgrade:prefix;TestPlugin_BareIDInstallSentinelQualifierHonestpins the on-diskdemo@defaultform.Break-verifies run before each commit (literal failure sets): every
applyOptsfield ignored inside the pipeline → the apply tests that own it;applyOpts{dryRun: true}/{noGitBackup: true}/ a pipeline honouringagentsCSVat the plugin site → the new tests; a hand-rolled re-apply body → the guard's second half on every branch; the headline format changed toapplied:→ both plugin_verbs tests; the wrap dropped → the prefix pin;resolveMarketplaceNamemade identity → the on-disk pin; the zero-agents warning replaced → its test; thePruneBackupscall removed → its test (and the guard stays quiet); a raw-string column-0 brace anywhere inrunApplyPipeline, including between its twoBuildReportcalls → the guard's truncation check; the caveat moved after the unevaluable loop →TestLossless_UnevaluableIsNotReportedAsLossy. Dropping the sentinel default inrecordedMarketplacefails 0 tests: a pre-existing gap reported as-is (#251), not papered over.Secrets invariants on the final tree: 0
.Canonical()unwraps ininternal/cli, nonolintdelta,SubstituteCanonicalcall sites 5 → 4.Gates, each commit independently and again on the head:
go build,go vet,gofmt -lempty, gofumpt,AGENTSYNC_TEST_IN_CONTAINER=1 go test ./...(29 packages ok),go test -race ./internal/cli/...,go test -tags=e2e ./test/e2e/...,go test -tags=bdd ./test/bdd/...,GOTOOLCHAIN=go1.26.2 golangci-lint@v2.12.2 run ./...→ 0 issues.just test-releaseis green (the release bar) —justis not installable in this session; every layer the recipe orchestrates (vet → build → race → e2e → bdd) was run directly as listed above, and CI's hermetictest-releasejob is green on the PR.just lintis clean — run directly with the pinned toolchain;go.mod/go.sumuntouched.Checklist
fix(secrets): …).internal/secrets,internal/capture, or anysource.Write*path, I've re-read the secret-handling invariants inCLAUDE.md/SECURITY.mdand not weakened them. — Not touched; the pipeline still renders fromsecrets.Resolvedand the extraction moves no unwrap and adds no dest→source write (counts above).docs/architecture.md§4 (step 9 clause + "One implementation" paragraph),docs/components.md,docs/concepts.md,docs/user-guide.md(four places incl. the cron paragraph),website/src/content/docs/reference/cli.mdx,guides/updating.mdx(incl. the scheduling section),guides/rollback.mdx,CHANGELOG.md(Fixed + Changed, plus two older[Unreleased]entries reconciled). The dateddocs/superpowers/plans/…file that namesapplyRunis a historical record and is left alone.🤖 Generated with Claude Code
https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG