Viewing files at commit 3354584a5d91Commit detailsView latest

Browse files
scripts/oakbench/conformance_matrix.py 864 lines · 45.7 KB · python
1
"""Roundtrip conformance matrix: author -> commit -> push -> fresh clone (+ pull) -> compare.
2
3
Every behaviour has an immutable ID (ADR-0005). The expected state is what the
4
harness itself wrote to the filesystem (never a VCS answer). Each subject run
5
compares that expectation against:
6
7
- ``commit_read`` - the committed tree of the authoring repository, read by an
8
  independent instrument: Oak through ``oak_sqlite_reader`` (pure-Python
9
  BLAKE3/zstd, no Oak code), Git through its plumbing (``ls-tree``/``cat-file``);
10
  checked after every step, so intermediate commits (deletes, transitions) count;
11
- ``source_preserved`` - the authoring worktree is not rewritten by commit/push
12
  (checked after every step);
13
- ``clone_fs`` / ``clone_read`` / ``clone_status_clean`` - a fresh clone of the
14
  pushed branch: filesystem bytes, independently read committed state, and the
15
  subject's own claim that the fresh clone is clean;
16
- ``pull_fs`` / ``pull_read`` / ``pull_status_clean`` - multi-step behaviours
17
  only: a clone taken after step 1 and updated with ``pull``.
18
19
A Git differential is recorded on Oak rows: Oak's independently read committed
20
tree against Git's committed tree for the same authored fixture (paths, bytes,
21
executable bit, symlink targets). Records are diagnostic conformance evidence
22
(``evidence_class: diagnostic_conformance``), never timing rows.
23
"""
24
from __future__ import annotations
25
26
from dataclasses import dataclass
27
import hashlib
28
import json
29
import os
30
from pathlib import Path
31
import shutil
32
import signal
33
import socket
34
import stat
35
import subprocess
36
import tempfile
37
import time
38
39
from oakbench.environment import base_env
40
41
MATRIX_VERSION = "2026-09-28.2"
42
ORACLE = "roundtrip_independent_v1"
43
SCHEMA_VERSION = 3
44
HUGE_ENV = "OAK_BENCH_CONFORMANCE_HUGE"
45
COMMAND_TIMEOUT_S = 180
46
HUGE_TIMEOUT_S = 3600
47
LEGS = ("commit_read", "source_preserved", "clone_fs", "clone_read", "clone_status_clean",
48
        "pull_fs", "pull_read", "pull_status_clean")
49
50
51
class CapabilityUnavailable(Exception):
52
    pass
53
54
55
class LegFailure(AssertionError):
56
    pass
57
58
59
# --- fixture DSL ------------------------------------------------------------
60
# Each step is a tuple of operations applied in order to the authoring tree:
61
#   ("write", path, bytes[, executable]) ("symlink", path, target)
62
#   ("mkdir", path) ("remove", path) ("rename", src, dst) ("chmod", path, mode)
63
#   ("sparse", path, size, tail) ("inplace", path, bytes)  # same size, mtime kept
64
65
def apply_op(root: Path, op: tuple) -> None:
66
    kind = op[0]
67
    path = root / op[1]
68
    if kind == "write":
69
        path.parent.mkdir(parents=True, exist_ok=True)
70
        path.write_bytes(op[2])
71
        path.chmod(0o755 if len(op) > 3 and op[3] else 0o644)
72
    elif kind == "symlink":
73
        path.parent.mkdir(parents=True, exist_ok=True)
74
        os.symlink(op[2], path)
75
    elif kind == "mkdir":
76
        path.mkdir(parents=True, exist_ok=True)
77
    elif kind == "remove":
78
        if path.is_symlink() or path.is_file():
79
            path.unlink()
80
        else:
81
            shutil.rmtree(path)
82
    elif kind == "rename":
83
        target = root / op[2]
84
        target.parent.mkdir(parents=True, exist_ok=True)
85
        os.rename(path, target)
86
    elif kind == "chmod":
87
        path.chmod(op[2])
88
    elif kind == "sparse":
89
        path.parent.mkdir(parents=True, exist_ok=True)
90
        with path.open("wb") as stream:
91
            stream.truncate(op[2] - len(op[3]))
92
            stream.seek(op[2] - len(op[3]))
93
            stream.write(op[3])
94
    elif kind == "inplace":
95
        # Same size, same mtime: a stat-cache that trusts (size, mtime) misses it.
96
        before = path.stat()
97
        if len(op[2]) != before.st_size:
98
            raise ValueError("inplace edit must keep the size")
99
        with path.open("r+b") as stream:
100
            stream.write(op[2])
101
        os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns))
102
    else:
103
        raise ValueError(f"unknown fixture op {kind!r}")
104
105
106
@dataclass(frozen=True)
107
class Behaviour:
108
    id: str
109
    group: str
110
    summary: str
111
    steps: tuple
112
    requires: frozenset = frozenset()
113
    gated: bool = False
114
    prior_art: tuple = ()
115
    observe_dirs: bool = False
116
117
    def describe(self) -> dict:
118
        return {"behaviour": self.id, "group": self.group, "summary": self.summary,
119
                "steps": len(self.steps), "requires": sorted(self.requires), "gated": self.gated,
120
                "prior_art": list(self.prior_art)}
121
122
123
def _w(path, data, executable=False):
124
    return ("write", path, data if isinstance(data, bytes) else data.encode(), executable)
125
126
127
def _files(*names, payload=b"conformance payload\n"):
128
    return tuple(_w(name, str(index).encode() + b":" + payload) for index, name in enumerate(names))
129
130
131
def _stream(seed: bytes, size: int) -> bytes:
132
    """Deterministic incompressible bytes (SHA-256 counter stream)."""
133
    out = bytearray()
134
    counter = 0
135
    while len(out) < size:
136
        out += hashlib.sha256(seed + counter.to_bytes(8, "little")).digest()
137
        counter += 1
138
    return bytes(out[:size])
139
140
141
def _many_small():
142
    ops = []
143
    for index in range(1200):
144
        size = (index * 7919) % 65
145
        ops.append(_w(f"d{index % 30:02d}/f{index:05d}.txt", _stream(b"small%d" % index, size)))
146
    return tuple(ops)
