ISSUE-378: Reference-count gitignore entries before removing them - #384
Conversation
- Treat a gitignore entry as a shared resource: `GitignoreManager` resolves one file per machine, so a pack in two scopes held two claims on one line and the first scope torn down deleted it out from under the other - Protect `GitignoreManager.coreEntries` outright — no pack record owns them, so ref counting alone could never vouch for them - Drop the doctor-only workaround that re-added stripped lines, now that the removal primitive is correct on every path Claude-Session: https://claude.ai/code/session_018DxgSbTvRtshXfmVZp1cKo
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate reference-counting defects can leave gitignore state incorrect.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds reference counting for globally shared gitignore entries.
Changes:
- Protects entries claimed by other scopes or packs.
- Preserves core mcs ignore entries.
- Caches shared state during removal.
- Removes the doctor restoration workaround.
- Adds documentation and regression tests.
File summaries
| File | Review |
|---|---|
Tests/MCSTests/ResourceRefCounterTests.swift |
Adds unit coverage for gitignore reference decisions. |
Tests/MCSTests/LifecycleIntegrationTests.swift |
Adds lifecycle coverage for shared-entry removal. |
Sources/mcs/Sync/ResourceRefCounter.swift |
Adds gitignore reference counting and caching. Moderate (1 vote): skipping the entire current project can miss another pack’s claim and incorrectly delete a shared entry. |
Sources/mcs/Sync/Configurator.swift |
Applies reference counting during cleanup. Moderate (2 votes): stale state across multiple removals can leave orphaned entries. Nit (1 vote): the message should say “another scope or pack.” |
Sources/mcs/Doctor/ScopeDuplicationCheck.swift |
Removes obsolete restoration logic. |
CLAUDE.md |
Documents shared gitignore handling. |
Review details
Suppressed comments (1)
Sources/mcs/Sync/Configurator.swift:1209
- This can also return
.stillNeededbecause another pack in the same scope declares the entry (for example, the added pack-removal test does exactly that), so “another scope” is inaccurate. Report “another scope or pack” to match the condition being checked.
output.dimmed(" Keeping gitignore entry '\(entry)' — still needed by another scope")
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| switch result { | ||
| case .removed, .stillNeeded: | ||
| removedEntries.insert(entry) |
| search: for entry in indexData.projects { | ||
| // Skip the scope being removed | ||
| if entry.path == scopePath { continue } |
- Scan the removing scope's index entry instead of skipping it wholesale, so a shared artifact survives when another pack in that same scope declares it; only the pack being unconfigured is excluded there - Keep skipping the global scope's own entry, which `checkGlobalArtifacts` already covers by ownership rather than declaration - Say "another scope or pack" in the three Keeping messages, since the referent is often a sibling pack rather than a different scope Claude-Session: https://claude.ai/code/session_018DxgSbTvRtshXfmVZp1cKo
|
Thanks — both findings verified against the code, and both were real. Same-project sibling ( Worth noting the gap was not gitignore-specific: on the same path, removing a pack from a project could already uninstall a brew package that a sibling pack in that project still declared. That is fixed too. Two regression tests were added. One asserts the sibling keeps the entry — it fails against the pre-fix source, verified by running it against the previous commit. The other asserts the pack being removed is not its own referent, which is the failure mode the fix introduces if the pack-id exclusion is ever dropped: without it nothing would be removable and every "keeps the line" test would still pass. Wording nit — fixed. Applied to the brew and plugin messages as well, which carried the same inaccuracy and would otherwise have drifted apart. Reciprocal stale reference during multi-pack removal — confirmed, and filed as #385 rather than fixed here. The premise holds: the removal loop does not save between packs, so each |
There was a problem hiding this comment.
🟡 Changes recommended
Multi-pack removal can permanently retain an unclaimed gitignore entry, and the associated regression path is not covered.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
Sources/mcs/Sync/Configurator.swift:1209
- For a protected core entry with no other claimant, this message says another scope or pack needs it even though
isStillNeededreturned true solely becauseisProtecteddid. Emit a distinct “core entry managed by mcs” message before the reference-count check so users are told the actual reason the line was retained.
output.dimmed(" Keeping gitignore entry '\(entry)' — still needed by another scope or pack")
Sources/mcs/Sync/ResourceRefCounter.swift:178
- Scanning the removing scope creates the same stale-peer problem for project syncs: if two packs in one project share a resource and are deselected together, each unconfigure call reads the unchanged index and counts the other pack, so neither removes the now-unclaimed resource. Exclude all packs pending removal in this convergence pass, while still allowing genuinely surviving siblings to count.
if otherPackID == packID,
entry.path == scopePath || scopePath == ProjectIndex.packRemoveSentinel {
continue
}
if packDeclaresResource(packID: otherPackID, resource: resource) {
Tests/MCSTests/LifecycleIntegrationTests.swift:2906
- This bypasses the actual
mcs pack removeorchestration: it invokes one configurator directly and never exercises the global-plus-project federated loop or its final index update. The acceptance criterion about releasing the line only after the last scope therefore remains uncovered. Configure the same pack in both scopes and drive the command-level removal path (or an extracted federated-removal helper).
var state = try bed.projectState()
bed.makeConfigurator(registry: registry).unconfigurePack(
"pack-a", state: &state, refCountScope: ProjectIndex.packRemoveSentinel
)
Tests/MCSTests/LifecycleIntegrationTests.swift:2837
- This newly added suite documentation describes the old bug as current behavior: after this change, deselection should not strip the line for a later sync to restore it. Rephrase this as historical context or explain only that the two cleanup paths use different orchestration.
/// Both entry points are exercised separately — `mcs sync` deselection restores stripped lines on
/// the next sync of the surviving scope, but `mcs pack remove` calls `unconfigurePack` directly
/// and runs no step that would add anything back, so a miss there is permanent.
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Balanced
| case let .gitignoreEntry(entry): | ||
| if artifacts.gitignoreEntries.contains(entry) { return true } |
- A core entry is retained because mcs owns it, not because another claimant exists; say so instead of sending the user looking for one - Correct a test suite comment that described pre-fix deselection behaviour as though it were current Claude-Session: https://claude.ai/code/session_018DxgSbTvRtshXfmVZp1cKo
Summary
A pack synced into both a project and the global scope records the same gitignore lines in two
places, but git only has one such file per machine. Removing the pack from either scope deleted
those lines outright, so files that were previously ignored started appearing in
git statusacross every repo on the machine.
mcs syncpapered over this on the next run of the survivingscope;
mcs pack removenever re-runs the steps that would restore them, so there the loss waspermanent.
Making gitignore scope-aware is the better end state and is worth a follow-up, but its migration
has to decide whether a line already in the global file is still claimed elsewhere — which is the
primitive this PR adds.
Changes
delete it. No pack record owns them, so reference counting alone could never have vouched for them.
pass instead of once per entry. That also stops the duplicate "project not found" warnings and the
repeated index rewrites the per-entry path was producing.
mcs doctor --fixon a scope-duplicated pack no longer re-adds lines it just deleted; the removalit calls is correct on its own now.
Test plan
swift testpasses locallyswiftformat --lint .andswiftlintpass without violationsmcs sync,mcs doctor)To verify by hand with a pack declaring
gitignoreEntries:mcs sync --pack <pack>in a project, thenmcs sync --global --pack <pack>→ the entries appearonce in the global gitignore.
mcs syncin the project and deselect the pack → expect the lines to survive, withKeeping gitignore entry '<entry>' — still needed by another scopein the output.mcs doctor→ expect nomissing entriesfailure.mcs pack remove <pack>with no other pack declaring those entries → expect the lines to go.Checklist for engine changes
fix()implementation does cleanup/migration only — never installs or registers resourcesLifecycleIntegrationTestsorDoctorRunnerIntegrationTests)CLAUDE.md,docs/,techpack.yamlschema inExternalPackManifest.swift)Closes #378
https://claude.ai/code/session_018DxgSbTvRtshXfmVZp1cKo