Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@

### Overview

This is a Python script designed to read git diff output and leverage Ollama to generate commit messages for each file. When you run this script, commits will be automatically created for all staged files.
`auto_commit.py` is a Python script that automates the process of generating concise, conventional commit messages for your Git repository changes using an LLM (via Ollama). It can commit all changes at once or commit each file separately, with AI-generated commit messages based on the actual diffs.

<img src="https://github.com/user-attachments/assets/f39344db-10c5-4dbc-a3e6-2ce275d52004" />

### Features

- AI-generated commit messages: Uses an LLM to analyze Git diffs and suggest relevant, conventional commit messages (e.g., feat:, fix:, chore:).
- Commit all or per file: Optionally commits all changes together or each file separately, each with its own message.
- Handles new, modified, and deleted files.
- Works with both staged and unstaged changes.

### How It Works

1. Detects changed files (staged and unstaged).
2. Gets diffs for each file.
3. Sends diffs and file info to the LLM via Ollama to generate a commit message.
4. Commits changes using the generated message(s).


### Installation

- Python 3.7+

- [Olama](https://ollama.com/download)

- [Ollama Model](https://ollama.com/library/gemma3)
Expand All @@ -17,27 +34,43 @@ This script currently uses the `gemma3:4b` model.
ollama run gemma3:4b
```

- [Python Ollama](https://github.com/ollama/ollama-python)
- [Ollama Python client](https://github.com/ollama/ollama-python)
```
pip install ollama
```

- Git installed and available in PATH

### Usage

Place the `auto_commit.py` file in the root directory of your project.
If ollama server is running, run the script using the command

```
python3 auto_commit.py
python auto_commit.py <repository_path> [single]
```

- <repository_path>: Path to your Git repository.
- single (optional): If provided, commits each file separately; otherwise, all changes are committed together.

### Example

Commit all changes in git:

```
python3 auto_commit.py /Users/ttpho/Documents/GitHub/chat
```

Create commit per file
Commit each file separately:

```
python3 auto_commit.py single
python3 auto_commit.py /Users/ttpho/Documents/GitHub/chat single
```

### Notes

- The script uses the `gemma3:4b` model by default. You can change the model by editing the model variable.
- Commit messages are limited to 72 characters and follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) style.
- The script will print the generated commit messages before committing.

### Miscellaneous

Expand Down
96 changes: 56 additions & 40 deletions auto_commit.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import asyncio
import os
import subprocess
import sys
from ollama import AsyncClient

model = "gemma3:4b"
prompt = f"""
Given the following Git diff and the list of changed files (with file types), suggest a single concise and relevant commit message that best summarizes all the changes made. Use a conventional commit style (e.g., feat:, fix:, chore:, docs:, refactor:). The message should be no longer than 72 characters.
Given the following Git diff and the list of changed files (with file types), suggest a single concise and relevant commit message that best summarizes all the changes made.
Use a conventional commit style (e.g., feat:, fix:, chore:, docs:, refactor:).
The message should be no longer than 72 characters.
Just return the commit messages without any additional text or explanation, without any Markdown formatting.
Input:
Git Diff:
Expand All @@ -21,37 +24,46 @@
"""
client = AsyncClient()


async def get_changed_files():
async def get_changed_files(repository_path):
# Git add all
subprocess.run(
["git", "add", "."],
capture_output=True, text=True
capture_output=True, text=True, cwd=repository_path
)
# Get all staged and unstaged files (excluding untracked)
result = subprocess.run(
["git", "diff", "--name-only"],
capture_output=True, text=True
capture_output=True, text=True, cwd=repository_path
)
unstaged = set(result.stdout.splitlines())
result = subprocess.run(
["git", "diff", "--name-only", "--staged"],
capture_output=True, text=True
capture_output=True, text=True, cwd=repository_path
)
staged = set(result.stdout.splitlines())
# Union of both sets
return sorted(unstaged | staged)


async def get_diff_for_file(filename, staged=False):
async def get_diff_for_file(file_path, repository_path, staged=False):
cmd = ["git", "diff"]
if staged:
cmd.append("--staged")
cmd.append("--")
cmd.append(filename)
result = subprocess.run(cmd, capture_output=True, text=True)
cmd.append(file_path)
result = subprocess.run(cmd, capture_output=True, text=True, cwd=repository_path)
return result.stdout

def replace_backticks(text):
"""Replaces all occurrences of ``` with an empty string.

Args:
text: The input string.

Returns:
The string with all ``` delimiters replaced by empty strings.
"""
return text.replace("```", "")

async def get_commit_messages(diff, file_with_type):
# Use the Ollama chat model to get commit messages
Expand All @@ -65,12 +77,13 @@ async def get_commit_messages(diff, file_with_type):
},
]
response = await client.chat(model=model, messages=messages)
return response['message']['content']
content = response['message']['content']
return replace_backticks(content)
except Exception:
return ""


