641 lines
19 KiB
Python
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)
|