mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-06 12:02:51 +09:00
The largest single move in the plan (#38). The entire WordPress backend leaves core for the bundled, default-installed serverkit-wordpress extension (D4). Moved (12 files → builtin-extensions/serverkit-wordpress/backend/, intra-extension imports made relative, core imports stay absolute): api/wordpress.py, api/wordpress_sites.py, api/environment_pipeline.py, wordpress_service.py (2676 lines), git_wordpress_service, wordpress_env_service, wordpress_plugin_library_service, wp_analytics/reports/security/update/vulnerability. Wiring: - Blueprints keep /api/v1/wordpress, /wordpress/projects and the /wordpress/pipelines alias (D9) via new manifest `extra_blueprints` support (3 blueprints / 4 mounts). - Flagship (D4): seeded active on every panel (fresh + upgrade), uninstallable, never deletes tracked builtin/pre-bundled files; the `wordpress` module toggle is retired (MODULES now empty) — the plugin status guard is the gate. - Loadability: the dashed package app.plugins.serverkit-wordpress is registered in-place via spec_from_file_location (no copy) — uniform across prod/dev/test. - The 11 WP test files re-pointed through app.services.wordpress_bridge; the 6 lazy core callers were re-pointed in the prior commit. - _regenerate_frontend_manifest() is a no-op under testing so the flagship boot seed can't clobber the checked-in plugins-manifest.json. Deviations (documented, matching the Email precedent): - WordPress *models* stay core — status_page/environment_activity/promotion_job hold FKs into wordpress_sites, so relocation would be a breaking data migration. - Event catalog + Fail2ban WP filter stay core as two-speed items (extension calls them via core APIs); inverting adds risk without blocking the move. - Frontend UI stays core this session (it lives in a TabGroupLayout — the tab-grouped conversion Phase 1 deliberately deferred); the extension is backend-only, so there is no route duplication. Frontend contribution is the scoped follow-up. Verified: clean collection across all 1223 tests; 199 passed / 2 skipped / 0 failed across the WP + plugin/extension + module + backup + core-caller parity set, incl. the new tests/test_wordpress_extraction.py proving the move. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
652 lines
24 KiB
Python
652 lines
24 KiB
Python
"""
|
|
Git WordPress Service
|
|
|
|
Connect Git repositories to WordPress wp-content for theme/plugin management.
|
|
Supports deployment from specific commits and creating dev environments for any revision.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import shutil
|
|
import json
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional
|
|
|
|
from app import db
|
|
from app.models.wordpress_site import WordPressSite, DatabaseSnapshot
|
|
from app.services.db_sync_service import DatabaseSyncService
|
|
from .wordpress_env_service import WordPressEnvService
|
|
|
|
|
|
class GitWordPressService:
|
|
"""Connect Git repositories to WordPress wp-content."""
|
|
|
|
DEFAULT_PATHS = ['wp-content/themes', 'wp-content/plugins']
|
|
|
|
@classmethod
|
|
def connect_repo(cls, site_id: int, repo_url: str, branch: str = 'main',
|
|
paths: List[str] = None, auto_deploy: bool = False) -> Dict:
|
|
"""
|
|
Connect a Git repository to a WordPress site.
|
|
|
|
Args:
|
|
site_id: ID of the WordPressSite
|
|
repo_url: Git repository URL (HTTPS or SSH)
|
|
branch: Branch to track (default: main)
|
|
paths: What to sync (default: ['wp-content/themes', 'wp-content/plugins'])
|
|
auto_deploy: Enable automatic deployment on push
|
|
|
|
Returns:
|
|
Dict with success status
|
|
"""
|
|
site = WordPressSite.query.get(site_id)
|
|
if not site:
|
|
return {'success': False, 'error': 'Site not found'}
|
|
|
|
if not site.application or not site.application.root_path:
|
|
return {'success': False, 'error': 'Site application path not found'}
|
|
|
|
wp_path = site.application.root_path
|
|
paths = paths or cls.DEFAULT_PATHS
|
|
|
|
try:
|
|
# Validate repository URL
|
|
if not cls._validate_repo_url(repo_url):
|
|
return {'success': False, 'error': 'Invalid repository URL'}
|
|
|
|
# Test repository access
|
|
test_result = cls._test_repo_access(repo_url)
|
|
if not test_result['success']:
|
|
return test_result
|
|
|
|
# Initialize git in wp-content if not exists
|
|
wp_content_path = os.path.join(wp_path, 'wp-content')
|
|
git_result = cls._init_git_tracking(wp_content_path, repo_url, branch)
|
|
if not git_result['success']:
|
|
return git_result
|
|
|
|
# Update site record
|
|
site.git_repo_url = repo_url
|
|
site.git_branch = branch
|
|
site.git_paths = json.dumps(paths)
|
|
site.auto_deploy = auto_deploy
|
|
|
|
db.session.commit()
|
|
|
|
return {
|
|
'success': True,
|
|
'message': f'Repository connected successfully',
|
|
'repo_url': repo_url,
|
|
'branch': branch,
|
|
'paths': paths
|
|
}
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def disconnect_repo(cls, site_id: int) -> Dict:
|
|
"""
|
|
Disconnect a Git repository from a WordPress site.
|
|
|
|
Args:
|
|
site_id: ID of the WordPressSite
|
|
|
|
Returns:
|
|
Dict with success status
|
|
"""
|
|
site = WordPressSite.query.get(site_id)
|
|
if not site:
|
|
return {'success': False, 'error': 'Site not found'}
|
|
|
|
try:
|
|
# Remove git tracking from wp-content
|
|
if site.application and site.application.root_path:
|
|
wp_content_path = os.path.join(site.application.root_path, 'wp-content')
|
|
git_dir = os.path.join(wp_content_path, '.git')
|
|
if os.path.exists(git_dir):
|
|
shutil.rmtree(git_dir)
|
|
|
|
# Clear git settings
|
|
site.git_repo_url = None
|
|
site.git_branch = None
|
|
site.git_paths = None
|
|
site.auto_deploy = False
|
|
site.last_deploy_commit = None
|
|
site.last_deploy_at = None
|
|
|
|
db.session.commit()
|
|
|
|
return {'success': True, 'message': 'Repository disconnected'}
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def deploy_from_commit(cls, site_id: int, commit_sha: str = None,
|
|
branch: str = None, create_snapshot: bool = True) -> Dict:
|
|
"""
|
|
Deploy a specific commit or branch to the WordPress site.
|
|
|
|
Args:
|
|
site_id: ID of the WordPressSite
|
|
commit_sha: Specific commit SHA to deploy (optional)
|
|
branch: Branch name to deploy latest from (optional)
|
|
create_snapshot: Create a database snapshot before deploying
|
|
|
|
Returns:
|
|
Dict with success status and deployment info
|
|
"""
|
|
site = WordPressSite.query.get(site_id)
|
|
if not site:
|
|
return {'success': False, 'error': 'Site not found'}
|
|
|
|
if not site.git_repo_url:
|
|
return {'success': False, 'error': 'No repository connected'}
|
|
|
|
if not site.application or not site.application.root_path:
|
|
return {'success': False, 'error': 'Site application path not found'}
|
|
|
|
wp_content_path = os.path.join(site.application.root_path, 'wp-content')
|
|
|
|
try:
|
|
# Create pre-deploy snapshot
|
|
if create_snapshot:
|
|
snapshot_result = DatabaseSyncService.create_snapshot(
|
|
db_name=site.db_name,
|
|
name=f"pre_deploy_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
|
tag='pre-deploy',
|
|
commit_sha=site.last_deploy_commit,
|
|
host=site.db_host,
|
|
user=site.db_user,
|
|
password=WordPressEnvService._get_db_password(site)
|
|
)
|
|
|
|
if snapshot_result['success']:
|
|
# Save snapshot to database
|
|
snapshot = DatabaseSnapshot(
|
|
site_id=site.id,
|
|
name=snapshot_result['snapshot']['name'],
|
|
tag='pre-deploy',
|
|
file_path=snapshot_result['snapshot']['file_path'],
|
|
size_bytes=snapshot_result['snapshot']['size_bytes'],
|
|
compressed=snapshot_result['snapshot']['compressed'],
|
|
commit_sha=site.last_deploy_commit,
|
|
tables_included=json.dumps(snapshot_result['snapshot'].get('tables', [])),
|
|
row_count=snapshot_result['snapshot'].get('row_count', 0),
|
|
status='completed'
|
|
)
|
|
db.session.add(snapshot)
|
|
|
|
# Fetch latest from remote
|
|
fetch_result = cls._run_git(wp_content_path, ['fetch', 'origin'])
|
|
if not fetch_result['success']:
|
|
return fetch_result
|
|
|
|
# Determine what to checkout
|
|
if commit_sha:
|
|
target = commit_sha
|
|
elif branch:
|
|
target = f'origin/{branch}'
|
|
else:
|
|
target = f'origin/{site.git_branch}'
|
|
|
|
# Checkout the target
|
|
checkout_result = cls._run_git(wp_content_path, ['checkout', target, '--force'])
|
|
if not checkout_result['success']:
|
|
return checkout_result
|
|
|
|
# Get the actual commit SHA we deployed
|
|
rev_result = cls._run_git(wp_content_path, ['rev-parse', 'HEAD'])
|
|
deployed_sha = rev_result['output'].strip() if rev_result['success'] else commit_sha
|
|
|
|
# Get commit message
|
|
msg_result = cls._run_git(wp_content_path, ['log', '-1', '--format=%s'])
|
|
commit_message = msg_result['output'].strip() if msg_result['success'] else ''
|
|
|
|
# Update site record
|
|
site.last_deploy_commit = deployed_sha
|
|
site.last_deploy_at = datetime.utcnow()
|
|
|
|
db.session.commit()
|
|
|
|
from app.services.event_service import EventService
|
|
EventService.emit_wp('wordpress.deployed', site, commit_sha=deployed_sha, commit_message=commit_message)
|
|
if create_snapshot and snapshot_result.get('success'):
|
|
EventService.emit_wp('wordpress.backup_completed', site, tag='pre-deploy')
|
|
|
|
return {
|
|
'success': True,
|
|
'message': 'Deployment successful',
|
|
'commit_sha': deployed_sha,
|
|
'commit_message': commit_message,
|
|
'pre_deploy_snapshot': snapshot_result.get('snapshot', {}).get('file_path') if create_snapshot else None
|
|
}
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
try:
|
|
from app.services.event_service import EventService
|
|
EventService.emit_wp('wordpress.deploy_failed', site, error=str(e))
|
|
except Exception:
|
|
pass
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def create_dev_for_commit(cls, production_site_id: int, commit_sha: str,
|
|
config: Dict, user_id: int) -> Dict:
|
|
"""
|
|
Create a development environment for a specific commit.
|
|
|
|
This creates a full dev environment with:
|
|
- Database snapshot from nearest point in time (or current)
|
|
- Code checked out at the specified commit
|
|
- URL search-replace applied
|
|
|
|
Args:
|
|
production_site_id: ID of the production WordPressSite
|
|
commit_sha: Git commit SHA to create dev for
|
|
config: Environment configuration (name, domain, etc.)
|
|
user_id: User ID creating the environment
|
|
|
|
Returns:
|
|
Dict with success status and dev environment info
|
|
"""
|
|
prod_site = WordPressSite.query.get(production_site_id)
|
|
if not prod_site:
|
|
return {'success': False, 'error': 'Production site not found'}
|
|
|
|
if not prod_site.is_production:
|
|
return {'success': False, 'error': 'Source site is not a production site'}
|
|
|
|
if not prod_site.git_repo_url:
|
|
return {'success': False, 'error': 'No repository connected to production site'}
|
|
|
|
try:
|
|
# Get short SHA for naming
|
|
short_sha = commit_sha[:7]
|
|
|
|
# Set defaults in config
|
|
if not config.get('name'):
|
|
config['name'] = f"{prod_site.application.name} @ {short_sha}"
|
|
|
|
if not config.get('domain'):
|
|
# Try to create subdomain from production domain
|
|
if prod_site.application.domains:
|
|
prod_domain = prod_site.application.domains[0].domain
|
|
config['domain'] = f"{short_sha}.dev.{prod_domain}"
|
|
|
|
# Create the environment using WordPressEnvService
|
|
env_result = WordPressEnvService.create_environment(
|
|
production_site_id=production_site_id,
|
|
env_type='development',
|
|
config=config,
|
|
user_id=user_id
|
|
)
|
|
|
|
if not env_result['success']:
|
|
return env_result
|
|
|
|
env_site_id = env_result['environment']['id']
|
|
env_site = WordPressSite.query.get(env_site_id)
|
|
|
|
# Checkout the specific commit
|
|
if env_site and env_site.application:
|
|
wp_content_path = os.path.join(env_site.application.root_path, 'wp-content')
|
|
|
|
# Initialize git in the new environment
|
|
init_result = cls._init_git_tracking(
|
|
wp_content_path,
|
|
prod_site.git_repo_url,
|
|
prod_site.git_branch
|
|
)
|
|
|
|
if init_result['success']:
|
|
# Fetch and checkout specific commit
|
|
cls._run_git(wp_content_path, ['fetch', 'origin'])
|
|
checkout_result = cls._run_git(wp_content_path, ['checkout', commit_sha, '--force'])
|
|
|
|
if checkout_result['success']:
|
|
env_site.last_deploy_commit = commit_sha
|
|
env_site.last_deploy_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
return {
|
|
'success': True,
|
|
'message': f'Development environment created for commit {short_sha}',
|
|
'environment': env_site.to_dict() if env_site else env_result['environment'],
|
|
'commit_sha': commit_sha,
|
|
'url': f"https://{config.get('domain')}" if config.get('domain') else None
|
|
}
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def get_recent_commits(cls, site_id: int, limit: int = 20) -> Dict:
|
|
"""
|
|
Get recent commits from the repository.
|
|
|
|
Args:
|
|
site_id: ID of the WordPressSite
|
|
limit: Maximum number of commits to return
|
|
|
|
Returns:
|
|
Dict with list of commits
|
|
"""
|
|
site = WordPressSite.query.get(site_id)
|
|
if not site:
|
|
return {'success': False, 'error': 'Site not found'}
|
|
|
|
if not site.git_repo_url:
|
|
return {'success': False, 'error': 'No repository connected'}
|
|
|
|
if not site.application or not site.application.root_path:
|
|
return {'success': False, 'error': 'Site application path not found'}
|
|
|
|
wp_content_path = os.path.join(site.application.root_path, 'wp-content')
|
|
|
|
try:
|
|
# Fetch latest
|
|
cls._run_git(wp_content_path, ['fetch', 'origin'])
|
|
|
|
# Get commit log
|
|
log_format = '%H|%h|%s|%an|%ae|%ai'
|
|
log_result = cls._run_git(wp_content_path, [
|
|
'log',
|
|
f'origin/{site.git_branch}',
|
|
f'-{limit}',
|
|
f'--format={log_format}'
|
|
])
|
|
|
|
if not log_result['success']:
|
|
return log_result
|
|
|
|
commits = []
|
|
for line in log_result['output'].strip().split('\n'):
|
|
if not line:
|
|
continue
|
|
parts = line.split('|')
|
|
if len(parts) >= 6:
|
|
commits.append({
|
|
'sha': parts[0],
|
|
'short_sha': parts[1],
|
|
'message': parts[2],
|
|
'author_name': parts[3],
|
|
'author_email': parts[4],
|
|
'date': parts[5],
|
|
'is_deployed': parts[0] == site.last_deploy_commit
|
|
})
|
|
|
|
return {
|
|
'success': True,
|
|
'commits': commits,
|
|
'current_deploy': site.last_deploy_commit
|
|
}
|
|
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def list_branches(cls, site_id: int) -> Dict:
|
|
"""
|
|
List remote branches for a site's connected repository.
|
|
|
|
Args:
|
|
site_id: ID of the WordPressSite
|
|
|
|
Returns:
|
|
Dict with list of branches and their latest commit info
|
|
"""
|
|
site = WordPressSite.query.get(site_id)
|
|
if not site:
|
|
return {'success': False, 'error': 'Site not found'}
|
|
|
|
if not site.git_repo_url:
|
|
return {'success': False, 'error': 'No repository connected'}
|
|
|
|
if not site.application or not site.application.root_path:
|
|
return {'success': False, 'error': 'Site application path not found'}
|
|
|
|
wp_content_path = os.path.join(site.application.root_path, 'wp-content')
|
|
git_dir = os.path.join(wp_content_path, '.git')
|
|
|
|
try:
|
|
if os.path.exists(git_dir):
|
|
# Fetch latest from remote
|
|
cls._run_git(wp_content_path, ['fetch', '--prune', 'origin'])
|
|
|
|
# List remote branches with latest commit info
|
|
log_format = '%(refname:short)|%(objectname:short)|%(subject)|%(authorname)|%(creatordate:iso8601)'
|
|
result = cls._run_git(wp_content_path, [
|
|
'for-each-ref',
|
|
'--sort=-creatordate',
|
|
f'--format={log_format}',
|
|
'refs/remotes/origin/'
|
|
])
|
|
else:
|
|
# No local clone — use ls-remote to list branches
|
|
result = subprocess.run(
|
|
['git', 'ls-remote', '--heads', site.git_repo_url],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
if result.returncode != 0:
|
|
return {'success': False, 'error': f'Failed to list branches: {result.stderr}'}
|
|
|
|
branches = []
|
|
for line in result.stdout.strip().split('\n'):
|
|
if not line:
|
|
continue
|
|
sha, ref = line.split('\t')
|
|
branch_name = ref.replace('refs/heads/', '')
|
|
branches.append({
|
|
'name': branch_name,
|
|
'short_sha': sha[:7],
|
|
'message': '',
|
|
'author': '',
|
|
'date': '',
|
|
'is_current': branch_name == site.git_branch,
|
|
})
|
|
return {'success': True, 'branches': branches}
|
|
|
|
if not result['success']:
|
|
return result
|
|
|
|
branches = []
|
|
existing_multidevs = set()
|
|
for env in site.environments:
|
|
if env.environment_type == 'multidev' and env.multidev_branch:
|
|
existing_multidevs.add(env.multidev_branch)
|
|
|
|
for line in result['output'].strip().split('\n'):
|
|
if not line:
|
|
continue
|
|
parts = line.split('|')
|
|
if len(parts) < 5:
|
|
continue
|
|
|
|
ref_name = parts[0]
|
|
# Strip 'origin/' prefix
|
|
branch_name = ref_name.replace('origin/', '', 1)
|
|
|
|
# Skip HEAD pointer
|
|
if branch_name == 'HEAD':
|
|
continue
|
|
|
|
branches.append({
|
|
'name': branch_name,
|
|
'short_sha': parts[1],
|
|
'message': parts[2],
|
|
'author': parts[3],
|
|
'date': parts[4],
|
|
'is_current': branch_name == site.git_branch,
|
|
'has_multidev': branch_name in existing_multidevs,
|
|
})
|
|
|
|
return {
|
|
'success': True,
|
|
'branches': branches,
|
|
'current_branch': site.git_branch,
|
|
}
|
|
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def rollback_to_commit(cls, site_id: int, commit_sha: str) -> Dict:
|
|
"""
|
|
Rollback a site to a previous commit.
|
|
|
|
Args:
|
|
site_id: ID of the WordPressSite
|
|
commit_sha: Commit SHA to rollback to
|
|
|
|
Returns:
|
|
Dict with success status
|
|
"""
|
|
# This is essentially the same as deploy_from_commit
|
|
return cls.deploy_from_commit(site_id, commit_sha=commit_sha, create_snapshot=True)
|
|
|
|
@classmethod
|
|
def get_git_status(cls, site_id: int) -> Dict:
|
|
"""
|
|
Get the Git status for a site.
|
|
|
|
Args:
|
|
site_id: ID of the WordPressSite
|
|
|
|
Returns:
|
|
Dict with Git status information
|
|
"""
|
|
site = WordPressSite.query.get(site_id)
|
|
if not site:
|
|
return {'success': False, 'error': 'Site not found'}
|
|
|
|
result = {
|
|
'success': True,
|
|
'connected': bool(site.git_repo_url),
|
|
'repo_url': site.git_repo_url,
|
|
'branch': site.git_branch,
|
|
'auto_deploy': site.auto_deploy,
|
|
'last_deploy_commit': site.last_deploy_commit,
|
|
'last_deploy_at': site.last_deploy_at.isoformat() if site.last_deploy_at else None,
|
|
'paths': json.loads(site.git_paths) if site.git_paths else None
|
|
}
|
|
|
|
if site.git_repo_url and site.application and site.application.root_path:
|
|
wp_content_path = os.path.join(site.application.root_path, 'wp-content')
|
|
|
|
# Check if there are uncommitted changes
|
|
status_result = cls._run_git(wp_content_path, ['status', '--porcelain'])
|
|
if status_result['success']:
|
|
result['has_local_changes'] = bool(status_result['output'].strip())
|
|
|
|
# Check if behind remote
|
|
cls._run_git(wp_content_path, ['fetch', 'origin'])
|
|
behind_result = cls._run_git(wp_content_path, [
|
|
'rev-list',
|
|
'--count',
|
|
f'HEAD..origin/{site.git_branch}'
|
|
])
|
|
if behind_result['success']:
|
|
result['commits_behind'] = int(behind_result['output'].strip() or 0)
|
|
|
|
return result
|
|
|
|
@classmethod
|
|
def _validate_repo_url(cls, url: str) -> bool:
|
|
"""Validate a Git repository URL."""
|
|
if not url:
|
|
return False
|
|
|
|
# Accept HTTPS URLs
|
|
if url.startswith('https://'):
|
|
return True
|
|
|
|
# Accept SSH URLs
|
|
if url.startswith('git@') or url.startswith('ssh://'):
|
|
return True
|
|
|
|
return False
|
|
|
|
@classmethod
|
|
def _test_repo_access(cls, repo_url: str) -> Dict:
|
|
"""Test if we can access the repository."""
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'ls-remote', '--exit-code', repo_url],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return {'success': True}
|
|
else:
|
|
return {
|
|
'success': False,
|
|
'error': f'Cannot access repository: {result.stderr or "Authentication failed or repository not found"}'
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {'success': False, 'error': 'Connection to repository timed out'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def _init_git_tracking(cls, path: str, repo_url: str, branch: str) -> Dict:
|
|
"""Initialize Git tracking in a directory."""
|
|
try:
|
|
git_dir = os.path.join(path, '.git')
|
|
|
|
if os.path.exists(git_dir):
|
|
# Already initialized, just update remote
|
|
cls._run_git(path, ['remote', 'set-url', 'origin', repo_url])
|
|
else:
|
|
# Initialize new repo
|
|
init_result = cls._run_git(path, ['init'])
|
|
if not init_result['success']:
|
|
return init_result
|
|
|
|
# Add remote
|
|
remote_result = cls._run_git(path, ['remote', 'add', 'origin', repo_url])
|
|
if not remote_result['success']:
|
|
return remote_result
|
|
|
|
# Fetch from remote
|
|
fetch_result = cls._run_git(path, ['fetch', 'origin'])
|
|
if not fetch_result['success']:
|
|
return fetch_result
|
|
|
|
return {'success': True}
|
|
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def _run_git(cls, cwd: str, args: List[str]) -> Dict:
|
|
"""Run a Git command."""
|
|
try:
|
|
cmd = ['git'] + args
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=cwd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120
|
|
)
|
|
|
|
return {
|
|
'success': result.returncode == 0,
|
|
'output': result.stdout,
|
|
'error': result.stderr if result.returncode != 0 else None
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {'success': False, 'error': 'Git command timed out'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|