Viewing files at commit 3cd2661fdbe9Commit detailsView latest

suites/scale/scalesuite/ops.py 900 lines · 40.0 KB · python
1
"""The scale-lane operation catalog: every user-facing op, git and oak.
2
3
Each op is one function per subject kind. Inside it, ONLY ``ctx.timed(...)``
4
calls are measured (their elapsed times are summed into the row); setup,
5
oracle and teardown use ``ctx.untimed(...)`` and run outside the timed path
6
(ADR-0011: oracles untimed). An op whose timed command fails emits no oracle
7
verdict (the row carries the failure); an op with no equivalent for a kind
8
returns a skip (returncode 77 with a reason) so coverage gaps are rows, never
9
silent omissions.
10
11
Oracle evidence classes (``oracle_reader``):
12
- ``git_fixture_instrument``: expected values read from the generated
13
  fixture with git plumbing, compared against the subject's worktree bytes
14
  hashed by the harness (independent of the subject under test);
15
- ``subject_output``: the subject's own output parsed by the harness
16
  (counts, names, JSON fields);
17
- ``subject_file_inspect``: Oak's ``oak file inspect`` (subject-produced)
18
  or git's ``git show``/``cat-file`` of the subject repository.
19
20
t/perf references cite upstream git test FILE NAMES only (GPL-2.0); the ops
21
are re-implemented here, nothing is copied (docs/prior-art.md).
22
"""
23
24
from __future__ import annotations
25
26
import hashlib
27
import json
28
import os
29
import random
30
import shutil
31
from dataclasses import dataclass, field
32
from pathlib import Path
33
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
34
35
from . import generator as gen
36
37
UNIT_ORDER: Tuple[Tuple[str, ...], ...] = (
38
    ("status.clean",),
39
    ("log.full",),
40
    ("log.n",),
41
    ("log.path",),
42
    ("blame",),
43
    ("file.show",),
44
    ("branch.list",),
45
    ("review.branch_diff",),
46
    ("merge.preview",),
47
    ("merge.preview.conflict",),
48
    ("switch.existing", "switch.back"),
49
    ("branch.create",),
50
    ("status.dirty", "diff.dirty", "commit.dirty"),
51
    ("merge.local",),
52
    ("merge.conflict",),
53
    ("push.branch", "pull.ff"),
54
    ("pull.diverged",),
55
    ("clone.full",),
56
    ("gc",),
57
)
58
59
60
@dataclass(frozen=True)
61
class OpInfo:
62
    name: str
63
    description: str
64
    tperf: Tuple[str, ...] = ()
65
    core_yaml: Tuple[str, ...] = ()
66
    mutates: bool = False
67
68
69
OP_INFO: Dict[str, OpInfo] = {info.name: info for info in (
70
    OpInfo("status.clean", "Status of a clean working tree (porcelain).",
71
           ("p0005-status.sh", "p7519-fsmonitor.sh"), ("status.clean",)),
72
    OpInfo("status.dirty", "Status after the tier's dirty shape (modify/add/delete/rename).",
73
           ("p0005-status.sh", "p7519-fsmonitor.sh"), ("status.dirty",)),
74
    OpInfo("diff.dirty", "Full worktree diff of the dirty shape.",
75
           ("p4000-diff-algorithms.sh", "p0009-diff-pathspec.sh"), ("diff.dirty",)),
76
    OpInfo("commit.dirty", "Commit the dirty shape (git: add -A + commit, summed).",
77
           ("p0007-write-cache.sh", "p0090-cache-tree.sh"), ("snapshot.dirty",), mutates=True),
78
    OpInfo("log.full", "Entire history reachable from the current branch, one line per commit.",
79
           ("p0001-rev-list.sh", "p4205-log-pretty-formats.sh"), ("history.log.range",)),
80
    OpInfo("log.n", "The newest 20 commits.", ("p4205-log-pretty-formats.sh",), ("history.log.tip",)),
81
    OpInfo("log.path", "History of one path (the fixture's probe path).",
82
           ("p4211-line-log.sh", "p0001-rev-list.sh"), ("history.log.range",)),
83
    OpInfo("blame", "Line authorship of the probe path.", ("p8020-last-modified.sh",), ()),
84
    OpInfo("file.show", "Committed bytes of the probe path at HEAD.", ("p1006-cat-file.sh",), ()),
85
    OpInfo("branch.list", "Enumerate branches.", ("p6300-for-each-ref.sh",), ("branch.list",)),
86
    OpInfo("branch.create", "Create and switch to a new branch.", ("p1400-update-ref.sh",),
87
           ("branch.create",), mutates=True),
88
    OpInfo("switch.existing", "Switch to the fixture's side branch b00000.",
89
           ("p0006-read-tree-checkout.sh", "p2000-sparse-operations.sh"), ("branch.switch",), mutates=True),
90
    OpInfo("switch.back", "Switch back to the base branch.", ("p0006-read-tree-checkout.sh",),
91
           ("history.restore_head",), mutates=True),
92
    OpInfo("review.branch_diff", "Checkout-free diff of b00000 against main.",
93
           ("p4000-diff-algorithms.sh", "p6010-merge-base.sh"), ()),
94
    OpInfo("merge.preview", "Dry-run merge prediction of b00000 into main.",
95
           ("p6010-merge-base.sh",), ()),
96
    OpInfo("merge.preview.conflict", "Dry-run merge prediction of the conflict-shape branches.",
97
           ("p6010-merge-base.sh",), ("merge.conflict",)),
98
    OpInfo("merge.local", "Local (non-fast-forward) merge of b00000 into a scratch branch.",
99
           ("p6010-merge-base.sh", "p3400-rebase.sh"), (), mutates=True),
100
    OpInfo("merge.conflict", "Local merge of the conflict-shape branches (expected to conflict), then abort.",
101
           ("p6010-merge-base.sh",), ("merge.conflict", "merge.abort"), mutates=True),
102
    OpInfo("push.branch", "Push one new commit over loopback HTTP.",
103
           ("p5313-pack-objects.sh", "p5310-pack-bitmaps.sh"), (), mutates=True),
104
    OpInfo("pull.ff", "Pull one upstream commit (fast-forward).",
105
           ("p5311-pack-bitmaps-fetch.sh", "p5551-fetch-rescan.sh"), (), mutates=True),
106
    OpInfo("pull.diverged", "Pull when local and upstream both have one new commit.",
107
           ("p5311-pack-bitmaps-fetch.sh", "p3400-rebase.sh"), (), mutates=True),
108
    OpInfo("clone.full", "Full clone over loopback HTTP into a new directory.",
109
           ("p5600-partial-clone.sh", "p5601-clone-reference.sh"), ()),
110
    OpInfo("gc", "Repository maintenance (git gc / oak maintenance compact).",
111
           ("p5304-prune.sh", "p5313-pack-objects.sh", "p5303-many-packs.sh"), (), mutates=True),
112
)}
113
114
ALL_OPS: Tuple[str, ...] = tuple(op for unit in UNIT_ORDER for op in unit)
115
assert set(ALL_OPS) == set(OP_INFO), "UNIT_ORDER and OP_INFO must list the same operations"
116
117
118
@dataclass
119
class Timed:
120
    label: str
