From 2d5a6a678a779bd7e8198da6c1a69b2f00a3d847 Mon Sep 17 00:00:00 2001 From: Alberto De Bortoli Date: Thu, 9 Apr 2026 00:14:51 +0100 Subject: [PATCH] Skip post-checkout hook installation in git worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a git worktree, `.git` is a regular file (containing a gitdir pointer) rather than a directory. The previous code used `fileExists(atPath:)` to detect whether the current directory was a git repository, but that method returns true for both files and directories. As a result, `GitHookInstaller` would proceed past the guard and attempt to copy the hook to `.git/hooks/post-checkout` — a path that cannot exist when `.git` is a file — causing an error. Additionally, when the post-checkout hook fires in a worktree context (git shares the main repo's hooks across all worktrees), it invokes `luca install`, which would re-trigger the same failing installation. Fix: after confirming `.git` exists, check that it is a directory using `attributesOfItem(atPath:)`. If it is a file (worktree), return early — hooks are owned by the main repository. Add `attributesOfItem(atPath:)` to `GitHookInstallerFileManaging` and a new test covering the worktree case. --- .../GitHookInstallerFileManaging.swift | 1 + .../Core/GitHookInstaller/GitHookInstaller.swift | 6 ++++++ Tests/Core/GitHookInstallerTests.swift | 16 ++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/Sources/LucaCore/Core/FileManagerProtocols/GitHookInstallerFileManaging.swift b/Sources/LucaCore/Core/FileManagerProtocols/GitHookInstallerFileManaging.swift index b630ac9..d5b5b35 100644 --- a/Sources/LucaCore/Core/FileManagerProtocols/GitHookInstallerFileManaging.swift +++ b/Sources/LucaCore/Core/FileManagerProtocols/GitHookInstallerFileManaging.swift @@ -12,4 +12,5 @@ public protocol GitHookInstallerFileManaging { func readString(at url: URL) throws -> String func writeString(_ content: String, to url: URL) throws func setAttributes(_ attributes: [FileAttributeKey: Any], ofItemAtPath path: String) throws + func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] } diff --git a/Sources/LucaCore/Core/GitHookInstaller/GitHookInstaller.swift b/Sources/LucaCore/Core/GitHookInstaller/GitHookInstaller.swift index ba74c6a..b527ebb 100644 --- a/Sources/LucaCore/Core/GitHookInstaller/GitHookInstaller.swift +++ b/Sources/LucaCore/Core/GitHookInstaller/GitHookInstaller.swift @@ -30,6 +30,12 @@ public struct GitHookInstaller { return } + // In a git worktree, `.git` is a file (not a directory) pointing to the main repo. + // Hooks live in the main repo; skip installation to avoid failing in a worktree context. + guard (try? fileManager.attributesOfItem(atPath: gitDirectory.path)[.type] as? FileAttributeType) == .typeDirectory else { + return + } + let sourceHookPath = fileManager.homeDirectoryForCurrentUser .appending(components: Constants.toolFolder, "post-checkout") diff --git a/Tests/Core/GitHookInstallerTests.swift b/Tests/Core/GitHookInstallerTests.swift index eb691e4..52b8d7d 100644 --- a/Tests/Core/GitHookInstallerTests.swift +++ b/Tests/Core/GitHookInstallerTests.swift @@ -40,6 +40,22 @@ final class GitHookInstallerTests: XCTestCase { XCTAssertTrue(printer.printedMessages.isEmpty) } + // MARK: - Git Worktree + + func test_installPostCheckoutHook_whenGitWorktree_doesNothing() throws { + // Given: `.git` is a file (worktree), not a directory + let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) + try fileManager.createDirectory(at: currentDirectory, withIntermediateDirectories: true) + let gitFile = currentDirectory.appending(component: ".git") + _ = fileManager.createFile(atPath: gitFile.path, contents: Data("gitdir: /some/main/repo/.git/worktrees/feature\n".utf8)) + + // When + try sut.installPostCheckoutHook() + + // Then + XCTAssertTrue(printer.printedMessages.isEmpty) + } + // MARK: - Source Hook Missing func test_installPostCheckoutHook_whenSourceHookMissing_printsWarning() throws {