Coverage for src / moai_adk / core / template / merger.py: 15.91%
88 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 file merger (SPEC-INIT-003 v0.3.0).
3Intelligently merges existing user files with new templates.
4"""
6from __future__ import annotations
8import json
9import shutil
10from pathlib import Path
11from typing import Any
14class TemplateMerger:
15 """Encapsulate template merging logic."""
17 PROJECT_INFO_HEADERS = (
18 "## Project Information",
19 "## Project Information", # Keep for backward compatibility
20 "## Project Info",
21 )
23 def __init__(self, target_path: Path) -> None:
24 """Initialize the merger.
26 Args:
27 target_path: Project path (absolute).
28 """
29 self.target_path = target_path.resolve()
31 def merge_claude_md(self, template_path: Path, existing_path: Path) -> None:
32 """Smart merge for CLAUDE.md.
34 Rules:
35 - Use the latest template structure/content.
36 - Preserve the existing "## Project Information" section.
38 Args:
39 template_path: Template CLAUDE.md.
40 existing_path: Existing CLAUDE.md.
41 """
42 # Extract the existing project information section
43 existing_content = existing_path.read_text(encoding="utf-8")
44 project_info_start, _ = self._find_project_info_section(existing_content)
45 project_info = ""
46 if project_info_start != -1:
47 # Extract until EOF
48 project_info = existing_content[project_info_start:]
50 # Load template content
51 template_content = template_path.read_text(encoding="utf-8")
53 # Merge when project info exists
54 if project_info:
55 # Remove the project info section from the template
56 template_project_start, _ = self._find_project_info_section(
57 template_content
58 )
59 if template_project_start != -1:
60 template_content = template_content[:template_project_start].rstrip()
62 # Merge template content with the preserved section
63 merged_content = f"{template_content}\n\n{project_info}"
64 existing_path.write_text(merged_content, encoding="utf-8")
65 else:
66 # No project info; copy the template as-is
67 shutil.copy2(template_path, existing_path)
69 def _find_project_info_section(self, content: str) -> tuple[int, str | None]:
70 """Find the project information header in the given content."""
71 for header in self.PROJECT_INFO_HEADERS:
72 index = content.find(header)
73 if index != -1:
74 return index, header
75 return -1, None
77 def merge_gitignore(self, template_path: Path, existing_path: Path) -> None:
78 """.gitignore merge.
80 Rules:
81 - Keep existing entries.
82 - Add new entries from the template.
83 - Remove duplicates.
85 Args:
86 template_path: Template .gitignore file.
87 existing_path: Existing .gitignore file.
88 """
89 template_lines = set(template_path.read_text(encoding="utf-8").splitlines())
90 existing_lines = existing_path.read_text(encoding="utf-8").splitlines()
92 # Merge while removing duplicates
93 merged_lines = existing_lines + [
94 line for line in template_lines if line not in existing_lines
95 ]
97 existing_path.write_text("\n".join(merged_lines) + "\n", encoding="utf-8")
99 def merge_config(self, detected_language: str | None = None) -> dict[str, str]:
100 """Smart merge for config.json.
102 Rules:
103 - Prefer existing settings.
104 - Use detected language plus defaults for new projects.
106 Args:
107 detected_language: Detected language.
109 Returns:
110 Merged configuration dictionary.
111 """
112 config_path = self.target_path / ".moai" / "config" / "config.json"
114 # Load existing config if present
115 existing_config: dict[str, Any] = {}
116 if config_path.exists():
117 with open(config_path, encoding="utf-8") as f:
118 existing_config = json.load(f)
120 # Build new config while preferring existing values
121 new_config: dict[str, str] = {
122 "projectName": existing_config.get("projectName", self.target_path.name),
123 "mode": existing_config.get("mode", "personal"),
124 "locale": existing_config.get("locale", "ko"),
125 "language": existing_config.get("language", detected_language or "generic"),
126 }
128 return new_config
130 def merge_settings_json(
131 self, template_path: Path, existing_path: Path, backup_path: Path | None = None
132 ) -> None:
133 """Smart merge for .claude/settings.json.
135 Rules:
136 - env: shallow merge (user variables preserved)
137 - permissions.allow: array merge (deduplicated)
138 - permissions.deny: template priority (security)
139 - hooks: template priority
141 Args:
142 template_path: Template settings.json.
143 existing_path: Existing settings.json.
144 backup_path: Backup settings.json (optional, for user settings extraction).
145 """
146 # Load template
147 template_data = json.loads(template_path.read_text(encoding="utf-8"))
149 # Load backup or existing for user settings
150 user_data: dict[str, Any] = {}
151 if backup_path and backup_path.exists():
152 user_data = json.loads(backup_path.read_text(encoding="utf-8"))
153 elif existing_path.exists():
154 user_data = json.loads(existing_path.read_text(encoding="utf-8"))
156 # Merge env (shallow merge, user variables preserved)
157 merged_env = {**template_data.get("env", {}), **user_data.get("env", {})}
159 # Merge permissions.allow (deduplicated array merge)
160 template_allow = set(template_data.get("permissions", {}).get("allow", []))
161 user_allow = set(user_data.get("permissions", {}).get("allow", []))
162 merged_allow = sorted(template_allow | user_allow)
164 # permissions.deny: template priority (security)
165 merged_deny = template_data.get("permissions", {}).get("deny", [])
167 # permissions.ask: template priority + user additions
168 template_ask = set(template_data.get("permissions", {}).get("ask", []))
169 user_ask = set(user_data.get("permissions", {}).get("ask", []))
170 merged_ask = sorted(template_ask | user_ask)
172 # Start with full template (include all fields from template)
173 merged = template_data.copy()
175 # Override with merged values
176 merged["env"] = merged_env
177 merged["permissions"] = {
178 "defaultMode": template_data.get("permissions", {}).get(
179 "defaultMode", "default"
180 ),
181 "allow": merged_allow,
182 "ask": merged_ask,
183 "deny": merged_deny,
184 }
186 # Preserve user customizations for specific fields (if exist in backup/existing)
187 preserve_fields = ["outputStyle", "spinnerTipsEnabled"]
188 for field in preserve_fields:
189 if field in user_data:
190 merged[field] = user_data[field]
192 existing_path.write_text(
193 json.dumps(merged, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
194 )
196 def merge_github_workflows(self, template_dir: Path, existing_dir: Path) -> None:
197 """Smart merge for .github/workflows/ directory.
199 Rules:
200 - Preserve existing user workflows (never delete)
201 - Add/update only MoAI-ADK managed workflows (moai-*.yml)
202 - Copy other template directories (ISSUE_TEMPLATE/, PULL_REQUEST_TEMPLATE.md)
204 Args:
205 template_dir: Template .github directory.
206 existing_dir: Existing .github directory.
207 """
208 import shutil
210 # Ensure workflows directory exists
211 workflows_dir = existing_dir / "workflows"
212 workflows_dir.mkdir(exist_ok=True)
214 # Track existing user workflows for preservation
215 user_workflows = set()
216 if workflows_dir.exists():
217 for workflow_file in workflows_dir.glob("*.yml"):
218 user_workflows.add(workflow_file.name)
220 # Copy template contents with smart merge for workflows
221 for item in template_dir.rglob("*"):
222 if item.is_file():
223 rel_path = item.relative_to(template_dir)
224 dst_item = existing_dir / rel_path
226 # Handle workflow files specially
227 if rel_path.parent.name == "workflows" and rel_path.name.endswith(
228 ".yml"
229 ):
230 # Only update MoAI-ADK managed workflows (moai-*.yml)
231 if rel_path.name.startswith("moai-"):
232 dst_item.parent.mkdir(parents=True, exist_ok=True)
233 shutil.copy2(item, dst_item)
234 # Skip non-moai workflows to preserve user custom workflows
235 continue
237 # Copy non-workflow files normally
238 dst_item.parent.mkdir(parents=True, exist_ok=True)
239 shutil.copy2(item, dst_item)
241 elif item.is_dir():
242 # Create directories as needed
243 rel_path = item.relative_to(template_dir)
244 dst_item = existing_dir / rel_path
245 dst_item.mkdir(parents=True, exist_ok=True)