121
    command: List[str]
122
    capture: Any
123
124
125
@dataclass
126
class Outcome:
127
    timed: List[Timed] = field(default_factory=list)
128
    failed: Optional[str] = None
129
    skip_reason: Optional[str] = None
130
    oracle_ok: Optional[bool] = None
131
    oracle_method: Optional[str] = None
132
    oracle_reader: Optional[str] = None
133
    oracle_detail: Dict[str, Any] = field(default_factory=dict)
134
    semantic_class: Optional[str] = None
135
    extra: Dict[str, Any] = field(default_factory=dict)
136
137
138
class OpFailed(Exception):
139
    """A timed command failed; the row records it and the unit aborts."""
140
141
142
class OpSkipped(Exception):
143
    pass
144
145
146
class Ctx:
147
    """Per (subject, run, op) execution context."""
148
149
    def __init__(self, prepared: Any, subject: Any, run: int, runner: Callable[..., Any],
150
                 scratch: Path, timeout_s: float) -> None:
151
        self.p = prepared
152
        self.subject = subject
153
        self.kind = subject.kind
154
        self.bin = str(subject.bin)
155
        self.run = run
156
        self._run_timed = runner
157
        self.scratch = scratch
158
        self.timeout_s = timeout_s
159
        self.outcome = Outcome()
160
        self.tag = f"{subject.name}-r{run}"
161
162
    # -- command helpers ----------------------------------------------------
163
164
    def timed(self, args: Sequence[str], cwd: Path, *, label: str = "main", accept: Sequence[int] = (0,),
165
              raw: bool = False) -> Any:
166
        command = [self.bin, *args]
167
        capture = self._run_timed(command, cwd, self.p.env, 4000, full_output_bytes=64 << 20,
168
                                  timeout_seconds=self.timeout_s, raw_output_bytes=(64 << 20) if raw else None)
169
        self.outcome.timed.append(Timed(label, command, capture))
170
        if capture.returncode not in accept:
171
            tail = (capture.stderr_text or capture.stdout_text or "")[-400:]
172
            raise OpFailed(f"{label}: exit {capture.returncode}: {tail}")
173
        return capture
174
175
    def untimed(self, args: Sequence[str], cwd: Path, *, check: bool = True, binary: Optional[str] = None
176
                ) -> Any:
177
        from .workspace import run
178
        return run([binary or self.bin, *args], cwd=cwd, env=self.p.env, check=check)
179
180
    def git(self, args: Sequence[str], cwd: Path, check: bool = True) -> Any:
181
        """The oracle's git instrument (the resolved real git), never the subject binary."""
182
        return self.untimed(args, cwd, check=check, binary=self.p.git_bin)
183
184
    def oracle(self, ok: bool, method: str, reader: str, **detail: Any) -> None:
185
        self.outcome.oracle_ok = bool(ok)
186
        self.outcome.oracle_method = method
187
        self.outcome.oracle_reader = reader
188
        self.outcome.oracle_detail.update(detail)
189
190
    # -- workspace paths ----------------------------------------------------
191
192
    @property
193
    def work(self) -> Path:
194
        return self.p.path(self.kind, "work")
195
196
    def client(self, name: str) -> Path:
197
        return self.p.path(self.kind, name)
198
199
    @property
200
    def state(self) -> Dict[str, Any]:
201
        return self.p.state.setdefault(self.kind, {"harness_commits": 0, "deleted": set(), "server_extra": 0,
202
                                                     "branch_seq": 0})
203
204
205
# ---------------------------------------------------------------------------
206
# Shared oracle helpers (harness-side, independent of the subject)
207
# ---------------------------------------------------------------------------
208
209
210
def worktree_files(root: Path) -> List[str]:
211
    out = []
212
    for base, dirs, files in os.walk(root):
213
        dirs[:] = [d for d in dirs if d not in (".git", ".oak")]
214
        rel = os.path.relpath(base, root)
215
        for name in files:
216
            out.append(name if rel == "." else f"{rel}/{name}".replace(os.sep, "/"))
217
    return out
218
219
220
def blob_id_of(path: Path) -> str:
221
    digest = hashlib.sha1()
222
    size = path.stat().st_size
223
    digest.update(b"blob %d\0" % size)
224
    with path.open("rb") as fh:
225
        for chunk in iter(lambda: fh.read(1 << 20), b""):
226
            digest.update(chunk)
227
    return digest.hexdigest()
228
229
230
def check_worktree(ctx: Ctx, root: Path, expected_count: Optional[int], ignore_prefixes: Sequence[str] = ()
231
                   ) -> Tuple[bool, Dict[str, Any]]:
232
    """Sampled content check against the fixture plus an exact file count."""
233
    exp = ctx.p.expected
234
    files = [f for f in worktree_files(root) if not any(f.startswith(prefix) for prefix in ignore_prefixes)]
235
    mismatched = []
236
    for rel, blob in exp.sample.items():
237
        if rel in ctx.state["deleted"]:
