refactor: hooks to be called via aicommits (#99)

This commit is contained in:
hiroki osame
2023-02-22 00:01:11 -05:00
committed by GitHub
parent 09013af5fc
commit 13b06e8c16
7 changed files with 124 additions and 107 deletions

View File

@@ -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
}
]
} }
} }

View File

@@ -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
View 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);
});

View File

@@ -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);
}); });
}); });

View File

@@ -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);
}); });
}); });

View File

@@ -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);
}); });

View File

@@ -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);