Viewing files at commit faa835d7e15eCommit detailsView latest

suites/scale/scalesuite/ops.py 858 lines · 38.4 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 op_status_dirty(ctx: Ctx) -> None:
336
    dirty = apply_dirty(ctx)
337
    ctx.outcome.extra["dirty_applied"] = dirty["applied"]
338
    args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
339
    cap = ctx.timed(args, ctx.work)
340
    text = cap.stdout_full_text or ""
341
    reported = set(porcelain_paths(text))
342
    must = set(dirty["modified"]) | set(dirty["deleted"]) | {old for old, _ in dirty["renamed"]}
343
    missing = sorted(must - reported)
344
    # Git collapses untracked directories ("?? added/<tag>/"); accept a
345
    # reported ancestor directory for added/renamed targets.
346
    new_paths = dirty["added"] + [new for _, new in dirty["renamed"]]
347
    uncovered = [p for p in new_paths if p not in reported and not any(
348
        p.startswith(r.rstrip("/") + "/") for r in reported if r.endswith("/") or r in ("added", "renamed"))]
349
    ctx.oracle(not missing and not uncovered, "dirty_paths_reported", "subject_output",
350
               missing=missing[:10], missing_count=len(missing), uncovered_new=uncovered[:10],
351
               uncovered_new_count=len(uncovered), stdout_bytes=cap.stdout_bytes)
352
353
354
def op_diff_dirty(ctx: Ctx) -> None:
355
    dirty = ctx.state.get("dirty")
356
    if not dirty:
357
        raise OpFailed("diff.dirty needs status.dirty's dirty shape")
358
    args = ["diff", "--no-ext-diff"] if _is_git(ctx) else ["diff", "--print"]
359
    cap = ctx.timed(args, ctx.work)
360
    text = cap.stdout_full_text
361
    if text is None:
362
        ctx.oracle(cap.stdout_bytes > 0, "diff_output_nonempty_over_8MiB", "subject_output",
363
                   stdout_bytes=cap.stdout_bytes)
364
        return
365
    must = dirty["modified"] + dirty["deleted"]
366
    missing = [p for p in must if p not in text]
367
    ctx.oracle(not missing, "diff_mentions_modified_and_deleted_paths", "subject_output",
368
               missing=missing[:10], missing_count=len(missing), stdout_bytes=cap.stdout_bytes)
369
370
371
def op_commit_dirty(ctx: Ctx) -> None:
372
    dirty = ctx.state.pop("dirty", None)
373
    if not dirty:
374
        raise OpFailed("commit.dirty needs status.dirty's dirty shape")
375
    if _is_git(ctx):
376
        ctx.timed(["add", "-A"], ctx.work, label="add")
377
        ctx.timed(["commit", "-q", "--no-verify", "-m", f"dirty {ctx.tag}"], ctx.work, label="commit")
378
    else:
379
        ctx.timed(["commit", "--json"], ctx.work)
380
    ctx.state["harness_commits"] += 1
381
    ctx.state["deleted"].update(dirty["deleted"])
382
    ctx.state["deleted"].update(old for old, _ in dirty["renamed"])
383
    probe = dirty["modified"][0] if dirty["modified"] else None
384
    status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
385
    left = porcelain_paths(ctx.untimed(status_args, ctx.work).stdout.decode(errors="replace"))
386
    committed_ok, reader = True, "subject_file_inspect"
387
    if probe:
388
        want = (ctx.work / probe).read_bytes()
389
        if _is_git(ctx):
390
            got = ctx.git(["cat-file", "blob", f"HEAD:{probe}"], ctx.work, check=False).stdout
391
        else:
392
            out = ctx.scratch / f"inspect-{ctx.tag}.bin"
393
            ctx.untimed(["file", "inspect", "--at", "HEAD", "--json", probe, "--output", str(out)], ctx.work,
394
                        check=False)
395
            got = out.read_bytes() if out.exists() else b""
396
            if out.exists():
397
                out.unlink()
398
        committed_ok = got == want
399
    ctx.oracle(not left and committed_ok, "tree_clean_and_modified_bytes_committed", reader,
400
               left_after_commit=left[:10], committed_bytes_match=committed_ok)
401
402
403
def _log_count(text: str) -> int:
404
    return sum(1 for line in text.splitlines() if line.strip() and not line.startswith("No matching commits"))