238
            continue
239
        target = root / rel
240
        if not target.is_file() or blob_id_of(target) != blob:
241
            mismatched.append(rel)
242
    detail = {"files_seen": len(files), "files_expected": expected_count, "sample_checked": len(exp.sample),
243
              "sample_mismatched": mismatched[:10], "sample_mismatch_count": len(mismatched)}
244
    ok = not mismatched and (expected_count is None or len(files) == expected_count)
245
    return ok, detail
246
247
248
def porcelain_paths(text: str) -> List[str]:
249
    paths = []
250
    for line in text.splitlines():
251
        line = line.rstrip()
252
        if len(line) < 3:
253
            continue
254
        rest = line[2:].strip() if line[2] == " " or line[1] == " " else line.split(None, 1)[-1]
255
        if " -> " in rest:
256
            old, new = rest.split(" -> ", 1)
257
            paths.extend([old.strip('"'), new.strip('"')])
258
        else:
259
            paths.append(rest.strip('"'))
260
    return paths
261
262
263
# ---------------------------------------------------------------------------
264
# Dirty shape (untimed setup)
265
# ---------------------------------------------------------------------------
266
267
268
def apply_dirty(ctx: Ctx) -> Dict[str, Any]:
269
    spec = ctx.p.tier.spec
270
    shape = ctx.p.tier.dirty
271
    cache = gen.layout(spec)
272
    factory = gen.ContentFactory(spec)
273
    protected = set(ctx.p.expected.protected)
274
    rng = random.Random(f"{spec.seed}:dirty:{ctx.tag}")
275
    deleted: set = ctx.state["deleted"]
276
    wanted = shape.modified + shape.deleted + shape.renamed
277
    pool: List[int] = []
278
    attempts = 0
279
    seen = set()
280
    while len(pool) < wanted and attempts < wanted * 20 + 100:
281
        attempts += 1
282
        index = rng.randrange(spec.files)
283
        path = gen.file_path(spec, index, cache)
284
        if index in seen or path in protected or path in deleted:
285
            continue
286
        seen.add(index)
287
        pool.append(index)
288
    modified = pool[:shape.modified]
289
    removed = pool[shape.modified:shape.modified + shape.deleted]
290
    renamed = pool[shape.modified + shape.deleted:]
291
    root = ctx.work
292
    mod_paths, del_paths, ren_pairs, add_paths = [], [], [], []
293
    for index in modified:
294
        path = gen.file_path(spec, index, cache)
295
        (root / path).write_bytes(factory.text(index, f"dirty-{ctx.tag}"))
296
        mod_paths.append(path)
297
    for index in removed:
298
        path = gen.file_path(spec, index, cache)
299
        (root / path).unlink()
300
        del_paths.append(path)
301
    for index in renamed:
302
        path = gen.file_path(spec, index, cache)
303
        target = f"renamed/{ctx.tag}/f{index}.txt"
304
        (root / target).parent.mkdir(parents=True, exist_ok=True)
305
        os.replace(root / path, root / target)
306
        ren_pairs.append((path, target))
307
    for number in range(shape.added):
308
        rel = f"added/{ctx.tag}/n{number}.txt"
309
        (root / rel).parent.mkdir(parents=True, exist_ok=True)
310
        (root / rel).write_bytes(factory.text(10_000_000 + number, f"add-{ctx.tag}"))
311
        add_paths.append(rel)
312
    dirty = {"modified": mod_paths, "deleted": del_paths, "renamed": ren_pairs, "added": add_paths,
313
             "requested": [shape.modified, shape.added, shape.deleted, shape.renamed],
314
             "applied": [len(mod_paths), len(add_paths), len(del_paths), len(ren_pairs)]}
315
    ctx.state["dirty"] = dirty
316
    return dirty
317
318
319
# ---------------------------------------------------------------------------
320
# Operations. Each returns None; results land in ctx.outcome.
321
# ---------------------------------------------------------------------------
322
323
324
def _is_git(ctx: Ctx) -> bool:
325
    return ctx.kind == "git"
326
327
328
def op_status_clean(ctx: Ctx) -> None:
329
    args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
330
    cap = ctx.timed(args, ctx.work)
331
    paths = porcelain_paths(cap.stdout_full_text or "")
332
    ctx.oracle(not paths, "clean_status_reports_no_paths", "subject_output", reported_paths=paths[:10])
333
334
335
def _dirty_branch_enter(ctx: Ctx) -> None:
336
    """The dirty cycle runs on a throwaway branch (untimed switch), so the base
337
    branch never accumulates harness commits: every run's switch/log/merge ops
338
    see the same repository state (no run-over-run drift)."""
339
    name = f"dirty-{ctx.tag}"
340
    if _is_git(ctx):
341
        ctx.git(["switch", "-q", "-c", name, "main"], ctx.work)
342
    else:
343
        ctx.untimed(["switch", "-c", name], ctx.work)
344
    ctx.state["dirty_branch"] = name
345
346
347
def _dirty_branch_leave(ctx: Ctx) -> None:
348
    name = ctx.state.pop("dirty_branch", None)
349
    if name is None:
350
        return
351
    if _is_git(ctx):
352
        ctx.git(["switch", "-q", "-f", "main"], ctx.work)
353
        ctx.git(["branch", "-q", "-D", name], ctx.work, check=False)
354
    else:
355
        ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
356
357
358
def recover(ctx: Ctx) -> Optional[str]:
359
    """Untimed repair after a failed unit: discard worktree changes, return to base."""
360
    try:
361
        ctx.state.pop("dirty", None)
362
        ctx.state.pop("dirty_branch", None)
363
        if _is_git(ctx):
364
            ctx.git(["merge", "--abort"], ctx.work, check=False)
365
            ctx.git(["reset", "-q", "--hard"], ctx.work, check=False)
366
            ctx.git(["clean", "-fdq", "-e", "build/"], ctx.work, check=False)
367
            ctx.git(["switch", "-q", "-f", "main"], ctx.work)
368
        else:
369
            ctx.untimed(["reset", "-f"], ctx.work, check=False)
