From 21e8867650d9b24cc6393c98b0fa519550fd4da8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 May 2025 07:25:16 +0000 Subject: [PATCH 1/4] Initial plan for issue From 77e6ebe3499fd08f34a7b1dd5c63ff06ca68b634 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 May 2025 07:33:04 +0000 Subject: [PATCH 2/4] feat(git): add integration with git hooks Co-authored-by: JayDoubleu <40270505+JayDoubleu@users.noreply.github.com> --- DEVELOPMENT.md | 29 +++++++++++ README.md | 17 +++++++ src/pycomet/cli.py | 46 +++++++++++++++++ src/pycomet/git.py | 120 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 211 insertions(+), 1 deletion(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5d5c77b..f21cb25 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -91,6 +91,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 diff --git a/README.md b/README.md index def7ffc..a02b28a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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). diff --git a/src/pycomet/cli.py b/src/pycomet/cli.py index 323cad5..a101024 100644 --- a/src/pycomet/cli.py +++ b/src/pycomet/cli.py @@ -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() diff --git a/src/pycomet/git.py b/src/pycomet/git.py index 576b731..6ccd4cc 100644 --- a/src/pycomet/git.py +++ b/src/pycomet/git.py @@ -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: @@ -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") + 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") + + 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)}" From e4a9814a60fc0c1dbb70e61b4714876bcfe67c4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 May 2025 07:42:11 +0000 Subject: [PATCH 3/4] test: Add tests for git hooks functionality Co-authored-by: JayDoubleu <40270505+JayDoubleu@users.noreply.github.com> --- tests/test_cli_hooks.py | 95 +++++++++++++++++++++++ tests/test_git.py | 165 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 tests/test_cli_hooks.py create mode 100644 tests/test_git.py diff --git a/tests/test_cli_hooks.py b/tests/test_cli_hooks.py new file mode 100644 index 0000000..42e5140 --- /dev/null +++ b/tests/test_cli_hooks.py @@ -0,0 +1,95 @@ +from unittest import mock + +import pytest +from click.testing import CliRunner + +from pycomet.cli import hooks, hooks_install, hooks_uninstall +from pycomet.git import GitRepo + + +class TestCliHooks: + """Tests for CLI hooks commands.""" + + @pytest.fixture + def runner(self): + """Return a Click CLI test runner.""" + return CliRunner() + + def test_hooks_group(self, runner): + """Test the hooks command group exists.""" + result = runner.invoke(hooks) + assert result.exit_code == 0 + assert "Manage git hooks" in result.output + + def test_hooks_install_success(self, runner): + """Test the hooks install command when successful.""" + with mock.patch.object( + GitRepo, "install_prepare_commit_msg_hook", + return_value=(True, "Prepare-commit-msg hook installed successfully") + ): + result = runner.invoke(hooks_install) + + assert result.exit_code == 0 + assert "✅" in result.output + assert "installed successfully" in result.output + assert "git commit" in result.output # Instructions in output + + def test_hooks_install_verbose(self, runner): + """Test the hooks install command with verbose flag.""" + with mock.patch.object( + GitRepo, "install_prepare_commit_msg_hook", + return_value=(True, "Prepare-commit-msg hook installed successfully") + ): + result = runner.invoke(hooks_install, ["--verbose"]) + + assert result.exit_code == 0 + assert "Installing prepare-commit-msg hook..." in result.output + assert "✅" in result.output + + def test_hooks_install_failure(self, runner): + """Test the hooks install command when it fails.""" + with mock.patch.object( + GitRepo, "install_prepare_commit_msg_hook", + return_value=(False, "Not in a git repository") + ): + result = runner.invoke(hooks_install) + + assert result.exit_code == 0 # CLI doesn't return error code + assert "❌" in result.output + assert "Not in a git repository" in result.output + + def test_hooks_uninstall_success(self, runner): + """Test the hooks uninstall command when successful.""" + with mock.patch.object( + GitRepo, "uninstall_prepare_commit_msg_hook", + return_value=(True, "PyComet hook removed") + ): + result = runner.invoke(hooks_uninstall) + + assert result.exit_code == 0 + assert "✅" in result.output + assert "hook removed" in result.output + + def test_hooks_uninstall_verbose(self, runner): + """Test the hooks uninstall command with verbose flag.""" + with mock.patch.object( + GitRepo, "uninstall_prepare_commit_msg_hook", + return_value=(True, "PyComet hook removed") + ): + result = runner.invoke(hooks_uninstall, ["--verbose"]) + + assert result.exit_code == 0 + assert "Uninstalling prepare-commit-msg hook..." in result.output + assert "✅" in result.output + + def test_hooks_uninstall_failure(self, runner): + """Test the hooks uninstall command when it fails.""" + with mock.patch.object( + GitRepo, "uninstall_prepare_commit_msg_hook", + return_value=(False, "Not in a git repository") + ): + result = runner.invoke(hooks_uninstall) + + assert result.exit_code == 0 # CLI doesn't return error code + assert "❌" in result.output + assert "Not in a git repository" in result.output \ No newline at end of file diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..67578d4 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,165 @@ +import os +import stat +import tempfile +from pathlib import Path +from unittest import mock + +import pytest + +from pycomet.git import GitRepo + + +class TestGitHooks: + """Tests for git hooks functionality in GitRepo class.""" + + @pytest.fixture + def mock_git_repo(self): + """Create a temporary directory with a fake .git structure.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create .git/hooks directory structure + git_dir = Path(temp_dir) / ".git" + hooks_dir = git_dir / "hooks" + hooks_dir.mkdir(parents=True) + + # Mock get_git_root to return our temp directory + with mock.patch.object( + GitRepo, "get_git_root", return_value=temp_dir + ) as _: + yield temp_dir + + def test_install_hook_creates_file(self, mock_git_repo): + """Test that install_prepare_commit_msg_hook creates the hook file.""" + # Setup + git_repo = GitRepo() + hook_path = Path(mock_git_repo) / ".git" / "hooks" / "prepare-commit-msg" + + # Execute + success, message = git_repo.install_prepare_commit_msg_hook() + + # Assert + assert success is True + assert "successfully" in message + assert hook_path.exists() + assert "PyComet" in hook_path.read_text() + + # Check file is executable + assert bool(os.stat(hook_path).st_mode & stat.S_IXUSR) + + def test_install_hook_when_already_installed(self, mock_git_repo): + """Test that installation is idempotent.""" + # Setup - install hook first + git_repo = GitRepo() + hook_path = Path(mock_git_repo) / ".git" / "hooks" / "prepare-commit-msg" + hook_path.write_text("#!/bin/sh\n# PyComet AI-powered commit message hook") + + # Execute + success, message = git_repo.install_prepare_commit_msg_hook() + + # Assert + assert success is True + assert "already installed" in message + + def test_install_hook_with_existing_hook(self, mock_git_repo): + """Test that existing hooks are backed up.""" + # Setup - create an existing hook + git_repo = GitRepo() + hook_path = Path(mock_git_repo) / ".git" / "hooks" / "prepare-commit-msg" + hook_path.write_text("#!/bin/sh\n# Some existing hook") + + # Execute + success, message = git_repo.install_prepare_commit_msg_hook() + + # Assert + assert success is True + assert "backed up" in message + backup_path = Path(str(hook_path) + ".backup") + assert backup_path.exists() + assert "Some existing hook" in backup_path.read_text() + + def test_uninstall_hook(self, mock_git_repo): + """Test that uninstall_prepare_commit_msg_hook removes the hook.""" + # Setup - install hook first + git_repo = GitRepo() + hook_path = Path(mock_git_repo) / ".git" / "hooks" / "prepare-commit-msg" + hook_path.write_text("#!/bin/sh\n# PyComet AI-powered commit message hook") + + # Execute + success, message = git_repo.uninstall_prepare_commit_msg_hook() + + # Assert + assert success is True + assert "hook removed" in message + assert not hook_path.exists() + + def test_uninstall_hook_with_backup(self, mock_git_repo): + """Test that uninstallation restores backups.""" + # Setup - create hook and backup + git_repo = GitRepo() + hook_path = Path(mock_git_repo) / ".git" / "hooks" / "prepare-commit-msg" + backup_path = Path(str(hook_path) + ".backup") + + hook_path.write_text("#!/bin/sh\n# PyComet AI-powered commit message hook") + backup_path.write_text("#!/bin/sh\n# Original hook") + + # Execute + success, message = git_repo.uninstall_prepare_commit_msg_hook() + + # Assert + assert success is True + assert "restored" in message + assert hook_path.exists() + assert "Original hook" in hook_path.read_text() + assert not backup_path.exists() + + def test_uninstall_non_pycomet_hook(self, mock_git_repo): + """Test that uninstall won't remove non-PyComet hooks.""" + # Setup - create non-PyComet hook + git_repo = GitRepo() + hook_path = Path(mock_git_repo) / ".git" / "hooks" / "prepare-commit-msg" + hook_path.write_text("#!/bin/sh\n# Some other hook") + + # Execute + success, message = git_repo.uninstall_prepare_commit_msg_hook() + + # Assert + assert success is False + assert "not a PyComet hook" in message + assert hook_path.exists() + + def test_uninstall_when_not_installed(self, mock_git_repo): + """Test uninstallation when hook is not installed.""" + # Setup - no hook present + git_repo = GitRepo() + + # Execute + success, message = git_repo.uninstall_prepare_commit_msg_hook() + + # Assert + assert success is True + assert "not installed" in message + + def test_install_not_in_git_repo(self): + """Test installation failure when not in a git repository.""" + # Setup - mock get_git_root to return None + with mock.patch.object(GitRepo, "get_git_root", return_value=None): + git_repo = GitRepo() + + # Execute + success, message = git_repo.install_prepare_commit_msg_hook() + + # Assert + assert success is False + assert "Not in a git repository" in message + + def test_uninstall_not_in_git_repo(self): + """Test uninstallation failure when not in a git repository.""" + # Setup - mock get_git_root to return None + with mock.patch.object(GitRepo, "get_git_root", return_value=None): + git_repo = GitRepo() + + # Execute + success, message = git_repo.uninstall_prepare_commit_msg_hook() + + # Assert + assert success is False + assert "Not in a git repository" in message \ No newline at end of file From 9cc4670bc92ae1e03b9be88ce94e4710eed7bae7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 May 2025 10:07:19 +0000 Subject: [PATCH 4/4] feat: Add docker support for running tests Co-authored-by: JayDoubleu <40270505+JayDoubleu@users.noreply.github.com> --- DEVELOPMENT.md | 22 ++++++++++++++++++++++ Dockerfile.test | 18 ++++++++++++++++++ docker-compose.yml | 25 +++++++++++++++++++++++++ docker-test.sh | 30 ++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 Dockerfile.test create mode 100644 docker-compose.yml create mode 100755 docker-test.sh diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f21cb25..379a4bb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 @@ -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 diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..6e5f82f --- /dev/null +++ b/Dockerfile.test @@ -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"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f283afb --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/docker-test.sh b/docker-test.sh new file mode 100755 index 0000000..ae13d4b --- /dev/null +++ b/docker-test.sh @@ -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 \ No newline at end of file