405
406
407
def op_log_full(ctx: Ctx) -> None:
408
    args = ["log", "--oneline"] if _is_git(ctx) else ["log", "--oneline", "-n", "100000000"]
409
    cap = ctx.timed(args, ctx.work)
410
    count = _log_count(cap.stdout_full_text) if cap.stdout_full_text is not None else None
411
    expected = ctx.p.expected.main_commits + ctx.state["harness_commits"]
412
    ctx.oracle(count == expected, "log_line_count_equals_history_length", "subject_output",
413
               lines=count, expected=expected, stdout_bytes=cap.stdout_bytes,
414
               output_over_capture_cap=count is None)
415
416
417
def op_log_n(ctx: Ctx) -> None:
418
    cap = ctx.timed(["log", "--oneline", "-n", "20"], ctx.work)
419
    count = _log_count(cap.stdout_full_text or "")
420
    expected = min(20, ctx.p.expected.main_commits + ctx.state["harness_commits"])
421
    ctx.oracle(count == expected, "log_n_line_count", "subject_output", lines=count, expected=expected)
422
423
424
def op_log_path(ctx: Ctx) -> None:
425
    probe = ctx.p.expected.probe_path
426
    args = ["log", "--oneline", "--", probe] if _is_git(ctx) else ["log", "--oneline", "-n", "100000000", probe]
427
    cap = ctx.timed(args, ctx.work)
428
    count = _log_count(cap.stdout_full_text or "")
429
    expected = ctx.p.expected.probe_commit_count
430
    ctx.oracle(count == expected, "path_log_count_equals_generator_touch_count", "subject_output",
431
               lines=count, expected=expected, probe_path=probe)
432
433
434
def op_blame(ctx: Ctx) -> None:
435
    if not _is_git(ctx):
436
        raise OpSkipped("oak_cli_has_no_blame_or_annotate_command")
437
    probe = ctx.p.expected.probe_path
438
    cap = ctx.timed(["blame", "-s", "--", probe], ctx.work)
439
    lines = _log_count(cap.stdout_full_text or "")
440
    ctx.oracle(lines == ctx.p.expected.probe_lines, "blame_line_count_equals_file_lines", "subject_output",
441
               lines=lines, expected=ctx.p.expected.probe_lines)
442
443
444
def op_file_show(ctx: Ctx) -> None:
445
    probe = ctx.p.expected.probe_path
446
    if _is_git(ctx):
447
        cap = ctx.timed(["show", f"HEAD:{probe}"], ctx.work, raw=True)
448
        got = cap.stdout_raw or b""
449
        reader = "subject_output"
450
    else:
451
        out = ctx.scratch / f"show-{ctx.tag}.bin"
452
        ctx.timed(["file", "inspect", "--at", "HEAD", "--json", probe, "--output", str(out)], ctx.work)
453
        got = out.read_bytes() if out.exists() else b""
454
        if out.exists():
455
            out.unlink()
456
        reader = "subject_file_inspect"
457
    ctx.oracle(got == ctx.p.expected.probe_bytes, "shown_bytes_equal_fixture_blob", reader,
458
               bytes=len(got), expected_bytes=len(ctx.p.expected.probe_bytes))
459
460
461
def op_branch_list(ctx: Ctx) -> None:
462
    if _is_git(ctx):
463
        cap = ctx.timed(["branch", "--list"], ctx.work)
464
        names = [line[2:].strip() for line in (cap.stdout_full_text or "").splitlines() if line.strip()]
465
        reader = "subject_output"
466
    else:
467
        cap = ctx.timed(["branch", "list", "--json"], ctx.work)
468
        try:
469
            names = [item.get("name") for item in json.loads(cap.stdout_full_text or "[]")]
470
        except ValueError:
471
            names = []
472
        reader = "subject_output_json"
473
    spec = ctx.p.tier.spec
474
    fixture_branches = spec.branches if _is_git(ctx) else ctx.p.oak_branches_created
475
    need = [gen.branch_name(0)] if fixture_branches else []
476
    ok = len(names) >= fixture_branches and all(n in names for n in need)
477
    ctx.oracle(ok, "branch_count_at_least_fixture_branches", reader, listed=len(names),
478
               fixture_branches=fixture_branches)
479
480
481
def _branch_paths_match(ctx: Ctx, root: Path, want: Dict[str, bytes]) -> Tuple[bool, List[str]]:
482
    bad = [rel for rel, data in want.items() if not (root / rel).is_file() or (root / rel).read_bytes() != data]
