Viewing files at commit ef78687eef1aCommit detailsView latest

suites/merge/mergesuite/oak_adapter.py 534 lines · 24.8 KB · python
1
"""Drive Oak's client merge engine on a scenario and report what it produced.
2
3
Exercised Oak path (phase 1, "pull-sync"):
4
5
    oak clone <git.git>   (git-history import: theirs lineage, then the fork)
6
    oak push              (theirs clone bootstraps `main` on a local `oak serve`)
7
    oak commit            (ours: one checkpoint per scripted tree state)
8
    oak push; oak fetch; oak merge --dry-run --json   (local merge prediction)
9
    oak pull --json       (parent sync: the CLI merges `main` INTO the branch)
10
11
`oak pull`'s parent sync is the Oak client's 3-way merge engine (merge base
12
resolution over the local+hydrated graph, `three_way_merge_manifests`, diffy
13
text merge, conflict markers). It is the same engine `oak merge` uses for
14
non-main parents. The hosted squash-merge onto `main` (oakspace) is NOT
15
exercised; see HostedMergeAdapter for the phase-2 plug point.
16
17
`oak serve` 0.105 does not implement `GET /api/:o/:r/raw/:commit/:path`, which
18
the client uses to hydrate parent blobs. RawShim fronts the real server and
19
answers only that route, from the publishing (theirs) clone via
20
`oak file inspect --at <commit> --output` (hash-verified by Oak itself); every
21
other request is forwarded byte-for-byte. The shim can also pin the `head`
22
the server reports for `main` to an earlier main commit ("staging"), which
23
lets multi-step scenarios move `main` forward between client pulls without a
24
hosted merge endpoint. No merge logic lives in the shim.
25
"""
26
from __future__ import annotations
27
28
import http.server
29
import json
30
import os
31
from pathlib import Path
32
import shutil
33
import socket
34
import sqlite3
35
import subprocess
36
import tempfile
37
import threading
38
import time
39
import urllib.error
40
import urllib.parse
41
import urllib.request
42
from typing import Dict, List, Optional
43
44
from .gitops import Git, worktree_manifest
45
46
PATH_EXERCISED = "oak-pull-parent-sync"
47
PATH_DESCRIPTION = ("oak pull parent-sync (client merge engine: merge base over local+hydrated "
48
                    "graph, three_way_merge_manifests, diffy text merge) against a local oak serve; "
49
                    "prediction via oak merge --dry-run --json")
50
51
52
class HarnessError(Exception):
53
    """The instrument (not Oak) failed; the case is classified harness-error."""
54
55
56
class StepFailed(Exception):
57
    """An Oak command in scenario setup failed; carries the step record."""
58
59
    def __init__(self, message: str, step: dict):
60
        super().__init__(message)
61
        self.step = step
62
63
64
def _free_port() -> int:
65
    with socket.socket() as s:
66
        s.bind(("127.0.0.1", 0))
67
        return s.getsockname()[1]
68
69
70
class RawShim:
71
    """Forwarding proxy that adds the missing /raw route and optional main-head pinning."""
72
73
    def __init__(self, upstream: str, oak_bin: str, env: dict):
74
        self.upstream, self.oak_bin, self.env = upstream, oak_bin, env
75
        self.repos: Dict[str, dict] = {}  # "owner/name" -> {"source": Path, "stage_head": str|None}
76
        self.lock = threading.Lock()
77
        self.log: List[str] = []
78
        shim = self
79
80
        class Handler(http.server.BaseHTTPRequestHandler):
81
            protocol_version = "HTTP/1.1"
82
83
            def log_message(self, *args):
84
                pass
85
86
            def _send(self, code, data, headers=()):
87
                self.send_response(code)
88
                for k, v in headers:
89
                    if k.lower() not in ("transfer-encoding", "content-length", "connection"):
90
                        self.send_header(k, v)
91
                self.send_header("Content-Length", str(len(data)))
92
                self.end_headers()
93
                self.wfile.write(data)
94
95
            def _handle(self):
96
                parsed = urllib.parse.urlsplit(self.path)
97
                parts = parsed.path.split("/")
98
                length = int(self.headers.get("Content-Length") or 0)
99
                body = self.rfile.read(length) if length else None
100
                if self.command == "GET" and len(parts) > 6 and parts[1] == "api" and parts[4] == "raw":
101
                    key = f"{parts[2]}/{parts[3]}"
102
                    code, data = shim.raw(key, parts[5], urllib.parse.unquote("/".join(parts[6:])))
