File size: 18,019 Bytes
a4b70d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
#!/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()