Skip to content

fix(bundler): bundle update uninstalls components dropped by new version#3353

Merged
mnriem merged 1 commit into
github:mainfrom
jawwad-ali:fix/bundle-update-orphaned-components
Jul 7, 2026
Merged

fix(bundler): bundle update uninstalls components dropped by new version#3353
mnriem merged 1 commit into
github:mainfrom
jawwad-ali:fix/bundle-update-orphaned-components

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Description

When specify bundle update refreshes a bundle to a new version that drops a component, that component is permanently orphaned.

install_bundle iterates only plan.components (the new version's set). A component the previous version owned but the new one no longer ships is:

  • never revisited by the loop, so it stays installed on disk, and
  • absent from the rewritten record (contributed only holds plan.components).

With no record referencing it, remove_bundle can never clean it up — it's stranded forever, violating the provenance invariant that records capture exactly what a bundle contributed (FR-022).

Reproduced on main @ bba473c with the repo's FakeInstaller: install v1 (ext-a + ext-b), update to v2 (ext-a only, refresh=True) → remove_calls is empty, ext-b stays in installed, and the record lists only ext-a.

Fix

After the component loop, when refresh is True and a prior record exists, uninstall each previously-owned component absent from the new plan — unless another bundle still needs it (components_still_needed refcount, mirroring remove_bundle), in which case it stays installed and is simply de-attributed from this bundle. The removal runs inside the existing try, so a failure takes the same bounded-rollback / no-record-written path as the rest of the install.

Testing

New tests in test_bundler_install_flow.py:

  • test_update_uninstalls_components_dropped_by_new_version: v1(ext-a,ext-b) → v2(ext-a) → ext-b is in remove_calls and result.uninstalled, gone from installed, and dropped from the record. Fails before (ext-b orphaned — verified by source-stash), passes after.
  • test_update_keeps_component_still_needed_by_sibling_bundle: a sibling bundle also owns ext-b → the update does not remove it; it stays installed and is only de-attributed from the updated bundle.

Full test_bundler_install_flow.py: 13 passed. uvx ruff check clean.

AI Disclosure

  • I did use AI assistance (describe below)

Found and fixed with Claude Code (Claude Fable 5) under my direction. AI traced the orphaning to the refresh path skipping dropped components; I reproduced it with FakeInstaller, mirrored remove_bundle's refcount logic, verified fail-before/pass-after including the shared-component case, and reviewed the diff.

On refresh (bundle update), install_bundle iterated only the new plan's
components, so a component the previous version owned but the new one no
longer ships was left installed on disk while being dropped from the
rewritten record (contributed only holds plan.components). With no
record referencing it, remove_bundle could never clean it up —
permanently orphaned, violating the provenance invariant (FR-022). After
the component loop, when refresh and a prior record exists, uninstall
each previously-owned component absent from the new plan — unless another
bundle still needs it (components_still_needed refcount, mirroring
remove_bundle), in which case it stays installed and is simply
de-attributed. Runs inside the existing try so a failed removal takes the
same no-record-written rollback path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a bundler provenance/lifecycle bug where specify bundle update (via install_bundle(..., refresh=True)) could leave components from the prior bundle version installed on disk but no longer referenced by any installed-bundle record, making them impossible to clean up later.

Changes:

  • Extend install_bundle’s refresh/update flow to uninstall previously-owned components that are no longer present in the new install plan (unless still required by another bundle’s record).
  • Add integration tests covering (1) dropped components are uninstalled and removed from the updated record, and (2) dropped-but-still-shared components remain installed and are de-attributed from the updated bundle.
Show a summary per file
File Description
src/specify_cli/bundler/services/installer.py On refresh updates, removes components that were previously attributed to the bundle but are absent from the new plan, respecting “still needed by other bundles” refcount logic.
tests/integration/test_bundler_install_flow.py Adds tests validating correct uninstall/de-attribution behavior when updating to a bundle version that drops components, including shared-component scenarios.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Low

@mnriem mnriem merged commit d5ba062 into github:main Jul 7, 2026
12 checks passed
@mnriem

mnriem commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Thank you!

kanfil added a commit to tikalk/agentic-sdlc-spec-kit that referenced this pull request Jul 8, 2026
Upstream commits merged (9):

- 0151d23 agy honors SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (github#3347)

- fb796c2 shell step validate() rejects non-string run (github#3348)

- 220e6fc fan-in validate() rejects non-mapping output (github#3349)

- 12faf7b workflow run/resume errors to stderr under --json (github#3352)

- d5ba062 bundle update uninstalls dropped components (github#3353)

- f1a8d8f release 0.12.7, begin 0.12.8.dev0 (github#3398)

- 10d4bca guard _sha256 against unreadable managed files (github#3376)

- a307894 return None on malformed GHES port (github#3379)

- 882e1e9 exit cleanly on malformed IPv6 URLs in add commands (github#3369)

Conflict: presets/_commands.py — combined IPv6 try/except + _esc()

with fork accent() theming.

Assisted-by: OpenCode (model: glm-5.2, autonomous)
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.

3 participants