ReviewOS

also looking at this

stacks/gitlint

fix: support linked Git worktrees

#551
Merged chris wants to merge agent/worktree-git-paths into main
4 files +122 -13
package.jsonmodified+1-1
Changes to package.json
@@ -80,7 +80,7 @@
8080 },
8181 "autoRestage": true
8282 },
83 "commit-msg": "bunx gitlint --edit .git/COMMIT_EDITMSG"
83 "commit-msg": "bunx gitlint --edit \"$(git rev-parse --git-path COMMIT_EDITMSG)\""
8484 },
8585 "overrides": {
8686 "typescript": "^7.0.2"
src/hooks.tsmodified+30-10
Changes to src/hooks.ts
@@ -1,13 +1,14 @@
1import { execSync } from 'node:child_process'
1import { execFileSync } from 'node:child_process'
22import fs from 'node:fs'
33import path from 'node:path'
4import process from 'node:process'
45
56/**
67 * Find the Git repository root directory
78 */
8function findGitRoot(): string | null {
9function findGitRoot(cwd: string): string | null {
910 try {
10 const gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
11 const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' }).trim()
1112 return gitRoot
1213 }
1314 // eslint-disable-next-line unused-imports/no-unused-vars
@@ -16,18 +17,32 @@ function findGitRoot(): string | null {
1617 }
1718}
1819
20/** Resolve the effective hooks directory for main checkouts and linked worktrees. */
21export function resolveHooksDirectory(cwd: string = process.cwd()): string | null {
22 try {
23 const hooksPath = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
24 cwd,
25 encoding: 'utf8',
26 }).trim()
27 return path.resolve(cwd, hooksPath)
28 }
29 catch {
30 return null
31 }
32}
33
1934/**
2035 * Install Git hooks for the current repository
2136 */
22export function installGitHooks(force = false): boolean {
23 const gitRoot = findGitRoot()
37export function installGitHooks(force = false, cwd: string = process.cwd()): boolean {
38 const gitRoot = findGitRoot(cwd)
2439 if (!gitRoot) {
2540 console.error('Not a git repository')
2641 return false
2742 }
2843
29 const hooksDir = path.join(gitRoot, '.git', 'hooks')
30 if (!fs.existsSync(hooksDir)) {
44 const hooksDir = resolveHooksDirectory(cwd)
45 if (!hooksDir || !fs.existsSync(hooksDir)) {
3146 console.error(`Git hooks directory not found: ${hooksDir}`)
3247 return false
3348 }
@@ -63,14 +78,19 @@ gitlint --edit "$1"
6378/**
6479 * Uninstall Git hooks for the current repository
6580 */
66export function uninstallGitHooks(): boolean {
67 const gitRoot = findGitRoot()
81export function uninstallGitHooks(cwd: string = process.cwd()): boolean {
82 const gitRoot = findGitRoot(cwd)
6883 if (!gitRoot) {
6984 console.error('Not a git repository')
7085 return false
7186 }
7287
73 const commitMsgHookPath = path.join(gitRoot, '.git', 'hooks', 'commit-msg')
88 const hooksDir = resolveHooksDirectory(cwd)
89 if (!hooksDir) {
90 console.error('Git hooks directory could not be resolved')
91 return false
92 }
93 const commitMsgHookPath = path.join(hooksDir, 'commit-msg')
7494 if (!fs.existsSync(commitMsgHookPath)) {
7595 console.error('No commit-msg hook found')
7696 return true
src/utils.tsmodified+24-2
Changes to src/utils.ts
@@ -1,13 +1,35 @@
1import { execFileSync } from 'node:child_process'
12import fs from 'node:fs'
23import path from 'node:path'
34import process from 'node:process'
45
6/** Resolve a path below `.git` through Git so linked worktrees are supported. */
7export function resolveCommitMessagePath(filePath: string, cwd: string = process.cwd()): string {
8 const absolutePath = path.resolve(cwd, filePath)
9 const relativePath = path.relative(cwd, absolutePath).split(path.sep).join('/')
10 if (relativePath !== '.git' && !relativePath.startsWith('.git/')) return absolutePath
11
12 const gitPath = relativePath === '.git' ? '' : relativePath.slice('.git/'.length)
13 if (!gitPath) return absolutePath
14
15 try {
16 const resolved = execFileSync('git', ['rev-parse', '--git-path', gitPath], {
17 cwd,
18 encoding: 'utf8',
19 }).trim()
20 return path.resolve(cwd, resolved)
21 }
22 catch {
23 return absolutePath
24 }
25}
26
527/**
628 * Read the commit message from a file
729 */
8export function readCommitMessageFromFile(filePath: string): string {
30export function readCommitMessageFromFile(filePath: string, cwd: string = process.cwd()): string {
931 try {
10 return fs.readFileSync(path.resolve(process.cwd(), filePath), 'utf8')
32 return fs.readFileSync(resolveCommitMessagePath(filePath, cwd), 'utf8')
1133 }
1234 catch (error: unknown) {
1335 console.error(`Error reading commit message file: ${filePath}`)
test/worktree.test.tsadded+67-0
Changes to test/worktree.test.ts
@@ -0,0 +1,67 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { execFileSync } from 'node:child_process'
3import fs from 'node:fs'
4import os from 'node:os'
5import path from 'node:path'
6import { installGitHooks, resolveHooksDirectory, uninstallGitHooks } from '../src/hooks'
7import { readCommitMessageFromFile, resolveCommitMessagePath } from '../src/utils'
8
9const temporaryRoots: string[] = []
10
11function git(cwd: string, ...args: string[]): string {
12 return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim()
13}
14
15function linkedWorktree(): { repository: string, worktree: string } {
16 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitlint-worktree-'))
17 temporaryRoots.push(root)
18 const repository = path.join(root, 'repository')
19 const worktree = path.join(root, 'linked')
20 fs.mkdirSync(repository)
21 git(repository, 'init', '--initial-branch=main')
22 git(repository, 'config', 'user.name', 'Gitlint Test')
23 git(repository, 'config', 'user.email', 'gitlint@example.com')
24 fs.writeFileSync(path.join(repository, 'README.md'), '# fixture\n')
25 git(repository, 'add', 'README.md')
26 git(repository, 'commit', '-m', 'chore: initialize fixture')
27 git(repository, 'worktree', 'add', '-b', 'test-worktree', worktree)
28 return { repository, worktree }
29}
30
31afterEach(() => {
32 for (const root of temporaryRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true })
33})
34
35describe('linked worktree support', () => {
36 it('resolves .git commit message paths through Git', () => {
37 const { worktree } = linkedWorktree()
38 expect(fs.statSync(path.join(worktree, '.git')).isFile()).toBeTrue()
39 const actualPath = path.resolve(worktree, git(worktree, 'rev-parse', '--git-path', 'COMMIT_EDITMSG'))
40 fs.writeFileSync(actualPath, 'feat: support linked worktrees\n')
41
42 expect(resolveCommitMessagePath('.git/COMMIT_EDITMSG', worktree)).toBe(actualPath)
43 expect(readCommitMessageFromFile('.git/COMMIT_EDITMSG', worktree)).toBe('feat: support linked worktrees\n')
44 })
45
46 it('installs and removes hooks in the effective Git hooks directory', () => {
47 const { worktree } = linkedWorktree()
48 const hooksDirectory = path.resolve(worktree, git(worktree, 'rev-parse', '--git-path', 'hooks'))
49 const hookPath = path.join(hooksDirectory, 'commit-msg')
50
51 expect(resolveHooksDirectory(worktree)).toBe(hooksDirectory)
52 expect(installGitHooks(true, worktree)).toBeTrue()
53 expect(fs.readFileSync(hookPath, 'utf8')).toContain('gitlint --edit "$1"')
54 expect(uninstallGitHooks(worktree)).toBeTrue()
55 expect(fs.existsSync(hookPath)).toBeFalse()
56 })
57
58 it('preserves ordinary absolute and relative paths', () => {
59 const { worktree } = linkedWorktree()
60 const relativePath = 'message.txt'
61 const absolutePath = path.join(worktree, relativePath)
62 fs.writeFileSync(absolutePath, 'fix: ordinary path\n')
63
64 expect(resolveCommitMessagePath(relativePath, worktree)).toBe(absolutePath)
65 expect(resolveCommitMessagePath(absolutePath, worktree)).toBe(absolutePath)
66 })
67})