Skip to content

Plain interactive 'mcs sync' wipes component exclusions chosen via --customize #363

Description

@bguidolim

Problem

Component exclusions chosen via mcs sync --customize are silently discarded — and erased from state — by the next plain interactive mcs sync.

Configurator.interactiveConfigure (Sources/mcs/Sync/Configurator.swift:128-135) hardcodes an empty exclusion set and only populates it when --customize is passed:

var excludedComponents: [String: Set<String>] = [:]
if customize, !selectedPacks.isEmpty {
    excludedComponents = ConfiguratorSupport.selectComponentExclusions(...)
}

previousState is read only to seed the picker's checkboxes inside selectComponentExclusions — never to carry exclusions forward when the picker doesn't run.

Downstream, installAndReconcileArtifacts writes that empty set straight back to state:

// Configurator.swift:832
state.setExcludedComponents(excluded, for: pack.identifier)

So a plain mcs sync reinstalls every previously-excluded component and wipes the record that they were ever excluded.

Inconsistent with the non-interactive paths

--pack / --all get this right — SyncCommand loads persisted exclusions before delegating:

// SyncCommand.swift:87-89 (global), :145-147 (project)
persistedExclusions = try ProjectState(...).allExcludedComponents

and passes them at SyncCommand.swift:104 / :162. Only the interactive non-customize path drops them. Both scopes (project and --global) are affected, since interactiveConfigure is shared.

Reproduction

  1. mcs sync --customize, deselect a component of some pack
  2. mcs sync (plain, interactive, same pack selection)
  3. The deselected component is reinstalled, and .claude/.mcs-project no longer lists it under excludedComponents

Fix sketch

Seed from persisted state when the picker doesn't run:

var excludedComponents = previousState.allExcludedComponents
if customize, !selectedPacks.isEmpty {
    excludedComponents = ConfiguratorSupport.selectComponentExclusions(...)
}

Newly-selected packs contribute no entry, so they install fully — correct. Deselected packs are removed by unconfigurePack, which already clears their state entry (ProjectState.swift:162).

Worth confirming while fixing: removeNewlyExcludedComponentArtifacts (Configurator.swift:538-545) diffs previousExcluded vs currentExcluded. With the fix those are equal on a plain sync, so it correctly becomes a no-op.

Tests

Tests/MCSTests/LifecycleIntegrationTests.swift:

  • customize-exclude a component → plain re-sync → component still absent, still recorded in excludedComponents
  • customize-exclude → plain re-sync → --customize again shows the component unchecked
  • newly added pack on a plain sync installs all its components (regression guard: persisted exclusions from other packs must not leak)

Relation to #356

#356 (requiredBy on prompts) depends on the selected-component set being stable across syncs — a prompt skipped under --customize would otherwise be re-asked on the next plain sync. This should land first.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal prioritybugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions