diff --git a/Sources/LucaCLI/Commands/InstallCommand.swift b/Sources/LucaCLI/Commands/InstallCommand.swift index 08f3939..4757636 100644 --- a/Sources/LucaCLI/Commands/InstallCommand.swift +++ b/Sources/LucaCLI/Commands/InstallCommand.swift @@ -107,6 +107,9 @@ luca install @Flag(help: "Skip architecture compatibility validation during installation.") var ignoreArchCheck: Bool = false + @Flag(inversion: .prefixedNo, help: "Install the post-checkout git hook in the current repository.") + var installPostCheckoutGitHook: Bool = true + @Flag(help: "Suppress all output except final success message.") var quiet: Bool = false @@ -138,6 +141,11 @@ luca install let gitIgnoreManager = GitIgnoreManager(fileManager: fileManager, printer: printer) try gitIgnoreManager.ensureGitIgnoreIncludesActiveFolder() + if installPostCheckoutGitHook { + let gitHookInstaller = GitHookInstaller(fileManager: fileManager, printer: printer) + try gitHookInstaller.installPostCheckoutHook() + } + try await installer.install(installationType: installationType) } diff --git a/Sources/LucaCLI/Utils/FileManagerWrapper.swift b/Sources/LucaCLI/Utils/FileManagerWrapper.swift index da459e1..b06c5bc 100644 --- a/Sources/LucaCLI/Utils/FileManagerWrapper.swift +++ b/Sources/LucaCLI/Utils/FileManagerWrapper.swift @@ -79,6 +79,10 @@ public struct FileManagerWrapper: FileManaging { try fileManager.attributesOfItem(atPath: path) } + public func copyItem(at srcURL: URL, to dstURL: URL) throws { + try fileManager.copyItem(at: srcURL, to: dstURL) + } + public func readString(at url: URL) throws -> String { try String(contentsOf: url, encoding: .utf8) } diff --git a/Sources/LucaCore/Core/FileManagerProtocols/FileManaging.swift b/Sources/LucaCore/Core/FileManagerProtocols/FileManaging.swift index c69a17a..0a5efb4 100644 --- a/Sources/LucaCore/Core/FileManagerProtocols/FileManaging.swift +++ b/Sources/LucaCore/Core/FileManagerProtocols/FileManaging.swift @@ -7,6 +7,7 @@ public protocol FileManaging: BinaryFinderFileManaging, ChecksumValidatorFileManaging, FileTypeDetectorFileManaging, + GitHookInstallerFileManaging, GitIgnoreFileManaging, InstalledToolsFileManaging, PermissionManagerFileManaging, @@ -27,4 +28,5 @@ public protocol FileManaging: func setAttributes(_ attributes: [FileAttributeKey: Any], ofItemAtPath path: String) throws func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions) throws -> [URL] func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] + func copyItem(at srcURL: URL, to dstURL: URL) throws } diff --git a/Sources/LucaCore/Core/FileManagerProtocols/GitHookInstallerFileManaging.swift b/Sources/LucaCore/Core/FileManagerProtocols/GitHookInstallerFileManaging.swift new file mode 100644 index 0000000..1ed0c0e --- /dev/null +++ b/Sources/LucaCore/Core/FileManagerProtocols/GitHookInstallerFileManaging.swift @@ -0,0 +1,11 @@ +// GitHookInstallerFileManaging.swift + +import Foundation + +public protocol GitHookInstallerFileManaging { + var currentDirectoryPath: String { get } + var homeDirectoryForCurrentUser: URL { get } + func fileExists(atPath: String) -> Bool + func copyItem(at srcURL: URL, to dstURL: URL) throws + func setAttributes(_ attributes: [FileAttributeKey: Any], ofItemAtPath path: String) throws +} diff --git a/Sources/LucaCore/Core/GitHookInstaller/GitHookInstaller.swift b/Sources/LucaCore/Core/GitHookInstaller/GitHookInstaller.swift new file mode 100644 index 0000000..a9ced8d --- /dev/null +++ b/Sources/LucaCore/Core/GitHookInstaller/GitHookInstaller.swift @@ -0,0 +1,49 @@ +// GitHookInstaller.swift + +import Foundation + +public struct GitHookInstaller { + + private let fileManager: GitHookInstallerFileManaging + private let printer: Printing + + public init(fileManager: GitHookInstallerFileManaging, printer: Printing) { + self.fileManager = fileManager + self.printer = printer + } + + public func installPostCheckoutHook() throws { + let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) + let gitDirectory = currentDirectory.appending(component: ".git") + + // Skip if not a git repository + guard fileManager.fileExists(atPath: gitDirectory.path) else { + return + } + + let sourceHookPath = fileManager.homeDirectoryForCurrentUser + .appending(components: Constants.toolFolder, "post-checkout") + + // Warn and continue if source hook doesn't exist + guard fileManager.fileExists(atPath: sourceHookPath.path) else { + printer.printFormatted("\(.raw("⚠️ Post-checkout hook source not found at \(sourceHookPath.path)"))") + return + } + + let hooksDirectory = gitDirectory.appending(component: "hooks") + let destinationHookPath = hooksDirectory.appending(component: "post-checkout") + + // Skip if hook already exists (preserve existing hook) + guard !fileManager.fileExists(atPath: destinationHookPath.path) else { + return + } + + // Copy hook file + try fileManager.copyItem(at: sourceHookPath, to: destinationHookPath) + + // Set executable permissions (0o755) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationHookPath.path) + + printer.printFormatted("\(.info("🪝 Installed post-checkout hook"))") + } +} diff --git a/Sources/LucaCore/Core/GitIgnoreManager/GitIgnoreManager.swift b/Sources/LucaCore/Core/GitIgnoreManager/GitIgnoreManager.swift index 444e339..80037fd 100644 --- a/Sources/LucaCore/Core/GitIgnoreManager/GitIgnoreManager.swift +++ b/Sources/LucaCore/Core/GitIgnoreManager/GitIgnoreManager.swift @@ -28,12 +28,12 @@ public struct GitIgnoreManager { if !content.contains(entryToAdd) { let newContent = content.hasSuffix("\n") ? content + entryToAdd + "\n" : content + "\n" + entryToAdd + "\n" try fileManager.writeString(newContent, to: gitIgnoreFile) - printer.printFormatted("\(.raw("🙈 Added \(entryToAdd) to .gitignore"))") + printer.printFormatted("\(.info("🙈 Added \(entryToAdd) to .gitignore"))") } } else { let content = entryToAdd + "\n" try fileManager.writeString(content, to: gitIgnoreFile) - printer.printFormatted("\(.raw("🙈 Created .gitignore with \(entryToAdd)"))") + printer.printFormatted("\(.info("🙈 Created .gitignore with \(entryToAdd)"))") } } } diff --git a/Sources/LucaCore/Core/Installer/Installer.swift b/Sources/LucaCore/Core/Installer/Installer.swift index 0723113..8046001 100644 --- a/Sources/LucaCore/Core/Installer/Installer.swift +++ b/Sources/LucaCore/Core/Installer/Installer.swift @@ -101,7 +101,6 @@ public struct Installer { let toolFactory = ToolFactory(releaseInfoProvider: releaseInfoProvider, specLoader: specLoader) printer.printFormatted("\(.info("🧠 Detecting tools to install..."))") - printer.printFormatted("") let tools = try await toolFactory.toolsForInstallationType(installationType) diff --git a/Tests/Core/GitHookInstallerTests.swift b/Tests/Core/GitHookInstallerTests.swift new file mode 100644 index 0000000..79f83fd --- /dev/null +++ b/Tests/Core/GitHookInstallerTests.swift @@ -0,0 +1,136 @@ +// GitHookInstallerTests.swift + +import XCTest +import Noora +@testable import LucaCore + +final class GitHookInstallerTests: XCTestCase { + + var fileManager: FileManagerWrapperMock! + var printer: PrinterSpyMock! + var sut: GitHookInstaller! + + override func setUp() { + super.setUp() + fileManager = FileManagerWrapperMock() + printer = PrinterSpyMock() + sut = GitHookInstaller(fileManager: fileManager, printer: printer) + } + + override func tearDown() { + fileManager = nil + printer = nil + sut = nil + super.tearDown() + } + + // MARK: - Not a Git Repository + + func test_installPostCheckoutHook_whenNotGitRepo_doesNothing() throws { + // Given + let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) + try fileManager.createDirectory(at: currentDirectory, withIntermediateDirectories: true) + + // When + try sut.installPostCheckoutHook() + + // Then + let hooksDirectory = currentDirectory.appending(components: ".git", "hooks") + XCTAssertFalse(fileManager.fileExists(atPath: hooksDirectory.path)) + XCTAssertTrue(printer.printedMessages.isEmpty) + } + + // MARK: - Source Hook Missing + + func test_installPostCheckoutHook_whenSourceHookMissing_printsWarning() 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.installPostCheckoutHook() + + // Then + XCTAssertEqual(printer.printedMessages.count, 1) + XCTAssertTrue(printer.printedMessages[0].contains("Post-checkout hook source not found")) + } + + // MARK: - Hook Already Exists + + func test_installPostCheckoutHook_whenHookAlreadyExists_skipsInstallation() 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 hooksDirectory = gitDirectory.appending(component: "hooks") + try fileManager.createDirectory(at: hooksDirectory, withIntermediateDirectories: true) + + // Create source hook + let sourceHookDirectory = fileManager.homeDirectoryForCurrentUser.appending(component: ".luca") + try fileManager.createDirectory(at: sourceHookDirectory, withIntermediateDirectories: true) + let sourceHookPath = sourceHookDirectory.appending(component: "post-checkout") + try fileManager.writeString("#!/bin/sh\necho 'source hook'", to: sourceHookPath) + + // Create existing destination hook + let destinationHookPath = hooksDirectory.appending(component: "post-checkout") + try fileManager.writeString("#!/bin/sh\necho 'existing hook'", to: destinationHookPath) + + // When + try sut.installPostCheckoutHook() + + // Then + let existingContent = try fileManager.readString(at: destinationHookPath) + XCTAssertEqual(existingContent, "#!/bin/sh\necho 'existing hook'") + XCTAssertTrue(printer.printedMessages.isEmpty) + } + + // MARK: - Successful Installation + + func test_installPostCheckoutHook_whenAllConditionsMet_copiesHookAndSetsPermissions() 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 hooksDirectory = gitDirectory.appending(component: "hooks") + try fileManager.createDirectory(at: hooksDirectory, withIntermediateDirectories: true) + + // Create source hook + let sourceHookDirectory = fileManager.homeDirectoryForCurrentUser.appending(component: ".luca") + try fileManager.createDirectory(at: sourceHookDirectory, withIntermediateDirectories: true) + let sourceHookPath = sourceHookDirectory.appending(component: "post-checkout") + try fileManager.writeString("#!/bin/sh\necho 'post-checkout hook'", to: sourceHookPath) + + // When + try sut.installPostCheckoutHook() + + // Then + let destinationHookPath = hooksDirectory.appending(component: "post-checkout") + XCTAssertTrue(fileManager.fileExists(atPath: destinationHookPath.path)) + + let copiedContent = try fileManager.readString(at: destinationHookPath) + XCTAssertEqual(copiedContent, "#!/bin/sh\necho 'post-checkout hook'") + + let attributes = try fileManager.attributesOfItem(atPath: destinationHookPath.path) + let permissions = attributes[.posixPermissions] as? Int + XCTAssertEqual(permissions, 0o755) + + XCTAssertEqual(printer.printedMessages.count, 1) + XCTAssertTrue(printer.printedMessages[0].contains("Installed post-checkout hook")) + } +} + +// MARK: - PrinterSpyMock + +final class PrinterSpyMock: Printing { + + private let noora = Noora() + var printedMessages: [String] = [] + + func printFormatted(_ terminalText: TerminalText) { + printedMessages.append(noora.format(terminalText)) + } +} diff --git a/Tests/Mocks/FileManagerWrapperMock.swift b/Tests/Mocks/FileManagerWrapperMock.swift index 3476911..5e4a26d 100644 --- a/Tests/Mocks/FileManagerWrapperMock.swift +++ b/Tests/Mocks/FileManagerWrapperMock.swift @@ -93,6 +93,10 @@ class FileManagerWrapperMock: FileManaging { try fileManager.attributesOfItem(atPath: path) } + func copyItem(at srcURL: URL, to dstURL: URL) throws { + try fileManager.copyItem(at: srcURL, to: dstURL) + } + func readString(at url: URL) throws -> String { try String(contentsOf: url, encoding: .utf8) }