Skip to content
Closed
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [1.5.6] - 2025-08-21


### ✨ Features
- refine commit processing and API reliability

### 📚 Documentation
- add release process guide

### 📦 Build System
- bump version to 1.5.6
- support branch selection in release script

## [1.5.5] - 2025-06-15


Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,15 @@ CommitLoom automatically:
- ✅ **Documentation**: Comprehensive README and type hints
- ✅ **Maintenance**: Actively maintained and accepting contributions

## 🔄 Release Process

The project ships via a helper script that bumps versions, updates the changelog and tags releases.

1. Ensure the working tree is clean and you are on the branch you want to release.
2. Run `python release.py patch` (use `minor` or `major` as needed).
3. Add `--branch` to target a different branch or `--skip-push` to avoid pushing to origin.
4. When pushed, `auto-release.yml` creates the GitHub release and `publish.yml` uploads the package to PyPI.

## 🤝 Contributing

While I maintain this project personally, I welcome contributions! If you'd like to help improve CommitLoom, please:
Expand Down
2 changes: 1 addition & 1 deletion commitloom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from .core.git import GitError, GitFile, GitOperations
from .services.ai_service import AIService, CommitSuggestion, TokenUsage

__version__ = "1.5.5"
__version__ = "1.5.6"
__author__ = "Petru Arakiss"
__email__ = "petruarakiss@gmail.com"

Expand Down
27 changes: 11 additions & 16 deletions commitloom/cli/cli_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ def _process_single_commit(self, files: list[GitFile]) -> None:
# Print analysis
console.print_warnings(analysis)
self._maybe_create_branch(analysis)
self._maybe_create_branch(analysis)

try:
# Generate commit message
Expand Down Expand Up @@ -239,9 +238,7 @@ def _create_batches(self, changed_files: list[GitFile]) -> list[list[GitFile]]:
invalid_files = []

for file in changed_files:
if hasattr(self.git, "should_ignore_file") and self.git.should_ignore_file(
file.path
):
if self.git.should_ignore_file(file.path):
invalid_files.append(file)
console.print_warning(f"Ignoring file: {file.path}")
else:
Expand Down Expand Up @@ -289,18 +286,16 @@ def _create_combined_commit(self, batches: list[dict]) -> None:

# Create combined commit message
title = "📦 chore: combine multiple changes"
body = "\n\n".join(
[
title,
"\n".join(
f"{data['emoji']} {category}:" for category, data in all_changes.items()
),
"\n".join(
f"- {change}" for data in all_changes.values() for change in data["changes"]
),
" ".join(summary_points),
]
)
body_parts = [
"\n".join(
f"{data['emoji']} {category}:" for category, data in all_changes.items()
),
"\n".join(
f"- {change}" for data in all_changes.values() for change in data["changes"]
),
" ".join(summary_points),
]
body = "\n\n".join(part for part in body_parts if part)

# Stage and commit all files
self.git.stage_files(all_files)
Expand Down
2 changes: 1 addition & 1 deletion commitloom/core/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ def format_cost_for_humans(cost: float) -> str:
elif cost >= 0.01:
return f"{cost*100:.2f}¢"
else:
return "0.10¢" # For very small costs, show as 0.10¢
return f"{cost*100:.2f}¢"

@staticmethod
def get_cost_context(total_cost: float) -> str:
Expand Down
11 changes: 11 additions & 0 deletions commitloom/core/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import os
import subprocess
from dataclasses import dataclass
from fnmatch import fnmatch

from ..config.settings import config

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -38,6 +41,14 @@ def is_renamed(self) -> bool:
class GitOperations:
"""Basic git operations handler."""

@staticmethod
def should_ignore_file(path: str) -> bool:
"""Check if a file should be ignored based on configured patterns."""
for pattern in config.ignored_patterns:
if fnmatch(path, pattern):
return True
return False

@staticmethod
def _handle_git_output(result: subprocess.CompletedProcess, context: str = "") -> None:
"""Handle git command output and log messages."""
Expand Down
93 changes: 59 additions & 34 deletions commitloom/services/ai_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
from dataclasses import dataclass
import os
import time

import requests

Expand Down Expand Up @@ -88,6 +89,7 @@ def __init__(self, api_key: str | None = None, test_mode: bool = False):
self.test_mode = test_mode
# Permitir override por variable de entorno
self.model_name = os.getenv("COMMITLOOM_MODEL", config.default_model)
self.session = requests.Session()

