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
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}