147
148
149
T3902 = "git t3902-quoted (unambiguous odd path names)"
150
T0050 = "git t0050-filesystem (case and Unicode-normalization filesystems)"
151
T3910 = "git t3910-mac-os-precompose (NFC/NFD names)"
152
T3700 = "git t3700-add (executable bit, core.filemode)"
153
T4129 = "git t4129-apply-samemode (mode-only changes)"
154
T4011 = "git t4011-diff-symlink (symlink targets as content)"
155
T2007 = "git t2007-checkout-symlink (directory replaced by symlink)"
156
T4114 = "git t4114-apply-typechange (file/symlink/directory type changes)"
157
T2021 = "git t2021-checkout-overwrite (file/directory replacement on checkout)"
158
T2003 = "git t2003-checkout-cache-mkdir (leading-path creation, symlinked parents)"
159
T0020 = "git t0020-crlf (byte-exact line endings when conversion is off)"
160
T0021 = "git t0021-conversion (content conversion must not alter stored bytes)"
161
T0027 = "git t0027-auto-crlf (LF/CRLF/mixed/no-final-newline matrix)"
162
T4012 = "git t4012-diff-binary (binary detection)"
163
T0010 = "git t0010-racy-git (same-size edits within timestamp granularity)"
164
T7001 = "git t7001-mv (renames, case-only renames)"
165
T4102 = "git t4102-apply-rename (moves across directories)"
166
DULWICH = "dulwich tests/compat (differential against real git; pattern only)"
167
168
LONG_COMPONENT = "l" * 251 + ".txt"
169
LONG_UNICODE_COMPONENT = "é" * 127  # 254 UTF-8 bytes
170
LONG_PATH = "/".join(f"segment-{index:02d}-" + "p" * 48 for index in range(12)) + "/leaf.txt"
171
DEEP_PATH = "/".join("d" for _ in range(40)) + "/bottom.txt"
172
173
BEHAVIOURS = (
174
    # --- paths ---------------------------------------------------------------
175
    Behaviour("path_space_v1", "paths", "Leading, trailing and inner spaces in files and directories.",
176
              (_files("space name", " leading space", "trailing space ", "dir with space/inner file"),),
177
              prior_art=(T3902,)),
178
    Behaviour("path_tab_v1", "paths", "Tab inside a file name.", (_files("tab\tname"),), prior_art=(T3902,)),
179
    Behaviour("path_newline_v1", "paths", "Newline and carriage return inside a file name.",
180
              (_files("line\nbreak", "carriage\rreturn"),), prior_art=(T3902,)),
181
    Behaviour("path_control_chars_v1", "paths", "Other C0/DEL control characters inside file names.",
182
              (_files("bell\x07name", "escape\x1b[0m", "delete\x7fname", "unit\x1fsep"),), prior_art=(T3902,)),
183
    Behaviour("path_leading_dash_v1", "paths", "Names that look like command-line options.",
184
              (_files("-leading", "--double-dash", "-n", "--", "dir/-inner"),), prior_art=(T3902,)),
185
    Behaviour("path_shell_metachars_v1", "paths", "Quotes, backslash, glob, shell and URL metacharacters.",
186
              (_files("quote\"name", "single'quote", "glob*star?[x]", "hash#bang!",
187
                      "$HOME", "semi;amp&pipe|", "percent%20", "colon:name", "brace{a,b}", "tilde~", "a=b"),),
188
              prior_art=(T3902,)),
189
    Behaviour("path_backslash_v1", "paths", "Backslash inside a POSIX file name is a literal byte, not a separator.",
190
              (_files("back\\slash", "trailing\\", "dir/in\\ner"),), requires=frozenset({"posix_backslash_names"}),
191
              prior_art=(T3902,)),
192
    Behaviour("path_unicode_nfc_v1", "paths", "Precomposed and non-Latin Unicode names, astral and format characters.",
193
              (_files("café", "日本語/文件", "λ-lambda", "emoji-\U0001F600",
194
                      "zwj-‍-joiner", "rtl-‮-override"),), prior_art=(T3910, T0050)),
195
    Behaviour("path_unicode_nfd_v1", "paths", "Decomposed (NFD) names must keep their exact code points.",
196
              (_files("café", "Å-ring"),), prior_art=(T3910, T0050)),
197
    Behaviour("path_unicode_nfc_nfd_pair_v1", "paths", "NFC and NFD spellings of one name as two distinct paths.",
198
              (_files("café", "café"),), requires=frozenset({"normalization_sensitive_fs"}),
199
              prior_art=(T3910, T0050)),
200
    Behaviour("path_non_utf8_v1", "paths", "A file name that is not valid UTF-8.",
201
              ((("write", os.fsdecode(b"latin1-\xe9.txt"), b"bytes name\n"),),),
202
              requires=frozenset({"non_utf8_names"}), prior_art=(T3902,)),
203
    Behaviour("path_long_component_v1", "paths", "255-byte path components (ASCII and multi-byte).",
204
              (_files(LONG_COMPONENT, LONG_UNICODE_COMPONENT),), requires=frozenset({"long_component_255"})),
205
    Behaviour("path_long_path_v1", "paths", "A ~700-byte relative path.", (_files(LONG_PATH),)),
206
    Behaviour("path_deep_tree_v1", "paths", "A file 40 directories deep.", (_files(DEEP_PATH),)),
207
    Behaviour("path_reserved_names_v1", "paths", "Windows-reserved and dot-heavy names that are legal on POSIX.",
208
              (_files("CON", "prn.txt", "aux.c", "NUL", "com1", "LPT9.log", "trailing-dot.", "..double-leading",
209
                      "...", ".hidden", "-", "_", "@", "clock$"),), prior_art=(T0050,)),
210
    Behaviour("path_case_only_rename_v1", "paths", "Case-only rename on a case-insensitive filesystem.",
211
              ((_w("File.txt", "case rename payload\n"),), (("rename", "File.txt", "file.txt"),)),
212
              requires=frozenset({"case_insensitive_fs"}), prior_art=(T7001, T0050)),
213
    Behaviour("path_case_distinct_v1", "paths", "Names differing only by case on a case-sensitive filesystem.",
214
              (_files("README", "readme", "ReadMe"),), requires=frozenset({"case_sensitive_fs"}),
215
              prior_art=(T0050,)),
216
    # --- modes ---------------------------------------------------------------
217
    Behaviour("mode_exec_new_v1", "modes", "New executable file next to a regular file with identical bytes.",
218
              ((_w("tool.sh", "#!/bin/sh\necho conformance\n", True), _w("copy.txt", "#!/bin/sh\necho conformance\n")),),
219
              requires=frozenset({"exec_bit"}), prior_art=(T3700,)),
220
    Behaviour("mode_exec_add_v1", "modes", "Executable bit added without a content change.",
221
              ((_w("run.sh", "#!/bin/sh\ntrue\n"),), (("chmod", "run.sh", 0o755),)),
222
              requires=frozenset({"exec_bit"}), prior_art=(T3700, T4129)),
223
    Behaviour("mode_exec_remove_v1", "modes", "Executable bit removed without a content change.",
224
              ((_w("run.sh", "#!/bin/sh\ntrue\n", True),), (("chmod", "run.sh", 0o644),)),
225
              requires=frozenset({"exec_bit"}), prior_art=(T3700, T4129)),
226
    Behaviour("mode_exec_and_content_v1", "modes", "Executable bit and content change in one commit.",
227
              ((_w("run.sh", "#!/bin/sh\ntrue\n"),), (_w("run.sh", "#!/bin/sh\nfalse\n", True),)),
228
              requires=frozenset({"exec_bit"}), prior_art=(T3700,)),
229
    # --- symlinks ------------------------------------------------------------
230
    Behaviour("symlink_relative_v1", "symlinks", "Relative links at several depths.",
231
              ((_w("target.txt", "link target\n"), ("symlink", "link", "target.txt"),
232
                ("symlink", "sub/uplink", "../target.txt"), ("symlink", "sub/deep/ln", "../../target.txt")),),
233
              requires=frozenset({"symlink"}), prior_art=(T4011,)),
234
    Behaviour("symlink_absolute_v1", "symlinks", "Absolute link targets are stored verbatim, never followed.",
235
              ((_w("keep.txt", "keep\n"), ("symlink", "abs", "/nonexistent-oak-conformance/absolute/target"),
236
                ("symlink", "abs-root", "/")),), requires=frozenset({"symlink"}), prior_art=(T4011,)),
237
    Behaviour("symlink_dangling_v1", "symlinks", "Dangling, self-referential and cyclic links.",
238
              ((_w("keep.txt", "keep\n"), ("symlink", "dangling", "absent-target"), ("symlink", "self", "self"),
239
                ("symlink", "loop-a", "loop-b"), ("symlink", "loop-b", "loop-a")),),
240
              requires=frozenset({"symlink"}), prior_art=(T4011,)),
241
    Behaviour("symlink_to_dir_v1", "symlinks", "Links to directories (including '.') are stored as links.",
242
              ((_w("real/file.txt", "inside real dir\n"), ("symlink", "alias", "real"), ("symlink", "here", ".")),),
243
              requires=frozenset({"symlink"}), prior_art=(T2003, T4011)),
244
    Behaviour("symlink_target_edge_v1", "symlinks", "Targets with spaces, Unicode, a newline and 1000 bytes.",
245
              ((_w("keep.txt", "keep\n"), ("symlink", "spaced", "target with space"),
246
                ("symlink", "unicode", "tärget-λ"), ("symlink", "newline", "nl\ntarget"),
247
                ("symlink", "long", "x" * 1000)),), requires=frozenset({"symlink"}), prior_art=(T4011,)),
248
    Behaviour("symlink_retarget_v1", "symlinks", "A link's target changes.",
249
              ((_w("a", "a\n"), _w("b", "b\n"), ("symlink", "l", "a")), (("remove", "l"), ("symlink", "l", "b"))),
250
              requires=frozenset({"symlink"}), prior_art=(T4011,)),
251
    Behaviour("symlink_to_file_v1", "symlinks", "A symlink replaced by a regular file.",
252
              ((_w("t", "t\n"), ("symlink", "x", "t")), (("remove", "x"), _w("x", "now a file\n"))),
253
              requires=frozenset({"symlink"}), prior_art=(T4114,)),
254
    Behaviour("file_to_symlink_v1", "symlinks", "A regular file replaced by a symlink.",
255
              ((_w("t", "t\n"), _w("x", "a file\n")), (("remove", "x"), ("symlink", "x", "t"))),
256
              requires=frozenset({"symlink"}), prior_art=(T4114,)),
257
    Behaviour("dir_to_symlink_v1", "symlinks", "A directory replaced by a symlink.",
258
              ((_w("p/inner.txt", "inner\n"), _w("elsewhere/e.txt", "e\n")),
259
               (("remove", "p"), ("symlink", "p", "elsewhere"))),
260
              requires=frozenset({"symlink"}), prior_art=(T2007, T4114)),
261
    Behaviour("symlink_to_dir_real_v1", "symlinks", "A symlink replaced by a real directory.",
262
              ((_w("elsewhere/e.txt", "e\n"), ("symlink", "p", "elsewhere")),
263
               (("remove", "p"), _w("p/inner.txt", "inner\n"))),
264
              requires=frozenset({"symlink"}), prior_art=(T2007, T4114)),
265
    # --- file <-> directory --------------------------------------------------
266
    Behaviour("file_to_dir_v1", "file_dir", "A file replaced by a directory of the same name.",
267
              ((_w("node", "file\n"), _w("keep", "k\n")), (("remove", "node"), _w("node/child.txt", "child\n"))),
268
              prior_art=(T2021, T4114)),
269
    Behaviour("dir_to_file_v1", "file_dir", "A directory tree replaced by a file of the same name.",
270
              ((_w("node/child.txt", "child\n"), _w("node/sub/g.txt", "g\n"), _w("keep", "k\n")),
271
               (("remove", "node"), _w("node", "file now\n"))), prior_art=(T2021, T4114)),
272
    Behaviour("file_to_deep_dir_v1", "file_dir", "A nested file replaced by a deeper directory chain.",
273
              ((_w("a/b", "b file\n"),), (("remove", "a/b"), _w("a/b/c/d.txt", "deep\n"))), prior_art=(T2021,)),
274
    # --- empty ---------------------------------------------------------------
275
    Behaviour("empty_file_v1", "empty", "Empty regular and executable files at several depths.",
276
              ((_w("empty", b""), _w("dir/empty", b""), _w("empty-exec", b"", True)),),
277
              requires=frozenset({"exec_bit"})),
278
    Behaviour("empty_dir_semantics_v1", "empty", "Empty directories are not content (recorded, never failed).",
279
              ((_w("keep.txt", "keep\n"), ("mkdir", "void"), ("mkdir", "nested/void/deeper")),), observe_dirs=True),
280
    Behaviour("dir_emptied_v1", "empty", "Deleting a directory's last file leaves no stale file.",
281
              ((_w("d/f.txt", "f\n"), _w("keep", "k\n")), (("remove", "d/f.txt"),)), observe_dirs=True),
282
    Behaviour("delete_all_v1", "empty", "Deleting every tracked file commits an empty tree.",
283
              ((_w("a", "a\n"), _w("b/c", "c\n")), (("remove", "a"), ("remove", "b")))),
284
    # --- content -------------------------------------------------------------
285
    Behaviour("bytes_all_values_v1", "content", "Every byte value, repeated.",
286
              ((_w("binary", bytes(range(256)) * 17),),), prior_art=(T4012,)),
287
    Behaviour("eol_lf_v1", "content", "LF line endings.", ((_w("lf.txt", b"one\ntwo\nthree\n"),),),
288
              prior_art=(T0020, T0027)),
289
    Behaviour("eol_crlf_v1", "content", "CRLF line endings, byte-exact.", ((_w("crlf.txt", b"one\r\ntwo\r\nthree\r\n"),),),
290
              prior_art=(T0020, T0027)),
291
    Behaviour("eol_cr_only_v1", "content", "Classic-Mac CR-only line endings.", ((_w("cr.txt", b"one\rtwo\rthree\r"),),),
292
              prior_art=(T0020,)),
293
    Behaviour("eol_mixed_v1", "content", "LF, CRLF and CR mixed in one file.",
294
              ((_w("mixed.txt", b"lf\ncrlf\r\ncr\rlf-again\n\r\n\n"),),), prior_art=(T0027,)),
295
    Behaviour("eol_no_final_newline_v1", "content", "Text without a final newline (LF and CRLF bodies).",
296
              ((_w("tail-lf.txt", b"a\nb"), _w("tail-crlf.txt", b"a\r\nb")),), prior_art=(T0027,)),
297
    Behaviour("eol_lf_to_crlf_v1", "content", "Changing only LF to CRLF is a committed change.",
298
              ((_w("eol.txt", b"x\ny\n"),), (_w("eol.txt", b"x\r\ny\r\n"),)), prior_art=(T0020, T0021)),
299
    Behaviour("whitespace_only_change_v1", "content", "Trailing-space and tab/space-only edits are committed.",
300
              ((_w("ws.txt", b"a\n\tb\n"),), (_w("ws.txt", b"a \n    b\n"),)), prior_art=(T0021,)),
301
    Behaviour("utf8_bom_v1", "content", "UTF-8 and UTF-16 byte-order marks are preserved.",
302
              ((_w("bom8.txt", b"\xef\xbb\xbfbom text\n"), _w("bom16.txt", "utf16 text\n".encode("utf-16-le"))),),
303
              prior_art=(T0021,)),
304
    Behaviour("binary_detection_v1", "content", "Binary-looking and text-looking payloads roundtrip byte-exact.",
305
              ((_w("nul-in-text.txt", b"text\x00more\n"), _w("latin1.txt", b"caf\xe9\n"),
306
                _w("late-nul.txt", b"a" * 9000 + b"\x00tail\n"), _w("image.png", b"\x89PNG\r\n\x1a\n" + _stream(b"png", 2048)),
307
                _w("high-bytes", bytes(range(128, 256)) * 8), _w("random.bin", _stream(b"random", 65536))),),
308
              prior_art=(T4012,)),
309
    Behaviour("binary_text_transition_v1", "content", "The same path changes from text to binary.",
310
              ((_w("blob", b"plain text\n"),), (_w("blob", _stream(b"bin", 4096)),)), prior_art=(T4012,)),
311
    Behaviour("modify_same_size_same_mtime_v1", "content",
312
              "Same-size in-place edit with the mtime restored must still be committed.",
313
              ((_w("racy.txt", b"aaaa-original\n"),), (("inplace", "racy.txt", b"bbbb-modified\n"),)),
314
              prior_art=(T0010,)),
315
    Behaviour("large_file_chunked_v1", "content", "A 10 MiB + 64 KiB incompressible file (chunked storage path).",
316
              ((_w("large.bin", _stream(b"large", 10 * 1024 * 1024 + 64 * 1024)),),)),
317
    Behaviour("huge_file_sparse_v1", "content", "A 1 GiB + 5 byte sparse file (gated).",
318
              ((("sparse", "huge.bin", 1024 * 1024 * 1024 + 5, b"tail\n"),),),
319
              requires=frozenset({"sparse_files"}), gated=True),
320
    # --- history -------------------------------------------------------------
321
    Behaviour("delete_readd_v1", "history", "Delete then re-add identical bytes at the same path.",
322
              ((_w("f.txt", "same\n"), _w("keep", "k\n")), (("remove", "f.txt"),), (_w("f.txt", "same\n"),))),
323
    Behaviour("rename_move_v1", "history", "Move a file to a new directory with a new name.",
324
              ((_w("old/name.txt", "moving payload\n"),), (("rename", "old/name.txt", "new/dir/name2.txt"),)),
325
              prior_art=(T4102, T7001)),
326
    Behaviour("shared_blob_across_modes_v1", "history",
327
              "One byte string as regular file, executable, duplicate and symlink target.",
328
              ((_w("a.txt", "abc"), _w("b.sh", "abc", True), _w("c.txt", "abc"), ("symlink", "link", "abc")),),
329
              requires=frozenset({"symlink", "exec_bit"})),
330
    Behaviour("many_small_files_v1", "history", "1,200 small files across 30 directories.", (_many_small(),)),
331
)
332
BY_ID = {behaviour.id: behaviour for behaviour in BEHAVIOURS}
333
assert len(BY_ID) == len(BEHAVIOURS), "behaviour IDs must be unique"
334
335
336
# --- capability probes ------------------------------------------------------
337
def probe_capabilities(workdir: Path) -> dict:
338
    workdir.mkdir(parents=True, exist_ok=True)
339
    caps = {}
340
    with tempfile.TemporaryDirectory(prefix="caps-", dir=workdir) as temporary:
341
        root = Path(temporary)
342
        try:
343
            (root / "t").write_text("t")
344
            (root / "l").symlink_to("t")
345
            caps["symlink"] = (root / "l").is_symlink()
346
        except (OSError, NotImplementedError):
347
            caps["symlink"] = False
348
        probe = root / "x"
349
        probe.write_text("x")
350
        probe.chmod(0o755)
351
        caps["exec_bit"] = bool(probe.stat().st_mode & 0o100) and os.name == "posix"
352
        (root / "CaseProbe").write_text("c")
353
        insensitive = (root / "caseprobe").exists()
354
        caps["case_insensitive_fs"] = insensitive
355
        caps["case_sensitive_fs"] = not insensitive
356
        (root / "café").write_text("n")
357
        caps["normalization_sensitive_fs"] = not (root / "café").exists()
358
        try:
359
            (root / os.fsdecode(b"\xe9-probe")).write_text("b")
360
            caps["non_utf8_names"] = True
361
        except (OSError, UnicodeError):
362
            caps["non_utf8_names"] = False
363
        try:
364
            (root / LONG_COMPONENT).write_text("l")
365
            (root / LONG_UNICODE_COMPONENT).write_text("l")
