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
2 changes: 1 addition & 1 deletion Sources/LucaCLI/LucaCLI.docc/Lucafile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
58 changes: 38 additions & 20 deletions Sources/ManagerCore/Core/GitIgnoreManager/GitIgnoreManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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)"))")
}
}
41 changes: 30 additions & 11 deletions Tests/Core/GitIgnoreManagerSkillsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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
Expand All @@ -74,15 +94,15 @@ 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")
]

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
Expand All @@ -101,22 +121,21 @@ 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")
try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true)

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)
}
}
80 changes: 40 additions & 40 deletions Tests/Core/GitIgnoreManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}