372 lines
13 KiB
Python
372 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Copy System - Structure-preserving file copying for installation
|
|
|
|
This module provides intelligent file copying that preserves directory structure,
|
|
handles permissions correctly, and provides progress reporting.
|
|
|
|
Key Features:
|
|
- Directory structure preservation (lib/foo.py → .claude/lib/foo.py)
|
|
- Executable permissions for scripts (scripts/*.py get +x)
|
|
- Progress reporting with callbacks
|
|
- Error handling with optional continuation
|
|
- Timestamp preservation
|
|
- Rollback support
|
|
|
|
Usage:
|
|
from copy_system import CopySystem
|
|
|
|
# Basic copy
|
|
copier = CopySystem(source_dir, dest_dir)
|
|
result = copier.copy_all()
|
|
|
|
# Copy with progress
|
|
def progress(current, total, file_path):
|
|
print(f"[{current}/{total}] {file_path}")
|
|
|
|
copier.copy_all(progress_callback=progress)
|
|
|
|
Date: 2025-11-17
|
|
Issue: GitHub #80 (Bootstrap overhaul - 100% file coverage)
|
|
Agent: implementer
|
|
|
|
Design Patterns:
|
|
See library-design-patterns skill for standardized design patterns.
|
|
"""
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional, Callable
|
|
|
|
# Security utilities for path validation and audit logging
|
|
try:
|
|
from plugins.autonomous_dev.lib.security_utils import validate_path, audit_log
|
|
except ImportError:
|
|
from security_utils import validate_path, audit_log
|
|
|
|
|
|
class CopyError(Exception):
|
|
"""Exception raised during copy operations."""
|
|
pass
|
|
|
|
|
|
class CopySystem:
|
|
"""Intelligent file copying with structure preservation.
|
|
|
|
This class is stateless - source and dest are provided per operation,
|
|
not stored in the constructor. This allows one instance to handle
|
|
multiple copy operations with different sources/destinations.
|
|
|
|
Attributes:
|
|
source: Source directory path
|
|
dest: Destination directory path
|
|
|
|
Examples:
|
|
>>> copier = CopySystem(plugin_dir, project_dir / ".claude")
|
|
>>> result = copier.copy_all()
|
|
>>> print(f"Copied {result['files_copied']} files")
|
|
"""
|
|
|
|
def __init__(self, source: Path, dest: Path):
|
|
"""Initialize copy system with security validation.
|
|
|
|
Args:
|
|
source: Source directory path
|
|
dest: Destination directory path
|
|
|
|
Raises:
|
|
ValueError: If path validation fails (path traversal, symlink)
|
|
"""
|
|
# Validate source path (prevents CWE-22, CWE-59)
|
|
self.source = validate_path(
|
|
Path(source).resolve(),
|
|
purpose="source directory",
|
|
allow_missing=False
|
|
)
|
|
|
|
# Validate destination path (can be missing, will be created)
|
|
self.dest = validate_path(
|
|
Path(dest).resolve(),
|
|
purpose="destination directory",
|
|
allow_missing=True
|
|
)
|
|
|
|
# Audit log initialization
|
|
audit_log("copy_system", "initialized", {
|
|
"source": str(self.source),
|
|
"dest": str(self.dest)
|
|
})
|
|
|
|
def copy_all(
|
|
self,
|
|
files: Optional[List[Path]] = None,
|
|
overwrite: bool = True,
|
|
preserve_timestamps: bool = True,
|
|
show_progress: bool = False,
|
|
progress_callback: Optional[Callable[[int, int, str, str], None]] = None,
|
|
continue_on_error: bool = False,
|
|
protected_files: Optional[List[str]] = None,
|
|
protected_patterns: Optional[List[str]] = None,
|
|
backup_conflicts: bool = False,
|
|
backup_timestamp: bool = False,
|
|
conflict_strategy: str = "skip"
|
|
) -> Dict[str, Any]:
|
|
"""Copy all files while preserving directory structure.
|
|
|
|
Args:
|
|
files: List of files to copy (absolute paths). If None, copies all files.
|
|
overwrite: Allow overwriting existing files (default: True)
|
|
preserve_timestamps: Preserve file modification times (default: True)
|
|
show_progress: Display progress to stdout (default: False)
|
|
progress_callback: Callback function(current, total, file_path, action)
|
|
continue_on_error: Continue copying on errors (default: False)
|
|
protected_files: List of protected file paths (relative) to skip
|
|
protected_patterns: List of glob patterns for protected files
|
|
backup_conflicts: Create backups for conflicting files
|
|
backup_timestamp: Add timestamp to backup filenames
|
|
conflict_strategy: Strategy for conflicts (skip, overwrite, backup)
|
|
|
|
Returns:
|
|
Dictionary with copy results:
|
|
{
|
|
"files_copied": 123,
|
|
"files_skipped": 5,
|
|
"files_backed_up": 2,
|
|
"errors": 0,
|
|
"error_list": [],
|
|
"skipped_files": [],
|
|
"backed_up_files": []
|
|
}
|
|
|
|
Raises:
|
|
CopyError: If source doesn't exist or overwrite=False and file exists
|
|
"""
|
|
# Validate source exists
|
|
if not self.source.exists():
|
|
raise CopyError(
|
|
f"Source directory not found: {self.source}\n"
|
|
f"Expected structure: plugins/autonomous-dev/"
|
|
)
|
|
|
|
# Discover files if not provided
|
|
if files is None:
|
|
from plugins.autonomous_dev.lib.file_discovery import FileDiscovery
|
|
discovery = FileDiscovery(self.source)
|
|
files = discovery.discover_all_files()
|
|
|
|
# Create destination directory
|
|
self.dest.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Initialize counters and lists
|
|
files_copied = 0
|
|
files_skipped = 0
|
|
files_backed_up = 0
|
|
errors = []
|
|
skipped_files = []
|
|
backed_up_files = []
|
|
|
|
# Normalize protected files list
|
|
protected_set = set(protected_files or [])
|
|
protected_patterns_list = protected_patterns or []
|
|
|
|
# Import fnmatch for pattern matching
|
|
import fnmatch
|
|
from datetime import datetime
|
|
|
|
for idx, file_path in enumerate(files, 1):
|
|
try:
|
|
# Get relative path
|
|
relative = file_path.relative_to(self.source)
|
|
relative_str = str(relative).replace("\\", "/")
|
|
dest_path = self.dest / relative
|
|
|
|
# Check if file is protected
|
|
is_protected = False
|
|
if relative_str in protected_set:
|
|
is_protected = True
|
|
else:
|
|
# Check patterns
|
|
for pattern in protected_patterns_list:
|
|
if fnmatch.fnmatch(relative_str, pattern):
|
|
is_protected = True
|
|
break
|
|
|
|
# Handle protected files
|
|
if is_protected and dest_path.exists():
|
|
files_skipped += 1
|
|
skipped_files.append(relative_str)
|
|
|
|
# Progress reporting for skipped files
|
|
if progress_callback:
|
|
progress_callback(idx, len(files), relative_str, "skipped")
|
|
|
|
if show_progress:
|
|
percentage = (idx / len(files)) * 100
|
|
print(f"[{idx}/{len(files)}] Skipping {relative} (protected)... ({percentage:.0f}%)")
|
|
|
|
continue
|
|
|
|
# Handle conflicts (file exists but not protected)
|
|
if dest_path.exists():
|
|
# Apply conflict strategy
|
|
if conflict_strategy == "skip":
|
|
files_skipped += 1
|
|
skipped_files.append(relative_str)
|
|
|
|
if progress_callback:
|
|
progress_callback(idx, len(files), relative_str, "skipped")
|
|
|
|
continue
|
|
|
|
elif conflict_strategy == "backup" or backup_conflicts:
|
|
# Create backup
|
|
if backup_timestamp:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
backup_path = dest_path.parent / f"{dest_path.name}.backup.{timestamp}"
|
|
else:
|
|
backup_path = dest_path.parent / f"{dest_path.name}.backup"
|
|
|
|
# Handle backup name collision
|
|
counter = 1
|
|
while backup_path.exists():
|
|
if backup_timestamp:
|
|
backup_path = dest_path.parent / f"{dest_path.name}.backup.{timestamp}.{counter}"
|
|
else:
|
|
backup_path = dest_path.parent / f"{dest_path.name}.backup.{counter}"
|
|
counter += 1
|
|
|
|
# Create backup (preserve permissions)
|
|
shutil.copy2(dest_path, backup_path)
|
|
files_backed_up += 1
|
|
backed_up_files.append(relative_str)
|
|
|
|
if progress_callback:
|
|
progress_callback(idx, len(files), relative_str, "backed_up")
|
|
|
|
elif conflict_strategy == "overwrite":
|
|
# Will be overwritten below
|
|
pass
|
|
|
|
else:
|
|
# Check overwrite flag
|
|
if not overwrite:
|
|
raise CopyError(
|
|
f"File already exists: {dest_path}\n"
|
|
f"Use overwrite=True to replace existing files"
|
|
)
|
|
|
|
# Create parent directories
|
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Security: Validate file path before copy (prevents CWE-22)
|
|
validate_path(file_path, purpose="plugin file")
|
|
|
|
# Copy file without following symlinks (prevents CWE-59)
|
|
shutil.copy2(file_path, dest_path, follow_symlinks=False)
|
|
|
|
# Set permissions
|
|
is_script = self._is_script(file_path)
|
|
self._set_permissions(dest_path, file_path, is_script)
|
|
|
|
# Preserve timestamps if requested
|
|
if not preserve_timestamps:
|
|
# Touch file to update timestamp
|
|
dest_path.touch()
|
|
|
|
files_copied += 1
|
|
|
|
# Progress reporting
|
|
if progress_callback:
|
|
progress_callback(idx, len(files), relative_str, "copied")
|
|
|
|
if show_progress:
|
|
percentage = (idx / len(files)) * 100
|
|
print(f"[{idx}/{len(files)}] Copying {relative}... ({percentage:.0f}%)")
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error copying {file_path}: {e}"
|
|
errors.append(error_msg)
|
|
|
|
if not continue_on_error:
|
|
raise CopyError(error_msg)
|
|
|
|
return {
|
|
"files_copied": files_copied,
|
|
"files_skipped": files_skipped,
|
|
"files_backed_up": files_backed_up,
|
|
"errors": len(errors),
|
|
"error_list": errors,
|
|
"skipped_files": skipped_files,
|
|
"backed_up_files": backed_up_files
|
|
}
|
|
|
|
def _is_script(self, file_path: Path) -> bool:
|
|
"""Check if file is a script (should be executable).
|
|
|
|
Args:
|
|
file_path: Path to check
|
|
|
|
Returns:
|
|
True if file should be executable
|
|
"""
|
|
# Scripts directory
|
|
parts = file_path.relative_to(self.source).parts
|
|
if len(parts) > 0 and parts[0] == "scripts":
|
|
return file_path.suffix == ".py"
|
|
|
|
# Files with shebang
|
|
try:
|
|
with open(file_path, "rb") as f:
|
|
first_line = f.readline()
|
|
return first_line.startswith(b"#!")
|
|
except:
|
|
return False
|
|
|
|
def _set_permissions(self, dest_path: Path, source_path: Path, is_script: bool) -> None:
|
|
"""Set appropriate file permissions.
|
|
|
|
Args:
|
|
dest_path: Destination file path
|
|
source_path: Source file path
|
|
is_script: Whether file is a script (should be executable)
|
|
"""
|
|
if is_script:
|
|
# Scripts: rwxr-xr-x (0o755)
|
|
dest_path.chmod(0o755)
|
|
else:
|
|
# Copy permissions from source
|
|
source_mode = source_path.stat().st_mode
|
|
dest_path.chmod(source_mode)
|
|
|
|
|
|
def rollback(backup_dir: Path, dest_dir: Path) -> bool:
|
|
"""Rollback installation by restoring from backup.
|
|
|
|
Args:
|
|
backup_dir: Path to backup directory
|
|
dest_dir: Path to destination directory to restore
|
|
|
|
Returns:
|
|
True if rollback successful, False otherwise
|
|
|
|
Examples:
|
|
>>> success = rollback(backup_dir, project_dir / ".claude")
|
|
"""
|
|
try:
|
|
# Check if backup exists before removing destination
|
|
if not backup_dir.exists():
|
|
# No backup to restore - don't modify destination
|
|
return False
|
|
|
|
# Remove current installation
|
|
if dest_dir.exists():
|
|
shutil.rmtree(dest_dir)
|
|
|
|
# Restore from backup
|
|
shutil.copytree(backup_dir, dest_dir)
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Rollback failed: {e}")
|
|
return False
|