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/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"); } 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 4c48c1557a..7faca366ec 100644 --- a/@commitlint/top-level/package.json +++ b/@commitlint/top-level/package.json @@ -36,10 +36,11 @@ }, "license": "MIT", "devDependencies": { - "@commitlint/utils": "^20.0.0" - }, - "dependencies": { - "find-up": "^7.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)); + }); +}); 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; + } }