370
            for extra in ("added", "renamed"):
371
                shutil.rmtree(ctx.work / extra, ignore_errors=True)
372
            ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
373
        return None
374
    except Exception as exc:  # noqa: BLE001 - reported on the row, never hidden
375
        return f"{type(exc).__name__}: {str(exc)[-300:]}"
376
377
378
def op_status_dirty(ctx: Ctx) -> None:
379
    _dirty_branch_enter(ctx)
380
    dirty = apply_dirty(ctx)
381
    ctx.outcome.extra["dirty_applied"] = dirty["applied"]
382
    args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
383
    cap = ctx.timed(args, ctx.work)
384
    text = cap.stdout_full_text or ""
385
    reported = set(porcelain_paths(text))
386
    must = set(dirty["modified"]) | set(dirty["deleted"]) | {old for old, _ in dirty["renamed"]}
387
    missing = sorted(must - reported)
388
    # Git collapses untracked directories ("?? added/<tag>/"); accept a
389
    # reported ancestor directory for added/renamed targets.
390
    new_paths = dirty["added"] + [new for _, new in dirty["renamed"]]
391
    uncovered = [p for p in new_paths if p not in reported and not any(
392
        p.startswith(r.rstrip("/") + "/") for r in reported if r.endswith("/") or r in ("added", "renamed"))]
393
    ctx.oracle(not missing and not uncovered, "dirty_paths_reported", "subject_output",
394
               missing=missing[:10], missing_count=len(missing), uncovered_new=uncovered[:10],
395
               uncovered_new_count=len(uncovered), stdout_bytes=cap.stdout_bytes)
396
397
398
def op_diff_dirty(ctx: Ctx) -> None:
399
    dirty = ctx.state.get("dirty")
400
    if not dirty:
401
        raise OpFailed("diff.dirty needs status.dirty's dirty shape")
402
    args = ["diff", "--no-ext-diff"] if _is_git(ctx) else ["diff", "--print"]
403
    cap = ctx.timed(args, ctx.work)
404
    text = cap.stdout_full_text
405
    if text is None:
406
        ctx.oracle(cap.stdout_bytes > 0, "diff_output_nonempty_over_8MiB", "subject_output",
407
                   stdout_bytes=cap.stdout_bytes)
408
        return
409
    must = dirty["modified"] + dirty["deleted"]
410
    missing = [p for p in must if p not in text]
411
    ctx.oracle(not missing, "diff_mentions_modified_and_deleted_paths", "subject_output",
412
               missing=missing[:10], missing_count=len(missing), stdout_bytes=cap.stdout_bytes)
413
414
415
def op_commit_dirty(ctx: Ctx) -> None:
416
    dirty = ctx.state.pop("dirty", None)
417
    if not dirty:
418
        raise OpFailed("commit.dirty needs status.dirty's dirty shape")
419
    if _is_git(ctx):
420
        ctx.timed(["add", "-A"], ctx.work, label="add")
421
        ctx.timed(["commit", "-q", "--no-verify", "-m", f"dirty {ctx.tag}"], ctx.work, label="commit")
422
    else:
423
        ctx.timed(["commit", "--json"], ctx.work)
424
    probe = dirty["modified"][0] if dirty["modified"] else None
425
    status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
426
    left = porcelain_paths(ctx.untimed(status_args, ctx.work).stdout.decode(errors="replace"))
427
    committed_ok, reader = True, "subject_file_inspect"
428
    if probe:
429
        want = (ctx.work / probe).read_bytes()
430
        if _is_git(ctx):
431
            got = ctx.git(["cat-file", "blob", f"HEAD:{probe}"], ctx.work, check=False).stdout
432
        else:
433
            out = ctx.scratch / f"inspect-{ctx.tag}.bin"
434
            ctx.untimed(["file", "inspect", "--at", "HEAD", "--json", probe, "--output", str(out)], ctx.work,
435
                        check=False)
436
            got = out.read_bytes() if out.exists() else b""
437
            if out.exists():
438
                out.unlink()
439
        committed_ok = got == want
440
    ctx.oracle(not left and committed_ok, "tree_clean_and_modified_bytes_committed", reader,
441
               left_after_commit=left[:10], committed_bytes_match=committed_ok)
442
    _dirty_branch_leave(ctx)
443
444
445
def _log_count(text: str) -> int:
446
    return sum(1 for line in text.splitlines() if line.strip() and not line.startswith("No matching commits"))
447
448
449
def op_log_full(ctx: Ctx) -> None:
450
    args = ["log", "--oneline"] if _is_git(ctx) else ["log", "--oneline", "-n", "100000000"]
451
    cap = ctx.timed(args, ctx.work)
452
    count = _log_count(cap.stdout_full_text) if cap.stdout_full_text is not None else None
453
    expected = ctx.p.expected.main_commits + ctx.state["harness_commits"]
454
    ctx.oracle(count == expected, "log_line_count_equals_history_length", "subject_output",
455
               lines=count, expected=expected, stdout_bytes=cap.stdout_bytes,
456
               output_over_capture_cap=count is None)
457
458
459
def op_log_n(ctx: Ctx) -> None:
460
    cap = ctx.timed(["log", "--oneline", "-n", "20"], ctx.work)
461
    count = _log_count(cap.stdout_full_text or "")
462
    expected = min(20, ctx.p.expected.main_commits + ctx.state["harness_commits"])
463
    ctx.oracle(count == expected, "log_n_line_count", "subject_output", lines=count, expected=expected)
464
465
466
def op_log_path(ctx: Ctx) -> None:
467
    probe = ctx.p.expected.probe_path
468
    args = ["log", "--oneline", "--", probe] if _is_git(ctx) else ["log", "--oneline", "-n", "100000000", probe]
469
    cap = ctx.timed(args, ctx.work)
470
    count = _log_count(cap.stdout_full_text or "")
471
    expected = ctx.p.expected.probe_commit_count
472
    ctx.oracle(count == expected, "path_log_count_equals_generator_touch_count", "subject_output",
473
               lines=count, expected=expected, probe_path=probe)