366
            caps["long_component_255"] = True
367
        except OSError:
368
            caps["long_component_255"] = False
369
        try:
370
            sparse = root / "sparse"
371
            with sparse.open("wb") as stream:
372
                stream.truncate(64 * 1024 * 1024)
373
            caps["sparse_files"] = sparse.stat().st_blocks * 512 < 1024 * 1024
374
        except OSError:
375
            caps["sparse_files"] = False
376
        caps["posix_backslash_names"] = os.name == "posix"
377
        free = shutil.disk_usage(root).free
378
        caps["free_bytes"] = free
379
    return caps
380
381
382
def fs_profile(caps: dict) -> str:
383
    """Host filesystem class keying expected failures and coverage (e.g. darwin-ci-ni)."""
384
    import sys
385
    return "-".join((sys.platform, "ci" if caps.get("case_insensitive_fs") else "cs",
386
                     "ns" if caps.get("normalization_sensitive_fs") else "ni"))
387
388
389
# --- manifests --------------------------------------------------------------
390
def _sha256_file(path: Path) -> tuple[str, int]:
391
    digest = hashlib.sha256()
392
    size = 0
393
    with path.open("rb") as stream:
394
        for block in iter(lambda: stream.read(1024 * 1024), b""):
395
            digest.update(block)