def status_file(file_path):
def status_file(file_path, repository_path):
"""
Creates a descriptive commit message for changes to a single file,
detecting if it was added, modified, or deleted.
Expand All @@ -79,15 +92,15 @@ def status_file(file_path):
# Check if the file is new (not tracked yet)
status_new_process = subprocess.run(
['git', 'status', '--porcelain', file_path],
capture_output=True, text=True, check=True
capture_output=True, text=True, check=True, cwd=repository_path,
)
if status_new_process.stdout.strip().startswith("??"):
return "Add"

# Check if the file was deleted
status_deleted_process = subprocess.run(
['git', 'diff', '--staged', '--name-status', file_path],
capture_output=True, text=True, check=True,
capture_output=True, text=True, check=True, cwd=repository_path,
)
if status_deleted_process.stdout.strip().startswith("D"):
return "Remove"
Expand All @@ -99,12 +112,13 @@ def status_file(file_path):
return ""


async def diff_single_file(file):
async def diff_single_file(file_path, repository_path):
commit_messages = []
status = status_file(file).strip()
file_with_type = f"{file} : {status}"
unstaged_diff = (await get_diff_for_file(file, staged=False)).strip()
staged_diff = (await get_diff_for_file(file, staged=True)).strip()
status = status_file(file_path, repository_path).strip()
file_name = os.path.basename(file_path).strip()
file_with_type = f"{status} : {file_name}"
unstaged_diff = (await get_diff_for_file(file_path, repository_path, staged=False)).strip()
staged_diff = (await get_diff_for_file(file_path, repository_path, staged=True)).strip()
messages_staged_diff = (await get_commit_messages(staged_diff, file_with_type)).strip()
messages_unstaged_diff = (await get_commit_messages(unstaged_diff, file_with_type)).strip()
if messages_staged_diff:
Expand All @@ -114,20 +128,20 @@ async def diff_single_file(file):
return commit_messages


async def git_commit_everything(message):
async def git_commit_everything(message, repository_path):
"""
Stages all changes (including new, modified, deleted files), commits with the given message,
and pushes the commit to the current branch on the default remote ('origin').
"""
if not message:
return
# Stage all changes (new, modified, deleted)
subprocess.run(['git', 'add', '-A'], check=True)
subprocess.run(['git', 'add', '-A'], check=True, cwd=repository_path,)
# Commit with the provided message
subprocess.run(['git', 'commit', '-m', message], check=True)
subprocess.run(['git', 'commit', '-m', message], check=True, cwd=repository_path,)


async def git_commit_file(file, message):
async def git_commit_file(file_path, repository_path, message):
"""
Stages all changes (including new, modified, deleted files), commits with the given message,
and pushes the commit to the current branch on the default remote ('origin').
Expand All @@ -136,42 +150,44 @@ async def git_commit_file(file, message):
return

try:
subprocess.run(['git', 'add', file], check=True)
subprocess.run(['git', 'add', file_path], check=True, cwd=repository_path,)
except:
print("An exception occurred")
# Commit with the provided message
subprocess.run(['git', 'commit', file, '-m', message], check=True)
subprocess.run(['git', 'commit', file_path, '-m', message], check=True, cwd=repository_path,)


async def commit_comment_per_file(files):
for file in files:
commit_messages = await diff_single_file(file)
async def commit_comment_per_file(all_file_path, repository_path):
for file_path in all_file_path:
commit_messages = await diff_single_file(file_path, repository_path)
commit_messages_text = "\n".join(commit_messages)
print(f"{file}: {commit_messages_text}")
await git_commit_file(file, commit_messages_text)
print(f"{file_path}: {commit_messages_text}")
await git_commit_file(file_path, repository_path, commit_messages_text)


async def comit_comment_all(files):
async def commit_comment_all(all_file_path, repository_path):
all_message = []
for file in files:
commit_messages = await diff_single_file(file)
for file_path in all_file_path:
commit_messages = await diff_single_file(file_path, repository_path)
commit_messages_text = "\n".join(commit_messages)
print(f"{file}: {commit_messages_text}")
print(f"{file_path}: {commit_messages_text}")
all_message.extend(commit_messages)
await git_commit_everything(message="\n".join(all_message))
await git_commit_everything(message="\n".join(all_message), repository_path = repository_path)


async def main():
files = await get_changed_files()
if not files:
repository_path = sys.argv[1] if len(sys.argv) > 1 else None
is_commit_per_file = True if (len(sys.argv) > 2 and sys.argv[2] == 'single') else False

all_file_path = await get_changed_files(repository_path)
if not all_file_path:
print("No changes detected.")
return
is_commit_per_file = True if (
len(sys.argv) > 1 and sys.argv[1] == 'single') else False

if is_commit_per_file:
await commit_comment_per_file(files)
await commit_comment_per_file(all_file_path, repository_path)
else:
await comit_comment_all(files)
await commit_comment_all(all_file_path, repository_path)

if __name__ == "__main__":
asyncio.run(main())