| 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 (oak: open branches; harness branches are closed, git deletes them).", ("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 | watchdog_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 = None if "reader_unavailable" in detail else 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 | def committed_bytes(ctx: Ctx, repo: Path, paths: Sequence[str]) -> Tuple[Dict[str, bytes], str, Dict[str, Any]]:
|
| 264 | """Committed HEAD bytes of ``paths`` read WITHOUT the subject binary.
|
| 265 |
|
| 266 | git: ``git cat-file`` with the oracle's own git. oak: the independent
|
| 267 | stdlib reader of Oak's SQLite store (oakbench.oak_sqlite_reader). Returns
|
| 268 | (payloads, reader label, meta). A reader bound (Unavailable) puts
|
| 269 | ``reader_unavailable`` in meta; Ctx.oracle then records oracle_ok None
|
| 270 | (unmeasured, ADR-0002) instead of a guess.
|
| 271 | """
|
| 272 | if _is_git(ctx):
|
| 273 | out: Dict[str, bytes] = {}
|
| 274 | for rel in paths:
|
| 275 | proc = ctx.git(["cat-file", "blob", f"HEAD:{rel}"], repo, check=False)
|
| 276 | if proc.returncode == 0:
|
| 277 | out[rel] = proc.stdout
|
| 278 | return out, "git_cat_file_oracle_instrument", {}
|
| 279 | from oakbench import oak_sqlite_reader as reader
|
| 280 | try:
|
| 281 | payloads, meta = reader.committed_payloads(Path(repo), list(paths))
|
| 282 | except reader.Unavailable as exc:
|
| 283 | # A harness/reader bound, not a property of Oak's data: the oracle
|
| 284 | # becomes unmeasured (Ctx.oracle turns this into oracle_ok None).
|
| 285 | return {}, "oak_sqlite_reader_independent", {"reader_unavailable": str(exc)[:300]}
|
| 286 | except reader.Invalid as exc:
|
| 287 | return {}, "oak_sqlite_reader_independent", {"invalid": str(exc)[:300]}
|
| 288 | return payloads, "oak_sqlite_reader_independent", {"head": meta.get("head"),
|
| 289 | "reader_version": meta.get("reader_version")}
|
| 290 |
|
| 291 |
|
| 292 | # ---------------------------------------------------------------------------
|
| 293 | # Dirty shape (untimed setup)
|
| 294 | # ---------------------------------------------------------------------------
|
| 295 |
|
| 296 |
|
| 297 | def apply_dirty(ctx: Ctx) -> Dict[str, Any]:
|
| 298 | spec = ctx.p.tier.spec
|
| 299 | shape = ctx.p.tier.dirty
|
| 300 | cache = gen.layout(spec)
|
| 301 | factory = gen.ContentFactory(spec)
|
| 302 | protected = set(ctx.p.expected.protected)
|
| 303 | rng = random.Random(f"{spec.seed}:dirty:{ctx.tag}")
|
| 304 | deleted: set = ctx.state["deleted"]
|
| 305 | wanted = shape.modified + shape.deleted + shape.renamed
|
| 306 | pool: List[int] = []
|
| 307 | attempts = 0
|
| 308 | seen = set()
|
| 309 | while len(pool) < wanted and attempts < wanted * 20 + 100:
|
| 310 | attempts += 1
|
| 311 | index = rng.randrange(spec.files)
|
| 312 | path = gen.file_path(spec, index, cache)
|
| 313 | if index in seen or path in protected or path in deleted:
|
| 314 | continue
|
| 315 | seen.add(index)
|
| 316 | pool.append(index)
|
| 317 | modified = pool[:shape.modified]
|
| 318 | removed = pool[shape.modified:shape.modified + shape.deleted]
|
| 319 | renamed = pool[shape.modified + shape.deleted:]
|
| 320 | root = ctx.work
|
| 321 | mod_paths, del_paths, ren_pairs, add_paths = [], [], [], []
|
| 322 | for index in modified:
|
| 323 | path = gen.file_path(spec, index, cache)
|
| 324 | (root / path).write_bytes(factory.text(index, f"dirty-{ctx.tag}"))
|
| 325 | mod_paths.append(path)
|
| 326 | for index in removed:
|
| 327 | path = gen.file_path(spec, index, cache)
|
| 328 | (root / path).unlink()
|
| 329 | del_paths.append(path)
|
| 330 | for index in renamed:
|
| 331 | path = gen.file_path(spec, index, cache)
|
| 332 | target = f"renamed/{ctx.tag}/f{index}.txt"
|
| 333 | (root / target).parent.mkdir(parents=True, exist_ok=True)
|
| 334 | os.replace(root / path, root / target)
|
| 335 | ren_pairs.append((path, target))
|
| 336 | for number in range(shape.added):
|
| 337 | rel = f"added/{ctx.tag}/n{number}.txt"
|
| 338 | (root / rel).parent.mkdir(parents=True, exist_ok=True)
|
| 339 | (root / rel).write_bytes(factory.text(10_000_000 + number, f"add-{ctx.tag}"))
|
| 340 | add_paths.append(rel)
|
| 341 | dirty = {"modified": mod_paths, "deleted": del_paths, "renamed": ren_pairs, "added": add_paths,
|
| 342 | "requested": [shape.modified, shape.added, shape.deleted, shape.renamed],
|
| 343 | "applied": [len(mod_paths), len(add_paths), len(del_paths), len(ren_pairs)]}
|
| 344 | ctx.state["dirty"] = dirty
|
| 345 | return dirty
|
| 346 |
|
| 347 |
|
| 348 | # ---------------------------------------------------------------------------
|
| 349 | # Operations. Each returns None; results land in ctx.outcome.
|
| 350 | # ---------------------------------------------------------------------------
|
| 351 |
|
| 352 |
|
| 353 | def _is_git(ctx: Ctx) -> bool:
|
| 354 | return ctx.kind == "git"
|
| 355 |
|
| 356 |
|
| 357 | def op_status_clean(ctx: Ctx) -> None:
|
| 358 | args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
|
| 359 | cap = ctx.timed(args, ctx.work)
|
| 360 | paths = porcelain_paths(cap.stdout_full_text or "")
|
| 361 | ctx.oracle(not paths, "clean_status_reports_no_paths", "subject_output", reported_paths=paths[:10])
|
| 362 |
|
| 363 |
|
| 364 | def _dirty_branch_enter(ctx: Ctx) -> None:
|
| 365 | """The dirty cycle runs on a throwaway branch (untimed switch), so the base
|
| 366 | branch never accumulates harness commits: every run's switch/log/merge ops
|
| 367 | see the same repository state (no run-over-run drift)."""
|
| 368 | name = f"dirty-{ctx.tag}"
|
| 369 | if _is_git(ctx):
|
| 370 | ctx.git(["switch", "-q", "-c", name, "main"], ctx.work)
|
| 371 | else:
|
| 372 | ctx.untimed(["switch", "-c", name], ctx.work)
|
| 373 | ctx.state["dirty_branch"] = name
|
| 374 |
|
| 375 |
|
| 376 | def _dirty_branch_leave(ctx: Ctx) -> None:
|
| 377 | name = ctx.state.pop("dirty_branch", None)
|
| 378 | if name is None:
|
| 379 | return
|
| 380 | _drop_branch(ctx, name)
|
| 381 |
|
| 382 |
|
| 383 | def _drop_branch(ctx: Ctx, name: str) -> None:
|
| 384 | """Untimed teardown: back to base, then remove the harness branch so branch
|
| 385 | counts (branch.list) stay fixed across runs. git deletes it; Oak has no
|
| 386 | local delete, so it is closed (and oak branch.list lists open branches)."""
|
| 387 | if _is_git(ctx):
|
| 388 | ctx.git(["switch", "-q", "-f", "main"], ctx.work)
|
| 389 | ctx.git(["branch", "-q", "-D", name], ctx.work, check=False)
|
| 390 | else:
|
| 391 | ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
|
| 392 | ctx.untimed(["close", name], ctx.work, check=False)
|
| 393 |
|
| 394 |
|
| 395 | def recover(ctx: Ctx) -> Optional[str]:
|
| 396 | """Untimed repair after a failed unit: discard worktree changes, return to base."""
|
| 397 | try:
|
| 398 | ctx.state.pop("dirty", None)
|
| 399 | stale = ctx.state.pop("dirty_branch", None)
|
| 400 | if _is_git(ctx):
|
| 401 | ctx.git(["merge", "--abort"], ctx.work, check=False)
|
| 402 | ctx.git(["reset", "-q", "--hard"], ctx.work, check=False)
|
| 403 | ctx.git(["clean", "-fdq", "-e", "build/"], ctx.work, check=False)
|
| 404 | ctx.git(["switch", "-q", "-f", "main"], ctx.work)
|
| 405 | else:
|
| 406 | ctx.untimed(["reset", "-f"], ctx.work, check=False)
|
| 407 | for extra in ("added", "renamed"):
|
| 408 | shutil.rmtree(ctx.work / extra, ignore_errors=True)
|
| 409 | ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
|
| 410 | if stale:
|
| 411 | _drop_branch(ctx, stale)
|
| 412 | return None
|
| 413 | except Exception as exc: # noqa: BLE001 - reported on the row, never hidden
|
| 414 | return f"{type(exc).__name__}: {str(exc)[-300:]}"
|
| 415 |
|
| 416 |
|
| 417 | def op_status_dirty(ctx: Ctx) -> None:
|
| 418 | _dirty_branch_enter(ctx)
|
| 419 | dirty = apply_dirty(ctx)
|
| 420 | ctx.outcome.extra["dirty_applied"] = dirty["applied"]
|
| 421 | args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
|
| 422 | cap = ctx.timed(args, ctx.work)
|
| 423 | text = cap.stdout_full_text or ""
|
| 424 | reported = set(porcelain_paths(text))
|
| 425 | must = set(dirty["modified"]) | set(dirty["deleted"]) | {old for old, _ in dirty["renamed"]}
|
| 426 | missing = sorted(must - reported)
|
| 427 | # Git collapses untracked directories ("?? added/<tag>/"); accept a
|
| 428 | # reported ancestor directory for added/renamed targets.
|
| 429 | new_paths = dirty["added"] + [new for _, new in dirty["renamed"]]
|
| 430 | uncovered = [p for p in new_paths if p not in reported and not any(
|
| 431 | p.startswith(r.rstrip("/") + "/") for r in reported if r.endswith("/") or r in ("added", "renamed"))]
|
| 432 | ctx.oracle(not missing and not uncovered, "dirty_paths_reported", "subject_output",
|
| 433 | missing=missing[:10], missing_count=len(missing), uncovered_new=uncovered[:10],
|
| 434 | uncovered_new_count=len(uncovered), stdout_bytes=cap.stdout_bytes)
|
| 435 |
|
| 436 |
|
| 437 | def op_diff_dirty(ctx: Ctx) -> None:
|
| 438 | dirty = ctx.state.get("dirty")
|
| 439 | if not dirty:
|
| 440 | raise OpFailed("diff.dirty needs status.dirty's dirty shape")
|
| 441 | args = ["diff", "--no-ext-diff"] if _is_git(ctx) else ["diff", "--print"]
|
| 442 | cap = ctx.timed(args, ctx.work)
|
| 443 | text = cap.stdout_full_text
|
| 444 | if text is None:
|
| 445 | ctx.oracle(cap.stdout_bytes > 0, "diff_output_nonempty_over_8MiB", "subject_output",
|
| 446 | stdout_bytes=cap.stdout_bytes)
|
| 447 | return
|
| 448 | must = dirty["modified"] + dirty["deleted"]
|
| 449 | missing = [p for p in must if p not in text]
|
| 450 | ctx.oracle(not missing, "diff_mentions_modified_and_deleted_paths", "subject_output",
|
| 451 | missing=missing[:10], missing_count=len(missing), stdout_bytes=cap.stdout_bytes)
|
| 452 |
|
| 453 |
|
| 454 | def op_commit_dirty(ctx: Ctx) -> None:
|
| 455 | dirty = ctx.state.pop("dirty", None)
|
| 456 | if not dirty:
|
| 457 | raise OpFailed("commit.dirty needs status.dirty's dirty shape")
|
| 458 | if _is_git(ctx):
|
| 459 | ctx.timed(["add", "-A"], ctx.work, label="add")
|
| 460 | ctx.timed(["commit", "-q", "--no-verify", "-m", f"dirty {ctx.tag}"], ctx.work, label="commit")
|
| 461 | else:
|
| 462 | ctx.timed(["commit", "--json"], ctx.work)
|
| 463 | probe = dirty["modified"][0] if dirty["modified"] else None
|
| 464 | gone = dirty["deleted"][0] if dirty["deleted"] else None
|
| 465 | status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
|
| 466 | left = porcelain_paths(ctx.untimed(status_args, ctx.work).stdout.decode(errors="replace"))
|
| 467 | wanted = [p for p in (probe, gone) if p]
|
| 468 | payloads, reader, meta = committed_bytes(ctx, ctx.work, wanted)
|
| 469 | committed_ok = probe is None or payloads.get(probe) == (ctx.work / probe).read_bytes()
|
| 470 | deleted_ok = gone is None or gone not in payloads
|
| 471 | ctx.oracle(not left and committed_ok and deleted_ok and "invalid" not in meta and "reader_unavailable" not in meta,
|
| 472 | "tree_clean_modified_bytes_committed_deleted_path_absent", reader,
|
| 473 | left_after_commit=left[:10], committed_bytes_match=committed_ok, deleted_absent=deleted_ok, **meta)
|
| 474 | _dirty_branch_leave(ctx)
|
| 475 |
|
| 476 |
|
| 477 | def _log_count(text: str) -> int:
|
| 478 | return sum(1 for line in text.splitlines() if line.strip() and not line.startswith("No matching commits"))
|
| 479 |
|
| 480 |
|
| 481 | def op_log_full(ctx: Ctx) -> None:
|
| 482 | args = ["log", "--oneline"] if _is_git(ctx) else ["log", "--oneline", "-n", "100000000"]
|
| 483 | cap = ctx.timed(args, ctx.work)
|
| 484 | count = _log_count(cap.stdout_full_text) if cap.stdout_full_text is not None else None
|
| 485 | expected = ctx.p.expected.main_commits + ctx.state["harness_commits"]
|
| 486 | ctx.oracle(count == expected, "log_line_count_equals_history_length", "subject_output",
|
| 487 | lines=count, expected=expected, stdout_bytes=cap.stdout_bytes,
|
| 488 | output_over_capture_cap=count is None)
|
| 489 |
|
| 490 |
|
| 491 | def op_log_n(ctx: Ctx) -> None:
|
| 492 | cap = ctx.timed(["log", "--oneline", "-n", "20"], ctx.work)
|
| 493 | count = _log_count(cap.stdout_full_text or "")
|
| 494 | expected = min(20, ctx.p.expected.main_commits + ctx.state["harness_commits"])
|
| 495 | ctx.oracle(count == expected, "log_n_line_count", "subject_output", lines=count, expected=expected)
|
| 496 |
|
| 497 |
|
| 498 | def op_log_path(ctx: Ctx) -> None:
|
| 499 | probe = ctx.p.expected.probe_path
|
| 500 | args = ["log", "--oneline", "--", probe] if _is_git(ctx) else ["log", "--oneline", "-n", "100000000", probe]
|
| 501 | cap = ctx.timed(args, ctx.work)
|
| 502 | count = _log_count(cap.stdout_full_text or "")
|
| 503 | expected = ctx.p.expected.probe_commit_count
|
| 504 | ctx.oracle(count == expected, "path_log_count_equals_generator_touch_count", "subject_output",
|
| 505 | lines=count, expected=expected, probe_path=probe)
|
| 506 |
|
| 507 |
|
| 508 | def op_blame(ctx: Ctx) -> None:
|
| 509 | if not _is_git(ctx):
|
| 510 | raise OpSkipped("oak_cli_has_no_blame_or_annotate_command")
|
| 511 | probe = ctx.p.expected.probe_path
|
| 512 | cap = ctx.timed(["blame", "-s", "--", probe], ctx.work)
|
| 513 | lines = _log_count(cap.stdout_full_text or "")
|
| 514 | ctx.oracle(lines == ctx.p.expected.probe_lines, "blame_line_count_equals_file_lines", "subject_output",
|
| 515 | lines=lines, expected=ctx.p.expected.probe_lines)
|
| 516 |
|
| 517 |
|
| 518 | def op_file_show(ctx: Ctx) -> None:
|
| 519 | probe = ctx.p.expected.probe_path
|
| 520 | if _is_git(ctx):
|
| 521 | cap = ctx.timed(["show", f"HEAD:{probe}"], ctx.work, raw=True)
|
| 522 | got = cap.stdout_raw or b""
|
| 523 | reader = "subject_output"
|
| 524 | else:
|
| 525 | out = ctx.scratch / f"show-{ctx.tag}.bin"
|
| 526 | ctx.timed(["file", "inspect", "--at", "HEAD", "--json", probe, "--output", str(out)], ctx.work)
|
| 527 | got = out.read_bytes() if out.exists() else b""
|
| 528 | if out.exists():
|
| 529 | out.unlink()
|
| 530 | reader = "subject_file_inspect"
|
| 531 | ctx.oracle(got == ctx.p.expected.probe_bytes, "shown_bytes_equal_fixture_blob", reader,
|
| 532 | bytes=len(got), expected_bytes=len(ctx.p.expected.probe_bytes))
|
| 533 |
|
| 534 |
|
| 535 | def op_branch_list(ctx: Ctx) -> None:
|
| 536 | if _is_git(ctx):
|
| 537 | cap = ctx.timed(["branch", "--list"], ctx.work)
|
| 538 | names = [line[2:].strip() for line in (cap.stdout_full_text or "").splitlines() if line.strip()]
|
| 539 | reader = "subject_output"
|
| 540 | else:
|
| 541 | # Open branches: harness branches are closed on teardown (Oak has no local delete).
|
| 542 | cap = ctx.timed(["branch", "list", "--status", "open", "--json"], ctx.work)
|
| 543 | try:
|
| 544 | names = [item.get("name") for item in json.loads(cap.stdout_full_text or "[]")]
|
| 545 | except ValueError:
|
| 546 | names = []
|
| 547 | reader = "subject_output_json"
|
| 548 | spec = ctx.p.tier.spec
|
| 549 | fixture_branches = spec.branches if _is_git(ctx) else ctx.p.oak_branches_created
|
| 550 | need = [gen.branch_name(0)] if fixture_branches else []
|
| 551 | ok = len(names) >= fixture_branches and all(n in names for n in need)
|
| 552 | ctx.oracle(ok, "branch_count_at_least_fixture_branches", reader, listed=len(names),
|
| 553 | fixture_branches=fixture_branches)
|
| 554 |
|
| 555 |
|
| 556 | def _branch_paths_match(ctx: Ctx, root: Path, want: Dict[str, bytes]) -> Tuple[bool, List[str]]:
|
| 557 | bad = [rel for rel, data in want.items() if not (root / rel).is_file() or (root / rel).read_bytes() != data]
|
| 558 | return not bad, bad
|
| 559 |
|
| 560 |
|
| 561 | def op_switch_existing(ctx: Ctx) -> None:
|
| 562 | exp = ctx.p.expected
|
| 563 | args = ["switch", "-q", exp.branch] if _is_git(ctx) else ["switch", exp.branch]
|
| 564 | ctx.timed(args, ctx.work)
|
| 565 | ok, bad = _branch_paths_match(ctx, ctx.work, exp.branch_bytes)
|
| 566 | ctx.oracle(ok, "branch_paths_bytes_equal_fixture", "git_fixture_instrument", mismatched=bad[:10])
|
| 567 |
|
| 568 |
|
| 569 | def _base_branch(ctx: Ctx) -> str:
|
| 570 | return "main" if _is_git(ctx) else str(ctx.p.oak_base_branch)
|
| 571 |
|
| 572 |
|
| 573 | def op_switch_back(ctx: Ctx) -> None:
|
| 574 | exp = ctx.p.expected
|
| 575 | args = ["switch", "-q", _base_branch(ctx)] if _is_git(ctx) else ["switch", _base_branch(ctx)]
|
| 576 | ctx.timed(args, ctx.work)
|
| 577 | ok, bad = _branch_paths_match(ctx, ctx.work, exp.main_bytes_of_branch_paths)
|
| 578 | ctx.oracle(ok, "base_paths_bytes_equal_fixture_main", "git_fixture_instrument", mismatched=bad[:10])
|
| 579 |
|
| 580 |
|
| 581 | def current_branch(ctx: Ctx, root: Path) -> str:
|
| 582 | if _is_git(ctx):
|
| 583 | return ctx.git(["symbolic-ref", "--short", "HEAD"], root, check=False).stdout.decode().strip()
|
| 584 | out = ctx.untimed(["status", "--json"], root, check=False).stdout
|
| 585 | try:
|
| 586 | return str(json.loads(out).get("branch"))
|
| 587 | except ValueError:
|
| 588 | return ""
|
| 589 |
|
| 590 |
|
| 591 | def op_branch_create(ctx: Ctx) -> None:
|
| 592 | ctx.state["branch_seq"] += 1
|
| 593 | name = f"new-{ctx.tag}-{ctx.state['branch_seq']}"
|
| 594 | args = ["switch", "-q", "-c", name] if _is_git(ctx) else ["switch", "-c", name]
|
| 595 | ctx.timed(args, ctx.work)
|
| 596 | now = current_branch(ctx, ctx.work)
|
| 597 | ctx.outcome.semantic_class = "git_branch_from_HEAD" if _is_git(ctx) else "oak_branch_from_main"
|
| 598 | _drop_branch(ctx, name)
|
| 599 | ctx.oracle(now == name, "current_branch_is_new_branch", "subject_output", current=now, wanted=name)
|
| 600 |
|
| 601 |
|
| 602 | def op_review_branch_diff(ctx: Ctx) -> None:
|
| 603 | exp = ctx.p.expected
|
| 604 | if _is_git(ctx):
|
| 605 | cap = ctx.timed(["diff", "--name-status", f"main...{exp.branch}"], ctx.work)
|
| 606 | paths = [line.split("\t", 1)[-1] for line in (cap.stdout_full_text or "").splitlines() if "\t" in line]
|
| 607 | reader = "subject_output"
|
| 608 | else:
|
| 609 | cap = ctx.timed(["branch", "diff", exp.branch, "--json"], ctx.work)
|
| 610 | try:
|
| 611 | paths = [item["path"] for item in json.loads(cap.stdout_full_text or "{}").get("changed_files", [])]
|
| 612 | except (ValueError, KeyError, AttributeError):
|
| 613 | paths = []
|
| 614 | reader = "subject_output_json"
|
| 615 | ctx.oracle(sorted(paths) == sorted(exp.branch_paths), "changed_paths_equal_generator_branch_paths", reader,
|
| 616 | reported=sorted(paths)[:10], expected=sorted(exp.branch_paths)[:10])
|
| 617 |
|
| 618 |
|
| 619 | def op_merge_preview(ctx: Ctx) -> None:
|
| 620 | exp = ctx.p.expected
|
| 621 | if _is_git(ctx):
|
| 622 | cap = ctx.timed(["merge-tree", "--write-tree", "main", exp.branch], ctx.work)
|
| 623 | tree = (cap.stdout_full_text or "").split("\n", 1)[0].strip()
|
| 624 | bad = []
|
| 625 | for rel, data in exp.branch_bytes.items():
|
| 626 | got = ctx.git(["cat-file", "blob", f"{tree}:{rel}"], ctx.work, check=False).stdout
|
| 627 | if got != data:
|
| 628 | bad.append(rel)
|
| 629 | ctx.oracle(bool(tree) and not bad, "clean_prediction_tree_has_branch_bytes", "git_fixture_instrument",
|
| 630 | mismatched=bad[:10])
|
| 631 | ctx.outcome.semantic_class = "git_merge_tree_two_refs"
|
| 632 | return
|
| 633 | ctx.untimed(["switch", exp.branch], ctx.work)
|
| 634 | try:
|
| 635 | # Exit 7 = prediction computed but not certified against the remote
|
| 636 | # target head (documented exit code); the prediction itself is timed.
|
| 637 | cap = ctx.timed(["merge", "--dry-run", "--json"], ctx.work, accept=(0, 7))
|
| 638 | finally:
|
| 639 | ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
|
| 640 | try:
|
| 641 | data = json.loads((cap.stdout_full_text or "").splitlines()[0])
|
| 642 | except (ValueError, IndexError):
|
| 643 | data = {}
|
| 644 | changed = sorted(item.get("path") if isinstance(item, dict) else item for item in data.get("changed_files", []))
|
| 645 | ctx.outcome.semantic_class = "oak_merge_dry_run_current_into_main"
|
| 646 | ctx.outcome.extra["merge_certified"] = (data.get("merge_safety") or {}).get("certified")
|
| 647 | ctx.oracle(data.get("clean") is True and changed == sorted(exp.branch_paths),
|
| 648 | "clean_prediction_lists_branch_paths", "subject_output_json", clean=data.get("clean"),
|
| 649 | changed=changed[:10], expected=sorted(exp.branch_paths)[:10])
|
| 650 |
|
| 651 |
|
| 652 | def op_merge_preview_conflict(ctx: Ctx) -> None:
|
| 653 | conflict = ctx.p.expected.conflict
|
| 654 | if not conflict:
|
| 655 | raise OpSkipped("fixture_has_no_conflict_shape")
|
| 656 | if not _is_git(ctx):
|
| 657 | raise OpSkipped("oak_merge_dry_run_predicts_current_branch_into_main_only")
|
| 658 | cap = ctx.timed(["merge-tree", "--write-tree", "--name-only", conflict["ours"], conflict["theirs"]],
|
| 659 | ctx.work, accept=(1,))
|
| 660 | lines = (cap.stdout_full_text or "").split("\n\n", 1)[0].splitlines()[1:]
|
| 661 | ctx.oracle(len(lines) == conflict["paths"], "conflicted_path_count_equals_fixture", "subject_output",
|
| 662 | conflicted=len(lines), expected=conflict["paths"])
|
| 663 |
|
| 664 |
|
| 665 | def op_merge_local(ctx: Ctx) -> None:
|
| 666 | if not _is_git(ctx):
|
| 667 | raise OpSkipped("oak_local_merge_needs_non_main_parent_unreachable_from_cli; "
|
| 668 | "oak_serve_lacks_merge_to_main")
|
| 669 | exp = ctx.p.expected
|
| 670 | scratch = f"merge-{ctx.tag}"
|
| 671 | ctx.git(["switch", "-q", "-c", scratch, "main"], ctx.work)
|
| 672 | try:
|
| 673 | ctx.timed(["merge", "-q", "--no-ff", "--no-edit", exp.branch], ctx.work)
|
| 674 | parents = ctx.git(["rev-list", "--parents", "-n", "1", "HEAD"], ctx.work).stdout.split()
|
| 675 | ok, bad = _branch_paths_match(ctx, ctx.work, exp.branch_bytes)
|
| 676 | ctx.oracle(ok and len(parents) == 3, "merge_commit_two_parents_and_branch_bytes", "git_fixture_instrument",
|
| 677 | parents=len(parents) - 1, mismatched=bad[:10])
|
| 678 | finally:
|
| 679 | ctx.git(["merge", "--abort"], ctx.work, check=False)
|
| 680 | ctx.git(["switch", "-q", "-f", "main"], ctx.work)
|
| 681 | ctx.git(["branch", "-q", "-D", scratch], ctx.work, check=False)
|
| 682 |
|
| 683 |
|
| 684 | def op_merge_conflict(ctx: Ctx) -> None:
|
| 685 | conflict = ctx.p.expected.conflict
|
| 686 | if not conflict:
|
| 687 | raise OpSkipped("fixture_has_no_conflict_shape")
|
| 688 | if not _is_git(ctx):
|
| 689 | raise OpSkipped("oak_local_merge_needs_non_main_parent_unreachable_from_cli")
|
| 690 | scratch = f"conflict-{ctx.tag}"
|
| 691 | ctx.git(["switch", "-q", "-c", scratch, conflict["ours"]], ctx.work)
|
| 692 | try:
|
| 693 | ctx.timed(["merge", "-q", "--no-edit", conflict["theirs"]], ctx.work, accept=(1,))
|
| 694 | unmerged = ctx.git(["diff", "--name-only", "--diff-filter=U"], ctx.work).stdout.decode().split()
|
| 695 | ctx.oracle(len(unmerged) == conflict["paths"], "unmerged_path_count_equals_fixture", "subject_output",
|
| 696 | unmerged=len(unmerged), expected=conflict["paths"])
|
| 697 | finally:
|
| 698 | ctx.git(["merge", "--abort"], ctx.work, check=False)
|
| 699 | ctx.git(["switch", "-q", "-f", "main"], ctx.work)
|
| 700 | ctx.git(["branch", "-q", "-D", scratch], ctx.work, check=False)
|
| 701 |
|
| 702 |
|
| 703 | def _client_commit(ctx: Ctx, client: Path, rel: str, content: bytes) -> str:
|
| 704 | target = client / rel
|
| 705 | target.parent.mkdir(parents=True, exist_ok=True)
|
| 706 | target.write_bytes(content)
|
| 707 | if _is_git(ctx):
|
| 708 | ctx.git(["add", "-A"], client)
|
| 709 | ctx.git(["commit", "-q", "--no-verify", "-m", f"sync {rel}"], client)
|
| 710 | return ctx.git(["rev-parse", "HEAD"], client).stdout.decode().strip()
|
| 711 | ctx.untimed(["commit", "--json"], client)
|
| 712 | return json.loads(ctx.untimed(["status", "--json"], client).stdout)["head"]
|
| 713 |
|
| 714 |
|
| 715 | def _head(ctx: Ctx, client: Path) -> str:
|
| 716 | if _is_git(ctx):
|
| 717 | return ctx.git(["rev-parse", "HEAD"], client).stdout.decode().strip()
|
| 718 | return json.loads(ctx.untimed(["status", "--json"], client).stdout)["head"]
|
| 719 |
|
| 720 |
|
| 721 | def _oak_remote(ctx: Ctx) -> None:
|
| 722 | """Sync ops need seeded loopback servers; label an Oak snapshot seed."""
|
| 723 | if not getattr(ctx.p.tier, "sync", True):
|
| 724 | raise OpSkipped("tier_excludes_sync_clients_disk_budget")
|
| 725 | if _is_git(ctx):
|
| 726 | return
|
| 727 | if getattr(ctx.p, "oak_remote_unavailable", None):
|
| 728 | raise OpSkipped(str(ctx.p.oak_remote_unavailable))
|
| 729 | seed = getattr(ctx.p, "oak_seed", None)
|
| 730 | ctx.outcome.extra["oak_server_seed"] = seed
|
| 731 | if seed and seed != "full_history":
|
| 732 | ctx.outcome.semantic_class = f"oak_server_seed_{seed}"
|
| 733 |
|
| 734 |
|
| 735 | def op_push_branch(ctx: Ctx) -> None:
|
| 736 | _oak_remote(ctx)
|
| 737 | client_b = ctx.client("clientB")
|
| 738 | rel = f"sync/push-{ctx.tag}.txt"
|
| 739 | head = _client_commit(ctx, client_b, rel, f"push {ctx.tag}\n".encode())
|
| 740 | ctx.state["pushed"] = (rel, head)
|
| 741 | if _is_git(ctx):
|
| 742 | ctx.timed(["push", "-q", "origin", "HEAD:main"], client_b)
|
| 743 | remote = ctx.git(["ls-remote", "origin", "refs/heads/main"], client_b).stdout.decode().split()
|
| 744 | ctx.state["server_extra"] += 1
|
| 745 | ctx.oracle(bool(remote) and remote[0] == head, "server_ref_equals_pushed_head", "git_ls_remote",
|
| 746 | remote=remote[:1], pushed=head)
|
| 747 | else:
|
| 748 | cap = ctx.timed(["push", "--json"], client_b)
|
| 749 | try:
|
| 750 | receipt = json.loads((cap.stdout_full_text or "").splitlines()[-1])
|
| 751 | except (ValueError, IndexError):
|
| 752 | receipt = {}
|
| 753 | # Server-side confirmation comes from clientA's pull.ff oracle in the
|
| 754 | # same unit (independent checkout sees the pushed commit and bytes).
|
| 755 | ctx.outcome.extra["push_receipt_pushed"] = receipt.get("pushed")
|
| 756 | ctx.oracle(receipt.get("remote_contacted") is True, "push_receipt_remote_contacted_confirmed_by_pull_ff",
|
| 757 | "subject_output_json", pushed=receipt.get("pushed"))
|
| 758 |
|
| 759 |
|
| 760 | def op_pull_ff(ctx: Ctx) -> None:
|
| 761 | _oak_remote(ctx)
|
| 762 | pushed = ctx.state.pop("pushed", None)
|
| 763 | if not pushed:
|
| 764 | raise OpFailed("pull.ff needs push.branch in the same unit")
|
| 765 | rel, head = pushed
|
| 766 | client_a = ctx.client("clientA")
|
| 767 | if _is_git(ctx):
|
| 768 | ctx.timed(["pull", "-q", "--ff-only"], client_a)
|
| 769 | else:
|
| 770 | ctx.timed(["pull", "--json"], client_a)
|
| 771 | now = _head(ctx, client_a)
|
| 772 | want = f"push {ctx.tag}\n".encode()
|
| 773 | data_ok = (client_a / rel).is_file() and (client_a / rel).read_bytes() == want
|
| 774 | payloads, reader, meta = committed_bytes(ctx, client_a, [rel])
|
| 775 | committed_ok = payloads.get(rel) == want
|
| 776 | ctx.oracle(now == head and data_ok and committed_ok, "puller_head_worktree_and_committed_bytes_equal_pusher",
|
| 777 | reader, puller_head=now, pusher_head=head, bytes_ok=data_ok, committed_ok=committed_ok, **meta)
|
| 778 |
|
| 779 |
|
| 780 | def op_pull_diverged(ctx: Ctx) -> None:
|
| 781 | _oak_remote(ctx)
|
| 782 | client_a, client_b = ctx.client("clientA"), ctx.client("clientB")
|
| 783 | local_rel, up_rel = f"sync/local-{ctx.tag}.txt", f"sync/up-{ctx.tag}.txt"
|
| 784 | _client_commit(ctx, client_a, local_rel, b"local\n")
|
| 785 | _client_commit(ctx, client_b, up_rel, b"upstream\n")
|
| 786 | if _is_git(ctx):
|
| 787 | ctx.git(["push", "-q", "origin", "HEAD:main"], client_b)
|
| 788 | ctx.state["server_extra"] += 1
|
| 789 | ctx.timed(["pull", "-q", "--no-rebase", "--no-edit"], client_a)
|
| 790 | ctx.outcome.semantic_class = "git_merge_commit"
|
| 791 | else:
|
| 792 | ctx.untimed(["push", "--json"], client_b)
|
| 793 | cap = ctx.timed(["pull", "--json"], client_a)
|
| 794 | try:
|
| 795 | receipt = json.loads((cap.stdout_full_text or "").splitlines()[-1])
|
| 796 | except (ValueError, IndexError):
|
| 797 | receipt = {}
|
| 798 | ctx.outcome.semantic_class = (ctx.outcome.semantic_class or "") + f"|oak_pull_{receipt.get('convergence', 'unknown')}"
|
| 799 | both = (client_a / local_rel).is_file() and (client_a / up_rel).is_file()
|
| 800 | status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
|
| 801 | left = porcelain_paths(ctx.untimed(status_args, client_a).stdout.decode(errors="replace"))
|
| 802 | payloads, reader, meta = committed_bytes(ctx, client_a, [local_rel, up_rel])
|
| 803 | committed_ok = payloads.get(local_rel) == b"local\n" and payloads.get(up_rel) == b"upstream\n"
|
| 804 | ctx.oracle(both and not left and committed_ok, "both_sides_committed_present_and_tree_clean", reader,
|
| 805 | local_present=(client_a / local_rel).is_file(), upstream_present=(client_a / up_rel).is_file(),
|
| 806 | committed_ok=committed_ok, left=left[:10], **meta)
|
| 807 | # Re-converge (untimed): A publishes its merge, B catches up. A failure
|
| 808 | # here is an observed subject defect in an UNTIMED step: it is recorded on
|
| 809 | # the row (teardown_error) without discarding the timed sample.
|
| 810 | if _is_git(ctx):
|
| 811 | ctx.git(["push", "-q", "origin", "HEAD:main"], client_a)
|
| 812 | ctx.state["server_extra"] += 1
|
| 813 | ctx.git(["pull", "-q", "--ff-only"], client_b)
|
| 814 | return
|
| 815 | ctx.untimed(["push", "--json"], client_a)
|
| 816 | done = ctx.untimed(["pull", "--json"], client_b, check=False)
|
| 817 | if done.returncode != 0:
|
| 818 | try:
|
| 819 | receipt = json.loads(done.stdout.decode(errors="replace").splitlines()[-1])
|
| 820 | except (ValueError, IndexError):
|
| 821 | receipt = {}
|
| 822 | ctx.outcome.extra["teardown_error"] = (
|
| 823 | f"peer pull after reparent exited {done.returncode}: branch_update={receipt.get('branch_update')} "
|
| 824 | f"parent_sync_error_code={receipt.get('parent_sync_error_code')}")
|
| 825 | if _head(ctx, client_b) != _head(ctx, client_a):
|
| 826 | raise OpFailed("peer checkout did not converge after reparent: " + ctx.outcome.extra["teardown_error"])
|
| 827 |
|
| 828 |
|
| 829 | def op_clone_full(ctx: Ctx) -> None:
|
| 830 | _oak_remote(ctx)
|
| 831 | dest = ctx.scratch / f"clone-{ctx.tag}"
|
| 832 | if dest.exists():
|
| 833 | shutil.rmtree(dest)
|
| 834 | try:
|
| 835 | if _is_git(ctx):
|
| 836 | ctx.timed(["clone", "-q", str(ctx.p.git_url), str(dest)], ctx.scratch)
|
| 837 | # server main = fixture + files pushed to main by the sync ops
|
| 838 | expected = ctx.p.expected.main_file_count + ctx.state["server_extra"]
|
| 839 | extra_prefix: Tuple[str, ...] = ()
|
| 840 | else:
|
| 841 | ctx.timed(["clone", "-r", str(ctx.p.oak_url), "bench/fx", str(dest)], ctx.scratch)
|
| 842 | expected = ctx.p.expected.main_file_count # oak sync ops publish to a branch, not main
|
| 843 | # (a snapshot seed has the same main tree, one commit of history)
|
| 844 | extra_prefix = ()
|
| 845 | ok, detail = check_worktree(ctx, dest, expected, extra_prefix)
|
| 846 | ctx.oracle(ok, "clone_file_count_and_sampled_blob_ids_equal_fixture", "git_fixture_instrument", **detail)
|
| 847 | finally:
|
| 848 | shutil.rmtree(dest, ignore_errors=True)
|
| 849 |
|
| 850 |
|
| 851 | def op_gc(ctx: Ctx) -> None:
|
| 852 | status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
|
| 853 | if _is_git(ctx):
|
| 854 | before = ctx.git(["rev-list", "--count", "HEAD"], ctx.work).stdout.strip()
|
| 855 | ctx.timed(["gc", "--quiet"], ctx.work)
|
| 856 | after = ctx.git(["rev-list", "--count", "HEAD"], ctx.work).stdout.strip()
|
| 857 | ctx.outcome.semantic_class = "git_gc"
|
| 858 | else:
|
| 859 | before = ctx.untimed(["hash"], ctx.work).stdout.strip()
|
| 860 | ctx.timed(["maintenance", "compact"], ctx.work)
|
| 861 | after = ctx.untimed(["hash"], ctx.work).stdout.strip()
|
| 862 | ctx.outcome.semantic_class = "oak_maintenance_compact"
|
| 863 | left = porcelain_paths(ctx.untimed(status_args, ctx.work).stdout.decode(errors="replace"))
|
| 864 | probe = ctx.p.expected.probe_path
|
| 865 | payloads, reader, meta = committed_bytes(ctx, ctx.work, [probe])
|
| 866 | probe_ok = payloads.get(probe) == ctx.p.expected.probe_bytes
|
| 867 | ctx.oracle(before == after and not left and probe_ok, "head_status_and_committed_probe_unchanged",
|
| 868 | reader, before=before.decode() if isinstance(before, bytes) else before,
|
| 869 | after=after.decode() if isinstance(after, bytes) else after, left=left[:10],
|
| 870 | committed_probe_ok=probe_ok, **meta)
|
| 871 |
|
| 872 |
|
| 873 | OPS: Dict[str, Callable[[Ctx], None]] = {
|
| 874 | "status.clean": op_status_clean,
|
| 875 | "status.dirty": op_status_dirty,
|
| 876 | "diff.dirty": op_diff_dirty,
|
| 877 | "commit.dirty": op_commit_dirty,
|
| 878 | "log.full": op_log_full,
|
| 879 | "log.n": op_log_n,
|
| 880 | "log.path": op_log_path,
|
| 881 | "blame": op_blame,
|
| 882 | "file.show": op_file_show,
|
| 883 | "branch.list": op_branch_list,
|
| 884 | "branch.create": op_branch_create,
|
| 885 | "switch.existing": op_switch_existing,
|
| 886 | "switch.back": op_switch_back,
|
| 887 | "review.branch_diff": op_review_branch_diff,
|
| 888 | "merge.preview": op_merge_preview,
|
| 889 | "merge.preview.conflict": op_merge_preview_conflict,
|
| 890 | "merge.local": op_merge_local,
|
| 891 | "merge.conflict": op_merge_conflict,
|
| 892 | "push.branch": op_push_branch,
|
| 893 | "pull.ff": op_pull_ff,
|
| 894 | "pull.diverged": op_pull_diverged,
|
| 895 | "clone.full": op_clone_full,
|
| 896 | "gc": op_gc,
|
| 897 | }
|
| 898 | assert set(OPS) == set(OP_INFO)
|
| 899 |
|
| 900 |
|
| 901 | def catalog_yaml() -> str:
|
| 902 | """scenarios/scale.yaml, generated from this catalog (a test keeps them equal)."""
|
| 903 | lines = [
|
| 904 | "schema_version: 1",
|
| 905 | "scenario_set: scale",
|
| 906 | "description: Scale and coverage lane (suites/scale). Every user-facing VCS operation timed for git and oak"
|
| 907 | " across repository tiers. Generated from suites/scale/scalesuite/ops.py (catalog_yaml); "
|
| 908 | "tests/test_scale_suite.py keeps the two equal.",
|
| 909 | "",
|
| 910 | "operation_catalog:",
|
| 911 | ]
|
| 912 | for unit in UNIT_ORDER:
|
| 913 | for op in unit:
|
| 914 | info = OP_INFO[op]
|
| 915 | lines += [f" {op}:", f" description: {info.description}", " timed: true",
|
| 916 | f" mutates: {str(info.mutates).lower()}"]
|
| 917 | for key, values in (("tperf_refs", info.tperf), ("core_yaml_ops", info.core_yaml)):
|
| 918 | if values:
|
| 919 | lines.append(f" {key}:")
|
| 920 | lines.extend(f" - {value}" for value in values)
|
| 921 | else:
|
| 922 | lines.append(f" {key}: []")
|
| 923 | return "\n".join(lines) + "\n"
|
| 924 |
|
| 925 |
|
| 926 | def execute(ctx: Ctx, op: str) -> Outcome:
|
| 927 | try:
|
| 928 | OPS[op](ctx)
|
| 929 | except OpSkipped as exc:
|
| 930 | ctx.outcome.skip_reason = str(exc)
|
| 931 | except OpFailed as exc:
|
| 932 | ctx.outcome.failed = str(exc)
|
| 933 | except Exception as exc: # noqa: BLE001 - harness errors are recorded, never hidden
|
| 934 | ctx.outcome.failed = f"harness_error: {type(exc).__name__}: {str(exc)[-800:]}"
|
| 935 | ctx.outcome.extra["harness_error"] = True
|
| 936 | return ctx.outcome
|