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
13 changes: 12 additions & 1 deletion Sources/mcs/Core/GitignoreManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

/// Manages the global gitignore file. Resolves the correct path,
/// creates the file if needed, and adds entries idempotently.
struct GitignoreManager: Sendable {
struct GitignoreManager {
let shell: ShellRunner

/// Core entries managed by mcs (not pack-specific).
Expand Down Expand Up @@ -81,6 +81,17 @@ struct GitignoreManager: Sendable {
return true
}

/// Read the global gitignore and return its lines as a set.
/// Returns `nil` if the file doesn't exist. Throws if the file exists but can't be read.
func readLines() throws -> Set<String>? {
let path = resolveGlobalGitignorePath()
guard FileManager.default.fileExists(atPath: path.path) else {
return nil
}
let content = try String(contentsOf: path, encoding: .utf8)
return Set(content.components(separatedBy: .newlines))
}

/// Add all core gitignore entries.
func addCoreEntries() throws {
for entry in Self.coreEntries {
Expand Down
160 changes: 146 additions & 14 deletions Sources/mcs/Doctor/CoreDoctorChecks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -235,28 +235,25 @@ struct GitignoreCheck: DoctorCheck {
}

func check() -> CheckResult {
let shell = ShellRunner(environment: Environment())
let gitignoreManager = GitignoreManager(shell: shell)
let gitignorePath = gitignoreManager.resolveGlobalGitignorePath()
guard FileManager.default.fileExists(atPath: gitignorePath.path),
let content = try? String(contentsOf: gitignorePath, encoding: .utf8)
else {
return .fail("global gitignore not found")
}
let allEntries = GitignoreManager.coreEntries
var missing: [String] = []
for entry in allEntries where !content.contains(entry) {
missing.append(entry)
let gitignoreManager = GitignoreManager(shell: ShellRunner(environment: Environment()))
let lines: Set<String>
do {
guard let result = try gitignoreManager.readLines() else {
return .fail("global gitignore not found")
}
lines = result
} catch {
return .fail("global gitignore unreadable: \(error.localizedDescription)")
}
let missing = GitignoreManager.coreEntries.filter { !lines.contains($0) }
if missing.isEmpty {
return .pass("all entries present")
}
return .fail("missing entries: \(missing.joined(separator: ", "))")
}

func fix() -> FixResult {
let shell = ShellRunner(environment: Environment())
let gitignoreManager = GitignoreManager(shell: shell)
let gitignoreManager = GitignoreManager(shell: ShellRunner(environment: Environment()))
do {
try gitignoreManager.addCoreEntries()
return .fixed("added missing entries")
Expand Down Expand Up @@ -326,6 +323,141 @@ struct ProjectIndexCheck: DoctorCheck {
}
}

/// Verifies that pack-contributed hook commands are still present in the settings file.
struct HookSettingsCheck: DoctorCheck {
let commands: [String]
let settingsPath: URL
let packName: String

var name: String {
"Hook entries (\(packName))"
}

var section: String {
"Hooks"
}

func check() -> CheckResult {
guard FileManager.default.fileExists(atPath: settingsPath.path) else {
return .fail("settings file not found")
}
let settings: Settings
do {
settings = try Settings.load(from: settingsPath)
} catch {
return .fail("cannot read settings: \(error.localizedDescription)")
}
let allCommands = (settings.hooks ?? [:]).values
.flatMap(\.self)
.compactMap(\.hooks)
.flatMap(\.self)
.compactMap(\.command)
let commandSet = Set(allCommands)
let missing = commands.filter { !commandSet.contains($0) }
if missing.isEmpty {
return .pass("all hook commands present")
}
return .fail("missing hook commands: \(missing.joined(separator: ", "))")
}

func fix() -> FixResult {
.notFixable("Run 'mcs sync' to restore hook entries")
}
}

/// Verifies that pack-contributed settings keys are still present in the settings file.
struct SettingsKeysCheck: DoctorCheck {
let keys: [String]
let settingsPath: URL
let packName: String

var name: String {
"Settings keys (\(packName))"
}

var section: String {
"Settings"
}

func check() -> CheckResult {
let data: Data
do {
data = try Data(contentsOf: settingsPath)
} catch {
return .fail("settings file not found or unreadable")
}
let json: [String: Any]
do {
guard let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return .fail("settings file is not a JSON object")
}
json = parsed
} catch {
return .fail("settings file contains invalid JSON: \(error.localizedDescription)")
}
var missing: [String] = []
for keyPath in keys where !keyExists(keyPath, in: json) {
missing.append(keyPath)
}
if missing.isEmpty {
return .pass("all settings keys present")
}
return .fail("missing settings keys: \(missing.joined(separator: ", "))")
}

func fix() -> FixResult {
.notFixable("Run 'mcs sync' to restore settings keys")
}

/// Check if a dot-notation key path exists in a JSON dictionary.
private func keyExists(_ keyPath: String, in json: [String: Any]) -> Bool {
let parts = keyPath.split(separator: ".", maxSplits: 1)
let topLevel = String(parts[0])
if parts.count == 2 {
let subKey = String(parts[1])
guard let nested = json[topLevel] as? [String: Any] else { return false }
return nested[subKey] != nil
}
return json[topLevel] != nil
}
}

/// Verifies that pack-contributed gitignore entries are still present in the global gitignore.
struct PackGitignoreCheck: DoctorCheck {
let entries: [String]
let packName: String

var name: String {
"Gitignore entries (\(packName))"
}

var section: String {
"Gitignore"
}

func check() -> CheckResult {
let gitignoreManager = GitignoreManager(shell: ShellRunner(environment: Environment()))
let lines: Set<String>
do {
guard let result = try gitignoreManager.readLines() else {
return .fail("global gitignore not found")
}
lines = result
} catch {
return .fail("global gitignore unreadable: \(error.localizedDescription)")
}
let missing = entries.filter { !lines.contains($0) }
if missing.isEmpty {
return .pass("all entries present")
}
return .fail("missing entries: \(missing.joined(separator: ", "))")
}

func fix() -> FixResult {
.notFixable("Run 'mcs sync' to restore gitignore entries")
}
}

struct CommandFileCheck: DoctorCheck {
let name: String
let section = "Commands"
Expand Down
89 changes: 75 additions & 14 deletions Sources/mcs/Doctor/DoctorRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,14 @@ struct DoctorRunner {
allChecks += pack.supplementaryDoctorChecks(projectRoot: scope.effectiveProjectRoot)
.map { (check: $0, isExcluded: false) }

// File content drift checks from stored hashes
// Artifact-record-driven checks from stored state
if let artifacts = scope.artifactsByPack[pack.identifier] {
let baseURL = scope.effectiveProjectRoot ?? env.claudeDirectory
for (relativePath, expectedHash) in artifacts.fileHashes {
let fileURL = baseURL.appendingPathComponent(relativePath)
allChecks.append((
check: FileContentCheck(
name: "File content: \(relativePath)",
section: "Installed Files",
path: fileURL,
expectedHash: expectedHash
),
isExcluded: false
))
}
allChecks += artifactChecks(
for: artifacts,
pack: pack,
scope: scope,
env: env
)
}
}
}
Expand Down Expand Up @@ -403,6 +396,74 @@ struct DoctorRunner {
)
}

// MARK: - Artifact-record checks

/// Builds doctor checks derived from a pack's stored artifact record.
/// Covers file content hashes, hook commands, settings keys, and gitignore entries.
private func artifactChecks(
for artifacts: PackArtifactRecord,
pack: any TechPack,
scope: CheckScope,
env: Environment
) -> [(check: any DoctorCheck, isExcluded: Bool)] {
var checks: [(check: any DoctorCheck, isExcluded: Bool)] = []

let baseURL = scope.effectiveProjectRoot ?? env.claudeDirectory
for (relativePath, expectedHash) in artifacts.fileHashes {
let fileURL = baseURL.appendingPathComponent(relativePath)
checks.append((
check: FileContentCheck(
name: "File content: \(relativePath)",
section: "Installed Files",
path: fileURL,
expectedHash: expectedHash
),
isExcluded: false
))
}

if !artifacts.hookCommands.isEmpty || !artifacts.settingsKeys.isEmpty {
let settingsPath: URL = if let root = scope.effectiveProjectRoot {
root.appendingPathComponent(Constants.FileNames.claudeDirectory)
.appendingPathComponent("settings.local.json")
} else {
env.claudeSettings
}
if !artifacts.hookCommands.isEmpty {
checks.append((
check: HookSettingsCheck(
commands: artifacts.hookCommands,
settingsPath: settingsPath,
packName: pack.displayName
),
isExcluded: false
))
}
if !artifacts.settingsKeys.isEmpty {
checks.append((
check: SettingsKeysCheck(
keys: artifacts.settingsKeys,
settingsPath: settingsPath,
packName: pack.displayName
),
isExcluded: false
))
}
}

if !artifacts.gitignoreEntries.isEmpty {
checks.append((
check: PackGitignoreCheck(
entries: artifacts.gitignoreEntries,
packName: pack.displayName
),
isExcluded: false
))
}

return checks
}

// MARK: - Standalone checks (not tied to any component)

/// Checks that cannot be derived from any ComponentDefinition.
Expand Down
Loading
Loading