diff --git a/src/cli.ts b/src/cli.ts index 7cecac23..1495c81b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -90,7 +90,7 @@ cli( process.exit(0); } - if (isCalledFromGitHook) { + if (isCalledFromGitHook()) { prepareCommitMessageHook(); } else { aicommits( @@ -100,7 +100,7 @@ cli( argv.flags.type, argv.flags.confirm, argv.flags.clipboard, - rawArgv + rawArgv, ); } }, diff --git a/src/commands/hook.ts b/src/commands/hook.ts index 8ba24174..0d398532 100644 --- a/src/commands/hook.ts +++ b/src/commands/hook.ts @@ -3,18 +3,18 @@ import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import { green, red } from 'kolorist'; import { command } from 'cleye'; -import { assertGitRepo } from '../utils/git.js'; +import { getGitHooksPath, getGitDir } from '../utils/git.js'; import { fileExists } from '../utils/fs.js'; import { KnownError, handleCliError } from '../utils/error.js'; const hookName = 'prepare-commit-msg'; -const symlinkPath = `.git/hooks/${hookName}`; const hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url)); -export const isCalledFromGitHook = process.argv[1] - .replace(/\\/g, '/') // Replace Windows back slashes with forward slashes - .endsWith(`/${symlinkPath}`); +export const isCalledFromGitHook = () => { + const scriptPath = process.argv[1].replace(/\\/g, '/'); + return scriptPath.endsWith(hookName) || scriptPath.includes('/hooks/'); +}; const isWindows = process.platform === 'win32'; const windowsHook = ` @@ -33,17 +33,23 @@ export default command( }, (argv) => { (async () => { - const gitRepoPath = await assertGitRepo(); + const gitDir = await getGitDir(); + const hooksPath = await getGitHooksPath(); const { installUninstall: mode } = argv._; - const absoltueSymlinkPath = path.join(gitRepoPath, symlinkPath); - const hookExists = await fileExists(absoltueSymlinkPath); + let absoluteHookPath: string; + if (path.isAbsolute(hooksPath)) { + absoluteHookPath = hooksPath; + } else { + absoluteHookPath = path.resolve(hooksPath); + } + + const absoluteSymlinkPath = path.join(absoluteHookPath, hookName); + const hookExists = await fileExists(absoluteSymlinkPath); if (mode === 'install') { if (hookExists) { - // If the symlink is broken, it will throw an error - // eslint-disable-next-line @typescript-eslint/no-empty-function const realpath = await fs - .realpath(absoltueSymlinkPath) + .realpath(absoluteSymlinkPath) .catch(() => {}); if (realpath === hookPath) { console.warn('The hook is already installed'); @@ -54,13 +60,13 @@ export default command( ); } - await fs.mkdir(path.dirname(absoltueSymlinkPath), { recursive: true }); + await fs.mkdir(path.dirname(absoluteSymlinkPath), { recursive: true }); if (isWindows) { - await fs.writeFile(absoltueSymlinkPath, windowsHook); + await fs.writeFile(absoluteSymlinkPath, windowsHook); } else { - await fs.symlink(hookPath, absoltueSymlinkPath, 'file'); - await fs.chmod(absoltueSymlinkPath, 0o755); + await fs.symlink(hookPath, absoluteSymlinkPath, 'file'); + await fs.chmod(absoluteSymlinkPath, 0o755); } console.log(`${green('✔')} Hook installed`); return; @@ -73,20 +79,20 @@ export default command( } if (isWindows) { - const scriptContent = await fs.readFile(absoltueSymlinkPath, 'utf8'); + const scriptContent = await fs.readFile(absoluteSymlinkPath, 'utf8'); if (scriptContent !== windowsHook) { console.warn('Hook is not installed'); return; } } else { - const realpath = await fs.realpath(absoltueSymlinkPath); + const realpath = await fs.realpath(absoluteSymlinkPath); if (realpath !== hookPath) { console.warn('Hook is not installed'); return; } } - await fs.rm(absoltueSymlinkPath); + await fs.rm(absoluteSymlinkPath); console.log(`${green('✔')} Hook uninstalled`); return; } diff --git a/src/utils/git.ts b/src/utils/git.ts index c5c9f4df..e6831e56 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -15,6 +15,44 @@ export const assertGitRepo = async () => { return stdout; }; +export const getGitHooksPath = async () => { + const { stdout, failed } = await execa( + 'git', + ['rev-parse', '--git-path', 'hooks'], + { reject: false } + ); + + if (failed) { + throw new KnownError('Failed to determine Git hooks directory'); + } + + return stdout; +}; + +export const getGitDir = async () => { + const { stdout, failed } = await execa( + 'git', + ['rev-parse', '--show-toplevel'], + { reject: false } + ); + + if (!failed) { + return stdout; + } + + const { stdout: gitDir, failed: gitDirFailed } = await execa( + 'git', + ['rev-parse', '--git-dir'], + { reject: false } + ); + + if (gitDirFailed) { + throw new KnownError('The current directory must be a Git repository!'); + } + + return gitDir; +}; + const excludeFromDiff = (path: string) => `:(exclude)${path}`; const filesToExclude = [ diff --git a/tests/specs/git-hook.ts b/tests/specs/git-hook.ts index 8fea48be..c4a9d6f4 100644 --- a/tests/specs/git-hook.ts +++ b/tests/specs/git-hook.ts @@ -1,20 +1,15 @@ import path from 'path'; +import fs from 'fs/promises'; import { testSuite, expect } from 'manten'; import { createFixture, createGit, files, } from '../utils.js'; +import { getGitHooksPath, getGitDir } from '../../src/utils/git.js'; export default testSuite(({ describe }) => { describe('Git hook', ({ test }) => { - if (!process.env.OPENAI_API_KEY) { - console.warn( - '⚠️ process.env.OPENAI_API_KEY is necessary to run these tests. Skipping...' - ); - return; - } - test('errors when not in Git repo', async () => { const { fixture, aicommits } = await createFixture(files); const { exitCode, stderr } = await aicommits(['hook', 'install'], { @@ -36,17 +31,108 @@ export default testSuite(({ describe }) => { }); await createGit(fixture.path); + const subDir = path.join(fixture.path, 'some-dir'); + const { exitCode } = await aicommits(['hook', 'install'], { + cwd: subDir, + }); + expect(exitCode).toBe(0); + + expect(await fixture.exists('.git/hooks/prepare-commit-msg')).toBe(true); + + await fixture.rm(); + }); + + test('uninstalls hook', async () => { + const { fixture, aicommits } = await createFixture(files); + await createGit(fixture.path); + + await aicommits(['hook', 'install']); + + const { stdout } = await aicommits(['hook', 'uninstall']); + expect(stdout).toMatch('Hook uninstalled'); + + const hooksPath = await getGitHooksPath(); + const gitDir = await getGitDir(); + const absoluteHookPath = path.isAbsolute(hooksPath) + ? hooksPath + : path.resolve(gitDir, hooksPath); + const hookFilePath = path.join(absoluteHookPath, 'prepare-commit-msg'); + expect(await fixture.exists(hookFilePath)).toBe(false); + + await fixture.rm(); + }); + + test('installs hook in git worktree', async () => { + const { fixture, aicommits } = await createFixture(files); + const git = await createGit(fixture.path); + + const worktreePath = path.join(fixture.path, 'wt1'); + await git('worktree', ['add', worktreePath]); + const { stdout } = await aicommits(['hook', 'install'], { - cwd: path.join(fixture.path, 'some-dir'), + cwd: worktreePath, }); expect(stdout).toMatch('Hook installed'); expect(await fixture.exists('.git/hooks/prepare-commit-msg')).toBe(true); + const worktreeGitPath = path.join(worktreePath, '.git'); + const stats = await fs.stat(worktreeGitPath); + expect(stats.isFile()).toBe(true); + + await fixture.rm(); + }); + + test('uninstalls hook from git worktree', async () => { + const { fixture, aicommits } = await createFixture(files); + const git = await createGit(fixture.path); + + const worktreePath = path.join(fixture.path, 'wt2'); + await git('worktree', ['add', worktreePath]); + await aicommits(['hook', 'install'], { cwd: worktreePath }); + + const { stdout } = await aicommits(['hook', 'uninstall'], { + cwd: worktreePath, + }); + expect(stdout).toMatch('Hook uninstalled'); + + expect(await fixture.exists('.git/hooks/prepare-commit-msg')).toBe(false); + + await fixture.rm(); + }); + + test('installs hook in bare repository', async () => { + const { fixture, aicommits } = await createFixture(files); + const git = await createGit(fixture.path); + + await git('add', ['.']); + await git('commit', ['-m', 'initial']); + + const barePath = path.join(fixture.path, 'bare.git'); + await git('clone', ['--bare', fixture.path, barePath]); + + const { stdout } = await aicommits(['hook', 'install'], { + cwd: barePath, + }); + expect(stdout).toMatch('Hook installed'); + + try { + await fs.access(path.join(barePath, 'hooks', 'prepare-commit-msg')); + } catch { + throw new Error('Hook file does not exist in bare repository'); + } + await fixture.rm(); }); test('Commits', async () => { + if (!process.env.OPENAI_API_KEY) { + console.warn( + '⚠️ process.env.OPENAI_API_KEY is necessary to run this test. Skipping...' + ); + return; + } + const { fixture, aicommits } = await createFixture(files); const git = await createGit(fixture.path);