mirror of
https://github.com/xtekky/gpt4free.git
synced 2026-01-06 01:02:13 -08:00
feat: enhance commit tool with advanced options and error handling
- Added command-line argument parsing with options for model selection, editing, and no-commit mode - Implemented fallback mechanism with multiple AI models if the primary model fails - Added spinner display to indicate progress during API calls - Created functions to filter sensitive data from diffs before sending to API - Added diff truncation capabilities for handling large changesets - Implemented commit message editing in user's configured editor - Added model listing functionality to show available AI options - Enhanced error handling with retries and better error reporting - Added keyboard interrupt handling for graceful termination - Improved type annotations throughout the codebase - Added constants for configuration parameters like retry delay and max diff size
This commit is contained in:
parent
54ef1a511c
commit
2e13110e37
1 changed files with 273 additions and 37 deletions
|
|
@ -7,13 +7,52 @@ staged changes. It analyzes the git diff and suggests appropriate commit
|
||||||
messages following conventional commit format.
|
messages following conventional commit format.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -m etc.tool.commit
|
python -m etc.tool.commit [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--model MODEL Specify the AI model to use (default: claude-3.7-sonnet)
|
||||||
|
--edit Edit the generated commit message before committing
|
||||||
|
--no-commit Generate message only without committing
|
||||||
|
--list-models List available AI models and exit
|
||||||
|
--help Show this help message
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from g4f.client import Client
|
import os
|
||||||
|
import argparse
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from typing import Optional, Dict, Any, List, Tuple
|
||||||
|
|
||||||
def get_git_diff():
|
from g4f.client import Client
|
||||||
|
from g4f.models import ModelUtils
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
DEFAULT_MODEL = "claude-3.7-sonnet"
|
||||||
|
FALLBACK_MODELS = ["claude-3.5-sonnet", "o1", "o3-mini", "gpt-4o"]
|
||||||
|
MAX_DIFF_SIZE = None # Set to None to disable truncation, or a number for character limit
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
RETRY_DELAY = 2 # Seconds
|
||||||
|
|
||||||
|
def parse_arguments():
|
||||||
|
"""Parse command line arguments"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="AI Commit Message Generator",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__
|
||||||
|
)
|
||||||
|
parser.add_argument("--model", type=str, default=DEFAULT_MODEL,
|
||||||
|
help=f"AI model to use (default: {DEFAULT_MODEL})")
|
||||||
|
parser.add_argument("--edit", action="store_true",
|
||||||
|
help="Edit the generated commit message before committing")
|
||||||
|
parser.add_argument("--no-commit", action="store_true",
|
||||||
|
help="Generate message only without committing")
|
||||||
|
parser.add_argument("--list-models", action="store_true",
|
||||||
|
help="List available AI models and exit")
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
def get_git_diff() -> Optional[str]:
|
||||||
"""Get the current git diff for staged changes"""
|
"""Get the current git diff for staged changes"""
|
||||||
try:
|
try:
|
||||||
diff_process = subprocess.run(
|
diff_process = subprocess.run(
|
||||||
|
|
@ -21,20 +60,102 @@ def get_git_diff():
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True
|
text=True
|
||||||
)
|
)
|
||||||
|
if diff_process.returncode != 0:
|
||||||
|
print(f"Error: git diff command failed with code {diff_process.returncode}")
|
||||||
|
return None
|
||||||
|
|
||||||
return diff_process.stdout
|
return diff_process.stdout
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error running git diff: {e}")
|
print(f"Error running git diff: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def generate_commit_message(diff_text):
|
def truncate_diff(diff_text: str, max_size: int = MAX_DIFF_SIZE) -> str:
|
||||||
|
"""Truncate diff if it's too large, preserving the most important parts"""
|
||||||
|
if max_size is None or len(diff_text) <= max_size:
|
||||||
|
return diff_text
|
||||||
|
|
||||||
|
print(f"Warning: Diff is large ({len(diff_text)} chars), truncating to {max_size} chars")
|
||||||
|
|
||||||
|
# Split by file sections and keep as many complete files as possible
|
||||||
|
sections = diff_text.split("diff --git ")
|
||||||
|
header = sections[0]
|
||||||
|
file_sections = ["diff --git " + s for s in sections[1:]]
|
||||||
|
|
||||||
|
result = header
|
||||||
|
for section in file_sections:
|
||||||
|
if len(result) + len(section) <= max_size:
|
||||||
|
result += section
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def filter_sensitive_data(diff_text: str) -> str:
|
||||||
|
"""Filter out potentially sensitive data from the diff"""
|
||||||
|
# List of patterns that might indicate sensitive data
|
||||||
|
sensitive_patterns = [
|
||||||
|
("password", "***REDACTED***"),
|
||||||
|
("secret", "***REDACTED***"),
|
||||||
|
("token", "***REDACTED***"),
|
||||||
|
("api_key", "***REDACTED***"),
|
||||||
|
("apikey", "***REDACTED***"),
|
||||||
|
("auth", "***REDACTED***"),
|
||||||
|
("credential", "***REDACTED***"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Simple pattern matching - in a real implementation, you might want more sophisticated regex
|
||||||
|
filtered_text = diff_text
|
||||||
|
for pattern, replacement in sensitive_patterns:
|
||||||
|
# Only replace if it looks like an assignment or declaration
|
||||||
|
filtered_text = filtered_text.replace(f'{pattern}="', f'{pattern}="{replacement}')
|
||||||
|
filtered_text = filtered_text.replace(f"{pattern}='", f"{pattern}='{replacement}'")
|
||||||
|
filtered_text = filtered_text.replace(f"{pattern}:", f"{pattern}: {replacement}")
|
||||||
|
filtered_text = filtered_text.replace(f"{pattern} =", f"{pattern} = {replacement}")
|
||||||
|
|
||||||
|
return filtered_text
|
||||||
|
|
||||||
|
def show_spinner(duration: int = None):
|
||||||
|
"""Display a simple spinner to indicate progress"""
|
||||||
|
import itertools
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
spinner = itertools.cycle(['-', '/', '|', '\\'])
|
||||||
|
stop_spinner = threading.Event()
|
||||||
|
|
||||||
|
def spin():
|
||||||
|
while not stop_spinner.is_set():
|
||||||
|
sys.stdout.write(f"\rGenerating commit message... {next(spinner)} ")
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
spinner_thread = threading.Thread(target=spin)
|
||||||
|
spinner_thread.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if duration:
|
||||||
|
time.sleep(duration)
|
||||||
|
stop_spinner.set()
|
||||||
|
return stop_spinner
|
||||||
|
except:
|
||||||
|
stop_spinner.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def generate_commit_message(diff_text: str, model: str = DEFAULT_MODEL) -> Optional[str]:
|
||||||
"""Generate a commit message based on the git diff"""
|
"""Generate a commit message based on the git diff"""
|
||||||
if not diff_text or diff_text.strip() == "":
|
if not diff_text or diff_text.strip() == "":
|
||||||
return "No changes staged for commit"
|
return "No changes staged for commit"
|
||||||
|
|
||||||
|
# Filter sensitive data
|
||||||
|
filtered_diff = filter_sensitive_data(diff_text)
|
||||||
|
|
||||||
|
# Truncate if necessary
|
||||||
|
truncated_diff = truncate_diff(filtered_diff)
|
||||||
|
|
||||||
client = Client()
|
client = Client()
|
||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
{diff_text}
|
{truncated_diff}
|
||||||
```
|
```
|
||||||
|
|
||||||
Analyze ONLY the exact changes in this git diff and create a precise commit message.
|
Analyze ONLY the exact changes in this git diff and create a precise commit message.
|
||||||
|
|
@ -56,50 +177,165 @@ def generate_commit_message(diff_text):
|
||||||
IMPORTANT: Be 100% factual. Only mention code that was actually changed. Never invent or assume changes not shown in the diff. If unsure about a change's purpose, describe what changed rather than why. Output nothing except for the commit message, and don't surround it in quotes.
|
IMPORTANT: Be 100% factual. Only mention code that was actually changed. Never invent or assume changes not shown in the diff. If unsure about a change's purpose, describe what changed rather than why. Output nothing except for the commit message, and don't surround it in quotes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
# Start spinner
|
||||||
|
spinner = show_spinner()
|
||||||
|
|
||||||
|
# Make API call
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[{"role": "user", "content": prompt}]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stop spinner and clear line
|
||||||
|
spinner.set()
|
||||||
|
sys.stdout.write("\r" + " " * 50 + "\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
except Exception as e:
|
||||||
|
# Stop spinner if it's running
|
||||||
|
if 'spinner' in locals() and spinner:
|
||||||
|
spinner.set()
|
||||||
|
sys.stdout.write("\r" + " " * 50 + "\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
print(f"Error generating commit message (attempt {attempt+1}/{MAX_RETRIES}): {e}")
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
print(f"Retrying in {RETRY_DELAY} seconds...")
|
||||||
|
time.sleep(RETRY_DELAY)
|
||||||
|
# Try with a fallback model if available
|
||||||
|
if attempt < len(FALLBACK_MODELS):
|
||||||
|
fallback = FALLBACK_MODELS[attempt]
|
||||||
|
print(f"Trying with fallback model: {fallback}")
|
||||||
|
model = fallback
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def edit_commit_message(message: str) -> str:
|
||||||
|
"""Allow user to edit the commit message in their default editor"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.txt') as temp:
|
||||||
|
temp.write(message)
|
||||||
|
temp_path = temp.name
|
||||||
|
|
||||||
|
# Get the default editor from git config or environment
|
||||||
try:
|
try:
|
||||||
response = client.chat.completions.create(
|
editor = subprocess.run(
|
||||||
model="claude-3.7-sonnet",
|
["git", "config", "--get", "core.editor"],
|
||||||
messages=[{"role": "user", "content": prompt}]
|
capture_output=True, text=True
|
||||||
|
).stdout.strip()
|
||||||
|
except:
|
||||||
|
editor = os.environ.get('EDITOR', 'vim')
|
||||||
|
|
||||||
|
if not editor:
|
||||||
|
editor = 'vim' # Default fallback
|
||||||
|
|
||||||
|
# Open the editor
|
||||||
|
try:
|
||||||
|
subprocess.run([editor, temp_path], check=True)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print("Warning: Editor exited with an error")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Warning: Editor '{editor}' not found, falling back to basic input")
|
||||||
|
print("Edit your commit message (Ctrl+D when done):")
|
||||||
|
edited_message = sys.stdin.read().strip()
|
||||||
|
os.unlink(temp_path)
|
||||||
|
return edited_message
|
||||||
|
|
||||||
|
# Read the edited message
|
||||||
|
with open(temp_path, 'r') as temp:
|
||||||
|
edited_message = temp.read()
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
|
return edited_message
|
||||||
|
|
||||||
|
def list_available_models() -> List[str]:
|
||||||
|
"""List available AI models that can be used for commit message generation"""
|
||||||
|
# Filter for text models that are likely to be good for code understanding
|
||||||
|
relevant_models = []
|
||||||
|
|
||||||
|
for model_name, model in ModelUtils.convert.items():
|
||||||
|
# Skip image, audio, and video models
|
||||||
|
if model_name and not model_name.startswith(('dall', 'sd-', 'flux', 'midjourney')):
|
||||||
|
relevant_models.append(model_name)
|
||||||
|
|
||||||
|
return sorted(relevant_models)
|
||||||
|
|
||||||
|
def make_commit(message: str) -> bool:
|
||||||
|
"""Make a git commit with the provided message"""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "commit", "-m", message],
|
||||||
|
check=True
|
||||||
)
|
)
|
||||||
|
return True
|
||||||
return response.choices[0].message.content.strip()
|
except subprocess.CalledProcessError as e:
|
||||||
except Exception as e:
|
print(f"Error making commit: {e}")
|
||||||
print(f"Error generating commit message: {e}")
|
return False
|
||||||
return None
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("Fetching git diff...")
|
"""Main function"""
|
||||||
diff = get_git_diff()
|
try:
|
||||||
|
args = parse_arguments()
|
||||||
if diff is None:
|
|
||||||
print("Failed to get git diff. Are you in a git repository?")
|
# If --list-models is specified, list available models and exit
|
||||||
sys.exit(1)
|
if args.list_models:
|
||||||
|
print("Available AI models for commit message generation:")
|
||||||
if diff.strip() == "":
|
for model in list_available_models():
|
||||||
print("No changes staged for commit. Stage changes with 'git add' first.")
|
print(f" - {model}")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
print("Generating commit message...")
|
print("Fetching git diff...")
|
||||||
commit_message = generate_commit_message(diff)
|
diff = get_git_diff()
|
||||||
|
|
||||||
if commit_message:
|
if diff is None:
|
||||||
|
print("Failed to get git diff. Are you in a git repository?")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if diff.strip() == "":
|
||||||
|
print("No changes staged for commit. Stage changes with 'git add' first.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"Using model: {args.model}")
|
||||||
|
commit_message = generate_commit_message(diff, args.model)
|
||||||
|
|
||||||
|
if not commit_message:
|
||||||
|
print("Failed to generate commit message after multiple attempts.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
print("\nGenerated commit message:")
|
print("\nGenerated commit message:")
|
||||||
print("-" * 50)
|
print("-" * 50)
|
||||||
print(commit_message)
|
print(commit_message)
|
||||||
print("-" * 50)
|
print("-" * 50)
|
||||||
|
|
||||||
|
if args.edit:
|
||||||
|
print("\nOpening editor to modify commit message...")
|
||||||
|
commit_message = edit_commit_message(commit_message)
|
||||||
|
print("\nEdited commit message:")
|
||||||
|
print("-" * 50)
|
||||||
|
print(commit_message)
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
if args.no_commit:
|
||||||
|
print("\nCommit message generated but not committed (--no-commit flag used).")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
user_input = input("\nDo you want to use this commit message? (y/n): ")
|
user_input = input("\nDo you want to use this commit message? (y/n): ")
|
||||||
if user_input.lower() == 'y':
|
if user_input.lower() == 'y':
|
||||||
try:
|
if make_commit(commit_message):
|
||||||
subprocess.run(
|
|
||||||
["git", "commit", "-m", commit_message],
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
print("Commit successful!")
|
print("Commit successful!")
|
||||||
except subprocess.CalledProcessError as e:
|
else:
|
||||||
print(f"Error making commit: {e}")
|
print("Commit failed.")
|
||||||
else:
|
sys.exit(1)
|
||||||
print("Failed to generate commit message.")
|
else:
|
||||||
|
print("Commit aborted.")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nOperation cancelled by user.")
|
||||||
|
sys.exit(130) # Standard exit code for SIGINT
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue