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
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ cli(
process.exit(0);
}

if (isCalledFromGitHook) {
if (isCalledFromGitHook()) {
prepareCommitMessageHook();
} else {
aicommits(
Expand All @@ -100,7 +100,7 @@ cli(
argv.flags.type,
argv.flags.confirm,
argv.flags.clipboard,
rawArgv
rawArgv,
);
}
},
Expand Down
42 changes: 24 additions & 18 deletions src/commands/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand All @@ -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');
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
38 changes: 38 additions & 0 deletions src/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
102 changes: 94 additions & 8 deletions tests/specs/git-hook.ts
Original file line number Diff line number Diff line change
@@ -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'], {
Expand All @@ -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);

Expand Down