396
            size += len(block)
397
    return digest.hexdigest(), size
398
399
400
def fs_manifest(root: Path, dirs: list | None = None) -> dict:
401
    """Exact path -> entry projection of a worktree, never following links.
402
403
    Empty directories are not content; pass ``dirs`` to collect every
404
    directory path as an observation. Root ``.git``/``.oak`` are excluded.
405
    """
406
    result = {}
407
408
    def visit(directory: Path, prefix: str) -> None:
409
        with os.scandir(directory) as iterator:
410
            children = sorted(iterator, key=lambda entry: entry.name)
411
        for entry in children:
412
            if not prefix and entry.name in (".git", ".oak"):
413
                continue
414
            relative = prefix + entry.name
415
            mode = entry.stat(follow_symlinks=False).st_mode
416
            if stat.S_ISLNK(mode):
417
                result[relative] = {"kind": "symlink", "target": os.readlink(entry.path)}
418
            elif stat.S_ISDIR(mode):
419
                if dirs is not None:
420
                    dirs.append(relative)
421
                visit(Path(entry.path), relative + "/")
422
            elif stat.S_ISREG(mode):
423
                digest, size = _sha256_file(Path(entry.path))
424
                result[relative] = {"kind": "file", "size": size, "sha256": digest,
425
                                    "executable": bool(mode & 0o111)}
426
            else:
427
                result[relative] = {"kind": "special"}
428
    visit(root, "")
429
    return result
430
431
432
def reader_projection(entries: list) -> dict:
433
    result = {}
434
    for entry in entries:
435
        if entry["kind"] == "file":
436
            result[entry["path"]] = {"kind": "file", "size": entry["size"], "sha256": entry["sha256"],
437
                                     "executable": entry["executable"]}
438
        elif entry["kind"] == "symlink":
439
            result[entry["path"]] = {"kind": "symlink",
440
                                     "target": os.fsdecode(bytes.fromhex(entry["target_hex"]))}
441
    return result
442
443
444
def diff_manifests(expected: dict, actual: dict, limit: int = 8) -> str | None:
445
    if expected == actual:
446
        return None
447
    missing = sorted(expected.keys() - actual.keys())
448
    extra = sorted(actual.keys() - expected.keys())
449
    changed = sorted(key for key in expected.keys() & actual.keys() if expected[key] != actual[key])
450
    parts = []
451
    for label, items in (("missing", missing), ("extra", extra), ("changed", changed)):
452
        if items:
453
            parts.append(f"{label}={items[:limit]!r}" + (f"(+{len(items) - limit})" if len(items) > limit else ""))
454
    return "; ".join(parts)
455
456
457
# --- subjects ---------------------------------------------------------------
458
class Runner:
459
    def __init__(self, binary: str, env: dict, timeout: float):
460
        self.binary, self.env, self.timeout = binary, env, timeout
461
        self.commands = []
462
463
    def run(self, cwd: Path, *args, check=True, timeout=None) -> subprocess.CompletedProcess:
464
        started = time.monotonic()
465
        try:
466
            process = subprocess.run([self.binary, *args], cwd=cwd, env=self.env, stdin=subprocess.DEVNULL,
467
                                     capture_output=True, timeout=timeout or self.timeout)
468
        except subprocess.TimeoutExpired as exc:
469
            self.commands.append({"argv": [str(a) for a in args], "returncode": None, "timeout": True})
470
            raise LegFailure(f"command timed out: {args[:3]!r}") from exc
471
        self.commands.append({
472
            "argv": [str(a) for a in args], "returncode": process.returncode,
473
            "seconds": round(time.monotonic() - started, 3),
474
            "stderr": process.stderr.decode("utf-8", "backslashreplace")[-600:]})
475
        if check and process.returncode != 0:
476
            raise LegFailure(f"{Path(self.binary).name} {' '.join(map(str, args[:2]))} exited "
477
                             f"{process.returncode}: {process.stderr.decode('utf-8', 'backslashreplace').strip()[-300:]}")
478
        return process
479
480
481
class GitSubject:
482
    kind = "git"
483
    reader_label = "git_plumbing_committed_tree"
484
485
    def __init__(self, runner: Runner, workdir: Path):
486
        self.git, self.workdir = runner, workdir
487
488
    CONFIG = ("core.autocrlf=false", "core.precomposeunicode=false", "core.filemode=true",
489
              "core.symlinks=true", "core.quotepath=false")
490
491
    def init(self, repo: Path):
492
        self.git.run(repo, "init", "-q", "-b", "main")
493
        for item in self.CONFIG:
494
            key, value = item.split("=")
495
            self.git.run(repo, "config", key, value)
496
        self.remote = repo.parent / "remote.git"
497
        self.git.run(repo.parent, "init", "-q", "--bare", str(self.remote))
498
        self.git.run(repo, "remote", "add", "origin", str(self.remote))
499
500
    def commit(self, repo: Path, message: str):
501
        self.git.run(repo, "add", "--all")
502
        self.git.run(repo, "commit", "-q", "--no-verify", "--allow-empty-message", "-m", message)
503
504
    def push(self, repo: Path):
505
        self.git.run(repo, "push", "-q", "origin", "HEAD:refs/heads/main")
506
507
    def clone(self, repo: Path, destination: Path):
508
        config = [argument for item in self.CONFIG for argument in ("--config", item)]
509
        self.git.run(destination.parent, "clone", "-q", *config, "--branch", "main", str(self.remote),
510
                     str(destination))
511
512
    def pull(self, repo: Path):
513
        self.git.run(repo, "pull", "-q", "--ff-only", "origin", "main")
514
515
    def status_clean(self, repo: Path):
516
        output = self.git.run(repo, "status", "--porcelain=v1", "-z", "--untracked-files=all").stdout
517
        if output:
518
            raise LegFailure("git status reports changes: " + output.decode("utf-8", "backslashreplace")[:300])
519
520
    def committed(self, repo: Path) -> tuple[dict, dict]:
521
        listing = self.git.run(repo, "ls-tree", "-r", "-z", "--full-tree", "HEAD", check=False)
522
        if listing.returncode != 0:
523
            head = self.git.run(repo, "rev-parse", "--verify", "-q", "HEAD", check=False)
524
            if head.returncode != 0:
525
                raise LegFailure("no committed HEAD")
526
            raise LegFailure("git ls-tree failed")
527
        entries = []
528
        for record in listing.stdout.split(b"\0"):
529
            if not record:
530
                continue
531
            meta, path = record.split(b"\t", 1)
532
            mode, kind, object_id = meta.split(b" ")
533
            entries.append((mode.decode(), kind.decode(), object_id.decode(), os.fsdecode(path)))
534
        manifest = {}
535
        if entries:
536
            process = subprocess.run([self.git.binary, "cat-file", "--batch"], cwd=repo, env=self.git.env,
537
                                     input=b"".join(e[2].encode() + b"\n" for e in entries),
538
                                     capture_output=True, timeout=self.git.timeout)
539
            if process.returncode != 0:
540
                raise LegFailure("git cat-file --batch failed")
541
            data, offset = process.stdout, 0
542
            for mode, kind, object_id, path in entries:
543
                end = data.index(b"\n", offset)
544
                header = data[offset:end].split(b" ")
545
                size = int(header[2])
546
                payload = data[end + 1:end + 1 + size]
547
                offset = end + 2 + size
548
                if mode == "120000":
549
                    manifest[path] = {"kind": "symlink", "target": os.fsdecode(payload)}
550
                elif mode in ("100644", "100755"):
551
                    manifest[path] = {"kind": "file", "size": size, "sha256": hashlib.sha256(payload).hexdigest(),
552
                                      "executable": mode == "100755"}
553
                else:
554
                    manifest[path] = {"kind": "special:" + mode}
555
        head = self.git.run(repo, "rev-parse", "HEAD").stdout.decode().strip()
556
        return manifest, {"head": head, "reader": self.reader_label}
557
558
559
class OakSubject:
560
    kind = "oak"
561
    reader_label = "independent_oak_sqlite_reader"
562
563
    def __init__(self, runner: Runner, workdir: Path, server):
564
        self.oak, self.workdir, self.server = runner, workdir, server
565
        self.branch = None
566
567
    def init(self, repo: Path):
568
        self.oak.run(repo, "init", ".")
569
        self.spec = self.server.new_repo_spec()
570
571
    def commit(self, repo: Path, message: str):
572
        self.oak.run(repo, "commit", "--no-verify")
573
        if self.branch is None:
574
            self.branch = json.loads(self.oak.run(repo, "status", "--json").stdout)["branch"]
575
576
    def push(self, repo: Path):
577
        self.oak.run(repo, "push", "--repo", self.spec, "--remote", self.server.url)
578
579
    def clone(self, repo: Path, destination: Path):
580
        self.oak.run(destination.parent, "clone", self.spec, str(destination), "--remote", self.server.url,
581
                     "--branch", self.branch)
582
583
    def pull(self, repo: Path):
584
        self.oak.run(repo, "pull")
585
586
    def status_clean(self, repo: Path):
587
        status = json.loads(self.oak.run(repo, "status", "--json").stdout)
588
        dirty = list(status.get("changes") or []) + list((status.get("working_changes") or {}).get("changes") or [])
589
        if dirty:
590
            raise LegFailure("oak status reports changes: " + json.dumps(dirty)[:300])
591
592
    def committed(self, repo: Path) -> tuple[dict, dict]:
593
        from oakbench import oak_sqlite_reader as reader
594
        try:
595
            result = reader.read_committed_tree(repo, None, manifest=True, deep=True, complete=True)
596
        except reader.Invalid as exc:
597
            raise LegFailure("independent reader: stored state invalid: " + str(exc)) from exc
598
        except reader.Unavailable as exc:
599
            raise CapabilityUnavailable("independent reader unavailable: " + str(exc)) from exc
600
        info = {"head": result["head"], "reader": self.reader_label, "reader_version": result["reader_version"],
601
                "reader_backend": result["reader_backend"], "history": result.get("history"),
602
                "chunked_blobs_reassembled": result["chunked_blobs_reassembled"],
603
                "chunk_mappings_crosschecked": result["chunk_mappings_crosschecked"]}
604
        return reader_projection(result["manifest"]), info
605
606
607
class LoopbackOakServer:
608
    """One ``oak serve`` on 127.0.0.1 per matrix run; only this PID is ever stopped."""
609
610
    def __init__(self, binary: str, root: Path, env: dict):
611
        self.binary, self.root, self.env = binary, root, env
612
        self.process = None