103
                    return self._send(code, data)
104
                req = urllib.request.Request(
105
                    shim.upstream + self.path, data=body, method=self.command,
106
                    headers={k: v for k, v in self.headers.items()
107
                             if k.lower() not in ("host", "content-length", "connection")})
108
                try:
109
                    with urllib.request.urlopen(req, timeout=120) as r:
110
                        code, data, hdrs = r.status, r.read(), list(r.headers.items())
111
                except urllib.error.HTTPError as e:
112
                    code, data, hdrs = e.code, e.read(), list(e.headers.items())
113
                if self.command == "GET" and code == 200 and len(parts) in (4, 6) and parts[1] == "api":
114
                    data = shim.pin_head(f"{parts[2]}/{parts[3]}", parts, data)
115
                return self._send(code, data, hdrs)
116
117
            do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = _handle
118
119
        class Server(http.server.ThreadingHTTPServer):
120
            def handle_error(self, request, client_address):
121
                shim.log.append("shim connection error (client disconnected)")
122
123
        self.server = Server(("127.0.0.1", 0), Handler)
124
        self.server.daemon_threads = True
125
        self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
126
        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
127
        self.thread.start()
128
129
    def register(self, key: str, source: Path) -> None:
130
        with self.lock:
131
            self.repos[key] = {"source": Path(source), "stage_head": None}
132
133
    def stage(self, key: str, head: Optional[str]) -> None:
134
        with self.lock:
135
            self.repos[key]["stage_head"] = head
136
137
    def pin_head(self, key: str, parts: List[str], data: bytes) -> bytes:
138
        with self.lock:
139
            entry = self.repos.get(key)
140
            head = entry and entry["stage_head"]
141
        if not head:
142
            return data
143
        is_repo = len(parts) == 4
144
        is_main = len(parts) == 6 and parts[4] == "branches" and parts[5] == "main"
145
        if not (is_repo or is_main):
146
            return data
147
        try:
148
            doc = json.loads(data)
149
        except ValueError:
150
            return data
151
        for field in ("head", "head_hash"):
152
            if isinstance(doc, dict) and field in doc:
153
                doc[field] = head
154
        return json.dumps(doc).encode()
155
156
    def raw(self, key: str, commit: str, path: str):
157
        with self.lock:
158
            entry = self.repos.get(key)
159
        if entry is None:
160
            self.log.append(f"raw for unregistered repo {key}")
161
            return 404, b""
162
        with tempfile.TemporaryDirectory(prefix="rawshim-") as tmp:
163
            out = Path(tmp) / "blob"
164
            p = subprocess.run([self.oak_bin, "file", "inspect", "--at", commit, "--json", path,
165
                                "--output", str(out)], cwd=entry["source"], env=self.env,
166
                               capture_output=True, timeout=60)
167
            if p.returncode or not out.exists():
168
                self.log.append(f"raw miss {key} {commit[:12]} {path!r} rc={p.returncode} "
169
                                f"{p.stderr.decode('utf-8', 'replace')[:200]}")
170
                return 404, b""
171
            return 200, out.read_bytes()
172
173
    def close(self) -> None:
174
        self.server.shutdown()
175
        self.server.server_close()
176
177
178
class LocalOakServer:
179
    """`oak serve` on a private data dir + RawShim in front of it."""
180
181
    def __init__(self, workdir: Path, oak_bin: str, env: dict):
182
        self.data = workdir / "oak-serve-data"
183
        self.data.mkdir(parents=True, exist_ok=True)
184
        self.port = _free_port()
185
        self.log_path = workdir / "oak-serve.log"
186
        self._log = open(self.log_path, "wb")
187
        self.proc = subprocess.Popen([oak_bin, "serve", "-d", str(self.data), "-p", str(self.port)],
188
                                     stdout=self._log, stderr=subprocess.STDOUT, env=env,
189
                                     stdin=subprocess.DEVNULL)
190
        upstream = f"http://127.0.0.1:{self.port}"
191
        deadline = time.monotonic() + 20
192
        while True:
193
            try:
194
                urllib.request.urlopen(upstream + "/api/capabilities", timeout=2).read()
195
                break
196
            except (urllib.error.URLError, ConnectionError, OSError):
197
                if self.proc.poll() is not None or time.monotonic() > deadline:
198
                    raise HarnessError(f"oak serve did not start (see {self.log_path})")
199
                time.sleep(0.1)
200
        self.shim = RawShim(upstream, oak_bin, env)
201
        self.url = self.shim.url
202
203
    def close(self) -> None:
204
        self.shim.close()
205
        if self.proc.poll() is None:
206
            self.proc.terminate()  # our own child only; never pattern-kill
207
            try:
208
                self.proc.wait(timeout=10)
209
            except subprocess.TimeoutExpired:
210
                self.proc.kill()
211
                self.proc.wait(timeout=10)
212
        self._log.close()
213
214
215
def oak_env(home: Path, git_bin: str) -> dict:
216
    env = {k: v for k, v in os.environ.items()
217
           if not k.startswith(("OAK_", "GIT_")) and k not in ("HOME",)}
218
    git_dir = str(Path(git_bin).parent)
219
    env.update({
220
        "HOME": str(home), "OAK_NO_UPDATE_CHECK": "1", "NO_COLOR": "1", "CI": "1",
221
        "OAK_PROGRESS": "never", "OAK_AUTHOR": "merge-suite",
222
        # `oak clone <x.git>` shells out to `git clone`; make it the resolved real git.
223
        "PATH": git_dir + os.pathsep + env.get("PATH", ""),
224
        "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(home / "gitconfig"),
225
    })
226
    return env
227
228
229
class OakRun:
230
    """One directional scenario execution against Oak."""
231
232
    def __init__(self, server: LocalOakServer, oak_bin: str, env: dict, case_dir: Path, repo_key: str):
233
        self.server, self.oak_bin, self.env = server, oak_bin, env
234
        self.case_dir, self.repo_key = case_dir, repo_key
235
        self.steps: List[dict] = []
236
237
    def oak(self, cwd: Path, *args: str, ok=(0,), label: str = "") -> dict:
238
        start = time.monotonic()
239
        p = subprocess.run([self.oak_bin, *args], cwd=cwd, env=self.env, capture_output=True,
240
                           stdin=subprocess.DEVNULL, timeout=180)
241
        step = {"label": label or args[0], "argv": ["oak", *args], "cwd": cwd.name,
242
                "returncode": p.returncode, "ms": round((time.monotonic() - start) * 1000),
243
                "stdout": p.stdout.decode("utf-8", "replace")[-4000:],
244
                "stderr": p.stderr.decode("utf-8", "replace")[-2000:]}
245
        self.steps.append(step)
246
        if ok is not None and p.returncode not in ok:
247
            raise StepFailed(f"oak {' '.join(args)} exited {p.returncode}", step)
248
        return step
249
250
251
def _json_tail(text: str) -> Optional[dict]:
252
    """Last JSON object printed on stdout (receipts are single-line JSON)."""
253
    for line in reversed(text.strip().splitlines()):
254
        line = line.strip()
255
        if line.startswith("{"):
256
            try:
257
                return json.loads(line)
258
            except ValueError:
259
                continue
260
    return None
261
262
263
def oak_commit_map(repo: Path) -> Dict[str, str]:
264
    """Read-only map commit message -> oak hash from a clone's local store.
265
266
    Plumbing only (locates staged main heads by their unique scenario
267
    messages); it is never used to judge merge results."""
268
    db = repo / ".oak" / "oak.db"
269
    with tempfile.TemporaryDirectory(prefix="oakmap-") as tmp:
270
        # Copy (db + WAL) so we never take locks or write -shm files in Oak's store.
271
        for suffix in ("", "-wal"):
272
            src = Path(str(db) + suffix)
273
            if src.exists():
274
                shutil.copyfile(src, Path(tmp) / ("oak.db" + suffix))
275
        con = sqlite3.connect(str(Path(tmp) / "oak.db"))
276
        try:
277
            rows = con.execute("SELECT hash, message FROM commits").fetchall()
278
        finally:
279
            con.close()
280
    out: Dict[str, str] = {}
281
    for h, msg in rows:
282
        if msg:
283
            out.setdefault(msg.strip(), h)
284
    return out
285
286
287
def _clear_worktree(root: Path) -> None:
288
    for child in root.iterdir():
289
        if child.name == ".oak":
290
            continue
291
        if child.is_dir() and not child.is_symlink():
292
            shutil.rmtree(child)
293
        else:
294
            child.unlink()
295
296
297
def run_pull_sync(server: LocalOakServer, oak_bin: str, env: dict, git: Git, bare: Path,
298
                  case_dir: Path, repo_key: str, plan: dict) -> dict:
299
    """Execute a directional plan and return Oak's observed result.
300
301
    plan = {
302
      "main": git commit that becomes server `main` (theirs tip / final main),
303
      "fork": git commit the ours clone is imported at (must be on main's history),
304
      "steps": [{"op": "commit", "tree": <git commit whose tree to check in>}
305
                | {"op": "pull", "stage": <git commit on main>, "resolve": {path: bytes|None}}],
306
      "final_stage": optional git commit to pin as main for the measured pull,
307
    }
308
    """
309
    run = OakRun(server, oak_bin, env, case_dir, repo_key)
310
    result = {"path_exercised": PATH_EXERCISED, "steps": run.steps, "error": None,
311
              "error_kind": None}
312
    theirs_dir, ours_dir = case_dir / "theirs", case_dir / "ours"
313
    owner, name = repo_key.split("/")
314
    try:
315
        # 1. theirs clone imports main's lineage and bootstraps server main.
316
        _point_head(git, bare, plan["main"])
317
        run.oak(case_dir, "clone", str(bare), "theirs", label="import-main")
318
        run.oak(theirs_dir, "push", "-r", server.url, "--repo", repo_key, label="bootstrap-main")
319
        server.shim.register(repo_key, theirs_dir)
320
        messages = oak_commit_map(theirs_dir)
321
        commit_msgs = _git_messages(git, bare)
322
323
        def oak_hash(git_commit: str) -> str:
324
            msg = commit_msgs.get(git_commit)
325
            if msg is None or msg not in messages:
326
                raise HarnessError(f"cannot map git commit {git_commit[:12]} to an oak hash")
327
            return messages[msg]
328
329
        # 2. ours clone imports the fork point; scripted steps follow.
330
        _point_head(git, bare, plan["fork"])
331
        run.oak(case_dir, "clone", str(bare), "ours", label="import-fork")
332
        shutil.rmtree(ours_dir / ".git", ignore_errors=True)
333
        linked = False
334
        for step in plan["steps"]:
335
            if step["op"] == "commit":
336
                _clear_worktree(ours_dir)
337
                git.materialize(step["tree"], ours_dir)
338
                st = run.oak(ours_dir, "status", "--json", label="status")
339
                if (_json_tail(st["stdout"]) or {}).get("changes"):
340
                    run.oak(ours_dir, "commit", "--json", label="commit")
341
            elif step["op"] == "pull":
342
                server.shim.stage(repo_key, oak_hash(step["stage"]))
343
                run.oak(ours_dir, "push", "-r", server.url, "--repo", repo_key, label="push")
344
                linked = True
345
                pull = run.oak(ours_dir, "pull", "--json", ok=(0, 5), label="pull-step")
346
                step_receipt = _json_tail(pull["stdout"]) or {}
347
                if pull["returncode"] == 5 and step_receipt.get("parent_sync") != "conflict":
348
                    raise StepFailed("setup pull failed: " + str(step_receipt.get("parent_sync_error")), pull)
349
                if pull["returncode"] == 5:
350
                    if step.get("resolve") is None:
351
                        raise StepFailed("setup pull conflicted without a scripted resolution", pull)
352
                    for path, content in step["resolve"].items():
353
                        target = ours_dir / path
354
                        if content is None:
355
                            target.unlink(missing_ok=True)
356
                        else:
357
                            target.parent.mkdir(parents=True, exist_ok=True)
358
                            target.write_bytes(content)
359
                    run.oak(ours_dir, "pull", "--continue", label="pull-continue")
360
            else:
361
                raise HarnessError("unknown step op " + step["op"])
362
363
        # 3. measured merge: pin final main, predict, then pull.
364
        final = plan.get("final_stage")
365
        server.shim.stage(repo_key, oak_hash(final) if final else None)
366
        if not linked or plan["steps"]:
367
            run.oak(ours_dir, "push", "-r", server.url, "--repo", repo_key, label="push")
368
        run.oak(ours_dir, "fetch", ok=None, label="fetch")
369
        pred = run.oak(ours_dir, "merge", "--dry-run", "--json", ok=None, label="predict")
370
        result["prediction"] = summarize_prediction(_json_tail(pred["stdout"]), pred["returncode"])
371
        pre = _json_tail(run.oak(ours_dir, "status", "--json", label="pre-pull-status")["stdout"]) or {}
372
        pull = run.oak(ours_dir, "pull", "--json", ok=None, label="measured-pull")
373
        receipt = _json_tail(pull["stdout"]) or {}