483
    return not bad, bad
484
485
486
def op_switch_existing(ctx: Ctx) -> None:
487
    exp = ctx.p.expected
488
    args = ["switch", "-q", exp.branch] if _is_git(ctx) else ["switch", exp.branch]
489
    ctx.timed(args, ctx.work)
490
    ok, bad = _branch_paths_match(ctx, ctx.work, exp.branch_bytes)
491
    ctx.oracle(ok, "branch_paths_bytes_equal_fixture", "git_fixture_instrument", mismatched=bad[:10])
492
493
494
def _base_branch(ctx: Ctx) -> str:
495
    return "main" if _is_git(ctx) else str(ctx.p.oak_base_branch)
496
497
498
def op_switch_back(ctx: Ctx) -> None:
499
    exp = ctx.p.expected
500
    args = ["switch", "-q", _base_branch(ctx)] if _is_git(ctx) else ["switch", _base_branch(ctx)]
501
    ctx.timed(args, ctx.work)
502
    ok, bad = _branch_paths_match(ctx, ctx.work, exp.main_bytes_of_branch_paths)
503
    ctx.oracle(ok, "base_paths_bytes_equal_fixture_main", "git_fixture_instrument", mismatched=bad[:10])
504
505
506
def current_branch(ctx: Ctx, root: Path) -> str:
507
    if _is_git(ctx):
508
        return ctx.git(["symbolic-ref", "--short", "HEAD"], root, check=False).stdout.decode().strip()
509
    out = ctx.untimed(["status", "--json"], root, check=False).stdout
510
    try:
511
        return str(json.loads(out).get("branch"))
512
    except ValueError:
513
        return ""
514
515
516
def op_branch_create(ctx: Ctx) -> None:
517
    ctx.state["branch_seq"] += 1
518
    name = f"new-{ctx.tag}-{ctx.state['branch_seq']}"
519
    args = ["switch", "-q", "-c", name] if _is_git(ctx) else ["switch", "-c", name]
520
    ctx.timed(args, ctx.work)
521
    now = current_branch(ctx, ctx.work)
522
    ctx.outcome.semantic_class = "git_branch_from_HEAD" if _is_git(ctx) else "oak_branch_from_main"
523
    # teardown: back to base (and drop the git branch so ref counts stay fixed)
524
    if _is_git(ctx):
525
        ctx.git(["switch", "-q", "main"], ctx.work)
526
        ctx.git(["branch", "-q", "-D", name], ctx.work)
527
    else:
528
        ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
529
    ctx.oracle(now == name, "current_branch_is_new_branch", "subject_output", current=now, wanted=name)
530
531
532
def op_review_branch_diff(ctx: Ctx) -> None:
533
    exp = ctx.p.expected
534
    if _is_git(ctx):
535
        cap = ctx.timed(["diff", "--name-status", f"main...{exp.branch}"], ctx.work)
536
        paths = [line.split("\t", 1)[-1] for line in (cap.stdout_full_text or "").splitlines() if "\t" in line]
537
        reader = "subject_output"
538
    else:
539
        cap = ctx.timed(["branch", "diff", exp.branch, "--json"], ctx.work)
540
        try:
541
            paths = [item["path"] for item in json.loads(cap.stdout_full_text or "{}").get("changed_files", [])]
542
        except (ValueError, KeyError, AttributeError):
543
            paths = []
544
        reader = "subject_output_json"
545
    ctx.oracle(sorted(paths) == sorted(exp.branch_paths), "changed_paths_equal_generator_branch_paths", reader,
546
               reported=sorted(paths)[:10], expected=sorted(exp.branch_paths)[:10])
547
548
549
def op_merge_preview(ctx: Ctx) -> None:
550
    exp = ctx.p.expected
551
    if _is_git(ctx):
552
        cap = ctx.timed(["merge-tree", "--write-tree", "main", exp.branch], ctx.work)
553
        tree = (cap.stdout_full_text or "").split("\n", 1)[0].strip()
554
        bad = []
555
        for rel, data in exp.branch_bytes.items():
556
            got = ctx.git(["cat-file", "blob", f"{tree}:{rel}"], ctx.work, check=False).stdout
557
            if got != data:
558
                bad.append(rel)
