| 1 | #!/usr/bin/env python3
|
| 2 | """Generate deterministic monorepo-shaped git fixtures via fast-import.
|
| 3 |
|
| 4 | Produces synthetic repositories whose scale knobs (tree width, history depth,
|
| 5 | ref count) are independent, for the xl/monorepo fixture family declared in
|
| 6 | config/fixtures.json. The same seed always yields byte-identical history:
|
| 7 | fixed author/committer identity, timestamps stepping from a fixed epoch,
|
| 8 | seeded RNG for paths/content/touch-sets, deterministic fast-import marks β
|
| 9 | so commit SHAs are reproducible run-to-run and machine-to-machine.
|
| 10 |
|
| 11 | The whole history is written as a single fast-import stream piped to one
|
| 12 | `git fast-import` subprocess (no per-file subprocess calls), which keeps
|
| 13 | generation tractable at 500k-file / 100k-commit scale. The working tree is
|
| 14 | left unpopulated; check out refs/heads/main if a materialized tree is needed.
|
| 15 |
|
| 16 | Layout: file i lives at dir{i//1000}/file{i}.txt. The initial commit carries
|
| 17 | the full tree; each subsequent commit rewrites a seeded subset (~10 files).
|
| 18 | A seeded fraction of files (--binary-share) holds incompressible bytes so
|
| 19 | pack/transfer measurements are not dominated by pathological compression.
|
| 20 | refs/heads/main is the primary branch; refs/heads/branch-{i} and
|
| 21 | refs/tags/tag-{i} point at seeded-deterministic commits.
|
| 22 |
|
| 23 | After import, HISTORY_MANIFEST.json (schema_version 1) records the knobs and
|
| 24 | the resulting HEAD SHA for fixture-identity checks.
|
| 25 |
|
| 26 | Exit codes: 0 ok, 1 usage/generation error, 3 git unavailable (skip-row
|
| 27 | honesty: the caller can record a skip instead of a bogus result).
|
| 28 |
|
| 29 | Usage:
|
| 30 | python3 scripts/make_monorepo_fixture.py --out /tmp/xl-tree \\
|
| 31 | --seed oakbench-xl-tree-v1 --files 500000 --commits 1000 --refs 100
|
| 32 | """
|
| 33 |
|
| 34 | from __future__ import annotations
|
| 35 |
|
| 36 | import argparse
|
| 37 | import hashlib
|
| 38 | import json
|
| 39 | import os
|
| 40 | import random
|
| 41 | import shutil
|
| 42 | import subprocess
|
| 43 | import sys
|
| 44 | from pathlib import Path
|
| 45 |
|
| 46 | GENERATOR_VERSION = "monorepo-v1"
|
| 47 | IDENTITY = b"Oak Bench <[email protected]>"
|
| 48 | EPOCH = 1_700_000_000 # first commit timestamp; +60s per commit
|
| 49 | TOUCHES_PER_COMMIT = 10
|
| 50 | PRIMARY_BRANCH = b"refs/heads/main"
|
| 51 | MANIFEST_NAME = "HISTORY_MANIFEST.json"
|
| 52 |
|
| 53 | QUICK_DEFAULTS = {"files": 50, "commits": 20, "refs": 5}
|
| 54 | FULL_DEFAULTS = {"files": 10_000, "commits": 1_000, "refs": 50}
|
| 55 |
|
| 56 | WORDS = (
|
| 57 | "handler request response buffer stream parse encode decode commit tree "
|
| 58 | "branch merge index cache token batch worker queue retry timeout config "
|
| 59 | "schema field record column filter reduce visit walk node edge graph "
|
| 60 | "mount hydrate snapshot manifest oracle subject lane scenario operation"
|
| 61 | ).split()
|
| 62 |
|
| 63 |
|
| 64 | def make_rng(seed: str, *scope: object) -> random.Random:
|
| 65 | digest = hashlib.sha256(":".join([seed, *map(str, scope)]).encode()).digest()
|
| 66 | return random.Random(int.from_bytes(digest[:8], "big"))
|
| 67 |
|
| 68 |
|
| 69 | def file_path(i: int) -> str:
|
| 70 | return f"dir{i // 1000}/file{i}.txt"
|
| 71 |
|
| 72 |
|
| 73 | def text_blob(seed: str, i: int, version: int, quick: bool) -> bytes:
|
| 74 | rng = make_rng(seed, "text", i, version)
|
| 75 | # Log-normal-ish sizes centered near ~3.5 KB so 500k files land near 2 GiB.
|
| 76 | lines = 2 if quick else max(8, int(rng.lognormvariate(4.0, 0.6)))
|
| 77 | out = [f"# {GENERATOR_VERSION} {file_path(i)} v{version}"]
|
| 78 | for n in range(lines):
|
| 79 | w = [rng.choice(WORDS) for _ in range(rng.randint(4, 9))]
|
| 80 | out.append(f"{w[0]}_{rng.randint(0, 999)} = {w[1]}({', '.join(w[2:])}) # {n}")
|
| 81 | return ("\n".join(out) + "\n").encode()
|
| 82 |
|
| 83 |
|
| 84 | def binary_blob(seed: str, i: int, version: int, quick: bool) -> bytes:
|
| 85 | rng = make_rng(seed, "binary", i, version)
|
| 86 | size = 64 if quick else rng.randint(4_096, 32_768)
|
| 87 | return rng.randbytes(size)
|
| 88 |
|
| 89 |
|
| 90 | def binary_file_set(seed: str, files: int, binary_share: float) -> frozenset[int]:
|
| 91 | count = int(round(files * binary_share))
|
| 92 | if count <= 0:
|
| 93 | return frozenset()
|
| 94 | return frozenset(make_rng(seed, "binary-set").sample(range(files), count))
|
| 95 |
|
| 96 |
|
| 97 | class StreamWriter:
|
| 98 | """Emits a deterministic fast-import stream to a binary file object."""
|
| 99 |
|
| 100 | def __init__(self, fh, seed: str, quick: bool, binary_files: frozenset[int]):
|
| 101 | self.fh = fh
|
| 102 | self.seed = seed
|
| 103 | self.quick = quick
|
| 104 | self.binary_files = binary_files
|
| 105 | self.mark = 0
|
| 106 | self.commit_marks: list[int] = []
|
| 107 |
|
| 108 | def blob_for(self, i: int, version: int) -> int:
|
| 109 | if i in self.binary_files:
|
| 110 | data = binary_blob(self.seed, i, version, self.quick)
|
| 111 | else:
|
| 112 | data = text_blob(self.seed, i, version, self.quick)
|
| 113 | self.mark += 1
|
| 114 | self.fh.write(b"blob\nmark :%d\ndata %d\n" % (self.mark, len(data)))
|
| 115 | self.fh.write(data)
|
| 116 | self.fh.write(b"\n")
|
| 117 | return self.mark
|
| 118 |
|
| 119 | def commit(self, index: int, message: str, changes: list[tuple[str, int]]) -> None:
|
| 120 | self.mark += 1
|
| 121 | ident = IDENTITY + b" %d +0000\n" % (EPOCH + 60 * index)
|
| 122 | msg = message.encode()
|
| 123 | fh = self.fh
|
| 124 | fh.write(b"commit " + PRIMARY_BRANCH + b"\nmark :%d\n" % self.mark)
|
| 125 | fh.write(b"author " + ident)
|
| 126 | fh.write(b"committer " + ident)
|
| 127 | fh.write(b"data %d\n" % len(msg))
|
| 128 | fh.write(msg)
|
| 129 | fh.write(b"\n")
|
| 130 | for path, blob_mark in changes:
|
| 131 | fh.write(b"M 100644 :%d %s\n" % (blob_mark, path.encode()))
|
| 132 | fh.write(b"\n")
|
| 133 | self.commit_marks.append(self.mark)
|
| 134 |
|
| 135 | def reset(self, ref: str, commit_mark: int) -> None:
|
| 136 | self.fh.write(b"reset %s\nfrom :%d\n\n" % (ref.encode(), commit_mark))
|
| 137 |
|
| 138 |
|
| 139 | def write_stream(fh, *, seed: str, files: int, commits: int, refs: int,
|
| 140 | tags: int, binary_share: float, quick: bool) -> None:
|
| 141 | writer = StreamWriter(fh, seed, quick, binary_file_set(seed, files, binary_share))
|
| 142 | fh.write(b"feature done\n")
|
| 143 |
|
| 144 | changes = [(file_path(i), writer.blob_for(i, 0)) for i in range(files)]
|
| 145 | writer.commit(0, f"initial tree: {files} files", changes)
|
| 146 |
|
| 147 | touches = min(TOUCHES_PER_COMMIT, files)
|
| 148 | for c in range(1, commits):
|
| 149 | subset = sorted(make_rng(seed, "touch", c).sample(range(files), touches))
|
| 150 | changes = [(file_path(i), writer.blob_for(i, c)) for i in subset]
|
| 151 | writer.commit(c, f"commit {c}: touch {touches} files", changes)
|
| 152 |
|
| 153 | marks = writer.commit_marks
|
| 154 | for i in range(refs):
|
| 155 | target = make_rng(seed, "branch", i).randrange(commits)
|
| 156 | writer.reset(f"refs/heads/branch-{i}", marks[target])
|
| 157 | for i in range(tags):
|
| 158 | target = make_rng(seed, "tag", i).randrange(commits)
|
| 159 | writer.reset(f"refs/tags/tag-{i}", marks[target])
|
| 160 | fh.write(b"done\n")
|
| 161 |
|
| 162 |
|
| 163 | def git_env() -> dict[str, str]:
|
| 164 | # Isolate from user/system git config so generation is config-independent.
|
| 165 | env = dict(os.environ)
|
| 166 | env["GIT_CONFIG_GLOBAL"] = os.devnull
|
| 167 | env["GIT_CONFIG_NOSYSTEM"] = "1"
|
| 168 | return env
|
| 169 |
|
| 170 |
|
| 171 | def run_git(args: list[str], cwd: Path) -> str:
|
| 172 | proc = subprocess.run(["git", *args], cwd=cwd, env=git_env(), check=True,
|
| 173 | capture_output=True, text=True)
|
| 174 | return proc.stdout.strip()
|
| 175 |
|
| 176 |
|
| 177 | def generate(out: Path, *, seed: str, files: int, commits: int, refs: int,
|
| 178 | tags: int, binary_share: float, quick: bool) -> dict[str, object]:
|
| 179 | run_git(["init", "-q"], out)
|
| 180 | importer = subprocess.Popen(
|
| 181 | ["git", "fast-import", "--quiet", "--done"],
|
| 182 | cwd=out, env=git_env(), stdin=subprocess.PIPE, stderr=subprocess.PIPE,
|
| 183 | )
|
| 184 | assert importer.stdin is not None
|
| 185 | try:
|
| 186 | write_stream(importer.stdin, seed=seed, files=files, commits=commits,
|
| 187 | refs=refs, tags=tags, binary_share=binary_share, quick=quick)
|
| 188 | except BrokenPipeError:
|
| 189 | pass
|
| 190 | finally:
|
| 191 | try:
|
| 192 | importer.stdin.close()
|
| 193 | except BrokenPipeError:
|
| 194 | pass
|
| 195 | stderr = importer.stderr.read()
|
| 196 | importer.stderr.close()
|
| 197 | importer.wait()
|
| 198 | if importer.returncode != 0:
|
| 199 | sys.stderr.write(stderr.decode(errors="replace"))
|
| 200 | raise RuntimeError(f"git fast-import failed with exit code {importer.returncode}")
|
| 201 |
|
| 202 | run_git(["symbolic-ref", "HEAD", PRIMARY_BRANCH.decode()], out)
|
| 203 | head_sha = run_git(["rev-parse", "HEAD"], out)
|
| 204 | manifest = {
|
| 205 | "schema_version": 1,
|
| 206 | "seed": seed,
|
| 207 | "files": files,
|
| 208 | "commits": commits,
|
| 209 | "refs": refs,
|
| 210 | "tags": tags,
|
| 211 | "head_sha": head_sha,
|
| 212 | "generator_version": GENERATOR_VERSION,
|
| 213 | }
|
| 214 | (out / MANIFEST_NAME).write_text(json.dumps(manifest, indent=2) + "\n")
|
| 215 | return manifest
|
| 216 |
|
| 217 |
|
| 218 | def parse_args() -> argparse.Namespace:
|
| 219 | ap = argparse.ArgumentParser(
|
| 220 | description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 221 | ap.add_argument("--out", type=Path, required=True, help="target directory (git init + fast-import)")
|
| 222 | ap.add_argument("--files", type=int, default=None)
|
| 223 | ap.add_argument("--commits", type=int, default=None)
|
| 224 | ap.add_argument("--refs", type=int, default=None)
|
| 225 | ap.add_argument("--tags", type=int, default=0)
|
| 226 | ap.add_argument("--seed", required=True)
|
| 227 | ap.add_argument("--binary-share", type=float, default=0.02,
|
| 228 | help="fraction of files with seeded binary content (default 0.02)")
|
| 229 | ap.add_argument("--quick", action="store_true", help="tiny sizes for tests")
|
| 230 | return ap.parse_args()
|
| 231 |
|
| 232 |
|
| 233 | def main() -> int:
|
| 234 | args = parse_args()
|
| 235 | if shutil.which("git") is None:
|
| 236 | print("make_monorepo_fixture: git not found on PATH; cannot generate "
|
| 237 | "fixture β record this run as skipped, not failed", file=sys.stderr)
|
| 238 | return 3
|
| 239 |
|
| 240 | defaults = QUICK_DEFAULTS if args.quick else FULL_DEFAULTS
|
| 241 | files = args.files if args.files is not None else defaults["files"]
|
| 242 | commits = args.commits if args.commits is not None else defaults["commits"]
|
| 243 | refs = args.refs if args.refs is not None else defaults["refs"]
|
| 244 |
|
| 245 | if files < 1 or commits < 1:
|
| 246 | print("--files and --commits must be >= 1", file=sys.stderr)
|
| 247 | return 1
|
| 248 | if refs < 0 or args.tags < 0:
|
| 249 | print("--refs and --tags must be >= 0", file=sys.stderr)
|
| 250 | return 1
|
| 251 | if not 0.0 <= args.binary_share <= 1.0:
|
| 252 | print("--binary-share must be within [0, 1]", file=sys.stderr)
|
| 253 | return 1
|
| 254 |
|
| 255 | out = args.out
|
| 256 | out.mkdir(parents=True, exist_ok=True)
|
| 257 | if any(out.iterdir()):
|
| 258 | print(f"refusing to write into non-empty {out}", file=sys.stderr)
|
| 259 | return 1
|
| 260 |
|
| 261 | manifest = generate(out, seed=args.seed, files=files, commits=commits,
|
| 262 | refs=refs, tags=args.tags,
|
| 263 | binary_share=args.binary_share, quick=args.quick)
|
| 264 | print(f"{GENERATOR_VERSION} seed={args.seed} files={files} commits={commits} "
|
| 265 | f"refs={refs} tags={args.tags} -> {out}")
|
| 266 | print(f"HEAD {manifest['head_sha']}")
|
| 267 | return 0
|
| 268 |
|
| 269 |
|
| 270 | if __name__ == "__main__":
|
| 271 | raise SystemExit(main())
|