| 1 | """Deterministic synthetic repository generator (git fast-import stream).
|
| 2 |
|
| 3 | One ``GenSpec`` describes a repository shape along the git-sizer dimensions
|
| 4 | (files, bytes, path depth, history length, refs, big blobs). ``generate()``
|
| 5 | writes ONE fast-import stream into ONE ``git fast-import`` process, so the XL
|
| 6 | shapes (1M files, 100k commits, 10k refs, multi-GiB blobs) are generated in
|
| 7 | minutes, not hours, and without a working tree.
|
| 8 |
|
| 9 | Determinism contract (tested): identical spec -> identical bytes in the
|
| 10 | stream -> identical commit ids and ref values on any machine and any Git
|
| 11 | that speaks fast-import. Everything that could vary is pinned: identity,
|
| 12 | timestamps (fixed epoch + 60 s per commit), the RNG (``random.Random`` seeded
|
| 13 | from the spec seed), path layout, content, and ref names. ``refs_sha256``
|
| 14 | (sha256 over sorted ``<oid> <ref>`` lines) is the fixture identity recorded
|
| 15 | with every row; ``spec_sha256`` identifies the recipe.
|
| 16 |
|
| 17 | Content model:
|
| 18 | - text files: unique per (file, revision) so blobs never dedupe by accident;
|
| 19 | a header line plus filler words sliced from a seeded 64 KiB pool;
|
| 20 | - big binaries ("sparse where possible"): every 1 MiB segment is a 4 KiB
|
| 21 | seeded random block followed by zeros. Git packs them small, a chunking
|
| 22 | store dedupes the zero runs, and a checkout still writes the full size, so
|
| 23 | the working tree pays the real byte count while the fixture store does not.
|
| 24 |
|
| 25 | Path model: file i lives in leaf directory i // fanout. Leaf directories are
|
| 26 | addressed by digits over ``depth - 1`` directory levels (base ``fanout`` when
|
| 27 | that fits, otherwise the smallest base that addresses every leaf). Unused
|
| 28 | high digits are a zero-padded spine, so ``depth`` 64 yields a long single-child
|
| 29 | chain above a branching bottom: every tracked file sits at exactly ``depth``
|
| 30 | path components (git-sizer's "maximum path depth").
|
| 31 | """
|
| 32 |
|
| 33 | from __future__ import annotations
|
| 34 |
|
| 35 | import hashlib
|
| 36 | import json
|
| 37 | import math
|
| 38 | import os
|
| 39 | import random
|
| 40 | import subprocess
|
| 41 | import time
|
| 42 | from dataclasses import asdict, dataclass, field
|
| 43 | from pathlib import Path
|
| 44 | from typing import Any, BinaryIO, Dict, List, Optional, Tuple
|
| 45 |
|
| 46 | GENERATOR_VERSION = "scale-gen-2026-09-28.1"
|
| 47 | IDENTITY = b"Oak Bench <[email protected]>"
|
| 48 | EPOCH = 1_700_000_000
|
| 49 | SEGMENT = 1 << 20
|
| 50 | SEGMENT_RANDOM = 4096
|
| 51 | POOL_BYTES = 64 * 1024
|
| 52 | FLUSH_BYTES = 4 << 20
|
| 53 | MANIFEST_NAME = "GENERATOR_MANIFEST.json"
|
| 54 | PROBE_EVERY = 7 # the probe path is rewritten by every 7th history commit
|
| 55 |
|
| 56 |
|
| 57 | @dataclass(frozen=True)
|
| 58 | class GenSpec:
|
| 59 | """A repository recipe. Field names follow git-sizer's dimensions."""
|
| 60 |
|
| 61 | name: str
|
| 62 | seed: str
|
| 63 | files: int = 100
|
| 64 | file_bytes: int = 256
|
| 65 | depth: int = 3
|
| 66 | fanout: int = 32
|
| 67 | commits: int = 1
|
| 68 | touches_per_commit: int = 4
|
| 69 | branches: int = 0
|
| 70 | branch_commit_files: int = 2
|
| 71 | tags: int = 0
|
| 72 | binaries: int = 0
|
| 73 | binary_bytes: int = 0
|
| 74 | binary_revisions: int = 0
|
| 75 | ignore_patterns: Tuple[str, ...] = ()
|
| 76 | conflict_files: int = 0
|
| 77 | notes: str = ""
|
| 78 |
|
| 79 | def validate(self) -> None:
|
| 80 | if self.files < 1:
|
| 81 | raise ValueError("files must be >= 1")
|
| 82 | if self.depth < 1:
|
| 83 | raise ValueError("depth must be >= 1 (1 = files at the root)")
|
| 84 | if self.fanout < 2:
|
| 85 | raise ValueError("fanout must be >= 2")
|
| 86 | if self.commits < 1:
|
| 87 | raise ValueError("commits must be >= 1")
|
| 88 | if self.file_bytes < 32:
|
| 89 | raise ValueError("file_bytes must be >= 32 (room for the unique header line)")
|
| 90 | if self.binary_revisions and not self.binaries:
|
| 91 | raise ValueError("binary_revisions needs binaries >= 1")
|
| 92 | if self.binary_revisions > self.commits:
|
| 93 | raise ValueError("binary_revisions cannot exceed commits")
|
| 94 | if self.conflict_files > self.files:
|
| 95 | raise ValueError("conflict_files cannot exceed files")
|
| 96 | if self.branch_commit_files > self.files:
|
| 97 | raise ValueError("branch_commit_files cannot exceed files")
|
| 98 |
|
| 99 | def to_json(self) -> Dict[str, Any]:
|
| 100 | data = asdict(self)
|
| 101 | data["ignore_patterns"] = list(self.ignore_patterns)
|
| 102 | return data
|
| 103 |
|
| 104 | def spec_sha256(self) -> str:
|
| 105 | payload = json.dumps({"generator_version": GENERATOR_VERSION, "spec": self.to_json()},
|
| 106 | sort_keys=True, separators=(",", ":"))
|
| 107 | return hashlib.sha256(payload.encode()).hexdigest()
|
| 108 |
|
| 109 | def estimated_bytes(self) -> Dict[str, int]:
|
| 110 | """Rough working-tree and store sizes, for disk budgeting before generation."""
|
| 111 | text = self.files * self.file_bytes
|
| 112 | binary = self.binaries * self.binary_bytes
|
| 113 | # APFS/ext4 allocate at least one 4 KiB block per small file.
|
| 114 | worktree = self.files * max(4096, self.file_bytes) + binary
|
| 115 | store = text + self.commits * self.touches_per_commit * self.file_bytes \
|
| 116 | + max(1, self.binary_revisions) * self.binaries * (self.binary_bytes // SEGMENT + 1) * SEGMENT_RANDOM
|
| 117 | return {"worktree_bytes": worktree, "store_bytes_estimate": store}
|
| 118 |
|
| 119 |
|
| 120 | # ---------------------------------------------------------------------------
|
| 121 | # Layout and content (pure functions: also used by oracles)
|
| 122 | # ---------------------------------------------------------------------------
|
| 123 |
|
| 124 |
|
| 125 | def layout(spec: GenSpec) -> Tuple[int, int]:
|
| 126 | """(directory levels, digit base) for the spec's path layout."""
|
| 127 | dir_levels = spec.depth - 1
|
| 128 | if dir_levels == 0:
|
| 129 | return 0, spec.fanout
|
| 130 | leaves = max(1, math.ceil(spec.files / spec.fanout))
|
| 131 | needed = 1 if leaves <= 1 else math.ceil(math.log(leaves, spec.fanout) - 1e-12)
|
| 132 | if needed <= dir_levels:
|
| 133 | return dir_levels, spec.fanout
|
| 134 | base = 2
|
| 135 | while base ** dir_levels < leaves:
|
| 136 | base += 1
|
| 137 | return dir_levels, base
|
| 138 |
|
| 139 |
|
| 140 | def file_path(spec: GenSpec, index: int, _cache: Optional[Tuple[int, int]] = None) -> str:
|
| 141 | dir_levels, base = _cache or layout(spec)
|
| 142 | name = f"f{index}.txt"
|
| 143 | if dir_levels == 0:
|
| 144 | return name
|
| 145 | leaf = index // spec.fanout
|
| 146 | digits = []
|
| 147 | for _ in range(dir_levels):
|
| 148 | digits.append(leaf % base)
|
| 149 | leaf //= base
|
| 150 | parts = [f"d{digit}" for digit in reversed(digits)]
|
| 151 | parts.append(name)
|
| 152 | return "/".join(parts)
|
| 153 |
|
| 154 |
|
| 155 | def branch_name(number: int) -> str:
|
| 156 | return f"b{number:05d}"
|
| 157 |
|
| 158 |
|
| 159 | def branch_changes(spec: GenSpec, number: int, _cache: Optional[Tuple[int, int]] = None) -> List[Tuple[int, str]]:
|
| 160 | """(file index, path) rewritten by side branch ``number`` (content revision = branch name)."""
|
| 161 | cache = _cache or layout(spec)
|
| 162 | out: List[Tuple[int, str]] = []
|
| 163 | seen = set()
|
| 164 | for j in range(spec.branch_commit_files):
|
| 165 | index = (number * 7919 + j * 104729 + 1) % spec.files
|
| 166 | if index in seen:
|
| 167 | continue
|
| 168 | seen.add(index)
|
| 169 | out.append((index, file_path(spec, index, cache)))
|
| 170 | return out
|
| 171 |
|
| 172 |
|
| 173 | def binary_path(index: int) -> str:
|
| 174 | return f"bin/blob{index}.bin"
|
| 175 |
|
| 176 |
|
| 177 | def _pool(seed: str) -> bytes:
|
| 178 | rng = random.Random(f"{seed}:pool")
|
| 179 | words = [b"alpha", b"branch", b"commit", b"delta", b"engine", b"field", b"graph", b"hash",
|
| 180 | b"index", b"journal", b"kernel", b"ledger", b"merge", b"node", b"oracle", b"patch",
|
| 181 | b"query", b"record", b"stream", b"tree", b"update", b"vector", b"worker", b"zone"]
|
| 182 | out = bytearray()
|
| 183 | column = 0
|
| 184 | while len(out) < POOL_BYTES + 8192:
|
| 185 | word = words[rng.randrange(len(words))]
|
| 186 | out += word
|
| 187 | column += len(word) + 1
|
| 188 | if column > 60:
|
| 189 | out += b"\n"
|
| 190 | column = 0
|
| 191 | else:
|
| 192 | out += b" "
|
| 193 | return bytes(out)
|
| 194 |
|
| 195 |
|
| 196 | class ContentFactory:
|
| 197 | """Byte-exact content for any (file, revision); oracles call the same code."""
|
| 198 |
|
| 199 | def __init__(self, spec: GenSpec) -> None:
|
| 200 | self.spec = spec
|
| 201 | self.pool = _pool(spec.seed)
|
| 202 |
|
| 203 | def text(self, index: int, revision: str) -> bytes:
|
| 204 | header = f"{self.spec.name}:{index}:{revision}\n".encode()
|
| 205 | size = max(self.spec.file_bytes, len(header) + 1)
|
| 206 | filler = size - len(header)
|
| 207 | # Cheap deterministic offset mixing (no per-file hashing).
|
| 208 | offset = ((index * 2654435761) ^ (len(revision) * 40503) ^ sum(revision.encode())) % POOL_BYTES
|
| 209 | body = self.pool[offset:offset + filler]
|
| 210 | if len(body) < filler:
|
| 211 | body = (body + self.pool)[:filler]
|
| 212 | if body and not body.endswith(b"\n"):
|
| 213 | body = body[:-1] + b"\n"
|
| 214 | return header + body
|
| 215 |
|
| 216 | def binary_segments(self, index: int, revision: int):
|
| 217 | """Yield the binary's bytes in <= 1 MiB pieces (never materialized whole)."""
|
| 218 | total = self.spec.binary_bytes
|
| 219 | segment_no = 0
|
| 220 | written = 0
|
| 221 | while written < total:
|
| 222 | rng = random.Random(f"{self.spec.seed}:bin:{index}:{revision}:{segment_no}")
|
| 223 | head = rng.getrandbits(SEGMENT_RANDOM * 8).to_bytes(SEGMENT_RANDOM, "little")
|
| 224 | size = min(SEGMENT, total - written)
|
| 225 | if size <= SEGMENT_RANDOM:
|
| 226 | piece = head[:size]
|
| 227 | yield piece
|
| 228 | else:
|
| 229 | yield head
|
| 230 | zeros = size - SEGMENT_RANDOM
|
| 231 | while zeros > 0:
|
| 232 | step = min(zeros, 1 << 18)
|
| 233 | yield bytes(step)
|
| 234 | zeros -= step
|
| 235 | written += size
|
| 236 | segment_no += 1
|
| 237 |
|
| 238 |
|
| 239 | def binary_sha1_and_sha256(factory: ContentFactory, index: int, revision: int) -> Tuple[str, str]:
|
| 240 | """(git blob id, sha256) of a generated binary, streamed."""
|
| 241 | total = factory.spec.binary_bytes
|
| 242 | git = hashlib.sha1(f"blob {total}\0".encode())
|
| 243 | plain = hashlib.sha256()
|
| 244 | for piece in factory.binary_segments(index, revision):
|
| 245 | git.update(piece)
|
| 246 | plain.update(piece)
|
| 247 | return git.hexdigest(), plain.hexdigest()
|
| 248 |
|
| 249 |
|
| 250 | def git_blob_id(data: bytes) -> str:
|
| 251 | return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
|
| 252 |
|
| 253 |
|
| 254 | # ---------------------------------------------------------------------------
|
| 255 | # Stream writer
|
| 256 | # ---------------------------------------------------------------------------
|
| 257 |
|
| 258 |
|
| 259 | class _Stream:
|
| 260 | def __init__(self, sink: BinaryIO) -> None:
|
| 261 | self.sink = sink
|
| 262 | self.buffer: List[bytes] = []
|
| 263 | self.size = 0
|
| 264 | self.total = 0
|
| 265 |
|
| 266 | def write(self, data: bytes) -> None:
|
| 267 | self.buffer.append(data)
|
| 268 | self.size += len(data)
|
| 269 | if self.size >= FLUSH_BYTES:
|
| 270 | self.flush()
|
| 271 |
|
| 272 | def flush(self) -> None:
|
| 273 | if self.buffer:
|
| 274 | blob = b"".join(self.buffer)
|
| 275 | self.sink.write(blob)
|
| 276 | self.total += len(blob)
|
| 277 | self.buffer = []
|
| 278 | self.size = 0
|
| 279 |
|
| 280 | def data(self, payload: bytes) -> None:
|
| 281 | self.write(b"data %d\n" % len(payload))
|
| 282 | self.write(payload)
|
| 283 | self.write(b"\n")
|
| 284 |
|
| 285 |
|
| 286 | def _commit_header(stream: _Stream, ref: bytes, mark: int, when: int, message: bytes,
|
| 287 | parent: Optional[int]) -> None:
|
| 288 | stream.write(b"commit " + ref + b"\n")
|
| 289 | stream.write(b"mark :%d\n" % mark)
|
| 290 | stamp = b"%d +0000" % when
|
| 291 | stream.write(b"author " + IDENTITY + b" " + stamp + b"\n")
|
| 292 | stream.write(b"committer " + IDENTITY + b" " + stamp + b"\n")
|
| 293 | stream.data(message)
|
| 294 | if parent is not None:
|
| 295 | stream.write(b"from :%d\n" % parent)
|
| 296 |
|
| 297 |
|
| 298 | def write_stream(spec: GenSpec, sink: BinaryIO) -> Dict[str, Any]:
|
| 299 | """Write the full fast-import stream for ``spec``; return the manifest facts."""
|
| 300 | spec.validate()
|
| 301 | rng = random.Random(f"{spec.seed}:history")
|
| 302 | factory = ContentFactory(spec)
|
| 303 | cache = layout(spec)
|
| 304 | stream = _Stream(sink)
|
| 305 | stream.write(b"feature done\n")
|
| 306 | main = b"refs/heads/main"
|
| 307 | probe_index = 0
|
| 308 | probe_path = file_path(spec, probe_index, cache)
|
| 309 | probe_commits = 1 # the initial commit adds it
|
| 310 | touched_paths_last: List[str] = []
|
| 311 | mark = 0
|
| 312 | tick = 0
|
| 313 |
|
| 314 | # Commit 1: the full tree.
|
| 315 | mark += 1
|
| 316 | tick += 1
|
| 317 | _commit_header(stream, main, mark, EPOCH + 60 * tick, b"c1 initial tree\n", None)
|
| 318 | if spec.ignore_patterns:
|
| 319 | stream.write(b"M 100644 inline .gitignore\n")
|
| 320 | stream.data(("\n".join(spec.ignore_patterns) + "\n").encode())
|
| 321 | for index in range(spec.files):
|
| 322 | stream.write(b"M 100644 inline " + file_path(spec, index, cache).encode() + b"\n")
|
| 323 | stream.data(factory.text(index, "r1"))
|
| 324 | for index in range(spec.binaries):
|
| 325 | stream.write(b"M 100644 inline " + binary_path(index).encode() + b"\n")
|
| 326 | stream.write(b"data %d\n" % spec.binary_bytes)
|
| 327 | for piece in factory.binary_segments(index, 1):
|
| 328 | stream.write(piece)
|
| 329 | stream.write(b"\n")
|
| 330 | commit_marks = [mark]
|
| 331 |
|
| 332 | # Commits 2..N: seeded touch sets; every PROBE_EVERY-th also rewrites the probe path.
|
| 333 | for number in range(2, spec.commits + 1):
|
| 334 | mark += 1
|
| 335 | tick += 1
|
| 336 | _commit_header(stream, main, mark, EPOCH + 60 * tick, b"c%d\n" % number, mark - 1)
|
| 337 | chosen = set()
|
| 338 | for _ in range(min(spec.touches_per_commit, spec.files)):
|
| 339 | chosen.add(rng.randrange(spec.files))
|
| 340 | if number % PROBE_EVERY == 0:
|
| 341 | chosen.add(probe_index)
|
| 342 | elif probe_index in chosen:
|
| 343 | chosen.discard(probe_index)
|
| 344 | if probe_index in chosen:
|
| 345 | probe_commits += 1
|
| 346 | touched_paths_last = []
|
| 347 | for index in sorted(chosen):
|
| 348 | path = file_path(spec, index, cache)
|
| 349 | touched_paths_last.append(path)
|
| 350 | stream.write(b"M 100644 inline " + path.encode() + b"\n")
|
| 351 | stream.data(factory.text(index, f"r{number}"))
|
| 352 | if number <= spec.binary_revisions:
|
| 353 | stream.write(b"M 100644 inline " + binary_path(0).encode() + b"\n")
|
| 354 | stream.write(b"data %d\n" % spec.binary_bytes)
|
| 355 | for piece in factory.binary_segments(0, number):
|
| 356 | stream.write(piece)
|
| 357 | stream.write(b"\n")
|
| 358 | commit_marks.append(mark)
|
| 359 | tip = mark
|
| 360 |
|
| 361 | # Branches: each forks from the tip with one commit of branch_commit_files rewrites.
|
| 362 | branch_sample: Dict[str, Any] = {}
|
| 363 | for number in range(spec.branches):
|
| 364 | name = branch_name(number)
|
| 365 | mark += 1
|
| 366 | tick += 1
|
| 367 | _commit_header(stream, b"refs/heads/" + name.encode(), mark, EPOCH + 60 * tick,
|
| 368 | b"branch " + name.encode() + b"\n", tip)
|
| 369 | changed = []
|
| 370 | for index, path in branch_changes(spec, number, cache):
|
| 371 | changed.append(path)
|
| 372 | stream.write(b"M 100644 inline " + path.encode() + b"\n")
|
| 373 | stream.data(factory.text(index, name))
|
| 374 | if number == 0:
|
| 375 | branch_sample = {"name": name, "changed_paths": sorted(changed)}
|
| 376 |
|
| 377 | # Conflict shape: two branches rewrite the same files differently.
|
| 378 | conflict: Dict[str, Any] = {}
|
| 379 | if spec.conflict_files:
|
| 380 | conflict_paths = [file_path(spec, i, cache) for i in range(spec.conflict_files)]
|
| 381 | for side in ("bench-ours", "bench-theirs"):
|
| 382 | mark += 1
|
| 383 | tick += 1
|
| 384 | _commit_header(stream, b"refs/heads/" + side.encode(), mark, EPOCH + 60 * tick,
|
| 385 | side.encode() + b"\n", tip)
|
| 386 | for i in range(spec.conflict_files):
|
| 387 | stream.write(b"M 100644 inline " + conflict_paths[i].encode() + b"\n")
|
| 388 | stream.data(factory.text(i, side))
|
| 389 | conflict = {"ours": "bench-ours", "theirs": "bench-theirs", "paths": len(conflict_paths),
|
| 390 | "paths_sample": conflict_paths[:20]}
|
| 391 |
|
| 392 | # Lightweight tags at seeded history commits.
|
| 393 | for number in range(spec.tags):
|
| 394 | target = commit_marks[rng.randrange(len(commit_marks))]
|
| 395 | stream.write(b"reset refs/tags/t%05d\nfrom :%d\n\n" % (number, target))
|
| 396 |
|
| 397 | stream.write(b"done\n")
|
| 398 | stream.flush()
|
| 399 | return {
|
| 400 | "main_commits": spec.commits,
|
| 401 | "total_commits": mark,
|
| 402 | "probe_path": probe_path,
|
| 403 | "probe_path_commit_count": probe_commits,
|
| 404 | "last_commit_paths": sorted(touched_paths_last),
|
| 405 | "branch_sample": branch_sample,
|
| 406 | "conflict": conflict,
|
| 407 | "binary_paths": [binary_path(i) for i in range(spec.binaries)],
|
| 408 | "stream_bytes": stream.total,
|
| 409 | }
|
| 410 |
|
| 411 |
|
| 412 | # ---------------------------------------------------------------------------
|
| 413 | # Generation
|
| 414 | # ---------------------------------------------------------------------------
|
| 415 |
|
| 416 |
|
| 417 | def git_env() -> Dict[str, str]:
|
| 418 | env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
| 419 | env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, GIT_TERMINAL_PROMPT="0",
|
| 420 | LC_ALL="C", TZ="UTC")
|
| 421 | return env
|
| 422 |
|
| 423 |
|
| 424 | def refs_digest(git_bin: str, repo: Path) -> Tuple[str, int]:
|
| 425 | out = subprocess.run([git_bin, "-C", str(repo), "for-each-ref", "--format=%(objectname) %(refname)"],
|
| 426 | check=True, capture_output=True, env=git_env()).stdout
|
| 427 | lines = sorted(line for line in out.splitlines() if line)
|
| 428 | return hashlib.sha256(b"\n".join(lines) + b"\n").hexdigest(), len(lines)
|
| 429 |
|
| 430 |
|
| 431 | def generate(spec: GenSpec, out_dir: Path, git_bin: str = "git") -> Dict[str, Any]:
|
| 432 | """Generate ``out_dir/src.git`` (bare) plus ``GENERATOR_MANIFEST.json``.
|
| 433 |
|
| 434 | Idempotent: an existing fixture whose manifest names the same spec_sha256
|
| 435 | and still resolves to the recorded refs digest is reused, not rebuilt.
|
| 436 | """
|
| 437 | spec.validate()
|
| 438 | out_dir = Path(out_dir)
|
| 439 | repo = out_dir / "src.git"
|
| 440 | manifest_path = out_dir / MANIFEST_NAME
|
| 441 | if manifest_path.exists() and repo.exists():
|
| 442 | existing = json.loads(manifest_path.read_text())
|
| 443 | if existing.get("spec_sha256") == spec.spec_sha256():
|
| 444 | digest, _ = refs_digest(git_bin, repo)
|
| 445 | if digest == existing.get("refs_sha256"):
|
| 446 | existing["reused"] = True
|
| 447 | return existing
|
| 448 | if repo.exists():
|
| 449 | import shutil
|
| 450 | shutil.rmtree(repo)
|
| 451 | out_dir.mkdir(parents=True, exist_ok=True)
|
| 452 | env = git_env()
|
| 453 | subprocess.run([git_bin, "init", "-q", "--bare", "-b", "main", str(repo)], check=True, env=env)
|
| 454 | for key, value in (("gc.auto", "0"), ("core.bigFileThreshold", "512m")):
|
| 455 | subprocess.run([git_bin, "-C", str(repo), "config", key, value], check=True, env=env)
|
| 456 | started = time.perf_counter()
|
| 457 | proc = subprocess.Popen([git_bin, "-C", str(repo), "fast-import", "--quiet", "--done"],
|
| 458 | stdin=subprocess.PIPE, env=env)
|
| 459 | assert proc.stdin is not None
|
| 460 | try:
|
| 461 | facts = write_stream(spec, proc.stdin)
|
| 462 | finally:
|
| 463 | proc.stdin.close()
|
| 464 | returncode = proc.wait()
|
| 465 | if returncode != 0:
|
| 466 | raise RuntimeError(f"git fast-import failed with exit {returncode}")
|
| 467 | elapsed = time.perf_counter() - started
|
| 468 | subprocess.run([git_bin, "-C", str(repo), "symbolic-ref", "HEAD", "refs/heads/main"], check=True, env=env)
|
| 469 | head = subprocess.run([git_bin, "-C", str(repo), "rev-parse", "refs/heads/main", "refs/heads/main^{tree}"],
|
| 470 | check=True, capture_output=True, text=True, env=env).stdout.split()
|
| 471 | digest, ref_count = refs_digest(git_bin, repo)
|
| 472 | version = subprocess.run([git_bin, "--version"], capture_output=True, text=True, env=env).stdout.strip()
|
| 473 | manifest = {
|
| 474 | "schema_version": 1,
|
| 475 | "generator": "suites/scale/scalesuite/generator.py",
|
| 476 | "generator_version": GENERATOR_VERSION,
|
| 477 | "spec": spec.to_json(),
|
| 478 | "spec_sha256": spec.spec_sha256(),
|
| 479 | "refs_sha256": digest,
|
| 480 | "ref_count": ref_count,
|
| 481 | "head_commit": head[0],
|
| 482 | "head_tree": head[1],
|
| 483 | "generation_elapsed_s": round(elapsed, 3),
|
| 484 | "generation_git_version": version,
|
| 485 | "store_bytes": dir_bytes(repo),
|
| 486 | **facts,
|
| 487 | }
|
| 488 | manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
| 489 | manifest["reused"] = False
|
| 490 | return manifest
|
| 491 |
|
| 492 |
|
| 493 | def dir_bytes(root: Path) -> int:
|
| 494 | """Allocated bytes under root (st_blocks; sparse-aware), no symlink following."""
|
| 495 | total = 0
|
| 496 | for base, dirs, files in os.walk(root):
|
| 497 | for name in files:
|
| 498 | try:
|
| 499 | total += os.lstat(os.path.join(base, name)).st_blocks * 512
|
| 500 | except OSError:
|
| 501 | pass
|
| 502 | return total
|