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

1# src/retemplar/core.py

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

3

4 from pathlib import Path

5

6 from retemplar.lockfile import LockfileManager, LockfileNotFoundError

7 from retemplar.logging import RetemplarError, get_logger

8 from retemplar.schema import RetemplarLock, parse_template_ref

9 from retemplar.utils import fs_utils, merge_utils

10 from retemplar.utils.apply_utils import apply_file_changes

11 from retemplar.utils.plan_utils import plan_file_changes

12

13 logger = get_logger(__name__)

14

15

16 class 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 }