@property
def model(self) -> str:
Expand All @@ -106,14 +108,15 @@ def token_usage_from_api_usage(cls, usage: dict[str, int]) -> TokenUsage:
def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str:
"""Generate the prompt for the AI model."""
files_summary = ", ".join(f.path for f in changed_files)
has_binary = any(f.is_binary for f in changed_files)
binary_files = ", ".join(f.path for f in changed_files if f.is_binary)
text_files = [f for f in changed_files if not f.is_binary]

# Check if we're dealing with binary files
if diff.startswith("Binary files changed:"):
if has_binary and not text_files:
return (
"Generate a structured commit message for the following binary file changes.\n"
"You must respond ONLY with a valid JSON object.\n\n"
f"Files changed: {files_summary}\n\n"
f"{diff}\n\n"
f"Files changed: {binary_files}\n\n"
"Requirements:\n"
"1. Title: Maximum 50 characters, starting with an appropriate "
"gitemoji (📝 for data files), followed by the semantic commit "
Expand All @@ -128,18 +131,22 @@ def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str:
' "emoji": "📝",\n'
' "changes": [\n'
' "Updated binary files with new data",\n'
' "Files affected: example.bin"\n'
f' "Files affected: {binary_files}"\n'
" ]\n"
" }\n"
" },\n"
' "summary": "Updated binary files with new data"\n'
f' "summary": "Updated binary files: {binary_files}"\n'
"}"
)

return (
prompt = (
"Generate a structured commit message for the following git diff.\n"
"You must respond ONLY with a valid JSON object.\n\n"
f"Files changed: {files_summary}\n\n"
)
if binary_files:
prompt += f"Binary files: {binary_files}\n\n"
prompt += (
"```\n"
f"{diff}\n"
"```\n\n"
Expand Down Expand Up @@ -172,6 +179,7 @@ def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str:
' "summary": "Added new feature X with configuration updates"\n'
"}"
)
return prompt

def generate_commit_message(
self, diff: str, changed_files: list[GitFile]
Expand Down Expand Up @@ -213,36 +221,53 @@ def generate_commit_message(
"temperature": 0.7,
}

try:
response = requests.post(
"https://api.openai.com/v1/chat/completions",
headers=headers,
json=data,
timeout=30,
)
last_exception: requests.exceptions.RequestException | None = None
response: requests.Response | None = None
for attempt in range(3):
try:
response = self.session.post(
"https://api.openai.com/v1/chat/completions",
headers=headers,
json=data,
timeout=30,
)
if response.status_code >= 500:
raise requests.exceptions.RequestException(
f"Server error: {response.status_code}", response=response
)
break
except requests.exceptions.RequestException as e:
last_exception = e
if attempt == 2:
break
time.sleep(2**attempt)

if last_exception and (response is None or response.status_code >= 500):
if (
hasattr(last_exception, "response")
and last_exception.response is not None
and hasattr(last_exception.response, "text")
):
error_message = last_exception.response.text
else:
error_message = str(last_exception)
raise ValueError(f"API Request failed: {error_message}") from last_exception

if response.status_code == 400:
error_data = response.json()
error_message = error_data.get("error", {}).get("message", "Unknown error")
raise ValueError(f"API Error: {error_message}")
if response.status_code == 400:
error_data = response.json()
error_message = error_data.get("error", {}).get("message", "Unknown error")
raise ValueError(f"API Error: {error_message}")

response.raise_for_status()
response_data = response.json()
content = response_data["choices"][0]["message"]["content"]
usage = response_data["usage"]
response.raise_for_status()
response_data = response.json()
content = response_data["choices"][0]["message"]["content"]
usage = response_data["usage"]

try:
commit_data = json.loads(content)
return CommitSuggestion(**commit_data), TokenUsage.from_api_usage(usage)
except json.JSONDecodeError as e:
raise ValueError(f"Failed to parse AI response: {str(e)}") from e

except requests.exceptions.RequestException as e:
if hasattr(e, "response") and e.response is not None and hasattr(e.response, "text"):
error_message = e.response.text
else:
error_message = str(e)
raise ValueError(f"API Request failed: {error_message}") from e
try:
commit_data = json.loads(content)
return CommitSuggestion(**commit_data), TokenUsage.from_api_usage(usage)
except json.JSONDecodeError as e:
raise ValueError(f"Failed to parse AI response: {str(e)}") from e

@staticmethod
def format_commit_message(commit_data: CommitSuggestion) -> str:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "commitloom"
version = "1.5.5"
version = "1.5.6"
description = "Weave perfect git commits with AI-powered intelligence"
authors = ["Petru Arakiss <petruarakiss@gmail.com>"]
readme = "README.md"
Expand Down
Loading
Loading