gabriel / muse public
patterns.py python
313 lines 10.2 KB
Raw
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 days ago
1 """Harmony pattern persistence — record, load, list, forget, clear.
2
3 Single responsibility: CRUD for ConflictPattern objects in the harmony store,
4 plus the shared _write_atomic helper used by all harmony persistence modules.
5 """
6
7 from __future__ import annotations
8
9 import json
10 import logging
11 import os
12 import pathlib
13 import tempfile
14 from collections.abc import Mapping
15
16 from muse.core.types import JsonValue, load_json_file, long_id, short_id
17
18 from .fingerprint import _parse_dt
19 from .paths import (
20 _BARE_HEX64_RE,
21 _PATTERN_FILE,
22 _RESOLUTIONS,
23 _pattern_entry_dir,
24 _resolutions_dir,
25 patterns_dir,
26 _validate_id,
27 )
28 from .types import ConflictPattern, ConflictType, _PatternDict
29
30 logger = logging.getLogger(__name__)
31
32 #: Maximum bytes read from ``pattern.json``.
33 _MAX_PATTERN_BYTES: int = 32_768 # 32 KiB
34
35 #: Maximum patterns scanned by :func:`list_patterns` to protect degenerate repos.
36 _MAX_SCAN: int = 100_000
37
38 # ---------------------------------------------------------------------------
39 # Shared atomic write helper
40 # ---------------------------------------------------------------------------
41
42 def _write_atomic(dest: pathlib.Path, content: str) -> None:
43 """Write *content* to *dest* atomically via a temp file and ``os.replace``.
44
45 Creates parent directories as needed. On failure the temp file is removed
46 and the exception re-raised, leaving *dest* unchanged.
47
48 Args:
49 dest: Target path for the final file.
50 content: UTF-8 string content to write.
51 """
52 dest.parent.mkdir(parents=True, exist_ok=True)
53 fd, tmp_str = tempfile.mkstemp(dir=dest.parent, prefix=".harmony-tmp-")
54 tmp = pathlib.Path(tmp_str)
55 try:
56 with os.fdopen(fd, "w", encoding="utf-8") as fh:
57 fh.write(content)
58 os.replace(tmp, dest)
59 except Exception:
60 tmp.unlink(missing_ok=True)
61 raise
62
63 # ---------------------------------------------------------------------------
64 # Serialisation helpers
65 # ---------------------------------------------------------------------------
66
67 def _pattern_to_dict(pattern: ConflictPattern) -> _PatternDict:
68 return _PatternDict(
69 pattern_id=pattern.pattern_id,
70 path=pattern.path,
71 domain=pattern.domain,
72 conflict_type=pattern.conflict_type,
73 blob_fingerprint=pattern.blob_fingerprint,
74 semantic_fingerprint=pattern.semantic_fingerprint,
75 ours_id=pattern.ours_id,
76 theirs_id=pattern.theirs_id,
77 description=pattern.description,
78 recorded_at=pattern.recorded_at.isoformat(),
79 recorded_by=pattern.recorded_by,
80 )
81
82 def _dict_to_pattern(data: Mapping[str, JsonValue]) -> ConflictPattern | None:
83 """Deserialise a JSON dict to :class:`ConflictPattern`, returning ``None`` on error."""
84 try:
85 return ConflictPattern(
86 pattern_id=str(data["pattern_id"]),
87 path=str(data.get("path", "")),
88 domain=str(data.get("domain", "")),
89 conflict_type=str(data.get("conflict_type", ConflictType.UNKNOWN)),
90 blob_fingerprint=str(data.get("blob_fingerprint", "")),
91 semantic_fingerprint=str(data.get("semantic_fingerprint", "")),
92 ours_id=str(data.get("ours_id", "")),
93 theirs_id=str(data.get("theirs_id", "")),
94 description=data.get("description") or {},
95 recorded_at=_parse_dt(data.get("recorded_at")),
96 recorded_by=str(data.get("recorded_by", "unknown")),
97 )
98 except (KeyError, TypeError, ValueError) as exc:
99 logger.warning("⚠️ harmony: failed to deserialise pattern: %s", exc)
100 return None
101
102 # ---------------------------------------------------------------------------
103 # Pattern CRUD
104 # ---------------------------------------------------------------------------
105
106 def record_pattern(root: pathlib.Path, pattern: ConflictPattern) -> str:
107 """Persist a :class:`ConflictPattern` to the harmony store.
108
109 **Idempotent** — if *pattern.pattern_id* already exists in the store the
110 existing ``pattern.json`` is left unchanged and the pattern_id is returned.
111
112 Args:
113 root: Repository root.
114 pattern: Pattern to record.
115
116 Returns:
117 The ``sha256:<hex>`` ``pattern_id`` identifying this conflict.
118
119 Raises:
120 ValueError: If ``pattern.pattern_id`` is not a valid content-addressed ID.
121 """
122 _validate_id(pattern.pattern_id, "pattern_id")
123 meta_p = _pattern_entry_dir(root, pattern.pattern_id) / _PATTERN_FILE
124 if meta_p.exists():
125 logger.debug(
126 "harmony: pattern %s already recorded for '%s'",
127 short_id(pattern.pattern_id),
128 pattern.path,
129 )
130 return pattern.pattern_id
131
132 _write_atomic(meta_p, json.dumps(_pattern_to_dict(pattern), indent=2))
133 logger.debug(
134 "harmony: recorded pattern %s for '%s' (type=%s, domain=%s)",
135 short_id(pattern.pattern_id),
136 pattern.path,
137 pattern.conflict_type,
138 pattern.domain,
139 )
140 return pattern.pattern_id
141
142 def load_pattern(root: pathlib.Path, pattern_id: str) -> ConflictPattern | None:
143 """Load a :class:`ConflictPattern` from the harmony store.
144
145 Returns ``None`` when:
146
147 - *pattern_id* is not a valid content-addressed ID.
148 - No entry exists for *pattern_id*.
149 - The ``pattern.json`` file exceeds :data:`_MAX_PATTERN_BYTES`.
150 - The JSON cannot be parsed or is missing required fields.
151
152 Args:
153 root: Repository root.
154 pattern_id: ``sha256:<hex>`` pattern ID.
155 """
156 try:
157 _validate_id(pattern_id, "pattern_id")
158 except ValueError:
159 return None
160
161 meta_p = _pattern_entry_dir(root, pattern_id) / _PATTERN_FILE
162 if not meta_p.exists():
163 return None
164
165 try:
166 size = meta_p.stat().st_size
167 except OSError as exc:
168 logger.warning("⚠️ harmony: failed to read pattern %s: %s", pattern_id, exc)
169 return None
170 if size > _MAX_PATTERN_BYTES:
171 logger.warning(
172 "⚠️ harmony: pattern.json for %s is %d bytes — exceeds %d cap; skipping",
173 pattern_id,
174 size,
175 _MAX_PATTERN_BYTES,
176 )
177 return None
178 data = load_json_file(meta_p)
179 if data is None:
180 logger.warning(
181 "⚠️ harmony: failed to read pattern %s: unreadable or invalid JSON", pattern_id
182 )
183 return None
184
185 return _dict_to_pattern(data)
186
187 def list_patterns(root: pathlib.Path) -> list[ConflictPattern]:
188 """Return all :class:`ConflictPattern` entries in the harmony store.
189
190 Scans ``.muse/harmony/patterns/``. Skips symlinks, directories whose
191 names are not valid 64-char hex strings, and entries that fail to load.
192 Capped at :data:`_MAX_SCAN` entries to protect degenerate repos.
193
194 Returns patterns sorted by ``recorded_at`` descending (most recent first).
195
196 Args:
197 root: Repository root.
198 """
199 pdir = patterns_dir(root)
200 if not pdir.exists():
201 return []
202
203 results: list[ConflictPattern] = []
204 count = 0
205
206 for algo_dir in pdir.iterdir():
207 if algo_dir.is_symlink() or not algo_dir.is_dir():
208 continue
209 for entry in algo_dir.iterdir():
210 if count >= _MAX_SCAN:
211 logger.warning(
212 "⚠️ harmony: patterns dir has >%d entries — scan truncated",
213 _MAX_SCAN,
214 )
215 break
216 count += 1
217
218 if entry.is_symlink():
219 logger.debug("harmony: skipping symlink %s in patterns dir", entry.name)
220 continue
221 if not entry.is_dir():
222 continue
223 if not _BARE_HEX64_RE.match(entry.name):
224 continue
225
226 p = load_pattern(root, long_id(entry.name, algo_dir.name))
227 if p is not None:
228 results.append(p)
229
230 results.sort(key=lambda p: p.recorded_at, reverse=True)
231 return results
232
233 def forget_pattern(root: pathlib.Path, pattern_id: str) -> bool:
234 """Remove a conflict pattern and all its resolutions from the harmony store.
235
236 Deletes ``pattern.json``, all resolution files under ``resolutions/``,
237 and the entry directory itself. Returns ``False`` if the pattern does not
238 exist or *pattern_id* is not a valid content-addressed ID.
239
240 Args:
241 root: Repository root.
242 pattern_id: ``sha256:`` content-addressed pattern ID.
243 """
244 try:
245 entry = _pattern_entry_dir(root, pattern_id)
246 except ValueError:
247 logger.warning(
248 "⚠️ harmony: invalid pattern_id in forget_pattern: %r", pattern_id
249 )
250 return False
251
252 if not entry.exists():
253 return False
254
255 res_dir = entry / _RESOLUTIONS
256 if res_dir.exists() and not res_dir.is_symlink():
257 for algo_dir in res_dir.iterdir():
258 if algo_dir.is_symlink():
259 continue
260 if algo_dir.is_dir():
261 for f in algo_dir.iterdir():
262 if not f.is_symlink():
263 f.unlink(missing_ok=True)
264 try:
265 algo_dir.rmdir()
266 except OSError:
267 pass
268 else:
269 algo_dir.unlink(missing_ok=True)
270 try:
271 res_dir.rmdir()
272 except OSError:
273 pass
274
275 for child in entry.iterdir():
276 if not child.is_symlink():
277 child.unlink(missing_ok=True)
278 try:
279 entry.rmdir()
280 except OSError:
281 pass # not empty — leave; caller may retry
282
283 logger.debug("harmony: forgot pattern %s", short_id(pattern_id))
284 return True
285
286 def clear_all(root: pathlib.Path) -> int:
287 """Remove every conflict pattern and all its resolutions from the store.
288
289 Symlinks in the patterns directory are skipped (only real subdirectories
290 are cleared).
291
292 Args:
293 root: Repository root.
294
295 Returns:
296 Number of patterns removed.
297 """
298 pdir = patterns_dir(root)
299 if not pdir.exists():
300 return 0
301
302 removed = 0
303 for algo_dir in pdir.iterdir():
304 if algo_dir.is_symlink() or not algo_dir.is_dir():
305 continue
306 for entry in algo_dir.iterdir():
307 if entry.is_symlink() or not entry.is_dir():
308 continue
309 if forget_pattern(root, long_id(entry.name, algo_dir.name)):
310 removed += 1
311
312 logger.debug("harmony: clear_all removed %d patterns", removed)
313 return removed
File History 3 commits
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 33 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 52 days ago