sync.py python
309 lines 10.4 KB
Raw
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