474
475
476
def op_blame(ctx: Ctx) -> None:
477
    if not _is_git(ctx):
478
        raise OpSkipped("oak_cli_has_no_blame_or_annotate_command")
479
    probe = ctx.p.expected.probe_path
480
    cap = ctx.timed(["blame", "-s", "--", probe], ctx.work)
481
    lines = _log_count(cap.stdout_full_text or "")
482
    ctx.oracle(lines == ctx.p.expected.probe_lines, "blame_line_count_equals_file_lines", "subject_output",
483
               lines=lines, expected=ctx.p.expected.probe_lines)
484
485
486
def op_file_show(ctx: Ctx) -> None:
487
    probe = ctx.p.expected.probe_path
488
    if _is_git(ctx):
489
        cap = ctx.timed(["show", f"HEAD:{probe}"], ctx.work, raw=True)
490
        got = cap.stdout_raw or b""
491
        reader = "subject_output"
492
    else:
493
        out = ctx.scratch / f"show-{ctx.tag}.bin"
494
        ctx.timed(["file", "inspect", "--at", "HEAD", "--json", probe, "--output", str(out)], ctx.work)
495
        got = out.read_bytes() if out.exists() else b""
496
        if out.exists():
497
            out.unlink()
498
        reader = "subject_file_inspect"
499
    ctx.oracle(got == ctx.p.expected.probe_bytes, "shown_bytes_equal_fixture_blob", reader,
500
               bytes=len(got), expected_bytes=len(ctx.p.expected.probe_bytes))
501
502
503
def op_branch_list(ctx: Ctx) -> None:
504
    if _is_git(ctx):
505
        cap = ctx.timed(["branch", "--list"], ctx.work)
506
        names = [line[2:].strip() for line in (cap.stdout_full_text or "").splitlines() if line.strip()]
507
        reader = "subject_output"
508
    else:
509
        cap = ctx.timed(["branch", "list", "--json"], ctx.work)
510
        try:
511
            names = [item.get("name") for item in json.loads(cap.stdout_full_text or "[]")]
512
        except ValueError:
513
            names = []
514
        reader = "subject_output_json"
515
    spec = ctx.p.tier.spec
516
    fixture_branches = spec.branches if _is_git(ctx) else ctx.p.oak_branches_created
517
    need = [gen.branch_name(0)] if fixture_branches else []
518
    ok = len(names) >= fixture_branches and all(n in names for n in need)
519
    ctx.oracle(ok, "branch_count_at_least_fixture_branches", reader, listed=len(names),
520
               fixture_branches=fixture_branches)
521
522
523
def _branch_paths_match(ctx: Ctx, root: Path, want: Dict[str, bytes]) -> Tuple[bool, List[str]]:
524
    bad = [rel for rel, data in want.items() if not (root / rel).is_file() or (root / rel).read_bytes() != data]
525
    return not bad, bad
526
527
528
def op_switch_existing(ctx: Ctx) -> None:
529
    exp = ctx.p.expected
530
    args = ["switch", "-q", exp.branch] if _is_git(ctx) else ["switch", exp.branch]
531
    ctx.timed(args, ctx.work)
532
    ok, bad = _branch_paths_match(ctx, ctx.work, exp.branch_bytes)
533
    ctx.oracle(ok, "branch_paths_bytes_equal_fixture", "git_fixture_instrument", mismatched=bad[:10])
534
535
536
def _base_branch(ctx: Ctx) -> str:
537
    return "main" if _is_git(ctx) else str(ctx.p.oak_base_branch)
538
539
540
def op_switch_back(ctx: Ctx) -> None:
541
    exp = ctx.p.expected
542
    args = ["switch", "-q", _base_branch(ctx)] if _is_git(ctx) else ["switch", _base_branch(ctx)]
543
    ctx.timed(args, ctx.work)
544
    ok, bad = _branch_paths_match(ctx, ctx.work, exp.main_bytes_of_branch_paths)
545
    ctx.oracle(ok, "base_paths_bytes_equal_fixture_main", "git_fixture_instrument", mismatched=bad[:10])
546
547
548
def current_branch(ctx: Ctx, root: Path) -> str:
549
    if _is_git(ctx):
550
        return ctx.git(["symbolic-ref", "--short", "HEAD"], root, check=False).stdout.decode().strip()
551
    out = ctx.untimed(["status", "--json"], root, check=False).stdout
552
    try:
553
        return str(json.loads(out).get("branch"))
554
    except ValueError:
555
        return ""
556
557
558
def op_branch_create(ctx: Ctx) -> None:
559
    ctx.state["branch_seq"] += 1
560
    name = f"new-{ctx.tag}-{ctx.state['branch_seq']}"
561
    args = ["switch", "-q", "-c", name] if _is_git(ctx) else ["switch", "-c", name]
562
    ctx.timed(args, ctx.work)
563
    now = current_branch(ctx, ctx.work)
564
    ctx.outcome.semantic_class = "git_branch_from_HEAD" if _is_git(ctx) else "oak_branch_from_main"
565
    # teardown: back to base (and drop the git branch so ref counts stay fixed)
566
    if _is_git(ctx):
567
        ctx.git(["switch", "-q", "main"], ctx.work)
568
        ctx.git(["branch", "-q", "-D", name], ctx.work)
569
    else:
570
        ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
571
    ctx.oracle(now == name, "current_branch_is_new_branch", "subject_output", current=now, wanted=name)
572
573
574
def op_review_branch_diff(ctx: Ctx) -> None:
575
    exp = ctx.p.expected
576
    if _is_git(ctx):
577
        cap = ctx.timed(["diff", "--name-status", f"main...{exp.branch}"], ctx.work)
578
        paths = [line.split("\t", 1)[-1] for line in (cap.stdout_full_text or "").splitlines() if "\t" in line]
579
        reader = "subject_output"
580
    else:
581
        cap = ctx.timed(["branch", "diff", exp.branch, "--json"], ctx.work)
582
        try:
583
            paths = [item["path"] for item in json.loads(cap.stdout_full_text or "{}").get("changed_files", [])]
584
        except (ValueError, KeyError, AttributeError):
