Skip to content

Instantly share code, notes, and snippets.

@kasperschnack
Created March 25, 2025 07:03
Show Gist options
  • Save kasperschnack/58a9b76e4ccb2b11193a7e9bf9034633 to your computer and use it in GitHub Desktop.
Save kasperschnack/58a9b76e4ccb2b11193a7e9bf9034633 to your computer and use it in GitHub Desktop.
Generate Conventional Commits using AI from Git diffs. Stages changes, runs pre-commit, suggests a commit message via OpenAI, and optionally opens editor or pushes. Make executable as git smart-commit.
import os
import sys
import asyncio
import subprocess
import argparse
from openai import AsyncOpenAI
client = AsyncOpenAI()
def run_command(command, check=True):
    try:
        result = subprocess.run(
            command,
            shell=True,
            check=check,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True
        )
        return result
    except subprocess.CalledProcessError as e:
        if check:
            print(f"Error executing command: {e}")
            print(f"stdout: {e.stdout}")
            print(f"stderr: {e.stderr}")
            sys.exit(1)
        return e
def get_git_diff():
    result = run_command('git diff HEAD')
    return result.stdout
async def get_commit_suggestion(diff):
    prompt = f"""Please suggest a concise and meaningful git commit message for the following diff.
Follow the Conventional Commits format (type(scope): description).
Common types are: feat, fix, docs, style, refactor, test, chore. Please only output the commit message.
Here's the diff:
{diff}"""
    stream = await client.chat.completions.create(
        model="gemini_1_5_pro",
        messages=[{"role": "user", "content": prompt}],
        stream=True,
    )
    message_parts = []
    async for chunk in stream:
        content = chunk.choices[0].delta.content
        if content:
            message_parts.append(content)
            print(content, end="")
    
    return "".join(message_parts).strip()
async def get_commit_suggestion(diff, hint=None):
    # Build the prompt with the optional hint
    base_prompt = """Please suggest a concise and meaningful git commit message for the following diff.
Follow the Conventional Commits format (type(scope): description).
Common types are: feat, fix, docs, style, refactor, test, chore."""
    if hint:
        base_prompt += f"\n\nHint about the changes: {hint}"
    prompt = f"""{base_prompt}
Here's the diff:
{diff}"""
    stream = await client.chat.completions.create(
        model="gemini_1_5_pro",
        messages=[{"role": "user", "content": prompt}],
        stream=True,
    )
    message_parts = []
    async for chunk in stream:
        content = chunk.choices[0].delta.content
        if content:
            message_parts.append(content)
            print(content, end="")
    
    return "".join(message_parts).strip()
def run_pre_commit():
    print("\nRunning pre-commit checks...")
    result = run_command('pre-commit run --all-files', check=False)
    
    if result.returncode != 0:
        print("\nPre-commit checks failed. The following issues were found:")
        print(result.stdout)
        print(result.stderr)
        
        # Check if there are unstaged changes (possibly from ruff auto-fixing)
        if run_command('git diff --quiet', check=False).returncode != 0:
            print("\nRuff or other tools made some automatic fixes. These changes need to be staged.")
            # choice = input("Would you like to stage these changes and retry? (y/N): ")
            # if choice.lower() == 'y':
            #     run_command('git add -A')
            #     return run_pre_commit()  # Retry pre-commit
            # else:
            #     print("\nAborting commit. Please fix the issues and try again.")
            #     sys.exit(1)
            # For now, we'll just stage the changes and retry
            print("\nStaging changes and retrying pre-commit...")
            run_command('git add -A')
            return run_pre_commit()  # Retry pre-commit
        else:
            print("\nAborting commit. Please fix the issues and try again.")
            sys.exit(1)
    
    return True
async def main():
    # Set up argument parser
    parser = argparse.ArgumentParser(description='Generate a smart commit message')
    parser.add_argument('--hint', '-m',
                       help='A hint about the intention or context of the changes',
                       default=None)
    args = parser.parse_args()
    # Check if we're in a git repository
    if run_command('git rev-parse --is-inside-work-tree 2>/dev/null', check=False).returncode != 0:
        print("Error: Not a git repository")
        sys.exit(1)
    # Get the current branch name
    branch = run_command('git branch --show-current').stdout.strip()
    # Stage all changes
    print("Staging all changes...")
    run_command('git add -A')
    # Run pre-commit checks first
    if not run_pre_commit():
        sys.exit(1)
    # Get the diff after pre-commit potentially made changes
    diff = get_git_diff()
    if not diff:
        print("No changes to commit")
        sys.exit(0)
    # Get commit suggestion with hint if provided
    print("\nAnalyzing changes and suggesting commit message...")
    if args.hint:
        print(f"Using hint: {args.hint}")
    print("-" * 50)
    suggested_message = await get_commit_suggestion(diff, args.hint)
    print("\n" + "-" * 50)
    # Ask for confirmation
    while True:
        choice = input("\nDo you want to:\n[1] Use this message\n[2] Modify it\n[3] Cancel\nChoice 1-3 (Default is 1): ") or "1"
        
        if choice == '1':
            # Use suggested message as is
            run_command(f'git commit -m "{suggested_message}"')
            break
        elif choice == '2':
            # Open editor with suggested message
            with open('.git/COMMIT_EDITMSG', 'w') as f:
                f.write(suggested_message)
            
            # Get the user's preferred editor
            editor = (
                os.environ.get('GIT_EDITOR') or
                os.environ.get('VISUAL') or
                os.environ.get('EDITOR') or
                'vim'  # fallback
            )
            
            # Open editor and wait for it to close
            try:
                # subprocess.call blocks until the editor is closed by the user
                # [editor, '.git/COMMIT_EDITMSG'] becomes something like ['vim', '.git/COMMIT_EDITMSG']
                editor_result = subprocess.call([editor, '.git/COMMIT_EDITMSG'])
                
                # editor_result will be 0 if the editor closed normally
                # non-zero if the user cancelled (e.g., :q! in vim)
                if editor_result == 0:
                    # After the editor is closed, read the contents of the file
                    with open('.git/COMMIT_EDITMSG', 'r') as f:
                        edited_message = f.read().strip()
                    
                    # Check if the user actually wrote a message
                    if edited_message:
                        # If there is a message, create the commit using that message
                        # git commit -F reads the commit message from a file
                        run_command(f'git commit -F .git/COMMIT_EDITMSG')
                    else:
                        # If the message is empty, abort the commit
                        print("Empty commit message, aborting")
                        run_command('git reset')  # Unstage all changes
                        sys.exit(1)
            except Exception as e:
                print(f"Error while editing commit message: {e}")
                run_command('git reset')  # Unstage all changes
                sys.exit(1)
            break
        elif choice == '3':
            print("Commit cancelled")
            run_command('git reset')  # Unstage all changes
            sys.exit(0)
        else:
            print("Invalid choice. Please try again.")
    # Ask about pushing
    push_choice = input("\nDo you want to push the changes? (Y/n): ") or "y"
    if push_choice.lower() == 'y':
        print(f"\nPushing to origin/{branch}...")
        run_command(f'git push origin {branch}')
        print("Done!")
if __name__ == "__main__":
    asyncio.run(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment