patterns.py
python
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