feat: git hook (#95)

This commit is contained in:
hiroki osame
2023-02-21 08:08:44 -05:00
committed by GitHub
parent 0d3f35c135
commit 0b80a0031e
7 changed files with 191 additions and 32 deletions

View File

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

View File

@@ -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: {

59
src/commands/hook.ts Normal file
View File

@@ -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: ['<install/uninstall>'],
}, (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
});
});

View File

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

View File

@@ -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<typeof configParsers[key]>;
};
const configPath = path.join(os.homedir(), '.aicommits');
const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false);
export const getConfig = async (): Promise<ConfigType> => {
const configExists = await fileExists(configPath);
if (!configExists) {
@@ -35,7 +48,13 @@ export const getConfig = async (): Promise<ConfigType> => {
}
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');

3
src/utils/fs.ts Normal file
View File

@@ -0,0 +1,3 @@
import fs from 'fs/promises';
export const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false);

View File

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