Summary
ResourceRefCounter.Resource has exactly two cases, .brewPackage and .plugin. Gitignore entries belong in that set and are missing from it.
GitignoreManager.resolveGlobalGitignorePath() resolves a single file for the whole machine — git config core.excludesFile, falling back to ~/.config/git/ignore. There is no per-project gitignore. Yet both ProjectSyncStrategy.installArtifacts and GlobalSyncStrategy.installArtifacts record the same entries into their own PackArtifactRecord.gitignoreEntries, so a pack installed in two scopes has two claims on one physical line — and Configurator.unconfigurePack deletes it on behalf of whichever scope is torn down first, with no check for the others.
Affected paths
All four route through Configurator.removeGitignoreArtifact, none of them ref-counted:
unconfigurePack — deselecting a pack in mcs sync while another scope still has it
removeNewlyExcludedComponentArtifacts — excluding the component via --customize
reconcileStaleArtifacts — stale-artifact cleanup
mcs pack remove — worst case: the federated loop runs unconfigurePack once per affected scope, so the first scope strips the shared lines out from under every scope that follows, and their removals silently no-op
Symptom
An ignore line disappears while a pack still claims it, so previously-ignored files begin showing up in git status — across every project on the machine, since the file is global. Sync is convergent, so the next mcs sync --global or mcs update restores it; until then the state is wrong.
Proposed fix
Add case gitignoreEntry(String) to ResourceRefCounter.Resource:
Resource.displayName — a case for the new kind
checkGlobalArtifacts — match against PackArtifactRecord.gitignoreEntries
packDeclaresResource — match against the .gitignoreEntries(entries:) install action
Configurator.removeGitignoreArtifact — return the same .removed / .stillNeeded / .failed tri-state as its siblings removeBrewArtifact and removePluginArtifact, and update the call sites to the switch those already model
One detail makes this safer than it looks for the other resource kinds: .gitignoreEntries(entries: [String]) carries a literal payload with no placeholder substitution (unlike MCPServerConfig.substituting), so declaration-based matching in packDeclaresResource is exact.
Known wrinkle, matching existing behavior rather than fixing it: checkProjectIndex does not consult per-scope excludedComponents, so a scope that excluded the gitignore component still counts as a referent. That biases toward keeping a line, which is the same conservative bias brew packages and plugins already accept, and the safer direction here.
Cleanup this enables
PR #377 compensates for this on the doctor path only — ScopeDuplicationCheck.restoreSharedGitignoreEntries re-adds whatever the global record still claims after unconfigurePack has stripped it. That workaround and its comment should be deleted when this lands. Its test (fixPreservesSharedGitignoreEntries) asserts the entry survives, which stays true, so it converts into a regression guard for the primitive instead of for the workaround.
Acceptance criteria
Summary
ResourceRefCounter.Resourcehas exactly two cases,.brewPackageand.plugin. Gitignore entries belong in that set and are missing from it.GitignoreManager.resolveGlobalGitignorePath()resolves a single file for the whole machine —git config core.excludesFile, falling back to~/.config/git/ignore. There is no per-project gitignore. Yet bothProjectSyncStrategy.installArtifactsandGlobalSyncStrategy.installArtifactsrecord the same entries into their ownPackArtifactRecord.gitignoreEntries, so a pack installed in two scopes has two claims on one physical line — andConfigurator.unconfigurePackdeletes it on behalf of whichever scope is torn down first, with no check for the others.Affected paths
All four route through
Configurator.removeGitignoreArtifact, none of them ref-counted:unconfigurePack— deselecting a pack inmcs syncwhile another scope still has itremoveNewlyExcludedComponentArtifacts— excluding the component via--customizereconcileStaleArtifacts— stale-artifact cleanupmcs pack remove— worst case: the federated loop runsunconfigurePackonce per affected scope, so the first scope strips the shared lines out from under every scope that follows, and their removals silently no-opSymptom
An ignore line disappears while a pack still claims it, so previously-ignored files begin showing up in
git status— across every project on the machine, since the file is global. Sync is convergent, so the nextmcs sync --globalormcs updaterestores it; until then the state is wrong.Proposed fix
Add
case gitignoreEntry(String)toResourceRefCounter.Resource:Resource.displayName— a case for the new kindcheckGlobalArtifacts— match againstPackArtifactRecord.gitignoreEntriespackDeclaresResource— match against the.gitignoreEntries(entries:)install actionConfigurator.removeGitignoreArtifact— return the same.removed/.stillNeeded/.failedtri-state as its siblingsremoveBrewArtifactandremovePluginArtifact, and update the call sites to the switch those already modelOne detail makes this safer than it looks for the other resource kinds:
.gitignoreEntries(entries: [String])carries a literal payload with no placeholder substitution (unlikeMCPServerConfig.substituting), so declaration-based matching inpackDeclaresResourceis exact.Known wrinkle, matching existing behavior rather than fixing it:
checkProjectIndexdoes not consult per-scopeexcludedComponents, so a scope that excluded the gitignore component still counts as a referent. That biases toward keeping a line, which is the same conservative bias brew packages and plugins already accept, and the safer direction here.Cleanup this enables
PR #377 compensates for this on the doctor path only —
ScopeDuplicationCheck.restoreSharedGitignoreEntriesre-adds whatever the global record still claims afterunconfigurePackhas stripped it. That workaround and its comment should be deleted when this lands. Its test (fixPreservesSharedGitignoreEntries) asserts the entry survives, which stays true, so it converts into a regression guard for the primitive instead of for the workaround.Acceptance criteria
mcs pack removeremoves a shared line exactly once, after the last scope releases itResourceRefCounterTestscovers the new caseScopeDuplicationCheck.restoreSharedGitignoreEntriesand its explanatory comment are removed