From f32a88dbcabfd2cddd49dc33c777e9b70868baa8 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 25 Mar 2026 19:03:45 +0100 Subject: [PATCH] Fix orphaned fileHashes blocking pack removal in unconfigurePack - Purge fileHashes entries without corresponding files entries after file removal in unconfigurePack(), fixing packs stuck in "could not be removed" state on every sync - Add test seeding orphaned fileHashes to verify full pack removal --- Sources/mcs/Install/Configurator.swift | 4 +++ Tests/MCSTests/GlobalConfiguratorTests.swift | 28 ++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Sources/mcs/Install/Configurator.swift b/Sources/mcs/Install/Configurator.swift index 894fca4..a9d19ca 100644 --- a/Sources/mcs/Install/Configurator.swift +++ b/Sources/mcs/Install/Configurator.swift @@ -317,6 +317,10 @@ struct Configurator { removedFiles.insert(path) } remaining.files.removeAll { removedFiles.contains($0) } + // Purge fileHashes: remove hashes for deleted files and any orphaned entries + // (entries whose key has no corresponding files entry, e.g. from past state inconsistencies) + let trackedFiles = Set(remaining.files) + remaining.fileHashes = remaining.fileHashes.filter { trackedFiles.contains($0.key) } // Remove auto-derived hook commands and contributed settings keys let hasHooksToRemove = !artifacts.hookCommands.isEmpty diff --git a/Tests/MCSTests/GlobalConfiguratorTests.swift b/Tests/MCSTests/GlobalConfiguratorTests.swift index 408a106..19165e9 100644 --- a/Tests/MCSTests/GlobalConfiguratorTests.swift +++ b/Tests/MCSTests/GlobalConfiguratorTests.swift @@ -899,6 +899,34 @@ struct GlobalUnconfigurePackTests { #expect(state.configuredPacks.contains("pack-b")) #expect(!state.configuredPacks.contains("pack-a")) } + + @Test("unconfigurePack clears orphaned fileHashes and fully removes pack from state") + func unconfigureClearsOrphanedFileHashes() throws { + let tmpDir = try makeGlobalTmpDir() + defer { try? FileManager.default.removeItem(at: tmpDir) } + + // Seed state with a pack that has orphaned fileHashes (entries without + // corresponding files entries) — reproduces the bug where unconfigure + // could never clear such packs because PackArtifactRecord.isEmpty + // checks fileHashes.isEmpty. + let env = Environment(home: tmpDir) + var state = try ProjectState(stateFile: env.globalStateFile) + state.recordPack("orphan-pack") + var record = PackArtifactRecord() + record.fileHashes = [ + "hooks/orphan-pack/hook.sh": "abc123", + "skills/orphan-skill/SKILL.md": "def456", + ] + state.setArtifacts(record, for: "orphan-pack") + try state.save() + + let configurator = makeGlobalConfigurator(home: tmpDir) + try configurator.configure(packs: [], confirmRemovals: false) + + let after = try ProjectState(stateFile: env.globalStateFile) + #expect(!after.configuredPacks.contains("orphan-pack")) + #expect(after.artifacts(for: "orphan-pack") == nil) + } } // MARK: - Global Dry Run Tests