613
        self.counter = 0
614
615
    def __enter__(self):
616
        self.root.mkdir(parents=True, exist_ok=True)
617
        with socket.socket() as listener:
618
            listener.bind(("127.0.0.1", 0))
619
            self.port = listener.getsockname()[1]
620
        self.url = f"http://127.0.0.1:{self.port}"
621
        self.process = subprocess.Popen([self.binary, "serve", "--dir", str(self.root), "--host", "127.0.0.1",
622
                                         "--port", str(self.port)], cwd=self.root, env=self.env,
623
                                        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
624
                                        start_new_session=os.name == "posix")
625
        deadline = time.monotonic() + 20
626
        while time.monotonic() < deadline:
627
            if self.process.poll() is not None:
628
                raise CapabilityUnavailable("oak serve exited before accepting connections")
629
            try:
630
                with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
631
                    return self
632
            except OSError:
633
                time.sleep(0.05)
634
        self.__exit__(None, None, None)
635
        raise CapabilityUnavailable("oak serve did not accept connections")
636
637
    def new_repo_spec(self) -> str:
638
        self.counter += 1
639
        return f"conformance/r{self.counter}-{os.getpid()}"
640
641
    def __exit__(self, *exc):
642
        """Stop the server's whole process group (a wrapper script's child too).
643
644
        The group was created by this run (start_new_session), so only
645
        processes this run started are signalled.
646
        """
647
        if self.process is None:
648
            return
649
        group = self.process.pid if os.name == "posix" else None
650
        for sig, wait in ((signal.SIGTERM, 10), (signal.SIGKILL, 10)):
651
            try:
652
                if group is not None:
653
                    os.killpg(group, sig)
654
                elif self.process.poll() is None:
655
                    self.process.send_signal(sig)
656
            except ProcessLookupError:
657
                break
658
            try:
659
                self.process.wait(timeout=wait)
660
            except subprocess.TimeoutExpired:
661
                continue
662
            if group is None:
663
                break
664
            try:
665
                os.killpg(group, 0)  # members other than the direct child left?
666
            except ProcessLookupError:
667
                break
668
669
670
# --- behaviour execution ----------------------------------------------------
671
def _leg(legs: dict, name: str, function) -> None:
672
    try:
673
        detail = function()
674
        legs[name] = {"status": "fail", "detail": detail} if detail else {"status": "pass"}
675
    except CapabilityUnavailable as exc:
676
        legs[name] = {"status": "unmeasured", "detail": str(exc)}
677
    except LegFailure as exc:
678
        legs[name] = {"status": "fail", "detail": str(exc)}
679
680
681
def run_behaviour(subject, behaviour: Behaviour, root: Path, observations: dict) -> tuple[dict, dict | None]:
682
    """Execute one behaviour; returns (legs, committed-manifest-of-final-state)."""
683
    legs = {}
684
    source = root / "src"
685
    source.mkdir()
686
    subject.init(source)
687
    step_manifests = []
688
    early_clone = root / "early"
689
    final_committed = None
690
    for index, step in enumerate(behaviour.steps):
691
        for op in step:
692
            apply_op(source, op)
693
        expected = fs_manifest(source)
694
        step_manifests.append(expected)
695
        label = f"commit_read@{index + 1}" if len(behaviour.steps) > 1 else "commit_read"
696
        try:
697
            subject.commit(source, f"conformance {behaviour.id} step {index + 1}")
698
            subject.push(source)
699
        except LegFailure as exc:
700
            legs["commit_push" + (f"@{index + 1}" if len(behaviour.steps) > 1 else "")] = {
701
                "status": "fail", "detail": str(exc)}
702
            return legs, None
703
        committed_holder = {}
704
705
        def check_commit(expected=expected):
706
            manifest, info = subject.committed(source)
707
            committed_holder["manifest"], committed_holder["info"] = manifest, info
708
            return diff_manifests(expected, manifest)
709
        _leg(legs, label, check_commit)
710
        suffix = f"@{index + 1}" if len(behaviour.steps) > 1 else ""
711
        _leg(legs, "source_preserved" + suffix, lambda expected=expected: diff_manifests(expected, fs_manifest(source)))
712
        final_committed = committed_holder.get("manifest")
713
        if committed_holder.get("info"):
714
            observations.setdefault("committed_reader", committed_holder["info"])
715
        if index == 0 and len(behaviour.steps) > 1:
716
            try:
717
                subject.clone(source, early_clone)
718
            except LegFailure as exc:
719
                legs["pull_fs"] = {"status": "fail", "detail": "clone for pull leg failed: " + str(exc)}
720
    expected = step_manifests[-1]
721
    clone = root / "clone"
722
    try:
723
        subject.clone(source, clone)
724
    except LegFailure as exc:
725
        legs["clone_fs"] = {"status": "fail", "detail": str(exc)}
726
    else:
727
        dirs = [] if behaviour.observe_dirs else None
728
        _leg(legs, "clone_fs", lambda: diff_manifests(expected, fs_manifest(clone, dirs)))
729
        if dirs is not None:
730
            observations["clone_dirs"] = dirs
731
        def check_clone_read():
732
            manifest, info = subject.committed(clone)
733
            observations["clone_reader"] = info
734
            return diff_manifests(expected, manifest)
735
        _leg(legs, "clone_read", check_clone_read)
736
        _leg(legs, "clone_status_clean", lambda: subject.status_clean(clone))
737
    if len(behaviour.steps) > 1 and "pull_fs" not in legs:
738
        try:
739
            subject.pull(early_clone)
740
        except LegFailure as exc:
741
            legs["pull_fs"] = {"status": "fail", "detail": str(exc)}
742
        else:
743
            dirs = [] if behaviour.observe_dirs else None
744
            _leg(legs, "pull_fs", lambda: diff_manifests(expected, fs_manifest(early_clone, dirs)))
745
            if dirs is not None:
746
                observations["pull_dirs"] = dirs
747
            _leg(legs, "pull_read", lambda: diff_manifests(expected, subject.committed(early_clone)[0]))
748
            _leg(legs, "pull_status_clean", lambda: subject.status_clean(early_clone))
749
    return legs, final_committed
750
751
752
def _binary_identity(binary: str, kind: str = "oak") -> dict:
753
    resolved = shutil.which(binary)
754
    if resolved is None:
755
        return {}
756
    extra = {}
757
    if kind == "git":
758
        # Same rule as the timed lanes (ADR-0011): run the real Git, never the xcrun shim.
759
        from oakbench.subjects import is_xcrun_git_shim, resolve_real_git
760
        if is_xcrun_git_shim(resolved):
761
            real = resolve_real_git()
762
            extra = {"git_requested_bin": resolved, "git_shim_resolved": real is not None}
763
            resolved = str(real) if real else resolved
764
    resolved = str(Path(resolved).resolve())
765
    return {"binary": resolved, "binary_sha256": hashlib.sha256(Path(resolved).read_bytes()).hexdigest(), **extra}
766
767
768
def base_row(kind: str, behaviour: Behaviour) -> dict:
769
    return {"conformance_schema_version": SCHEMA_VERSION, "evidence_class": "diagnostic_conformance",
770
            "matrix_version": MATRIX_VERSION, "oracle": ORACLE, "subject": kind, "scenario": behaviour.id,
771
            "behaviour_group": behaviour.group, "summary": behaviour.summary, "fixture_version": 1,
772
            "prior_art": list(behaviour.prior_art), "wall_ms": None, "tokens": None, "integrity_ok": None}
773
774
775
def run_matrix(workdir: Path, subjects: dict, behaviours=None, include_gated: bool | None = None,
776
               progress=None) -> list[dict]:
777
    """Run behaviours for {kind: binary}; git first so Oak rows carry the differential."""
778
    workdir = Path(workdir).resolve()
779
    workdir.mkdir(parents=True, exist_ok=True)
780
    if include_gated is None:
781
        include_gated = os.environ.get(HUGE_ENV) == "1"
782
    selected = [BY_ID[name] for name in behaviours] if behaviours else list(BEHAVIOURS)
783
    caps = probe_capabilities(workdir)
784
    profile = fs_profile(caps)
785
    rows = []
786
    git_committed = {}
787
    order = [kind for kind in ("git", "oak") if kind in subjects]
788
    for kind in order:
789
        identity = _binary_identity(subjects[kind], kind)
790
        home = workdir / f"home-{kind}"
791
        home.mkdir(exist_ok=True)
792
        env = base_env(home=home)
793
        env["OAK_NO_UPDATE_CHECK"] = "1"
794
        if not identity:
795
            rows += [{**base_row(kind, b), "fs_profile": profile, "binary_sha256": None, "returncode": 77,
796
                      "skip_reason": f"binary unavailable: {subjects[kind]}"} for b in selected]
797
            continue
798
        version = subprocess.run([identity["binary"], "--version"], capture_output=True, env=env,
799
                                 timeout=30).stdout.decode("utf-8", "replace").strip()
800
        server = LoopbackOakServer(identity["binary"], workdir / "oak-serve-data", env) if kind == "oak" else None
801
        try:
802
            if server is not None:
803
                server.__enter__()
804
            for behaviour in selected:
805
                row = {**base_row(kind, behaviour), **identity, "version": version, "fs_profile": profile,
806
                       "capabilities": {k: v for k, v in caps.items() if k in behaviour.requires}}
807
                missing = sorted(cap for cap in behaviour.requires if not caps.get(cap))
808
                if behaviour.gated and not include_gated:
809
                    row.update(returncode=77, skip_reason=f"gated: set {HUGE_ENV}=1 to run")
810
                elif missing:
811
                    row.update(returncode=77, skip_reason="filesystem capability unavailable: " + ",".join(missing))
812
                elif behaviour.gated and caps.get("free_bytes", 0) < 8 * 1024 ** 3:
813
                    row.update(returncode=77, skip_reason="gated behaviour needs >= 8 GiB free disk")
814
                else:
815
                    runner = Runner(identity["binary"], env, HUGE_TIMEOUT_S if behaviour.gated else COMMAND_TIMEOUT_S)
816
                    subject = GitSubject(runner, workdir) if kind == "git" else OakSubject(runner, workdir, server)
817
                    observations = {}
818
                    started = time.monotonic()
819
                    with tempfile.TemporaryDirectory(prefix=f"{kind}-{behaviour.id}-", dir=workdir) as temporary:
820
                        try:
821
                            legs, committed = run_behaviour(subject, behaviour, Path(temporary), observations)
822
                        except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
823
                            legs, committed = {"harness": {"status": "error", "detail": repr(exc)}}, None
824
                    row["legs"] = legs
825
                    row["observations"] = observations
826
                    row["commands"] = runner.commands
827
                    row["harness_seconds"] = round(time.monotonic() - started, 2)
828
                    statuses = {leg["status"] for leg in legs.values()}
829
                    failed = sorted(name for name, leg in legs.items() if leg["status"] in ("fail", "error"))
830
                    row["failed_legs"] = failed
831
                    if "error" in statuses:
832
                        row.update(returncode=2, failure="harness error: " + legs["harness"]["detail"])
833
                    elif failed:
834
                        first = legs[failed[0]]
835
                        row.update(returncode=1, integrity_ok=False, failure=f"{failed[0]}: {first.get('detail')}")
836
                    elif behaviour.gated and "unmeasured" in statuses and all(
837
                            leg["status"] == "pass" or "reader bound" in str(leg.get("detail"))
838
                            for leg in legs.values()):
839
                        # Gated huge behaviour: filesystem legs are measured; the
840
                        # reader legs are over the reader's bounds by design.
841
                        row.update(returncode=77, skip_reason="gated: filesystem legs passed; reader legs over "
842
                                   "reader bound (content not independently re-hashed)")
843
                    elif "unmeasured" in statuses:
844
                        row.update(returncode=77, skip_reason="leg unmeasured: " + "; ".join(
845
                            f"{name}: {leg['detail']}" for name, leg in legs.items() if leg["status"] == "unmeasured"))
846
                    else:
847
                        row.update(returncode=0, integrity_ok=True)
848
                    if kind == "git" and committed is not None:
849
                        git_committed[behaviour.id] = committed
850
                    if kind == "oak":
851
                        reference = git_committed.get(behaviour.id)
852
                        if reference is None or committed is None:
853
                            row["git_differential"] = "unavailable"
854
                        else:
855
                            difference = diff_manifests(reference, committed)
856
                            row["git_differential"] = "identical" if difference is None else "differs: " + difference
857
                rows.append(row)
858
                if progress:
859
                    progress(row)
860
        finally:
861
            if server is not None:
862
                server.__exit__(None, None, None)
863
    return rows
864