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

1"""Template backup manager (SPEC-INIT-003 v0.3.0). 

2 

3Creates and manages backups to protect user data during template updates. 

4""" 

5 

6from __future__ import annotations 

7 

8import shutil 

9from pathlib import Path 

10 

11 

12class TemplateBackup: 

13 """Create and manage template backups.""" 

14 

15 # Paths excluded from backups (protect user data) 

16 BACKUP_EXCLUDE_DIRS = [ 

17 "specs", # User SPEC documents 

18 "reports", # User reports 

19 ] 

20 

21 def __init__(self, target_path: Path) -> None: 

22 """Initialize the backup manager. 

23 

24 Args: 

25 target_path: Project path (absolute). 

26 """ 

27 self.target_path = target_path.resolve() 

28 

29 @property 

30 def backup_dir(self) -> Path: 

31 """Get the backup directory path. 

32 

33 Returns: 

34 Path to .moai-backups directory. 

35 """ 

36 return self.target_path / ".moai-backups" 

37 

38 def has_existing_files(self) -> bool: 

39 """Check whether backup-worthy files already exist. 

40 

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 ) 

48 

49 def create_backup(self) -> Path: 

50 """Create a single backup (always at .moai-backups/backup/). 

51 

52 Existing backups are overwritten to maintain only one backup copy. 

53 

54 Returns: 

55 Backup path (always .moai-backups/backup/). 

56 """ 

57 backup_path = self.target_path / ".moai-backups" / "backup" 

58 

59 # Remove existing backup if present 

60 if backup_path.exists(): 

61 shutil.rmtree(backup_path) 

62 

63 backup_path.mkdir(parents=True, exist_ok=True) 

64 

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 

70 

71 dst = backup_path / item 

72 

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) 

80 

81 return backup_path 

82 

83 def _copy_exclude_protected(self, src: Path, dst: Path) -> None: 

84 """Copy backup content while excluding protected paths. 

85 

86 Args: 

87 src: Source directory. 

88 dst: Destination directory. 

89 """ 

90 dst.mkdir(parents=True, exist_ok=True) 

91 

92 for item in src.rglob("*"): 

93 rel_path = item.relative_to(src) 

94 rel_path_str = str(rel_path) 

95 

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 

102 

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) 

109 

110 def restore_backup(self, backup_path: Path | None = None) -> None: 

111 """Restore project files from backup. 

112 

113 Restores .moai, .claude, .github directories and CLAUDE.md file 

114 from a backup created by create_backup(). 

115 

116 Args: 

117 backup_path: Backup path to restore from. 

118 Defaults to .moai-backups/backup/ 

119 

120 Raises: 

121 FileNotFoundError: When backup_path doesn't exist. 

122 """ 

123 if backup_path is None: 

124 backup_path = self.backup_dir / "backup" 

125 

126 if not backup_path.exists(): 

127 raise FileNotFoundError(f"Backup not found: {backup_path}") 

128 

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 

133 

134 # Skip if not in backup 

135 if not src.exists(): 

136 continue 

137 

138 # Remove current version 

139 if dst.exists(): 

140 if dst.is_dir(): 

141 shutil.rmtree(dst) 

142 else: 

143 dst.unlink() 

144 

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)