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
8 changes: 8 additions & 0 deletions Sources/LucaCLI/Commands/InstallCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}

Expand Down
4 changes: 4 additions & 0 deletions Sources/LucaCLI/Utils/FileManagerWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/LucaCore/Core/FileManagerProtocols/FileManaging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public protocol FileManaging:
BinaryFinderFileManaging,
ChecksumValidatorFileManaging,
FileTypeDetectorFileManaging,
GitHookInstallerFileManaging,
GitIgnoreFileManaging,
InstalledToolsFileManaging,
PermissionManagerFileManaging,
Expand All @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions Sources/LucaCore/Core/GitHookInstaller/GitHookInstaller.swift
Original file line number Diff line number Diff line change
@@ -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"))")
}
}
4 changes: 2 additions & 2 deletions Sources/LucaCore/Core/GitIgnoreManager/GitIgnoreManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"))")
}
}
}
1 change: 0 additions & 1 deletion Sources/LucaCore/Core/Installer/Installer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
136 changes: 136 additions & 0 deletions Tests/Core/GitHookInstallerTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
4 changes: 4 additions & 0 deletions Tests/Mocks/FileManagerWrapperMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down