mirror of
https://github.com/xtekky/gpt4free.git
synced 2025-12-06 02:30:41 -08:00
- **g4f/Provider/Copilot.py** - Added `"Smart (GPT-5)"` to `models` list. - Added `"gpt-5"` alias mapping to `"GPT-5"` in `model_aliases`. - Introduced `mode` selection logic to support `"smart"` mode for GPT-5 models alongside existing `"reasoning"` and `"chat"` modes. - **g4f/Provider/EasyChat.py** - Added `get_models` class method to map `-free` models to aliases and store them in `cls.models`. - Resolved model via `cls.get_model(model)` at start of `create_async_generator`. - Reset `cls.captchaToken` to `None` at the beginning of `callback`. - Wrapped main generator logic in a loop to allow retry once if `CLEAR-CAPTCHA-TOKEN` error occurs, clearing auth file and resetting args. - **g4f/Provider/needs_auth/OpenaiChat.py** - Added handling for image models: detect and set `image_model` flag, use `default_model` when sending requests if image model selected, and include `"picture_v2"` in `system_hints` when applicable. - Replaced textarea/button detection code in page load sequence with `nodriver` `select` calls, sending "Hello" before clicking send button, and included profile button selection if class needs auth. - **g4f/Provider/openai/models.py** - Changed `default_image_model` from `"dall-e-3"` to `"gpt-image"`. - Added `"gpt-5"` and `"gpt-5-thinking"` to `text_models` list. - Added alias mapping for `"dall-e-3"` pointing to new `default_image_model`.
506 lines
18 KiB
Python
Executable file
506 lines
18 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
AI Commit Message Generator using gpt4free (g4f)
|
|
|
|
This tool uses AI to generate meaningful git commit messages based on
|
|
staged changes. It analyzes the git diff and suggests appropriate commit
|
|
messages following conventional commit format. The tool can work with
|
|
any git repository and supports multiple AI models for message generation.
|
|
|
|
Created for use with gpt4free (g4f) development workflow.
|
|
|
|
Usage:
|
|
python -m etc.tool.commit [options]
|
|
|
|
Examples:
|
|
# Generate commit for current repository
|
|
python -m etc.tool.commit
|
|
|
|
# Generate commit for specific repository
|
|
python -m etc.tool.commit --repo /path/to/repo
|
|
python -m etc.tool.commit --repo ../my-project
|
|
python -m etc.tool.commit --repo ~/projects/website
|
|
|
|
# Generate and edit commit message before committing
|
|
python -m etc.tool.commit --repo ./docs --edit
|
|
|
|
# Generate message only without committing
|
|
python -m etc.tool.commit --repo ~/projects/app --no-commit
|
|
|
|
# Use specific AI model
|
|
python -m etc.tool.commit --model gpt-4 --repo ./backend
|
|
|
|
# List available models
|
|
python -m etc.tool.commit --list-models
|
|
|
|
# Complex workflow example
|
|
python -m etc.tool.commit --repo ../frontend --model claude-3-sonnet --edit
|
|
|
|
Features:
|
|
- AI-powered commit message generation using gpt4free
|
|
- Support for multiple AI models (GPT-4, Claude, Gemini, etc.)
|
|
- Conventional commit format compliance
|
|
- Multi-repository support with automatic validation
|
|
- Interactive editing with system default editor
|
|
- Sensitive data filtering and protection
|
|
- Automatic retry logic with fallback models
|
|
- Git repository information display
|
|
- Staged changes analysis and diff processing
|
|
- Comprehensive error handling and validation
|
|
|
|
Options:
|
|
--model MODEL Specify the AI model to use (default: gpt-4o)
|
|
--edit Edit the generated commit message before committing
|
|
--no-commit Generate message only without committing
|
|
--list-models List available AI models and exit
|
|
--repo PATH Specify git repository path (default: current directory)
|
|
--help Show this help message and exit
|
|
|
|
Requirements:
|
|
- gpt4free (g4f) library
|
|
- Git installed and accessible via command line
|
|
- Active git repository with staged changes
|
|
- Internet connection for AI model access
|
|
|
|
Workflow:
|
|
1. Validates specified git repository
|
|
2. Fetches staged changes using git diff
|
|
3. Filters sensitive data from diff
|
|
4. Generates commit message using AI
|
|
5. Optionally allows editing the message
|
|
6. Creates git commit with generated message
|
|
|
|
Security Features:
|
|
- Automatic filtering of sensitive patterns (passwords, tokens, etc.)
|
|
- Local processing of git data
|
|
- No permanent storage of repository content
|
|
- Safe handling of authentication credentials
|
|
|
|
Supported Commit Types:
|
|
- feat: New features
|
|
- fix: Bug fixes
|
|
- docs: Documentation changes
|
|
- refactor: Code refactoring
|
|
- test: Test additions/modifications
|
|
- style: Code style changes
|
|
- perf: Performance improvements
|
|
- chore: Maintenance tasks
|
|
|
|
Author: Created for gpt4free (g4f) project
|
|
License: MIT
|
|
"""
|
|
import subprocess
|
|
import sys
|
|
import os
|
|
import argparse
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional, Any, List
|
|
|
|
from g4f.client import Client
|
|
from g4f.models import ModelUtils
|
|
from g4f.cookies import read_cookie_files
|
|
|
|
from g4f import debug
|
|
debug.logging = True
|
|
|
|
# Constants
|
|
DEFAULT_MODEL = "gpt-4o"
|
|
FALLBACK_MODELS = []
|
|
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 using gpt4free (g4f)",
|
|
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")
|
|
parser.add_argument("--repo", type=str, default=".",
|
|
help="Git repository path (default: current directory)")
|
|
parser.add_argument("--max-retries", type=int, default=MAX_RETRIES,
|
|
help="Maximum number of retries for AI generation (default: 3)")
|
|
|
|
return parser.parse_args()
|
|
|
|
def validate_git_repository(repo_path: str) -> Path:
|
|
"""Validate that the specified path is a git repository"""
|
|
repo_path = Path(repo_path).resolve()
|
|
|
|
if not repo_path.exists():
|
|
print(f"Error: Repository path does not exist: {repo_path}")
|
|
sys.exit(1)
|
|
|
|
if not repo_path.is_dir():
|
|
print(f"Error: Repository path is not a directory: {repo_path}")
|
|
sys.exit(1)
|
|
|
|
# Check if it's a git repository
|
|
git_dir = repo_path / ".git"
|
|
if not git_dir.exists():
|
|
# Check if we're in a subdirectory of a git repo
|
|
current = repo_path
|
|
while current != current.parent:
|
|
if (current / ".git").exists():
|
|
repo_path = current
|
|
break
|
|
current = current.parent
|
|
else:
|
|
print(f"Error: Not a git repository: {repo_path}")
|
|
print("Initialize a git repository with: git init")
|
|
sys.exit(1)
|
|
|
|
return repo_path
|
|
|
|
def get_git_diff(repo_path: Path) -> Optional[str]:
|
|
"""Get the current git diff for staged changes in specified repository"""
|
|
try:
|
|
diff_process = subprocess.run(
|
|
["git", "diff", "--staged"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=repo_path
|
|
)
|
|
if diff_process.returncode != 0:
|
|
print(f"Error: git diff command failed with code {diff_process.returncode}")
|
|
print(f"Error output: {diff_process.stderr}")
|
|
return None
|
|
|
|
return diff_process.stdout
|
|
except Exception as e:
|
|
print(f"Error running git diff in {repo_path}: {e}")
|
|
return None
|
|
|
|
def get_repository_info(repo_path: Path) -> dict:
|
|
"""Get information about the git repository"""
|
|
info = {
|
|
"path": repo_path,
|
|
"name": repo_path.name,
|
|
"branch": "unknown",
|
|
"remote": "unknown"
|
|
}
|
|
|
|
try:
|
|
# Get current branch
|
|
branch_process = subprocess.run(
|
|
["git", "branch", "--show-current"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=repo_path
|
|
)
|
|
if branch_process.returncode == 0:
|
|
info["branch"] = branch_process.stdout.strip()
|
|
except:
|
|
pass
|
|
|
|
try:
|
|
# Get remote origin URL
|
|
remote_process = subprocess.run(
|
|
["git", "remote", "get-url", "origin"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=repo_path
|
|
)
|
|
if remote_process.returncode == 0:
|
|
info["remote"] = remote_process.stdout.strip()
|
|
except:
|
|
pass
|
|
|
|
return info
|
|
|
|
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, max_retries: int = MAX_RETRIES) -> Optional[str]:
|
|
"""Generate a commit message based on the git diff"""
|
|
if not diff_text or diff_text.strip() == "":
|
|
return "No changes staged for commit"
|
|
|
|
read_cookie_files() # Load cookies for g4f client
|
|
|
|
# Filter sensitive data
|
|
filtered_diff = filter_sensitive_data(diff_text)
|
|
|
|
# Truncate if necessary
|
|
truncated_diff = truncate_diff(filtered_diff)
|
|
|
|
client = Client()
|
|
|
|
prompt = f"""
|
|
{truncated_diff}
|
|
```
|
|
|
|
Analyze ONLY the exact changes in this git diff and create a precise commit message.
|
|
|
|
FORMAT:
|
|
1. First line: "<type>: <summary>" (max 70 chars)
|
|
- Type: feat, fix, docs, refactor, test, etc.
|
|
- Summary must describe ONLY actual changes shown in the diff
|
|
|
|
2. Leave one blank line
|
|
|
|
3. Add sufficient bullet points to:
|
|
- Describe ALL specific changes seen in the diff
|
|
- Reference exact functions/files/components that were modified
|
|
- Do NOT mention anything not explicitly shown in the code changes
|
|
- Avoid general statements or assumptions not directly visible in diff
|
|
- Include enough points to cover all significant changes (don't limit to a specific number)
|
|
|
|
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(
|
|
prompt,
|
|
model=model,
|
|
stream=True,
|
|
)
|
|
content = []
|
|
for chunk in response:
|
|
if isinstance(chunk.choices[0].delta.content, str):
|
|
# Stop spinner and clear line
|
|
if spinner:
|
|
spinner.set()
|
|
print(" " * 50 + "\n", flush=True)
|
|
spinner = None
|
|
content.append(chunk.choices[0].delta.content)
|
|
print(chunk.choices[0].delta.content, end="", flush=True)
|
|
return "".join(content).strip("`").split("\n---\n")[0].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()
|
|
if max_retries == 1:
|
|
raise e # If no retries, raise immediately
|
|
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:
|
|
editor = subprocess.run(
|
|
["git", "config", "--get", "core.editor"],
|
|
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, repo_path: Path) -> bool:
|
|
"""Make a git commit with the provided message in specified repository"""
|
|
try:
|
|
subprocess.run(
|
|
["git", "commit", "-m", message],
|
|
check=True,
|
|
cwd=repo_path
|
|
)
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"Error making commit: {e}")
|
|
return False
|
|
|
|
def main():
|
|
"""Main function"""
|
|
try:
|
|
args = parse_arguments()
|
|
|
|
# If --list-models is specified, list available models and exit
|
|
if args.list_models:
|
|
print("Available AI models for commit message generation:")
|
|
for model in list_available_models():
|
|
print(f" - {model}")
|
|
sys.exit(0)
|
|
|
|
# Validate and get repository path
|
|
repo_path = validate_git_repository(args.repo)
|
|
repo_info = get_repository_info(repo_path)
|
|
|
|
print(f"Repository: {repo_info['name']} ({repo_path})")
|
|
print(f"Branch: {repo_info['branch']}")
|
|
if repo_info['remote'] != "unknown":
|
|
print(f"Remote: {repo_info['remote']}")
|
|
print()
|
|
|
|
print("Fetching git diff...")
|
|
diff = get_git_diff(repo_path)
|
|
|
|
if diff is None:
|
|
print("Failed to get git diff.")
|
|
sys.exit(1)
|
|
|
|
if diff.strip() == "":
|
|
print("No changes staged for commit. Stage changes with 'git add' first.")
|
|
print(f"Run this in the repository: cd {repo_path} && git add <files>")
|
|
sys.exit(0)
|
|
|
|
print(f"Using model: {args.model}")
|
|
commit_message = generate_commit_message(diff, args.model, args.max_retries)
|
|
|
|
if not commit_message:
|
|
print("Failed to generate commit message after multiple attempts.")
|
|
sys.exit(1)
|
|
|
|
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(f"\nDo you want to commit to {repo_info['name']} ({repo_info['branch']})? (y/n): ")
|
|
if user_input.lower() == 'y':
|
|
if make_commit(commit_message, repo_path):
|
|
print("Commit successful!")
|
|
else:
|
|
print("Commit failed.")
|
|
sys.exit(1)
|
|
else:
|
|
print("Commit aborted.")
|
|
|
|
except KeyboardInterrupt:
|
|
print("\nOperation cancelled by user.")
|
|
sys.exit(130) # Standard exit code for SIGINT
|
|
|
|
if __name__ == "__main__":
|
|
main()
|