feat: git hook (#95)
This commit is contained in:
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicommits",
|
"name": "aicommits",
|
||||||
"version": "0.0.0-semantic-release",
|
"version": "1.0.7",
|
||||||
"description": "Writes your git commit messages for you with AI",
|
"description": "Writes your git commit messages for you with AI",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
@@ -14,13 +14,12 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"bin": "dist/cli.mjs",
|
"bin": "./dist/cli.mjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "simple-git-hooks",
|
"prepare": "simple-git-hooks",
|
||||||
"build": "pkgroll --minify",
|
"build": "pkgroll --minify",
|
||||||
"lint": "eslint --cache .",
|
"lint": "eslint --cache .",
|
||||||
"type-check": "tsc",
|
"type-check": "tsc"
|
||||||
"prepack": "pnpm build"
|
|
||||||
},
|
},
|
||||||
"simple-git-hooks": {
|
"simple-git-hooks": {
|
||||||
"pre-commit": "pnpm lint-staged"
|
"pre-commit": "pnpm lint-staged"
|
||||||
@@ -46,9 +45,22 @@
|
|||||||
"typescript": "^4.9.5"
|
"typescript": "^4.9.5"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": "@pvtnbr"
|
"extends": "@pvtnbr",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "./src/prepare-commit-msg-hook.ts",
|
||||||
|
"rules": {
|
||||||
|
"unicorn/prevent-abbreviations": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"release": {
|
"pkgroll": {
|
||||||
"branches": ["main"]
|
"output": [
|
||||||
|
{
|
||||||
|
"path": "./dist/prepare-commit-msg-hook.mjs",
|
||||||
|
"executable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { getConfig } from './utils/config.js';
|
import { getConfig } from './utils/config.js';
|
||||||
import { generateCommitMessage } from './utils/openai.js';
|
import { generateCommitMessage } from './utils/openai.js';
|
||||||
import configCommand from './commands/config.js';
|
import configCommand from './commands/config.js';
|
||||||
|
import hookCommand from './commands/hook.js';
|
||||||
|
|
||||||
const rawArgv = process.argv.slice(2);
|
const rawArgv = process.argv.slice(2);
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ cli(
|
|||||||
|
|
||||||
commands: [
|
commands: [
|
||||||
configCommand,
|
configCommand,
|
||||||
|
hookCommand,
|
||||||
],
|
],
|
||||||
|
|
||||||
help: {
|
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 path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import ini from 'ini';
|
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) {
|
OPENAI_KEY(key: string) {
|
||||||
if (!key) {
|
parseAssert('OPENAI_KEY', key, 'Cannot be empty');
|
||||||
return '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 key;
|
||||||
return 'Must start with "sk-"';
|
},
|
||||||
}
|
generate(key: string) {
|
||||||
|
parseAssert('generate', key, 'Cannot be empty');
|
||||||
|
parseAssert('generate', /^\d+$/.test(key), 'Must be an integer');
|
||||||
|
|
||||||
if (key.length !== 51) {
|
const parsed = Number(key);
|
||||||
return 'Must be 51 characters long';
|
parseAssert('generate', parsed > 0, 'Must be greater than 0');
|
||||||
}
|
parseAssert('generate', parsed <= 5, 'Must be less or equal to 5');
|
||||||
|
|
||||||
|
return parsed;
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type ValidKeys = keyof typeof keyValidators;
|
type ValidKeys = keyof typeof configParsers;
|
||||||
type ConfigType = {
|
type ConfigType = {
|
||||||
[key in ValidKeys]?: string;
|
[key in ValidKeys]?: ReturnType<typeof configParsers[key]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const configPath = path.join(os.homedir(), '.aicommits');
|
const configPath = path.join(os.homedir(), '.aicommits');
|
||||||
|
|
||||||
const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false);
|
|
||||||
|
|
||||||
export const getConfig = async (): Promise<ConfigType> => {
|
export const getConfig = async (): Promise<ConfigType> => {
|
||||||
const configExists = await fileExists(configPath);
|
const configExists = await fileExists(configPath);
|
||||||
if (!configExists) {
|
if (!configExists) {
|
||||||
@@ -35,7 +48,13 @@ export const getConfig = async (): Promise<ConfigType> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configString = await fs.readFile(configPath, 'utf8');
|
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;
|
const { hasOwnProperty } = Object.prototype;
|
||||||
@@ -47,16 +66,12 @@ export const setConfigs = async (
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
|
|
||||||
for (const [key, value] of keyValues) {
|
for (const [key, value] of keyValues) {
|
||||||
if (!hasOwn(keyValidators, key)) {
|
if (!hasOwn(configParsers, key)) {
|
||||||
throw new Error(`Invalid config property: ${key}`);
|
throw new Error(`Invalid config property: ${key}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInvalid = keyValidators[key as ValidKeys](value);
|
const parsed = configParsers[key as ValidKeys](value);
|
||||||
if (isInvalid) {
|
config[key as ValidKeys] = parsed as any;
|
||||||
throw new Error(`Invalid value for ${key}: ${isInvalid}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
config[key as ValidKeys] = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.writeFile(configPath, ini.stringify(config), 'utf8');
|
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 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:';
|
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 (
|
export const generateCommitMessage = async (
|
||||||
@@ -30,9 +32,10 @@ export const generateCommitMessage = async (
|
|||||||
n: completions,
|
n: completions,
|
||||||
});
|
});
|
||||||
|
|
||||||
return completion.data.choices
|
return deduplicateMessages(
|
||||||
.filter(choice => choice.text)
|
completion.data.choices
|
||||||
.map(choice => sanitizeMessage(choice.text!));
|
.map(choice => sanitizeMessage(choice.text!)),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorAsAny = error as any;
|
const errorAsAny = error as any;
|
||||||
errorAsAny.message = `OpenAI API Error: ${errorAsAny.message} - ${errorAsAny.response.statusText}`;
|
errorAsAny.message = `OpenAI API Error: ${errorAsAny.message} - ${errorAsAny.response.statusText}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user