fix(hook): Windows support (#176)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath, pathToFileURL } from 'url';
|
||||||
import { green, red } from 'kolorist';
|
import { green, red } from 'kolorist';
|
||||||
import { command } from 'cleye';
|
import { command } from 'cleye';
|
||||||
import { assertGitRepo } from '../utils/git.js';
|
import { assertGitRepo } from '../utils/git.js';
|
||||||
@@ -10,14 +10,24 @@ import { KnownError, handleCliError } from '../utils/error.js';
|
|||||||
const hookName = 'prepare-commit-msg';
|
const hookName = 'prepare-commit-msg';
|
||||||
const symlinkPath = `.git/hooks/${hookName}`;
|
const symlinkPath = `.git/hooks/${hookName}`;
|
||||||
|
|
||||||
export const isCalledFromGitHook = process.argv[1].endsWith(`/${symlinkPath}`);
|
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}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const windowsHook = `
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import(${JSON.stringify(pathToFileURL(hookPath))})
|
||||||
|
`.trim();
|
||||||
|
|
||||||
export default command({
|
export default command({
|
||||||
name: 'hook',
|
name: 'hook',
|
||||||
parameters: ['<install/uninstall>'],
|
parameters: ['<install/uninstall>'],
|
||||||
}, (argv) => {
|
}, (argv) => {
|
||||||
const hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url));
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await assertGitRepo();
|
await assertGitRepo();
|
||||||
|
|
||||||
@@ -29,7 +39,7 @@ export default command({
|
|||||||
// If the symlink is broken, it will throw an error
|
// If the symlink is broken, it will throw an error
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
const realpath = await fs.realpath(symlinkPath).catch(() => {});
|
const realpath = await fs.realpath(symlinkPath).catch(() => {});
|
||||||
if (realpath === hookPath) {
|
if (realpath === hookPath) {
|
||||||
console.warn('The hook is already installed');
|
console.warn('The hook is already installed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -37,8 +47,16 @@ export default command({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(symlinkPath), { recursive: true });
|
await fs.mkdir(path.dirname(symlinkPath), { recursive: true });
|
||||||
await fs.symlink(hookPath, symlinkPath, 'file');
|
|
||||||
await fs.chmod(symlinkPath, 0o755);
|
if (isWindows) {
|
||||||
|
await fs.writeFile(
|
||||||
|
symlinkPath,
|
||||||
|
windowsHook,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await fs.symlink(hookPath, symlinkPath, 'file');
|
||||||
|
await fs.chmod(symlinkPath, 0o755);
|
||||||
|
}
|
||||||
console.log(`${green('✔')} Hook installed`);
|
console.log(`${green('✔')} Hook installed`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -48,10 +66,19 @@ export default command({
|
|||||||
console.warn('Hook is not installed');
|
console.warn('Hook is not installed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const realpath = await fs.realpath(symlinkPath);
|
|
||||||
if (realpath !== hookPath) {
|
if (isWindows) {
|
||||||
console.warn('Hook is not installed');
|
const scriptContent = await fs.readFile(symlinkPath, 'utf8');
|
||||||
return;
|
if (scriptContent !== windowsHook) {
|
||||||
|
console.warn('Hook is not installed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const realpath = await fs.realpath(symlinkPath);
|
||||||
|
if (realpath !== hookPath) {
|
||||||
|
console.warn('Hook is not installed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.rm(symlinkPath);
|
await fs.rm(symlinkPath);
|
||||||
|
|||||||
@@ -50,14 +50,32 @@ export default () => (async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
s.stop('Changes analyzed');
|
s.stop('Changes analyzed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When `--no-edit` is passed in, the base commit message is empty,
|
||||||
|
* and even when you use pass in comments via #, they are ignored.
|
||||||
|
*
|
||||||
|
* Note: `--no-edit` cannot be detected in argvs so this is the only way to check
|
||||||
|
*/
|
||||||
|
const baseMessage = await fs.readFile(messageFilePath, 'utf8');
|
||||||
|
const supportsComments = baseMessage !== '';
|
||||||
const hasMultipleMessages = messages.length > 1;
|
const hasMultipleMessages = messages.length > 1;
|
||||||
let instructions = `# 🤖 AI generated commit${hasMultipleMessages ? 's' : ''}\n`;
|
|
||||||
|
let instructions = '';
|
||||||
|
|
||||||
|
if (supportsComments) {
|
||||||
|
instructions = `# 🤖 AI generated commit${hasMultipleMessages ? 's' : ''}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasMultipleMessages) {
|
if (hasMultipleMessages) {
|
||||||
instructions += '# Select one of the following messages by uncommeting:\n';
|
if (supportsComments) {
|
||||||
|
instructions += '# Select one of the following messages by uncommeting:\n';
|
||||||
|
}
|
||||||
instructions += `\n${messages.map(message => `# ${message}`).join('\n')}`;
|
instructions += `\n${messages.map(message => `# ${message}`).join('\n')}`;
|
||||||
} else {
|
} else {
|
||||||
instructions += '# Edit the message below and commit:\n';
|
if (supportsComments) {
|
||||||
|
instructions += '# Edit the message below and commit:\n';
|
||||||
|
}
|
||||||
instructions += `\n${messages[0]}\n`;
|
instructions += `\n${messages[0]}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ import { describe } from 'manten';
|
|||||||
describe('aicommits', ({ runTestSuite }) => {
|
describe('aicommits', ({ runTestSuite }) => {
|
||||||
runTestSuite(import('./specs/cli/index.js'));
|
runTestSuite(import('./specs/cli/index.js'));
|
||||||
runTestSuite(import('./specs/config.js'));
|
runTestSuite(import('./specs/config.js'));
|
||||||
|
runTestSuite(import('./specs/git-hook.js'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { testSuite, expect } from 'manten';
|
import { testSuite, expect } from 'manten';
|
||||||
import { createFixture, createGit } from '../../utils.js';
|
import { createFixture, createGit, files } from '../../utils.js';
|
||||||
|
|
||||||
const { OPENAI_KEY } = process.env;
|
|
||||||
|
|
||||||
export default testSuite(({ describe }) => {
|
export default testSuite(({ describe }) => {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
@@ -10,17 +8,12 @@ export default testSuite(({ describe }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!OPENAI_KEY) {
|
if (!process.env.OPENAI_KEY) {
|
||||||
console.warn('⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...');
|
console.warn('⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('CLI', async ({ test, describe }) => {
|
describe('CLI', async ({ test, describe }) => {
|
||||||
const files = {
|
|
||||||
'.aicommits': `OPENAI_KEY=${OPENAI_KEY}`,
|
|
||||||
'data.json': 'Lorem ipsum dolor sit amet '.repeat(10),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
test('Excludes files', async () => {
|
test('Excludes files', async () => {
|
||||||
const { fixture, aicommits } = await createFixture(files);
|
const { fixture, aicommits } = await createFixture(files);
|
||||||
const git = await createGit(fixture.path);
|
const git = await createGit(fixture.path);
|
||||||
|
|||||||
40
tests/specs/git-hook.ts
Normal file
40
tests/specs/git-hook.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { testSuite, expect } from 'manten';
|
||||||
|
import { createFixture, createGit, files } from '../utils.js';
|
||||||
|
|
||||||
|
export default testSuite(({ describe }) => {
|
||||||
|
describe('Git hook', ({ test }) => {
|
||||||
|
test('errors when not in Git repo', async () => {
|
||||||
|
const { fixture, aicommits } = await createFixture(files);
|
||||||
|
const { exitCode, stderr } = await aicommits(['hook', 'install'], {
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stderr).toMatch('The current directory must be a Git repository');
|
||||||
|
|
||||||
|
await fixture.rm();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Commits', async () => {
|
||||||
|
const { fixture, aicommits } = await createFixture(files);
|
||||||
|
const git = await createGit(fixture.path);
|
||||||
|
|
||||||
|
const { stdout } = await aicommits(['hook', 'install']);
|
||||||
|
expect(stdout).toMatch('Hook installed');
|
||||||
|
|
||||||
|
await git('add', ['data.json']);
|
||||||
|
await git('commit', ['--no-edit'], {
|
||||||
|
env: {
|
||||||
|
HOME: fixture.path,
|
||||||
|
USERPROFILE: fixture.path,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdout: commitMessage } = await git('log', ['--pretty=%B']);
|
||||||
|
console.log('Committed with:', commitMessage);
|
||||||
|
expect(commitMessage.startsWith('# ')).not.toBe(true);
|
||||||
|
|
||||||
|
await fixture.rm();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,3 +72,8 @@ export const createFixture = async (
|
|||||||
aicommits,
|
aicommits,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const files = Object.freeze({
|
||||||
|
'.aicommits': `OPENAI_KEY=${process.env.OPENAI_KEY}`,
|
||||||
|
'data.json': 'Lorem ipsum dolor sit amet '.repeat(10),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user