Skip to content
Draft
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
51 changes: 51 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ PyComet includes a comprehensive test suite that verifies functionality across m

### Running Tests

#### Local Test Execution

Basic test execution:
```bash
# Run all tests
Expand All @@ -49,6 +51,26 @@ uv run pytest tests/ --show-llm-output
uv run pytest tests/ -k "gemini" --show-llm-output
```

#### Docker Test Execution

You can also run tests in an isolated Docker environment:

```bash
# Make the script executable (if not already)
chmod +x docker-test.sh

# Run all tests (excluding integration tests)
./docker-test.sh all

# Run only git hooks tests
./docker-test.sh hooks

# Run specific tests with custom arguments
./docker-test.sh tests/test_git.py -v
```

The Docker testing environment ensures consistent test execution across different development setups.

For detailed information about testing options and configurations, see [tests/README.md](tests/README.md).

## Code Quality
Expand Down Expand Up @@ -91,6 +113,35 @@ uv run pre-commit install
uv run pre-commit run --all-files
```

## Git Hooks Integration

PyComet can integrate with Git's `prepare-commit-msg` hook to automatically generate AI-powered commit messages when you run `git commit`.

### Setting up Git Hooks

To install the Git hooks integration:

```bash
# Install the prepare-commit-msg hook
pycomet hooks install

# To uninstall the hook
pycomet hooks uninstall
```

When the hook is installed, running `git commit` (without a `-m` message) will:
1. Generate an AI-powered commit message based on staged changes
2. Pre-fill the commit message in your editor
3. Allow you to edit the message before finalizing the commit

The hook won't run when you provide a message with `git commit -m "message"` or during
merge/rebase operations.

To temporarily disable the hook for a specific commit, you can run:
```bash
git -c core.hooksPath=/dev/null commit
```

## Contributing

1. Fork the repository
Expand Down
18 changes: 18 additions & 0 deletions Dockerfile.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM python:3.12-slim

# Install git
RUN apt-get update && apt-get install -y \
git \
&& rm -rf /var/lib/apt/lists/*

# Set up work directory
WORKDIR /app

# Copy project files
COPY . .

# Set up Python path for running tests
ENV PYTHONPATH="${PYTHONPATH}:/app"

# Run tests by default (with -k option to specify test module)
CMD ["pytest", "tests/", "-v"]
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ After configuration, edit `~/.config/pycomet/config.yaml` to add your API key an
- 🔧 **Customizable**: Configure prompts, formats, and preferences
- 📊 **Usage Tracking**: Monitor token usage and costs
- 🚀 **Rate Limiting**: Automatic handling of API rate limits
- 🪝 **Git Hooks**: Integrate with git's prepare-commit-msg hook

## Basic Commands

Expand Down Expand Up @@ -131,6 +132,22 @@ uv run pycomet commit --prompt "$(cat my-prompt.txt)"
uv run pycomet commit --editor vim
```

## Git Hooks Integration

PyComet can integrate with Git's built-in hooks system to automatically generate commit messages:

```bash
# Install the prepare-commit-msg hook
pycomet hooks install

# Uninstall the hook
pycomet hooks uninstall
```

When the hook is installed, running `git commit` will automatically generate an AI commit message and pre-fill it in your editor. You can still edit the message before the commit is finalized.

