diff --git a/package.json b/package.json index 0a92813..b6e72da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicommits", - "version": "0.0.0-semantic-release", + "version": "1.0.7", "description": "Writes your git commit messages for you with AI", "keywords": [ "ai", @@ -14,13 +14,12 @@ "files": [ "dist" ], - "bin": "dist/cli.mjs", + "bin": "./dist/cli.mjs", "scripts": { "prepare": "simple-git-hooks", "build": "pkgroll --minify", "lint": "eslint --cache .", - "type-check": "tsc", - "prepack": "pnpm build" + "type-check": "tsc" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" @@ -46,9 +45,22 @@ "typescript": "^4.9.5" }, "eslintConfig": { - "extends": "@pvtnbr" + "extends": "@pvtnbr", + "overrides": [ + { + "files": "./src/prepare-commit-msg-hook.ts", + "rules": { + "unicorn/prevent-abbreviations": "off" + } + } + ] }, - "release": { - "branches": ["main"] + "pkgroll": { + "output": [ + { + "path": "./dist/prepare-commit-msg-hook.mjs", + "executable": true + } + ] } } diff --git a/src/cli.ts b/src/cli.ts index 9f58efc..6962e0f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { import { getConfig } from './utils/config.js'; import { generateCommitMessage } from './utils/openai.js'; import configCommand from './commands/config.js'; +import hookCommand from './commands/hook.js'; const rawArgv = process.argv.slice(2); @@ -40,6 +41,7 @@ cli( commands: [ configCommand, + hookCommand, ], help: { diff --git a/src/commands/hook.ts b/src/commands/hook.ts new file mode 100644 index 0000000..20ada89 --- /dev/null +++ b/src/commands/hook.ts @@ -0,0 +1,59 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { green, red } from 'kolorist'; +import { command } from 'cleye'; +import { assertGitRepo } from '../utils/git.js'; +import { fileExists } from '../utils/fs.js'; + +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}`; + + (async () => { + await assertGitRepo(); + + const { installUninstall } = argv._; + + const hookExists = await fileExists(symlinkPath); + if (installUninstall === 'install') { + if (hookExists) { + const realpath = await fs.realpath(symlinkPath); + 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.`); + } + } + + await fs.mkdir(path.dirname(symlinkPath), { recursive: true }); + await fs.symlink(hookPath, symlinkPath, 'file'); + await fs.chmod(symlinkPath, 0o755); + console.log(`${green('✔')} Hook installed`); + return; + } + + if (installUninstall === 'uninstall') { + if (!hookExists) { + throw new Error('Hook is not installed'); + } + const realpath = await fs.realpath(symlinkPath); + if (realpath !== hookPath) { + throw new Error('Hook is not installed'); + } + + await fs.rm(symlinkPath); + console.log(`${green('✔')} Hook uninstalled`); + return; + } + + throw new Error(`Invalid mode: ${installUninstall}`); + })().catch((error) => { + console.error(`${red('✖')} ${error.message}`); + process.exit(1); // eslint-disable-line unicorn/no-process-exit + }); +}); diff --git a/src/prepare-commit-msg-hook.ts b/src/prepare-commit-msg-hook.ts new file mode 100644 index 0000000..8085ffb --- /dev/null +++ b/src/prepare-commit-msg-hook.ts @@ -0,0 +1,65 @@ +import fs from 'fs/promises'; +import { + intro, outro, spinner, +} from '@clack/prompts'; +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'; + +const [messageFilePath, commitSource] = process.argv.slice(2); + +(async () => { + if (!messageFilePath) { + throw new Error('Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook'); + } + + // If a commit message is passed in, ignore + if (commitSource) { + return; + } + + // All staged files can be ignored by our filter + const staged = await getStagedDiff(); + if (!staged) { + return; + } + + intro(bgCyan(black(' aicommits '))); + + const { OPENAI_KEY, generate } = await getConfig(); + 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 || 1, + ); + s.stop('Changes analyzed'); + + const hasMultipleMessages = messages.length > 1; + let instructions = `# 🤖 AI generated commit${hasMultipleMessages ? 's' : ''}\n`; + + if (hasMultipleMessages) { + instructions += '# Select one of the following messages by uncommeting:\n'; + instructions += `\n${messages.map(message => `# ${message}`).join('\n')}`; + } else { + instructions += '# Edit the message below and commit:\n'; + instructions += `\n${messages[0]}\n`; + } + + await fs.appendFile( + messageFilePath, + instructions, + ); + outro(`${green('✔')} Saved commit message!`); +})().catch((error) => { + outro(`${red('✖')} ${error.message}`); + process.exit(1); // eslint-disable-line unicorn/no-process-exit +}); diff --git a/src/utils/config.ts b/src/utils/config.ts index ac8c97e..4d341ce 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -2,32 +2,45 @@ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import ini from 'ini'; +import { fileExists } from './fs.js'; -const keyValidators = { +const parseAssert = ( + name: string, + condition: any, + message: string, +) => { + if (!condition) { + throw new Error(`Invalid config property ${name}: ${message}`); + } +}; + +const configParsers = { OPENAI_KEY(key: string) { - if (!key) { - return 'Cannot be empty'; - } + parseAssert('OPENAI_KEY', key, 'Cannot be empty'); + parseAssert('OPENAI_KEY', key.startsWith('sk-'), 'Must start with "sk-"'); + parseAssert('OPENAI_KEY', key.length === 51, 'Must be 51 characters long'); - if (!key.startsWith('sk-')) { - return 'Must start with "sk-"'; - } + return key; + }, + generate(key: string) { + parseAssert('generate', key, 'Cannot be empty'); + parseAssert('generate', /^\d+$/.test(key), 'Must be an integer'); - if (key.length !== 51) { - return 'Must be 51 characters long'; - } + const parsed = Number(key); + parseAssert('generate', parsed > 0, 'Must be greater than 0'); + parseAssert('generate', parsed <= 5, 'Must be less or equal to 5'); + + return parsed; }, } as const; -type ValidKeys = keyof typeof keyValidators; +type ValidKeys = keyof typeof configParsers; type ConfigType = { - [key in ValidKeys]?: string; + [key in ValidKeys]?: ReturnType; }; const configPath = path.join(os.homedir(), '.aicommits'); -const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false); - export const getConfig = async (): Promise => { const configExists = await fileExists(configPath); if (!configExists) { @@ -35,7 +48,13 @@ export const getConfig = async (): Promise => { } const configString = await fs.readFile(configPath, 'utf8'); - return ini.parse(configString); + const config = ini.parse(configString); + for (const key of Object.keys(config)) { + const parsed = configParsers[key as ValidKeys](config[key]); + config[key as ValidKeys] = parsed; + } + + return config; }; const { hasOwnProperty } = Object.prototype; @@ -47,16 +66,12 @@ export const setConfigs = async ( const config = await getConfig(); for (const [key, value] of keyValues) { - if (!hasOwn(keyValidators, key)) { + if (!hasOwn(configParsers, key)) { throw new Error(`Invalid config property: ${key}`); } - const isInvalid = keyValidators[key as ValidKeys](value); - if (isInvalid) { - throw new Error(`Invalid value for ${key}: ${isInvalid}`); - } - - config[key as ValidKeys] = value; + const parsed = configParsers[key as ValidKeys](value); + config[key as ValidKeys] = parsed as any; } await fs.writeFile(configPath, ini.stringify(config), 'utf8'); diff --git a/src/utils/fs.ts b/src/utils/fs.ts new file mode 100644 index 0000000..fffb64d --- /dev/null +++ b/src/utils/fs.ts @@ -0,0 +1,3 @@ +import fs from 'fs/promises'; + +export const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false); diff --git a/src/utils/openai.ts b/src/utils/openai.ts index dc21b16..730ac39 100644 --- a/src/utils/openai.ts +++ b/src/utils/openai.ts @@ -2,6 +2,8 @@ import { Configuration, OpenAIApi } from 'openai'; const sanitizeMessage = (message: string) => message.trim().replace(/[\n\r]/g, '').replace(/(\w)\.$/, '$1'); +const deduplicateMessages = (array: string[]) => Array.from(new Set(array)); + const promptTemplate = 'Write an insightful but concise Git commit message in a complete sentence in present tense for the following diff without prefacing it with anything:'; export const generateCommitMessage = async ( @@ -30,9 +32,10 @@ export const generateCommitMessage = async ( n: completions, }); - return completion.data.choices - .filter(choice => choice.text) - .map(choice => sanitizeMessage(choice.text!)); + return deduplicateMessages( + completion.data.choices + .map(choice => sanitizeMessage(choice.text!)), + ); } catch (error) { const errorAsAny = error as any; errorAsAny.message = `OpenAI API Error: ${errorAsAny.message} - ${errorAsAny.response.statusText}`;