repos
/ repos-rust master

repos-rust

mirror archived upstream

A minimal self-hosted git browser on Rust axum: bare repos rendered as a website with commits, diffs, syntax-highlighted blobs, atom feeds, and clone over HTTPS.

axumdockergitgit-browsergitoxidegixrustself-hosted

49.0 KB · 1476 lines · Rust Raw History
   1//! Generate fake-but-realistic bare git repos under `fixtures/git/` so the
   2//! repos landing page has something to render in dev. Each repo gets a
   3//! month of commit history with multiple authors, a per-archetype file shape
   4//! (Rust crate, TS lib, Python package, markdown blog, dotfiles), and commit
   5//! messages drawn from a per-archetype corpus.
   6//!
   7//! Deterministic: pass `--seed N` to reproduce the same set of repos. Default
   8//! seed is fine; the same invocation always yields the same fixtures.
   9//!
  10//! Usage:
  11//!   cargo run --bin seed
  12//!   cargo run --bin seed -- --count 12 --days 45
  13//!   cargo run --bin seed -- --reset      # wipe fixtures/git/ first
  14
  15use std::collections::HashSet;
  16use std::fs;
  17use std::io::Write;
  18use std::path::{Path, PathBuf};
  19use std::process::Command;
  20
  21const DEFAULT_COUNT: usize = 8;
  22const DEFAULT_DAYS: i64 = 30;
  23const DEFAULT_SEED: u64 = 0xC0DE_FEED;
  24const DEST: &str = "fixtures/git";
  25
  26// ---------- PRNG ------------------------------------------------------------
  27
  28/// Xorshift64. Deterministic, zero deps, plenty good for picking from small
  29/// pools. Seeded from CLI so a given run is reproducible.
  30struct Rng(u64);
  31impl Rng {
  32    fn new(seed: u64) -> Self {
  33        Self(if seed == 0 { 0xCAFE_BABE } else { seed })
  34    }
  35    fn next_u64(&mut self) -> u64 {
  36        let mut x = self.0;
  37        x ^= x << 13;
  38        x ^= x >> 7;
  39        x ^= x << 17;
  40        self.0 = x;
  41        x
  42    }
  43    fn range(&mut self, max: usize) -> usize {
  44        (self.next_u64() as usize) % max.max(1)
  45    }
  46    fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
  47        &items[self.range(items.len())]
  48    }
  49    /// Returns true with probability `n_in_10 / 10`.
  50    fn chance(&mut self, n_in_10: usize) -> bool {
  51        self.range(10) < n_in_10
  52    }
  53}
  54
  55// ---------- authors ---------------------------------------------------------
  56
  57struct Author {
  58    name: &'static str,
  59    email: &'static str,
  60}
  61
  62const AUTHORS: &[Author] = &[
  63    Author { name: "Isaac Bythewood", email: "[email protected]" },
  64    Author { name: "Anna Holm",       email: "[email protected]" },
  65    Author { name: "Jules Sato",      email: "[email protected]" },
  66    Author { name: "Maren Akkerman",  email: "[email protected]" },
  67    Author { name: "Felix Ortiz",     email: "[email protected]" },
  68];
  69
  70/// 70% Isaac, 30% one of the others. Mirrors the look of a personal repo with
  71/// occasional drive-by contributions.
  72fn pick_author(rng: &mut Rng) -> &'static Author {
  73    if rng.chance(7) {
  74        &AUTHORS[0]
  75    } else {
  76        &AUTHORS[1 + rng.range(AUTHORS.len() - 1)]
  77    }
  78}
  79
  80// ---------- archetypes ------------------------------------------------------
  81
  82/// A single mutation applied per commit, paired with the commit messages
  83/// that plausibly describe it. Bundling op + messages stops the message and
  84/// diff from drifting apart (a commit titled "switch to thiserror" that
  85/// actually appends "" to README is the giveaway that this is fake).
  86struct Patch {
  87    op: PatchOp,
  88    messages: &'static [&'static str],
  89}
  90
  91enum PatchOp {
  92    /// Create the file with this body. If it already exists, the patch is a
  93    /// no-op and the seeder picks a different patch.
  94    Create { path: &'static str, body: &'static str },
  95    /// Append a single line. If the line is already the file's last line,
  96    /// the patch is a no-op and the seeder picks a different patch.
  97    Append { path: &'static str, line: &'static str },
  98}
  99
 100struct Archetype {
 101    kind: &'static str,
 102    description: &'static str,
 103    names: &'static [&'static str],
 104    initial: &'static [(&'static str, &'static str)],
 105    patches: &'static [Patch],
 106}
 107
 108// --- rust crate ---
 109
 110const RUST_LIB_RS: &str = "//! {name}: a small Rust library.\n\
 111\n\
 112pub fn version() -> &'static str {\n\
 113    env!(\"CARGO_PKG_VERSION\")\n\
 114}\n\
 115\n\
 116#[cfg(test)]\n\
 117mod tests {\n\
 118    use super::*;\n\
 119\n\
 120    #[test]\n\
 121    fn version_is_set() {\n\
 122        assert!(!version().is_empty());\n\
 123    }\n\
 124}\n";
 125
 126const RUST_CARGO_TOML: &str = "[package]\n\
 127name = \"{name}\"\n\
 128version = \"0.1.0\"\n\
 129edition = \"2021\"\n\
 130\n\
 131[dependencies]\n";
 132
 133const RUST_README: &str = "# {name}\n\
 134\n\
 135A small Rust crate that does one thing well.\n\
 136\n\
 137The API surface is intentionally narrow: a handful of types and free\n\
 138functions, no async runtime baked in.\n\
 139\n\
 140## Quick example\n\
 141\n\
 142    use {snake}::version;\n\
 143\n\
 144    println!(\"{}\", version());\n\
 145\n\
 146## License\n\
 147\n\
 148BSD-2-Clause.\n";
 149
 150const RUST_GITIGNORE: &str = "/target\nCargo.lock\n";
 151
 152const RUST_PARSE_RS: &str = "//! Tiny hand-written parser. Greedy, not particularly fast, but the\n\
 153//! tokenizer is straightforward enough to step through in a debugger.\n\
 154\n\
 155pub fn parse(input: &str) -> Result<Vec<String>, ParseError> {\n\
 156    let mut out = Vec::new();\n\
 157    for token in input.split_whitespace() {\n\
 158        if token.is_empty() {\n\
 159            return Err(ParseError::Empty);\n\
 160        }\n\
 161        out.push(token.to_string());\n\
 162    }\n\
 163    Ok(out)\n\
 164}\n\
 165\n\
 166#[derive(Debug)]\n\
 167pub enum ParseError {\n\
 168    Empty,\n\
 169    Unterminated,\n\
 170}\n";
 171
 172const RUST_ERROR_RS: &str = "use std::fmt;\n\
 173\n\
 174#[derive(Debug)]\n\
 175pub enum Error {\n\
 176    Io(std::io::Error),\n\
 177    Parse(crate::parse::ParseError),\n\
 178}\n\
 179\n\
 180impl fmt::Display for Error {\n\
 181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n\
 182        match self {\n\
 183            Error::Io(e) => write!(f, \"io: {}\", e),\n\
 184            Error::Parse(e) => write!(f, \"parse: {:?}\", e),\n\
 185        }\n\
 186    }\n\
 187}\n\
 188\n\
 189impl std::error::Error for Error {}\n";
 190
 191const RUST_UTIL_RS: &str = "/// Pad `s` on the right with spaces up to `width`.\n\
 192pub fn pad_right(s: &str, width: usize) -> String {\n\
 193    if s.len() >= width { s.to_string() } else { format!(\"{:<w$}\", s, w = width) }\n\
 194}\n";
 195
 196const RUST_BENCH_RS: &str = "#![feature(test)]\n\
 197extern crate test;\n\
 198\n\
 199use test::Bencher;\n\
 200use {name}::parse::parse;\n\
 201\n\
 202#[bench]\n\
 203fn parse_short(b: &mut Bencher) {\n\
 204    b.iter(|| parse(\"one two three four\"));\n\
 205}\n";
 206
 207const RUST_ARCHETYPE: Archetype = Archetype {
 208    kind: "rust-crate",
 209    description: "a small rust library",
 210    names: &[
 211        "roman-runes",
 212        "axum-knife",
 213        "beam-walker",
 214        "sextant",
 215        "basalt",
 216        "copper-net",
 217        "runesmith",
 218        "oxide-quill",
 219        "ferroquill",
 220    ],
 221    initial: &[
 222        ("Cargo.toml", RUST_CARGO_TOML),
 223        ("src/lib.rs", RUST_LIB_RS),
 224        ("README.md", RUST_README),
 225        (".gitignore", RUST_GITIGNORE),
 226    ],
 227    patches: &[
 228        Patch {
 229            op: PatchOp::Create { path: "src/parse.rs", body: RUST_PARSE_RS },
 230            messages: &[
 231                "split parse into its own module",
 232                "first cut of the tokenizer",
 233                "carve parse out of lib.rs",
 234            ],
 235        },
 236        Patch {
 237            op: PatchOp::Create { path: "src/error.rs", body: RUST_ERROR_RS },
 238            messages: &[
 239                "tighten error type",
 240                "introduce Error enum",
 241                "give errors a Display impl",
 242            ],
 243        },
 244        Patch {
 245            op: PatchOp::Create { path: "src/util.rs", body: RUST_UTIL_RS },
 246            messages: &[
 247                "add pad_right util",
 248                "extract padding helper",
 249                "pull util out of lib.rs",
 250            ],
 251        },
 252        Patch {
 253            op: PatchOp::Create { path: "benches/parse.rs", body: RUST_BENCH_RS },
 254            messages: &["add bench skeleton", "bench parse on a short input"],
 255        },
 256        Patch {
 257            op: PatchOp::Append { path: "src/lib.rs", line: "pub mod parse;" },
 258            messages: &["wire parse into lib.rs", "expose parse module"],
 259        },
 260        Patch {
 261            op: PatchOp::Append { path: "src/lib.rs", line: "pub mod error;" },
 262            messages: &["expose error module", "wire error into lib.rs"],
 263        },
 264        Patch {
 265            op: PatchOp::Append { path: "src/lib.rs", line: "pub mod util;" },
 266            messages: &["expose util module", "promote util to pub"],
 267        },
 268        Patch {
 269            op: PatchOp::Append { path: "Cargo.toml", line: "thiserror = \"1\"" },
 270            messages: &["switch to thiserror for Error", "add thiserror dep"],
 271        },
 272        Patch {
 273            op: PatchOp::Append { path: "Cargo.toml", line: "anyhow = \"1\"" },
 274            messages: &["pull anyhow for the examples", "add anyhow dep"],
 275        },
 276        Patch {
 277            op: PatchOp::Append {
 278                path: "README.md",
 279                line: "Tested against rust 1.75 and current stable.",
 280            },
 281            messages: &["note MSRV in the readme", "mention tested rust versions"],
 282        },
 283        Patch {
 284            op: PatchOp::Append { path: ".gitignore", line: "*.rs.bk" },
 285            messages: &["ignore rustfmt backup files"],
 286        },
 287        Patch {
 288            op: PatchOp::Append { path: ".gitignore", line: "perf.data*" },
 289            messages: &["ignore perf data files"],
 290        },
 291        Patch {
 292            op: PatchOp::Append { path: ".gitignore", line: "/criterion" },
 293            messages: &["ignore criterion output dir"],
 294        },
 295        Patch {
 296            op: PatchOp::Append { path: ".gitignore", line: ".envrc" },
 297            messages: &["ignore .envrc (direnv)"],
 298        },
 299        Patch {
 300            op: PatchOp::Append { path: "Cargo.toml", line: "serde = { version = \"1\", features = [\"derive\"] }" },
 301            messages: &["pull serde for the public types"],
 302        },
 303        Patch {
 304            op: PatchOp::Append { path: "Cargo.toml", line: "log = \"0.4\"" },
 305            messages: &["add a log facade"],
 306        },
 307        Patch {
 308            op: PatchOp::Append { path: "Cargo.toml", line: "regex = \"1\"" },
 309            messages: &["lean on regex for the trickier patterns"],
 310        },
 311        Patch {
 312            op: PatchOp::Append {
 313                path: "README.md",
 314                line: "MSRV: rust 1.75. Older toolchains may compile but aren't tested.",
 315            },
 316            messages: &["pin MSRV in the readme"],
 317        },
 318        Patch {
 319            op: PatchOp::Append {
 320                path: "README.md",
 321                line: "Issues and patches welcome. The repo lives at git.bythewood.me/{name}.",
 322            },
 323            messages: &["link the source in the readme"],
 324        },
 325        Patch {
 326            op: PatchOp::Append { path: "src/lib.rs", line: "pub use parse::parse;" },
 327            messages: &["re-export parse() at the crate root"],
 328        },
 329        Patch {
 330            op: PatchOp::Create {
 331                path: "rustfmt.toml",
 332                body: "edition = \"2021\"\nmax_width = 100\nuse_field_init_shorthand = true\n",
 333            },
 334            messages: &["pin rustfmt settings"],
 335        },
 336        Patch {
 337            op: PatchOp::Create {
 338                path: "CHANGELOG.md",
 339                body: "# Changelog\n\n## Unreleased\n\n- Initial slice.\n",
 340            },
 341            messages: &["start a changelog"],
 342        },
 343        Patch {
 344            op: PatchOp::Create {
 345                path: ".cargo/config.toml",
 346                body: "[build]\nrustflags = [\"-D\", \"warnings\"]\n",
 347            },
 348            messages: &["fail the build on warnings"],
 349        },
 350        Patch {
 351            op: PatchOp::Create {
 352                path: "examples/parse_one.rs",
 353                body: "use {snake}::parse::parse;\n\nfn main() {\n    println!(\"{:?}\", parse(\"one two three\"));\n}\n",
 354            },
 355            messages: &["add a parse_one example"],
 356        },
 357        Patch {
 358            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Tighten error type." },
 359            messages: &["changelog: error tightening"],
 360        },
 361        Patch {
 362            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Promote util to pub." },
 363            messages: &["changelog: util pub"],
 364        },
 365        Patch {
 366            op: PatchOp::Append {
 367                path: "src/lib.rs",
 368                line: "/// Re-exports the canonical parser. See [`parse`] for the full API.",
 369            },
 370            messages: &["doc-comment the parse re-export"],
 371        },
 372    ],
 373};
 374
 375// --- typescript lib ---
 376
 377const TS_PKG_JSON: &str = "{\n\
 378  \"name\": \"{name}\",\n\
 379  \"version\": \"0.1.0\",\n\
 380  \"type\": \"module\",\n\
 381  \"main\": \"dist/index.js\",\n\
 382  \"types\": \"dist/index.d.ts\",\n\
 383  \"scripts\": {\n\
 384    \"build\": \"tsc\",\n\
 385    \"test\": \"bun test\"\n\
 386  }\n\
 387}\n";
 388
 389const TS_TSCONFIG: &str = "{\n\
 390  \"compilerOptions\": {\n\
 391    \"target\": \"ES2022\",\n\
 392    \"module\": \"ESNext\",\n\
 393    \"moduleResolution\": \"bundler\",\n\
 394    \"declaration\": true,\n\
 395    \"outDir\": \"dist\",\n\
 396    \"strict\": true\n\
 397  },\n\
 398  \"include\": [\"src\"]\n\
 399}\n";
 400
 401const TS_INDEX: &str = "export interface Options {\n\
 402  width?: number;\n\
 403  prefix?: string;\n\
 404}\n\
 405\n\
 406export function pad(s: string, opts: Options = {}): string {\n\
 407  const width = opts.width ?? 8;\n\
 408  const prefix = opts.prefix ?? \"\";\n\
 409  if (s.length >= width) return prefix + s;\n\
 410  return prefix + s + \" \".repeat(width - s.length);\n\
 411}\n";
 412
 413const TS_README: &str = "# {name}\n\
 414\n\
 415Tiny TypeScript helper, zero runtime dependencies.\n\
 416\n\
 417## Install\n\
 418\n\
 419    bun add {name}\n\
 420\n\
 421## Use\n\
 422\n\
 423    import { pad } from \"{name}\";\n\
 424\n\
 425    pad(\"hi\", { width: 8 });\n";
 426
 427const TS_GITIGNORE: &str = "node_modules\ndist\nbun.lock\n";
 428
 429const TS_STRINGS_TS: &str = "export function capitalize(s: string): string {\n\
 430  if (!s) return s;\n\
 431  return s[0].toUpperCase() + s.slice(1);\n\
 432}\n\
 433\n\
 434export function kebab(s: string): string {\n\
 435  return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`).replace(/^-/, \"\");\n\
 436}\n";
 437
 438const TS_TEST: &str = "import { describe, it, expect } from \"bun:test\";\n\
 439import { pad } from \"./index\";\n\
 440\n\
 441describe(\"pad\", () => {\n\
 442  it(\"pads short strings to width\", () => {\n\
 443    expect(pad(\"hi\", { width: 5 })).toBe(\"hi   \");\n\
 444  });\n\
 445});\n";
 446
 447const TS_ARCHETYPE: Archetype = Archetype {
 448    kind: "ts-lib",
 449    description: "a tiny typescript helper",
 450    names: &["spindrift", "kelp", "sailcloth", "dunelight", "vellum", "papyrus", "marblefall"],
 451    initial: &[
 452        ("package.json", TS_PKG_JSON),
 453        ("tsconfig.json", TS_TSCONFIG),
 454        ("src/index.ts", TS_INDEX),
 455        ("README.md", TS_README),
 456        (".gitignore", TS_GITIGNORE),
 457    ],
 458    patches: &[
 459        Patch {
 460            op: PatchOp::Create { path: "src/strings.ts", body: TS_STRINGS_TS },
 461            messages: &["add capitalize + kebab", "split string helpers into a module"],
 462        },
 463        Patch {
 464            op: PatchOp::Create { path: "src/index.test.ts", body: TS_TEST },
 465            messages: &["add bun:test smoke test", "tests: pad pads short strings"],
 466        },
 467        Patch {
 468            op: PatchOp::Append {
 469                path: "src/index.ts",
 470                line: "export * from \"./strings\";",
 471            },
 472            messages: &["re-export strings from the entrypoint", "wire strings into the public API"],
 473        },
 474        Patch {
 475            op: PatchOp::Append {
 476                path: "README.md",
 477                line: "Targets ES2022. Runs on bun, node 20+, and modern browsers.",
 478            },
 479            messages: &["note runtime targets in the readme"],
 480        },
 481        Patch {
 482            op: PatchOp::Append { path: ".gitignore", line: "*.tsbuildinfo" },
 483            messages: &["ignore tsbuildinfo"],
 484        },
 485        Patch {
 486            op: PatchOp::Append { path: ".gitignore", line: ".turbo" },
 487            messages: &["ignore turbo cache"],
 488        },
 489        Patch {
 490            op: PatchOp::Append { path: ".gitignore", line: "coverage" },
 491            messages: &["ignore coverage reports"],
 492        },
 493        Patch {
 494            op: PatchOp::Append {
 495                path: "src/index.ts",
 496                line: "export const VERSION = \"0.1.0\";",
 497            },
 498            messages: &["expose VERSION constant"],
 499        },
 500        Patch {
 501            op: PatchOp::Append {
 502                path: "package.json",
 503                line: "  \"keywords\": [\"strings\", \"utility\"],",
 504            },
 505            messages: &["add keywords to package.json"],
 506        },
 507        Patch {
 508            op: PatchOp::Append {
 509                path: "README.md",
 510                line: "Zero runtime dependencies. ESM-only.",
 511            },
 512            messages: &["note ESM-only in the readme"],
 513        },
 514        Patch {
 515            op: PatchOp::Append {
 516                path: "README.md",
 517                line: "Source lives at git.bythewood.me/{name}.",
 518            },
 519            messages: &["link the source"],
 520        },
 521        Patch {
 522            op: PatchOp::Create {
 523                path: "biome.json",
 524                body: "{\n  \"$schema\": \"https://biomejs.dev/schemas/1.9.0/schema.json\",\n  \"linter\": { \"enabled\": true },\n  \"formatter\": { \"indentStyle\": \"space\", \"indentWidth\": 2 }\n}\n",
 525            },
 526            messages: &["adopt biome for lint + format"],
 527        },
 528        Patch {
 529            op: PatchOp::Create {
 530                path: "CHANGELOG.md",
 531                body: "# Changelog\n\n## Unreleased\n\n- Initial slice.\n",
 532            },
 533            messages: &["start a changelog"],
 534        },
 535        Patch {
 536            op: PatchOp::Create {
 537                path: "src/numbers.ts",
 538                body: "export function clamp(n: number, lo: number, hi: number): number {\n  return Math.min(hi, Math.max(lo, n));\n}\n\nexport function lerp(a: number, b: number, t: number): number {\n  return a + (b - a) * t;\n}\n",
 539            },
 540            messages: &["add numeric helpers"],
 541        },
 542        Patch {
 543            op: PatchOp::Append {
 544                path: "src/index.ts",
 545                line: "export * from \"./numbers\";",
 546            },
 547            messages: &["re-export numeric helpers"],
 548        },
 549        Patch {
 550            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Add strings module." },
 551            messages: &["changelog: strings"],
 552        },
 553        Patch {
 554            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Add numbers module." },
 555            messages: &["changelog: numbers"],
 556        },
 557        Patch {
 558            op: PatchOp::Create {
 559                path: ".github/dependabot.yml",
 560                body: "version: 2\nupdates:\n  - package-ecosystem: npm\n    directory: \"/\"\n    schedule:\n      interval: weekly\n",
 561            },
 562            messages: &["wire dependabot for npm"],
 563        },
 564    ],
 565};
 566
 567// --- python package ---
 568
 569const PY_PYPROJECT: &str = "[project]\n\
 570name = \"{name}\"\n\
 571version = \"0.1.0\"\n\
 572requires-python = \">=3.11\"\n\
 573description = \"\"\n\
 574readme = \"README.md\"\n\
 575dependencies = []\n\
 576\n\
 577[build-system]\n\
 578requires = [\"hatchling\"]\n\
 579build-backend = \"hatchling.build\"\n";
 580
 581const PY_INIT: &str = "from .core import run, Result\n\
 582\n\
 583__all__ = [\"run\", \"Result\"]\n\
 584__version__ = \"0.1.0\"\n";
 585
 586const PY_CORE: &str = "from dataclasses import dataclass\n\
 587\n\
 588\n\
 589@dataclass(frozen=True, slots=True)\n\
 590class Result:\n\
 591    ok: bool\n\
 592    value: str | None = None\n\
 593\n\
 594\n\
 595def run(query: str) -> Result:\n\
 596    if not query.strip():\n\
 597        return Result(ok=False)\n\
 598    return Result(ok=True, value=query.strip().lower())\n";
 599
 600const PY_README: &str = "# {name}\n\
 601\n\
 602A small Python package. Pure stdlib at the core; optional extras for the CLI.\n\
 603\n\
 604## Install\n\
 605\n\
 606    uv pip install {name}\n\
 607\n\
 608## Use\n\
 609\n\
 610    from {snake} import run\n\
 611\n\
 612    print(run(\"  hello  \").value)  # 'hello'\n";
 613
 614const PY_GITIGNORE: &str = "__pycache__\n.venv\ndist\n*.egg-info\n";
 615
 616const PY_TEST: &str = "from {snake} import run\n\
 617\n\
 618\n\
 619def test_run_strips_and_lowercases():\n\
 620    assert run(\"  Hello  \").value == \"hello\"\n\
 621\n\
 622\n\
 623def test_run_rejects_blank():\n\
 624    assert run(\"   \").ok is False\n";
 625
 626const PY_CLI: &str = "\"\"\"Tiny CLI for {snake}.\"\"\"\n\
 627import sys\n\
 628\n\
 629from .core import run\n\
 630\n\
 631\n\
 632def main() -> int:\n\
 633    if len(sys.argv) < 2:\n\
 634        print(\"usage: {name} <query>\", file=sys.stderr)\n\
 635        return 2\n\
 636    r = run(sys.argv[1])\n\
 637    if not r.ok:\n\
 638        print(\"no result\", file=sys.stderr)\n\
 639        return 1\n\
 640    print(r.value)\n\
 641    return 0\n";
 642
 643const PY_ARCHETYPE: Archetype = Archetype {
 644    kind: "python-pkg",
 645    description: "a small python package",
 646    names: &["cinnabar", "vermilion", "ochre", "indigo", "malachite", "citrine"],
 647    initial: &[
 648        ("pyproject.toml", PY_PYPROJECT),
 649        ("src/{snake}/__init__.py", PY_INIT),
 650        ("src/{snake}/core.py", PY_CORE),
 651        ("README.md", PY_README),
 652        (".gitignore", PY_GITIGNORE),
 653    ],
 654    patches: &[
 655        Patch {
 656            op: PatchOp::Create { path: "src/{snake}/cli.py", body: PY_CLI },
 657            messages: &["add cli entry point", "scaffold {snake} cli", "wire main() for cli"],
 658        },
 659        Patch {
 660            op: PatchOp::Create { path: "tests/test_core.py", body: PY_TEST },
 661            messages: &["tests: smoke for run()", "add core tests"],
 662        },
 663        Patch {
 664            op: PatchOp::Append { path: "pyproject.toml", line: "[project.scripts]" },
 665            messages: &["reserve entry-points table"],
 666        },
 667        Patch {
 668            op: PatchOp::Append {
 669                path: "README.md",
 670                line: "Type-annotated, mypy-clean on strict.",
 671            },
 672            messages: &["note mypy strict in the readme"],
 673        },
 674        Patch {
 675            op: PatchOp::Append { path: ".gitignore", line: ".mypy_cache" },
 676            messages: &["ignore mypy cache"],
 677        },
 678        Patch {
 679            op: PatchOp::Append { path: ".gitignore", line: ".ruff_cache" },
 680            messages: &["ignore ruff cache"],
 681        },
 682        Patch {
 683            op: PatchOp::Append { path: ".gitignore", line: ".pytest_cache" },
 684            messages: &["ignore pytest cache"],
 685        },
 686        Patch {
 687            op: PatchOp::Append { path: ".gitignore", line: ".coverage" },
 688            messages: &["ignore coverage data"],
 689        },
 690        Patch {
 691            op: PatchOp::Append { path: ".gitignore", line: "*.egg-info" },
 692            messages: &["ignore egg-info"],
 693        },
 694        Patch {
 695            op: PatchOp::Append {
 696                path: "README.md",
 697                line: "Tested on CPython 3.11 and 3.12.",
 698            },
 699            messages: &["note tested CPython versions"],
 700        },
 701        Patch {
 702            op: PatchOp::Append {
 703                path: "README.md",
 704                line: "Source lives at git.bythewood.me/{name}.",
 705            },
 706            messages: &["link the source"],
 707        },
 708        Patch {
 709            op: PatchOp::Create {
 710                path: "src/{snake}/parse.py",
 711                body: "from __future__ import annotations\n\n\ndef tokenize(s: str) -> list[str]:\n    return [tok for tok in s.split() if tok]\n",
 712            },
 713            messages: &["pull tokenize into its own module"],
 714        },
 715        Patch {
 716            op: PatchOp::Create {
 717                path: "src/{snake}/__main__.py",
 718                body: "from .cli import main\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n",
 719            },
 720            messages: &["allow `python -m {snake}`"],
 721        },
 722        Patch {
 723            op: PatchOp::Create {
 724                path: "tests/conftest.py",
 725                body: "import pytest\n\n\n@pytest.fixture\ndef sample():\n    return \"  Hello  \"\n",
 726            },
 727            messages: &["add conftest fixture"],
 728        },
 729        Patch {
 730            op: PatchOp::Create {
 731                path: "CHANGELOG.md",
 732                body: "# Changelog\n\n## Unreleased\n\n- Initial slice.\n",
 733            },
 734            messages: &["start a changelog"],
 735        },
 736        Patch {
 737            op: PatchOp::Create {
 738                path: ".python-version",
 739                body: "3.12\n",
 740            },
 741            messages: &["pin python to 3.12"],
 742        },
 743        Patch {
 744            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Add cli entry point." },
 745            messages: &["changelog: cli entry point"],
 746        },
 747        Patch {
 748            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Tighten Result." },
 749            messages: &["changelog: Result tightening"],
 750        },
 751        Patch {
 752            op: PatchOp::Append {
 753                path: "pyproject.toml",
 754                line: "{snake} = \"{snake}.cli:main\"",
 755            },
 756            messages: &["wire console_scripts entry"],
 757        },
 758    ],
 759};
 760
 761// --- markdown blog ---
 762
 763const BLOG_README: &str = "# {name}\n\
 764\n\
 765Personal notes. Mostly things I want to come back to.\n\
 766\n\
 767Posts live in `posts/` as plain markdown, filenames prefixed with the date.\n";
 768
 769const BLOG_ABOUT: &str = "# About\n\
 770\n\
 771This is a small markdown notebook. The build script is whatever static site\n\
 772generator I'm using this week; the source is just files.\n";
 773
 774const BLOG_POST_1: &str = "# A note on patience\n\
 775\n\
 776The fastest path is rarely the straightest, and the straightest path rarely\n\
 777the most interesting. Some of the better turns I've taken came from sitting\n\
 778with a problem long enough to find a third option.\n";
 779
 780const BLOG_POST_2: &str = "# On reading old code\n\
 781\n\
 782Code that has survived a few years tends to teach you something. Not the\n\
 783\"this is how to write code\" kind of teaching, more like the geology of a\n\
 784hillside: you can see where things shifted and roughly when.\n";
 785
 786const BLOG_POST_3: &str = "# Small tools, sharp edges\n\
 787\n\
 788The smaller the tool, the more it pays to keep the edge keen. A 200-line\n\
 789script with crisp behavior outlasts a 2000-line system that almost works.\n";
 790
 791const BLOG_POST_4: &str = "# Notes from the porch\n\
 792\n\
 793Rain since morning. The trees off the porch are picking up a slow, weighted\n\
 794sound that fits the kind of work I want to do today: quiet, no rush.\n";
 795
 796const BLOG_GITIGNORE: &str = ".cache\nbuild\n";
 797
 798const BLOG_ARCHETYPE: Archetype = Archetype {
 799    kind: "blog",
 800    description: "personal markdown notebook",
 801    names: &["backwood-notes", "longshore", "weathersong", "dim-burrows", "ash-and-rime"],
 802    initial: &[
 803        ("README.md", BLOG_README),
 804        ("about.md", BLOG_ABOUT),
 805        (".gitignore", BLOG_GITIGNORE),
 806        ("posts/2025-04-12-patience.md", BLOG_POST_1),
 807    ],
 808    patches: &[
 809        Patch {
 810            op: PatchOp::Create {
 811                path: "posts/2025-04-19-old-code.md",
 812                body: BLOG_POST_2,
 813            },
 814            messages: &["post: on reading old code"],
 815        },
 816        Patch {
 817            op: PatchOp::Create {
 818                path: "posts/2025-04-26-small-tools.md",
 819                body: BLOG_POST_3,
 820            },
 821            messages: &["post: small tools, sharp edges"],
 822        },
 823        Patch {
 824            op: PatchOp::Create {
 825                path: "posts/2025-05-03-porch-notes.md",
 826                body: BLOG_POST_4,
 827            },
 828            messages: &["post: notes from the porch"],
 829        },
 830        Patch {
 831            op: PatchOp::Append {
 832                path: "README.md",
 833                line: "Built whenever I have time; please don't link-aggregate.",
 834            },
 835            messages: &["readme: ask not to be link-aggregated"],
 836        },
 837        Patch {
 838            op: PatchOp::Append {
 839                path: "about.md",
 840                line: "Contact through the address on isaacbythewood.com.",
 841            },
 842            messages: &["about: add contact line"],
 843        },
 844        Patch {
 845            op: PatchOp::Append { path: ".gitignore", line: ".DS_Store" },
 846            messages: &["stop checking in .DS_Store"],
 847        },
 848        Patch {
 849            op: PatchOp::Append { path: ".gitignore", line: "drafts/" },
 850            messages: &["ignore the drafts dir"],
 851        },
 852        Patch {
 853            op: PatchOp::Create {
 854                path: "posts/2025-05-10-quiet-tools.md",
 855                body: "# Quiet tools\n\nThe tools I keep coming back to all share one trait: they don't try\nto be the center of attention. They wait for instructions, do exactly\nwhat I asked, and get out of the way.\n",
 856            },
 857            messages: &["post: quiet tools"],
 858        },
 859        Patch {
 860            op: PatchOp::Create {
 861                path: "posts/2025-05-17-walking-distance.md",
 862                body: "# Walking distance\n\nThe radius of my day shrinks when I'm tired. A house, a coffee shop,\na library: the world cooperates with that, if you let it.\n",
 863            },
 864            messages: &["post: walking distance"],
 865        },
 866        Patch {
 867            op: PatchOp::Create {
 868                path: "posts/2025-05-24-stone-light.md",
 869                body: "# Stone light\n\nLate afternoon light hitting the south wall reads as warm but isn't.\nThe stones have been losing heat since two o'clock; what I'm seeing is\nthe last of it, the way bread smells most strongly when it's already cool.\n",
 870            },
 871            messages: &["post: stone light"],
 872        },
 873        Patch {
 874            op: PatchOp::Create {
 875                path: "feed.xml",
 876                body: "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n  <title>{name}</title>\n</feed>\n",
 877            },
 878            messages: &["scaffold atom feed"],
 879        },
 880        Patch {
 881            op: PatchOp::Create {
 882                path: "build.sh",
 883                body: "#!/bin/sh\nset -eu\n# Tiny static-site build. Walks posts/, wraps each in the template.\nfor src in posts/*.md; do\n    echo \"  $(basename \"$src\")\"\ndone\n",
 884            },
 885            messages: &["add build.sh"],
 886        },
 887        Patch {
 888            op: PatchOp::Create {
 889                path: "template.html",
 890                body: "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><title>{{ title }}</title></head>\n<body>{{ content }}</body>\n</html>\n",
 891            },
 892            messages: &["add minimal page template"],
 893        },
 894        Patch {
 895            op: PatchOp::Append {
 896                path: "README.md",
 897                line: "Built with a 40-line shell script. The output is plain HTML.",
 898            },
 899            messages: &["readme: note the tiny build"],
 900        },
 901        Patch {
 902            op: PatchOp::Append { path: ".gitignore", line: "_site/" },
 903            messages: &["ignore the _site output dir"],
 904        },
 905        Patch {
 906            op: PatchOp::Append { path: ".gitignore", line: "node_modules/" },
 907            messages: &["ignore node_modules (whenever I dabble)"],
 908        },
 909    ],
 910};
 911
 912// --- dotfiles ---
 913
 914const DOT_README: &str = "# {name}\n\
 915\n\
 916My dotfiles. Bootstrapped via `bin/install`, which symlinks `config/` into\n\
 917`$HOME`. Tested on macOS and recent Debian.\n";
 918
 919const DOT_INSTALL: &str = "#!/bin/sh\n\
 920set -eu\n\
 921\n\
 922HERE=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\n\
 923for src in \"$HERE\"/config/.*; do\n\
 924    name=$(basename \"$src\")\n\
 925    case \"$name\" in . | .. ) continue ;; esac\n\
 926    ln -snf \"$src\" \"$HOME/$name\"\n\
 927done\n\
 928echo done.\n";
 929
 930const DOT_ZSHRC: &str = "# zsh: small + fast. no oh-my-zsh.\n\
 931\n\
 932export EDITOR=nvim\n\
 933export PAGER=less\n\
 934\n\
 935HISTFILE=~/.zsh_history\n\
 936HISTSIZE=10000\n\
 937SAVEHIST=10000\n\
 938\n\
 939setopt SHARE_HISTORY HIST_IGNORE_DUPS INC_APPEND_HISTORY\n\
 940\n\
 941alias ll='ls -lah'\n\
 942alias gs='git status'\n\
 943alias gd='git diff'\n";
 944
 945const DOT_TMUX: &str = "# tmux: prefix on ctrl-a, vim keys, no mouse\n\
 946\n\
 947unbind C-b\n\
 948set -g prefix C-a\n\
 949bind C-a send-prefix\n\
 950\n\
 951set -g default-terminal \"tmux-256color\"\n\
 952set -g escape-time 10\n\
 953\n\
 954bind h select-pane -L\n\
 955bind j select-pane -D\n\
 956bind k select-pane -U\n\
 957bind l select-pane -R\n";
 958
 959const DOT_NVIM: &str = "-- neovim: lean. lazy.nvim handles plugins.\n\
 960\n\
 961vim.g.mapleader = ' '\n\
 962vim.opt.number = true\n\
 963vim.opt.relativenumber = true\n\
 964vim.opt.expandtab = true\n\
 965vim.opt.shiftwidth = 4\n\
 966vim.opt.tabstop = 4\n\
 967vim.opt.smartcase = true\n\
 968\n\
 969vim.keymap.set('n', '<leader>w', ':write<CR>')\n";
 970
 971const DOT_GITCONFIG: &str = "[user]\n\
 972    name = Isaac Bythewood\n\
 973    email = [email protected]\n\
 974[init]\n\
 975    defaultBranch = master\n\
 976[push]\n\
 977    autoSetupRemote = true\n\
 978[pull]\n\
 979    ff = only\n";
 980
 981const DOT_ARCHETYPE: Archetype = Archetype {
 982    kind: "dotfiles",
 983    description: "personal dotfiles",
 984    names: &["paperhouse", "swale", "hearthstone", "hush-hollow", "gypsum"],
 985    initial: &[
 986        ("README.md", DOT_README),
 987        ("bin/install", DOT_INSTALL),
 988        ("config/.zshrc", DOT_ZSHRC),
 989    ],
 990    patches: &[
 991        Patch {
 992            op: PatchOp::Create { path: "config/.tmux.conf", body: DOT_TMUX },
 993            messages: &[
 994                "tmux: rebind prefix to ctrl-a",
 995                "tmux: vim keys for pane nav",
 996                "add tmux config",
 997            ],
 998        },
 999        Patch {
1000            op: PatchOp::Create { path: "config/nvim/init.lua", body: DOT_NVIM },
1001            messages: &[
1002                "neovim: switch to lazy.nvim",
1003                "neovim: relative line numbers",
1004                "first cut of init.lua",
1005            ],
1006        },
1007        Patch {
1008            op: PatchOp::Create { path: "config/.gitconfig", body: DOT_GITCONFIG },
1009            messages: &["git: default branch master, push autoSetup", "add gitconfig"],
1010        },
1011        Patch {
1012            op: PatchOp::Append { path: "config/.zshrc", line: "alias gp='git push'" },
1013            messages: &["zsh: alias gp"],
1014        },
1015        Patch {
1016            op: PatchOp::Append {
1017                path: "config/.zshrc",
1018                line: "alias gpl='git pull --ff-only'",
1019            },
1020            messages: &["zsh: alias gpl with ff-only"],
1021        },
1022        Patch {
1023            op: PatchOp::Append {
1024                path: "config/.zshrc",
1025                line: "alias t='tmux attach || tmux'",
1026            },
1027            messages: &["zsh: alias t for tmux attach"],
1028        },
1029        Patch {
1030            op: PatchOp::Append {
1031                path: "config/.tmux.conf",
1032                line: "set -g status-style fg=white,bg=default",
1033            },
1034            messages: &["tmux: lean status line"],
1035        },
1036        Patch {
1037            op: PatchOp::Append {
1038                path: "README.md",
1039                line: "`bin/install` is idempotent: it just refreshes the symlinks.",
1040            },
1041            messages: &["readme: note install is idempotent"],
1042        },
1043        Patch {
1044            op: PatchOp::Append { path: "config/.zshrc", line: "alias k='kubectl'" },
1045            messages: &["zsh: alias k for kubectl"],
1046        },
1047        Patch {
1048            op: PatchOp::Append { path: "config/.zshrc", line: "alias d='docker'" },
1049            messages: &["zsh: alias d for docker"],
1050        },
1051        Patch {
1052            op: PatchOp::Append {
1053                path: "config/.zshrc",
1054                line: "export FZF_DEFAULT_COMMAND='fd --hidden --follow'",
1055            },
1056            messages: &["zsh: faster fzf default command"],
1057        },
1058        Patch {
1059            op: PatchOp::Append {
1060                path: "config/.tmux.conf",
1061                line: "set -g history-limit 50000",
1062            },
1063            messages: &["tmux: bigger scrollback"],
1064        },
1065        Patch {
1066            op: PatchOp::Append {
1067                path: "config/.tmux.conf",
1068                line: "set -g renumber-windows on",
1069            },
1070            messages: &["tmux: renumber windows on close"],
1071        },
1072        Patch {
1073            op: PatchOp::Append {
1074                path: "config/.gitconfig",
1075                line: "[core]\n    excludesfile = ~/.gitignore_global",
1076            },
1077            messages: &["git: global excludes file"],
1078        },
1079        Patch {
1080            op: PatchOp::Append {
1081                path: "config/.gitconfig",
1082                line: "[alias]\n    co = checkout\n    br = branch\n    st = status -sb",
1083            },
1084            messages: &["git: short aliases"],
1085        },
1086        Patch {
1087            op: PatchOp::Create {
1088                path: "config/.gitignore_global",
1089                body: ".DS_Store\nThumbs.db\n*.swp\n*.swo\n*~\n.idea/\n.vscode/\n",
1090            },
1091            messages: &["add global gitignore"],
1092        },
1093        Patch {
1094            op: PatchOp::Create {
1095                path: "bin/uninstall",
1096                body: "#!/bin/sh\nset -eu\n# Remove symlinks created by bin/install. Idempotent.\nHERE=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nfor src in \"$HERE\"/config/.*; do\n    name=$(basename \"$src\")\n    case \"$name\" in . | .. ) continue ;; esac\n    link=\"$HOME/$name\"\n    [ -L \"$link\" ] && rm \"$link\"\ndone\n",
1097            },
1098            messages: &["add uninstall script"],
1099        },
1100        Patch {
1101            op: PatchOp::Create {
1102                path: "config/.editorconfig",
1103                body: "root = true\n\n[*]\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\nindent_style = space\nindent_size = 4\n",
1104            },
1105            messages: &["add editorconfig"],
1106        },
1107        Patch {
1108            op: PatchOp::Create {
1109                path: "config/starship.toml",
1110                body: "format = \"$directory$git_branch$git_status$character\"\nadd_newline = false\n\n[character]\nsuccess_symbol = \"[\\u003e](bold green)\"\nerror_symbol = \"[\\u003e](bold red)\"\n",
1111            },
1112            messages: &["adopt starship prompt"],
1113        },
1114        Patch {
1115            op: PatchOp::Append {
1116                path: "config/.zshrc",
1117                line: "eval \"$(starship init zsh)\"",
1118            },
1119            messages: &["zsh: enable starship"],
1120        },
1121    ],
1122};
1123
1124const ARCHETYPES: &[&Archetype] = &[
1125    &RUST_ARCHETYPE,
1126    &TS_ARCHETYPE,
1127    &PY_ARCHETYPE,
1128    &BLOG_ARCHETYPE,
1129    &DOT_ARCHETYPE,
1130];
1131
1132// ---------- main ------------------------------------------------------------
1133
1134struct Opts {
1135    count: usize,
1136    days: i64,
1137    seed: u64,
1138    reset: bool,
1139}
1140
1141fn parse_args() -> Result<Opts, String> {
1142    let mut opts = Opts {
1143        count: DEFAULT_COUNT,
1144        days: DEFAULT_DAYS,
1145        seed: DEFAULT_SEED,
1146        reset: false,
1147    };
1148    let args: Vec<String> = std::env::args().skip(1).collect();
1149    let mut i = 0;
1150    while i < args.len() {
1151        match args[i].as_str() {
1152            "--count" => {
1153                i += 1;
1154                opts.count = args
1155                    .get(i)
1156                    .ok_or("--count needs a value")?
1157                    .parse()
1158                    .map_err(|e: std::num::ParseIntError| e.to_string())?;
1159            }
1160            "--days" => {
1161                i += 1;
1162                opts.days = args
1163                    .get(i)
1164                    .ok_or("--days needs a value")?
1165                    .parse()
1166                    .map_err(|e: std::num::ParseIntError| e.to_string())?;
1167            }
1168            "--seed" => {
1169                i += 1;
1170                opts.seed = args
1171                    .get(i)
1172                    .ok_or("--seed needs a value")?
1173                    .parse()
1174                    .map_err(|e: std::num::ParseIntError| e.to_string())?;
1175            }
1176            "--reset" => opts.reset = true,
1177            "-h" | "--help" => {
1178                print_usage();
1179                std::process::exit(0);
1180            }
1181            other => return Err(format!("unknown arg: {other}")),
1182        }
1183        i += 1;
1184    }
1185    Ok(opts)
1186}
1187
1188fn print_usage() {
1189    eprintln!(
1190        "seed: generate fake bare git repos under {DEST}/\n\
1191         \n\
1192         Usage:\n  \
1193           cargo run --bin seed                       defaults: 8 repos, 30 days, seed 0xC0DEFEED\n  \
1194           cargo run --bin seed -- --count N          number of repos to generate\n  \
1195           cargo run --bin seed -- --days D           days of history per repo\n  \
1196           cargo run --bin seed -- --seed N           PRNG seed (reproducible)\n  \
1197           cargo run --bin seed -- --reset            wipe {DEST}/ first\n"
1198    );
1199}
1200
1201fn main() {
1202    if let Err(e) = run() {
1203        eprintln!("seed: {e}");
1204        std::process::exit(1);
1205    }
1206}
1207
1208fn run() -> Result<(), String> {
1209    let opts = parse_args()?;
1210
1211    let dest = PathBuf::from(DEST);
1212    if opts.reset && dest.exists() {
1213        fs::remove_dir_all(&dest).map_err(|e| format!("reset {DEST}: {e}"))?;
1214    }
1215    fs::create_dir_all(&dest).map_err(|e| format!("mkdir {DEST}: {e}"))?;
1216
1217    let mut rng = Rng::new(opts.seed);
1218    let chosen = pick_repos(&mut rng, opts.count);
1219
1220    let now = unix_now();
1221    let oldest = now - opts.days * 86_400;
1222
1223    for (arch, name) in &chosen {
1224        let target = dest.join(format!("{name}.git"));
1225        if target.exists() {
1226            println!("  skip   {name} (already in {DEST}/)");
1227            continue;
1228        }
1229        println!("  seed   {name} ({})", arch.kind);
1230        seed_one(&target, arch, name, oldest, opts.days, &mut rng)
1231            .map_err(|e| format!("seed {name}: {e}"))?;
1232    }
1233    Ok(())
1234}
1235
1236/// Pick `count` (archetype, name) pairs, no duplicate names. First pass
1237/// guarantees one repo per archetype (so the landing page always shows the
1238/// full variety); any remaining slots are filled by random archetype + name.
1239fn pick_repos(rng: &mut Rng, count: usize) -> Vec<(&'static Archetype, &'static str)> {
1240    let mut chosen = Vec::with_capacity(count);
1241    let mut used: HashSet<&'static str> = HashSet::new();
1242    let total_names: usize = ARCHETYPES.iter().map(|a| a.names.len()).sum();
1243    let cap = count.min(total_names);
1244
1245    // First pass: one per archetype.
1246    for arch in ARCHETYPES {
1247        if chosen.len() >= cap {
1248            break;
1249        }
1250        let name = *rng.pick(arch.names);
1251        if used.insert(name) {
1252            chosen.push((*arch, name));
1253        }
1254    }
1255
1256    // Fill remaining slots with uniformly random archetype + name.
1257    let mut guard = 0usize;
1258    while chosen.len() < cap && guard < cap * 50 {
1259        let arch = *rng.pick(ARCHETYPES);
1260        let name = *rng.pick(arch.names);
1261        if used.insert(name) {
1262            chosen.push((arch, name));
1263        }
1264        guard += 1;
1265    }
1266    chosen
1267}
1268
1269// ---------- per-repo synthesis ---------------------------------------------
1270
1271fn seed_one(
1272    target: &Path,
1273    arch: &Archetype,
1274    name: &str,
1275    oldest: i64,
1276    days: i64,
1277    rng: &mut Rng,
1278) -> Result<(), String> {
1279    // Build the history in a temp working dir, then bare-clone into the
1280    // fixtures dir. Doing the bare clone at the end lets us use the regular
1281    // working-tree commit flow (which is much simpler than driving
1282    // commit-tree directly).
1283    let work = std::env::temp_dir().join(format!("repos-seed-{name}"));
1284    if work.exists() {
1285        fs::remove_dir_all(&work).map_err(|e| e.to_string())?;
1286    }
1287    fs::create_dir_all(&work).map_err(|e| e.to_string())?;
1288
1289    git(&work, &["init", "-q", "-b", "master"])?;
1290
1291    // Initial files + commit. Always Isaac on the first commit; reads as the
1292    // "this is the operator's repo" handshake.
1293    for (path, body) in arch.initial {
1294        let real_path = expand(path, name);
1295        let real_body = expand(body, name);
1296        write_under(&work, &real_path, &real_body).map_err(|e| e.to_string())?;
1297    }
1298    git(&work, &["add", "."])?;
1299    let t = oldest + rng.range(86_400) as i64;
1300    commit(&work, "initial commit", t, &AUTHORS[0])?;
1301
1302    // Subsequent commits, spread across the remaining days.
1303    for day in 1..days {
1304        // Most days have 0 to 2 commits; ~30% are busy with up to 5.
1305        let n = if rng.chance(3) {
1306            1 + rng.range(5)
1307        } else if rng.chance(6) {
1308            1 + rng.range(2)
1309        } else {
1310            0
1311        };
1312        for _ in 0..n {
1313            // Pick a patch, apply it, check it actually produced a diff. If
1314            // the patch was a no-op (file already exists with same content,
1315            // or trailing line is already present), try a different one. Up
1316            // to a handful of attempts; give up silently if every patch in
1317            // the pool has already been applied to this repo.
1318            let mut produced_change = false;
1319            for _ in 0..6 {
1320                let patch = rng.pick(arch.patches);
1321                if !apply_patch(&work, &patch.op, name).map_err(|e| e.to_string())? {
1322                    continue;
1323                }
1324                git(&work, &["add", "."])?;
1325                if !staged_is_empty(&work)? {
1326                    // Pick a message from this patch's own pool.
1327                    let tmpl: &&str = rng.pick(patch.messages);
1328                    let msg = expand(tmpl, name);
1329                    let ts = oldest + day * 86_400 + rng.range(86_400) as i64;
1330                    commit(&work, &msg, ts, pick_author(rng))?;
1331                    produced_change = true;
1332                    break;
1333                }
1334            }
1335            // If every retry was a no-op, drop the commit slot rather than
1336            // emit an empty commit.
1337            let _ = produced_change;
1338        }
1339    }
1340
1341    // Bare clone into the destination.
1342    let status = Command::new("git")
1343        .args(["clone", "--bare", "--quiet"])
1344        .arg(&work)
1345        .arg(target)
1346        .status()
1347        .map_err(|e| format!("clone: {e}"))?;
1348    if !status.success() {
1349        return Err(format!("clone exited {:?}", status.code()));
1350    }
1351
1352    // Per-repo description (repos reads `description` for the landing
1353    // page). git's stock placeholder is filtered out in src/git.rs.
1354    fs::write(target.join("description"), format!("{}\n", arch.description))
1355        .map_err(|e| e.to_string())?;
1356
1357    let _ = fs::remove_dir_all(&work);
1358    Ok(())
1359}
1360
1361/// Apply a patch to the working tree. Returns `Ok(true)` if the disk actually
1362/// changed, `Ok(false)` if the patch was a no-op for this repo (file already
1363/// created, line already present). The caller skips committing on `false`.
1364fn apply_patch(work: &Path, op: &PatchOp, name: &str) -> std::io::Result<bool> {
1365    match op {
1366        PatchOp::Create { path, body } => {
1367            let real_path = expand(path, name);
1368            let full = work.join(&real_path);
1369            if full.exists() {
1370                return Ok(false);
1371            }
1372            let real_body = expand(body, name);
1373            write_under(work, &real_path, &real_body)?;
1374            Ok(true)
1375        }
1376        PatchOp::Append { path, line } => {
1377            let real_path = expand(path, name);
1378            let real_line = expand(line, name);
1379            let full = work.join(&real_path);
1380            if let Some(p) = full.parent() {
1381                fs::create_dir_all(p)?;
1382            }
1383            // If the line is already present (anywhere in the file), treat
1384            // this patch as exhausted for this repo. Avoids the README
1385            // sprouting duplicate sentences when the same Append fires twice.
1386            if let Ok(existing) = fs::read_to_string(&full) {
1387                if existing.lines().any(|l| l == real_line) {
1388                    return Ok(false);
1389                }
1390            }
1391            let mut f = fs::OpenOptions::new()
1392                .create(true)
1393                .append(true)
1394                .open(&full)?;
1395            f.write_all(real_line.as_bytes())?;
1396            f.write_all(b"\n")?;
1397            Ok(true)
1398        }
1399    }
1400}
1401
1402/// True iff `git add .` produced no staged changes.
1403fn staged_is_empty(work: &Path) -> Result<bool, String> {
1404    let out = Command::new("git")
1405        .current_dir(work)
1406        .args(["diff", "--cached", "--quiet"])
1407        .status()
1408        .map_err(|e| format!("git diff --cached: {e}"))?;
1409    // `git diff --quiet` exits 0 if no diff, 1 if there is one.
1410    Ok(out.success())
1411}
1412
1413fn write_under(work: &Path, rel: &str, body: &str) -> std::io::Result<()> {
1414    let full = work.join(rel);
1415    if let Some(p) = full.parent() {
1416        fs::create_dir_all(p)?;
1417    }
1418    fs::write(full, body)
1419}
1420
1421/// Expand templating placeholders. Two substitutions:
1422///   `{name}`:  the repo's slug as-is (e.g. `copper-net`).
1423///   `{snake}`: the same with hyphens turned into underscores. Used inside
1424///               Rust / Python code blocks where hyphens are illegal as
1425///               identifiers (`copper-net::version` is wrong;
1426///               `copper_net::version` is right).
1427/// Anything else with braces is left untouched, so format-style examples like
1428/// `println!("{}", x)` in file bodies pass through cleanly.
1429fn expand(s: &str, name: &str) -> String {
1430    s.replace("{name}", name)
1431        .replace("{snake}", &name.replace('-', "_"))
1432}
1433
1434// ---------- git plumbing ----------------------------------------------------
1435
1436fn git(cwd: &Path, args: &[&str]) -> Result<(), String> {
1437    let s = Command::new("git")
1438        .current_dir(cwd)
1439        .args(args)
1440        .status()
1441        .map_err(|e| format!("git {args:?}: {e}"))?;
1442    if !s.success() {
1443        return Err(format!("git {args:?} exited {:?}", s.code()));
1444    }
1445    Ok(())
1446}
1447
1448fn commit(cwd: &Path, message: &str, ts: i64, author: &Author) -> Result<(), String> {
1449    let date = format!("{ts} +0000");
1450    let s = Command::new("git")
1451        .current_dir(cwd)
1452        // Always go through env-vars so author + committer + their dates all
1453        // agree. `git commit --date=` only sets author date; without the
1454        // committer envs the commit hash would drift between runs.
1455        .env("GIT_AUTHOR_NAME", author.name)
1456        .env("GIT_AUTHOR_EMAIL", author.email)
1457        .env("GIT_AUTHOR_DATE", &date)
1458        .env("GIT_COMMITTER_NAME", author.name)
1459        .env("GIT_COMMITTER_EMAIL", author.email)
1460        .env("GIT_COMMITTER_DATE", &date)
1461        .args(["commit", "-q", "-m", message])
1462        .status()
1463        .map_err(|e| format!("git commit: {e}"))?;
1464    if !s.success() {
1465        return Err(format!("git commit exited {:?}", s.code()));
1466    }
1467    Ok(())
1468}
1469
1470fn unix_now() -> i64 {
1471    std::time::SystemTime::now()
1472        .duration_since(std::time::UNIX_EPOCH)
1473        .map(|d| d.as_secs() as i64)
1474        .unwrap_or(0)
1475}