559
        ctx.oracle(bool(tree) and not bad, "clean_prediction_tree_has_branch_bytes", "git_fixture_instrument",
560
                   mismatched=bad[:10])
561
        ctx.outcome.semantic_class = "git_merge_tree_two_refs"
562
        return
563
    ctx.untimed(["switch", exp.branch], ctx.work)
564
    try:
565
        # Exit 7 = prediction computed but not certified against the remote
566
        # target head (documented exit code); the prediction itself is timed.
567
        cap = ctx.timed(["merge", "--dry-run", "--json"], ctx.work, accept=(0, 7))
568
    finally:
569
        ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
570
    try:
571
        data = json.loads((cap.stdout_full_text or "").splitlines()[0])
572
    except (ValueError, IndexError):
573
        data = {}
574
    changed = sorted(item.get("path") if isinstance(item, dict) else item for item in data.get("changed_files", []))
575
    ctx.outcome.semantic_class = "oak_merge_dry_run_current_into_main"
576
    ctx.outcome.extra["merge_certified"] = (data.get("merge_safety") or {}).get("certified")
577
    ctx.oracle(data.get("clean") is True and changed == sorted(exp.branch_paths),
578
               "clean_prediction_lists_branch_paths", "subject_output_json", clean=data.get("clean"),
579
               changed=changed[:10], expected=sorted(exp.branch_paths)[:10])
580
581
582
def op_merge_preview_conflict(ctx: Ctx) -> None:
583
    conflict = ctx.p.expected.conflict
584
    if not conflict:
585
        raise OpSkipped("fixture_has_no_conflict_shape")
586
    if not _is_git(ctx):
587
        raise OpSkipped("oak_merge_dry_run_predicts_current_branch_into_main_only")
588
    cap = ctx.timed(["merge-tree", "--write-tree", "--name-only", conflict["ours"], conflict["theirs"]],
589
                    ctx.work, accept=(1,))
590
    lines = (cap.stdout_full_text or "").split("\n\n", 1)[0].splitlines()[1:]
591
    ctx.oracle(len(lines) == conflict["paths"], "conflicted_path_count_equals_fixture", "subject_output",
592
               conflicted=len(lines), expected=conflict["paths"])
593
594
595
def op_merge_local(ctx: Ctx) -> None:
596
    if not _is_git(ctx):
597
        raise OpSkipped("oak_local_merge_needs_non_main_parent_unreachable_from_cli; "
598
                        "oak_serve_lacks_merge_to_main")
599
    exp = ctx.p.expected
600
    scratch = f"merge-{ctx.tag}"
601
    ctx.git(["switch", "-q", "-c", scratch, "main"], ctx.work)
602
    try:
603
        ctx.timed(["merge", "-q", "--no-ff", "--no-edit", exp.branch], ctx.work)
604
        parents = ctx.git(["rev-list", "--parents", "-n", "1", "HEAD"], ctx.work).stdout.split()
605
        ok, bad = _branch_paths_match(ctx, ctx.work, exp.branch_bytes)
606
        ctx.oracle(ok and len(parents) == 3, "merge_commit_two_parents_and_branch_bytes", "git_fixture_instrument",
607
                   parents=len(parents) - 1, mismatched=bad[:10])
608
    finally:
609
        ctx.git(["merge", "--abort"], ctx.work, check=False)
610
        ctx.git(["switch", "-q", "-f", "main"], ctx.work)
611
        ctx.git(["branch", "-q", "-D", scratch], ctx.work, check=False)
612
613
614
def op_merge_conflict(ctx: Ctx) -> None:
615
    conflict = ctx.p.expected.conflict
616
    if not conflict:
617
        raise OpSkipped("fixture_has_no_conflict_shape")
618
    if not _is_git(ctx):
619
        raise OpSkipped("oak_local_merge_needs_non_main_parent_unreachable_from_cli")
620
    scratch = f"conflict-{ctx.tag}"
621
    ctx.git(["switch", "-q", "-c", scratch, conflict["ours"]], ctx.work)
622
    try:
623
        ctx.timed(["merge", "-q", "--no-edit", conflict["theirs"]], ctx.work, accept=(1,))
624
        unmerged = ctx.git(["diff", "--name-only", "--diff-filter=U"], ctx.work).stdout.decode().split()
625
        ctx.oracle(len(unmerged) == conflict["paths"], "unmerged_path_count_equals_fixture", "subject_output",
626
                   unmerged=len(unmerged), expected=conflict["paths"])