585
            paths = []
586
        reader = "subject_output_json"
587
    ctx.oracle(sorted(paths) == sorted(exp.branch_paths), "changed_paths_equal_generator_branch_paths", reader,
588
               reported=sorted(paths)[:10], expected=sorted(exp.branch_paths)[:10])
589
590
591
def op_merge_preview(ctx: Ctx) -> None:
592
    exp = ctx.p.expected
593
    if _is_git(ctx):
594
        cap = ctx.timed(["merge-tree", "--write-tree", "main", exp.branch], ctx.work)
595
        tree = (cap.stdout_full_text or "").split("\n", 1)[0].strip()
596
        bad = []
597
        for rel, data in exp.branch_bytes.items():
598
            got = ctx.git(["cat-file", "blob", f"{tree}:{rel}"], ctx.work, check=False).stdout
599
            if got != data:
600
                bad.append(rel)
601
        ctx.oracle(bool(tree) and not bad, "clean_prediction_tree_has_branch_bytes", "git_fixture_instrument",
602
                   mismatched=bad[:10])
603
        ctx.outcome.semantic_class = "git_merge_tree_two_refs"
604
        return
605
    ctx.untimed(["switch", exp.branch], ctx.work)
606
    try:
607
        # Exit 7 = prediction computed but not certified against the remote
608
        # target head (documented exit code); the prediction itself is timed.
609
        cap = ctx.timed(["merge", "--dry-run", "--json"], ctx.work, accept=(0, 7))
610
    finally:
611
        ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
612
    try:
613
        data = json.loads((cap.stdout_full_text or "").splitlines()[0])
614
    except (ValueError, IndexError):
615
        data = {}
616
    changed = sorted(item.get("path") if isinstance(item, dict) else item for item in data.get("changed_files", []))
617
    ctx.outcome.semantic_class = "oak_merge_dry_run_current_into_main"
618
    ctx.outcome.extra["merge_certified"] = (data.get("merge_safety") or {}).get("certified")
619
    ctx.oracle(data.get("clean") is True and changed == sorted(exp.branch_paths),
620
               "clean_prediction_lists_branch_paths", "subject_output_json", clean=data.get("clean"),
621
               changed=changed[:10], expected=sorted(exp.branch_paths)[:10])
622
623
624
def op_merge_preview_conflict(ctx: Ctx) -> None:
625
    conflict = ctx.p.expected.conflict
626
    if not conflict:
627
        raise OpSkipped("fixture_has_no_conflict_shape")
628
    if not _is_git(ctx):
629
        raise OpSkipped("oak_merge_dry_run_predicts_current_branch_into_main_only")
630
    cap = ctx.timed(["merge-tree", "--write-tree", "--name-only", conflict["ours"], conflict["theirs"]],
631
                    ctx.work, accept=(1,))
632
    lines = (cap.stdout_full_text or "").split("\n\n", 1)[0].splitlines()[1:]
633
    ctx.oracle(len(lines) == conflict["paths"], "conflicted_path_count_equals_fixture", "subject_output",
634
               conflicted=len(lines), expected=conflict["paths"])
635
636
637
def op_merge_local(ctx: Ctx) -> None:
638
    if not _is_git(ctx):
639
        raise OpSkipped("oak_local_merge_needs_non_main_parent_unreachable_from_cli; "
640
                        "oak_serve_lacks_merge_to_main")
641
    exp = ctx.p.expected
642
    scratch = f"merge-{ctx.tag}"
643
    ctx.git(["switch", "-q", "-c", scratch, "main"], ctx.work)
644
    try:
645
        ctx.timed(["merge", "-q", "--no-ff", "--no-edit", exp.branch], ctx.work)
646
        parents = ctx.git(["rev-list", "--parents", "-n", "1", "HEAD"], ctx.work).stdout.split()
647
        ok, bad = _branch_paths_match(ctx, ctx.work, exp.branch_bytes)
648
        ctx.oracle(ok and len(parents) == 3, "merge_commit_two_parents_and_branch_bytes", "git_fixture_instrument",
649
                   parents=len(parents) - 1, mismatched=bad[:10])
650
    finally:
651
        ctx.git(["merge", "--abort"], ctx.work, check=False)
652
        ctx.git(["switch", "-q", "-f", "main"], ctx.work)
653
        ctx.git(["branch", "-q", "-D", scratch], ctx.work, check=False)
654
655
656
def op_merge_conflict(ctx: Ctx) -> None:
657
    conflict = ctx.p.expected.conflict
658
    if not conflict:
659
        raise OpSkipped("fixture_has_no_conflict_shape")
660
    if not _is_git(ctx):
661
        raise OpSkipped("oak_local_merge_needs_non_main_parent_unreachable_from_cli")
662
    scratch = f"conflict-{ctx.tag}"
663
    ctx.git(["switch", "-q", "-c", scratch, conflict["ours"]], ctx.work)
664
    try:
665
        ctx.timed(["merge", "-q", "--no-edit", conflict["theirs"]], ctx.work, accept=(1,))
666
        unmerged = ctx.git(["diff", "--name-only", "--diff-filter=U"], ctx.work).stdout.decode().split()
667
        ctx.oracle(len(unmerged) == conflict["paths"], "unmerged_path_count_equals_fixture", "subject_output",
668
                   unmerged=len(unmerged), expected=conflict["paths"])
669
    finally:
670
        ctx.git(["merge", "--abort"], ctx.work, check=False)
671
        ctx.git(["switch", "-q", "-f", "main"], ctx.work)
672
        ctx.git(["branch", "-q", "-D", scratch], ctx.work, check=False)
673
674
675
def _client_commit(ctx: Ctx, client: Path, rel: str, content: bytes) -> str:
676
    target = client / rel
677
    target.parent.mkdir(parents=True, exist_ok=True)
678
    target.write_bytes(content)
679
    if _is_git(ctx):
680
        ctx.git(["add", "-A"], client)
681
        ctx.git(["commit", "-q", "--no-verify", "-m", f"sync {rel}"], client)
682
        return ctx.git(["rev-parse", "HEAD"], client).stdout.decode().strip()
