refactor: hooks to be called via aicommits (#99)
This commit is contained in:
13
package.json
13
package.json
@@ -46,21 +46,16 @@
|
|||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": "@pvtnbr",
|
"extends": "@pvtnbr",
|
||||||
|
"rules": {
|
||||||
|
"unicorn/no-process-exit": "off"
|
||||||
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": "./src/prepare-commit-msg-hook.ts",
|
"files": "./src/commands/prepare-commit-msg-hook.ts",
|
||||||
"rules": {
|
"rules": {
|
||||||
"unicorn/prevent-abbreviations": "off"
|
"unicorn/prevent-abbreviations": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"pkgroll": {
|
|
||||||
"output": [
|
|
||||||
{
|
|
||||||
"path": "./dist/prepare-commit-msg-hook.mjs",
|
|
||||||
"executable": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/cli.ts
86
src/cli.ts
@@ -1,21 +1,9 @@
|
|||||||
import { execa } from 'execa';
|
|
||||||
import {
|
|
||||||
black, dim, green, red, bgCyan,
|
|
||||||
} from 'kolorist';
|
|
||||||
import {
|
|
||||||
intro, outro, spinner, select, confirm, isCancel,
|
|
||||||
} from '@clack/prompts';
|
|
||||||
import { cli } from 'cleye';
|
import { cli } from 'cleye';
|
||||||
import { description, version } from '../package.json';
|
import { description, version } from '../package.json';
|
||||||
import {
|
import aicommits from './commands/aicommits.js';
|
||||||
assertGitRepo,
|
import prepareCommitMessageHook from './commands/prepare-commit-msg-hook.js';
|
||||||
getStagedDiff,
|
|
||||||
getDetectedMessage,
|
|
||||||
} from './utils/git.js';
|
|
||||||
import { getConfig } from './utils/config.js';
|
|
||||||
import { generateCommitMessage } from './utils/openai.js';
|
|
||||||
import configCommand from './commands/config.js';
|
import configCommand from './commands/config.js';
|
||||||
import hookCommand from './commands/hook.js';
|
import hookCommand, { isCalledFromGitHook } from './commands/hook.js';
|
||||||
|
|
||||||
const rawArgv = process.argv.slice(2);
|
const rawArgv = process.argv.slice(2);
|
||||||
|
|
||||||
@@ -51,70 +39,14 @@ cli(
|
|||||||
ignoreArgv: type => type === 'unknown-flag' || type === 'argument',
|
ignoreArgv: type => type === 'unknown-flag' || type === 'argument',
|
||||||
},
|
},
|
||||||
(argv) => {
|
(argv) => {
|
||||||
(async () => {
|
if (isCalledFromGitHook) {
|
||||||
intro(bgCyan(black(' aicommits ')));
|
prepareCommitMessageHook();
|
||||||
|
} else {
|
||||||
await assertGitRepo();
|
aicommits(
|
||||||
|
|
||||||
const detectingFiles = spinner();
|
|
||||||
detectingFiles.start('Detecting staged files');
|
|
||||||
const staged = await getStagedDiff();
|
|
||||||
|
|
||||||
if (!staged) {
|
|
||||||
throw new Error('No staged changes found. Make sure to stage your changes with `git add`.');
|
|
||||||
}
|
|
||||||
|
|
||||||
detectingFiles.stop(`${getDetectedMessage(staged.files)}:\n${
|
|
||||||
staged.files.map(file => ` ${file}`).join('\n')
|
|
||||||
}`);
|
|
||||||
|
|
||||||
const config = await getConfig();
|
|
||||||
const OPENAI_KEY = process.env.OPENAI_KEY ?? process.env.OPENAI_API_KEY ?? config.OPENAI_KEY;
|
|
||||||
if (!OPENAI_KEY) {
|
|
||||||
throw new Error('Please set your OpenAI API key in ~/.aicommits');
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = spinner();
|
|
||||||
s.start('The AI is analyzing your changes');
|
|
||||||
const messages = await generateCommitMessage(
|
|
||||||
OPENAI_KEY,
|
|
||||||
staged.diff,
|
|
||||||
argv.flags.generate,
|
argv.flags.generate,
|
||||||
|
rawArgv,
|
||||||
);
|
);
|
||||||
s.stop('Changes analyzed');
|
}
|
||||||
|
|
||||||
let message;
|
|
||||||
if (messages.length === 1) {
|
|
||||||
[message] = messages;
|
|
||||||
const confirmed = await confirm({
|
|
||||||
message: `Use this commit message?\n\n ${message}\n`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!confirmed || isCancel(confirmed)) {
|
|
||||||
outro('Commit cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const selected = await select({
|
|
||||||
message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`,
|
|
||||||
options: messages.map(value => ({ label: value, value })),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isCancel(selected)) {
|
|
||||||
outro('Commit cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
message = selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
await execa('git', ['commit', '-m', message, ...rawArgv]);
|
|
||||||
|
|
||||||
outro(`${green('✔')} Successfully committed!`);
|
|
||||||
})().catch((error) => {
|
|
||||||
outro(`${red('✖')} ${error.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
rawArgv,
|
rawArgv,
|
||||||
);
|
);
|
||||||
|
|||||||
82
src/commands/aicommits.ts
Normal file
82
src/commands/aicommits.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { execa } from 'execa';
|
||||||
|
import {
|
||||||
|
black, dim, green, red, bgCyan,
|
||||||
|
} from 'kolorist';
|
||||||
|
import {
|
||||||
|
intro, outro, spinner, select, confirm, isCancel,
|
||||||
|
} from '@clack/prompts';
|
||||||
|
import {
|
||||||
|
assertGitRepo,
|
||||||
|
getStagedDiff,
|
||||||
|
getDetectedMessage,
|
||||||
|
} from '../utils/git.js';
|
||||||
|
import { getConfig } from '../utils/config.js';
|
||||||
|
import { generateCommitMessage } from '../utils/openai.js';
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
generate: number,
|
||||||
|
rawArgv: string[],
|
||||||
|
) => (async () => {
|
||||||
|
intro(bgCyan(black(' aicommits ')));
|
||||||
|
|
||||||
|
await assertGitRepo();
|
||||||
|
|
||||||
|
const detectingFiles = spinner();
|
||||||
|
detectingFiles.start('Detecting staged files');
|
||||||
|
const staged = await getStagedDiff();
|
||||||
|
|
||||||
|
if (!staged) {
|
||||||
|
throw new Error('No staged changes found. Make sure to stage your changes with `git add`.');
|
||||||
|
}
|
||||||
|
|
||||||
|
detectingFiles.stop(`${getDetectedMessage(staged.files)}:\n${
|
||||||
|
staged.files.map(file => ` ${file}`).join('\n')
|
||||||
|
}`);
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const OPENAI_KEY = process.env.OPENAI_KEY ?? process.env.OPENAI_API_KEY ?? config.OPENAI_KEY;
|
||||||
|
if (!OPENAI_KEY) {
|
||||||
|
throw new Error('Please set your OpenAI API key in ~/.aicommits');
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = spinner();
|
||||||
|
s.start('The AI is analyzing your changes');
|
||||||
|
const messages = await generateCommitMessage(
|
||||||
|
OPENAI_KEY,
|
||||||
|
staged.diff,
|
||||||
|
generate,
|
||||||
|
);
|
||||||
|
s.stop('Changes analyzed');
|
||||||
|
|
||||||
|
let message;
|
||||||
|
if (messages.length === 1) {
|
||||||
|
[message] = messages;
|
||||||
|
const confirmed = await confirm({
|
||||||
|
message: `Use this commit message?\n\n ${message}\n`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed || isCancel(confirmed)) {
|
||||||
|
outro('Commit cancelled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const selected = await select({
|
||||||
|
message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`,
|
||||||
|
options: messages.map(value => ({ label: value, value })),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCancel(selected)) {
|
||||||
|
outro('Commit cancelled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execa('git', ['commit', '-m', message, ...rawArgv]);
|
||||||
|
|
||||||
|
outro(`${green('✔')} Successfully committed!`);
|
||||||
|
})().catch((error) => {
|
||||||
|
outro(`${red('✖')} ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -28,6 +28,6 @@ export default command({
|
|||||||
throw new Error(`Invalid mode: ${mode}`);
|
throw new Error(`Invalid mode: ${mode}`);
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
console.error(`${red('✖')} ${error.message}`);
|
console.error(`${red('✖')} ${error.message}`);
|
||||||
process.exit(1); // eslint-disable-line unicorn/no-process-exit
|
process.exit(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,28 +6,33 @@ import { command } from 'cleye';
|
|||||||
import { assertGitRepo } from '../utils/git.js';
|
import { assertGitRepo } from '../utils/git.js';
|
||||||
import { fileExists } from '../utils/fs.js';
|
import { fileExists } from '../utils/fs.js';
|
||||||
|
|
||||||
|
const hookName = 'prepare-commit-msg';
|
||||||
|
const symlinkPath = `.git/hooks/${hookName}`;
|
||||||
|
|
||||||
|
export const isCalledFromGitHook = process.argv[1].endsWith(`/${symlinkPath}`);
|
||||||
|
|
||||||
export default command({
|
export default command({
|
||||||
name: 'hook',
|
name: 'hook',
|
||||||
parameters: ['<install/uninstall>'],
|
parameters: ['<install/uninstall>'],
|
||||||
}, (argv) => {
|
}, (argv) => {
|
||||||
const hookPath = fileURLToPath(new URL('test.mjs', import.meta.url));
|
const hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url));
|
||||||
const hookName = 'prepare-commit-msg';
|
|
||||||
const symlinkPath = `.git/hooks/${hookName}`;
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await assertGitRepo();
|
await assertGitRepo();
|
||||||
|
|
||||||
const { installUninstall } = argv._;
|
const { installUninstall: mode } = argv._;
|
||||||
|
|
||||||
const hookExists = await fileExists(symlinkPath);
|
const hookExists = await fileExists(symlinkPath);
|
||||||
if (installUninstall === 'install') {
|
if (mode === 'install') {
|
||||||
if (hookExists) {
|
if (hookExists) {
|
||||||
const realpath = await fs.realpath(symlinkPath);
|
// If the symlink is broken, it will throw an error
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
const realpath = await fs.realpath(symlinkPath).catch(() => {});
|
||||||
if (realpath === hookPath) {
|
if (realpath === hookPath) {
|
||||||
throw new Error('The hook is already installed');
|
console.warn('The hook is already installed');
|
||||||
} else {
|
return;
|
||||||
throw new Error(`A different ${hookName} hook seems to be installed. Please remove it before installing aicommits.`);
|
|
||||||
}
|
}
|
||||||
|
throw new Error(`A different ${hookName} hook seems to be installed. Please remove it before installing aicommits.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(symlinkPath), { recursive: true });
|
await fs.mkdir(path.dirname(symlinkPath), { recursive: true });
|
||||||
@@ -37,13 +42,15 @@ export default command({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (installUninstall === 'uninstall') {
|
if (mode === 'uninstall') {
|
||||||
if (!hookExists) {
|
if (!hookExists) {
|
||||||
throw new Error('Hook is not installed');
|
console.warn('Hook is not installed');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const realpath = await fs.realpath(symlinkPath);
|
const realpath = await fs.realpath(symlinkPath);
|
||||||
if (realpath !== hookPath) {
|
if (realpath !== hookPath) {
|
||||||
throw new Error('Hook is not installed');
|
console.warn('Hook is not installed');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.rm(symlinkPath);
|
await fs.rm(symlinkPath);
|
||||||
@@ -51,9 +58,9 @@ export default command({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Invalid mode: ${installUninstall}`);
|
throw new Error(`Invalid mode: ${mode}`);
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
console.error(`${red('✖')} ${error.message}`);
|
console.error(`${red('✖')} ${error.message}`);
|
||||||
process.exit(1); // eslint-disable-line unicorn/no-process-exit
|
process.exit(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
black, green, red, bgCyan,
|
black, green, red, bgCyan,
|
||||||
} from 'kolorist';
|
} from 'kolorist';
|
||||||
import { getStagedDiff } from './utils/git.js';
|
import { getStagedDiff } from '../utils/git.js';
|
||||||
import { getConfig } from './utils/config.js';
|
import { getConfig } from '../utils/config.js';
|
||||||
import { generateCommitMessage } from './utils/openai.js';
|
import { generateCommitMessage } from '../utils/openai.js';
|
||||||
|
|
||||||
const [messageFilePath, commitSource] = process.argv.slice(2);
|
const [messageFilePath, commitSource] = process.argv.slice(2);
|
||||||
|
|
||||||
(async () => {
|
export default () => (async () => {
|
||||||
if (!messageFilePath) {
|
if (!messageFilePath) {
|
||||||
throw new Error('Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook');
|
throw new Error('Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook');
|
||||||
}
|
}
|
||||||
@@ -61,5 +61,5 @@ const [messageFilePath, commitSource] = process.argv.slice(2);
|
|||||||
outro(`${green('✔')} Saved commit message!`);
|
outro(`${green('✔')} Saved commit message!`);
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
outro(`${red('✖')} ${error.message}`);
|
outro(`${red('✖')} ${error.message}`);
|
||||||
process.exit(1); // eslint-disable-line unicorn/no-process-exit
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
export const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false);
|
// lstat is used because this is also used to check if a symlink file exists
|
||||||
|
export const fileExists = (filePath: string) => fs.lstat(filePath).then(() => true, () => false);
|
||||||
|
|||||||
Reference in New Issue
Block a user