sync.py
python
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
21 hours ago
| 1 | """``overseer sync`` command (§K4.3 + §K6.4 preserved / ``--include-preserved``).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import difflib |
| 6 | import sys |
| 7 | from argparse import Namespace |
| 8 | from pathlib import Path |
| 9 | |
| 10 | from adapters.config import load_config |
| 11 | from adapters.errors import ConfigError |
| 12 | from cli.atomic import WriteFailure |
| 13 | from cli.context import CliContext |
| 14 | from cli.digest import sha256_hex |
| 15 | from cli.docs_paths import living_doc_destinations, validate_muse_working_dir |
| 16 | from cli.footprint import resolve_footprint |
| 17 | from cli.footprint_writes import write_footprint_bytes |
| 18 | from cli.kit_root import kit_version |
| 19 | from cli.output import CommandReport |
| 20 | from cli.sanitize import format_config_error, sanitize_text |
| 21 | from cli.paths import is_within_repo, resolve_config_path, resolve_repo_root |
| 22 | from cli.sync_classify import Classification, classify_footprint, matches_glob |
| 23 | from cli.version_lock import ( |
| 24 | ORIGIN_KIT, |
| 25 | ORIGIN_PRESERVED, |
| 26 | FootprintEntry, |
| 27 | build_version_lock_from_entries, |
| 28 | entry_origin, |
| 29 | lock_path, |
| 30 | read_version_lock, |
| 31 | write_version_lock, |
| 32 | ) |
| 33 | |
| 34 | |
| 35 | def _emit_diff( |
| 36 | ctx: CliContext, |
| 37 | destination: str, |
| 38 | old_bytes: bytes, |
| 39 | new_bytes: bytes, |
| 40 | ) -> None: |
| 41 | old_lines = old_bytes.decode("utf-8").splitlines(keepends=True) |
| 42 | new_lines = new_bytes.decode("utf-8").splitlines(keepends=True) |
| 43 | diff = difflib.unified_diff( |
| 44 | old_lines, |
| 45 | new_lines, |
| 46 | fromfile=f"a/{destination}", |
| 47 | tofile=f"b/{destination}", |
| 48 | ) |
| 49 | for line in diff: |
| 50 | ctx.output.emit(line.rstrip("\n")) |
| 51 | |
| 52 | |
| 53 | def _is_preserved_path(lock, destination: str, living: frozenset[str]) -> bool: |
| 54 | """True when lock entry is ``origin: preserved`` (or living without kit origin).""" |
| 55 | for entry in lock.footprint: |
| 56 | if entry.path == destination: |
| 57 | return entry_origin(entry) == ORIGIN_PRESERVED |
| 58 | return destination in living |
| 59 | |
| 60 | |
| 61 | def _build_post_sync_entries( |
| 62 | *, |
| 63 | lock, |
| 64 | rendered, |
| 65 | only_globs: list[str], |
| 66 | writes: dict[str, bytes], |
| 67 | promoted: set[str], |
| 68 | retain_preserved: bool, |
| 69 | ) -> list[FootprintEntry]: |
| 70 | """Build full manifest entries after sync, retaining preserved/out-of-scope verbatim.""" |
| 71 | prior = {entry.path: entry for entry in lock.footprint} |
| 72 | entries: list[FootprintEntry] = [] |
| 73 | |
| 74 | for item in sorted(rendered, key=lambda row: row.destination): |
| 75 | dest = item.destination |
| 76 | in_scope = not only_globs or matches_glob(dest, only_globs) |
| 77 | prior_entry = prior.get(dest) |
| 78 | |
| 79 | if retain_preserved and prior_entry is not None and entry_origin(prior_entry) == ORIGIN_PRESERVED: |
| 80 | if dest not in promoted: |
| 81 | entries.append(prior_entry) |
| 82 | continue |
| 83 | |
| 84 | if not in_scope and prior_entry is not None and dest not in promoted: |
| 85 | entries.append(prior_entry) |
| 86 | continue |
| 87 | |
| 88 | if dest in writes: |
| 89 | content = writes[dest] |
| 90 | elif prior_entry is not None and dest in promoted: |
| 91 | # Ownership promotion with identical bytes — keep on-disk / rendered match |
| 92 | content = item.content |
| 93 | else: |
| 94 | content = item.content |
| 95 | |
| 96 | origin = ORIGIN_KIT |
| 97 | if prior_entry is not None and dest not in promoted and entry_origin(prior_entry) == ORIGIN_PRESERVED: |
| 98 | origin = ORIGIN_PRESERVED |
| 99 | |
| 100 | entries.append( |
| 101 | FootprintEntry( |
| 102 | path=dest, |
| 103 | source=item.source, |
| 104 | sha256=sha256_hex(content), |
| 105 | origin=origin, |
| 106 | ) |
| 107 | ) |
| 108 | |
| 109 | entries.sort(key=lambda row: row.path) |
| 110 | return entries |
| 111 | |
| 112 | |
| 113 | def run_sync(args: Namespace, ctx: CliContext) -> int: |
| 114 | """Execute ``overseer sync``.""" |
| 115 | report = CommandReport() |
| 116 | include_preserved = bool(getattr(args, "include_preserved", False)) |
| 117 | promote = bool(args.force and include_preserved) |
| 118 | |
| 119 | repo_root = resolve_repo_root(cwd=ctx.cwd, repo_arg=args.repo, command="sync") |
| 120 | config_path = resolve_config_path(repo_root, args.config) |
| 121 | |
| 122 | if not is_within_repo(repo_root, config_path): |
| 123 | ctx.output.error("refused: config path outside repo root") |
| 124 | return 4 |
| 125 | |
| 126 | if not config_path.is_file(): |
| 127 | ctx.output.error("config missing: run `ok init` first") |
| 128 | return 2 |
| 129 | |
| 130 | try: |
| 131 | config = load_config(config_path) |
| 132 | except ConfigError as exc: |
| 133 | ctx.output.error(format_config_error(exc, repo_root)) |
| 134 | return 2 |
| 135 | |
| 136 | try: |
| 137 | validate_muse_working_dir(repo_root, config.vcs.muse.working_dir) |
| 138 | except ConfigError as exc: |
| 139 | ctx.output.error(format_config_error(exc, repo_root)) |
| 140 | return 2 |
| 141 | |
| 142 | lock_file = lock_path(repo_root) |
| 143 | try: |
| 144 | lock = read_version_lock(lock_file) |
| 145 | except Exception as exc: |
| 146 | ctx.output.error(format_config_error(exc, repo_root)) |
| 147 | return 6 |
| 148 | |
| 149 | try: |
| 150 | rendered = resolve_footprint(config, kit=ctx.kit) |
| 151 | except ConfigError as exc: |
| 152 | ctx.output.error(format_config_error(exc, repo_root)) |
| 153 | return 2 |
| 154 | |
| 155 | living = living_doc_destinations(config) |
| 156 | classified = classify_footprint(rendered, lock, repo_root) |
| 157 | only_globs = list(args.only or []) |
| 158 | |
| 159 | in_scope = [ |
| 160 | row |
| 161 | for row in classified |
| 162 | if not only_globs or matches_glob(row.destination, only_globs) |
| 163 | ] |
| 164 | out_of_scope = [ |
| 165 | row |
| 166 | for row in classified |
| 167 | if only_globs and not matches_glob(row.destination, only_globs) |
| 168 | ] |
| 169 | |
| 170 | for row in out_of_scope: |
| 171 | if row.is_conflict: |
| 172 | report.add_warning( |
| 173 | f"out-of-scope conflict (not blocking): {row.destination} [{row.classification.value}]" |
| 174 | ) |
| 175 | |
| 176 | # Living-doc / origin:preserved paths are non-blocking unless promoting (§K6.4). |
| 177 | blocking_conflicts = [] |
| 178 | for row in in_scope: |
| 179 | preserved = _is_preserved_path(lock, row.destination, living) |
| 180 | if preserved and not promote: |
| 181 | if row.is_conflict or row.classification == Classification.KIT_UPDATED: |
| 182 | report.add_warning( |
| 183 | f"preserved living doc (not blocking): {row.destination} [{row.classification.value}]" |
| 184 | ) |
| 185 | continue |
| 186 | if row.is_conflict: |
| 187 | blocking_conflicts.append(row) |
| 188 | |
| 189 | report.data["classified"] = [ |
| 190 | {"path": row.destination, "status": row.classification.value} |
| 191 | for row in classified |
| 192 | ] |
| 193 | |
| 194 | show_diff = args.diff and not ctx.output.json_mode |
| 195 | if show_diff: |
| 196 | for row in classified: |
| 197 | if row.classification == Classification.UNCHANGED: |
| 198 | continue |
| 199 | dest = repo_root / row.destination |
| 200 | old = dest.read_bytes() if dest.is_file() else b"" |
| 201 | _emit_diff(ctx, row.destination, old, row.new_content) |
| 202 | |
| 203 | if blocking_conflicts and not args.force: |
| 204 | ctx.output.error("refused: consumer-modified files without --force") |
| 205 | for row in blocking_conflicts: |
| 206 | ctx.output.error(f" conflict: {row.destination} [{row.classification.value}]") |
| 207 | if ctx.output.json_mode: |
| 208 | ctx.output.emit_json(report.to_payload()) |
| 209 | return 4 |
| 210 | |
| 211 | writes_needed = [] |
| 212 | promoted: set[str] = set() |
| 213 | for row in in_scope: |
| 214 | preserved = _is_preserved_path(lock, row.destination, living) |
| 215 | if preserved: |
| 216 | if not promote: |
| 217 | continue |
| 218 | promoted.add(row.destination) |
| 219 | dest = repo_root / row.destination |
| 220 | on_disk = dest.read_bytes() if dest.is_file() else None |
| 221 | if on_disk != row.new_content: |
| 222 | writes_needed.append(row) |
| 223 | continue |
| 224 | if row.needs_write or (args.force and row.is_conflict): |
| 225 | writes_needed.append(row) |
| 226 | |
| 227 | if not writes_needed and not promoted and lock.kit_version == kit_version(): |
| 228 | report.data["status"] = "already_current" |
| 229 | if ctx.output.json_mode: |
| 230 | ctx.output.emit_json(report.to_payload()) |
| 231 | else: |
| 232 | ctx.output.emit("already current") |
| 233 | return 0 |
| 234 | |
| 235 | if args.dry_run: |
| 236 | report.data["dry_run"] = True |
| 237 | if ctx.output.json_mode: |
| 238 | ctx.output.emit_json(report.to_payload()) |
| 239 | else: |
| 240 | ctx.output.emit("dry-run: no files written") |
| 241 | return 0 |
| 242 | |
| 243 | if not args.yes and sys.stdin.isatty() and not ctx.output.json_mode: |
| 244 | answer = input("Apply sync? [y/N]: ").strip().lower() |
| 245 | if answer not in {"y", "yes"}: |
| 246 | ctx.output.emit("aborted") |
| 247 | return 0 |
| 248 | |
| 249 | writes: dict[str, bytes] = {} |
| 250 | try: |
| 251 | for row in writes_needed: |
| 252 | write_footprint_bytes( |
| 253 | repo_root / row.destination, |
| 254 | row.new_content, |
| 255 | destination=row.destination, |
| 256 | ) |
| 257 | writes[row.destination] = row.new_content |
| 258 | |
| 259 | entries = _build_post_sync_entries( |
| 260 | lock=lock, |
| 261 | rendered=rendered, |
| 262 | only_globs=only_globs, |
| 263 | writes=writes, |
| 264 | promoted=promoted, |
| 265 | retain_preserved=True, |
| 266 | ) |
| 267 | if promoted: |
| 268 | by_path = {e.path: e for e in entries} |
| 269 | for dest in promoted: |
| 270 | item = next(i for i in rendered if i.destination == dest) |
| 271 | on_disk_path = repo_root / dest |
| 272 | if dest in writes: |
| 273 | content = writes[dest] |
| 274 | elif on_disk_path.is_file(): |
| 275 | content = on_disk_path.read_bytes() |
| 276 | else: |
| 277 | content = item.content |
| 278 | by_path[dest] = FootprintEntry( |
| 279 | path=dest, |
| 280 | source=item.source, |
| 281 | sha256=sha256_hex(content), |
| 282 | origin=ORIGIN_KIT, |
| 283 | ) |
| 284 | entries = sorted(by_path.values(), key=lambda e: e.path) |
| 285 | |
| 286 | new_lock = build_version_lock_from_entries( |
| 287 | kit_version=kit_version(), |
| 288 | config_version=config.overseer_config_version, |
| 289 | entries=entries, |
| 290 | installed_at=lock.installed_at, |
| 291 | ) |
| 292 | write_version_lock(lock_file, new_lock) |
| 293 | except WriteFailure as exc: |
| 294 | ctx.output.error(sanitize_text(str(exc), repo_root)) |
| 295 | return 5 |
| 296 | |
| 297 | report.data["status"] = "synced" |
| 298 | report.data["updated"] = [row.destination for row in writes_needed] |
| 299 | report.data["promoted"] = sorted(promoted) |
| 300 | if ctx.output.json_mode: |
| 301 | ctx.output.emit_json(report.to_payload()) |
| 302 | else: |
| 303 | ctx.output.emit("sync complete") |
| 304 | for row in writes_needed: |
| 305 | ctx.output.emit(f" updated: {row.destination}") |
| 306 | for dest in sorted(promoted): |
| 307 | if dest not in {row.destination for row in writes_needed}: |
| 308 | ctx.output.emit(f" promoted: {dest}") |
| 309 | return 0 |
File History
2 commits
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
21 hours ago
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439
feat: K1-P1 complete — agent provenance, build-verification…
Sonnet 4.6
patch
52 days ago