diff --git a/package.json b/package.json index b6e72da..582b9a6 100644 --- a/package.json +++ b/package.json @@ -46,21 +46,16 @@ }, "eslintConfig": { "extends": "@pvtnbr", + "rules": { + "unicorn/no-process-exit": "off" + }, "overrides": [ { - "files": "./src/prepare-commit-msg-hook.ts", + "files": "./src/commands/prepare-commit-msg-hook.ts", "rules": { "unicorn/prevent-abbreviations": "off" } } ] - }, - "pkgroll": { - "output": [ - { - "path": "./dist/prepare-commit-msg-hook.mjs", - "executable": true - } - ] } } diff --git a/src/cli.ts b/src/cli.ts index 6962e0f..18c3863 100644 --- a/src/cli.ts +++ b/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 { description, version } from '../package.json'; -import { - assertGitRepo, - getStagedDiff, - getDetectedMessage, -} from './utils/git.js'; -import { getConfig } from './utils/config.js'; -import { generateCommitMessage } from './utils/openai.js'; +import aicommits from './commands/aicommits.js'; +import prepareCommitMessageHook from './commands/prepare-commit-msg-hook.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); @@ -51,70 +39,14 @@ cli( ignoreArgv: type => type === 'unknown-flag' || type === 'argument', }, (argv) => { - (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, + if (isCalledFromGitHook) { + prepareCommitMessageHook(); + } else { + aicommits( 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, ); diff --git a/src/commands/aicommits.ts b/src/commands/aicommits.ts new file mode 100644 index 0000000..ce335c0 --- /dev/null +++ b/src/commands/aicommits.ts @@ -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); +}); diff --git a/src/commands/config.ts b/src/commands/config.ts index 1a55228..85c742a 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -28,6 +28,6 @@ export default command({ throw new Error(`Invalid mode: ${mode}`); })().catch((error) => { console.error(`${red('✖')} ${error.message}`); - process.exit(1); // eslint-disable-line unicorn/no-process-exit + process.exit(1); }); }); diff --git a/src/commands/hook.ts b/src/commands/hook.ts index 20ada89..45563b1 100644 --- a/src/commands/hook.ts +++ b/src/commands/hook.ts @@ -6,28 +6,33 @@ import { command } from 'cleye'; import { assertGitRepo } from '../utils/git.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({ name: 'hook', parameters: [''], }, (argv) => { - const hookPath = fileURLToPath(new URL('test.mjs', import.meta.url)); - const hookName = 'prepare-commit-msg'; - const symlinkPath = `.git/hooks/${hookName}`; + const hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url)); (async () => { await assertGitRepo(); - const { installUninstall } = argv._; + const { installUninstall: mode } = argv._; const hookExists = await fileExists(symlinkPath); - if (installUninstall === 'install') { + if (mode === 'install') { 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) { - throw new Error('The hook is already installed'); - } else { - throw new Error(`A different ${hookName} hook seems to be installed. Please remove it before installing aicommits.`); + console.warn('The hook is already installed'); + return; } + 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 }); @@ -37,13 +42,15 @@ export default command({ return; } - if (installUninstall === 'uninstall') { + if (mode === 'uninstall') { if (!hookExists) { - throw new Error('Hook is not installed'); + console.warn('Hook is not installed'); + return; } const realpath = await fs.realpath(symlinkPath); if (realpath !== hookPath) { - throw new Error('Hook is not installed'); + console.warn('Hook is not installed'); + return; } await fs.rm(symlinkPath); @@ -51,9 +58,9 @@ export default command({ return; } - throw new Error(`Invalid mode: ${installUninstall}`); + throw new Error(`Invalid mode: ${mode}`); })().catch((error) => { console.error(`${red('✖')} ${error.message}`); - process.exit(1); // eslint-disable-line unicorn/no-process-exit + process.exit(1); }); }); diff --git a/src/prepare-commit-msg-hook.ts b/src/commands/prepare-commit-msg-hook.ts similarity index 86% rename from src/prepare-commit-msg-hook.ts rename to src/commands/prepare-commit-msg-hook.ts index 8085ffb..c675b58 100644 --- a/src/prepare-commit-msg-hook.ts +++ b/src/commands/prepare-commit-msg-hook.ts @@ -5,13 +5,13 @@ import { import { black, green, red, bgCyan, } from 'kolorist'; -import { getStagedDiff } from './utils/git.js'; -import { getConfig } from './utils/config.js'; -import { generateCommitMessage } from './utils/openai.js'; +import { getStagedDiff } from '../utils/git.js'; +import { getConfig } from '../utils/config.js'; +import { generateCommitMessage } from '../utils/openai.js'; const [messageFilePath, commitSource] = process.argv.slice(2); -(async () => { +export default () => (async () => { if (!messageFilePath) { 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!`); })().catch((error) => { outro(`${red('✖')} ${error.message}`); - process.exit(1); // eslint-disable-line unicorn/no-process-exit + process.exit(1); }); diff --git a/src/utils/fs.ts b/src/utils/fs.ts index fffb64d..ad49381 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -1,3 +1,4 @@ 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);