See [DEVELOPMENT.md](DEVELOPMENT.md#git-hooks-integration) for more details.

## Configuration

PyComet uses a YAML config file at `~/.config/pycomet/config.yaml`. For detailed configuration options and examples for all supported AI providers, see [CONFIGS.md](CONFIGS.md).
Expand Down
25 changes: 25 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
version: '3'

services:
pycomet-tests:
build:
context: .
dockerfile: Dockerfile.test
volumes:
- .:/app
environment:
# You can uncomment and provide these for integration tests if needed
# TEST_ANTHROPIC_API_KEY: ${TEST_ANTHROPIC_API_KEY}
# TEST_OPENAI_API_KEY: ${TEST_OPENAI_API_KEY}
# TEST_GEMINI_API_KEY: ${TEST_GEMINI_API_KEY}
# By default, run unit tests only
command: pytest tests/ -v -m "not integration"

# Service for running git hooks specific tests
hooks-tests:
build:
context: .
dockerfile: Dockerfile.test
volumes:
- .:/app
command: pytest tests/test_git.py tests/test_cli_hooks.py -v
30 changes: 30 additions & 0 deletions docker-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/bash
set -e

# Colors for terminal output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m' # No Color

print_section() {
echo -e "\n${BLUE}===============================================${NC}"
echo -e "${GREEN}$1${NC}"
echo -e "${BLUE}===============================================${NC}\n"
}

# Build the Docker image
print_section "Building Docker test image..."
docker build -f Dockerfile.test -t pycomet-test .

# Run specific tests based on arguments
if [ "$1" == "hooks" ]; then
print_section "Running Git Hooks tests..."
docker run --rm -v "$(pwd):/app" pycomet-test pytest tests/test_git.py tests/test_cli_hooks.py -v
elif [ "$1" == "all" ]; then
print_section "Running all tests (excluding integration tests)..."
docker run --rm -v "$(pwd):/app" pycomet-test pytest tests/ -v -m "not integration"
else
print_section "Running tests with custom command..."
docker run --rm -v "$(pwd):/app" pycomet-test pytest "$@"
fi
46 changes: 46 additions & 0 deletions src/pycomet/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,5 +247,51 @@ def preview(
click.echo(f"Error: {str(e)}", err=True)


@cli.group()
def hooks() -> None:
"""Manage git hooks integration"""
pass


@hooks.command("install")
@click.option(
"--verbose", "-v", is_flag=True, help="Show detailed execution information"
)
def hooks_install(verbose: bool) -> None:
"""Install git hooks for automatic commit message generation"""
if verbose:
click.echo("Installing prepare-commit-msg hook...")

git = GitRepo()
success, message = git.install_prepare_commit_msg_hook()

if success:
click.echo(f"✅ {message}")
click.echo("\nThe hook will generate AI commit messages when you run:")
click.echo(" git commit")
click.echo("\nYou can still use PyComet directly with:")
click.echo(" pycomet commit")
else:
click.echo(f"❌ {message}", err=True)


@hooks.command("uninstall")
@click.option(
"--verbose", "-v", is_flag=True, help="Show detailed execution information"
)
def hooks_uninstall(verbose: bool) -> None:
"""Uninstall PyComet git hooks"""
if verbose:
click.echo("Uninstalling prepare-commit-msg hook...")

git = GitRepo()
success, message = git.uninstall_prepare_commit_msg_hook()

if success:
click.echo(f"✅ {message}")
else:
click.echo(f"❌ {message}", err=True)


if __name__ == "__main__":
cli()
120 changes: 119 additions & 1 deletion src/pycomet/git.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import os
import stat
import subprocess
import tempfile
from typing import List, Optional
from pathlib import Path
from typing import List, Optional, Tuple


class GitRepo:
Expand Down Expand Up @@ -62,3 +64,119 @@ def has_staged_changes() -> bool:
# Log unexpected errors but assume no changes for safety
print(f"Error checking staged changes: {str(e)}")
return False

@staticmethod
def get_git_root() -> Optional[str]:
"""Get the git repository root directory.
Returns the path to the repository root or None if not in a git repo.
"""
try:
return GitRepo._run_git_command(["rev-parse", "--show-toplevel"]).strip()
except Exception:
return None

@staticmethod
def install_prepare_commit_msg_hook() -> Tuple[bool, str]:
"""Install the prepare-commit-msg git hook.
Returns (success, message) tuple.
"""
git_root = GitRepo.get_git_root()
if not git_root:
return False, "Not in a git repository"

hooks_dir = Path(git_root) / ".git" / "hooks"
hook_path = hooks_dir / "prepare-commit-msg"

# Create the hook script
hook_content = """#!/bin/sh
# PyComet AI-powered commit message hook
# https://github.com/JayDoubleu/PyComet

# Get the commit message file path from Git
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2

# Skip if not an interactive commit (e.g., merge commit, commit with -m)
if [ "$COMMIT_SOURCE" = "message" ] || [ "$COMMIT_SOURCE" = "template" ] || \\
[ "$COMMIT_SOURCE" = "merge" ] || [ "$COMMIT_SOURCE" = "squash" ]; then
exit 0
fi

# Generate commit message with PyComet and save to commit message file
pycomet preview --no-detailed 2>/dev/null | \\
awk 'BEGIN{f=0} /^-+$/{f=!f; next} f{print}' > "$COMMIT_MSG_FILE.pycomet"
if [ -s "$COMMIT_MSG_FILE.pycomet" ]; then
cat "$COMMIT_MSG_FILE.pycomet" > "$COMMIT_MSG_FILE"
rm "$COMMIT_MSG_FILE.pycomet"
echo "# PyComet: Generated AI commit message. Edit as needed." >> "$COMMIT_MSG_FILE"
echo "# To disable the PyComet hook: git config --local core.hooksPath /dev/null" \\
>> "$COMMIT_MSG_FILE"
fi
"""
try:
# Ensure hooks directory exists
hooks_dir.mkdir(exist_ok=True, parents=True)

# Check if hook already exists
if hook_path.exists():
with open(hook_path, "r") as f:
existing_content = f.read()
if "PyComet" in existing_content:
return True, "Prepare-commit-msg hook is already installed"
else:
# Backup existing hook
backup_path = Path(str(hook_path) + ".backup")
Copy link

Copilot AI May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider using an f-string for constructing backup_path for improved readability, e.g., Path(f"{hook_path}.backup").

Suggested change
backup_path = Path(str(hook_path) + ".backup")
backup_path = Path(f"{hook_path}.backup")

Copilot uses AI. Check for mistakes.
hook_path.rename(backup_path)
backup_msg = f" (existing hook backed up to {backup_path})"
else:
backup_msg = ""

# Write the hook script
with open(hook_path, "w") as f:
f.write(hook_content)

# Make the hook executable
hook_mode = os.stat(hook_path).st_mode
executable_mode = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
os.chmod(hook_path, hook_mode | executable_mode)

return True, f"Prepare-commit-msg hook installed successfully{backup_msg}"

except Exception as e:
return False, f"Failed to install hook: {str(e)}"

@staticmethod
def uninstall_prepare_commit_msg_hook() -> Tuple[bool, str]:
"""Uninstall the prepare-commit-msg git hook.
Returns (success, message) tuple.
"""
git_root = GitRepo.get_git_root()
if not git_root:
return False, "Not in a git repository"

hook_path = Path(git_root) / ".git" / "hooks" / "prepare-commit-msg"
backup_path = Path(str(hook_path) + ".backup")
Copy link

Copilot AI May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider using an f-string for constructing backup_path here as well, e.g., Path(f"{hook_path}.backup"), to maintain consistency and clarity.

Suggested change
backup_path = Path(str(hook_path) + ".backup")
backup_path = Path(f"{hook_path}.backup")

Copilot uses AI. Check for mistakes.

if not hook_path.exists():
return True, "Prepare-commit-msg hook is not installed"

try:
# Check if it's the PyComet hook
with open(hook_path, "r") as f:
content = f.read()
is_pycomet_hook = "PyComet" in content

# Remove the hook
if is_pycomet_hook:
# Restore backup if it exists
if backup_path.exists():
backup_path.rename(hook_path)
return True, "PyComet hook removed and original hook restored"
else:
hook_path.unlink()
return True, "PyComet hook removed"
else:
return False, "The prepare-commit-msg hook is not a PyComet hook"

except Exception as e:
return False, f"Failed to uninstall hook: {str(e)}"
Loading