feat: git hook (#95)
This commit is contained in:
26
package.json
26
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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
59
src/commands/hook.ts
Normal 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
|
||||
});
|
||||
});
|
||||
65
src/prepare-commit-msg-hook.ts
Normal file
65
src/prepare-commit-msg-hook.ts
Normal 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
|
||||
});
|
||||
@@ -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
3
src/utils/fs.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import fs from 'fs/promises';
|
||||
|
||||
export const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false);
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user