From 51e68e144f5d1e772de2fdee8eab095a29d6804c Mon Sep 17 00:00:00 2001 From: BSkaiTech Date: Tue, 27 Jan 2026 21:47:45 +0300 Subject: [PATCH 1/2] feat: add git commit message generator script --- .../git_commit_message_generator/README.md | 156 +++++++ .../requirements.txt | 6 + .../git_commit_message_generator/script.py | 399 ++++++++++++++++++ 3 files changed, 561 insertions(+) create mode 100644 scripts/git_commit_message_generator/README.md create mode 100644 scripts/git_commit_message_generator/requirements.txt create mode 100644 scripts/git_commit_message_generator/script.py diff --git a/scripts/git_commit_message_generator/README.md b/scripts/git_commit_message_generator/README.md new file mode 100644 index 0000000..412a2e4 --- /dev/null +++ b/scripts/git_commit_message_generator/README.md @@ -0,0 +1,156 @@ +# Git Commit Message Generator + +A Python script that automatically generates commit messages based on git changes analysis. This tool helps save time writing commit messages and ensures consistency by supporting the [Conventional Commits](https://www.conventionalcommits.org/) specification. + +## Features + +- **Change Analysis**: Automatically analyzes git changes (added, modified, deleted files) +- **Commit Message Suggestions**: Generates appropriate commit messages based on file changes +- **Conventional Commits Support**: Follows Conventional Commits specification for consistent commit history +- **Interactive Mode**: Allows customization of suggested commit messages +- **Smart Type Detection**: Automatically detects commit type (feat, fix, docs, etc.) based on file patterns and changes + +## Problem Statement + +Writing good commit messages can be time-consuming and maintaining consistency across a project is challenging. This script automates the process by: + +- Analyzing git changes to understand what was modified +- Suggesting appropriate commit messages following Conventional Commits format +- Reducing the time spent on writing commit messages +- Ensuring consistency in commit message style across the project + +## Requirements + +- Python 3.6 or higher +- Git installed and configured +- The script must be run in a git repository + +## Installation + +No external dependencies required. The script uses only Python standard library. + +## Usage + +1. Navigate to your git repository: + ```bash + cd /path/to/your/git/repository + ``` + +2. Make some changes to your files (add, modify, or delete files) + +3. Stage your changes (optional): + ```bash + git add . + ``` + +4. Run the script: + ```bash + python script.py + ``` + +5. Follow the interactive prompts: + - Review the suggested commit message + - Choose to use it, edit it, or create a custom message + - Confirm to create the commit + +## How It Works + +1. **Change Detection**: The script analyzes git status to identify staged, unstaged, and untracked files +2. **File Analysis**: Examines file changes using `git diff` to understand what was added, removed, or modified +3. **Pattern Matching**: Detects commit type based on: + - File patterns (e.g., `.md` files → `docs`, test files → `test`) + - Change patterns (e.g., bug fixes → `fix`, new features → `feat`) + - Code analysis (keywords in diffs) +4. **Message Generation**: Creates a commit message following Conventional Commits format: + ``` + (): + + + ``` + +## Commit Types + +The script supports the following Conventional Commits types: + +- `feat`: A new feature +- `fix`: A bug fix +- `docs`: Documentation only changes +- `style`: Changes that do not affect the meaning of the code +- `refactor`: A code change that neither fixes a bug nor adds a feature +- `perf`: A code change that improves performance +- `test`: Adding missing tests or correcting existing tests +- `build`: Changes that affect the build system or external dependencies +- `ci`: Changes to CI configuration files and scripts +- `chore`: Other changes that do not modify src or test files +- `revert`: Reverts a previous commit + +## Examples + +### Example 1: Adding a new feature +```bash +# After adding a new file: feature.py +$ python script.py + +Suggested commit message: +------------------------------------------------------------ +feat: add feature + +Modified files: feature.py +------------------------------------------------------------ +``` + +### Example 2: Fixing a bug +```bash +# After modifying buggy_code.py to fix an issue +$ python script.py + +Suggested commit message: +------------------------------------------------------------ +fix(buggy_code): fix issue in buggy_code + +Modified files: buggy_code.py +------------------------------------------------------------ +``` + +### Example 3: Documentation update +```bash +# After updating README.md +$ python script.py + +Suggested commit message: +------------------------------------------------------------ +docs: update README + +Modified files: README.md +------------------------------------------------------------ +``` + +## Interactive Options + +When running the script, you'll be presented with options: + +1. **Use suggested message**: Accept the generated message as-is +2. **Edit commit type**: Change the commit type (feat, fix, etc.) +3. **Edit description**: Modify the description part of the message +4. **Enter custom message**: Write your own commit message +5. **Cancel**: Exit without creating a commit + +## Notes + +- The script analyzes up to 10 files for performance reasons +- It works with both staged and unstaged changes +- If you choose not to auto-commit, you can manually use the suggested message +- The script must be run from within a git repository + +## Future Improvements + +- Support for breaking changes notation (`!`) +- Integration with git hooks for automatic message generation +- Support for multi-line commit bodies with detailed change descriptions +- Configuration file for custom commit type patterns +- Support for different commit message templates +- Integration with issue tracking systems (GitHub, GitLab, etc.) + +## Author + +Created as part of the Daily Python Scripts collection. diff --git a/scripts/git_commit_message_generator/requirements.txt b/scripts/git_commit_message_generator/requirements.txt new file mode 100644 index 0000000..80de1e4 --- /dev/null +++ b/scripts/git_commit_message_generator/requirements.txt @@ -0,0 +1,6 @@ +# No external dependencies required +# This script uses only Python standard library modules: +# - subprocess +# - re +# - sys +# - typing diff --git a/scripts/git_commit_message_generator/script.py b/scripts/git_commit_message_generator/script.py new file mode 100644 index 0000000..397e2d1 --- /dev/null +++ b/scripts/git_commit_message_generator/script.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +""" +Git Commit Message Generator + +Automatically generates commit messages based on git changes analysis. +Supports Conventional Commits specification for consistent commit messages. +""" + +import subprocess +import re +import sys +from typing import List, Dict, Tuple, Optional + + +class GitCommitMessageGenerator: + """Generates commit messages based on git changes analysis.""" + + # Conventional Commits types + COMMIT_TYPES = { + 'feat': 'A new feature', + 'fix': 'A bug fix', + 'docs': 'Documentation only changes', + 'style': 'Changes that do not affect the meaning of the code', + 'refactor': 'A code change that neither fixes a bug nor adds a feature', + 'perf': 'A code change that improves performance', + 'test': 'Adding missing tests or correcting existing tests', + 'build': 'Changes that affect the build system or external dependencies', + 'ci': 'Changes to CI configuration files and scripts', + 'chore': 'Other changes that do not modify src or test files', + 'revert': 'Reverts a previous commit' + } + + # File patterns for commit type detection + FILE_PATTERNS = { + 'docs': [r'\.md$', r'\.txt$', r'\.rst$', r'README', r'LICENSE', r'CHANGELOG'], + 'test': [r'test', r'spec', r'__test__', r'\.test\.'], + 'build': [r'requirements\.txt', r'setup\.py', r'Makefile', r'\.toml$', r'\.json$'], + 'ci': [r'\.github', r'\.gitlab', r'\.travis', r'\.circleci', r'\.jenkins'], + 'style': [r'\.css$', r'\.scss$', r'\.less$'] + } + + def __init__(self): + """Initialize the generator.""" + self.changed_files = [] + self.staged_files = [] + self.unstaged_files = [] + + def run_git_command(self, command: List[str]) -> str: + """ + Execute a git command and return the output. + + Args: + command: List of command arguments + + Returns: + Command output as string + + Raises: + subprocess.CalledProcessError: If git command fails + """ + try: + result = subprocess.run( + ['git'] + command, + capture_output=True, + text=True, + check=True + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Error running git command: {e}", file=sys.stderr) + sys.exit(1) + + def get_git_status(self) -> Dict[str, List[str]]: + """ + Get git status information. + + Returns: + Dictionary with 'staged', 'unstaged', 'untracked' file lists + """ + status_output = self.run_git_command(['status', '--porcelain']) + status = { + 'staged': [], + 'unstaged': [], + 'untracked': [] + } + + for line in status_output.split('\n'): + if not line: + continue + + status_code = line[:2] + filename = line[3:].strip() + + if status_code[0] == 'A' or status_code[0] == 'M' or status_code[0] == 'D': + status['staged'].append(filename) + elif status_code[1] == 'M' or status_code[1] == 'D': + status['unstaged'].append(filename) + elif status_code == '??': + status['untracked'].append(filename) + + return status + + def analyze_file_changes(self, filename: str) -> Dict[str, any]: + """ + Analyze changes in a specific file. + + Args: + filename: Path to the file + + Returns: + Dictionary with change analysis + """ + try: + diff_output = self.run_git_command(['diff', '--cached', filename]) + if not diff_output: + diff_output = self.run_git_command(['diff', filename]) + + added_lines = len(re.findall(r'^\+', diff_output, re.MULTILINE)) + removed_lines = len(re.findall(r'^\-', diff_output, re.MULTILINE)) + + # Check for specific patterns + has_feature = bool(re.search(r'\b(feat|add|new|implement|create)\b', diff_output, re.IGNORECASE)) + has_fix = bool(re.search(r'\b(fix|bug|error|issue|resolve)\b', diff_output, re.IGNORECASE)) + has_refactor = bool(re.search(r'\b(refactor|restructure|reorganize|clean)\b', diff_output, re.IGNORECASE)) + + return { + 'filename': filename, + 'added': added_lines, + 'removed': removed_lines, + 'has_feature': has_feature, + 'has_fix': has_fix, + 'has_refactor': has_refactor + } + except subprocess.CalledProcessError: + return { + 'filename': filename, + 'added': 0, + 'removed': 0, + 'has_feature': False, + 'has_fix': False, + 'has_refactor': False + } + + def detect_commit_type(self, files: List[str], changes: List[Dict]) -> str: + """ + Detect the appropriate commit type based on file changes. + + Args: + files: List of changed file paths + changes: List of change analysis dictionaries + + Returns: + Suggested commit type + """ + # Check file patterns + for commit_type, patterns in self.FILE_PATTERNS.items(): + for pattern in patterns: + if any(re.search(pattern, file, re.IGNORECASE) for file in files): + return commit_type + + # Analyze change patterns + total_added = sum(c['added'] for c in changes) + total_removed = sum(c['removed'] for c in changes) + + has_feature = any(c['has_feature'] for c in changes) + has_fix = any(c['has_fix'] for c in changes) + has_refactor = any(c['has_refactor'] for c in changes) + + if has_fix: + return 'fix' + elif has_feature: + return 'feat' + elif has_refactor: + return 'refactor' + elif total_added > total_removed * 2: + return 'feat' + elif total_removed > total_added * 2: + return 'refactor' + else: + return 'chore' + + def generate_scope(self, files: List[str]) -> Optional[str]: + """ + Generate commit scope from file paths. + + Args: + files: List of changed file paths + + Returns: + Scope string or None + """ + if not files: + return None + + # Extract common directory prefix + common_parts = [] + first_file_parts = files[0].split('/') + + for i, part in enumerate(first_file_parts[:-1]): + if all(len(f.split('/')) > i + 1 and f.split('/')[i] == part for f in files): + common_parts.append(part) + else: + break + + if common_parts: + return '/'.join(common_parts) + + # If no common prefix, use first directory or filename + first_file = files[0] + if '/' in first_file: + return first_file.split('/')[0] + else: + return None + + def generate_description(self, files: List[str], changes: List[Dict]) -> str: + """ + Generate commit message description. + + Args: + files: List of changed file paths + changes: List of change analysis dictionaries + + Returns: + Description string + """ + if not files: + return "update files" + + # Get main file name without extension + main_file = files[0].split('/')[-1] + main_file = re.sub(r'\.[^.]+$', '', main_file) + + # Count changes + total_added = sum(c['added'] for c in changes) + total_removed = sum(c['removed'] for c in changes) + + # Generate description based on patterns + if any(c['has_fix'] for c in changes): + return f"fix issue in {main_file}" + elif any(c['has_feature'] for c in changes): + return f"add {main_file} feature" + elif any(c['has_refactor'] for c in changes): + return f"refactor {main_file}" + elif total_added > 0 and total_removed == 0: + return f"add {main_file}" + elif total_removed > total_added: + return f"remove unused code in {main_file}" + else: + return f"update {main_file}" + + def generate_commit_message(self, commit_type: str, scope: Optional[str], + description: str, body: Optional[str] = None) -> str: + """ + Generate a commit message in Conventional Commits format. + + Args: + commit_type: Type of commit (feat, fix, etc.) + scope: Optional scope + description: Commit description + body: Optional commit body + + Returns: + Formatted commit message + """ + if scope: + header = f"{commit_type}({scope}): {description}" + else: + header = f"{commit_type}: {description}" + + if body: + return f"{header}\n\n{body}" + else: + return header + + def suggest_commit_message(self) -> Tuple[str, str]: + """ + Analyze git changes and suggest a commit message. + + Returns: + Tuple of (commit_type, full_message) + """ + status = self.get_git_status() + + # Get all changed files + all_files = status['staged'] + status['unstaged'] + status['untracked'] + + if not all_files: + print("No changes detected. Nothing to commit.") + sys.exit(0) + + # Analyze changes + changes = [] + for file in all_files[:10]: # Limit to first 10 files for performance + change = self.analyze_file_changes(file) + changes.append(change) + + # Detect commit type + commit_type = self.detect_commit_type(all_files, changes) + + # Generate scope + scope = self.generate_scope(all_files) + + # Generate description + description = self.generate_description(all_files, changes) + + # Generate full message + file_list = ', '.join(all_files[:5]) + if len(all_files) > 5: + file_list += f" and {len(all_files) - 5} more" + + body = f"Modified files: {file_list}" + + message = self.generate_commit_message(commit_type, scope, description, body) + + return commit_type, message + + def interactive_mode(self): + """Run in interactive mode to generate and customize commit message.""" + print("Analyzing git changes...\n") + + commit_type, suggested_message = self.suggest_commit_message() + + print("Suggested commit message:") + print("-" * 60) + print(suggested_message) + print("-" * 60) + print(f"\nCommit type: {commit_type} - {self.COMMIT_TYPES.get(commit_type, 'Unknown')}") + print("\nAvailable commit types:") + for ctype, desc in self.COMMIT_TYPES.items(): + print(f" {ctype:10} - {desc}") + + print("\nOptions:") + print("1. Use suggested message") + print("2. Edit commit type") + print("3. Edit description") + print("4. Enter custom message") + print("5. Cancel") + + choice = input("\nSelect option (1-5): ").strip() + + if choice == '1': + return suggested_message + elif choice == '2': + new_type = input(f"Enter commit type (current: {commit_type}): ").strip() + if new_type in self.COMMIT_TYPES: + scope = self.generate_scope(self.get_git_status()['staged'] + self.get_git_status()['unstaged']) + description = input("Enter description: ").strip() + return self.generate_commit_message(new_type, scope, description) + else: + print("Invalid commit type. Using original.") + return suggested_message + elif choice == '3': + description = input("Enter new description: ").strip() + scope = self.generate_scope(self.get_git_status()['staged'] + self.get_git_status()['unstaged']) + return self.generate_commit_message(commit_type, scope, description) + elif choice == '4': + return input("Enter custom commit message: ").strip() + else: + print("Cancelled.") + sys.exit(0) + + +def main(): + """Main entry point.""" + generator = GitCommitMessageGenerator() + + # Check if we're in a git repository + try: + generator.run_git_command(['rev-parse', '--git-dir']) + except subprocess.CalledProcessError: + print("Error: Not a git repository. Please run this script in a git repository.") + sys.exit(1) + + # Generate and display commit message + commit_message = generator.interactive_mode() + + if commit_message: + print("\n" + "=" * 60) + print("Final commit message:") + print("=" * 60) + print(commit_message) + print("=" * 60) + + use_message = input("\nUse this message for commit? (y/n): ").strip().lower() + if use_message == 'y': + try: + subprocess.run(['git', 'commit', '-m', commit_message], check=True) + print("\nCommit created successfully!") + except subprocess.CalledProcessError as e: + print(f"\nError creating commit: {e}") + print("\nYou can manually use:") + print(f'git commit -m "{commit_message}"') + else: + print("\nCommit cancelled. You can manually use:") + print(f'git commit -m "{commit_message}"') + + +if __name__ == "__main__": + main() From 845c7fab8388c9c6b8ae79f67efcd081184d78db Mon Sep 17 00:00:00 2001 From: BSkaiTech Date: Tue, 27 Jan 2026 23:02:31 +0300 Subject: [PATCH 2/2] feat: enhance git commit message generator with advanced features --- .../git_commit_message_generator/script.py | 1436 +++++++++++++---- 1 file changed, 1136 insertions(+), 300 deletions(-) diff --git a/scripts/git_commit_message_generator/script.py b/scripts/git_commit_message_generator/script.py index 397e2d1..1830cb6 100644 --- a/scripts/git_commit_message_generator/script.py +++ b/scripts/git_commit_message_generator/script.py @@ -1,398 +1,1234 @@ -#!/usr/bin/env python3 """ Git Commit Message Generator Automatically generates commit messages based on git changes analysis. Supports Conventional Commits specification for consistent commit messages. + +Features: +- Intelligent commit type detection based on file patterns and diff content +- Breaking change detection +- Scope inference from file paths +- Interactive and non-interactive modes +- Cross-platform support (Windows/Linux/macOS) +- Colorized output +- Configuration via command-line arguments """ import subprocess import re import sys -from typing import List, Dict, Tuple, Optional +import os +import argparse +from pathlib import Path +from typing import List, Dict, Tuple, Optional, Any, Set +from dataclasses import dataclass +from enum import Enum + + +class Color: + """ANSI color codes for terminal output.""" + RESET = '\033[0m' + BOLD = '\033[1m' + DIM = '\033[2m' + RED = '\033[91m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + MAGENTA = '\033[95m' + CYAN = '\033[96m' + WHITE = '\033[97m' + + @classmethod + def disable(cls): + """Disable colors for non-TTY or Windows without ANSI support.""" + cls.RESET = '' + cls.BOLD = '' + cls.DIM = '' + cls.RED = '' + cls.GREEN = '' + cls.YELLOW = '' + cls.BLUE = '' + cls.MAGENTA = '' + cls.CYAN = '' + cls.WHITE = '' + + +class CommitType(Enum): + """Conventional Commits types with descriptions and emoji.""" + FEAT = ('feat', 'A new feature', '✨') + FIX = ('fix', 'A bug fix', '🐛') + DOCS = ('docs', 'Documentation only changes', '📚') + STYLE = ('style', 'Changes that do not affect the meaning of the code', '💄') + REFACTOR = ('refactor', 'A code change that neither fixes a bug nor adds a feature', '♻️') + PERF = ('perf', 'A code change that improves performance', '⚡') + TEST = ('test', 'Adding missing tests or correcting existing tests', '🧪') + BUILD = ('build', 'Changes that affect the build system or external dependencies', '📦') + CI = ('ci', 'Changes to CI configuration files and scripts', '👷') + CHORE = ('chore', 'Other changes that do not modify src or test files', '🔧') + REVERT = ('revert', 'Reverts a previous commit', '⏪') + SECURITY = ('security', 'Security-related changes', '🔒') + DEPS = ('deps', 'Dependency updates', '📌') + CONFIG = ('config', 'Configuration changes', '⚙️') + INIT = ('init', 'Initial commit or project initialization', '🎉') + WIP = ('wip', 'Work in progress', '🚧') + HOTFIX = ('hotfix', 'Critical hotfix', '🚑') + RELEASE = ('release', 'Release/version tags', '🏷️') + MERGE = ('merge', 'Merge branches', '🔀') + I18N = ('i18n', 'Internationalization and localization', '🌐') + A11Y = ('a11y', 'Accessibility improvements', '♿') + UI = ('ui', 'UI/UX improvements', '🎨') + API = ('api', 'API changes', '🔌') + DB = ('db', 'Database related changes', '🗃️') + + @property + def name_str(self) -> str: + return self.value[0] + + @property + def description(self) -> str: + return self.value[1] + + @property + def emoji(self) -> str: + return self.value[2] + + +@dataclass +class FileChange: + """Represents changes in a single file.""" + filename: str + status: str = 'M' + added_lines: int = 0 + removed_lines: int = 0 + is_binary: bool = False + old_filename: Optional[str] = None + has_feature_keywords: bool = False + has_fix_keywords: bool = False + has_refactor_keywords: bool = False + has_perf_keywords: bool = False + has_security_keywords: bool = False + has_breaking_keywords: bool = False + has_deprecation_keywords: bool = False + has_todo_keywords: bool = False + + +@dataclass +class CommitSuggestion: + """Represents a suggested commit message.""" + commit_type: str + scope: Optional[str] + description: str + body: Optional[str] + footer: Optional[str] + is_breaking: bool = False + emoji: Optional[str] = None + + def format(self, use_emoji: bool = False) -> str: + """Format the commit message.""" + type_str = self.commit_type + if self.is_breaking: + type_str += '!' + + if use_emoji and self.emoji: + type_str = f"{self.emoji} {type_str}" + + if self.scope: + header = f"{type_str}({self.scope}): {self.description}" + else: + header = f"{type_str}: {self.description}" + + parts = [header] + if self.body: + parts.append('') + parts.append(self.body) + if self.footer: + parts.append('') + parts.append(self.footer) + if self.is_breaking and 'BREAKING CHANGE' not in (self.footer or ''): + parts.append('') + parts.append('BREAKING CHANGE: This commit introduces breaking changes') + + return '\n'.join(parts) class GitCommitMessageGenerator: """Generates commit messages based on git changes analysis.""" - # Conventional Commits types - COMMIT_TYPES = { - 'feat': 'A new feature', - 'fix': 'A bug fix', - 'docs': 'Documentation only changes', - 'style': 'Changes that do not affect the meaning of the code', - 'refactor': 'A code change that neither fixes a bug nor adds a feature', - 'perf': 'A code change that improves performance', - 'test': 'Adding missing tests or correcting existing tests', - 'build': 'Changes that affect the build system or external dependencies', - 'ci': 'Changes to CI configuration files and scripts', - 'chore': 'Other changes that do not modify src or test files', - 'revert': 'Reverts a previous commit' + FILE_PATTERNS: Dict[str, List[str]] = { + 'docs': [ + r'\.md$', r'\.mdx$', r'\.txt$', r'\.rst$', r'\.adoc$', + r'README', r'LICENSE', r'CHANGELOG', r'CONTRIBUTING', + r'AUTHORS', r'HISTORY', r'NOTICE', r'COPYING', + r'docs[/\\]', r'documentation[/\\]', r'wiki[/\\]', + r'\.docx?$', r'\.pdf$', r'man[/\\]', + ], + 'test': [ + r'test[s]?[/\\]', r'spec[s]?[/\\]', r'__test__', r'__tests__', + r'\.test\.', r'\.spec\.', r'_test\.', r'_spec\.', + r'test_.*\.py$', r'.*_test\.py$', r'.*_test\.go$', + r'\.feature$', r'fixtures[/\\]', r'mocks?[/\\]', + r'conftest\.py$', r'pytest\.ini$', r'jest\.config\.', + r'karma\.conf\.', r'cypress[/\\]', r'e2e[/\\]', + r'\.snap$', r'__snapshots__', + ], + 'build': [ + r'requirements.*\.txt$', r'setup\.py$', r'setup\.cfg$', + r'pyproject\.toml$', r'Pipfile', r'poetry\.lock$', + r'Makefile$', r'CMakeLists\.txt$', r'\.cmake$', + r'package\.json$', r'package-lock\.json$', r'yarn\.lock$', + r'pnpm-lock\.yaml$', r'\.npmrc$', r'\.yarnrc', + r'Cargo\.toml$', r'Cargo\.lock$', + r'go\.mod$', r'go\.sum$', + r'Gemfile', r'\.gemspec$', r'Rakefile$', + r'build\.gradle', r'pom\.xml$', r'\.mvn[/\\]', + r'composer\.json$', r'composer\.lock$', + r'mix\.exs$', r'rebar\.config$', + r'\.cabal$', r'stack\.yaml$', + r'vcpkg\.json$', r'conanfile\.', + r'webpack\.', r'rollup\.', r'vite\.config\.', + r'esbuild\.', r'parcel\.', r'snowpack\.', + r'gulpfile\.', r'Gruntfile\.', r'grunt\.', + r'Dockerfile', r'docker-compose', r'\.docker[/\\]', + r'Vagrantfile$', r'\.vagrant[/\\]', + ], + 'ci': [ + r'\.github[/\\]workflows', r'\.github[/\\]actions', + r'\.gitlab-ci', r'\.gitlab[/\\]', + r'\.travis\.yml$', r'\.travis[/\\]', + r'\.circleci[/\\]', r'circle\.yml$', + r'Jenkinsfile', r'\.jenkins[/\\]', + r'azure-pipelines', r'\.azure[/\\]', + r'bitbucket-pipelines', r'bamboo-specs', + r'\.drone\.yml$', r'\.drone[/\\]', + r'appveyor\.yml$', r'\.appveyor[/\\]', + r'cloudbuild\.yaml$', r'\.gcloudignore$', + r'buildspec\.yml$', r'\.aws[/\\]', + r'codecov\.yml$', r'\.codecov\.yml$', + r'\.coveragerc$', r'coverage\.', r'\.nyc', + r'sonar-project\.properties$', r'\.sonarcloud', + r'netlify\.toml$', r'vercel\.json$', r'now\.json$', + r'heroku\.yml$', r'Procfile$', r'app\.yaml$', + r'\.buildkite[/\\]', r'wercker\.yml$', + r'tox\.ini$', r'noxfile\.py$', + ], + 'style': [ + r'\.css$', r'\.scss$', r'\.sass$', r'\.less$', r'\.styl$', + r'\.styled\.[jt]sx?$', r'styles?[/\\]', + r'tailwind\.config\.', r'postcss\.config\.', + r'\.prettierrc', r'\.prettier\.', + r'\.eslintrc', r'\.eslint\.', r'eslint\.config\.', + r'\.stylelintrc', r'stylelint\.config\.', + r'\.editorconfig$', r'\.clang-format$', + r'rustfmt\.toml$', r'\.rubocop\.yml$', + r'phpcs\.xml', r'\.php-cs-fixer', + r'checkstyle\.xml$', r'spotless\.', + ], + 'config': [ + r'\.env', r'\.env\..*$', r'\.flaskenv$', + r'config[/\\]', r'settings[/\\]', r'\.config[/\\]', + r'\.ini$', r'\.cfg$', r'\.conf$', r'\.config$', + r'\.yaml$', r'\.yml$', r'\.toml$', + r'\.json$', r'\.json5$', r'\.jsonc$', + r'\.xml$', r'\.properties$', + r'tsconfig\.', r'jsconfig\.', r'babel\.config\.', + r'\.babelrc', r'\.swcrc$', + r'next\.config\.', r'nuxt\.config\.', + r'angular\.json$', r'vue\.config\.', + r'\.nvmrc$', r'\.node-version$', r'\.python-version$', + r'\.ruby-version$', r'\.java-version$', r'\.tool-versions$', + ], + 'security': [ + r'security[/\\]', r'auth[/\\]', r'authentication[/\\]', + r'\.snyk$', r'snyk\.', r'security\.txt$', + r'SECURITY\.md$', r'\.bandit$', r'\.safety$', + r'\.dependabot[/\\]', r'dependabot\.yml$', + r'renovate\.json', r'\.renovaterc', + ], + 'deps': [ + r'requirements.*\.txt$', r'constraints\.txt$', + r'package\.json$', r'package-lock\.json$', + r'yarn\.lock$', r'pnpm-lock\.yaml$', + r'Pipfile\.lock$', r'poetry\.lock$', + r'Cargo\.lock$', r'go\.sum$', + r'Gemfile\.lock$', r'composer\.lock$', + r'mix\.lock$', r'packages\.lock\.json$', + r'\.terraform\.lock\.hcl$', + ], + 'i18n': [ + r'i18n[/\\]', r'l10n[/\\]', r'locales?[/\\]', + r'translations?[/\\]', r'lang[/\\]', r'languages?[/\\]', + r'\.po$', r'\.pot$', r'\.mo$', + r'\.xlf$', r'\.xliff$', r'\.resx$', + r'messages\.[a-z]{2}\.', r'\.[a-z]{2}\.json$', + ], + 'db': [ + r'migrations?[/\\]', r'migrate[/\\]', + r'schema[s]?[/\\]', r'seeds?[/\\]', r'fixtures[/\\]', + r'\.sql$', r'\.prisma$', r'schema\.prisma$', + r'\.graphql$', r'\.gql$', + r'alembic[/\\]', r'flyway[/\\]', r'liquibase[/\\]', + r'knexfile\.', r'sequelize\.', r'typeorm\.', + ], + 'api': [ + r'api[/\\]', r'routes?[/\\]', r'endpoints?[/\\]', + r'controllers?[/\\]', r'handlers?[/\\]', + r'openapi\.', r'swagger\.', r'\.openapi\.', + r'\.proto$', r'\.grpc\.', r'\.graphql$', + r'resolvers?[/\\]', r'mutations?[/\\]', r'queries[/\\]', + ], + 'ui': [ + r'components?[/\\]', r'views?[/\\]', r'pages?[/\\]', + r'layouts?[/\\]', r'templates?[/\\]', + r'\.vue$', r'\.svelte$', r'\.tsx$', r'\.jsx$', + r'\.html$', r'\.htm$', r'\.pug$', r'\.ejs$', + r'\.hbs$', r'\.handlebars$', r'\.mustache$', + r'assets?[/\\]', r'images?[/\\]', r'icons?[/\\]', + r'\.svg$', r'\.png$', r'\.jpg$', r'\.gif$', r'\.webp$', + r'fonts?[/\\]', r'\.woff2?$', r'\.ttf$', r'\.eot$', + ], + 'perf': [ + r'perf[/\\]', r'performance[/\\]', r'benchmark[s]?[/\\]', + r'\.bench\.', r'_bench\.', r'profil', + ], } - # File patterns for commit type detection - FILE_PATTERNS = { - 'docs': [r'\.md$', r'\.txt$', r'\.rst$', r'README', r'LICENSE', r'CHANGELOG'], - 'test': [r'test', r'spec', r'__test__', r'\.test\.'], - 'build': [r'requirements\.txt', r'setup\.py', r'Makefile', r'\.toml$', r'\.json$'], - 'ci': [r'\.github', r'\.gitlab', r'\.travis', r'\.circleci', r'\.jenkins'], - 'style': [r'\.css$', r'\.scss$', r'\.less$'] + DIFF_KEYWORDS: Dict[str, List[str]] = { + 'feature': [ + r'\b(add|added|adding|adds)\b', + r'\b(implement|implemented|implementing|implements)\b', + r'\b(create|created|creating|creates)\b', + r'\b(introduce|introduced|introducing|introduces)\b', + r'\b(new|feature|enhancement)\b', + r'\b(support|supports|supporting)\b', + r'\b(enable|enabled|enabling|enables)\b', + r'\b(allow|allowed|allowing|allows)\b', + r'\b(provide|provided|providing|provides)\b', + r'\b(include|included|including|includes)\b', + ], + 'fix': [ + r'\b(fix|fixed|fixing|fixes)\b', + r'\b(bug|bugs|bugfix)\b', + r'\b(error|errors)\b', + r'\b(issue|issues)\b', + r'\b(resolve|resolved|resolving|resolves)\b', + r'\b(correct|corrected|correcting|corrects)\b', + r'\b(repair|repaired|repairing|repairs)\b', + r'\b(patch|patched|patching|patches)\b', + r'\b(workaround)\b', + r'\b(handle|handled|handling)\s+(error|exception|edge)', + r'\b(prevent|prevented|preventing|prevents)\b', + r'\b(address|addressed|addressing|addresses)\b', + ], + 'refactor': [ + r'\b(refactor|refactored|refactoring|refactors)\b', + r'\b(restructure|restructured|restructuring)\b', + r'\b(reorganize|reorganized|reorganizing)\b', + r'\b(clean|cleaned|cleaning|cleanup|cleanups)\b', + r'\b(simplify|simplified|simplifying|simplifies)\b', + r'\b(extract|extracted|extracting|extracts)\b', + r'\b(move|moved|moving|moves)\b', + r'\b(rename|renamed|renaming|renames)\b', + r'\b(split|splitting|splits)\b', + r'\b(merge|merged|merging|merges)\b', + r'\b(consolidate|consolidated|consolidating)\b', + r'\b(modularize|modularized|modularizing)\b', + r'\b(decouple|decoupled|decoupling)\b', + ], + 'perf': [ + r'\b(perf|performance)\b', + r'\b(optimize|optimized|optimizing|optimizes|optimization)\b', + r'\b(improve|improved|improving|improves)\s+(speed|performance|efficiency)', + r'\b(faster|quicker|speed\s*up)\b', + r'\b(reduce|reduced|reducing)\s+(memory|cpu|time|latency)', + r'\b(cache|cached|caching|memoize|memoized)\b', + r'\b(lazy|lazily|defer|deferred)\b', + r'\b(parallelize|parallelized|async|concurrent)\b', + r'\b(batch|batched|batching|bulk)\b', + r'\b(index|indexed|indexing)\b', + ], + 'security': [ + r'\b(security|secure|securing)\b', + r'\b(vulnerability|vulnerabilities|vuln|CVE-)\b', + r'\b(sanitize|sanitized|sanitizing|sanitization)\b', + r'\b(escape|escaped|escaping)\b', + r'\b(encrypt|encrypted|encrypting|encryption)\b', + r'\b(auth|authenticate|authenticated|authentication)\b', + r'\b(authorize|authorized|authorization)\b', + r'\b(permission|permissions)\b', + r'\b(csrf|xss|sqli|injection)\b', + r'\b(token|tokens|jwt|oauth)\b', + r'\b(secret|secrets|credential|credentials)\b', + r'\b(password|passwords|passphrase)\b', + r'\b(hash|hashed|hashing|bcrypt|argon)\b', + ], + 'breaking': [ + r'\b(breaking|break|breaks)\b', + r'\b(remove|removed|removing|removes)\s+(api|method|function|class|feature)', + r'\b(delete|deleted|deleting|deletes)\s+(api|method|function|class)', + r'\b(deprecate|deprecated|deprecating|deprecation)\b', + r'\b(incompatible|incompatibility)\b', + r'\b(major\s+version|major\s+change|major\s+update)\b', + r'\b(migration\s+required|requires\s+migration)\b', + r'BREAKING[\s_-]?CHANGE', + ], + 'deprecation': [ + r'\b(deprecate|deprecated|deprecating|deprecation)\b', + r'\b(obsolete|obsoleted)\b', + r'\b(legacy)\b', + r'@deprecated', + r'DeprecationWarning', + r'DEPRECATED', + ], + 'todo': [ + r'\bTODO\b', + r'\bFIXME\b', + r'\bHACK\b', + r'\bXXX\b', + r'\bWIP\b', + r'\bTEMP\b', + ], } - def __init__(self): - """Initialize the generator.""" - self.changed_files = [] - self.staged_files = [] - self.unstaged_files = [] - - def run_git_command(self, command: List[str]) -> str: - """ - Execute a git command and return the output. + SCOPE_MAPPINGS: Dict[str, Optional[str]] = { + 'src': None, + 'lib': None, + 'app': None, + 'pkg': None, + 'internal': None, + 'cmd': None, + 'components': 'components', + 'pages': 'pages', + 'views': 'views', + 'controllers': 'controllers', + 'models': 'models', + 'services': 'services', + 'utils': 'utils', + 'helpers': 'helpers', + 'hooks': 'hooks', + 'store': 'store', + 'redux': 'redux', + 'api': 'api', + 'routes': 'routes', + 'middleware': 'middleware', + 'plugins': 'plugins', + 'modules': 'modules', + 'core': 'core', + 'common': 'common', + 'shared': 'shared', + 'features': 'features', + 'domain': 'domain', + 'infrastructure': 'infra', + 'presentation': 'presentation', + 'tests': 'tests', + 'test': 'test', + 'specs': 'specs', + '__tests__': 'tests', + 'e2e': 'e2e', + 'integration': 'integration', + 'unit': 'unit', + 'docs': 'docs', + 'documentation': 'docs', + 'scripts': 'scripts', + 'tools': 'tools', + 'build': 'build', + 'config': 'config', + 'configs': 'config', + 'assets': 'assets', + 'static': 'static', + 'public': 'public', + 'styles': 'styles', + 'css': 'styles', + 'i18n': 'i18n', + 'locales': 'i18n', + 'migrations': 'db', + 'database': 'db', + 'db': 'db', + } - Args: - command: List of command arguments + def __init__(self, use_emoji: bool = False, no_color: bool = False): + """Initialize the generator.""" + self.use_emoji = use_emoji + self.file_changes: List[FileChange] = [] - Returns: - Command output as string + if no_color or not sys.stdout.isatty(): + Color.disable() - Raises: - subprocess.CalledProcessError: If git command fails - """ + if sys.platform == 'win32': + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + except Exception: + Color.disable() + + def run_git_command(self, command: List[str], check: bool = True) -> Tuple[str, int]: + """Execute a git command and return the output.""" try: result = subprocess.run( - ['git'] + command, + ['git', '--no-pager'] + command, capture_output=True, text=True, - check=True + check=False, + encoding='utf-8', + errors='replace' ) - return result.stdout.strip() - except subprocess.CalledProcessError as e: - print(f"Error running git command: {e}", file=sys.stderr) - sys.exit(1) - def get_git_status(self) -> Dict[str, List[str]]: - """ - Get git status information. + if check and result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, + ['git'] + command, + result.stdout, + result.stderr + ) + + return result.stdout.strip(), result.returncode + except FileNotFoundError: + self._print_error("Git is not installed or not in PATH") + sys.exit(1) - Returns: - Dictionary with 'staged', 'unstaged', 'untracked' file lists - """ - status_output = self.run_git_command(['status', '--porcelain']) - status = { + def _print_error(self, message: str): + """Print an error message.""" + print(f"{Color.RED}Error: {message}{Color.RESET}", file=sys.stderr) + + def _print_warning(self, message: str): + """Print a warning message.""" + print(f"{Color.YELLOW}Warning: {message}{Color.RESET}", file=sys.stderr) + + def _print_success(self, message: str): + """Print a success message.""" + print(f"{Color.GREEN}{message}{Color.RESET}") + + def _print_info(self, message: str): + """Print an info message.""" + print(f"{Color.CYAN}{message}{Color.RESET}") + + def is_git_repository(self) -> bool: + """Check if current directory is a git repository.""" + _, code = self.run_git_command(['rev-parse', '--git-dir'], check=False) + return code == 0 + + def get_git_root(self) -> Optional[str]: + """Get the root directory of the git repository.""" + output, code = self.run_git_command(['rev-parse', '--show-toplevel'], check=False) + return output if code == 0 else None + + def get_current_branch(self) -> str: + """Get the current branch name.""" + output, _ = self.run_git_command(['branch', '--show-current'], check=False) + if not output: + output, _ = self.run_git_command(['rev-parse', '--short', 'HEAD'], check=False) + return f"HEAD@{output}" if output else "HEAD" + return output + + def get_git_status(self) -> Dict[str, List[Any]]: + """Get git status information.""" + status_output, _ = self.run_git_command(['status', '--porcelain', '-uall']) + status: Dict[str, List[Any]] = { 'staged': [], 'unstaged': [], - 'untracked': [] + 'untracked': [], + 'renamed': [], + 'deleted': [], + 'conflicted': [] } for line in status_output.split('\n'): if not line: continue - status_code = line[:2] - filename = line[3:].strip() + index_status = line[0] + worktree_status = line[1] + filename = line[3:] + + if ' -> ' in filename: + old_name, new_name = filename.split(' -> ') + filename = new_name.strip() + status['renamed'].append((old_name.strip(), filename)) - if status_code[0] == 'A' or status_code[0] == 'M' or status_code[0] == 'D': + if index_status in 'AMDRC': status['staged'].append(filename) - elif status_code[1] == 'M' or status_code[1] == 'D': + if index_status == 'D': + status['deleted'].append(filename) + + if worktree_status in 'MD': status['unstaged'].append(filename) - elif status_code == '??': + if worktree_status == 'D': + status['deleted'].append(filename) + + if index_status == '?' and worktree_status == '?': status['untracked'].append(filename) + if index_status == 'U' or worktree_status == 'U' or (index_status == worktree_status == 'A'): + status['conflicted'].append(filename) + return status - def analyze_file_changes(self, filename: str) -> Dict[str, any]: - """ - Analyze changes in a specific file. + def get_staged_diff(self) -> str: + """Get the full diff of staged changes.""" + output, _ = self.run_git_command(['diff', '--cached', '--unified=3'], check=False) + return output - Args: - filename: Path to the file + def get_unstaged_diff(self) -> str: + """Get the full diff of unstaged changes.""" + output, _ = self.run_git_command(['diff', '--unified=3'], check=False) + return output - Returns: - Dictionary with change analysis - """ - try: - diff_output = self.run_git_command(['diff', '--cached', filename]) - if not diff_output: - diff_output = self.run_git_command(['diff', filename]) - - added_lines = len(re.findall(r'^\+', diff_output, re.MULTILINE)) - removed_lines = len(re.findall(r'^\-', diff_output, re.MULTILINE)) - - # Check for specific patterns - has_feature = bool(re.search(r'\b(feat|add|new|implement|create)\b', diff_output, re.IGNORECASE)) - has_fix = bool(re.search(r'\b(fix|bug|error|issue|resolve)\b', diff_output, re.IGNORECASE)) - has_refactor = bool(re.search(r'\b(refactor|restructure|reorganize|clean)\b', diff_output, re.IGNORECASE)) - - return { - 'filename': filename, - 'added': added_lines, - 'removed': removed_lines, - 'has_feature': has_feature, - 'has_fix': has_fix, - 'has_refactor': has_refactor - } - except subprocess.CalledProcessError: - return { - 'filename': filename, - 'added': 0, - 'removed': 0, - 'has_feature': False, - 'has_fix': False, - 'has_refactor': False - } - - def detect_commit_type(self, files: List[str], changes: List[Dict]) -> str: - """ - Detect the appropriate commit type based on file changes. + def analyze_file_changes(self, filename: str, staged: bool = True) -> FileChange: + """Analyze changes in a specific file.""" + change = FileChange(filename=filename, status='M') - Args: - files: List of changed file paths - changes: List of change analysis dictionaries + diff_cmd = ['diff', '--cached', filename] if staged else ['diff', filename] + diff_output, code = self.run_git_command(diff_cmd, check=False) + + if code != 0: + diff_output, _ = self.run_git_command(['diff', filename], check=False) + + if not diff_output: + status_output, _ = self.run_git_command(['status', '--porcelain', filename], check=False) + if status_output.startswith('A'): + change.status = 'A' + elif status_output.startswith('D') or (len(status_output) > 1 and status_output[1] == 'D'): + change.status = 'D' + return change + + if 'Binary files' in diff_output or '\x00' in diff_output: + change.is_binary = True + return change + + change.added_lines = len(re.findall(r'^\+(?!\+\+)', diff_output, re.MULTILINE)) + change.removed_lines = len(re.findall(r'^-(?!--)', diff_output, re.MULTILINE)) + + for keyword_type, patterns in self.DIFF_KEYWORDS.items(): + for pattern in patterns: + if re.search(pattern, diff_output, re.IGNORECASE): + if keyword_type == 'feature': + change.has_feature_keywords = True + elif keyword_type == 'fix': + change.has_fix_keywords = True + elif keyword_type == 'refactor': + change.has_refactor_keywords = True + elif keyword_type == 'perf': + change.has_perf_keywords = True + elif keyword_type == 'security': + change.has_security_keywords = True + elif keyword_type == 'breaking': + change.has_breaking_keywords = True + elif keyword_type == 'deprecation': + change.has_deprecation_keywords = True + elif keyword_type == 'todo': + change.has_todo_keywords = True + break + + return change + + def detect_commit_type_from_files(self, files: List[str]) -> Optional[str]: + """Detect commit type based on file patterns.""" + normalized_files = [f.replace('\\', '/') for f in files] + scores: Dict[str, int] = {} - Returns: - Suggested commit type - """ - # Check file patterns for commit_type, patterns in self.FILE_PATTERNS.items(): for pattern in patterns: - if any(re.search(pattern, file, re.IGNORECASE) for file in files): - return commit_type - - # Analyze change patterns - total_added = sum(c['added'] for c in changes) - total_removed = sum(c['removed'] for c in changes) - - has_feature = any(c['has_feature'] for c in changes) - has_fix = any(c['has_fix'] for c in changes) - has_refactor = any(c['has_refactor'] for c in changes) - - if has_fix: - return 'fix' - elif has_feature: - return 'feat' - elif has_refactor: - return 'refactor' - elif total_added > total_removed * 2: - return 'feat' - elif total_removed > total_added * 2: - return 'refactor' - else: - return 'chore' + for file in normalized_files: + if re.search(pattern, file, re.IGNORECASE): + scores[commit_type] = scores.get(commit_type, 0) + 1 - def generate_scope(self, files: List[str]) -> Optional[str]: - """ - Generate commit scope from file paths. + if scores: + return max(scores, key=lambda k: scores[k]) - Args: - files: List of changed file paths + return None - Returns: - Scope string or None - """ - if not files: - return None + def detect_commit_type(self, files: List[str], changes: List[FileChange]) -> Tuple[str, bool]: + """Detect the appropriate commit type based on file changes.""" + is_breaking = any(c.has_breaking_keywords for c in changes) - # Extract common directory prefix - common_parts = [] - first_file_parts = files[0].split('/') + if any(c.has_security_keywords for c in changes): + return 'security', is_breaking - for i, part in enumerate(first_file_parts[:-1]): - if all(len(f.split('/')) > i + 1 and f.split('/')[i] == part for f in files): - common_parts.append(part) - else: - break + file_type = self.detect_commit_type_from_files(files) - if common_parts: - return '/'.join(common_parts) + total_added = sum(c.added_lines for c in changes) + total_removed = sum(c.removed_lines for c in changes) - # If no common prefix, use first directory or filename - first_file = files[0] - if '/' in first_file: - return first_file.split('/')[0] - else: - return None + has_feature = any(c.has_feature_keywords for c in changes) + has_fix = any(c.has_fix_keywords for c in changes) + has_refactor = any(c.has_refactor_keywords for c in changes) + has_perf = any(c.has_perf_keywords for c in changes) - def generate_description(self, files: List[str], changes: List[Dict]) -> str: - """ - Generate commit message description. + if file_type in ['test', 'docs', 'ci', 'i18n', 'db']: + return file_type, is_breaking - Args: - files: List of changed file paths - changes: List of change analysis dictionaries + if file_type == 'security': + return 'security', is_breaking - Returns: - Description string - """ - if not files: - return "update files" + if has_perf: + return 'perf', is_breaking - # Get main file name without extension - main_file = files[0].split('/')[-1] - main_file = re.sub(r'\.[^.]+$', '', main_file) - - # Count changes - total_added = sum(c['added'] for c in changes) - total_removed = sum(c['removed'] for c in changes) - - # Generate description based on patterns - if any(c['has_fix'] for c in changes): - return f"fix issue in {main_file}" - elif any(c['has_feature'] for c in changes): - return f"add {main_file} feature" - elif any(c['has_refactor'] for c in changes): - return f"refactor {main_file}" - elif total_added > 0 and total_removed == 0: - return f"add {main_file}" - elif total_removed > total_added: - return f"remove unused code in {main_file}" - else: - return f"update {main_file}" + if has_fix and not has_feature: + return 'fix', is_breaking - def generate_commit_message(self, commit_type: str, scope: Optional[str], - description: str, body: Optional[str] = None) -> str: - """ - Generate a commit message in Conventional Commits format. + if has_feature: + return 'feat', is_breaking - Args: - commit_type: Type of commit (feat, fix, etc.) - scope: Optional scope - description: Commit description - body: Optional commit body + if has_refactor: + return 'refactor', is_breaking - Returns: - Formatted commit message - """ - if scope: - header = f"{commit_type}({scope}): {description}" - else: - header = f"{commit_type}: {description}" + if file_type in ['style', 'config']: + return file_type, is_breaking + + if file_type == 'deps': + return 'deps', is_breaking + + if file_type == 'build': + return 'build', is_breaking + + if file_type == 'ui': + return 'ui', is_breaking + + if file_type == 'api': + return 'api', is_breaking + + if total_added > 0 and total_removed == 0: + return 'feat', is_breaking + elif total_removed > total_added * 3: + return 'refactor', is_breaking + elif total_added > total_removed * 3: + return 'feat', is_breaking + + return 'chore', is_breaking + + def generate_scope(self, files: List[str]) -> Optional[str]: + """Generate commit scope from file paths.""" + if not files: + return None - if body: - return f"{header}\n\n{body}" + normalized_files = [Path(f).as_posix() for f in files] + all_parts = [f.split('/') for f in normalized_files] + + if len(all_parts) == 1: + parts = all_parts[0] else: - return header + common_parts = [] + for i in range(min(len(p) for p in all_parts)): + if len(set(p[i] for p in all_parts)) == 1: + common_parts.append(all_parts[0][i]) + else: + break + parts = common_parts if common_parts else all_parts[0] + + for part in parts[:-1]: + part_lower = part.lower() + if part_lower in self.SCOPE_MAPPINGS: + mapped = self.SCOPE_MAPPINGS[part_lower] + if mapped: + return mapped + + for part in parts[:-1]: + part_lower = part.lower() + if part_lower not in ['src', 'lib', 'app', 'pkg', 'internal', 'cmd', '.']: + scope = re.sub(r'[^a-zA-Z0-9-]', '', part) + if scope and len(scope) <= 20: + return scope.lower() + + if len(files) == 1: + filename = Path(files[0]).stem + scope = re.sub(r'[^a-zA-Z0-9-]', '', filename) + if scope and len(scope) <= 15: + return scope.lower() + + return None + + def generate_description(self, files: List[str], changes: List[FileChange], + commit_type: str) -> str: + """Generate commit message description.""" + if not files: + return "update files" - def suggest_commit_message(self) -> Tuple[str, str]: - """ - Analyze git changes and suggest a commit message. + main_file = Path(files[0]).stem + main_file = re.sub(r'^[._]', '', main_file) + main_file = re.sub(r'[-_.]', ' ', main_file) + + total_added = sum(c.added_lines for c in changes) + total_removed = sum(c.removed_lines for c in changes) + num_files = len(files) + + new_files = [c for c in changes if c.status == 'A'] + deleted_files = [c for c in changes if c.status == 'D'] + renamed_files = [c for c in changes if c.old_filename] + + if commit_type == 'docs': + if num_files == 1: + return f"update {main_file} documentation" + return f"update documentation ({num_files} files)" + + elif commit_type == 'test': + if num_files == 1: + return f"add tests for {main_file}" + return f"update tests ({num_files} files)" + + elif commit_type == 'ci': + if num_files == 1: + return f"update {main_file} configuration" + return "update CI/CD configuration" + + elif commit_type == 'build': + if 'requirements' in files[0].lower(): + return "update Python dependencies" + elif 'package.json' in files[0].lower(): + return "update npm dependencies" + elif 'cargo' in files[0].lower(): + return "update Rust dependencies" + elif 'go.mod' in files[0].lower(): + return "update Go dependencies" + return "update build configuration" + + elif commit_type == 'deps': + return "update dependencies" + + elif commit_type == 'security': + if any(c.has_security_keywords for c in changes): + return "fix security vulnerability" + return "improve security" + + elif commit_type == 'perf': + return f"improve performance in {main_file}" + + elif commit_type == 'i18n': + return "update translations" + + elif commit_type == 'db': + if 'migration' in files[0].lower(): + return "add database migration" + return "update database schema" + + elif commit_type == 'config': + return f"update {main_file} configuration" + + elif commit_type == 'style': + if any('css' in f.lower() or 'scss' in f.lower() or 'style' in f.lower() for f in files): + return "update styles" + return "format code" + + elif commit_type == 'fix': + if num_files == 1: + return f"fix issue in {main_file}" + return f"fix issues ({num_files} files)" + + elif commit_type == 'feat': + if len(new_files) == num_files: + if num_files == 1: + return f"add {main_file}" + return f"add new files ({num_files} files)" + if num_files == 1: + return f"add {main_file} feature" + return f"add new features ({num_files} files)" + + elif commit_type == 'refactor': + if renamed_files: + return f"rename {main_file}" + if num_files == 1: + return f"refactor {main_file}" + return f"refactor code ({num_files} files)" + + elif commit_type == 'ui': + return f"update {main_file} UI" + + elif commit_type == 'api': + return f"update {main_file} API" + + if len(deleted_files) == num_files: + return f"remove {main_file}" if num_files == 1 else f"remove files ({num_files} files)" + + if len(new_files) == num_files: + return f"add {main_file}" if num_files == 1 else f"add files ({num_files} files)" + + if renamed_files: + return f"rename {main_file}" + + if total_removed > total_added * 2: + return f"remove unused code in {main_file}" - Returns: - Tuple of (commit_type, full_message) - """ + if total_added > total_removed * 2: + return f"expand {main_file}" + + return f"update {main_file}" + + def generate_body(self, files: List[str], changes: List[FileChange]) -> Optional[str]: + """Generate commit message body.""" + lines = [] + + if len(files) > 1: + lines.append("Changes:") + for f in files[:10]: + change = next((c for c in changes if c.filename == f), None) + if change: + if change.status == 'A': + status = '+' + elif change.status == 'D': + status = '-' + elif change.old_filename: + status = 'R' + else: + status = 'M' + lines.append(f" {status} {f}") + else: + lines.append(f" M {f}") + + if len(files) > 10: + lines.append(f" ... and {len(files) - 10} more files") + + total_added = sum(c.added_lines for c in changes) + total_removed = sum(c.removed_lines for c in changes) + + if total_added > 0 or total_removed > 0: + if lines: + lines.append("") + lines.append(f"Stats: +{total_added}/-{total_removed} lines") + + return '\n'.join(lines) if lines else None + + def generate_footer(self, changes: List[FileChange]) -> Optional[str]: + """Generate commit message footer.""" + footers = [] + + if any(c.has_breaking_keywords for c in changes): + footers.append("BREAKING CHANGE: This commit contains breaking changes") + + if any(c.has_deprecation_keywords for c in changes): + footers.append("Deprecated: This commit deprecates some functionality") + + return '\n'.join(footers) if footers else None + + def get_commit_emoji(self, commit_type: str) -> Optional[str]: + """Get emoji for commit type.""" + for ct in CommitType: + if ct.name_str == commit_type: + return ct.emoji + return None + + def suggest_commit_message(self, include_untracked: bool = False, + staged_only: bool = False) -> CommitSuggestion: + """Analyze git changes and suggest a commit message.""" status = self.get_git_status() - # Get all changed files - all_files = status['staged'] + status['unstaged'] + status['untracked'] + if staged_only: + all_files = status['staged'] + else: + all_files = status['staged'] + status['unstaged'] + if include_untracked: + all_files += status['untracked'] + + seen: Set[str] = set() + unique_files = [] + for f in all_files: + if f not in seen: + seen.add(f) + unique_files.append(f) + all_files = unique_files if not all_files: - print("No changes detected. Nothing to commit.") - sys.exit(0) + raise ValueError("No changes detected. Nothing to commit.") - # Analyze changes - changes = [] - for file in all_files[:10]: # Limit to first 10 files for performance - change = self.analyze_file_changes(file) + changes: List[FileChange] = [] + for file in all_files[:20]: + staged = file in status['staged'] + change = self.analyze_file_changes(file, staged=staged) changes.append(change) - # Detect commit type - commit_type = self.detect_commit_type(all_files, changes) + self.file_changes = changes - # Generate scope + commit_type, is_breaking = self.detect_commit_type(all_files, changes) scope = self.generate_scope(all_files) + description = self.generate_description(all_files, changes, commit_type) + body = self.generate_body(all_files, changes) + footer = self.generate_footer(changes) + emoji = self.get_commit_emoji(commit_type) + + return CommitSuggestion( + commit_type=commit_type, + scope=scope, + description=description, + body=body, + footer=footer, + is_breaking=is_breaking, + emoji=emoji + ) + + def print_commit_types(self): + """Print available commit types.""" + print(f"\n{Color.BOLD}Available commit types:{Color.RESET}") + for ct in CommitType: + if self.use_emoji: + print(f" {ct.emoji} {Color.CYAN}{ct.name_str:12}{Color.RESET} - {ct.description}") + else: + print(f" {Color.CYAN}{ct.name_str:12}{Color.RESET} - {ct.description}") + + def print_status_summary(self, status: Dict[str, List[Any]]): + """Print git status summary.""" + print(f"\n{Color.BOLD}Git Status:{Color.RESET}") + + if status['staged']: + print(f" {Color.GREEN}Staged:{Color.RESET} {len(status['staged'])} file(s)") + if status['unstaged']: + print(f" {Color.YELLOW}Modified:{Color.RESET} {len(status['unstaged'])} file(s)") + if status['untracked']: + print(f" {Color.RED}Untracked:{Color.RESET} {len(status['untracked'])} file(s)") + if status['conflicted']: + print(f" {Color.RED}{Color.BOLD}Conflicted:{Color.RESET} {len(status['conflicted'])} file(s)") + if status['deleted']: + print(f" {Color.RED}Deleted:{Color.RESET} {len(status['deleted'])} file(s)") + + def interactive_mode(self, staged_only: bool = False) -> Optional[str]: + """Run in interactive mode to generate and customize commit message.""" + self._print_info("Analyzing git changes...\n") - # Generate description - description = self.generate_description(all_files, changes) + status = self.get_git_status() + self.print_status_summary(status) - # Generate full message - file_list = ', '.join(all_files[:5]) - if len(all_files) > 5: - file_list += f" and {len(all_files) - 5} more" + if status['conflicted']: + self._print_error("You have unresolved merge conflicts. Please resolve them first.") + for f in status['conflicted']: + print(f" - {f}") + return None - body = f"Modified files: {file_list}" + try: + suggestion = self.suggest_commit_message(staged_only=staged_only) + except ValueError as e: + self._print_error(str(e)) + return None - message = self.generate_commit_message(commit_type, scope, description, body) + formatted_message = suggestion.format(use_emoji=self.use_emoji) - return commit_type, message + print(f"\n{Color.BOLD}Suggested commit message:{Color.RESET}") + print("-" * 60) + print(f"{Color.GREEN}{formatted_message}{Color.RESET}") + print("-" * 60) - def interactive_mode(self): - """Run in interactive mode to generate and customize commit message.""" - print("Analyzing git changes...\n") + print(f"\n{Color.BOLD}Detected type:{Color.RESET} {Color.CYAN}{suggestion.commit_type}{Color.RESET}", end='') + if suggestion.is_breaking: + print(f" {Color.RED}(BREAKING CHANGE){Color.RESET}") + else: + print() - commit_type, suggested_message = self.suggest_commit_message() + if suggestion.scope: + print(f"{Color.BOLD}Scope:{Color.RESET} {suggestion.scope}") + + self.print_commit_types() + + print(f"\n{Color.BOLD}Options:{Color.RESET}") + print(" 1. Use suggested message") + print(" 2. Edit commit type") + print(" 3. Edit scope") + print(" 4. Edit description") + print(" 5. Toggle breaking change") + print(" 6. Enter custom message") + print(" 7. Show diff") + print(" 8. Cancel") + + while True: + try: + choice = input(f"\n{Color.CYAN}Select option (1-8):{Color.RESET} ").strip() + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return None + + if choice == '1': + return formatted_message + + elif choice == '2': + valid_types = [ct.name_str for ct in CommitType] + new_type = input(f"Enter commit type (current: {suggestion.commit_type}): ").strip().lower() + if new_type in valid_types: + suggestion.commit_type = new_type + suggestion.emoji = self.get_commit_emoji(new_type) + formatted_message = suggestion.format(use_emoji=self.use_emoji) + print(f"\n{Color.GREEN}Updated message:{Color.RESET}\n{formatted_message}") + else: + self._print_warning(f"Invalid commit type. Valid types: {', '.join(valid_types[:6])}...") + + elif choice == '3': + new_scope = input(f"Enter scope (current: {suggestion.scope or 'none'}, empty to remove): ").strip() + suggestion.scope = new_scope if new_scope else None + formatted_message = suggestion.format(use_emoji=self.use_emoji) + print(f"\n{Color.GREEN}Updated message:{Color.RESET}\n{formatted_message}") + + elif choice == '4': + new_desc = input(f"Enter description (current: {suggestion.description}): ").strip() + if new_desc: + suggestion.description = new_desc + formatted_message = suggestion.format(use_emoji=self.use_emoji) + print(f"\n{Color.GREEN}Updated message:{Color.RESET}\n{formatted_message}") + + elif choice == '5': + suggestion.is_breaking = not suggestion.is_breaking + formatted_message = suggestion.format(use_emoji=self.use_emoji) + status_str = "enabled" if suggestion.is_breaking else "disabled" + print(f"\n{Color.YELLOW}Breaking change {status_str}{Color.RESET}") + print(f"\n{Color.GREEN}Updated message:{Color.RESET}\n{formatted_message}") + + elif choice == '6': + custom = input("Enter custom commit message: ").strip() + if custom: + return custom + + elif choice == '7': + print(f"\n{Color.BOLD}Staged diff:{Color.RESET}") + print("-" * 60) + diff = self.get_staged_diff() + if diff: + for line in diff.split('\n')[:50]: + if line.startswith('+') and not line.startswith('+++'): + print(f"{Color.GREEN}{line}{Color.RESET}") + elif line.startswith('-') and not line.startswith('---'): + print(f"{Color.RED}{line}{Color.RESET}") + elif line.startswith('@@'): + print(f"{Color.CYAN}{line}{Color.RESET}") + else: + print(line) + if diff.count('\n') > 50: + print(f"\n{Color.DIM}... (truncated, {diff.count(chr(10)) - 50} more lines){Color.RESET}") + else: + print("No staged changes to show.") + print("-" * 60) + + elif choice == '8': + print("Cancelled.") + return None - print("Suggested commit message:") - print("-" * 60) - print(suggested_message) - print("-" * 60) - print(f"\nCommit type: {commit_type} - {self.COMMIT_TYPES.get(commit_type, 'Unknown')}") - print("\nAvailable commit types:") - for ctype, desc in self.COMMIT_TYPES.items(): - print(f" {ctype:10} - {desc}") - - print("\nOptions:") - print("1. Use suggested message") - print("2. Edit commit type") - print("3. Edit description") - print("4. Enter custom message") - print("5. Cancel") - - choice = input("\nSelect option (1-5): ").strip() - - if choice == '1': - return suggested_message - elif choice == '2': - new_type = input(f"Enter commit type (current: {commit_type}): ").strip() - if new_type in self.COMMIT_TYPES: - scope = self.generate_scope(self.get_git_status()['staged'] + self.get_git_status()['unstaged']) - description = input("Enter description: ").strip() - return self.generate_commit_message(new_type, scope, description) else: - print("Invalid commit type. Using original.") - return suggested_message - elif choice == '3': - description = input("Enter new description: ").strip() - scope = self.generate_scope(self.get_git_status()['staged'] + self.get_git_status()['unstaged']) - return self.generate_commit_message(commit_type, scope, description) - elif choice == '4': - return input("Enter custom commit message: ").strip() - else: - print("Cancelled.") - sys.exit(0) + print("Invalid option. Please select 1-8.") + + def quick_mode(self, staged_only: bool = False) -> str: + """Generate commit message without interaction.""" + suggestion = self.suggest_commit_message(staged_only=staged_only) + return suggestion.format(use_emoji=self.use_emoji) + + def commit(self, message: str, amend: bool = False, no_verify: bool = False) -> bool: + """Create a git commit with the given message.""" + cmd = ['commit', '-m', message] + if amend: + cmd.append('--amend') + if no_verify: + cmd.append('--no-verify') + + try: + output, code = self.run_git_command(cmd) + if code == 0: + self._print_success("\nCommit created successfully!") + return True + else: + self._print_error(f"Failed to create commit: {output}") + return False + except subprocess.CalledProcessError as e: + self._print_error(f"Failed to create commit: {e.stderr or e.stdout}") + return False def main(): """Main entry point.""" - generator = GitCommitMessageGenerator() - - # Check if we're in a git repository - try: - generator.run_git_command(['rev-parse', '--git-dir']) - except subprocess.CalledProcessError: - print("Error: Not a git repository. Please run this script in a git repository.") + parser = argparse.ArgumentParser( + description='Git Commit Message Generator - Auto-generate conventional commit messages', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s # Interactive mode + %(prog)s -q # Quick mode (non-interactive) + %(prog)s -e # Include emoji in message + %(prog)s -s # Only analyze staged files + %(prog)s --dry-run # Show message without committing + """ + ) + + parser.add_argument('-q', '--quick', action='store_true', + help='Quick mode: generate and commit without interaction') + parser.add_argument('-e', '--emoji', action='store_true', + help='Include emoji in commit message') + parser.add_argument('-s', '--staged', action='store_true', + help='Only analyze staged changes') + parser.add_argument('--dry-run', action='store_true', + help='Show generated message without committing') + parser.add_argument('--amend', action='store_true', + help='Amend the last commit') + parser.add_argument('--no-verify', action='store_true', + help='Skip pre-commit hooks') + parser.add_argument('--no-color', action='store_true', + help='Disable colored output') + parser.add_argument('-t', '--type', choices=[ct.name_str for ct in CommitType], + help='Override commit type') + parser.add_argument('--scope', help='Override commit scope') + parser.add_argument('-m', '--message', help='Override commit description') + + args = parser.parse_args() + + generator = GitCommitMessageGenerator(use_emoji=args.emoji, no_color=args.no_color) + + if not generator.is_git_repository(): + generator._print_error("Not a git repository. Please run this script in a git repository.") sys.exit(1) - # Generate and display commit message - commit_message = generator.interactive_mode() + branch = generator.get_current_branch() + print(f"{Color.BOLD}Branch:{Color.RESET} {Color.MAGENTA}{branch}{Color.RESET}") - if commit_message: - print("\n" + "=" * 60) - print("Final commit message:") - print("=" * 60) - print(commit_message) - print("=" * 60) - - use_message = input("\nUse this message for commit? (y/n): ").strip().lower() - if use_message == 'y': + try: + if args.quick: try: - subprocess.run(['git', 'commit', '-m', commit_message], check=True) - print("\nCommit created successfully!") - except subprocess.CalledProcessError as e: - print(f"\nError creating commit: {e}") - print("\nYou can manually use:") - print(f'git commit -m "{commit_message}"') + commit_message = generator.quick_mode(staged_only=args.staged) + + if args.type or args.scope or args.message: + suggestion = generator.suggest_commit_message(staged_only=args.staged) + if args.type: + suggestion.commit_type = args.type + suggestion.emoji = generator.get_commit_emoji(args.type) + if args.scope: + suggestion.scope = args.scope + if args.message: + suggestion.description = args.message + commit_message = suggestion.format(use_emoji=args.emoji) + + print(f"\n{Color.GREEN}{commit_message}{Color.RESET}") + + if args.dry_run: + print(f"\n{Color.YELLOW}(dry-run: commit not created){Color.RESET}") + else: + generator.commit(commit_message, amend=args.amend, no_verify=args.no_verify) + + except ValueError as e: + generator._print_error(str(e)) + sys.exit(1) else: - print("\nCommit cancelled. You can manually use:") - print(f'git commit -m "{commit_message}"') + commit_message = generator.interactive_mode(staged_only=args.staged) + + if commit_message: + print(f"\n{'=' * 60}") + print(f"{Color.BOLD}Final commit message:{Color.RESET}") + print("=" * 60) + print(f"{Color.GREEN}{commit_message}{Color.RESET}") + print("=" * 60) + + if args.dry_run: + print(f"\n{Color.YELLOW}(dry-run: commit not created){Color.RESET}") + print("\nYou can manually use:") + escaped_message = commit_message.replace('"', '\\"') + print(f'git commit -m "{escaped_message}"') + else: + try: + use_message = input(f"\n{Color.CYAN}Use this message for commit? (y/n):{Color.RESET} ").strip().lower() + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + sys.exit(0) + + if use_message == 'y': + if generator.commit(commit_message, amend=args.amend, no_verify=args.no_verify): + sys.exit(0) + else: + sys.exit(1) + else: + print("\nCommit cancelled. You can manually use:") + escaped_message = commit_message.replace('"', '\\"') + print(f'git commit -m "{escaped_message}"') + + except KeyboardInterrupt: + print("\n\nCancelled.") + sys.exit(0) if __name__ == "__main__":