683
    ctx.untimed(["commit", "--json"], client)
684
    return json.loads(ctx.untimed(["status", "--json"], client).stdout)["head"]
685
686
687
def _head(ctx: Ctx, client: Path) -> str:
688
    if _is_git(ctx):
689
        return ctx.git(["rev-parse", "HEAD"], client).stdout.decode().strip()
690
    return json.loads(ctx.untimed(["status", "--json"], client).stdout)["head"]
691
692
693
def _oak_remote(ctx: Ctx) -> None:
694
    """Sync ops need seeded loopback servers; label an Oak snapshot seed."""
695
    if not getattr(ctx.p.tier, "sync", True):
696
        raise OpSkipped("tier_excludes_sync_clients_disk_budget")
697
    if _is_git(ctx):
698
        return
699
    if getattr(ctx.p, "oak_remote_unavailable", None):
700
        raise OpSkipped(str(ctx.p.oak_remote_unavailable))
701
    seed = getattr(ctx.p, "oak_seed", None)
702
    ctx.outcome.extra["oak_server_seed"] = seed
703
    if seed and seed != "full_history":
704
        ctx.outcome.semantic_class = f"oak_server_seed_{seed}"
705
706
707
def op_push_branch(ctx: Ctx) -> None:
708
    _oak_remote(ctx)
709
    client_b = ctx.client("clientB")
710
    rel = f"sync/push-{ctx.tag}.txt"
711
    head = _client_commit(ctx, client_b, rel, f"push {ctx.tag}\n".encode())
712
    ctx.state["pushed"] = (rel, head)
713
    if _is_git(ctx):
714
        ctx.timed(["push", "-q", "origin", "HEAD:main"], client_b)
715
        remote = ctx.git(["ls-remote", "origin", "refs/heads/main"], client_b).stdout.decode().split()
716
        ctx.state["server_extra"] += 1
717
        ctx.oracle(bool(remote) and remote[0] == head, "server_ref_equals_pushed_head", "git_ls_remote",
718
                   remote=remote[:1], pushed=head)
719
    else:
720
        cap = ctx.timed(["push", "--json"], client_b)
721
        try:
722
            receipt = json.loads((cap.stdout_full_text or "").splitlines()[-1])
723
        except (ValueError, IndexError):
724
            receipt = {}
725
        # Server-side confirmation comes from clientA's pull.ff oracle in the
726
        # same unit (independent checkout sees the pushed commit and bytes).
727
        ctx.outcome.extra["push_receipt_pushed"] = receipt.get("pushed")
728
        ctx.oracle(receipt.get("remote_contacted") is True, "push_receipt_remote_contacted_confirmed_by_pull_ff",
729
                   "subject_output_json", pushed=receipt.get("pushed"))
730
731
732
def op_pull_ff(ctx: Ctx) -> None:
733
    _oak_remote(ctx)
734
    pushed = ctx.state.pop("pushed", None)
735
    if not pushed:
736
        raise OpFailed("pull.ff needs push.branch in the same unit")
737
    rel, head = pushed
738
    client_a = ctx.client("clientA")
739
    if _is_git(ctx):
740
        ctx.timed(["pull", "-q", "--ff-only"], client_a)
741
    else:
742
        ctx.timed(["pull", "--json"], client_a)
743
    now = _head(ctx, client_a)
744
    data_ok = (client_a / rel).is_file() and (client_a / rel).read_bytes() == f"push {ctx.tag}\n".encode()
745
    ctx.oracle(now == head and data_ok, "puller_head_and_bytes_equal_pusher", "git_fixture_instrument",
746
               puller_head=now, pusher_head=head, bytes_ok=data_ok)
747
748
749
def op_pull_diverged(ctx: Ctx) -> None:
750
    _oak_remote(ctx)
751
    client_a, client_b = ctx.client("clientA"), ctx.client("clientB")
752
    local_rel, up_rel = f"sync/local-{ctx.tag}.txt", f"sync/up-{ctx.tag}.txt"
753
    _client_commit(ctx, client_a, local_rel, b"local\n")
754
    _client_commit(ctx, client_b, up_rel, b"upstream\n")
755
    if _is_git(ctx):
756
        ctx.git(["push", "-q", "origin", "HEAD:main"], client_b)
757
        ctx.state["server_extra"] += 1
758
        ctx.timed(["pull", "-q", "--no-rebase", "--no-edit"], client_a)
759
        ctx.outcome.semantic_class = "git_merge_commit"
760
    else:
761
        ctx.untimed(["push", "--json"], client_b)
762
        cap = ctx.timed(["pull", "--json"], client_a)
763
        try:
764
            receipt = json.loads((cap.stdout_full_text or "").splitlines()[-1])
765
        except (ValueError, IndexError):
766
            receipt = {}
767
        ctx.outcome.semantic_class = (ctx.outcome.semantic_class or "") + f"|oak_pull_{receipt.get('convergence', 'unknown')}"
768
    both = (client_a / local_rel).is_file() and (client_a / up_rel).is_file()
769
    status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
770
    left = porcelain_paths(ctx.untimed(status_args, client_a).stdout.decode(errors="replace"))
771
    ctx.oracle(both and not left, "both_sides_present_and_tree_clean", "git_fixture_instrument",
772
               local_present=(client_a / local_rel).is_file(), upstream_present=(client_a / up_rel).is_file(),
773
               left=left[:10])
774
    # Re-converge (untimed): A publishes its merge, B catches up. A failure
775
    # here is an observed subject defect in an UNTIMED step: it is recorded on
776
    # the row (teardown_error) without discarding the timed sample.
777
    if _is_git(ctx):
778
        ctx.git(["push", "-q", "origin", "HEAD:main"], client_a)
779
        ctx.state["server_extra"] += 1
780
        ctx.git(["pull", "-q", "--ff-only"], client_b)
781
        return
782
    ctx.untimed(["push", "--json"], client_a)
783
    done = ctx.untimed(["pull", "--json"], client_b, check=False)
784
    if done.returncode != 0:
785
        try:
786
            receipt = json.loads(done.stdout.decode(errors="replace").splitlines()[-1])
787
        except (ValueError, IndexError):
788
            receipt = {}
789
        ctx.outcome.extra["teardown_error"] = (
790
            f"peer pull after reparent exited {done.returncode}: branch_update={receipt.get('branch_update')} "
791
            f"parent_sync_error_code={receipt.get('parent_sync_error_code')}")
792
        if _head(ctx, client_b) != _head(ctx, client_a):
793
            raise OpFailed("peer checkout did not converge after reparent: " + ctx.outcome.extra["teardown_error"])
794
795
796
def op_clone_full(ctx: Ctx) -> None:
797
    _oak_remote(ctx)
798
    dest = ctx.scratch / f"clone-{ctx.tag}"
799
    if dest.exists():
800
        shutil.rmtree(dest)
801
    try:
802
        if _is_git(ctx):
803
            ctx.timed(["clone", "-q", str(ctx.p.git_url), str(dest)], ctx.scratch)
804
            # server main = fixture + files pushed to main by the sync ops
805
            expected = ctx.p.expected.main_file_count + ctx.state["server_extra"]
806
            extra_prefix: Tuple[str, ...] = ()
807
        else:
808
            ctx.timed(["clone", "-r", str(ctx.p.oak_url), "bench/fx", str(dest)], ctx.scratch)
809
            expected = ctx.p.expected.main_file_count  # oak sync ops publish to a branch, not main
810
            # (a snapshot seed has the same main tree, one commit of history)
811
            extra_prefix = ()
812
        ok, detail = check_worktree(ctx, dest, expected, extra_prefix)
813
        ctx.oracle(ok, "clone_file_count_and_sampled_blob_ids_equal_fixture", "git_fixture_instrument", **detail)
814
    finally:
815
        shutil.rmtree(dest, ignore_errors=True)
816
817
818
def op_gc(ctx: Ctx) -> None:
819
    status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
820
    if _is_git(ctx):
821
        before = ctx.git(["rev-list", "--count", "HEAD"], ctx.work).stdout.strip()
822
        ctx.timed(["gc", "--quiet"], ctx.work)
823
        after = ctx.git(["rev-list", "--count", "HEAD"], ctx.work).stdout.strip()
824
        ctx.outcome.semantic_class = "git_gc"
825
    else:
826
        before = ctx.untimed(["hash"], ctx.work).stdout.strip()
827
        ctx.timed(["maintenance", "compact"], ctx.work)
828
        after = ctx.untimed(["hash"], ctx.work).stdout.strip()
829
        ctx.outcome.semantic_class = "oak_maintenance_compact"
830
    left = porcelain_paths(ctx.untimed(status_args, ctx.work).stdout.decode(errors="replace"))
831
    probe_ok = (ctx.work / ctx.p.expected.probe_path).read_bytes() == ctx.p.expected.probe_bytes
832
    ctx.oracle(before == after and not left and probe_ok, "history_head_status_and_probe_unchanged",
833
               "subject_output", before=before.decode() if isinstance(before, bytes) else before,
834
               after=after.decode() if isinstance(after, bytes) else after, left=left[:10])
835
836
837
OPS: Dict[str, Callable[[Ctx], None]] = {
838
    "status.clean": op_status_clean,
839
    "status.dirty": op_status_dirty,
840
    "diff.dirty": op_diff_dirty,
841
    "commit.dirty": op_commit_dirty,
842
    "log.full": op_log_full,
843
    "log.n": op_log_n,
844
    "log.path": op_log_path,
845
    "blame": op_blame,
846
    "file.show": op_file_show,
847
    "branch.list": op_branch_list,
848
    "branch.create": op_branch_create,
849
    "switch.existing": op_switch_existing,
850
    "switch.back": op_switch_back,
851
    "review.branch_diff": op_review_branch_diff,
852
    "merge.preview": op_merge_preview,
853
    "merge.preview.conflict": op_merge_preview_conflict,
854
    "merge.local": op_merge_local,
855
    "merge.conflict": op_merge_conflict,
856
    "push.branch": op_push_branch,
857
    "pull.ff": op_pull_ff,
858
    "pull.diverged": op_pull_diverged,
859
    "clone.full": op_clone_full,
860
    "gc": op_gc,
861
}
862
assert set(OPS) == set(OP_INFO)
863
864
865
def catalog_yaml() -> str:
866
    """scenarios/scale.yaml, generated from this catalog (a test keeps them equal)."""
867
    lines = [
868
        "schema_version: 1",
869
        "scenario_set: scale",
870
        "description: Scale and coverage lane (suites/scale). Every user-facing VCS operation timed for git and oak"
871
        " across repository tiers. Generated from suites/scale/scalesuite/ops.py (catalog_yaml); "
872
        "tests/test_scale_suite.py keeps the two equal.",
873
        "",
874
        "operation_catalog:",
875
    ]
876
    for unit in UNIT_ORDER:
877
        for op in unit:
878
            info = OP_INFO[op]
879
            lines += [f"  {op}:", f"    description: {info.description}", "    timed: true",
880
                      f"    mutates: {str(info.mutates).lower()}"]
881
            for key, values in (("tperf_refs", info.tperf), ("core_yaml_ops", info.core_yaml)):
882
                if values:
883
                    lines.append(f"    {key}:")
884
                    lines.extend(f"      - {value}" for value in values)
885
                else:
886
                    lines.append(f"    {key}: []")
887
    return "\n".join(lines) + "\n"
888
889
890
def execute(ctx: Ctx, op: str) -> Outcome:
891
    try:
892
        OPS[op](ctx)
893
    except OpSkipped as exc:
894
        ctx.outcome.skip_reason = str(exc)
895
    except OpFailed as exc:
896
        ctx.outcome.failed = str(exc)
897
    except Exception as exc:  # noqa: BLE001 - harness errors are recorded, never hidden
898
        ctx.outcome.failed = f"harness_error: {type(exc).__name__}: {str(exc)[-800:]}"
899
        ctx.outcome.extra["harness_error"] = True
900
    return ctx.outcome