Viewing files at commit 46ad54da0178Commit detailsView latest

suites/merge/mergesuite/oak_adapter.py 505 lines · 23.0 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
        pull = run.oak(ours_dir, "pull", "--json", ok=None, label="measured-pull")
372
        receipt = _json_tail(pull["stdout"]) or {}
373
        result["pull_returncode"] = pull["returncode"]
374
        result["pull_receipt"] = {k: receipt.get(k) for k in (
375
            "parent_sync", "convergence", "parent_head_before", "parent_head_after",
376
            "head_before", "head_after", "parent_sync_error", "parent_sync_error_code")}
377
        result["tree"] = worktree_manifest(ours_dir)
378
        result["result_source"] = "worktree-after-pull"
379
        if pull["returncode"] == 5 and receipt.get("parent_sync") == "conflict":
380
            result["clean"] = False
381
            show = run.oak(ours_dir, "conflict", "show", "--json", ok=None, label="conflict-show")
382
            result["conflicts"] = parse_conflict_show(_json_tail(show["stdout"]), receipt)
383
            # The conflicted worktree is a working view, not the merge result: Oak writes the
384
            # merged manifest's entries but leaves paths the merge deletes in place until
385
            # `--continue`. Measure the committed result: overwrite each conflicted path with a
386
            # sentinel resolution, continue, and read the (then clean) worktree.
387
            result["conflict_worktree"] = result["tree"]
388
            for path in result["conflicts"]:
389
                target = ours_dir / path
390
                if target.is_file() and not target.is_symlink():
391
                    target.write_bytes(b"mergesuite sentinel resolution\n")
392
            cont = run.oak(ours_dir, "pull", "--continue", ok=None, label="continue-sentinel")
393
            st = _json_tail(run.oak(ours_dir, "status", "--json", label="status")["stdout"]) or {}
394
            if cont["returncode"] == 0 and not st.get("changes"):
395
                result["tree"] = worktree_manifest(ours_dir)
396
                result["result_source"] = "committed-after-sentinel-continue"
397
                result["conflict_worktree_stale_paths"] = sorted(
398
                    p for p in set(result["conflict_worktree"]) - set(result["tree"])
399
                    if p not in result["conflicts"])
400
            else:
401
                result["result_source"] = "conflict-worktree (continue failed)"
402
        elif pull["returncode"] == 0 and receipt.get("parent_sync") in ("merged", "up_to_date",
403
                                                                       "fast_forward", "fast_forwarded"):
404
            result["clean"] = True
405
            result["conflicts"] = {}
406
            st = _json_tail(run.oak(ours_dir, "status", "--json", label="status")["stdout"]) or {}
407
            result["worktree_matches_head"] = not st.get("changes")
408
            again = run.oak(ours_dir, "pull", "--json", ok=None, label="idempotence-pull")
409
            again_receipt = _json_tail(again["stdout"]) or {}
410
            result["idempotence"] = {
411
                "returncode": again["returncode"],
412
                "parent_sync": again_receipt.get("parent_sync"),
413
                "head_unchanged": again_receipt.get("head_after") == receipt.get("head_after"),
414
                "tree_unchanged": worktree_manifest(ours_dir) == result["tree"],
415
            }
416
        else:
417
            result["clean"] = None
418
            result["error"] = (receipt.get("parent_sync_error") or pull["stderr"][-500:]
419
                               or f"oak pull exited {pull['returncode']}")
420
            result["error_kind"] = "oak"
421
    except StepFailed as exc:
422
        result["error"] = f"{exc}: {exc.step['stderr'][-400:]}"
423
        result["error_kind"] = "oak-setup"
424
    except (HarnessError, ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
425
        result["error"] = f"{type(exc).__name__}: {exc}"
426
        result["error_kind"] = "harness"
427
    result["shim_log"] = [line for line in server.shim.log if repo_key.split("/")[1] in line][-5:]
428
    return result
429
430
431
def _point_head(git: Git, bare: Path, commit: str) -> None:
432
    g = Git(bare, git.bin, git.home)
433
    g.run("update-ref", "refs/heads/oak-import", commit)
434
    g.run("symbolic-ref", "HEAD", "refs/heads/oak-import")
435
436
437
def _git_messages(git: Git, bare: Path) -> Dict[str, str]:
438
    g = Git(bare, git.bin, git.home)
439
    raw = g.run("log", "--all", "--format=%H%x00%B%x01").stdout.decode("utf-8", "replace")
440
    out = {}
441
    for rec in raw.split("\x01"):
442
        rec = rec.strip("\n")
443
        if "\x00" in rec:
444
            h, msg = rec.split("\x00", 1)
445
            out[h.strip()] = msg.strip()
446
    return out
447
448
449
def summarize_prediction(doc: Optional[dict], returncode: int) -> dict:
450
    if not doc:
451
        return {"available": False, "returncode": returncode}
452
    return {
453
        "available": bool(doc.get("prediction_available")),
454
        "returncode": returncode,
455
        "method": doc.get("prediction_method"),
456
        "clean": doc.get("clean"),
457
        "conflict_files": sorted(_path_of(x) for x in doc.get("conflict_files") or []),
458
        "certified": (doc.get("merge_safety") or {}).get("certified"),
459
        "fork_base": (doc.get("merge_safety") or {}).get("fork_base_commit"),
460
    }
461
462
463
def _path_of(item) -> str:
464
    return item.get("path") if isinstance(item, dict) else str(item)
465
466
467
def parse_conflict_show(doc: Optional[dict], receipt: dict) -> Dict[str, List[str]]:
468
    """{path: [oak-reported kinds]} from `oak conflict show --json` (fallback: receipt)."""
469
    paths: Dict[str, List[str]] = {}
470
    if doc:
471
        for key in ("paths", "conflicts", "files", "conflict_paths"):
472
            items = doc.get(key)
473
            if isinstance(items, list):
474
                for item in items:
475
                    if isinstance(item, dict) and item.get("path"):
476
                        kind = item.get("kind") or item.get("conflict_kind") or item.get("type")
477
                        facts = [str(kind)] if kind else []
478
                        if item.get("has_conflict_markers") is False:
479
                            facts.append("no-markers")
480
                        if item.get("exists") is False:
481
                            facts.append("absent-in-worktree")
482
                        paths[item["path"]] = facts
483
                    elif isinstance(item, str):
484
                        paths[item] = []
485
                if paths:
486
                    break
487
    if not paths:
488
        for p in receipt.get("conflict_paths") or []:
489
            paths[p] = []
490
    return dict(sorted(paths.items()))
491
492
493
class HostedMergeAdapter:
494
    """Phase-2 plug point: server-side (oakspace) merge of branch into main.
495
496
    Contract: given the same plan, return the same result dict shape as
497
    run_pull_sync with path_exercised="oakspace-server-merge". Needs a local
498
    oakspace (Postgres + oak-web) exposing POST /api/:o/:r/branches/:b/merge.
499
    Not implemented in phase 1.
500
    """
501
502
    path_exercised = "oakspace-server-merge"
503
504
    def run(self, *args, **kwargs):  # pragma: no cover - phase 2
505
        raise NotImplementedError("hosted merge adapter is phase 2")