TradingAgents/.claude/lib/git_operations.py

641 lines
19 KiB
Python

#!/usr/bin/env python3
"""
Git Operations Library - Consent-based automation for /auto-implement
This library provides git automation functions for the /auto-implement workflow.
All operations are consent-based with graceful degradation - if git operations
fail, the feature implementation still succeeds.
Key Features:
- Prerequisite validation (git installed, repo exists, config set)
- Automated staging and committing
- Automated push with network timeout handling
- Graceful degradation (commit succeeds even if push fails)
- Security-first (never log credentials, validate prerequisites)
Usage:
from git_operations import auto_commit_and_push
result = auto_commit_and_push(
commit_message='feat: add new feature',
branch='main',
push=True
)
if result['success']:
print(f"Committed: {result['commit_sha']}")
if result['pushed']:
print("Pushed to remote")
Date: 2025-11-04
Workflow: git_automation
Agent: implementer
Design Patterns:
See library-design-patterns skill for standardized design patterns.
See api-integration-patterns skill for standardized design patterns.
"""
import subprocess
from typing import Tuple, Dict, Any, List
def validate_git_repo() -> Tuple[bool, str]:
"""
Validate if current directory is a git repository.
Returns:
Tuple of (is_valid, error_message)
- (True, '') if valid git repository
- (False, error_message) if not a git repository or git not installed
Example:
>>> is_valid, error = validate_git_repo()
>>> if not is_valid:
... print(f"Error: {error}")
"""
try:
result = subprocess.run(
['git', 'rev-parse', '--git-dir'],
capture_output=True,
text=True,
check=True
)
return (True, '')
except FileNotFoundError:
return (False, 'git not installed')
except PermissionError:
return (False, 'permission denied')
except subprocess.CalledProcessError as e:
# Git command failed - likely not a git repository
if 'not a git repository' in e.stderr.lower():
return (False, 'not a git repository')
return (False, f'git error: {e.stderr.strip()}')
except Exception as e:
return (False, f'unexpected error: {str(e)}')
def check_git_config() -> Tuple[bool, str]:
"""
Validate that git user.name and user.email are configured.
Returns:
Tuple of (is_configured, error_message)
- (True, '') if both user.name and user.email are set
- (False, error_message) if one or both are missing
Example:
>>> is_configured, error = check_git_config()
>>> if not is_configured:
... print(f"Git config error: {error}")
"""
name_set = False
email_set = False
# Check user.name
try:
name_result = subprocess.run(
['git', 'config', 'user.name'],
capture_output=True,
text=True,
check=True
)
name = name_result.stdout.strip()
if name:
name_set = True
except subprocess.CalledProcessError:
pass # name not set
# Check user.email
try:
email_result = subprocess.run(
['git', 'config', 'user.email'],
capture_output=True,
text=True,
check=True
)
email = email_result.stdout.strip()
if email:
email_set = True
except subprocess.CalledProcessError:
pass # email not set
# Determine what's missing
if name_set and email_set:
return (True, '')
elif not name_set and not email_set:
return (False, 'git user.name not set') # Report first missing
elif not name_set:
return (False, 'git user.name not set')
else: # not email_set
return (False, 'git user.email not set')
def detect_merge_conflict() -> Tuple[bool, List[str]]:
"""
Detect if there are unmerged paths (merge conflicts).
Returns:
Tuple of (has_conflict, conflicted_files)
- (False, []) if no conflicts
- (True, ['file1.py', 'file2.py']) if conflicts exist
Example:
>>> has_conflict, files = detect_merge_conflict()
>>> if has_conflict:
... print(f"Conflicts in: {', '.join(files)}")
"""
try:
result = subprocess.run(
['git', 'status', '--porcelain'],
capture_output=True,
text=True,
check=True
)
# Parse output for merge conflict markers
# Porcelain format:
# UU = both modified
# AA = both added
# DD = both deleted
# Regular format (for test compatibility):
# "both modified:" or "both added:" or "both deleted:"
conflicted_files = []
for line in result.stdout.strip().split('\n'):
if not line:
continue
# Check porcelain format (first 2 characters)
if len(line) >= 3:
status = line[:2]
if status in ('UU', 'AA', 'DD'):
# Extract filename (after status codes and space)
filename = line[3:].strip()
if filename:
conflicted_files.append(filename)
# Also check regular format (for test compatibility)
if 'both modified:' in line or 'both added:' in line or 'both deleted:' in line:
# Extract filename after the marker
parts = line.split(':', 1)
if len(parts) >= 2:
filename = parts[1].strip()
if filename and filename not in conflicted_files:
conflicted_files.append(filename)
if conflicted_files:
return (True, conflicted_files)
return (False, [])
except Exception:
# On error, fail safe - assume no conflicts
return (False, [])
def is_detached_head() -> bool:
"""
Check if repository is in detached HEAD state.
Returns:
False if on a branch
True if in detached HEAD state or error (fail-safe)
Example:
>>> if is_detached_head():
... print("Warning: detached HEAD state")
"""
try:
result = subprocess.run(
['git', 'symbolic-ref', '-q', 'HEAD'],
capture_output=True,
check=True
)
# Returns 0 if on a branch
return False
except subprocess.CalledProcessError:
# Returns 1 if detached HEAD
return True
except Exception:
# On error, fail safe - assume detached
return True
def has_uncommitted_changes() -> bool:
"""
Check if there are uncommitted changes in working tree.
Returns:
False if working tree is clean
True if uncommitted changes exist or error (fail-safe)
Example:
>>> if not has_uncommitted_changes():
... print("Working tree clean")
"""
try:
result = subprocess.run(
['git', 'status', '--porcelain'],
capture_output=True,
text=True,
check=True
)
# Any output means changes exist
return bool(result.stdout.strip())
except Exception:
# On error, fail safe - assume changes exist
return True
def stage_all_changes() -> Tuple[bool, str]:
"""
Stage all changes in the working tree.
Returns:
Tuple of (success, error_message)
- (True, '') if staging succeeded
- (False, error_message) if staging failed
Example:
>>> success, error = stage_all_changes()
>>> if not success:
... print(f"Staging failed: {error}")
"""
try:
subprocess.run(
['git', 'add', '.'],
capture_output=True,
text=True,
check=True
)
return (True, '')
except PermissionError:
return (False, 'permission denied')
except subprocess.CalledProcessError as e:
return (False, f'git add failed: {e.stderr.strip()}')
except Exception as e:
return (False, f'unexpected error: {str(e)}')
def commit_changes(message: str) -> Tuple[bool, str, str]:
"""
Create a git commit with the given message.
Args:
message: Commit message (can be multiline)
Returns:
Tuple of (success, commit_sha, error_message)
- (True, commit_sha, '') if commit succeeded
- (False, '', error_message) if commit failed
Example:
>>> success, sha, error = commit_changes('feat: add feature')
>>> if success:
... print(f"Committed: {sha}")
"""
# Validate message
if not message or not message.strip():
return (False, '', 'commit message cannot be empty')
try:
result = subprocess.run(
['git', 'commit', '-m', message],
capture_output=True,
text=True,
check=True
)
# Parse commit SHA from output
# Format: "[branch_name commit_sha] commit message"
# Example: "[main abc1234] feat: add feature"
commit_sha = ''
stdout = result.stdout.strip()
if stdout:
# Look for pattern [branch sha]
import re
match = re.search(r'\[[\w/-]+\s+([a-f0-9]+)\]', stdout)
if match:
commit_sha = match.group(1)
return (True, commit_sha, '')
except subprocess.CalledProcessError as e:
stderr = e.stderr.strip()
# Handle "nothing to commit"
if 'nothing to commit' in stderr.lower():
return (False, '', 'nothing to commit, working tree clean')
# Handle missing git config
if 'user.name' in stderr.lower() or 'user.email' in stderr.lower():
return (False, '', 'git user.name or user.email not set')
return (False, '', f'git commit failed: {stderr}')
except Exception as e:
return (False, '', f'unexpected error: {str(e)}')
def get_remote_name() -> str:
"""
Get the name of the first git remote (usually 'origin').
Returns:
Remote name (e.g., 'origin') or empty string if no remote configured
Example:
>>> remote = get_remote_name()
>>> if not remote:
... print("No remote configured")
"""
try:
result = subprocess.run(
['git', 'remote'],
capture_output=True,
text=True,
check=True
)
# Return first line (first remote)
remotes = result.stdout.strip().split('\n')
if remotes and remotes[0]:
return remotes[0].strip()
return ''
except Exception:
return ''
def push_to_remote(
branch: str,
remote: str = 'origin',
set_upstream: bool = False,
timeout: int = 30
) -> Tuple[bool, str]:
"""
Push commits to remote repository.
Args:
branch: Branch name to push
remote: Remote name (default: 'origin')
set_upstream: Use -u flag for new branches (default: False)
timeout: Network timeout in seconds (default: 30)
Returns:
Tuple of (success, error_message)
- (True, '') if push succeeded
- (False, error_message) if push failed
Example:
>>> success, error = push_to_remote('main', 'origin')
>>> if not success:
... print(f"Push failed: {error}")
"""
try:
# Build command
cmd = ['git', 'push']
if set_upstream:
cmd.append('-u')
cmd.extend([remote, branch])
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
timeout=timeout
)
return (True, '')
except subprocess.TimeoutExpired:
return (False, 'network timeout while pushing to remote')
except subprocess.CalledProcessError as e:
stderr = e.stderr.strip()
# Parse specific errors
if 'protected branch' in stderr.lower():
return (False, 'protected branch update failed')
elif 'permission denied' in stderr.lower() or 'forbidden' in stderr.lower():
return (False, 'permission denied')
elif 'rejected' in stderr.lower():
return (False, f'push rejected: {stderr}')
else:
return (False, f'git push failed: {stderr}')
except Exception as e:
return (False, f'unexpected error: {str(e)}')
def create_feature_branch(branch_name: str) -> Tuple[bool, str, str]:
"""
Create a new feature branch.
Args:
branch_name: Name for the new branch
Returns:
Tuple of (success, branch_name, error_message)
- (True, branch_name, '') if branch created
- (False, '', error_message) if branch creation failed
Example:
>>> success, branch, error = create_feature_branch('feature/test')
>>> if success:
... print(f"Created branch: {branch}")
"""
try:
subprocess.run(
['git', 'checkout', '-b', branch_name],
capture_output=True,
text=True,
check=True
)
return (True, branch_name, '')
except subprocess.CalledProcessError as e:
stderr = e.stderr.strip()
# Parse specific errors
if 'already exists' in stderr.lower():
return (False, '', f"branch '{branch_name}' already exists")
elif 'not a valid branch name' in stderr.lower():
return (False, '', f"'{branch_name}' is not a valid branch name")
else:
return (False, '', f'git checkout failed: {stderr}')
except Exception as e:
return (False, '', f'unexpected error: {str(e)}')
def auto_commit_and_push(
commit_message: str,
branch: str,
push: bool = True
) -> Dict[str, Any]:
"""
High-level function that orchestrates the full commit-and-push workflow.
This function provides graceful degradation - if commit succeeds but push
fails, it still reports success (the commit worked).
Workflow:
1. Validate git repo
2. Check git config
3. Detect merge conflicts
4. Check for detached HEAD
5. Check for uncommitted changes
6. Stage all changes
7. Commit changes
8. Get remote name (if push requested)
9. Push to remote (if push requested)
Args:
commit_message: Commit message
branch: Branch name to push to
push: Whether to push after committing (default: True)
Returns:
Dictionary with keys:
- success (bool): Overall success (True if commit succeeded)
- commit_sha (str): Commit SHA if committed, '' otherwise
- pushed (bool): True if pushed successfully
- error (str): Error message if any, '' otherwise
Example:
>>> result = auto_commit_and_push('feat: add feature', 'main', True)
>>> if result['success']:
... print(f"Committed: {result['commit_sha']}")
... if result['pushed']:
... print("Pushed to remote")
"""
result = {
'success': False,
'commit_sha': '',
'pushed': False,
'error': ''
}
# Step 1: Validate git repository
is_valid, error = validate_git_repo()
if not is_valid:
result['error'] = error
return result
# Step 2: Check git config
is_configured, error = check_git_config()
if not is_configured:
result['error'] = error
return result
# Step 3: Detect merge conflicts
has_conflict, files = detect_merge_conflict()
if has_conflict:
result['error'] = f"merge conflict detected in: {', '.join(files)}"
return result
# Step 4: Check for detached HEAD
if is_detached_head():
result['error'] = 'repository is in detached HEAD state'
return result
# Step 5: Check for uncommitted changes
if not has_uncommitted_changes():
result['success'] = True # Not an error - just nothing to do
result['error'] = 'nothing to commit, working tree clean'
return result
# Step 6: Stage all changes
stage_success, error = stage_all_changes()
if not stage_success:
result['error'] = f'failed to stage changes: {error}'
return result
# Step 7: Commit changes
commit_success, commit_sha, error = commit_changes(commit_message)
if not commit_success:
result['error'] = error
return result
# Commit succeeded - mark as success even if push fails
result['success'] = True
result['commit_sha'] = commit_sha
# Step 8-9: Push to remote (if requested)
if push:
# Get remote name
remote = get_remote_name()
if not remote:
result['error'] = 'no remote configured'
return result
# Push to remote
push_success, error = push_to_remote(branch, remote)
if push_success:
result['pushed'] = True
else:
# Push failed, but commit succeeded - graceful degradation
result['error'] = error
return result
class GitOperations:
"""
Object-oriented wrapper for git operations functions.
Provides a class-based interface to git automation functions.
All methods are static/class methods that delegate to module functions.
"""
@staticmethod
def validate_repo() -> Tuple[bool, str]:
"""Validate if current directory is a git repository."""
return validate_git_repo()
@staticmethod
def check_config() -> Tuple[bool, str]:
"""Validate git user.name and user.email are configured."""
return check_git_config()
@staticmethod
def detect_conflicts() -> Tuple[bool, List[str]]:
"""Detect merge conflicts in repository."""
return detect_merge_conflict()
@staticmethod
def is_detached() -> bool:
"""Check if repository is in detached HEAD state."""
return is_detached_head()
@staticmethod
def has_changes() -> bool:
"""Check if repository has uncommitted changes."""
return has_uncommitted_changes()
@staticmethod
def stage_all() -> Tuple[bool, str]:
"""Stage all changes for commit."""
return stage_all_changes()
@staticmethod
def commit(message: str) -> Tuple[bool, str, str]:
"""Commit staged changes with given message."""
return commit_changes(message)
@staticmethod
def push(branch: str = 'main', remote: str = None) -> Tuple[bool, str]:
"""Push commits to remote repository."""
if remote is None:
remote = get_remote_name()
return push_to_remote(branch, remote)
@staticmethod
def auto_commit_push(
commit_message: str,
branch: str = 'main',
push: bool = True
) -> Dict[str, Any]:
"""Automated commit and push workflow."""
return auto_commit_and_push(commit_message, branch, push)