627
    finally:
628
        ctx.git(["merge", "--abort"], ctx.work, check=False)
629
        ctx.git(["switch", "-q", "-f", "main"], ctx.work)
630
        ctx.git(["branch", "-q", "-D", scratch], ctx.work, check=False)
631
632
633
def _client_commit(ctx: Ctx, client: Path, rel: str, content: bytes) -> str:
634
    target = client / rel
635
    target.parent.mkdir(parents=True, exist_ok=True)
636
    target.write_bytes(content)
637
    if _is_git(ctx):
638
        ctx.git(["add", "-A"], client)
639
        ctx.git(["commit", "-q", "--no-verify", "-m", f"sync {rel}"], client)
640
        return ctx.git(["rev-parse", "HEAD"], client).stdout.decode().strip()
641
    ctx.untimed(["commit", "--json"], client)
642
    return json.loads(ctx.untimed(["status", "--json"], client).stdout)["head"]
643
644
645
def _head(ctx: Ctx, client: Path) -> str:
646
    if _is_git(ctx):
647
        return ctx.git(["rev-parse", "HEAD"], client).stdout.decode().strip()
648
    return json.loads(ctx.untimed(["status", "--json"], client).stdout)["head"]
649
650
651
def _oak_remote(ctx: Ctx) -> None:
652
    """Sync ops need seeded loopback servers; label an Oak snapshot seed."""
653
    if not getattr(ctx.p.tier, "sync", True):
654
        raise OpSkipped("tier_excludes_sync_clients_disk_budget")
655
    if _is_git(ctx):
656
        return
657
    if getattr(ctx.p, "oak_remote_unavailable", None):
658
        raise OpSkipped(str(ctx.p.oak_remote_unavailable))
659
    seed = getattr(ctx.p, "oak_seed", None)
660
    ctx.outcome.extra["oak_server_seed"] = seed
661
    if seed and seed != "full_history":
662
        ctx.outcome.semantic_class = f"oak_server_seed_{seed}"
663
664
665
def op_push_branch(ctx: Ctx) -> None:
666
    _oak_remote(ctx)
667
    client_b = ctx.client("clientB")
668
    rel = f"sync/push-{ctx.tag}.txt"
669
    head = _client_commit(ctx, client_b, rel, f"push {ctx.tag}\n".encode())
670
    ctx.state["pushed"] = (rel, head)
671
    if _is_git(ctx):
672
        ctx.timed(["push", "-q", "origin", "HEAD:main"], client_b)
673
        remote = ctx.git(["ls-remote", "origin", "refs/heads/main"], client_b).stdout.decode().split()
674
        ctx.state["server_extra"] += 1
675
        ctx.oracle(bool(remote) and remote[0] == head, "server_ref_equals_pushed_head", "git_ls_remote",
676
                   remote=remote[:1], pushed=head)
677
    else:
678
        cap = ctx.timed(["push", "--json"], client_b)
679
        try:
680
            receipt = json.loads((cap.stdout_full_text or "").splitlines()[-1])
681
        except (ValueError, IndexError):
682
            receipt = {}
683
        # Server-side confirmation comes from clientA's pull.ff oracle in the
684
        # same unit (independent checkout sees the pushed commit and bytes).
685
        ctx.outcome.extra["push_receipt_pushed"] = receipt.get("pushed")
686
        ctx.oracle(receipt.get("remote_contacted") is True, "push_receipt_remote_contacted_confirmed_by_pull_ff",
687
                   "subject_output_json", pushed=receipt.get("pushed"))
688
689
690
def op_pull_ff(ctx: Ctx) -> None:
691
    _oak_remote(ctx)
692
    pushed = ctx.state.pop("pushed", None)
693
    if not pushed:
694
        raise OpFailed("pull.ff needs push.branch in the same unit")
695
    rel, head = pushed
696
    client_a = ctx.client("clientA")
697
    if _is_git(ctx):
698
        ctx.timed(["pull", "-q", "--ff-only"], client_a)
699
    else:
700
        ctx.timed(["pull", "--json"], client_a)
701
    now = _head(ctx, client_a)
702
    data_ok = (client_a / rel).is_file() and (client_a / rel).read_bytes() == f"push {ctx.tag}\n".encode()
