|
#!/usr/bin/env node |
|
|
|
/** |
|
* Git Commit Message Generator |
|
* |
|
* This script automates the generation of detailed, structured commit messages based on |
|
* staged changes in a git repository. It uses the Ollama API with the CodeLlama model |
|
* to analyze git diffs and generate appropriate commit messages. |
|
* |
|
* Features: |
|
* - Retrieves staged changes using git diff |
|
* - Generates structured commit messages using AI |
|
* - Automatically copies the result to clipboard (macOS only) |
|
* - Provides detailed error handling and logging |
|
* |
|
* Requirements: |
|
* - Node.js |
|
* - Git |
|
* - Ollama running locally with CodeLlama model |
|
* - macOS for clipboard functionality (pbcopy) |
|
*/ |
|
|
|
const {exec} = require('child_process'); |
|
const http = require('http'); |
|
|
|
// Configuration constants |
|
const OLLAMA_CONFIG = { |
|
hostname: '127.0.0.1', |
|
port: 11434, |
|
basePath: '/api', |
|
path: '/generate', |
|
// model: 'codellama:13b' |
|
model: 'llama3.1:latest' |
|
}; |
|
|
|
/** |
|
* A generic HTTP/HTTPS client for making REST API requests with consistent error handling. |
|
* @class |
|
* @example |
|
* // Create a basic client |
|
* const client = new APIClient({ |
|
* hostname: 'api.example.com', |
|
* port: 443, |
|
* useHttps: true |
|
* }) |
|
* |
|
* // Make a request |
|
* const [error, response] = await client.makeRestRequest({ |
|
* path: '/users', |
|
* method: 'GET' |
|
* }) |
|
* |
|
* if (error) { |
|
* console.error('Error:', error.message) |
|
* return |
|
* } |
|
* |
|
* console.log('Response:', response) |
|
*/ |
|
class APIClient { |
|
/** |
|
* Creates an instance of APIClient. |
|
* @param {Object} config - Configuration options for the client |
|
* @param {string} [config.hostname='localhost'] - The hostname for the API |
|
* @param {number} [config.port=443] - The port number |
|
* @param {string} [config.basePath=''] - Base path prefix for all requests |
|
* @param {boolean} [config.useHttps=true] - Whether to use HTTPS or HTTP |
|
* @param {Object} [config.defaultHeaders] - Default headers to include in all requests |
|
* @param {number} [config.timeout=30000] - Request timeout in milliseconds |
|
* @example |
|
* // Basic HTTPS client |
|
* const client = new APIClient({ |
|
* hostname: 'api.example.com' |
|
* }) |
|
* |
|
* // Advanced configuration |
|
* const client = new APIClient({ |
|
* hostname: 'api.example.com', |
|
* port: 8443, |
|
* basePath: '/v1', |
|
* useHttps: true, |
|
* defaultHeaders: { |
|
* 'Authorization': 'Bearer token', |
|
* 'Custom-Header': 'value' |
|
* }, |
|
* timeout: 60000 |
|
* }) |
|
*/ |
|
constructor(config) { |
|
this.config = { |
|
hostname: config.hostname || 'localhost', |
|
port: config.port || 443, |
|
basePath: config.basePath || '', |
|
useHttps: config.useHttps ?? true, |
|
defaultHeaders: { |
|
'Content-Type': 'application/json', |
|
...config.defaultHeaders |
|
}, |
|
timeout: config.timeout || 30000 // 30 seconds default timeout |
|
} |
|
} |
|
/** |
|
* Normalize path joining by: |
|
* 1. Removing trailing slash from basePath |
|
* 2. Removing leading slash from path |
|
* 3. Joining with a single slash |
|
*/ |
|
_normalizePath (basePath, pathSuffix) { |
|
const normalizedBase = basePath.replace(/\/+$/, '') // Remove trailing slashes |
|
const normalizedPath = pathSuffix.replace(/^\/+/, '') // Remove leading slashes |
|
return normalizedBase ? `${normalizedBase}/${normalizedPath}` : normalizedPath |
|
} |
|
/** |
|
* Makes an HTTP/HTTPS request and returns a promise that resolves to [error, response]. |
|
* Never throws exceptions - all errors are returned in the error position of the array. |
|
* @async |
|
* @param {Object} options - Request options |
|
* @param {string} [options.path='/'] - The request path (will be appended to basePath) |
|
* @param {string} [options.method='GET'] - The HTTP method to use |
|
* @param {Object|null} [options.data=null] - Data to send with the request |
|
* @param {Object} [options.headers={}] - Additional headers to send |
|
* @param {string} [options.responseType='json'] - Response type ('json' or 'text') |
|
* @param {Function} [options.validateStatus] - Function to validate HTTP status code |
|
* @returns {Promise<[Error|null, any|null]>} Promise resolving to [error, response] |
|
* @example |
|
* // Simple GET request |
|
* const [error, data] = await client.makeRestRequest({ |
|
* path: '/users' |
|
* }) |
|
* |
|
* // POST request with data |
|
* const [error, data] = await client.makeRestRequest({ |
|
* path: '/users', |
|
* method: 'POST', |
|
* data: { |
|
* name: 'John', |
|
* email: '[email protected]' |
|
* } |
|
* }) |
|
* |
|
* // Custom headers and response validation |
|
* const [error, data] = await client.makeRestRequest({ |
|
* path: '/orders', |
|
* method: 'POST', |
|
* data: { orderId: '123' }, |
|
* headers: { |
|
* 'X-Custom-Header': 'value' |
|
* }, |
|
* responseType: 'text', |
|
* validateStatus: (status) => status === 201 |
|
* }) |
|
* |
|
* // Error handling |
|
* const [error, data] = await client.makeRestRequest({ |
|
* path: '/users/123' |
|
* }) |
|
* |
|
* if (error) { |
|
* if (error.message.includes('HTTP Error: 404')) { |
|
* console.error('User not found') |
|
* } else { |
|
* console.error('Other error:', error.message) |
|
* } |
|
* return |
|
* } |
|
*/ |
|
async makeRestRequest(options) { |
|
const { |
|
path = '/', |
|
method = 'GET', |
|
data = null, |
|
headers = {}, |
|
responseType = 'json', |
|
validateStatus = (status) => status >= 200 && status < 300 |
|
} = options |
|
|
|
return new Promise((resolve) => { |
|
const postData = data ? JSON.stringify(data) : '' |
|
const requestOptions = { |
|
hostname: this.config.hostname, |
|
port: this.config.port, |
|
path: this._normalizePath(this.config.basePath, path), |
|
method: method, |
|
headers: { |
|
...this.config.defaultHeaders, |
|
...headers, |
|
...(postData && { 'Content-Length': Buffer.byteLength(postData) }) |
|
}, |
|
timeout: this.config.timeout |
|
} |
|
const httpModule = this.config.useHttps ? https : http |
|
const req = httpModule.request(requestOptions, (res) => { |
|
let data = '' |
|
res.on('data', (chunk) => { |
|
data += chunk |
|
}) |
|
res.on('end', () => { |
|
try { |
|
if (!validateStatus(res.statusCode)) { |
|
resolve([new Error(`HTTP Error: ${res.statusCode} ${res.statusMessage}\nResponse: ${data}`), null]) |
|
return |
|
} |
|
let parsedData |
|
switch (responseType) { |
|
case 'json': |
|
parsedData = JSON.parse(data) |
|
break |
|
case 'text': |
|
parsedData = data |
|
break |
|
default: |
|
parsedData = data |
|
} |
|
if (parsedData && parsedData.error) { |
|
resolve([new Error(`API Error: ${parsedData.error}`), null]) |
|
return |
|
} |
|
resolve([null, parsedData]) |
|
} catch (e) { |
|
resolve([new Error(`Parse Error: ${e.message}\nRaw response: ${data}`), null]) |
|
} |
|
}) |
|
}) |
|
req.on('error', (e) => { |
|
resolve([new Error(`Request Error: ${e.message}`), null]) |
|
}) |
|
req.on('timeout', () => { |
|
req.destroy() |
|
resolve([new Error(`Request timeout after ${this.config.timeout}ms`), null]) |
|
}) |
|
if (postData) { |
|
req.write(postData) |
|
} |
|
req.end() |
|
}) |
|
} |
|
} |
|
|
|
/** |
|
* Gets staged files and their change types from git |
|
* @returns {Promise<[Error|null, Array|null]>} Array containing [error, results] |
|
*/ |
|
function getStagedFilesAndChangeType() { |
|
return new Promise(resolve => { |
|
exec('git diff --cached --name-status', (error, stdout, stderr) => { |
|
if (error || stderr) { |
|
const errorMessage = error |
|
? `Git command failed: ${error.message}` |
|
: `Git error: ${stderr}` |
|
resolve([new Error(errorMessage), null]) |
|
return |
|
} |
|
|
|
try { |
|
if (!stdout.trim()) { |
|
resolve([null, []]) |
|
return |
|
} |
|
|
|
const commandDescriptions = { |
|
'M': 'Modified', |
|
'A': 'Added', |
|
'D': 'Deleted', |
|
'R': 'Renamed', |
|
'C': 'Copied', |
|
'U': 'Updated but unmerged' |
|
} |
|
|
|
const result = stdout |
|
.split('\n') |
|
.filter(line => line.trim().length > 0) |
|
.map(line => { |
|
const command = line[0] |
|
const file = line.substring(2).trim() |
|
return { |
|
command, |
|
file, |
|
description: commandDescriptions[command] || 'Unknown' |
|
} |
|
}) |
|
|
|
resolve([null, result]) |
|
} catch (parseError) { |
|
resolve([new Error(`Failed to parse git output: ${parseError.message}`), null]) |
|
} |
|
}) |
|
}) |
|
} |
|
|
|
/** |
|
* Gets the git diff for a specific file object |
|
* @param {{ command: string, file: string, description: string }} fileInfo - The file info object |
|
* @returns {Promise<[Error|null, string|null]>} Array containing [error, result] |
|
*/ |
|
function getGitDiff(fileInfo) { |
|
return new Promise(resolve => { |
|
if (fileInfo.command === 'D') { |
|
resolve([null, '']) // Return empty diff for deleted files |
|
return |
|
} |
|
const gitCommand = fileInfo.command === 'R' |
|
? `git --no-pager diff --cached -- "${fileInfo.file.split(' -> ')[1]}"` |
|
: `git --no-pager diff --cached -- "${fileInfo.file}"` |
|
exec(gitCommand, (error, stdout, stderr) => { |
|
if (error || stderr) { |
|
resolve([new Error(`Error getting git diff: ${error || stderr}`), null]) |
|
return |
|
} |
|
if (!stdout && fileInfo.command === 'A') { |
|
resolve([null, '']) // Return empty diff for new files |
|
return |
|
} |
|
if (!stdout && fileInfo.command !== 'A') { |
|
resolve([new Error('No changes found'), null]) |
|
return |
|
} |
|
resolve([null, stdout]) |
|
}) |
|
}) |
|
} |
|
|
|
/** @typedef {'M'|'A'|'D'|'R'|'C'|'U'} GitCommand */ |
|
|
|
/** @typedef {Object} FileDescriptor |
|
* @property {GitCommand} command - Git status command |
|
* @property {string} file - File path |
|
* @property {string} description - Human readable change description |
|
*/ |
|
|
|
/** @typedef {Object} DiffResult |
|
* @property {string} file - File path |
|
* @property {GitCommand} command - Git status command |
|
* @property {string} description - Human readable change description |
|
* @property {string} [diff] - Git diff output (present if success is true) |
|
* @property {string} [error] - Error message (present if success is false) |
|
* @property {boolean} success - Whether the operation succeeded |
|
*/ |
|
|
|
/** |
|
* Processes git changes and yields diff results |
|
* @param {FileDescriptor[]} fileDescriptors |
|
* @yields {DiffResult} |
|
*/ |
|
async function* processGitDiffs(fileDescriptors) { |
|
for (const fileDescriptor of fileDescriptors) { |
|
const [error, diff] = await getGitDiff(fileDescriptor) |
|
if (error) { |
|
yield { |
|
...fileDescriptor, |
|
error: error.message, |
|
success: false |
|
} |
|
continue |
|
} |
|
yield { |
|
...fileDescriptor, |
|
diff, |
|
success: true |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Constructs an AI prompt for generating git commit messages |
|
* @param {DiffResult} fileInfo |
|
* @returns {string} Formatted AI prompt |
|
*/ |
|
function constructPrompt({ file, description, diff }) { |
|
const prompt = ` |
|
You are an expert software developer tasked with creating an itemized Git commit message. Your goal is to analyze a code change and generate a concise, informative commit message. |
|
|
|
You will be provided with the following information: |
|
1. File information: |
|
Path: \`${file}\` |
|
Type: ${description} |
|
2. Git diff of the changes between the git_diff tags: |
|
<git_diff> |
|
${diff} |
|
</git_diff> |
|
|
|
Using this information, generate a commit message that follows these guidelines: |
|
|
|
1. Start with an H2 (##) header containing the file name and extension, followed by 2 new line. |
|
2. Look at ONLY the lines that start with + or - in the diff |
|
3. Describe EXACTLY what changed between these lines, no matter how trivial: |
|
- If only whitespace changed, say exactly what whitespace changed |
|
- If a line was added or removed, describe only that specific line |
|
- Do not look at surrounding context |
|
- Do not interpret the purpose of the change |
|
- Do not describe what the code does |
|
4. Use imperative form, maximum 20 words |
|
5. Ensure there is a new line after the explanation |
|
|
|
Format your commit message as follows: |
|
\`\`\` |
|
## [file name] |
|
[Concise explanation in imperative form, max 20 words] |
|
\`\`\` |
|
|
|
Remember to surround your entire response with <commit_message> tags. |
|
|
|
Example: |
|
<commit_message> |
|
## user_model.py |
|
Add email validation method to User class |
|
|
|
</commit_message> |
|
|
|
Now, analyze the provided file information and git diff, then generate an appropriate commit message following the instructions above. |
|
|
|
` |
|
console.log(prompt) |
|
return prompt |
|
} |
|
|
|
function extractCommitMessage(str) { |
|
const regex = /<commit_message>([\s\S]*?)<\/commit_message>/; |
|
const match = str.match(regex); |
|
return match ? match[1].trim() : null; |
|
} |
|
|
|
/** |
|
* Makes a request to the Ollama API using APIClient |
|
* @param {APIClient} client - Initialized Ollama API client |
|
* @param {string} prompt - The prompt to send to the API |
|
* @returns {Promise<[Error|null, string|null]>} Tuple of [error, response] |
|
*/ |
|
async function makeOllamaRequest(client, prompt) { |
|
const [error, response] = await client.makeRestRequest({ |
|
path: OLLAMA_CONFIG.path, |
|
method: 'POST', |
|
data: { |
|
model: OLLAMA_CONFIG.model, |
|
prompt, |
|
stream: false |
|
} |
|
}) |
|
|
|
if (error) return [error, null] |
|
if (!response?.response) { |
|
return [new Error('Invalid response format from Ollama API'), null] |
|
} |
|
|
|
return [null, extractCommitMessage(response.response)] |
|
} |
|
|
|
|
|
/** |
|
* Copies text to clipboard using pbcopy (macOS only) |
|
* |
|
* @param {string} text - Text to copy to clipboard |
|
* @returns {Promise<void>} |
|
*/ |
|
function copyToClipboard(text) { |
|
return new Promise((resolve, reject) => { |
|
const {spawn} = require('child_process'); |
|
const pbcopy = spawn('pbcopy'); |
|
pbcopy.on('error', reject); |
|
pbcopy.on('close', resolve); |
|
pbcopy.stdin.write(text); |
|
pbcopy.stdin.end(); |
|
}); |
|
} |
|
|
|
const createOllamaClient = (config = {}) => { |
|
const defaultConfig = { |
|
hostname: OLLAMA_CONFIG.hostname, |
|
port: OLLAMA_CONFIG.port, |
|
basePath: OLLAMA_CONFIG.basePath, |
|
useHttps: false, |
|
defaultHeaders: { |
|
'Content-Type': 'application/json' |
|
} |
|
}; |
|
|
|
return new APIClient({ ...defaultConfig, ...config }); |
|
} |
|
|
|
/** |
|
* Main execution function |
|
*/ |
|
async function main() { |
|
const ollamaClient = createOllamaClient(); |
|
try { |
|
const [error,files] = await getStagedFilesAndChangeType() |
|
|
|
if ( error ) { |
|
throw error; |
|
} |
|
|
|
const iterator = processGitDiffs(files) |
|
let commitMessage = '' |
|
for await (const result of iterator) { |
|
if (result.success) { |
|
const [error,singleCommitMessage] = await makeOllamaRequest(ollamaClient, constructPrompt(result) ); |
|
if ( error ) { |
|
throw error; |
|
} |
|
commitMessage += singleCommitMessage ? singleCommitMessage : ''; |
|
console.log(`Processed ${result.file}`) |
|
// console.log(result.diff) |
|
} else { |
|
console.error(`Error processing ${result.file}:`, result.error) |
|
} |
|
} |
|
console.log(JSON.stringify(commitMessage, null, 4)) |
|
await copyToClipboard(commitMessage); |
|
console.log('\nCommit message has been copied to clipboard!'); |
|
} catch (error) { |
|
console.error('Error:', error.message); |
|
process.exit(1); |
|
} |
|
} |
|
|
|
// Execute the script |
|
main(); |