Coverage for src / moai_adk / core / template / backup.py: 20.69%
58 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1"""Template backup manager (SPEC-INIT-003 v0.3.0).
3Creates and manages backups to protect user data during template updates.
4"""
6from __future__ import annotations
8import shutil
9from pathlib import Path
12class TemplateBackup:
13 """Create and manage template backups."""
15 # Paths excluded from backups (protect user data)
16 BACKUP_EXCLUDE_DIRS = [
17 "specs", # User SPEC documents
18 "reports", # User reports
19 ]
21 def __init__(self, target_path: Path) -> None:
22 """Initialize the backup manager.
24 Args:
25 target_path: Project path (absolute).
26 """
27 self.target_path = target_path.resolve()
29 @property
30 def backup_dir(self) -> Path:
31 """Get the backup directory path.
33 Returns:
34 Path to .moai-backups directory.
35 """
36 return self.target_path / ".moai-backups"
38 def has_existing_files(self) -> bool:
39 """Check whether backup-worthy files already exist.
41 Returns:
42 True when any tracked file exists.
43 """
44 return any(
45 (self.target_path / item).exists()
46 for item in [".moai", ".claude", ".github", "CLAUDE.md"]
47 )
49 def create_backup(self) -> Path:
50 """Create a single backup (always at .moai-backups/backup/).
52 Existing backups are overwritten to maintain only one backup copy.
54 Returns:
55 Backup path (always .moai-backups/backup/).
56 """
57 backup_path = self.target_path / ".moai-backups" / "backup"
59 # Remove existing backup if present
60 if backup_path.exists():
61 shutil.rmtree(backup_path)
63 backup_path.mkdir(parents=True, exist_ok=True)
65 # Copy backup targets
66 for item in [".moai", ".claude", ".github", "CLAUDE.md"]:
67 src = self.target_path / item
68 if not src.exists():
69 continue
71 dst = backup_path / item
73 if item == ".moai":
74 # Copy while skipping protected paths
75 self._copy_exclude_protected(src, dst)
76 elif src.is_dir():
77 shutil.copytree(src, dst, dirs_exist_ok=True)
78 else:
79 shutil.copy2(src, dst)
81 return backup_path
83 def _copy_exclude_protected(self, src: Path, dst: Path) -> None:
84 """Copy backup content while excluding protected paths.
86 Args:
87 src: Source directory.
88 dst: Destination directory.
89 """
90 dst.mkdir(parents=True, exist_ok=True)
92 for item in src.rglob("*"):
93 rel_path = item.relative_to(src)
94 rel_path_str = str(rel_path)
96 # Skip excluded paths
97 if any(
98 rel_path_str.startswith(exclude_dir)
99 for exclude_dir in self.BACKUP_EXCLUDE_DIRS
100 ):
101 continue
103 dst_item = dst / rel_path
104 if item.is_file():
105 dst_item.parent.mkdir(parents=True, exist_ok=True)
106 shutil.copy2(item, dst_item)
107 elif item.is_dir():
108 dst_item.mkdir(parents=True, exist_ok=True)
110 def restore_backup(self, backup_path: Path | None = None) -> None:
111 """Restore project files from backup.
113 Restores .moai, .claude, .github directories and CLAUDE.md file
114 from a backup created by create_backup().
116 Args:
117 backup_path: Backup path to restore from.
118 Defaults to .moai-backups/backup/
120 Raises:
121 FileNotFoundError: When backup_path doesn't exist.
122 """
123 if backup_path is None:
124 backup_path = self.backup_dir / "backup"
126 if not backup_path.exists():
127 raise FileNotFoundError(f"Backup not found: {backup_path}")
129 # Restore each item from backup
130 for item in [".moai", ".claude", ".github", "CLAUDE.md"]:
131 src = backup_path / item
132 dst = self.target_path / item
134 # Skip if not in backup
135 if not src.exists():
136 continue
138 # Remove current version
139 if dst.exists():
140 if dst.is_dir():
141 shutil.rmtree(dst)
142 else:
143 dst.unlink()
145 # Restore from backup
146 if src.is_dir():
147 shutil.copytree(src, dst, dirs_exist_ok=True)
148 else:
149 shutil.copy2(src, dst)