Coverage for src/retemplar/core.py: 100%

67 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-30 17:51 -0500

1# src/retemplar/core.py 

2"""Core retemplar operations (MVP).""" 

3 

4from pathlib import Path 

5 

6from retemplar.lockfile import LockfileManager, LockfileNotFoundError 

7from retemplar.logging import RetemplarError, get_logger 

8from retemplar.schema import RetemplarLock, parse_template_ref 

9from retemplar.utils import fs_utils, merge_utils 

10from retemplar.utils.apply_utils import apply_file_changes 

11from retemplar.utils.plan_utils import plan_file_changes 

12 

13logger = get_logger(__name__) 

14 

15 

16class RetemplarCore: 

17 """Core orchestrator for retemplar operations (refactored MVP).""" 

18 

19 repo_path: Path 

20 

21 def __init__(self, repo_path: Path): 

22 self.repo_path = repo_path 

23 

24 def adopt_template( 

25 self, 

26 lock: RetemplarLock, 

27 dry_run: bool = False, 

28 ) -> dict[str, str | list | bool]: 

29 """Create initial .retemplar.lock (no baseline yet).""" 

30 logger.debug( 

31 'adopt_template_started', 

32 template_ref=lock.template_ref, 

33 dry_run=dry_run, 

34 managed_paths_count=len(lock.managed_paths), 

35 ) 

36 

37 with LockfileManager(self.repo_path) as lockfile_manager: 

38 if lockfile_manager.exists(): 

39 raise RetemplarError( 

40 'Repository already has .retemplar.lock', 

41 repo_path=str(self.repo_path), 

42 ) 

43 

44 if not dry_run: 

45 try: 

46 lockfile_manager.write(lock) 

47 logger.debug('lockfile_created') 

48 except Exception as e: 

49 raise RetemplarError( 

50 'Failed to create lockfile', 

51 repo_path=str(self.repo_path), 

52 error=str(e), 

53 ) from e 

54 

55 return { 

56 'template': lock.template_ref, 

57 'managed_paths': [mp.path for mp in lock.managed_paths], 

58 'ignore_paths': lock.ignore_paths, 

59 'render_rules': lock.render_rules, 

60 'lockfile_created': not dry_run, 

61 } 

62 

63 def plan_upgrade( 

64 self, 

65 target_ref: str, 

66 ) -> dict[str, str | int | list[dict]]: 

67 """Compute a human-readable plan. 2-way semantics for now.""" 

68 logger.debug('plan_upgrade_started', target_ref=target_ref) 

69 

70 with LockfileManager(self.repo_path) as lockfile_manager: 

71 if not lockfile_manager.exists(): 

72 raise LockfileNotFoundError( 

73 "No .retemplar.lock found. Run 'retemplar adopt' first.", 

74 ) 

75 

76 lock = lockfile_manager.read() 

77 logger.debug( 

78 'lockfile_loaded', 

79 current_ref=lock.template_ref, 

80 managed_paths_count=len(lock.managed_paths), 

81 ) 

82 

83 target_src = parse_template_ref(target_ref) 

84 tpl_root = fs_utils.resolve_template_path(target_src.repo) 

85 managed_files = fs_utils.get_managed_files(lock, tpl_root) 

86 

87 logger.debug( 

88 'template_analysis', 

89 template_root=str(tpl_root), 

90 managed_files_count=len(managed_files), 

91 ) 

92 

93 items, conflicts = plan_file_changes(managed_files, tpl_root, lock) 

94 block_events = merge_utils.scan_block_protection( 

95 lock.managed_paths or [], 

96 ) 

97 

98 return { 

99 'current_version': lock.template_ref, 

100 'target_version': target_ref, 

101 'changes': [ 

102 { 

103 'file': item.path, 

104 'strategy': item.strategy, 

105 'type': item.kind, 

106 'note': item.note, 

107 'had_conflict': item.had_conflict, 

108 'matched_rule': getattr( 

109 managed_files.get(item.path), 

110 'path', 

111 item.path, 

112 ), 

113 } 

114 for item in items 

115 ], 

116 'conflicts': conflicts, 

117 'block_protection': block_events, 

118 } 

119 

120 def apply_changes( 

121 self, 

122 target_ref: str, 

123 dry_run: bool = False, 

124 ) -> dict[str, str | int]: 

125 """Apply the plan (non-interactive). 2-way for now; conflict markers on merge.""" 

126 logger.debug( 

127 'apply_changes_started', 

128 target_ref=target_ref, 

129 dry_run=dry_run, 

130 ) 

131 

132 with LockfileManager(self.repo_path) as lockfile_manager: 

133 if not lockfile_manager.exists(): 

134 raise LockfileNotFoundError( 

135 "No .retemplar.lock found. Run 'retemplar adopt' first.", 

136 ) 

137 if not target_ref: 

138 raise ValueError('target_ref is required for apply') 

139 

140 plan = self.plan_upgrade(target_ref) 

141 lock = lockfile_manager.read() 

142 target_src = parse_template_ref(target_ref) 

143 tpl_root = fs_utils.resolve_template_path(target_src.repo) 

144 

145 logger.debug( 

146 'applying_file_changes', 

147 changes_count=len(plan['changes']), 

148 dry_run=dry_run, 

149 ) 

150 

151 files_changed, conflicts = apply_file_changes( 

152 plan['changes'], 

153 tpl_root, 

154 lock, 

155 dry_run, 

156 ) 

157 

158 if not dry_run: 

159 try: 

160 updated = lock.model_copy( 

161 update={ 

162 'template_ref': target_ref, 

163 'applied_ref': target_src.ref, 

164 'applied_commit': target_src.commit, 

165 }, 

166 ) 

167 lockfile_manager.write(updated) 

168 logger.debug( 

169 'lockfile_updated_after_apply', 

170 target_ref=target_ref, 

171 ) 

172 except Exception as e: 

173 raise RetemplarError( 

174 'Failed to update lockfile after applying changes - manual intervention required', 

175 target_ref=target_ref, 

176 files_changed=files_changed, 

177 error=str(e), 

178 repo_path=str(self.repo_path), 

179 ) from e 

180 

181 return { 

182 'applied_version': target_ref, 

183 'files_changed': files_changed, 

184 'conflicts_resolved': conflicts, 

185 } 

186 

187 def detect_drift(self) -> dict[str, str | list]: 

188 """Detect drift between repo and baseline (placeholder for 3-way).""" 

189 logger.debug('detect_drift_started') 

190 

191 with LockfileManager(self.repo_path) as lockfile_manager: 

192 if not lockfile_manager.exists(): 

193 raise LockfileNotFoundError( 

194 "No .retemplar.lock found. Run 'retemplar adopt' first.", 

195 ) 

196 

197 current_lock = lockfile_manager.read() 

198 logger.debug( 

199 'lockfile_loaded_for_drift', 

200 current_ref=current_lock.template_ref, 

201 applied_ref=current_lock.applied_ref, 

202 ) 

203 

204 # TODO: Real implementation needs: 

205 # - Baseline resolution from applied_ref 

206 # - 3-way comparison: Base vs Ours vs Theirs 

207 # - Categorization of changes (template-only, local-only, conflicts) 

208 

209 return { 

210 'baseline_version': current_lock.applied_ref 

211 or getattr(current_lock.template, 'ref', 'unknown'), 

212 'template_only_changes': [], # TODO: implement with baseline 

213 'local_only_changes': [], # TODO: implement with baseline 

214 'conflicts': [], # TODO: implement with baseline 

215 'unmanaged_files': [], # TODO: implement 

216 }