Skip to content

fix(cli): plugin upgrade re-applies through the real apply pipeline (#231) - #252

Open
spxrogers wants to merge 7 commits into
mainfrom
claude/issue-231-apply-pipeline
Open

fix(cli): plugin upgrade re-applies through the real apply pipeline (#231)#252
spxrogers wants to merge 7 commits into
mainfrom
claude/issue-231-apply-pipeline

Conversation

@spxrogers

@spxrogers spxrogers commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #231. Fourth PR in the #226 code-quality series (after #240, #244, #247, #249). One bug fix on the plugin upgrade path, 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.go only). applyRun's body becomes runApplyPipeline(cmd, home, applyOpts), where applyOpts{dryRun, noGitBackup, agentsCSV} are exactly apply'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. apply is 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 -r empty, from a working directory outside any repository carrying its own .agentsync/.

2. The plugin re-apply is the pipeline (commit 2, 24f601f). reapplyAfterPluginChange was 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 ~/.claude with nothing for agentsync revert to undo; an unconditional applied: N ops for runs that wrote nothing; no backup pruning; no translation report. It is now a six-line delegation to runApplyPipeline(cmd, home, applyOpts{}). What the plugin path gains, each measured before→after on a fixture and documented: the git baseline + checkpoint (the Fixed bullet), the scope: line, the honest five-case headline (up to date: N ops, no changes on an idempotent upgrade), a warning instead of applied: 0 ops when no agents are enabled, backup pruning, the per-plugin translation report, apply's collision wording (printed even when the apply then fails partway), one re-apply after plugin upgrade: error prefix — and apply's git-backup policy in both forms: interactively, the one-time "enable git backup for this dir?" question can now appear during an upgrade and yes / don't ask again persists the mode to agentsync.toml; unattended under the default prompt mode, apply's hint and could not take a pre-apply baseline warning print on every run. What it must not gain: --dry-run and --agents. The latter is structurally inert (selectAgents early-returns unless the calling command reports Changed("agents"), and no plugin command registers the flag); the applyOpts.agentsCSV doc names that seam and its one residual (mcp add --agents is a same-named flag with a different meaning). plugin upgrade --no-git-backup is 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 renamed TestApplyPipelineLoadsStateAfterSourceReload, re-pointed at runApplyPipeline, split into two subtests, and gains a second half: reapplyAfterPluginChange must call runApplyPipeline and must not state.Load / render.Plan / render.Apply / state.Save on 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-level return nil at the end.

3. --lossless reporting extracted (commit 3, e168961): reportLosslessExclusions takes the 41-line announce block out of pollPluginsRun (194 → 160 lines); filterSafeBumps stays a pure partition. No test edits; the emission order the comment defends was already pinned by TestLossless_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 (pluginUpgradeRun maps the sentinel back to ""), and resolveMarketplaceName already existed. So: a defaultMarketplaceSentinel const and a recordedMarketplace(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 as name@default, which the poll engine's fetched index never keys unless a marketplace is literally registered under that name, so plugin outdated / plugin upgrade --all never 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) on plugin upgrade was undocumented; the up to date: headline was unpinned repo-wide; the sentinel helper's doc over-claimed; plus nits — fixed in 9a64e8e. Round 2 on 9a64e8e: the two guides' prompt sentence overclaimed for not 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 in 00f05e9 (incl. TestPluginUpgrade_WarnsWhenNoAgentsEnabled and TestPluginUpgrade_PrunesOldBackups). Round 3 on 00f05e9: 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 in 59c8aa1. 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 is apply's own path, pinned there; a copy would rebuild the fixture in package cli), rewording gitbackup.go's unattended hint, restructuring docs/components.md's Key list (#235), and having the older plugin_verbs setups adopt the new fixture.

Type of change

  • Bug fix
  • New feature / enhancement
  • Refactor (no behavior change)
  • Docs
  • Tests / CI / tooling

Test plan

New (internal/cli/plugin_reapply_pipeline_test.go, one fixture that deliberately never runs apply so the upgrade's re-apply is the first write): TestPluginUpgrade_TakesGitBackupBaselineAndCheckpoint (with git backup on, ~/.claude is 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, never applied: 0 ops), TestPluginUpgrade_PrunesOldBackups (DefaultBackupKeep+3 seeded backup dirs → exactly DefaultBackupKeep after the upgrade).

Rewritten (not retired): TestApplyPipelineLoadsStateAfterSourceReload (two subtests, above); TestPluginUpgradeAll_UpgradesAndReapplies / TestPluginUpgradeID_Reapplies assert the up to date: headline (the fixture's bump is version-only, so it is deterministic) and require the translation-report line plugin: demo@test-mp-v, which only the real pipeline emits; TestPluginPoll_UpgradeAllPartialFailureRescuesState pins the re-apply after plugin upgrade: prefix; TestPlugin_BareIDInstallSentinelQualifierHonest pins the on-disk demo@default form.

Break-verifies run before each commit (literal failure sets): every applyOpts field ignored inside the pipeline → the apply tests that own it; applyOpts{dryRun: true} / {noGitBackup: true} / a pipeline honouring agentsCSV at the plugin site → the new tests; a hand-rolled re-apply body → the guard's second half on every branch; the headline format changed to applied: → both plugin_verbs tests; the wrap dropped → the prefix pin; resolveMarketplaceName made identity → the on-disk pin; the zero-agents warning replaced → its test; the PruneBackups call removed → its test (and the guard stays quiet); a raw-string column-0 brace anywhere in runApplyPipeline, including between its two BuildReport calls → the guard's truncation check; the caveat moved after the unevaluable loop → TestLossless_UnevaluableIsNotReportedAsLossy. Dropping the sentinel default in recordedMarketplace fails 0 tests: a pre-existing gap reported as-is (#251), not papered over.

Secrets invariants on the final tree: 0 .Canonical() unwraps in internal/cli, no nolint delta, SubstituteCanonical call sites 5 → 4.

Gates, each commit independently and again on the head: go build, go vet, gofmt -l empty, 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-release is green (the release bar) — just is not installable in this session; every layer the recipe orchestrates (vet → build → race → e2e → bdd) was run directly as listed above, and CI's hermetic test-release job is green on the PR.
  • just lint is clean — run directly with the pinned toolchain; go.mod/go.sum untouched.

Checklist

  • Conventional commit messages with a scope (e.g. fix(secrets): …).
  • Tests added/updated for the behavior changed.
  • If this touches internal/secrets, internal/capture, or any
    source.Write* path, I've re-read the secret-handling invariants in
    CLAUDE.md / SECURITY.md and not weakened them. — Not touched; the pipeline still renders from secrets.Resolved and the extraction moves no unwrap and adds no dest→source write (counts above).
  • Docs updated if behavior, CLI surface, or capability coverage changed. — 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 dated docs/superpowers/plans/… file that names applyRun is a historical record and is left alone.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG

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
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.

Route plugin upgrade re-apply through the real apply pipeline

2 participants