703
    ctx.oracle(now == head and data_ok, "puller_head_and_bytes_equal_pusher", "git_fixture_instrument",
704
               puller_head=now, pusher_head=head, bytes_ok=data_ok)
705
706
707
def op_pull_diverged(ctx: Ctx) -> None:
708
    _oak_remote(ctx)
709
    client_a, client_b = ctx.client("clientA"), ctx.client("clientB")
710
    local_rel, up_rel = f"sync/local-{ctx.tag}.txt", f"sync/up-{ctx.tag}.txt"
711
    _client_commit(ctx, client_a, local_rel, b"local\n")
712
    _client_commit(ctx, client_b, up_rel, b"upstream\n")
713
    if _is_git(ctx):
714
        ctx.git(["push", "-q", "origin", "HEAD:main"], client_b)
715
        ctx.state["server_extra"] += 1
716
        ctx.timed(["pull", "-q", "--no-rebase", "--no-edit"], client_a)
717
        ctx.outcome.semantic_class = "git_merge_commit"
718
    else:
719
        ctx.untimed(["push", "--json"], client_b)
720
        cap = ctx.timed(["pull", "--json"], client_a)
721
        try:
722
            receipt = json.loads((cap.stdout_full_text or "").splitlines()[-1])
723
        except (ValueError, IndexError):
724
            receipt = {}
725
        ctx.outcome.semantic_class = (ctx.outcome.semantic_class or "") + f"|oak_pull_{receipt.get('convergence', 'unknown')}"
726
    both = (client_a / local_rel).is_file() and (client_a / up_rel).is_file()
727
    status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
728
    left = porcelain_paths(ctx.untimed(status_args, client_a).stdout.decode(errors="replace"))
729
    ctx.oracle(both and not left, "both_sides_present_and_tree_clean", "git_fixture_instrument",
730
               local_present=(client_a / local_rel).is_file(), upstream_present=(client_a / up_rel).is_file(),
731
               left=left[:10])
732
    # Re-converge (untimed): A publishes its merge, B catches up. A failure
733
    # here is an observed subject defect in an UNTIMED step: it is recorded on
734
    # the row (teardown_error) without discarding the timed sample.
735
    if _is_git(ctx):
736
        ctx.git(["push", "-q", "origin", "HEAD:main"], client_a)
737
        ctx.state["server_extra"] += 1
738
        ctx.git(["pull", "-q", "--ff-only"], client_b)
739
        return
740
    ctx.untimed(["push", "--json"], client_a)
741
    done = ctx.untimed(["pull", "--json"], client_b, check=False)
742
    if done.returncode != 0:
743
        try:
744
            receipt = json.loads(done.stdout.decode(errors="replace").splitlines()[-1])
745
        except (ValueError, IndexError):
746
            receipt = {}
747
        ctx.outcome.extra["teardown_error"] = (
748
            f"peer pull after reparent exited {done.returncode}: branch_update={receipt.get('branch_update')} "
749
            f"parent_sync_error_code={receipt.get('parent_sync_error_code')}")
750
        if _head(ctx, client_b) != _head(ctx, client_a):
751
            raise OpFailed("peer checkout did not converge after reparent: " + ctx.outcome.extra["teardown_error"])
752
753
754
def op_clone_full(ctx: Ctx) -> None:
755
    _oak_remote(ctx)
756
    dest = ctx.scratch / f"clone-{ctx.tag}"
757
    if dest.exists():
758
        shutil.rmtree(dest)
759
    try:
760
        if _is_git(ctx):
761
            ctx.timed(["clone", "-q", str(ctx.p.git_url), str(dest)], ctx.scratch)
762
            # server main = fixture + files pushed to main by the sync ops
763
            expected = ctx.p.expected.main_file_count + ctx.state["server_extra"]
764
            extra_prefix: Tuple[str, ...] = ()
765
        else:
766
            ctx.timed(["clone", "-r", str(ctx.p.oak_url), "bench/fx", str(dest)], ctx.scratch)
767
            expected = ctx.p.expected.main_file_count  # oak sync ops publish to a branch, not main
768
            # (a snapshot seed has the same main tree, one commit of history)
769
            extra_prefix = ()
770
        ok, detail = check_worktree(ctx, dest, expected, extra_prefix)
771
        ctx.oracle(ok, "clone_file_count_and_sampled_blob_ids_equal_fixture", "git_fixture_instrument", **detail)
