From d96cb7a956d459e79343a8a413edff05c0f83189 Mon Sep 17 00:00:00 2001 From: Adam Wojasinski Date: Thu, 8 Jan 2026 11:42:21 +0100 Subject: [PATCH 1/3] fix(top-level): use git rev-parse for worktree support Replace custom find-up implementation with git rev-parse --show-toplevel. This correctly handles git worktrees, submodules, and regular repositories. Also improves Windows path normalization by using resolve() before realpathSync() to handle Windows short path (8.3 format) vs long path discrepancies between Git output and Node.js filesystem APIs. Fixes #787 --- @commitlint/top-level/package.json | 3 -- @commitlint/top-level/src/index.ts | 58 ++++++++++++++++++++---------- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/@commitlint/top-level/package.json b/@commitlint/top-level/package.json index 4c48c1557a..5b562726fc 100644 --- a/@commitlint/top-level/package.json +++ b/@commitlint/top-level/package.json @@ -38,8 +38,5 @@ "devDependencies": { "@commitlint/utils": "^20.0.0" }, - "dependencies": { - "find-up": "^7.0.0" - }, "gitHead": "e82f05a737626bb69979d14564f5ff601997f679" } diff --git a/@commitlint/top-level/src/index.ts b/@commitlint/top-level/src/index.ts index 4996d4f8ee..46c3c02dd8 100644 --- a/@commitlint/top-level/src/index.ts +++ b/@commitlint/top-level/src/index.ts @@ -1,27 +1,47 @@ -import path from "node:path"; -import { findUp } from "find-up"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { realpathSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const execFileAsync = promisify(execFile); export default toplevel; /** - * Find the next git root + * Find the git root directory using git rev-parse. + * This correctly handles git worktrees, submodules, and regular repositories. */ -async function toplevel(cwd?: string) { - const found = await searchDotGit(cwd); - - if (typeof found !== "string") { - return found; - } - - return path.join(found, ".."); -} +async function toplevel(cwd?: string): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["rev-parse", "--show-toplevel"], + { + cwd, + }, + ); -/** - * Search .git, the '.git' can be a file(submodule), also can be a directory(normal) - */ -async function searchDotGit(cwd?: string) { - const foundFile = await findUp(".git", { cwd, type: "file" }); - const foundDir = await findUp(".git", { cwd, type: "directory" }); + const topLevel = stdout.trim(); + if (topLevel) { + // Resolve symlinks and normalize path on Windows to handle short/long path names + // Git may return long paths while Node.js uses short paths (or vice versa) + // We need to resolve through the filesystem to ensure consistency + try { + // First resolve the path (handles relative paths and normalizes) + const resolvedPath = resolve(topLevel); + // Then use realpathSync to resolve symlinks and get canonical path + // On Windows, this also handles 8.3 short name conversions + if (existsSync(resolvedPath)) { + return realpathSync(resolvedPath); + } + return resolvedPath; + } catch { + return topLevel; + } + } - return foundFile || foundDir; + return undefined; + } catch { + return undefined; + } } From 28995258e7518b83cccaa4693c6529047aa50a46 Mon Sep 17 00:00:00 2001 From: Adam Wojasinski Date: Thu, 8 Jan 2026 11:42:40 +0100 Subject: [PATCH 2/3] fix(read): use git rev-parse --git-dir for worktree support Replace custom .git detection with git rev-parse --git-dir. This correctly resolves the git directory in worktrees where .git is a file pointing to the actual git directory. --- @commitlint/read/src/get-edit-file-path.ts | 24 ++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/@commitlint/read/src/get-edit-file-path.ts b/@commitlint/read/src/get-edit-file-path.ts index b323dce1e2..b4e96cc1f8 100644 --- a/@commitlint/read/src/get-edit-file-path.ts +++ b/@commitlint/read/src/get-edit-file-path.ts @@ -1,6 +1,8 @@ import path from "node:path"; -import { Stats } from "node:fs"; -import fs from "fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); // Get path to recently edited commit message file export async function getEditFilePath( @@ -11,16 +13,12 @@ export async function getEditFilePath( return path.resolve(top, edit); } - const dotgitPath = path.join(top, ".git"); - const dotgitStats: Stats = await fs.lstat(dotgitPath); - - if (dotgitStats.isDirectory()) { - return path.join(top, ".git/COMMIT_EDITMSG"); - } - - const gitFile: string = await fs.readFile(dotgitPath, { - encoding: "utf-8", + // Use git rev-parse --git-dir to get the correct git directory + // This handles worktrees, submodules, and regular repositories correctly + const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], { + cwd: top, }); - const relativeGitPath = gitFile.replace("gitdir: ", "").replace("\n", ""); - return path.resolve(top, relativeGitPath, "COMMIT_EDITMSG"); + + const gitDir = stdout.trim(); + return path.resolve(top, gitDir, "COMMIT_EDITMSG"); } From 8a3b4e843645f772170df97089f1bdbcc7b3d2e4 Mon Sep 17 00:00:00 2001 From: Adam Wojasinski Date: Thu, 8 Jan 2026 11:44:42 +0100 Subject: [PATCH 3/3] test: add git worktree tests for top-level and read Add comprehensive tests for git worktree support: - Test toplevel() works correctly in worktrees - Test toplevel() works from subdirectories in worktrees - Test reading edit commit message from worktree --- @commitlint/read/package.json | 6 +- @commitlint/read/src/read.test.ts | 48 +++++++++ @commitlint/top-level/package.json | 6 +- @commitlint/top-level/src/index.test.ts | 127 ++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 @commitlint/top-level/src/index.test.ts diff --git a/@commitlint/read/package.json b/@commitlint/read/package.json index 18392d5a74..df4dc76a69 100644 --- a/@commitlint/read/package.json +++ b/@commitlint/read/package.json @@ -38,8 +38,12 @@ "devDependencies": { "@commitlint/test": "^20.0.0", "@commitlint/utils": "^20.0.0", + "@types/fs-extra": "^11.0.3", "@types/git-raw-commits": "^2.0.3", - "@types/minimist": "^1.2.4" + "@types/minimist": "^1.2.4", + "@types/tmp": "^0.2.5", + "fs-extra": "^11.0.0", + "tmp": "^0.2.1" }, "dependencies": { "@commitlint/top-level": "^20.0.0", diff --git a/@commitlint/read/src/read.test.ts b/@commitlint/read/src/read.test.ts index c1903b1609..c727a429ce 100644 --- a/@commitlint/read/src/read.test.ts +++ b/@commitlint/read/src/read.test.ts @@ -1,8 +1,10 @@ import { test, expect } from "vitest"; import fs from "fs/promises"; +import fsExtra from "fs-extra"; import path from "node:path"; import { git } from "@commitlint/test"; import { x } from "tinyexec"; +import tmp from "tmp"; import read from "./read.js"; @@ -150,3 +152,49 @@ test("should not read any commits when there are no tags", async () => { expect(result).toHaveLength(0); }); + +test("get edit commit message from git worktree", async () => { + const tmpDir = tmp.dirSync({ keep: false, unsafeCleanup: true }); + const mainRepoDir = path.join(tmpDir.name, "main"); + const worktreeDir = path.join(tmpDir.name, "worktree"); + + // Initialize main repo + await fsExtra.mkdirp(mainRepoDir); + await x("git", ["init"], { nodeOptions: { cwd: mainRepoDir } }); + await x("git", ["config", "user.email", "test@example.com"], { + nodeOptions: { cwd: mainRepoDir }, + }); + await x("git", ["config", "user.name", "test"], { + nodeOptions: { cwd: mainRepoDir }, + }); + await x("git", ["config", "commit.gpgsign", "false"], { + nodeOptions: { cwd: mainRepoDir }, + }); + + // Create initial commit in main repo + await fs.writeFile(path.join(mainRepoDir, "file.txt"), "content"); + await x("git", ["add", "."], { nodeOptions: { cwd: mainRepoDir } }); + await x("git", ["commit", "-m", "initial"], { + nodeOptions: { cwd: mainRepoDir }, + }); + + // Create a branch and worktree + await x("git", ["branch", "worktree-branch"], { + nodeOptions: { cwd: mainRepoDir }, + }); + await x("git", ["worktree", "add", worktreeDir, "worktree-branch"], { + nodeOptions: { cwd: mainRepoDir }, + }); + + // Make a commit in the worktree + await fs.writeFile(path.join(worktreeDir, "worktree-file.txt"), "worktree"); + await x("git", ["add", "."], { nodeOptions: { cwd: worktreeDir } }); + await x("git", ["commit", "-m", "worktree commit"], { + nodeOptions: { cwd: worktreeDir }, + }); + + // Read the edit commit message from the worktree + const expected = ["worktree commit\n\n"]; + const actual = await read({ edit: true, cwd: worktreeDir }); + expect(actual).toEqual(expected); +}); diff --git a/@commitlint/top-level/package.json b/@commitlint/top-level/package.json index 5b562726fc..7faca366ec 100644 --- a/@commitlint/top-level/package.json +++ b/@commitlint/top-level/package.json @@ -36,7 +36,11 @@ }, "license": "MIT", "devDependencies": { - "@commitlint/utils": "^20.0.0" + "@commitlint/utils": "^20.0.0", + "@types/fs-extra": "^11.0.3", + "@types/tmp": "^0.2.5", + "fs-extra": "^11.0.0", + "tmp": "^0.2.1" }, "gitHead": "e82f05a737626bb69979d14564f5ff601997f679" } diff --git a/@commitlint/top-level/src/index.test.ts b/@commitlint/top-level/src/index.test.ts new file mode 100644 index 0000000000..bc85231fb0 --- /dev/null +++ b/@commitlint/top-level/src/index.test.ts @@ -0,0 +1,127 @@ +import { test, expect, describe } from "vitest"; +import path from "node:path"; +import fs from "fs-extra"; +import tmp from "tmp"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { realpathSync } from "node:fs"; + +import toplevel from "./index.js"; + +const execFileAsync = promisify(execFile); + +/** + * Normalize a path for cross-platform comparison. + * On Windows, tmp paths may use short names (e.g., RUNNER~1) while git returns long names. + * This resolves symlinks and normalizes the path format. + */ +function normalizePath(p: string): string { + return realpathSync(p).replace(/\\/g, "/"); +} + +async function initGitRepo(cwd: string): Promise { + await execFileAsync("git", ["init"], { cwd }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { + cwd, + }); + await execFileAsync("git", ["config", "user.name", "test"], { cwd }); + await execFileAsync("git", ["config", "commit.gpgsign", "false"], { cwd }); +} + +describe("toplevel", () => { + test("should return git root for a regular repository", async () => { + const tmpDir = tmp.dirSync({ keep: false, unsafeCleanup: true }); + const repoDir = tmpDir.name; + + await initGitRepo(repoDir); + + const result = await toplevel(repoDir); + expect(normalizePath(result!)).toBe(normalizePath(repoDir)); + }); + + test("should return git root from a subdirectory", async () => { + const tmpDir = tmp.dirSync({ keep: false, unsafeCleanup: true }); + const repoDir = tmpDir.name; + + await initGitRepo(repoDir); + + const subDir = path.join(repoDir, "sub", "dir"); + await fs.mkdirp(subDir); + + const result = await toplevel(subDir); + expect(normalizePath(result!)).toBe(normalizePath(repoDir)); + }); + + test("should return undefined for a non-git directory", async () => { + const tmpDir = tmp.dirSync({ keep: false, unsafeCleanup: true }); + + const result = await toplevel(tmpDir.name); + expect(result).toBeUndefined(); + }); + + test("should work with git worktrees", async () => { + const tmpDir = tmp.dirSync({ keep: false, unsafeCleanup: true }); + const mainRepoDir = path.join(tmpDir.name, "main"); + const worktreeDir = path.join(tmpDir.name, "worktree"); + + await fs.mkdirp(mainRepoDir); + await initGitRepo(mainRepoDir); + + // Create an initial commit (required for worktree) + await fs.writeFile(path.join(mainRepoDir, "file.txt"), "content"); + await execFileAsync("git", ["add", "."], { cwd: mainRepoDir }); + await execFileAsync("git", ["commit", "-m", "initial"], { + cwd: mainRepoDir, + }); + + // Create a new branch for the worktree + await execFileAsync("git", ["branch", "worktree-branch"], { + cwd: mainRepoDir, + }); + + // Create the worktree + await execFileAsync( + "git", + ["worktree", "add", worktreeDir, "worktree-branch"], + { cwd: mainRepoDir }, + ); + + // toplevel should return the worktree directory, not the main repo + const result = await toplevel(worktreeDir); + expect(normalizePath(result!)).toBe(normalizePath(worktreeDir)); + }); + + test("should work from a subdirectory of a git worktree", async () => { + const tmpDir = tmp.dirSync({ keep: false, unsafeCleanup: true }); + const mainRepoDir = path.join(tmpDir.name, "main"); + const worktreeDir = path.join(tmpDir.name, "worktree"); + + await fs.mkdirp(mainRepoDir); + await initGitRepo(mainRepoDir); + + // Create an initial commit + await fs.writeFile(path.join(mainRepoDir, "file.txt"), "content"); + await execFileAsync("git", ["add", "."], { cwd: mainRepoDir }); + await execFileAsync("git", ["commit", "-m", "initial"], { + cwd: mainRepoDir, + }); + + // Create a new branch and worktree + await execFileAsync("git", ["branch", "worktree-branch"], { + cwd: mainRepoDir, + }); + await execFileAsync( + "git", + ["worktree", "add", worktreeDir, "worktree-branch"], + { cwd: mainRepoDir }, + ); + + // Create a subdirectory in the worktree + const subDir = path.join(worktreeDir, "sub", "dir"); + await fs.mkdirp(subDir); + + // toplevel from subdirectory should return the worktree root + const result = await toplevel(subDir); + expect(normalizePath(result!)).toBe(normalizePath(worktreeDir)); + }); +});