diff --git a/src/cli.ts b/src/cli.ts index 13dffde..2bd66b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,82 +3,54 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; import { getConfig, + assertGitRepo, + getStagedDiff, generateCommitMessage, } from './utils.js'; (async () => { + console.log(chalk.white('▲ ') + chalk.green('Welcome to AICommits!')); + + await assertGitRepo(); + + const staged = await getStagedDiff(); + if (!staged) { + throw new Error('No staged changes found. Make sure to stage your changes with `git add`.'); + } + const config = await getConfig(); const OPENAI_KEY = process.env.OPENAI_KEY ?? process.env.OPENAI_API_KEY ?? config.OPENAI_KEY; - console.log(chalk.white('▲ ') + chalk.green('Welcome to AICommits!')); - if (!OPENAI_KEY) { - console.error( - `${chalk.white('▲ ') - }Please save your OpenAI API key as an env variable by doing 'export OPENAI_KEY=YOUR_API_KEY'`, - ); - process.exit(1); + throw new Error('Please set your OpenAI API key in ~/.aicommits'); } - try { - await execa('git', ['rev-parse', '--is-inside-work-tree']); - } catch { - console.error(`${chalk.white('▲ ')}This is not a git repository`); - process.exit(1); - } - - const { stdout: diff } = await execa( - 'git', - ['diff', '--cached', '.', ':(exclude)package-lock.json', ':(exclude)yarn.lock', ':(exclude)pnpm-lock.yaml'], - ); - - if (!diff) { - console.log( - `${chalk.white('▲ ') - }No staged changes found. Make sure there are changes and run \`git add .\``, - ); - process.exit(1); - } - - // Accounting for GPT-3's input req of 4k tokens (approx 8k chars) - if (diff.length > 8000) { - console.log( - `${chalk.white('▲ ')}The diff is too large to write a commit message.`, - ); - process.exit(1); - } - - const prompt = `I want you to act like a git commit message writer. I will input a git diff and your job is to convert it into a useful commit message. Do not preface the commit with anything, use the present tense, return a complete sentence, and do not repeat yourself: ${diff}`; console.log( chalk.white('▲ ') + chalk.gray('Generating your AI commit message...\n'), ); + const aiCommitMessage = await generateCommitMessage(OPENAI_KEY, staged.diff); + console.log( + `${chalk.white('▲')} ${chalk.bold('Commit message:')} ${aiCommitMessage}\n`, + ); - try { - const aiCommitMessage = await generateCommitMessage(OPENAI_KEY, prompt); - console.log( - `${chalk.white('▲ ') + chalk.bold('Commit message: ') + aiCommitMessage - }\n`, - ); + const confirmationMessage = await inquirer.prompt([ + { + name: 'useCommitMessage', + message: 'Would you like to use this commit message? (Y / n)', + choices: ['Y', 'y', 'n'], + default: 'y', + }, + ]); - const confirmationMessage = await inquirer.prompt([ - { - name: 'useCommitMessage', - message: 'Would you like to use this commit message? (Y / n)', - choices: ['Y', 'y', 'n'], - default: 'y', - }, - ]); - - if (confirmationMessage.useCommitMessage === 'n') { - console.log(`${chalk.white('▲ ')}Commit message has not been commited.`); - process.exit(1); - } - - await execa('git', ['commit', '-m', aiCommitMessage], { - stdio: 'inherit', - }); - } catch (error) { - console.error(chalk.white('▲ ') + chalk.red((error as any).message)); - process.exit(1); + if (confirmationMessage.useCommitMessage === 'n') { + console.log(`${chalk.white('▲ ')}Commit message has not been commited.`); + return; } -})(); + + await execa('git', ['commit', '-m', aiCommitMessage], { + stdio: 'inherit', + }); +})().catch((error) => { + console.error(`${chalk.white('▲')} ${error.message}`); + process.exit(1); +}); diff --git a/src/utils.ts b/src/utils.ts index 74b8d53..e9f21ab 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,7 @@ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import ini from 'ini'; +import { execa } from 'execa'; import { Configuration, OpenAIApi } from 'openai'; const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false); @@ -21,10 +22,27 @@ export const getConfig = async (): Promise => { return ini.parse(configString); }; +export const assertGitRepo = async () => { + const { stdout } = await execa('git', ['rev-parse', '--is-inside-work-tree'], { reject: false }); + + if (stdout !== 'true') { + throw new Error('The current directory must be a Git repository!'); + } +}; + +const promptTemplate = 'I want you to act like a git commit message writer. I will input a git diff and your job is to convert it into a useful commit message. Do not preface the commit with anything, use the present tense, return a complete sentence, and do not repeat yourself:'; + export const generateCommitMessage = async ( apiKey: string, - prompt: string, + diff: string, ) => { + const prompt = `${promptTemplate}\n${diff}`; + + // Accounting for GPT-3's input req of 4k tokens (approx 8k chars) + if (prompt.length > 8000) { + throw new Error('The diff is too large for the OpenAI API'); + } + const openai = new OpenAIApi(new Configuration({ apiKey })); try { const completion = await openai.createCompletion({ @@ -46,3 +64,31 @@ export const generateCommitMessage = async ( throw errorAsAny; } }; + +const excludeFromDiff = [ + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', +].map(file => `:(exclude)${file}`); + +export const getStagedDiff = async () => { + const diffCached = ['diff', '--cached']; + const { stdout: files } = await execa( + 'git', + [...diffCached, '--name-only', ...excludeFromDiff], + ); + + if (!files) { + return; + } + + const { stdout: diff } = await execa( + 'git', + [...diffCached, ...excludeFromDiff], + ); + + return { + files: files.split('\n'), + diff, + }; +};