772
    finally:
773
        shutil.rmtree(dest, ignore_errors=True)
774
775
776
def op_gc(ctx: Ctx) -> None:
777
    status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
778
    if _is_git(ctx):
779
        before = ctx.git(["rev-list", "--count", "HEAD"], ctx.work).stdout.strip()
780
        ctx.timed(["gc", "--quiet"], ctx.work)
781
        after = ctx.git(["rev-list", "--count", "HEAD"], ctx.work).stdout.strip()
782
        ctx.outcome.semantic_class = "git_gc"
783
    else:
784
        before = ctx.untimed(["hash"], ctx.work).stdout.strip()
785
        ctx.timed(["maintenance", "compact"], ctx.work)
786
        after = ctx.untimed(["hash"], ctx.work).stdout.strip()
787
        ctx.outcome.semantic_class = "oak_maintenance_compact"
788
    left = porcelain_paths(ctx.untimed(status_args, ctx.work).stdout.decode(errors="replace"))
789
    probe_ok = (ctx.work / ctx.p.expected.probe_path).read_bytes() == ctx.p.expected.probe_bytes
790
    ctx.oracle(before == after and not left and probe_ok, "history_head_status_and_probe_unchanged",
791
               "subject_output", before=before.decode() if isinstance(before, bytes) else before,
792
               after=after.decode() if isinstance(after, bytes) else after, left=left[:10])
793
794
795
OPS: Dict[str, Callable[[Ctx], None]] = {
796
    "status.clean": op_status_clean,
797
    "status.dirty": op_status_dirty,
798
    "diff.dirty": op_diff_dirty,
799
    "commit.dirty": op_commit_dirty,
800
    "log.full": op_log_full,
801
    "log.n": op_log_n,
802
    "log.path": op_log_path,
803
    "blame": op_blame,
804
    "file.show": op_file_show,
805
    "branch.list": op_branch_list,
806
    "branch.create": op_branch_create,
807
    "switch.existing": op_switch_existing,
808
    "switch.back": op_switch_back,
809
    "review.branch_diff": op_review_branch_diff,
810
    "merge.preview": op_merge_preview,
811
    "merge.preview.conflict": op_merge_preview_conflict,
812
    "merge.local": op_merge_local,
813
    "merge.conflict": op_merge_conflict,
814
    "push.branch": op_push_branch,
815
    "pull.ff": op_pull_ff,
816
    "pull.diverged": op_pull_diverged,
817
    "clone.full": op_clone_full,
818
    "gc": op_gc,
819
}
820
assert set(OPS) == set(OP_INFO)
821
822
823
def catalog_yaml() -> str:
824
    """scenarios/scale.yaml, generated from this catalog (a test keeps them equal)."""
825
    lines = [
826
        "schema_version: 1",
827
        "scenario_set: scale",
828
        "description: Scale and coverage lane (suites/scale). Every user-facing VCS operation timed for git and oak"
829
        " across repository tiers. Generated from suites/scale/scalesuite/ops.py (catalog_yaml); "
830
        "tests/test_scale_suite.py keeps the two equal.",
831
        "",
832
        "operation_catalog:",
833
    ]
834
    for unit in UNIT_ORDER:
835
        for op in unit:
836
            info = OP_INFO[op]
837
            lines += [f"  {op}:", f"    description: {info.description}", "    timed: true",
838
                      f"    mutates: {str(info.mutates).lower()}"]
839
            for key, values in (("tperf_refs", info.tperf), ("core_yaml_ops", info.core_yaml)):
840
                if values:
841
                    lines.append(f"    {key}:")
842
                    lines.extend(f"      - {value}" for value in values)
843
                else:
844
                    lines.append(f"    {key}: []")
845
    return "\n".join(lines) + "\n"
846
847
848
def execute(ctx: Ctx, op: str) -> Outcome:
849
    try:
850
        OPS[op](ctx)
851
    except OpSkipped as exc:
852
        ctx.outcome.skip_reason = str(exc)
853
    except OpFailed as exc:
854
        ctx.outcome.failed = str(exc)
855
    except Exception as exc:  # noqa: BLE001 - harness errors are recorded, never hidden
856
        ctx.outcome.failed = f"harness_error: {type(exc).__name__}: {str(exc)[-800:]}"
857
        ctx.outcome.extra["harness_error"] = True
858
    return ctx.outcome