Skip to content

Instantly share code, notes, and snippets.

@psenger
Last active June 13, 2025 12:48
Show Gist options
  • Save psenger/8952ad3a9204cd9af817d3e5dbe0089e to your computer and use it in GitHub Desktop.
Save psenger/8952ad3a9204cd9af817d3e5dbe0089e to your computer and use it in GitHub Desktop.
[Git Commit] #AI #git #macos

🤖 Git Commit Helper

Never write boring commit messages again! This AI-powered tool analyzes your staged changes and generates detailed, structured commit messages automatically.

🌟 Features

  • 🔍 Analyzes staged git changes
  • ✨ Generates structured, detailed commit messages
  • 📋 Automatically copies to clipboard
  • 🎯 Creates per-file descriptions
  • 🚀 Uses CodeLlama for intelligent analysis

🛠️ Prerequisites

  • Node.js 20+ (node --version to check)
  • Oh My Zsh installed
  • Git
  • Ollama running locally with CodeLlama model (13b)
  • macOS (for clipboard functionality)

🚀 Installation

  1. First, make sure you have Node.js 20+ installed:
brew install node@20
  1. Create a directory for the script:
mkdir -p ~/.local/bin/git-commit-helper
cd ~/.local/bin/git-commit-helper
  1. Download the script from the gist:
curl -o git-commit-helper.js https://gist.github.com/psenger/8952ad3a9204cd9af817d3e5dbe0089e#file-git-commit-maker-js
  1. Make the script executable:
chmod +x git-commit-helper.js
  1. Create a symbolic link to make it globally accessible:
sudo ln -s "$HOME/.local/bin/git-commit-helper/git-commit-helper.js" /usr/local/bin/git-commit-helper

⚡ Setting Up the Alias

  1. Open your Oh My Zsh configuration:
vim ~/.zshrc
  1. Add this alias to your .zshrc:
# Git Commit Helper Alias
alias gsm="git-commit-helper"
  1. Reload your shell configuration:
source ~/.zshrc

🎯 Installing Ollama and CodeLlama

  1. Install Ollama:
curl https://ollama.ai/install.sh | sh
  1. Pull the CodeLlama model:
ollama pull llama3.1:latest
  1. Start the Ollama server (if not already running):
ollama serve

🎮 Usage

  1. Stage your changes as normal:
git add .
# or
git add specific-file.js
  1. Generate a commit message:
gsm
  1. The generated commit message will be automatically copied to your clipboard! 📋

Example output:

## `src/utils/auth.js`
Add user authentication middleware

Implements JWT verification and role-based access control for API routes.

## `test/auth.test.js`
Add test suite for authentication middleware

Creates comprehensive test coverage for JWT verification and RBAC functionality.

🔧 Troubleshooting

  • Error: Ollama not running

    # Start Ollama server
    ollama serve
  • Error: No staged changes

    # Make sure you've staged your changes
    git add .
  • Error: Permission denied

    # Make sure the script is executable
    chmod +x git-commit-helper.js
  • Error: Command not found

    # Make sure the symbolic link is correct
    ls -l /usr/local/bin/git-commit-helper
    # If needed, recreate the symbolic link
    sudo ln -s "$HOME/.local/bin/git-commit-helper/git-commit-helper.js" /usr/local/bin/git-commit-helper

🔒 Security Note

Always review scripts before downloading and executing them. You can view the source code directly on the gist page before installing.

📝 License

MIT License - feel free to use and modify as you wish!


⭐️ If you find this tool helpful, don't forget to star the gist! ⭐️

#!/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();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment