diff --git a/Sources/LucaCLI/LucaCLI.docc/Lucafile.md b/Sources/LucaCLI/LucaCLI.docc/Lucafile.md index d5cd311..2d09fdd 100644 --- a/Sources/LucaCLI/LucaCLI.docc/Lucafile.md +++ b/Sources/LucaCLI/LucaCLI.docc/Lucafile.md @@ -149,7 +149,7 @@ Use checksums for critical tools to verify integrity and protect against tamperi ### Git Integration -Add `.luca/tools/` to your `.gitignore` file. Luca can manage this automatically with the `--install-git-hook` flag. +Luca automatically ignores `.luca/tools/` and `.luca/skills/` by writing a nested `.gitignore` inside each folder — your project's own `.gitignore` is never modified for these. Each agent's project skill directory (e.g. `.claude/skills/`) is still added to your root `.gitignore`, since those live outside `.luca/`. ## Authenticating with Private Repositories & GitHub Enterprise Server diff --git a/Sources/ManagerCore/Core/FileManagerProtocols/GitIgnoreFileManaging.swift b/Sources/ManagerCore/Core/FileManagerProtocols/GitIgnoreFileManaging.swift index 327de29..603d7e6 100644 --- a/Sources/ManagerCore/Core/FileManagerProtocols/GitIgnoreFileManaging.swift +++ b/Sources/ManagerCore/Core/FileManagerProtocols/GitIgnoreFileManaging.swift @@ -5,7 +5,10 @@ import Foundation /// File system interface for ``GitIgnoreManager``. public protocol GitIgnoreFileManaging { var currentDirectoryPath: String { get } + var symlinksFolder: URL { get } + var skillsCacheFolder: URL { get } func fileExists(atPath: String) -> Bool func readString(at url: URL) throws -> String func writeString(_ content: String, to url: URL) throws + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws } diff --git a/Sources/ManagerCore/Core/GitIgnoreManager/GitIgnoreManager.swift b/Sources/ManagerCore/Core/GitIgnoreManager/GitIgnoreManager.swift index 8af7249..790cb98 100644 --- a/Sources/ManagerCore/Core/GitIgnoreManager/GitIgnoreManager.swift +++ b/Sources/ManagerCore/Core/GitIgnoreManager/GitIgnoreManager.swift @@ -3,18 +3,28 @@ import Foundation import LucaFoundation -/// Ensures the project's `.gitignore` contains entries for the tools and skills folders. +/// Ensures Luca's tools and skills folders stay untracked by git. +/// +/// `.luca/tools/` and `.luca/skills/` get their own nested `.gitignore` rather than an entry in +/// the project's root `.gitignore`. Each agent's project skill directory (e.g. `.claude/skills/`) +/// still gets an entry appended to the root `.gitignore`, since those live outside `.luca/`. public struct GitIgnoreManager { private let fileManager: GitIgnoreFileManaging private let printer: Printing + /// Content of a nested `.gitignore` that ignores everything in its folder, including itself. + private static let nestedGitIgnoreContent = "*\n" + public init(fileManager: GitIgnoreFileManaging, printer: Printing) { self.fileManager = fileManager self.printer = printer } - /// Appends or creates a `.gitignore` entry for the tools folder in the current project. + /// Ensures the project's tools symlinks folder ignores its own contents. + /// + /// Writes a nested `.gitignore` inside `.luca/tools/` rather than editing the project's + /// root `.gitignore`, so Luca never has to read or merge into a file it doesn't own. public func ensureGitIgnoreIncludesSymlinksFolder() throws { let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) let gitDirectory = currentDirectory.appending(component: ".git") @@ -23,24 +33,11 @@ public struct GitIgnoreManager { return } - let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") - let entryToAdd = "\(Constants.toolFolder)/\(Constants.symlinksFolder)" - - if fileManager.fileExists(atPath: gitIgnoreFile.path) { - let content = try fileManager.readString(at: gitIgnoreFile) - if !content.contains(entryToAdd) { - let newContent = content.hasSuffix("\n") ? content + entryToAdd + "\n" : content + "\n" + entryToAdd + "\n" - try fileManager.writeString(newContent, to: gitIgnoreFile) - printer.printFormatted("\(.info("🙈 Added \(entryToAdd) to .gitignore"))") - } - } else { - let content = entryToAdd + "\n" - try fileManager.writeString(content, to: gitIgnoreFile) - printer.printFormatted("\(.info("🙈 Created .gitignore with \(entryToAdd)"))") - } + try writeNestedGitIgnore(in: fileManager.symlinksFolder, label: "\(Constants.toolFolder)/\(Constants.symlinksFolder)") } - /// Appends or creates `.gitignore` entries for the skills cache folder and each agent's skill directory. + /// Ensures the project's skills cache folder ignores its own contents, and appends each + /// agent's skill directory to the root `.gitignore`. /// /// - Parameter agents: The agents whose skill directories should be added to `.gitignore`. public func ensureGitIgnoreIncludesSkillFolders(agents: [AgentInfo]) throws { @@ -51,9 +48,12 @@ public struct GitIgnoreManager { return } + try writeNestedGitIgnore(in: fileManager.skillsCacheFolder, label: "\(Constants.toolFolder)/\(Constants.skillsFolder)") + + let entriesToAdd = agents.map(\.projectSkillsPath) + guard !entriesToAdd.isEmpty else { return } + let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") - var entriesToAdd = ["\(Constants.toolFolder)/\(Constants.skillsFolder)"] - entriesToAdd += agents.map(\.projectSkillsPath) if fileManager.fileExists(atPath: gitIgnoreFile.path) { var content = try fileManager.readString(at: gitIgnoreFile) @@ -74,4 +74,22 @@ public struct GitIgnoreManager { printer.printFormatted("\(.info("🙈 Created .gitignore with skills entries"))") } } + + // MARK: - Private + + /// Writes a `.gitignore` inside `folder` that ignores everything in it, including itself, unless already present. + private func writeNestedGitIgnore(in folder: URL, label: String) throws { + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + + let nestedGitIgnoreFile = folder.appending(component: ".gitignore") + if fileManager.fileExists(atPath: nestedGitIgnoreFile.path) { + let content = try fileManager.readString(at: nestedGitIgnoreFile) + if content == Self.nestedGitIgnoreContent { + return + } + } + + try fileManager.writeString(Self.nestedGitIgnoreContent, to: nestedGitIgnoreFile) + printer.printFormatted("\(.info("🙈 Added .gitignore to \(label)"))") + } } diff --git a/Tests/Core/GitIgnoreManagerSkillsTests.swift b/Tests/Core/GitIgnoreManagerSkillsTests.swift index 116b286..4c65373 100644 --- a/Tests/Core/GitIgnoreManagerSkillsTests.swift +++ b/Tests/Core/GitIgnoreManagerSkillsTests.swift @@ -28,10 +28,30 @@ struct GitIgnoreManagerSkillsTests { let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") #expect(fileManager.fileExists(atPath: gitIgnoreFile.path) == false) + let nestedGitIgnore = fileManager.skillsCacheFolder.appending(component: ".gitignore") + #expect(fileManager.fileExists(atPath: nestedGitIgnore.path) == false) } @Test - func test_ensureGitIgnoreIncludesSkillFolders_createsGitIgnoreWithAllEntries() throws { + func test_ensureGitIgnoreIncludesSkillFolders_createsNestedGitIgnoreInSkillsFolder() throws { + let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) + try fileManager.createDirectory(at: currentDirectory, withIntermediateDirectories: true) + let gitDirectory = currentDirectory.appending(component: ".git") + try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) + let agents = [ + AgentInfo(id: "claude-code", projectSkillsPath: ".claude/skills", globalSkillsPath: "~/.claude/skills") + ] + + try sut.ensureGitIgnoreIncludesSkillFolders(agents: agents) + + let nestedGitIgnore = fileManager.skillsCacheFolder.appending(component: ".gitignore") + #expect(fileManager.fileExists(atPath: nestedGitIgnore.path)) + let content = try fileManager.readString(at: nestedGitIgnore) + #expect(content == "*\n") + } + + @Test + func test_ensureGitIgnoreIncludesSkillFolders_createsGitIgnoreWithAgentEntriesOnly() throws { let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) try fileManager.createDirectory(at: currentDirectory, withIntermediateDirectories: true) let gitDirectory = currentDirectory.appending(component: ".git") @@ -46,7 +66,7 @@ struct GitIgnoreManagerSkillsTests { let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") #expect(fileManager.fileExists(atPath: gitIgnoreFile.path)) let content = try fileManager.readString(at: gitIgnoreFile) - #expect(content == ".luca/skills\n.claude/skills\n.cursor/rules\n") + #expect(content == ".claude/skills\n.cursor/rules\n") } @Test @@ -64,7 +84,7 @@ struct GitIgnoreManagerSkillsTests { try sut.ensureGitIgnoreIncludesSkillFolders(agents: agents) let content = try fileManager.readString(at: gitIgnoreFile) - #expect(content == "existing\n.luca/skills\n.claude/skills\n") + #expect(content == "existing\n.claude/skills\n") } @Test @@ -74,7 +94,7 @@ struct GitIgnoreManagerSkillsTests { let gitDirectory = currentDirectory.appending(component: ".git") try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") - try fileManager.writeString(".luca/skills\n.claude/skills\n", to: gitIgnoreFile) + try fileManager.writeString(".claude/skills\n", to: gitIgnoreFile) let agents = [ AgentInfo(id: "claude-code", projectSkillsPath: ".claude/skills", globalSkillsPath: "~/.claude/skills") ] @@ -82,7 +102,7 @@ struct GitIgnoreManagerSkillsTests { try sut.ensureGitIgnoreIncludesSkillFolders(agents: agents) let content = try fileManager.readString(at: gitIgnoreFile) - #expect(content == ".luca/skills\n.claude/skills\n") + #expect(content == ".claude/skills\n") } @Test @@ -101,12 +121,11 @@ struct GitIgnoreManagerSkillsTests { let content = try fileManager.readString(at: gitIgnoreFile) #expect(content.hasPrefix("existing\n")) - #expect(content.contains(".luca/skills\n")) #expect(content.contains(".claude/skills\n")) } @Test - func test_ensureGitIgnoreIncludesSkillFolders_emptyAgents_onlyAddsSkillsEntry() throws { + func test_ensureGitIgnoreIncludesSkillFolders_emptyAgents_createsNestedGitIgnoreButNoRootGitIgnore() throws { let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) try fileManager.createDirectory(at: currentDirectory, withIntermediateDirectories: true) let gitDirectory = currentDirectory.appending(component: ".git") @@ -114,9 +133,9 @@ struct GitIgnoreManagerSkillsTests { try sut.ensureGitIgnoreIncludesSkillFolders(agents: []) - let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") - #expect(fileManager.fileExists(atPath: gitIgnoreFile.path)) - let content = try fileManager.readString(at: gitIgnoreFile) - #expect(content == ".luca/skills\n") + let nestedGitIgnore = fileManager.skillsCacheFolder.appending(component: ".gitignore") + #expect(fileManager.fileExists(atPath: nestedGitIgnore.path)) + let rootGitIgnore = currentDirectory.appending(component: ".gitignore") + #expect(fileManager.fileExists(atPath: rootGitIgnore.path) == false) } } diff --git a/Tests/Core/GitIgnoreManagerTests.swift b/Tests/Core/GitIgnoreManagerTests.swift index 760db16..f3a9d96 100644 --- a/Tests/Core/GitIgnoreManagerTests.swift +++ b/Tests/Core/GitIgnoreManagerTests.swift @@ -5,100 +5,100 @@ import XCTest @testable import ManagerCore final class GitIgnoreManagerTests: XCTestCase { - + var fileManager: FileManagerWrapperMock! var sut: GitIgnoreManager! - + override func setUp() { super.setUp() fileManager = FileManagerWrapperMock() sut = GitIgnoreManager(fileManager: fileManager, printer: PrinterMock()) } - + override func tearDown() { fileManager = nil sut = nil super.tearDown() } - + func test_ensureGitIgnoreIncludesSymlinksFolder_whenNotGitRepo_doesNothing() throws { // Given let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) try fileManager.createDirectory(at: currentDirectory, withIntermediateDirectories: true) - + // When try sut.ensureGitIgnoreIncludesSymlinksFolder() - + // Then - let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") - XCTAssertFalse(fileManager.fileExists(atPath: gitIgnoreFile.path)) + let nestedGitIgnore = fileManager.symlinksFolder.appending(component: ".gitignore") + XCTAssertFalse(fileManager.fileExists(atPath: nestedGitIgnore.path)) } - - func test_ensureGitIgnoreIncludesSymlinksFolder_whenGitRepoAndNoGitIgnore_createsGitIgnore() throws { + + func test_ensureGitIgnoreIncludesSymlinksFolder_whenGitRepo_createsNestedGitIgnoreInToolsFolder() throws { // Given let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) try fileManager.createDirectory(at: currentDirectory, withIntermediateDirectories: true) let gitDirectory = currentDirectory.appending(component: ".git") try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) - + // When try sut.ensureGitIgnoreIncludesSymlinksFolder() - + // Then - let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") - XCTAssertTrue(fileManager.fileExists(atPath: gitIgnoreFile.path)) - let content = try fileManager.readString(at: gitIgnoreFile) - XCTAssertEqual(content, ".luca/tools\n") + let nestedGitIgnore = fileManager.symlinksFolder.appending(component: ".gitignore") + XCTAssertTrue(fileManager.fileExists(atPath: nestedGitIgnore.path)) + let content = try fileManager.readString(at: nestedGitIgnore) + XCTAssertEqual(content, "*\n") } - - func test_ensureGitIgnoreIncludesSymlinksFolder_whenGitRepoAndGitIgnoreExistsWithoutEntry_appendsEntry() throws { + + func test_ensureGitIgnoreIncludesSymlinksFolder_whenGitRepo_doesNotTouchRootGitIgnore() throws { // Given let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) try fileManager.createDirectory(at: currentDirectory, withIntermediateDirectories: true) let gitDirectory = currentDirectory.appending(component: ".git") try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) - let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") - try fileManager.writeString("existing\n", to: gitIgnoreFile) - + // When try sut.ensureGitIgnoreIncludesSymlinksFolder() - + // Then - let content = try fileManager.readString(at: gitIgnoreFile) - XCTAssertEqual(content, "existing\n.luca/tools\n") + let rootGitIgnore = currentDirectory.appending(component: ".gitignore") + XCTAssertFalse(fileManager.fileExists(atPath: rootGitIgnore.path)) } - - func test_ensureGitIgnoreIncludesSymlinksFolder_whenGitRepoAndGitIgnoreExistsWithEntry_doesNothing() throws { + + func test_ensureGitIgnoreIncludesSymlinksFolder_whenSymlinksFolderDoesNotExistYet_createsFolderAndGitIgnore() throws { // Given let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) try fileManager.createDirectory(at: currentDirectory, withIntermediateDirectories: true) let gitDirectory = currentDirectory.appending(component: ".git") try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) - let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") - try fileManager.writeString(".luca/tools\n", to: gitIgnoreFile) - + XCTAssertFalse(fileManager.fileExists(atPath: fileManager.symlinksFolder.path)) + // When try sut.ensureGitIgnoreIncludesSymlinksFolder() - + // Then - let content = try fileManager.readString(at: gitIgnoreFile) - XCTAssertEqual(content, ".luca/tools\n") + var isDirectory: ObjCBool = false + XCTAssertTrue(fileManager.fileExists(atPath: fileManager.symlinksFolder.path)) + _ = FileManager.default.fileExists(atPath: fileManager.symlinksFolder.path, isDirectory: &isDirectory) + XCTAssertTrue(isDirectory.boolValue) } - - func test_ensureGitIgnoreIncludesSymlinksFolder_whenGitRepoAndGitIgnoreExistsWithoutNewline_appendsNewlineAndEntry() throws { + + func test_ensureGitIgnoreIncludesSymlinksFolder_whenNestedGitIgnoreAlreadyPresent_isIdempotent() throws { // Given let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) try fileManager.createDirectory(at: currentDirectory, withIntermediateDirectories: true) let gitDirectory = currentDirectory.appending(component: ".git") try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) - let gitIgnoreFile = currentDirectory.appending(component: ".gitignore") - try fileManager.writeString("existing", to: gitIgnoreFile) - + try fileManager.createDirectory(at: fileManager.symlinksFolder, withIntermediateDirectories: true) + let nestedGitIgnore = fileManager.symlinksFolder.appending(component: ".gitignore") + try fileManager.writeString("*\n", to: nestedGitIgnore) + // When try sut.ensureGitIgnoreIncludesSymlinksFolder() - + // Then - let content = try fileManager.readString(at: gitIgnoreFile) - XCTAssertEqual(content, "existing\n.luca/tools\n") + let content = try fileManager.readString(at: nestedGitIgnore) + XCTAssertEqual(content, "*\n") } }