Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Sources/mcs/Core/ProjectState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ struct PackArtifactRecord: Codable, Equatable {
&& gitignoreEntries.isEmpty && fileHashes.isEmpty
}

/// Union of all tracked file paths across a collection of artifact records.
static func allTrackedFiles(from records: some Collection<PackArtifactRecord>) -> Set<String> {
Set(records.flatMap(\.files))
}

/// Custom decoder for backward compatibility — existing JSON files may lack
/// newer keys (brewPackages, plugins, gitignoreEntries, fileHashes).
init(from decoder: Decoder) throws {
Expand Down Expand Up @@ -162,6 +167,12 @@ struct ProjectState {
storage.mcsVersion
}

/// All file paths tracked across all configured packs.
/// Used by `DestinationCollisionResolver` to distinguish mcs-managed files from user files.
var allTrackedFiles: Set<String> {
PackArtifactRecord.allTrackedFiles(from: storage.packArtifacts.values)
}

// MARK: - Pack Artifacts

/// Get the artifact record for a pack, if any.
Expand Down
10 changes: 9 additions & 1 deletion Sources/mcs/Doctor/DoctorRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ struct DoctorRunner {
let excludedComponentIDs: Set<String>
let label: String
let artifactsByPack: [String: PackArtifactRecord]

func makeCollisionContext(environment: Environment) -> (any CollisionFilesystemContext)? {
let trackedFiles = PackArtifactRecord.allTrackedFiles(from: artifactsByPack.values)
if let root = effectiveProjectRoot {
return ProjectCollisionContext(projectPath: root, trackedFiles: trackedFiles)
}
return GlobalCollisionContext(environment: environment, trackedFiles: trackedFiles)
}
}

let environment: Environment
Expand Down Expand Up @@ -149,7 +157,7 @@ struct DoctorRunner {

let scopePacks = DestinationCollisionResolver.resolveCollisions(
packs: availablePacks.filter { scope.packIDs.contains($0.identifier) },
output: output
output: output, filesystemContext: scope.makeCollisionContext(environment: env)
)

for pack in scopePacks {
Expand Down
13 changes: 9 additions & 4 deletions Sources/mcs/Install/Configurator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,11 @@ struct Configurator {

/// Compute and display what `configure` would do, without making any changes.
func dryRun(packs: [any TechPack]) throws {
let packs = DestinationCollisionResolver.resolveCollisions(packs: packs, output: output)
let state = try ProjectState(stateFile: scope.stateFile)
let fsContext = strategy.makeCollisionContext(trackedFiles: state.allTrackedFiles)
let packs = DestinationCollisionResolver.resolveCollisions(
packs: packs, output: output, filesystemContext: fsContext
)
let headerLabel = scope.isGlobalScope ? "Plan (Global)" : "Plan"
ConfiguratorSupport.dryRunSummary(
packs: packs,
Expand All @@ -133,10 +136,12 @@ struct Configurator {
confirmRemovals: Bool = true,
excludedComponents: [String: Set<String>] = [:]
) throws {
let packs = DestinationCollisionResolver.resolveCollisions(packs: packs, output: output)
let selectedIDs = Set(packs.map(\.identifier))

var state = try ProjectState(stateFile: scope.stateFile)
let fsContext = strategy.makeCollisionContext(trackedFiles: state.allTrackedFiles)
let packs = DestinationCollisionResolver.resolveCollisions(
packs: packs, output: output, filesystemContext: fsContext
)
let selectedIDs = Set(packs.map(\.identifier))
let previousIDs = state.configuredPacks

let removals = previousIDs.subtracting(selectedIDs)
Expand Down
148 changes: 128 additions & 20 deletions Sources/mcs/Install/DestinationCollisionResolver.swift
Original file line number Diff line number Diff line change
@@ -1,35 +1,96 @@
import Foundation

/// Detects cross-pack `copyPackFile` destination collisions and applies conditional namespacing.
// MARK: - Filesystem Context

/// Provides filesystem awareness to the collision resolver for detecting
/// pre-existing user files at `copyPackFile` destinations.
protocol CollisionFilesystemContext {
/// Whether a file or directory exists at the resolved destination path.
func fileExists(destination: String, fileType: CopyFileType) -> Bool
/// Whether the destination is tracked by any pack in the current project state.
func isTrackedByPack(destination: String, fileType: CopyFileType) -> Bool
}

/// Project-scoped filesystem context — resolves paths relative to `<project>/.claude/`.
struct ProjectCollisionContext: CollisionFilesystemContext {
let projectPath: URL
let trackedFiles: Set<String>

func fileExists(destination: String, fileType: CopyFileType) -> Bool {
let baseDir = fileType.projectBaseDirectory(projectPath: projectPath)
let destURL = baseDir.appendingPathComponent(destination)
return FileManager.default.fileExists(atPath: destURL.path)
}

func isTrackedByPack(destination: String, fileType: CopyFileType) -> Bool {
let baseDir = fileType.projectBaseDirectory(projectPath: projectPath)
let destURL = baseDir.appendingPathComponent(destination)
let relPath = PathContainment.relativePath(of: destURL.path, within: projectPath.path)
return trackedFiles.contains(relPath)
}
}

/// Global-scoped filesystem context — resolves paths relative to `~/.claude/`.
struct GlobalCollisionContext: CollisionFilesystemContext {
let environment: Environment
let trackedFiles: Set<String>

func fileExists(destination: String, fileType: CopyFileType) -> Bool {
let destURL = fileType.destinationURL(in: environment, destination: destination)
return FileManager.default.fileExists(atPath: destURL.path)
}

func isTrackedByPack(destination: String, fileType: CopyFileType) -> Bool {
let destURL = fileType.destinationURL(in: environment, destination: destination)
let relPath = PathContainment.relativePath(
of: destURL.path, within: environment.claudeDirectory.path
)
return trackedFiles.contains(relPath)
}
}

// MARK: - Collision Resolver

/// Detects `copyPackFile` destination collisions and applies conditional namespacing.
///
/// When no collision exists, destinations remain flat (e.g., `.claude/commands/pr.md`).
/// When two+ packs define the same `(destination, fileType)`:
/// - **Non-skill types**: destinations are prefixed with `<pack-id>/` subdirectory
/// - **Skills**: destinations are suffixed with `-<pack-id>` (first pack keeps the clean name)
/// because Claude Code requires flat one-level directories for skill discovery.
/// Three sources of collision are handled (when `filesystemContext` is provided):
/// 1. **Hooks always namespace** into `<pack-id>/` subdirectories, protecting user hooks.
/// 2. **Cross-pack collisions**: two+ packs targeting the same `(destination, fileType)`.
/// 3. **User-file conflicts**: a pack targets a path occupied by a file not tracked by mcs.
///
/// Without `filesystemContext`, only cross-pack collisions are resolved (backward compat).
enum DestinationCollisionResolver {
/// Returns a new pack array with destinations namespaced only for conflicting files.
/// Emits warnings for skill collisions via `output`.
static func resolveCollisions(packs: [any TechPack], output: CLIOutput) -> [any TechPack] {
// Phase 1: Scan all packs for copyPackFile destinations
// Key: (destination, fileType) → [(packIndex, componentIndex)]
/// Returns a new pack array with destinations namespaced where needed.
static func resolveCollisions(
packs: [any TechPack],
output: CLIOutput,
filesystemContext: (any CollisionFilesystemContext)? = nil
) -> [any TechPack] {
var packComponentOverrides: [Int: [Int: String]] = [:]
var collisionMap: [DestinationKey: [CollisionEntry]] = [:]

// Phase 0: Build collision map and always-namespace hooks (single pass).
for (packIndex, pack) in packs.enumerated() {
for (componentIndex, component) in pack.components.enumerated() {
guard case let .copyPackFile(_, destination, fileType) = component.installAction else {
continue
}

// Hooks always namespace into <pack-id>/ when filesystem context is available.
if filesystemContext != nil, fileType == .hook {
packComponentOverrides[packIndex, default: [:]][componentIndex] =
namespacedDestination(destination: destination, packIdentifier: pack.identifier, fileType: fileType)
}

let key = DestinationKey(destination: destination, fileType: fileType)
collisionMap[key, default: []].append(
CollisionEntry(packIndex: packIndex, componentIndex: componentIndex)
)
}
}

// Phase 2: Identify actual collisions (2+ distinct pack indices)
var packComponentOverrides: [Int: [Int: String]] = [:] // packIndex → (componentIndex → newDestination)

// Phase 1a: Apply cross-pack collision namespacing (2+ distinct packs).
// Guards skip entries already resolved by Phase 0.
for (key, entries) in collisionMap {
let distinctPackIndices = Set(entries.map(\.packIndex))
guard distinctPackIndices.count >= 2 else { continue }
Expand All @@ -46,7 +107,36 @@ enum DestinationCollisionResolver {
}
}

// Phase 3: Build result — wrap only packs that have overrides
// Phase 1b: User-file conflict detection.
// For entries not yet resolved by Phase 0 or 1a, check if the destination
// is occupied by a file not tracked by any pack.
if let ctx = filesystemContext {
for (packIndex, pack) in packs.enumerated() {
for (componentIndex, component) in pack.components.enumerated() {
// Skip if already resolved by Phase 0 or 1a
if packComponentOverrides[packIndex]?[componentIndex] != nil { continue }

guard case let .copyPackFile(_, destination, fileType) = component.installAction else {
continue
}

if ctx.fileExists(destination: destination, fileType: fileType),
!ctx.isTrackedByPack(destination: destination, fileType: fileType) {
let namespaced = namespacedDestination(
destination: destination, packIdentifier: pack.identifier, fileType: fileType
)
packComponentOverrides[packIndex, default: [:]][componentIndex] = namespaced
output.warn(
"Pre-existing file at '\(fileType.subdirectory)\(destination)' is not managed by mcs"
+ " \u{2014} pack '\(pack.identifier)' will install to "
+ "'\(fileType.subdirectory)\(namespaced)' instead"
)
}
}
}
}

// Phase 2: Build result — wrap only packs that have overrides
guard !packComponentOverrides.isEmpty else { return packs }

return packs.enumerated().map { packIndex, pack in
Expand Down Expand Up @@ -78,22 +168,37 @@ enum DestinationCollisionResolver {

// MARK: - Private

/// For non-skill types, prefix all colliding entries with `<pack-id>/`.
/// Computes the namespaced destination for a given file type.
/// Skills get a `-<pack-id>` suffix; all other types get a `<pack-id>/` subdirectory prefix.
private static func namespacedDestination(
destination: String, packIdentifier: String, fileType: CopyFileType
) -> String {
if fileType == .skill {
"\(destination)-\(packIdentifier)"
} else {
"\(packIdentifier)/\(destination)"
}
}

/// For non-skill types, prefix colliding entries with `<pack-id>/`.
/// Skips entries already resolved by an earlier phase.
private static func applySubdirectoryPrefix(
entries: [CollisionEntry],
packs: [any TechPack],
overrides: inout [Int: [Int: String]]
) {
for entry in entries {
guard overrides[entry.packIndex]?[entry.componentIndex] == nil else { continue }
let pack = packs[entry.packIndex]
let component = pack.components[entry.componentIndex]
guard case let .copyPackFile(_, destination, _) = component.installAction else { continue }
let namespaced = "\(pack.identifier)/\(destination)"
overrides[entry.packIndex, default: [:]][entry.componentIndex] = namespaced
guard case let .copyPackFile(_, destination, fileType) = component.installAction else { continue }
overrides[entry.packIndex, default: [:]][entry.componentIndex] =
namespacedDestination(destination: destination, packIdentifier: pack.identifier, fileType: fileType)
}
}

/// For skills, the first pack keeps the clean name; subsequent packs get `-<pack-id>` suffix.
/// Skips entries already resolved by an earlier phase.
private static func applySkillSuffix(
entries: [CollisionEntry],
destination: String,
Expand All @@ -105,8 +210,11 @@ enum DestinationCollisionResolver {
let firstPackName = packs[firstPackIndex].identifier

for entry in entries where entry.packIndex != firstPackIndex {
guard overrides[entry.packIndex]?[entry.componentIndex] == nil else { continue }
let pack = packs[entry.packIndex]
let suffixed = "\(destination)-\(pack.identifier)"
let suffixed = namespacedDestination(
destination: destination, packIdentifier: pack.identifier, fileType: .skill
)
overrides[entry.packIndex, default: [:]][entry.componentIndex] = suffixed
output.warn(
"Skill '\(destination)' from pack '\(pack.identifier)' renamed to " +
Expand Down
6 changes: 6 additions & 0 deletions Sources/mcs/Install/GlobalSyncStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ struct GlobalSyncStrategy: SyncStrategy {
)
}

// MARK: - Collision Context

func makeCollisionContext(trackedFiles: Set<String>) -> (any CollisionFilesystemContext)? {
GlobalCollisionContext(environment: environment, trackedFiles: trackedFiles)
}

// MARK: - Artifact Installation

func installArtifacts(
Expand Down
6 changes: 6 additions & 0 deletions Sources/mcs/Install/ProjectSyncStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ struct ProjectSyncStrategy: SyncStrategy {
)
}

// MARK: - Collision Context

func makeCollisionContext(trackedFiles: Set<String>) -> (any CollisionFilesystemContext)? {
ProjectCollisionContext(projectPath: projectPath, trackedFiles: trackedFiles)
}

// MARK: - Artifact Installation

func installArtifacts(
Expand Down
8 changes: 8 additions & 0 deletions Sources/mcs/Install/SyncStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ protocol SyncStrategy {
output: CLIOutput
) throws -> Set<String>?

/// Build a filesystem context for collision resolution.
///
/// Returns a `CollisionFilesystemContext` that enables the resolver to detect
/// pre-existing user files at `copyPackFile` destinations.
/// Every conformance must implement this — returning `nil` disables user-file protection
/// and hook namespacing (only cross-pack collisions are resolved).
func makeCollisionContext(trackedFiles: Set<String>) -> (any CollisionFilesystemContext)?

/// Derive the relative file path that artifact tracking records for a `copyPackFile` component.
///
/// Global scope computes relative to `~/.claude/`.
Expand Down
Loading
Loading