374
        result["pull_returncode"] = pull["returncode"]
375
        result["pull_receipt"] = {k: receipt.get(k) for k in (
376
            "parent_sync", "convergence", "parent_head_before", "parent_head_after",
377
            "head_before", "head_after", "parent_sync_error", "parent_sync_error_code")}
378
        result["tree"] = worktree_manifest(ours_dir)
379
        result["result_source"] = "worktree-after-pull"
380
        if pull["returncode"] == 5 and receipt.get("parent_sync") == "conflict":
381
            result["clean"] = False
382
            show = run.oak(ours_dir, "conflict", "show", "--json", ok=None, label="conflict-show")
383
            result["conflicts"] = parse_conflict_show(_json_tail(show["stdout"]), receipt)
384
            # The conflicted worktree is a working view, not the merge result: Oak writes the
385
            # merged manifest's entries but leaves paths the merge deletes in place until
386
            # `--continue`. Measure the committed result: overwrite each conflicted path with a
387
            # sentinel resolution, continue, and read the (then clean) worktree.
388
            result["conflict_worktree"] = result["tree"]
389
            for path in result["conflicts"]:
390
                target = ours_dir / path
391
                if target.is_file() and not target.is_symlink():
392
                    target.write_bytes(b"mergesuite sentinel resolution\n")
393
            cont = run.oak(ours_dir, "pull", "--continue", ok=None, label="continue-sentinel")
394
            st = _json_tail(run.oak(ours_dir, "status", "--json", label="status")["stdout"]) or {}
395
            if cont["returncode"] == 0 and not st.get("changes"):
396
                result["tree"] = worktree_manifest(ours_dir)
397
                result["result_source"] = "committed-after-sentinel-continue"
398
                result["conflict_worktree_stale_paths"] = sorted(
399
                    p for p in set(result["conflict_worktree"]) - set(result["tree"])
400
                    if p not in result["conflicts"])
401
            else:
402
                result["result_source"] = "conflict-worktree (continue failed)"
403
        elif pull["returncode"] == 0 and receipt.get("parent_sync") in ("merged", "up_to_date",
404
                                                                       "fast_forward", "fast_forwarded"):
405
            result["clean"] = True
406
            result["conflicts"] = {}
407
            st = _json_tail(run.oak(ours_dir, "status", "--json", label="status")["stdout"]) or {}
408
            result["worktree_matches_head"] = not st.get("changes")
409
            again = run.oak(ours_dir, "pull", "--json", ok=None, label="idempotence-pull")
410
            again_receipt = _json_tail(again["stdout"]) or {}
411
            result["idempotence"] = {
412
                "returncode": again["returncode"],
413
                "parent_sync": again_receipt.get("parent_sync"),
414
                "head_unchanged": again_receipt.get("head_after") == receipt.get("head_after"),
415
                "tree_unchanged": worktree_manifest(ours_dir) == result["tree"],
416
            }
417
        else:
418
            result["clean"] = None
419
            result["error"] = (receipt.get("parent_sync_error") or pull["stderr"][-500:]
420
                               or f"oak pull exited {pull['returncode']}")
421
            result["error_kind"] = "oak"
422
            result["post_failure"] = post_failure_invariants(run, ours_dir, pre, result["tree"])
423
    except StepFailed as exc:
424
        result["error"] = f"{exc}: {exc.step['stderr'][-400:]}"
425
        result["error_kind"] = "oak-setup"
