Skip to content
Open
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
6 changes: 5 additions & 1 deletion @commitlint/read/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 11 additions & 13 deletions @commitlint/read/src/get-edit-file-path.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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");
}
48 changes: 48 additions & 0 deletions @commitlint/read/src/read.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
9 changes: 5 additions & 4 deletions @commitlint/top-level/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
127 changes: 127 additions & 0 deletions @commitlint/top-level/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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));

Check failure on line 39 in @commitlint/top-level/src/index.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-2022, 20)

@commitlint/top-level/src/index.test.ts > toplevel > should return git root for a regular repository

AssertionError: expected 'C:/Users/runneradmin/AppData/Local/Te…' to be 'C:/Users/RUNNER~1/AppData/Local/Temp/…' // Object.is equality Expected: "C:/Users/RUNNER~1/AppData/Local/Temp/tmp-5316-gdiMcU3g5fm0" Received: "C:/Users/runneradmin/AppData/Local/Temp/tmp-5316-gdiMcU3g5fm0" ❯ @commitlint/top-level/src/index.test.ts:39:34
});

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));

Check failure on line 52 in @commitlint/top-level/src/index.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-2022, 20)

@commitlint/top-level/src/index.test.ts > toplevel > should return git root from a subdirectory

AssertionError: expected 'C:/Users/runneradmin/AppData/Local/Te…' to be 'C:/Users/RUNNER~1/AppData/Local/Temp/…' // Object.is equality Expected: "C:/Users/RUNNER~1/AppData/Local/Temp/tmp-5316-T1VI9WNegnol" Received: "C:/Users/runneradmin/AppData/Local/Temp/tmp-5316-T1VI9WNegnol" ❯ @commitlint/top-level/src/index.test.ts:52:34
});

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));

Check failure on line 91 in @commitlint/top-level/src/index.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-2022, 20)

@commitlint/top-level/src/index.test.ts > toplevel > should work with git worktrees

AssertionError: expected 'C:/Users/runneradmin/AppData/Local/Te…' to be 'C:/Users/RUNNER~1/AppData/Local/Temp/…' // Object.is equality Expected: "C:/Users/RUNNER~1/AppData/Local/Temp/tmp-5316-bjvo0pHOzMQG/worktree" Received: "C:/Users/runneradmin/AppData/Local/Temp/tmp-5316-bjvo0pHOzMQG/worktree" ❯ @commitlint/top-level/src/index.test.ts:91:34
});

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));

Check failure on line 125 in @commitlint/top-level/src/index.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-2022, 20)

@commitlint/top-level/src/index.test.ts > toplevel > should work from a subdirectory of a git worktree

AssertionError: expected 'C:/Users/runneradmin/AppData/Local/Te…' to be 'C:/Users/RUNNER~1/AppData/Local/Temp/…' // Object.is equality Expected: "C:/Users/RUNNER~1/AppData/Local/Temp/tmp-5316-g1kL9DfIylAe/worktree" Received: "C:/Users/runneradmin/AppData/Local/Temp/tmp-5316-g1kL9DfIylAe/worktree" ❯ @commitlint/top-level/src/index.test.ts:125:34
});
});
58 changes: 39 additions & 19 deletions @commitlint/top-level/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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;
}
}
Loading