diff --git a/README.md b/README.md index bdb936b..a2dcfb9 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,31 @@ git-ai-commit summarize --- +🏷️ `git-ai-commit conventional` + +Generate commit messages in the [Conventional Commits](https://www.conventionalcommits.org/) format (`type(scope): description`). + +This command: +1. Analyzes your staged changes using AI +2. Suggests the most appropriate commit type based on your changes +3. Suggests a relevant scope based on the affected components +4. Allows you to accept the suggestions or choose your own +5. Formats the message according to conventional commit standards +6. Gives you the option to commit and push + +Available commit types: + +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `style`: Formatting changes +- `refactor`: Code refactoring +- `perf`: Performance improvements +- `test`: Adding or modifying tests +- `chore`: Maintenance tasks + +--- + 📌 `git-ai-commit help`, `-h` Displays a list of available command and options to help you setup our tool. diff --git a/ai_commit_msg/cli/conventional_commit_handler.py b/ai_commit_msg/cli/conventional_commit_handler.py new file mode 100644 index 0000000..3f5d094 --- /dev/null +++ b/ai_commit_msg/cli/conventional_commit_handler.py @@ -0,0 +1,167 @@ +from ai_commit_msg.core.gen_commit_msg import generate_commit_message +from ai_commit_msg.services.git_service import GitService +from ai_commit_msg.utils.logger import Logger +from ai_commit_msg.utils.utils import execute_cli_command +from ai_commit_msg.utils.error import AIModelHandlerError +from ai_commit_msg.utils.git_utils import handle_git_push + + +COMMIT_TYPES = { + "feat": "New feature", + "fix": "Bug fix", + "docs": "Documentation changes", + "style": "Formatting changes", + "refactor": "Code refactoring", + "perf": "Performance improvements", + "test": "Adding or modifying tests", + "chore": "Maintenance tasks", +} + + +def print_conventional_commit(commit_type, scope, message): + formatted_commit = f"{commit_type}" + if scope: + formatted_commit += f"({scope})" + formatted_commit += f": {message}" + + Logger().log( + f"""Here is your conventional commit message: + + {formatted_commit} + +to use this commit message run: `git commit -m "{formatted_commit}"` +""" + ) + return formatted_commit + + +def select_commit_type(suggested_type=None): + logger = Logger() + + if suggested_type and suggested_type in COMMIT_TYPES: + logger.log( + f"AI suggests commit type: {suggested_type} ({COMMIT_TYPES[suggested_type]})" + ) + use_suggested = ( + input(f"Use suggested type '{suggested_type}'? (Y/n): ").strip().lower() + ) + if use_suggested == "" or use_suggested == "y": + return suggested_type + + logger.log("Select a commit type:") + + # Display commit types with descriptions + for i, (type_key, description) in enumerate(COMMIT_TYPES.items(), 1): + # Highlight the suggested type if it exists + highlight = "→ " if suggested_type == type_key else " " + logger.log(f"{highlight}{i}. {type_key}: {description}") + + # Add custom option + logger.log(f" {len(COMMIT_TYPES) + 1}. custom: Enter a custom type") + + while True: + try: + choice = input("Enter the number of your choice: ") + choice_num = int(choice) + + if 1 <= choice_num <= len(COMMIT_TYPES): + return list(COMMIT_TYPES.keys())[choice_num - 1] + elif choice_num == len(COMMIT_TYPES) + 1: + custom_type = input("Enter your custom commit type: ") + return custom_type + else: + logger.log("Invalid choice. Please try again.") + except ValueError: + logger.log("Please enter a valid number.") + + +def get_scope(suggested_scope=None): + logger = Logger() + + if suggested_scope and suggested_scope.strip() and suggested_scope != "none": + logger.log(f"AI suggests scope: '{suggested_scope}'") + use_suggested = ( + input(f"Use suggested scope '{suggested_scope}'? (Y/n): ").strip().lower() + ) + if use_suggested == "" or use_suggested == "y": + return suggested_scope + + scope = input("Enter scope (optional, press Enter to skip): ") + return scope.strip() + + +def conventional_commit_handler(args): + logger = Logger() + + logger.log("Fetching your staged changes...\n") + + if len(GitService.get_staged_files()) == 0: + logger.log( + "🚨 No files are staged for commit. Run `git add` to stage some of your changes" + ) + return + + staged_changes_diff = execute_cli_command(["git", "diff", "--staged"]) + diff = staged_changes_diff.stdout + + try: + logger.log("🤖 AI is analyzing your changes to suggest a commit type...\n") + suggested_type = generate_commit_message(diff, classify_type=True) + + if suggested_type not in COMMIT_TYPES: + logger.log( + f"AI suggested an invalid type: '{suggested_type}'. Falling back to manual selection." + ) + suggested_type = None + except AIModelHandlerError as e: + logger.log(f"Error classifying commit type: {e}") + suggested_type = None + + suggested_scope = None + try: + logger.log("🤖 AI is analyzing your changes to suggest a scope...\n") + suggested_scope = generate_commit_message( + diff, conventional=False, classify_type=False, classify_scope=True + ) + logger.log(f"Debug - AI suggested scope: '{suggested_scope}'") + + if suggested_scope == "none" or not suggested_scope: + suggested_scope = None + except AIModelHandlerError as e: + logger.log(f"Error suggesting scope: {e}") + suggested_scope = None + + try: + ai_commit_msg = generate_commit_message(diff, conventional=True) + except AIModelHandlerError as e: + logger.log(f"Error generating commit message: {e}") + logger.log("Please enter your commit message manually:") + ai_commit_msg = input().strip() + if not ai_commit_msg: + logger.log("No commit message provided. Exiting.") + return + + commit_type = select_commit_type(suggested_type) + + scope = get_scope(suggested_scope) + + formatted_commit = print_conventional_commit(commit_type, scope, ai_commit_msg) + + command_string = f""" +git commit -m "{formatted_commit}" +git push + +Would you like to commit your changes? (y/n): """ + + should_push_changes = input(command_string) + + if should_push_changes == "n": + logger.log("👋 Goodbye!") + return + elif should_push_changes != "y": + logger.log("🚨 Invalid input. Exiting.") + return + + execute_cli_command(["git", "commit", "-m", f'"{formatted_commit}"'], output=True) + + handle_git_push() \ No newline at end of file diff --git a/ai_commit_msg/cli/gen_ai_commit_message_handler.py b/ai_commit_msg/cli/gen_ai_commit_message_handler.py index 6053b26..ba03e17 100644 --- a/ai_commit_msg/cli/gen_ai_commit_message_handler.py +++ b/ai_commit_msg/cli/gen_ai_commit_message_handler.py @@ -4,6 +4,7 @@ from ai_commit_msg.utils.utils import execute_cli_command from ai_commit_msg.utils.error import AIModelHandlerError from ai_commit_msg.utils.logger import Logger +from ai_commit_msg.utils.git_utils import handle_git_push def gen_ai_commit_message_handler(): @@ -45,22 +46,7 @@ def gen_ai_commit_message_handler(): return execute_cli_command(["git", "commit", "-m", ai_gen_commit_msg], output=True) - current_branch = GitService.get_current_branch() - has_upstream = GitService.has_upstream_branch(current_branch) - if has_upstream: - execute_cli_command(["git", "push"], output=True) - return - - set_upstream = input( - f"No upstream branch found for '{current_branch}'. This will run: 'git push --set-upstream origin {current_branch}'. Set upstream? (y/n): " - ) - if set_upstream.lower() == "y": - execute_cli_command( - ["git", "push", "--set-upstream", "origin", current_branch], output=True - ) - print(f"🔄 Upstream branch set for '{current_branch}'") - else: - print("Skipping push. You can set upstream manually") + handle_git_push() return 0 diff --git a/ai_commit_msg/cli/summary_handler.py b/ai_commit_msg/cli/summary_handler.py index f7c102b..64ac11d 100644 --- a/ai_commit_msg/cli/summary_handler.py +++ b/ai_commit_msg/cli/summary_handler.py @@ -24,7 +24,7 @@ def summaryFromDiffFile(diff_file_path): def summary_handler(args): - if hasattr(args, 'diff') and args.diff is not None: + if hasattr(args, "diff") and args.diff is not None: summaryFromDiffFile(args.diff) return diff --git a/ai_commit_msg/core/gen_commit_msg.py b/ai_commit_msg/core/gen_commit_msg.py index 306e339..4ba838c 100644 --- a/ai_commit_msg/core/gen_commit_msg.py +++ b/ai_commit_msg/core/gen_commit_msg.py @@ -3,13 +3,26 @@ from ai_commit_msg.services.config_service import ConfigService -def generate_commit_message(diff: str = None) -> str: +def generate_commit_message( + diff: str = None, + conventional: bool = False, + classify_type: bool = False, + classify_scope: bool = False, +) -> str: if diff is None: raise ValueError("Diff is required to generate a commit message") - prompt = get_prompt(diff) + prompt = get_prompt( + diff, + conventional=conventional, + classify_type=classify_type, + classify_scope=classify_scope, + ) ai_gen_commit_msg = llm_chat_completion(prompt) - prefix = ConfigService().prefix - return prefix + ai_gen_commit_msg + if not classify_type and not classify_scope: + prefix = ConfigService().prefix + return prefix + ai_gen_commit_msg + else: + return ai_gen_commit_msg.strip().lower() diff --git a/ai_commit_msg/core/prompt.py b/ai_commit_msg/core/prompt.py index f9887e4..400e4ca 100644 --- a/ai_commit_msg/core/prompt.py +++ b/ai_commit_msg/core/prompt.py @@ -1,10 +1,65 @@ from ai_commit_msg.services.config_service import ConfigService -def get_prompt(diff): +def get_prompt(diff, conventional=False, classify_type=False, classify_scope=False): max_length = ConfigService().max_length - COMMIT_MSG_SYSTEM_MESSAGE = f""" + if classify_type: + COMMIT_MSG_SYSTEM_MESSAGE = f""" +You are a software engineer reviewing code changes to classify them according to conventional commit standards. +You will be provided with a set of code changes in diff format. + +Your task is to analyze the changes and determine the most appropriate conventional commit type. +Choose ONE type from the following options: +- feat: New feature +- fix: Bug fix +- docs: Documentation changes +- style: Formatting changes +- refactor: Code refactoring +- perf: Performance improvements +- test: Adding or modifying tests +- chore: Maintenance tasks + +Respond with ONLY the type (e.g., "feat", "fix", etc.) without any additional text or explanation. +""" + elif classify_scope: + COMMIT_MSG_SYSTEM_MESSAGE = f""" +You are a software engineer reviewing code changes to suggest an appropriate scope for a conventional commit. +You will be provided with a set of code changes in diff format. + +Your task is to analyze the changes and suggest a concise, meaningful scope that indicates what part of the codebase or functionality is being modified. +Good scopes are typically: +- Short (1-3 words) +- Descriptive of the component or feature being changed +- Lowercase with no spaces (use hyphens if needed) + +Examples of good scopes: +- "auth" for authentication changes +- "user-profile" for user profile features +- "api" for API-related changes +- "docs" for documentation +- "deps" for dependency updates +- "ui" for user interface changes + +If you can't determine a meaningful scope, respond with "none". +Respond with ONLY the suggested scope without any additional text or explanation. +""" + elif conventional: + COMMIT_MSG_SYSTEM_MESSAGE = f""" +You are a software engineer reviewing code changes. +You will be provided with a set of code changes in diff format. + +Your task is to write a concise commit message body that summarizes the changes. This will be used in a conventional commit format. + +These are your requirements for the commit message body: +- Write in the imperative mood (e.g., "add feature" not "added feature") +- Focus only on the description part - do NOT include type prefixes like "feat:" or "fix:" as these will be added separately +- Be specific but concise about what was changed +- You don't need to add any punctuation or capitalization +- Your response cannot be more than {max_length} characters +""" + else: + COMMIT_MSG_SYSTEM_MESSAGE = f""" Your a software engineer and you are reviewing a set of code changes. You will be provided with a set of code changes in diff format. diff --git a/ai_commit_msg/main.py b/ai_commit_msg/main.py index 11f39ee..cf0c562 100644 --- a/ai_commit_msg/main.py +++ b/ai_commit_msg/main.py @@ -15,6 +15,7 @@ from ai_commit_msg.services.config_service import ConfigService from ai_commit_msg.services.pip_service import PipService from ai_commit_msg.utils.logger import Logger +from ai_commit_msg.cli.conventional_commit_handler import conventional_commit_handler def called_from_git_hook(): @@ -135,9 +136,14 @@ def main(argv: Sequence[str] = sys.argv[1:]) -> int: help="Setup the prepare-commit-msg hook", ) summary_cmd_parser.add_argument( - "-d", "--diff", + "-d", + "--diff", default=None, - help="🔍 Provide a diff to generate a commit message" + help="🔍 Provide a diff to generate a commit message", + ) + + conventional_commit_parser = subparsers.add_parser( + "conventional", help="🏷️ Generate a conventional commit message" ) args = parser.parse_args(argv) @@ -160,6 +166,8 @@ def get_full_help_menu(): hook_handler(args) elif args.command == "summarize" or args.command == "summary": summary_handler(args) + elif args.command == "conventional": + conventional_commit_handler(args) ## Only in main script, we return zero instead of None when the return value is unused return 0 diff --git a/ai_commit_msg/utils/git_utils.py b/ai_commit_msg/utils/git_utils.py new file mode 100644 index 0000000..10e3bfd --- /dev/null +++ b/ai_commit_msg/utils/git_utils.py @@ -0,0 +1,30 @@ +from ai_commit_msg.services.git_service import GitService +from ai_commit_msg.utils.utils import execute_cli_command +from ai_commit_msg.utils.logger import Logger + + +def handle_git_push(): + """ + Handle git push operation with upstream branch setting if needed. + Returns True if push was successful, False otherwise. + """ + logger = Logger() + current_branch = GitService.get_current_branch() + has_upstream = GitService.has_upstream_branch(current_branch) + + if has_upstream: + execute_cli_command(["git", "push"], output=True) + return True + + set_upstream = input( + f"No upstream branch found for '{current_branch}'. This will run: 'git push --set-upstream origin {current_branch}'. Set upstream? (y/n): " + ) + if set_upstream.lower() == "y": + execute_cli_command( + ["git", "push", "--set-upstream", "origin", current_branch], output=True + ) + logger.log(f"🔄 Upstream branch set for '{current_branch}'") + return True + else: + logger.log("Skipping push. You can set upstream manually") + return False diff --git a/requirements-dev.txt b/requirements-dev.txt index 5f97595..27e6b41 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,4 +6,4 @@ prompt_toolkit==3.0.47 requests==2.32.3 rich==13.7.1 pre-commit==3.8.0 -black=24.8.0 \ No newline at end of file +black==24.8.0 \ No newline at end of file