426
    except (HarnessError, ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
427
        result["error"] = f"{type(exc).__name__}: {exc}"
428
        result["error_kind"] = "harness"
429
    result["shim_log"] = [line for line in server.shim.log if repo_key.split("/")[1] in line][-5:]
430
    return result
431
432
433
def post_failure_invariants(run: "OakRun", ours_dir: Path, pre: dict, tree_after: dict) -> dict:
434
    """After a failed (non-conflict) pull, check HEAD vs worktree vs what a retry claims.
435
436
    A failed sync must leave the branch where it was (HEAD unchanged, worktree
437
    matching HEAD). The hazard: HEAD advanced to a merge commit while the
438
    worktree still holds the pre-merge state, so the next pull says
439
    up_to_date and the next commit silently reverts main's changes."""
440
    st = _json_tail(run.oak(ours_dir, "status", "--json", ok=None, label="post-failure-status")["stdout"]) or {}
441
    retry = run.oak(ours_dir, "pull", "--json", ok=None, label="post-failure-repull")
442
    retry_receipt = _json_tail(retry["stdout"]) or {}
443
    head_moved = bool(pre.get("head")) and st.get("head") != pre.get("head")
444
    changes = st.get("changes") or []
445
    return {
446
        "head_before": pre.get("head"), "head_after": st.get("head"), "head_moved": head_moved,
447
        "worktree_changes_vs_head": len(changes),
448
        "worktree_changes_sample": [c.get("path") for c in changes[:8]],
449
        "sync_in_progress": st.get("sync_in_progress"),
450
        "repull_returncode": retry["returncode"], "repull_parent_sync": retry_receipt.get("parent_sync"),
451
        "pre_pull_worktree_changes": len(pre.get("changes") or []),
452
        "hazard_head_moved_worktree_stale": head_moved and bool(changes),
453
        # HEAD stayed put but the failed sync half-applied the merge to a worktree that
454
        # was clean before the pull: the invariant "failed sync => worktree matches HEAD".
455
        "hazard_worktree_dirty_head_unmoved": (not head_moved) and bool(changes)
456
        and not (pre.get("changes") or []),
457
    }
458
459
460
def _point_head(git: Git, bare: Path, commit: str) -> None:
461
    g = Git(bare, git.bin, git.home)
462
    g.run("update-ref", "refs/heads/oak-import", commit)
463
    g.run("symbolic-ref", "HEAD", "refs/heads/oak-import")
464
465
466
def _git_messages(git: Git, bare: Path) -> Dict[str, str]:
467
    g = Git(bare, git.bin, git.home)
468
    raw = g.run("log", "--all", "--format=%H%x00%B%x01").stdout.decode("utf-8", "replace")
469
    out = {}
470
    for rec in raw.split("\x01"):
471
        rec = rec.strip("\n")
472
        if "\x00" in rec:
473
            h, msg = rec.split("\x00", 1)
474
            out[h.strip()] = msg.strip()
475
    return out
476
477
478
def summarize_prediction(doc: Optional[dict], returncode: int) -> dict:
479
    if not doc:
480
        return {"available": False, "returncode": returncode}
481
    return {
482
        "available": bool(doc.get("prediction_available")),
483
        "returncode": returncode,
484
        "method": doc.get("prediction_method"),
485
        "clean": doc.get("clean"),
486
        "conflict_files": sorted(_path_of(x) for x in doc.get("conflict_files") or []),
487
        "certified": (doc.get("merge_safety") or {}).get("certified"),
488
        "fork_base": (doc.get("merge_safety") or {}).get("fork_base_commit"),
489
    }
490
491
492
def _path_of(item) -> str:
493
    return item.get("path") if isinstance(item, dict) else str(item)
494
495
496
def parse_conflict_show(doc: Optional[dict], receipt: dict) -> Dict[str, List[str]]:
497
    """{path: [oak-reported kinds]} from `oak conflict show --json` (fallback: receipt)."""
498
    paths: Dict[str, List[str]] = {}
499
    if doc:
500
        for key in ("paths", "conflicts", "files", "conflict_paths"):
501
            items = doc.get(key)
502
            if isinstance(items, list):
503
                for item in items:
504
                    if isinstance(item, dict) and item.get("path"):
505
                        kind = item.get("kind") or item.get("conflict_kind") or item.get("type")
506
                        facts = [str(kind)] if kind else []
507
                        if item.get("has_conflict_markers") is False:
508
                            facts.append("no-markers")
509
                        if item.get("exists") is False:
510
                            facts.append("absent-in-worktree")
511
                        paths[item["path"]] = facts
512
                    elif isinstance(item, str):
513
                        paths[item] = []
514
                if paths:
515
                    break
516
    if not paths:
517
        for p in receipt.get("conflict_paths") or []:
518
            paths[p] = []
519
    return dict(sorted(paths.items()))
520
521
522
class HostedMergeAdapter:
523
    """Phase-2 plug point: server-side (oakspace) merge of branch into main.
524
525
    Contract: given the same plan, return the same result dict shape as
526
    run_pull_sync with path_exercised="oakspace-server-merge". Needs a local
527
    oakspace (Postgres + oak-web) exposing POST /api/:o/:r/branches/:b/merge.
528
    Not implemented in phase 1.
529
    """
530
531
    path_exercised = "oakspace-server-merge"
532
533
    def run(self, *args, **kwargs):  # pragma: no cover - phase 2
534
        raise NotImplementedError("hosted merge adapter is phase 2")