heartwood every commit a ring

first ring

5accfa68 by Isaac Bythewood · 3 hours ago

added .dockerignore
@@ -0,0 +1,9 @@targetdistfrontend/node_modulesfixtures.env.git.gitignore*.mdsamplefiles
added .gitignore
@@ -0,0 +1,7 @@/target/dist/frontend/node_modules/fixtures/git.env.DS_Store*.swp
added CLAUDE.md
@@ -0,0 +1,194 @@# CLAUDE.mdThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.## What This IsA minimal web frontend for the bare git repos at `/srv/git/`. Single-operator,public read-only. Each repo gets a landing page (README + recent commits +clone URL), a tree/blob browser with syntax highlighting, a commit log, aunified-diff view per commit, and an Atom feed. Clone over HTTPS works via`git http-backend`. No pull requests, no issues, no auth, no database.Built to replace Github as the place this user's code is publicly visible.## Commands- **Dev server:** `make run` (Vite watch + cargo run concurrently on port 8000)- **Production build:** `make build` (Vite assets + release binary)- **Run release binary:** `make start`- **Seed local fixtures:** `make seed` (runs `cargo run --bin seed`, which  synthesizes fake-but-realistic bare repos under `fixtures/git/` from five  archetypes: Rust crate, TS lib, Python package, markdown blog, dotfiles,  with ~30 days of history and five rotating authors). Opt-in: `make run`  does not depend on it, so a fresh checkout shows an empty repo list until  you run `make seed` once. Idempotent: existing repo dirs are left alone.  `make seed-reset` wipes and regenerates. Override with  `COUNT=12 DAYS=45 SEED=42`.- **Docker build:** `sudo docker build .`There are no tests or linters configured.## Architecture**Backend:** Single-binary axum app (`src/main.rs`). No DB, no auth. Repometadata is read live from the bare repos via `gix` (gitoxide); thecommit-diff view shells out to `git show --patch` and parses the unified-diff output. Templates are minijinja, rendered server-side. State is justthe template environment and a `Config` struct.**git layer (`src/git.rs`):** Wrappers over `gix` that pre-shape data fortemplates. Functions: `discover` (walk repo root, return `RepoSummary`ssorted by most-recent HEAD), `open` / `resolve_path` (with traversalchecks), `repo_summary`, `commit_info`, `recent_commits`, `list_tree`,`read_blob`, `read_readme`, `resolve_rev`, and `diff_commit` (the onefunction that shells out to git; everything else is gix in-process). The`diff_commit` invocation passes `-c core.quotePath=false` so non-ASCIIpaths in the `diff --git a/... b/...` header parse correctly.**Markdown (`src/markdown.rs`):** pulldown-cmark with tables, footnotes,strikethrough, task-lists, and smart-punctuation. Used to render READMEson the repo landing.**Syntax highlighting (`src/highlight.rs`):** syntect with the default-fancyfeature set, themed with `base16-eighties.dark`. SyntaxSet and Theme areloaded once via `OnceLock` so each file view doesn't re-parse them.**Smart-HTTP clone (`src/http_backend.rs` + `src/routes/clone.rs`):** The`/info/refs` and `/git-upload-pack` routes spawn `git http-backend` as aCGI subprocess. CGI env vars are set from the axum request (PATH_INFO,QUERY_STRING, REQUEST_METHOD, CONTENT_TYPE, GIT_PROJECT_ROOT,GIT_HTTP_EXPORT_ALL, plus a few HTTP_* passthrough headers). Request bodyis piped into stdin; CGI headers are parsed off stdout, the rest isstreamed back as the response body. `/git-receive-pack` is wired butreturns 405 (heartwood is read-only).**Templates (`templates/`):** Jinja2-compatible. `base.html` is the shell;`index.html` is the repo list; `repo.html` is README + recent commits +clone URL + nav; `tree.html` / `blob.html` are the file browser;`log.html` / `commit.html` are commit views; `atom.xml` is the per-repofeed; `not_found.html` is the themed 404 shell.**Jinja2-faithful HTML formatter:** Like every other Rust project in thisworkspace, `src/templates.rs` installs a custom minijinja formatter so `/`is not escaped to `&#x2f;` (Vite asset URLs come through clean).**Frontend pipeline:** Vite from `frontend/`. Single entry(`frontend/static_src/index.js`) imports SCSS + JetBrains Mono via@fontsource. Output filenames are content-hashed and served at `/static/`;templates resolve them via `vite_asset` reading `dist/.vite/manifest.json`.The favicon lives in `frontend/static_src/public/favicon.svg`, which Vitecopies into `dist/` at build time; the runtime `/favicon.ico` and`/favicon.svg` handlers in `src/routes/seo.rs` read from `dist/favicon.svg`(not the `frontend/` source tree, which isn't shipped in the image).**Request logging:** `src/middleware.rs::log_requests` prints`time METHOD STATUS latency path` per request with ANSI-colored status.Sub-microsecond cost.**404s:** `src/render.rs::not_found` is the shared helper every route useson a missing repo / bad revspec / bad path. The router fallback (in`src/middleware.rs::not_found`) renders the same template, so any unmatchedURL gets the themed shell instead of a plain-text 404.## ConfigurationAll via env vars (defaults shown):- `PORT=8000` — HTTP listen port.- `HEARTWOOD_ROOT=.` — project root (where `templates/` and `dist/` live).- `HEARTWOOD_REPO_ROOT=/srv/git` — directory containing `<name>.git/` bare repos.- `HEARTWOOD_CLONE_BASE=https://heartwood.bythewood.me` — public origin used  in clone URLs and atom self-links.- `HEARTWOOD_TITLE=heartwood` and `HEARTWOOD_TAGLINE=every commit a ring`  for the topbar.- `BASE_URL=` — optional `<base href>` if served on a subpath.In dev, `make run` sets `HEARTWOOD_REPO_ROOT=./fixtures/git` so you don'tneed a real `/srv/git/`.## Layout```heartwood/├── Cargo.toml, Cargo.lock├── Makefile, README.md, LICENSE.md, CLAUDE.md├── src/│   ├── main.rs            # entry: env, server boot│   ├── app.rs             # AppState + router│   ├── render.rs          # render() + shared not_found() helper│   ├── middleware.rs      # request log + router fallback (renders not_found.html)│   ├── templates.rs       # minijinja env, vite_asset, jinja2 formatter, filters│   ├── git.rs             # gix wrappers (discover, open, log, tree, blob, diff)│   ├── markdown.rs        # pulldown-cmark wrapper for READMEs│   ├── highlight.rs       # syntect wrapper for blob views│   ├── http_backend.rs    # CGI subprocess for git http-backend│   ├── bin/│   │   └── seed.rs        # `cargo run --bin seed` to populate fixtures/git/│   └── routes/│       ├── index.rs       # /│       ├── repo.rs        # /:name (overview)│       ├── log.rs         # /:name/log│       ├── commit.rs      # /:name/commit/:sha│       ├── tree.rs        # /:name/tree/:rev[/*path]│       ├── blob.rs        # /:name/blob/:rev/*path and /:name/raw/:rev/*path│       ├── atom.rs        # /:name/atom.xml│       ├── clone.rs       # /:name.git/info/refs, /:name.git/git-upload-pack│       └── seo.rs         # /favicon.ico, /favicon.svg, /robots.txt├── templates/             # minijinja templates (incl. not_found.html)├── frontend/              # JS pipeline (package.json, vite.config.js, static_src/)├── dist/                  # vite output (gitignored, served at /static/)├── fixtures/git/          # local seed bare repos (gitignored, `make fixtures`)├── target/                # cargo output (gitignored)├── Dockerfile, docker-compose.yml└── samplefiles/           # Caddyfile, env, post-receive samples```## Key Routes- `/` — repo list (auto-discovered from `HEARTWOOD_REPO_ROOT`)- `/:name` — repo landing: README + recent commits + clone URL- `/:name/log` — commit log (default branch unless `?rev=` given, `?limit=` up to 500)- `/:name/commit/:sha` — single commit + unified diff- `/:name/tree/:rev` and `/:name/tree/:rev/*path` — file browser- `/:name/blob/:rev/*path` — file view with syntax highlighting- `/:name/raw/:rev/*path` — raw file content- `/:name/atom.xml` — atom feed of recent commits- `/:name.git/info/refs` and `/:name.git/git-upload-pack` — smart HTTP clone- `/static/*` — Vite assets (1y cache header)- `/favicon.ico`, `/favicon.svg`, `/robots.txt` — seo## Tooling- **Rust deps:** managed with `cargo` (`Cargo.toml`, `Cargo.lock`)- **JS deps:** managed with `bun`, run from `frontend/` (`frontend/package.json`, `frontend/bun.lock`)- **Production:** Docker (`rust:alpine` builder + `alpine:3.23` runtime).  Runtime image installs `git` (needed for `git http-backend` and the  `git show` subprocess used by the commit-diff view) and  `ca-certificates`. Deployed via `git push server master` triggering the  standard post-receive hook. The host's `/srv/git/` is mounted read-only  into the container so the web process can't corrupt a bare repo even  accidentally.## Why some choices- **gix for browse, shell out for clone + diff:** gix is great for reading  repos in-process but its server side isn't production-ready, so smart-  HTTP serving uses `git http-backend` (git's own CGI program, the  reference implementation). The commit-diff view shells out to `git show`  for the same reason: git's unified-diff renderer is the canonical  implementation and is already on the runtime image for `http-backend`.- **No DB:** All repo metadata is read live from the bare repos. With a  handful of personal repos this is fast enough; if the count grows we can  add a startup-time index later.- **Read-only host mount:** Defense in depth. The web process never needs  to write to a bare repo; `:ro` makes accidental damage impossible.## Development tools- Playwright MCP is available for browser testing. Use it to verify  visual changes after touching SCSS or templates. Start the dev server  first with `make run`. Save screenshots to `/tmp` and delete them after  reviewing.- The dev environment runs inside a Docker container with port 8000  mapped to the host.
added Cargo.lock
@@ -0,0 +1,3211 @@# This file is automatically @generated by Cargo.# It is not intended for manual editing.version = 4[[package]]name = "adler2"version = "2.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"[[package]]name = "ahash"version = "0.8.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"dependencies = [ "cfg-if", "once_cell", "version_check", "zerocopy",][[package]]name = "aho-corasick"version = "1.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"dependencies = [ "memchr",][[package]]name = "allocator-api2"version = "0.2.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"[[package]]name = "ammonia"version = "4.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"dependencies = [ "cssparser", "html5ever", "maplit", "tendril", "url",][[package]]name = "android_system_properties"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"dependencies = [ "libc",][[package]]name = "anyhow"version = "1.0.102"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"[[package]]name = "arc-swap"version = "1.9.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"dependencies = [ "rustversion",][[package]]name = "arrayvec"version = "0.7.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"[[package]]name = "async-stream"version = "0.3.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"dependencies = [ "async-stream-impl", "futures-core", "pin-project-lite",][[package]]name = "async-stream-impl"version = "0.3.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "atomic-waker"version = "1.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"[[package]]name = "autocfg"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"[[package]]name = "axum"version = "0.8.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"dependencies = [ "axum-core", "axum-macros", "bytes", "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", "hyper", "hyper-util", "itoa", "matchit", "memchr", "mime", "percent-encoding", "pin-project-lite", "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sync_wrapper", "tokio", "tower", "tower-layer", "tower-service", "tracing",][[package]]name = "axum-core"version = "0.5.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"dependencies = [ "bytes", "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", "sync_wrapper", "tower-layer", "tower-service", "tracing",][[package]]name = "axum-macros"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "base64"version = "0.22.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"[[package]]name = "bincode"version = "1.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"dependencies = [ "serde",][[package]]name = "bit-set"version = "0.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"dependencies = [ "bit-vec",][[package]]name = "bit-vec"version = "0.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"[[package]]name = "bitflags"version = "2.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"[[package]]name = "bstr"version = "1.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"dependencies = [ "memchr", "regex-automata", "serde",][[package]]name = "bumpalo"version = "3.20.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"[[package]]name = "bytes"version = "1.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"[[package]]name = "cc"version = "1.2.62"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"dependencies = [ "find-msvc-tools", "shlex",][[package]]name = "cfg-if"version = "1.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"[[package]]name = "chrono"version = "0.4.44"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"dependencies = [ "iana-time-zone", "num-traits", "serde", "windows-link",][[package]]name = "clru"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5"dependencies = [ "hashbrown 0.16.1",][[package]]name = "core-foundation-sys"version = "0.8.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"[[package]]name = "crc32fast"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"dependencies = [ "cfg-if",][[package]]name = "crossbeam-channel"version = "0.5.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"dependencies = [ "crossbeam-utils",][[package]]name = "crossbeam-utils"version = "0.8.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"[[package]]name = "cssparser"version = "0.35.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"dependencies = [ "cssparser-macros", "dtoa-short", "itoa", "phf", "smallvec",][[package]]name = "cssparser-macros"version = "0.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"dependencies = [ "quote", "syn",][[package]]name = "dashmap"version = "6.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"dependencies = [ "cfg-if", "crossbeam-utils", "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core",][[package]]name = "deranged"version = "0.5.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"dependencies = [ "powerfmt",][[package]]name = "displaydoc"version = "0.2.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "dotenvy"version = "0.15.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"[[package]]name = "dtoa"version = "1.0.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"[[package]]name = "dtoa-short"version = "0.3.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"dependencies = [ "dtoa",][[package]]name = "dunce"version = "1.0.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"[[package]]name = "encoding_rs"version = "0.8.35"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"dependencies = [ "cfg-if",][[package]]name = "equivalent"version = "1.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"[[package]]name = "errno"version = "0.3.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"dependencies = [ "libc", "windows-sys 0.61.2",][[package]]name = "fancy-regex"version = "0.16.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"dependencies = [ "bit-set", "regex-automata", "regex-syntax",][[package]]name = "faster-hex"version = "0.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183"[[package]]name = "fastrand"version = "2.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"[[package]]name = "filetime"version = "0.2.28"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"dependencies = [ "cfg-if", "libc",][[package]]name = "find-msvc-tools"version = "0.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"[[package]]name = "flate2"version = "1.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"dependencies = [ "crc32fast", "miniz_oxide",][[package]]name = "fnv"version = "1.0.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"[[package]]name = "foldhash"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"[[package]]name = "foldhash"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"[[package]]name = "form_urlencoded"version = "1.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"dependencies = [ "percent-encoding",][[package]]name = "futf"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"dependencies = [ "mac", "new_debug_unreachable",][[package]]name = "futures-channel"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"dependencies = [ "futures-core",][[package]]name = "futures-core"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"[[package]]name = "futures-macro"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "futures-sink"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"[[package]]name = "futures-task"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"[[package]]name = "futures-util"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"dependencies = [ "futures-core", "futures-macro", "futures-task", "pin-project-lite", "slab",][[package]]name = "getrandom"version = "0.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"dependencies = [ "cfg-if", "libc", "r-efi", "wasip2", "wasip3",][[package]]name = "gix"version = "0.66.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9048b8d1ae2104f045cb37e5c450fc49d5d8af22609386bfc739c11ba88995eb"dependencies = [ "gix-actor", "gix-attributes", "gix-command", "gix-commitgraph", "gix-config", "gix-date", "gix-diff", "gix-discover", "gix-features", "gix-filter", "gix-fs", "gix-glob", "gix-hash", "gix-hashtable", "gix-ignore", "gix-index", "gix-lock", "gix-object", "gix-odb", "gix-pack", "gix-path", "gix-pathspec", "gix-ref", "gix-refspec", "gix-revision", "gix-revwalk", "gix-sec", "gix-submodule", "gix-tempfile", "gix-trace", "gix-traverse", "gix-url", "gix-utils", "gix-validate 0.9.4", "gix-worktree", "once_cell", "smallvec", "thiserror 1.0.69",][[package]]name = "gix-actor"version = "0.32.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fc19e312cd45c4a66cd003f909163dc2f8e1623e30a0c0c6df3776e89b308665"dependencies = [ "bstr", "gix-date", "gix-utils", "itoa", "thiserror 1.0.69", "winnow",][[package]]name = "gix-attributes"version = "0.22.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebccbf25aa4a973dd352564a9000af69edca90623e8a16dad9cbc03713131311"dependencies = [ "bstr", "gix-glob", "gix-path", "gix-quote", "gix-trace", "kstring", "smallvec", "thiserror 1.0.69", "unicode-bom",][[package]]name = "gix-bitmap"version = "0.2.16"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d982fc7ef0608e669851d0d2a6141dae74c60d5a27e8daa451f2a4857bbf41e2"dependencies = [ "thiserror 2.0.18",][[package]]name = "gix-chunk"version = "0.4.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5c356b3825677cb6ff579551bb8311a81821e184453cbd105e2fc5311b288eeb"dependencies = [ "thiserror 2.0.18",][[package]]name = "gix-command"version = "0.3.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6d7d6b8f3a64453fd7e8191eb80b351eb7ac0839b40a1237cd2c137d5079fe53"dependencies = [ "bstr", "gix-path", "gix-trace", "shell-words",][[package]]name = "gix-commitgraph"version = "0.24.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "133b06f67f565836ec0c473e2116a60fb74f80b6435e21d88013ac0e3c60fc78"dependencies = [ "bstr", "gix-chunk", "gix-features", "gix-hash", "memmap2", "thiserror 1.0.69",][[package]]name = "gix-config"version = "0.40.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "78e797487e6ca3552491de1131b4f72202f282fb33f198b1c34406d765b42bb0"dependencies = [ "bstr", "gix-config-value", "gix-features", "gix-glob", "gix-path", "gix-ref", "gix-sec", "memchr", "once_cell", "smallvec", "thiserror 1.0.69", "unicode-bom", "winnow",][[package]]name = "gix-config-value"version = "0.14.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8dc2c844c4cf141884678cabef736fd91dd73068b9146e6f004ba1a0457944b6"dependencies = [ "bitflags", "bstr", "gix-path", "libc", "thiserror 2.0.18",][[package]]name = "gix-date"version = "0.9.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "daa30058ec7d3511fbc229e4f9e696a35abd07ec5b82e635eff864a2726217e4"dependencies = [ "bstr", "itoa", "jiff", "thiserror 2.0.18",][[package]]name = "gix-diff"version = "0.46.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92c9afd80fff00f8b38b1c1928442feb4cd6d2232a6ed806b6b193151a3d336c"dependencies = [ "bstr", "gix-command", "gix-filter", "gix-fs", "gix-hash", "gix-object", "gix-path", "gix-tempfile", "gix-trace", "gix-worktree", "imara-diff", "thiserror 1.0.69",][[package]]name = "gix-discover"version = "0.35.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0577366b9567376bc26e815fd74451ebd0e6218814e242f8e5b7072c58d956d2"dependencies = [ "bstr", "dunce", "gix-fs", "gix-hash", "gix-path", "gix-ref", "gix-sec", "thiserror 1.0.69",][[package]]name = "gix-features"version = "0.38.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ac7045ac9fe5f9c727f38799d002a7ed3583cd777e3322a7c4b43e3cf437dc69"dependencies = [ "crc32fast", "crossbeam-channel", "flate2", "gix-hash", "gix-trace", "gix-utils", "libc", "once_cell", "parking_lot", "prodash", "sha1_smol", "thiserror 1.0.69", "walkdir",][[package]]name = "gix-filter"version = "0.13.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4121790ae140066e5b953becc72e7496278138d19239be2e63b5067b0843119e"dependencies = [ "bstr", "encoding_rs", "gix-attributes", "gix-command", "gix-hash", "gix-object", "gix-packetline-blocking", "gix-path", "gix-quote", "gix-trace", "gix-utils", "smallvec", "thiserror 1.0.69",][[package]]name = "gix-fs"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f2bfe6249cfea6d0c0e0990d5226a4cb36f030444ba9e35e0639275db8f98575"dependencies = [ "fastrand", "gix-features", "gix-utils",][[package]]name = "gix-glob"version = "0.16.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "74908b4bbc0a0a40852737e5d7889f676f081e340d5451a16e5b4c50d592f111"dependencies = [ "bitflags", "bstr", "gix-features", "gix-path",][[package]]name = "gix-hash"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f93d7df7366121b5018f947a04d37f034717e113dcf9ccd85c34b58e57a74d5e"dependencies = [ "faster-hex", "thiserror 1.0.69",][[package]]name = "gix-hashtable"version = "0.5.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7ddf80e16f3c19ac06ce415a38b8591993d3f73aede049cb561becb5b3a8e242"dependencies = [ "gix-hash", "hashbrown 0.14.5", "parking_lot",][[package]]name = "gix-ignore"version = "0.11.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e447cd96598460f5906a0f6c75e950a39f98c2705fc755ad2f2020c9e937fab7"dependencies = [ "bstr", "gix-glob", "gix-path", "gix-trace", "unicode-bom",][[package]]name = "gix-index"version = "0.35.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0cd4203244444017682176e65fd0180be9298e58ed90bd4a8489a357795ed22d"dependencies = [ "bitflags", "bstr", "filetime", "fnv", "gix-bitmap", "gix-features", "gix-fs", "gix-hash", "gix-lock", "gix-object", "gix-traverse", "gix-utils", "gix-validate 0.9.4", "hashbrown 0.14.5", "itoa", "libc", "memmap2", "rustix 0.38.44", "smallvec", "thiserror 1.0.69",][[package]]name = "gix-lock"version = "14.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e3bc7fe297f1f4614774989c00ec8b1add59571dc9b024b4c00acb7dedd4e19d"dependencies = [ "gix-tempfile", "gix-utils", "thiserror 1.0.69",][[package]]name = "gix-object"version = "0.44.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2f5b801834f1de7640731820c2df6ba88d95480dc4ab166a5882f8ff12b88efa"dependencies = [ "bstr", "gix-actor", "gix-date", "gix-features", "gix-hash", "gix-utils", "gix-validate 0.9.4", "itoa", "smallvec", "thiserror 1.0.69", "winnow",][[package]]name = "gix-odb"version = "0.63.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a3158068701c17df54f0ab2adda527f5a6aca38fd5fd80ceb7e3c0a2717ec747"dependencies = [ "arc-swap", "gix-date", "gix-features", "gix-fs", "gix-hash", "gix-object", "gix-pack", "gix-path", "gix-quote", "parking_lot", "tempfile", "thiserror 1.0.69",][[package]]name = "gix-pack"version = "0.53.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3223aa342eee21e1e0e403cad8ae9caf9edca55ef84c347738d10681676fd954"dependencies = [ "clru", "gix-chunk", "gix-features", "gix-hash", "gix-hashtable", "gix-object", "gix-path", "memmap2", "smallvec", "thiserror 1.0.69", "uluru",][[package]]name = "gix-packetline-blocking"version = "0.17.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b9802304baa798dd6f5ff8008a2b6516d54b74a69ca2d3a2b9e2d6c3b5556b40"dependencies = [ "bstr", "faster-hex", "gix-trace", "thiserror 1.0.69",][[package]]name = "gix-path"version = "0.10.22"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7cb06c3e4f8eed6e24fd915fa93145e28a511f4ea0e768bae16673e05ed3f366"dependencies = [ "bstr", "gix-trace", "gix-validate 0.10.1", "thiserror 2.0.18",][[package]]name = "gix-pathspec"version = "0.7.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d23bf239532b4414d0e63b8ab3a65481881f7237ed9647bb10c1e3cc54c5ceb"dependencies = [ "bitflags", "bstr", "gix-attributes", "gix-config-value", "gix-glob", "gix-path", "thiserror 1.0.69",][[package]]name = "gix-quote"version = "0.4.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e49357fccdb0c85c0d3a3292a9f6db32d9b3535959b5471bb9624908f4a066c6"dependencies = [ "bstr", "gix-utils", "thiserror 2.0.18",][[package]]name = "gix-ref"version = "0.47.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ae0d8406ebf9aaa91f55a57f053c5a1ad1a39f60fdf0303142b7be7ea44311e5"dependencies = [ "gix-actor", "gix-features", "gix-fs", "gix-hash", "gix-lock", "gix-object", "gix-path", "gix-tempfile", "gix-utils", "gix-validate 0.9.4", "memmap2", "thiserror 1.0.69", "winnow",][[package]]name = "gix-refspec"version = "0.25.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebb005f82341ba67615ffdd9f7742c87787544441c88090878393d0682869ca6"dependencies = [ "bstr", "gix-hash", "gix-revision", "gix-validate 0.9.4", "smallvec", "thiserror 1.0.69",][[package]]name = "gix-revision"version = "0.29.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ba4621b219ac0cdb9256883030c3d56a6c64a6deaa829a92da73b9a576825e1e"dependencies = [ "bstr", "gix-date", "gix-hash", "gix-hashtable", "gix-object", "gix-revwalk", "gix-trace", "thiserror 1.0.69",][[package]]name = "gix-revwalk"version = "0.15.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b41e72544b93084ee682ef3d5b31b1ba4d8fa27a017482900e5e044d5b1b3984"dependencies = [ "gix-commitgraph", "gix-date", "gix-hash", "gix-hashtable", "gix-object", "smallvec", "thiserror 1.0.69",][[package]]name = "gix-sec"version = "0.10.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "47aeb0f13de9ef2f3033f5ff218de30f44db827ac9f1286f9ef050aacddd5888"dependencies = [ "bitflags", "gix-path", "libc", "windows-sys 0.52.0",][[package]]name = "gix-submodule"version = "0.14.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "529d0af78cc2f372b3218f15eb1e3d1635a21c8937c12e2dd0b6fc80c2ca874b"dependencies = [ "bstr", "gix-config", "gix-path", "gix-pathspec", "gix-refspec", "gix-url", "thiserror 1.0.69",][[package]]name = "gix-tempfile"version = "14.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "046b4927969fa816a150a0cda2e62c80016fe11fb3c3184e4dddf4e542f108aa"dependencies = [ "dashmap", "gix-fs", "libc", "once_cell", "parking_lot", "tempfile",][[package]]name = "gix-trace"version = "0.1.19"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6f23569e55f2ffaf958617353b9734a7d52a7c19c439eeaa5e3efc217fd2270e"[[package]]name = "gix-traverse"version = "0.41.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "030da39af94e4df35472e9318228f36530989327906f38e27807df305fccb780"dependencies = [ "bitflags", "gix-commitgraph", "gix-date", "gix-hash", "gix-hashtable", "gix-object", "gix-revwalk", "smallvec", "thiserror 1.0.69",][[package]]name = "gix-url"version = "0.27.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fd280c5e84fb22e128ed2a053a0daeacb6379469be6a85e3d518a0636e160c89"dependencies = [ "bstr", "gix-features", "gix-path", "home", "thiserror 1.0.69", "url",][[package]]name = "gix-utils"version = "0.1.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ff08f24e03ac8916c478c8419d7d3c33393da9bb41fa4c24455d5406aeefd35f"dependencies = [ "fastrand", "unicode-normalization",][[package]]name = "gix-validate"version = "0.9.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "34b5f1253109da6c79ed7cf6e1e38437080bb6d704c76af14c93e2f255234084"dependencies = [ "bstr", "thiserror 2.0.18",][[package]]name = "gix-validate"version = "0.10.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5b1e63a5b516e970a594f870ed4571a8fdcb8a344e7bd407a20db8bd61dbfde4"dependencies = [ "bstr", "thiserror 2.0.18",][[package]]name = "gix-worktree"version = "0.36.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c312ad76a3f2ba8e865b360d5cb3aa04660971d16dec6dd0ce717938d903149a"dependencies = [ "bstr", "gix-attributes", "gix-features", "gix-fs", "gix-glob", "gix-hash", "gix-ignore", "gix-index", "gix-object", "gix-path", "gix-validate 0.9.4",][[package]]name = "hashbrown"version = "0.14.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"dependencies = [ "ahash", "allocator-api2",][[package]]name = "hashbrown"version = "0.15.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"dependencies = [ "foldhash 0.1.5",][[package]]name = "hashbrown"version = "0.16.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0",][[package]]name = "hashbrown"version = "0.17.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"[[package]]name = "heartwood"version = "0.1.0"dependencies = [ "ammonia", "anyhow", "async-stream", "axum", "bytes", "chrono", "dotenvy", "futures-util", "gix", "mime_guess", "minijinja", "pulldown-cmark", "serde", "serde_json", "syntect", "tokio", "tower", "tower-http", "tracing", "tracing-subscriber", "urlencoding",][[package]]name = "heck"version = "0.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"[[package]]name = "home"version = "0.5.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"dependencies = [ "windows-sys 0.61.2",][[package]]name = "html5ever"version = "0.35.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"dependencies = [ "log", "markup5ever", "match_token",][[package]]name = "http"version = "1.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"dependencies = [ "bytes", "itoa",][[package]]name = "http-body"version = "1.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"dependencies = [ "bytes", "http",][[package]]name = "http-body-util"version = "0.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"dependencies = [ "bytes", "futures-core", "http", "http-body", "pin-project-lite",][[package]]name = "http-range-header"version = "0.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"[[package]]name = "httparse"version = "1.10.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"[[package]]name = "httpdate"version = "1.0.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"[[package]]name = "hyper"version = "1.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", "smallvec", "tokio",][[package]]name = "hyper-util"version = "0.1.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"dependencies = [ "bytes", "http", "http-body", "hyper", "pin-project-lite", "tokio", "tower-service",][[package]]name = "iana-time-zone"version = "0.1.65"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core",][[package]]name = "iana-time-zone-haiku"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"dependencies = [ "cc",][[package]]name = "icu_collections"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"dependencies = [ "displaydoc", "potential_utf", "utf8_iter", "yoke", "zerofrom", "zerovec",][[package]]name = "icu_locale_core"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec",][[package]]name = "icu_normalizer"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec",][[package]]name = "icu_normalizer_data"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"[[package]]name = "icu_properties"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec",][[package]]name = "icu_properties_data"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"[[package]]name = "icu_provider"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec",][[package]]name = "id-arena"version = "2.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"[[package]]name = "idna"version = "1.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"dependencies = [ "idna_adapter", "smallvec", "utf8_iter",][[package]]name = "idna_adapter"version = "1.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"dependencies = [ "icu_normalizer", "icu_properties",][[package]]name = "imara-diff"version = "0.1.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2"dependencies = [ "hashbrown 0.15.5",][[package]]name = "indexmap"version = "2.14.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"dependencies = [ "equivalent", "hashbrown 0.17.1", "serde", "serde_core",][[package]]name = "itoa"version = "1.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"[[package]]name = "jiff"version = "0.2.24"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"dependencies = [ "jiff-static", "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", "windows-sys 0.61.2",][[package]]name = "jiff-static"version = "0.2.24"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "jiff-tzdb"version = "0.1.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"[[package]]name = "jiff-tzdb-platform"version = "0.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"dependencies = [ "jiff-tzdb",][[package]]name = "js-sys"version = "0.3.98"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"dependencies = [ "cfg-if", "futures-util", "once_cell", "wasm-bindgen",][[package]]name = "kstring"version = "2.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1"dependencies = [ "static_assertions",][[package]]name = "lazy_static"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"[[package]]name = "leb128fmt"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"[[package]]name = "libc"version = "0.2.186"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"[[package]]name = "linked-hash-map"version = "0.5.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"[[package]]name = "linux-raw-sys"version = "0.4.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"[[package]]name = "linux-raw-sys"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"[[package]]name = "litemap"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"[[package]]name = "lock_api"version = "0.4.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"dependencies = [ "scopeguard",][[package]]name = "log"version = "0.4.29"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"[[package]]name = "mac"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"[[package]]name = "maplit"version = "1.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"[[package]]name = "markup5ever"version = "0.35.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"dependencies = [ "log", "tendril", "web_atoms",][[package]]name = "match_token"version = "0.35.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "matchers"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"dependencies = [ "regex-automata",][[package]]name = "matchit"version = "0.8.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"[[package]]name = "memchr"version = "2.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"[[package]]name = "memmap2"version = "0.9.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"dependencies = [ "libc",][[package]]name = "memo-map"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"[[package]]name = "mime"version = "0.3.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"[[package]]name = "mime_guess"version = "2.0.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"dependencies = [ "mime", "unicase",][[package]]name = "minijinja"version = "2.19.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d"dependencies = [ "memo-map", "serde",][[package]]name = "miniz_oxide"version = "0.8.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"dependencies = [ "adler2", "simd-adler32",][[package]]name = "mio"version = "1.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"dependencies = [ "libc", "wasi", "windows-sys 0.61.2",][[package]]name = "new_debug_unreachable"version = "1.0.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"[[package]]name = "nu-ansi-term"version = "0.50.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"dependencies = [ "windows-sys 0.61.2",][[package]]name = "num-conv"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"[[package]]name = "num-traits"version = "0.2.19"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"dependencies = [ "autocfg",][[package]]name = "once_cell"version = "1.21.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"[[package]]name = "parking_lot"version = "0.12.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"dependencies = [ "lock_api", "parking_lot_core",][[package]]name = "parking_lot_core"version = "0.9.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-link",][[package]]name = "percent-encoding"version = "2.3.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"[[package]]name = "phf"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"dependencies = [ "phf_macros", "phf_shared",][[package]]name = "phf_codegen"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"dependencies = [ "phf_generator", "phf_shared",][[package]]name = "phf_generator"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"dependencies = [ "phf_shared", "rand",][[package]]name = "phf_macros"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"dependencies = [ "phf_generator", "phf_shared", "proc-macro2", "quote", "syn",][[package]]name = "phf_shared"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"dependencies = [ "siphasher",][[package]]name = "pin-project-lite"version = "0.2.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"[[package]]name = "plist"version = "1.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"dependencies = [ "base64", "indexmap", "quick-xml", "serde", "time",][[package]]name = "portable-atomic"version = "1.13.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"[[package]]name = "portable-atomic-util"version = "0.2.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"dependencies = [ "portable-atomic",][[package]]name = "potential_utf"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"dependencies = [ "zerovec",][[package]]name = "powerfmt"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"[[package]]name = "precomputed-hash"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"[[package]]name = "prettyplease"version = "0.2.37"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"dependencies = [ "proc-macro2", "syn",][[package]]name = "proc-macro2"version = "1.0.106"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"dependencies = [ "unicode-ident",][[package]]name = "prodash"version = "28.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "744a264d26b88a6a7e37cbad97953fa233b94d585236310bcbc88474b4092d79"[[package]]name = "pulldown-cmark"version = "0.12.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"dependencies = [ "bitflags", "memchr", "pulldown-cmark-escape", "unicase",][[package]]name = "pulldown-cmark-escape"version = "0.11.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"[[package]]name = "quick-xml"version = "0.39.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"dependencies = [ "memchr",][[package]]name = "quote"version = "1.0.45"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"dependencies = [ "proc-macro2",][[package]]name = "r-efi"version = "6.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"[[package]]name = "rand"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"dependencies = [ "rand_core",][[package]]name = "rand_core"version = "0.6.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"[[package]]name = "redox_syscall"version = "0.5.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"dependencies = [ "bitflags",][[package]]name = "regex-automata"version = "0.4.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"dependencies = [ "aho-corasick", "memchr", "regex-syntax",][[package]]name = "regex-syntax"version = "0.8.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"[[package]]name = "rustix"version = "0.38.44"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", "windows-sys 0.59.0",][[package]]name = "rustix"version = "1.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys 0.12.1", "windows-sys 0.61.2",][[package]]name = "rustversion"version = "1.0.22"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"[[package]]name = "ryu"version = "1.0.23"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"[[package]]name = "same-file"version = "1.0.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"dependencies = [ "winapi-util",][[package]]name = "scopeguard"version = "1.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"[[package]]name = "semver"version = "1.0.28"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"[[package]]name = "serde"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"dependencies = [ "serde_core", "serde_derive",][[package]]name = "serde_core"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"dependencies = [ "serde_derive",][[package]]name = "serde_derive"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "serde_json"version = "1.0.149"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij",][[package]]name = "serde_path_to_error"version = "0.1.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"dependencies = [ "itoa", "serde", "serde_core",][[package]]name = "serde_urlencoded"version = "0.7.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"dependencies = [ "form_urlencoded", "itoa", "ryu", "serde",][[package]]name = "sha1_smol"version = "1.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"[[package]]name = "sharded-slab"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"dependencies = [ "lazy_static",][[package]]name = "shell-words"version = "1.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"[[package]]name = "shlex"version = "1.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"[[package]]name = "signal-hook-registry"version = "1.4.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"dependencies = [ "errno", "libc",][[package]]name = "simd-adler32"version = "0.3.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"[[package]]name = "siphasher"version = "1.0.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"[[package]]name = "slab"version = "0.4.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"[[package]]name = "smallvec"version = "1.15.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"[[package]]name = "socket2"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"dependencies = [ "libc", "windows-sys 0.61.2",][[package]]name = "stable_deref_trait"version = "1.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"[[package]]name = "static_assertions"version = "1.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"[[package]]name = "string_cache"version = "0.8.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"dependencies = [ "new_debug_unreachable", "parking_lot", "phf_shared", "precomputed-hash", "serde",][[package]]name = "string_cache_codegen"version = "0.5.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"dependencies = [ "phf_generator", "phf_shared", "proc-macro2", "quote",][[package]]name = "syn"version = "2.0.117"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"dependencies = [ "proc-macro2", "quote", "unicode-ident",][[package]]name = "sync_wrapper"version = "1.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"[[package]]name = "synstructure"version = "0.13.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "syntect"version = "5.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"dependencies = [ "bincode", "fancy-regex", "flate2", "fnv", "once_cell", "plist", "regex-syntax", "serde", "serde_derive", "serde_json", "thiserror 2.0.18", "walkdir", "yaml-rust",][[package]]name = "tempfile"version = "3.27.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"dependencies = [ "fastrand", "getrandom", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2",][[package]]name = "tendril"version = "0.4.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"dependencies = [ "futf", "mac", "utf-8",][[package]]name = "thiserror"version = "1.0.69"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"dependencies = [ "thiserror-impl 1.0.69",][[package]]name = "thiserror"version = "2.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"dependencies = [ "thiserror-impl 2.0.18",][[package]]name = "thiserror-impl"version = "1.0.69"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "thiserror-impl"version = "2.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "thread_local"version = "1.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"dependencies = [ "cfg-if",][[package]]name = "time"version = "0.3.47"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", "serde_core", "time-core", "time-macros",][[package]]name = "time-core"version = "0.1.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"[[package]]name = "time-macros"version = "0.2.27"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"dependencies = [ "num-conv", "time-core",][[package]]name = "tinystr"version = "0.8.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"dependencies = [ "displaydoc", "zerovec",][[package]]name = "tinyvec"version = "1.11.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"dependencies = [ "tinyvec_macros",][[package]]name = "tinyvec_macros"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"[[package]]name = "tokio"version = "1.52.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"dependencies = [ "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2",][[package]]name = "tokio-macros"version = "2.7.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "tokio-util"version = "0.7.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio",][[package]]name = "tower"version = "0.5.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"dependencies = [ "futures-core", "futures-util", "pin-project-lite", "sync_wrapper", "tokio", "tower-layer", "tower-service", "tracing",][[package]]name = "tower-http"version = "0.6.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"dependencies = [ "bitflags", "bytes", "futures-core", "futures-util", "http", "http-body", "http-body-util", "http-range-header", "httpdate", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", "tower-layer", "tower-service",][[package]]name = "tower-layer"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"[[package]]name = "tower-service"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"[[package]]name = "tracing"version = "0.1.44"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"dependencies = [ "log", "pin-project-lite", "tracing-attributes", "tracing-core",][[package]]name = "tracing-attributes"version = "0.1.31"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "tracing-core"version = "0.1.36"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"dependencies = [ "once_cell", "valuable",][[package]]name = "tracing-log"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"dependencies = [ "log", "once_cell", "tracing-core",][[package]]name = "tracing-subscriber"version = "0.3.23"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"dependencies = [ "matchers", "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log",][[package]]name = "uluru"version = "3.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da"dependencies = [ "arrayvec",][[package]]name = "unicase"version = "2.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"[[package]]name = "unicode-bom"version = "2.0.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217"[[package]]name = "unicode-ident"version = "1.0.24"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"[[package]]name = "unicode-normalization"version = "0.1.25"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"dependencies = [ "tinyvec",][[package]]name = "unicode-xid"version = "0.2.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"[[package]]name = "url"version = "2.5.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde",][[package]]name = "urlencoding"version = "2.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"[[package]]name = "utf-8"version = "0.7.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"[[package]]name = "utf8_iter"version = "1.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"[[package]]name = "valuable"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"[[package]]name = "version_check"version = "0.9.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"[[package]]name = "walkdir"version = "2.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"dependencies = [ "same-file", "winapi-util",][[package]]name = "wasi"version = "0.11.1+wasi-snapshot-preview1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"[[package]]name = "wasip2"version = "1.0.3+wasi-0.2.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"dependencies = [ "wit-bindgen 0.57.1",][[package]]name = "wasip3"version = "0.4.0+wasi-0.3.0-rc-2026-01-06"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"dependencies = [ "wit-bindgen 0.51.0",][[package]]name = "wasm-bindgen"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared",][[package]]name = "wasm-bindgen-macro"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"dependencies = [ "quote", "wasm-bindgen-macro-support",][[package]]name = "wasm-bindgen-macro-support"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"dependencies = [ "bumpalo", "proc-macro2", "quote", "syn", "wasm-bindgen-shared",][[package]]name = "wasm-bindgen-shared"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"dependencies = [ "unicode-ident",][[package]]name = "wasm-encoder"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"dependencies = [ "leb128fmt", "wasmparser",][[package]]name = "wasm-metadata"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"dependencies = [ "anyhow", "indexmap", "wasm-encoder", "wasmparser",][[package]]name = "wasmparser"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"dependencies = [ "bitflags", "hashbrown 0.15.5", "indexmap", "semver",][[package]]name = "web_atoms"version = "0.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"dependencies = [ "phf", "phf_codegen", "string_cache", "string_cache_codegen",][[package]]name = "winapi-util"version = "0.1.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"dependencies = [ "windows-sys 0.61.2",][[package]]name = "windows-core"version = "0.62.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", "windows-strings",][[package]]name = "windows-implement"version = "0.60.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "windows-interface"version = "0.59.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "windows-link"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"[[package]]name = "windows-result"version = "0.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"dependencies = [ "windows-link",][[package]]name = "windows-strings"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"dependencies = [ "windows-link",][[package]]name = "windows-sys"version = "0.52.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"dependencies = [ "windows-targets",][[package]]name = "windows-sys"version = "0.59.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"dependencies = [ "windows-targets",][[package]]name = "windows-sys"version = "0.61.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"dependencies = [ "windows-link",][[package]]name = "windows-targets"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc",][[package]]name = "windows_aarch64_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"[[package]]name = "windows_aarch64_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"[[package]]name = "windows_i686_gnu"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"[[package]]name = "windows_i686_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"[[package]]name = "windows_i686_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"[[package]]name = "windows_x86_64_gnu"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"[[package]]name = "windows_x86_64_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"[[package]]name = "windows_x86_64_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"[[package]]name = "winnow"version = "0.6.26"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"dependencies = [ "memchr",][[package]]name = "wit-bindgen"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"dependencies = [ "wit-bindgen-rust-macro",][[package]]name = "wit-bindgen"version = "0.57.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"[[package]]name = "wit-bindgen-core"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"dependencies = [ "anyhow", "heck", "wit-parser",][[package]]name = "wit-bindgen-rust"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"dependencies = [ "anyhow", "heck", "indexmap", "prettyplease", "syn", "wasm-metadata", "wit-bindgen-core", "wit-component",][[package]]name = "wit-bindgen-rust-macro"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"dependencies = [ "anyhow", "prettyplease", "proc-macro2", "quote", "syn", "wit-bindgen-core", "wit-bindgen-rust",][[package]]name = "wit-component"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"dependencies = [ "anyhow", "bitflags", "indexmap", "log", "serde", "serde_derive", "serde_json", "wasm-encoder", "wasm-metadata", "wasmparser", "wit-parser",][[package]]name = "wit-parser"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"dependencies = [ "anyhow", "id-arena", "indexmap", "log", "semver", "serde", "serde_derive", "serde_json", "unicode-xid", "wasmparser",][[package]]name = "writeable"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"[[package]]name = "yaml-rust"version = "0.4.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"dependencies = [ "linked-hash-map",][[package]]name = "yoke"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"dependencies = [ "stable_deref_trait", "yoke-derive", "zerofrom",][[package]]name = "yoke-derive"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"dependencies = [ "proc-macro2", "quote", "syn", "synstructure",][[package]]name = "zerocopy"version = "0.8.48"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"dependencies = [ "zerocopy-derive",][[package]]name = "zerocopy-derive"version = "0.8.48"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "zerofrom"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"dependencies = [ "zerofrom-derive",][[package]]name = "zerofrom-derive"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"dependencies = [ "proc-macro2", "quote", "syn", "synstructure",][[package]]name = "zerotrie"version = "0.2.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"dependencies = [ "displaydoc", "yoke", "zerofrom",][[package]]name = "zerovec"version = "0.11.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"dependencies = [ "yoke", "zerofrom", "zerovec-derive",][[package]]name = "zerovec-derive"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "zmij"version = "1.0.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
added Cargo.toml
@@ -0,0 +1,37 @@[package]name = "heartwood"version = "0.1.0"edition = "2021"# With a second binary in src/bin/ (seed), `cargo run` becomes ambiguous.# Pin the default to the server so `cargo run` from the Makefile still works.default-run = "heartwood"[dependencies]axum = { version = "0.8", features = ["macros"] }tokio = { version = "1", features = ["full"] }tower = { version = "0.5", features = ["util"] }tower-http = { version = "0.6", features = ["fs", "set-header"] }minijinja = { version = "2", features = ["loader", "loop_controls"] }gix = { version = "0.66", default-features = false, features = ["max-performance-safe", "blob-diff", "revision"] }syntect = { version = "5", default-features = false, features = ["default-fancy"] }pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }ammonia = "4"serde = { version = "1", features = ["derive"] }serde_json = "1"chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }anyhow = "1"tracing = "0.1"tracing-subscriber = { version = "0.3", features = ["env-filter"] }dotenvy = "0.15"bytes = "1"urlencoding = "2"futures-util = "0.3"async-stream = "0.3"mime_guess = "2"[profile.release]# Match darkfurrow: small + fast. Heartwood has no chunky dep tree# (no typst, no chromium) so LTO is tractable.lto = truecodegen-units = 1strip = true
added Dockerfile
@@ -0,0 +1,44 @@# syntax=docker/dockerfile:1# ----- builder -----FROM rust:alpine AS builderRUN apk add --no-cache musl-devCOPY --from=oven/bun:alpine /usr/local/bin/bun /usr/local/bin/bunWORKDIR /appCOPY Cargo.toml Cargo.lock ./COPY src ./srcCOPY frontend ./frontendRUN cd frontend && bun install --frozen-lockfile && bun run buildRUN --mount=type=cache,target=/usr/local/cargo/registry \    --mount=type=cache,target=/app/target \    cargo build --release && \    cp target/release/heartwood /app/heartwood# ----- runtime -----FROM alpine:3.23# git is required at runtime: clone endpoints shell out to `git http-backend`# (the canonical CGI), and the commit-diff view shells out to `git show`.# ca-certificates so any outbound TLS just works.RUN apk add --no-cache git ca-certificatesWORKDIR /appCOPY --from=builder /app/heartwood ./heartwoodCOPY --from=builder /app/dist ./distCOPY templates ./templatesRUN addgroup -S -g 1000 app && \    adduser -S -h /app -s /sbin/nologin -u 1000 -G app app && \    chown -R app:app /appUSER appENV PORT=8000ENV HEARTWOOD_REPO_ROOT=/srv/gitEXPOSE 8000CMD ["./heartwood"]
added LICENSE.md
@@ -0,0 +1,25 @@# The BSD 2-Clause LicenseCopyright (c) `2026`, `Isaac Bythewood`All rights reserved.Redistribution and use in source and binary forms, with or without modification,are permitted provided that the following conditions are met:1.  Redistributions of source code must retain the above copyright notice,    this list of conditions and the following disclaimer.2.  Redistributions in binary form must reproduce the above copyright notice,    this list of conditions and the following disclaimer in the documentation    and/or other materials provided with the distribution.THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" ANDANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIEDWARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE AREDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLEFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIALDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS ORSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVERCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OFTHIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
added Makefile
@@ -0,0 +1,58 @@CARGO ?= $(HOME)/.cargo/bin/cargoPORT  ?= 8000# In dev the bare repos at /srv/git live on the production server, not here.# Use ./fixtures/git as the local repo root; `make seed` populates it so# `make run` has something to show on the landing page.HEARTWOOD_REPO_ROOT ?= $(CURDIR)/fixtures/git.DEFAULT_GOAL := run.PHONY: run build start clean push seed seed-reset# Dev: Vite watch + cargo run concurrently. Both die on Ctrl+C.# Seeding is opt-in (run `make seed` once); a fresh checkout shows an empty# repo list until you do.run: frontend/node_modules dist/.vite/manifest.json	@trap 'kill 0' EXIT INT TERM; \	(cd frontend && bun run dev) & \	PORT=$(PORT) HEARTWOOD_REPO_ROOT=$(HEARTWOOD_REPO_ROOT) $(CARGO) run# Production build (Vite assets + release binary)build: frontend/node_modules	cd frontend && bun run build	$(CARGO) build --release# Run the release binary (after `make build`)start:	PORT=$(PORT) ./target/release/heartwood# Wipe regenerable output. Leaves fixtures/ alone so `make run` after `make# clean` doesn't blow away your seeded repos.clean:	rm -rf target dist frontend/node_modulespush:	git remote | xargs -I R git push R master# Synthesize a handful of fake-but-realistic bare repos under fixtures/git/# via the `seed` bin. Idempotent: existing repo dirs are left alone, so# re-running is cheap. Override defaults inline, e.g. `make seed COUNT=12# DAYS=45 SEED=42`. See src/bin/seed.rs for the archetypes and patches.COUNT ?=DAYS  ?=SEED  ?=SEED_ARGS = $(if $(COUNT),--count $(COUNT)) $(if $(DAYS),--days $(DAYS)) $(if $(SEED),--seed $(SEED))seed:	$(CARGO) run --quiet --bin seed -- $(SEED_ARGS)# Wipe and regenerate from scratch. Use after editing seed.rs or when you# want a different deterministic set (pair with SEED=<n>).seed-reset:	$(CARGO) run --quiet --bin seed -- --reset $(SEED_ARGS)frontend/node_modules:	cd frontend && bun installdist/.vite/manifest.json: frontend/node_modules	cd frontend && bun run build
added README.md
@@ -0,0 +1,166 @@# HeartwoodA minimal web frontend for the bare git repos on a single-operator server.Built to replace GitHub as the place my code is publicly visible.Single-binary axum service. No database, no auth, no pull requests, no issues.Repo metadata is read live from the bare repos via `gix` (gitoxide). Thecommit-diff view shells out to `git show --patch`, and `git clone` over HTTPSworks through `git http-backend` as a CGI subprocess. Everything else isin-process.## Features- Repo list auto-discovered from a directory of `*.git` bare repos- Per-repo landing page: README, recent commits, clone URL, default branch- Tree / blob browser with syntect syntax highlighting- Commit log and per-commit unified-diff view (rename detection on)- Atom feed of recent commits per repo- Read-only `git clone` over HTTPS (`/info/refs` + `/git-upload-pack`)- Pushes are intentionally 405; the only way code arrives is `git push server master`## Stack| Concern        | Crate / Tool                                          ||----------------|-------------------------------------------------------|| Web framework  | axum + tokio                                          || Git read       | gix (gitoxide) for browse, log, tree, blob, atom      || Git serve      | `git http-backend` subprocess for clone               || Diff           | `git show --patch` subprocess + in-house unified parser || Templates      | minijinja                                             || Markdown       | pulldown-cmark (tables, footnotes, strikethrough, tasklists) || Highlighting   | syntect (`default-fancy`, base16-eighties.dark theme) || Static assets  | Vite + Bun, SCSS, JetBrains Mono (self-hosted via `@fontsource`) |## RequirementsProduction runs through Docker (see `Dockerfile`). The only runtimedependencies are `git` and `ca-certificates` on top of `alpine:3.23`.For local development:- rust (cargo) for the backend- bun for the frontend bundler (Vite)- `git` on `PATH` (heartwood shells out to it for `http-backend` and `show`)## Running locally    cp samplefiles/env.sample .env  # optional; defaults are fine for dev    make seed                        # one-time: synthesize 8 fake bare repos in fixtures/git/    make run                         # vite watch + cargo run on port 8000`make run` does not seed automatically; a fresh checkout shows an empty repolist until you run `make seed` once. The seed step is opt-in so you can alsopoint `HEARTWOOD_REPO_ROOT` at a real directory and skip it entirely.`make seed` runs the `seed` bin (see `src/bin/seed.rs`), which synthesizesfake-but-realistic bare git repos under `fixtures/git/`. It picks a mix ofarchetypes (Rust crate, TS lib, Python package, markdown blog, dotfiles) withrealistic file shapes, commit messages drawn from per-archetype corpora, and~30 days of history across five rotating authors. Deterministic: the same`--seed` value always produces the same set of repos.Override the defaults inline:    make seed COUNT=12 DAYS=45         # more repos, longer history    make seed SEED=42                  # different deterministic mix`make seed` is idempotent: existing repo directories are left alone, sore-running is cheap. `make seed-reset` wipes `fixtures/git/` first.## ConfigurationAll config comes from environment variables (loaded from `.env` via `dotenvy`):| Variable | Required | Purpose ||---|---|---|| `PORT` | no (default `8000`) | HTTP listen port || `HEARTWOOD_ROOT` | no (default `.`) | Project root (where `templates/` and `dist/` live) || `HEARTWOOD_REPO_ROOT` | no (default `/srv/git`) | Directory of `<name>.git/` bare repos || `HEARTWOOD_CLONE_BASE` | no | Public origin used in clone URLs and atom self-links || `HEARTWOOD_TITLE` | no (default `heartwood`) | Topbar title || `HEARTWOOD_TAGLINE` | no (default `every commit a ring`) | Topbar tagline || `BASE_URL` | no | `<base href>` if served on a subpath |In dev, `make run` sets `HEARTWOOD_REPO_ROOT` to `./fixtures/git` so you don'tneed a real `/srv/git/`.## Make targets| Target | What it does ||---|---|| `make run` (default) | Vite watch + `cargo run` on port 8000 || `make build` | Vite assets + release binary (`target/release/heartwood`) || `make start` | Run the release binary (after `make build`) || `make seed` | Synthesize fake bare repos under `fixtures/git/` (idempotent, opt-in) || `make seed-reset` | Wipe and re-synthesize `fixtures/git/` || `make push` | `git push` to every configured remote || `make clean` | Remove `target/`, `dist/`, and `frontend/node_modules/` (leaves fixtures) |`seed` / `seed-reset` accept `COUNT=`, `DAYS=`, and `SEED=` overrides (seeRunning locally).There are no tests or linters configured.## Key Routes- `/`: repo list (auto-discovered from `HEARTWOOD_REPO_ROOT`, sorted by most-recent HEAD)- `/<name>`: repo landing (README, recent commits, clone URL)- `/<name>/log`: commit log (default branch unless `?rev=` given, `?limit=` up to 500)- `/<name>/commit/<sha>`: single commit + unified diff- `/<name>/tree/<rev>[/<path>]`: file browser- `/<name>/blob/<rev>/<path>`: file view with syntax highlighting- `/<name>/raw/<rev>/<path>`: raw blob bytes- `/<name>/atom.xml`: atom feed of recent commits- `/<name>.git/info/refs` and `/<name>.git/git-upload-pack`: smart HTTP clone- `/static/*`: Vite assets (1y cache header)## Production deploySame `git push server master` flow used by the rest of my projects:Server:    apk update && apk upgrade && apk add docker docker-compose caddy git iptables ip6tables ufw    ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable    rc-update add docker boot && service docker start    mkdir -p /srv/git/heartwood.git && cd /srv/git/heartwood.git && git init --bareLocal:    git remote add server root@heartwood.example.com:/srv/git/heartwood.git    git push --set-upstream server masterServer:    mkdir -p /srv/docker && cd /srv/docker && git clone /srv/git/heartwood.git heartwood && cd /srv/docker/heartwood    cp samplefiles/Caddyfile.sample /etc/caddy/Caddyfile    cp samplefiles/env.sample .env  # edit HEARTWOOD_CLONE_BASE and HEARTWOOD_TITLE/TAGLINE    cp samplefiles/post-receive.sample /srv/git/heartwood.git/hooks/post-receive && chmod +x /srv/git/heartwood.git/hooks/post-receive    docker-compose up --build --detach    rc-update add caddy boot && service caddy startThe host's `/srv/git/` is bind-mounted into the container read-only so theweb process can't damage a bare repo, even accidentally. Pushes still arrivethe usual way: SSH to the bare repo, post-receive hook runs the deploy.## BackupsAll your code already lives in `/srv/git/*.git/`; back that up and you have acomplete backup. There is no application database to preserve.## SupportI won't be providing user support for this project. I'm happy to accept goodpull requests and fix bugs but I don't have time to help people run or usethis project.
added docker-compose.yml
@@ -0,0 +1,14 @@services:  web:    container_name: heartwood    build: .    init: true    env_file: .env    environment:      HEARTWOOD_REPO_ROOT: /srv/git    volumes:      # /srv/git lives on the host (the bare repos the deploy hooks push to).      # Mount read-only so the web process can't corrupt a bare repo, even      # accidentally.      - /srv/git:/srv/git:ro    restart: unless-stopped
added frontend/bun.lock
@@ -0,0 +1,188 @@{  "lockfileVersion": 1,  "configVersion": 1,  "workspaces": {    "": {      "dependencies": {        "@fontsource/jetbrains-mono": "^5.2.5",      },      "devDependencies": {        "sass": "^1.97.3",        "vite": "^6.3.1",      },    },  },  "packages": {    "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],    "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],    "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],    "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],    "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],    "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],    "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],    "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],    "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],    "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],    "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],    "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],    "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],    "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],    "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],    "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],    "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],    "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],    "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],    "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],    "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],    "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],    "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],    "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],    "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],    "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],    "@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.2.8", "", {}, "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ=="],    "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],    "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],    "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],    "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],    "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],    "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],    "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],    "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],    "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],    "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],    "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],    "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],    "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],    "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],    "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="],    "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="],    "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="],    "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="],    "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="],    "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="],    "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="],    "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="],    "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="],    "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="],    "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="],    "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="],    "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="],    "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="],    "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="],    "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="],    "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="],    "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="],    "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="],    "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="],    "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="],    "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="],    "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="],    "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="],    "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],    "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],    "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],    "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],    "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],    "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],    "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],    "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],    "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],    "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],    "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],    "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],    "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],    "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],    "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],    "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],    "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],    "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="],    "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],    "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],    "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],  }}
added frontend/package.json
@@ -0,0 +1,15 @@{  "private": true,  "type": "module",  "scripts": {    "dev": "vite build --watch",    "build": "vite build"  },  "dependencies": {    "@fontsource/jetbrains-mono": "^5.2.5"  },  "devDependencies": {    "sass": "^1.97.3",    "vite": "^6.3.1"  }}
added frontend/static_src/index.js
@@ -0,0 +1,8 @@import "@fontsource/jetbrains-mono/400.css";import "@fontsource/jetbrains-mono/600.css";import "./styles/base.scss";document.querySelectorAll(".clone-box input").forEach((el) => {  el.addEventListener("focus", () => el.select());  el.addEventListener("click", () => el.select());});
added frontend/static_src/public/favicon.svg
@@ -0,0 +1,7 @@<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">  <rect width="64" height="64" rx="8" fill="#1a1410"/>  <circle cx="32" cy="32" r="24" fill="none" stroke="#6b9e78" stroke-width="2"/>  <circle cx="32" cy="32" r="17" fill="none" stroke="#8aae8e" stroke-width="1.5"/>  <circle cx="32" cy="32" r="11" fill="none" stroke="#c4a574" stroke-width="1.5"/>  <circle cx="32" cy="32" r="6"  fill="#b8543a"/></svg>
added frontend/static_src/styles/base.scss
@@ -0,0 +1,745 @@// heartwood — palette evokes the dense red-brown center of a tree.:root {  --bg:        #1a1410;  --surface:   #221a14;  --surface-2: #2a2018;  --border:    #3d2f24;  --text:      #e8dccc;  --text-mute: #9c8a72;  --accent:    #b8543a; // heartwood red  --accent-2:  #c4a574; // sap gold  --green:     #6b9e78;  --code-bg:   #15100c;  --add-bg:    #1f2e1f;  --add-fg:    #95c895;  --del-bg:    #2e1f1f;  --del-fg:    #d08585;  --hunk-fg:   #8aa9c4;  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;  --prose: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;}* { box-sizing: border-box; }html, body {  margin: 0;  padding: 0;  background: var(--bg);  color: var(--text);  font-family: var(--prose);  font-size: 15px;  line-height: 1.55;}// Sticky footer: body fills viewport, main takes the slack so the// footer-bar pins to the bottom on short pages (empty repo list, 404).body {  min-height: 100vh;  display: flex;  flex-direction: column;}main { flex: 1 0 auto; }a {  color: var(--accent-2);  text-decoration: none;  &:hover { text-decoration: underline; }}code, pre, .sha, .clone-box input {  font-family: var(--mono);}.container {  max-width: 1100px;  margin: 0 auto;  padding: 0 1.25rem;}// --- topbar ---.topbar {  position: relative;  background: linear-gradient(180deg, var(--surface-2) 0%, var(--surface) 100%);  border-bottom: 1px solid var(--border);  padding: 1.15rem 0;  // The outer accent stripe is a horizontal cross-section of the brand  // mark: heartwood-red core, sap-gold middle ring, green outer ring.  // Reads as a tree-ring slice across the top of every page.  &::before {    content: "";    position: absolute;    inset: 0 0 auto 0;    height: 2px;    background: linear-gradient(      90deg,      var(--accent) 0%,      var(--accent-2) 50%,      var(--green) 100%    );    opacity: 0.75;  }  &__row {    display: flex;    align-items: center;    gap: 1rem;    flex-wrap: wrap;  }  // 1px vertical hairline between brand and tagline, like a path-component  // separator. Hidden on narrow screens where the tagline drops out anyway.  &__divider {    width: 1px;    height: 1.4rem;    background: var(--border);    @media (max-width: 540px) { display: none; }  }}.brand {  display: inline-flex;  align-items: center;  gap: 0.7rem;  color: var(--text);  font-family: var(--mono);  font-weight: 600;  font-size: 1.15rem;  &:hover {    text-decoration: none;    color: var(--accent-2);  }}.brand-mark {  width: 32px;  height: 32px;  // Each ring gets a slow color tween so hovering the brand feels like the  // wood warming under a thumb. Strokes shift one step inward toward red.  circle { transition: stroke 350ms ease, fill 350ms ease; }}.brand:hover .brand-mark {  .brand-mark__r1   { stroke: var(--accent-2); }  .brand-mark__r2   { stroke: var(--accent-2); }  .brand-mark__r3   { stroke: var(--accent);  }  .brand-mark__core { fill:   var(--accent-2); }}.brand-text { letter-spacing: 0.03em; }.tagline {  color: var(--text-mute);  font-family: var(--mono);  font-style: italic;  font-size: 0.85rem;  letter-spacing: 0.02em;  @media (max-width: 540px) { display: none; }}.topnav {  margin-left: auto;  display: inline-flex;  align-items: center;  gap: 0.25rem;  &__link {    display: inline-flex;    align-items: center;    gap: 0.5rem;    color: var(--text-mute);    font-family: var(--mono);    font-size: 0.85rem;    text-transform: lowercase;    letter-spacing: 0.04em;    padding: 0.35rem 0.75rem;    border: 1px solid transparent;    border-radius: 3px;    transition: color 150ms ease, border-color 150ms ease, background 150ms ease;    &:hover {      color: var(--text);      border-color: var(--border);      background: var(--surface-2);      text-decoration: none;    }    // Current-page state: a subtle outlined "tab" with the accent dot lit.    &.is-active {      color: var(--text);      border-color: var(--border);      background: var(--surface-2);      .topnav__dot {        background: var(--accent);        box-shadow: 0 0 0 2px rgba(184, 84, 58, 0.15);      }    }  }  &__dot {    width: 6px;    height: 6px;    border-radius: 50%;    background: var(--text-mute);    opacity: 0.6;  }}// --- breadcrumbs ---.breadcrumbs {  background: var(--surface);  border-bottom: 1px solid var(--border);  font-family: var(--mono);  font-size: 0.9rem;  color: var(--text-mute);  padding: 0.85rem 0;  strong { color: var(--text); font-weight: 600; }  // Explicit separator span so we don't depend on whitespace stripping in  // the templates. Each path component is its own anchor; the slashes are  // visual-only and get a touch of horizontal margin.  &__sep {    margin: 0 0.35rem;    color: var(--text-mute);    opacity: 0.6;  }  &__ref {    margin-left: 0.75rem;    color: var(--accent-2);  }}main { padding: 3rem 0 4rem; }// --- hero / index ---.hero {  margin-bottom: 2.5rem;  h1 {    font-family: var(--mono);    font-size: 2rem;    margin: 0 0 0.5rem;    letter-spacing: -0.01em;  }  .lede { color: var(--text-mute); margin: 0; }}.empty {  color: var(--text-mute);  font-style: italic;}.repo-list {  list-style: none;  padding: 0;  margin: 0;  display: grid;  gap: 1px;  background: var(--border);  border: 1px solid var(--border);  border-radius: 4px;  overflow: hidden;}.repo-row {  background: var(--surface);  padding: 1rem 1.25rem;  &:hover { background: var(--surface-2); }  &__head {    display: flex;    justify-content: space-between;    align-items: baseline;    gap: 1rem;  }  &__name {    font-family: var(--mono);    font-weight: 600;    font-size: 1.1rem;    color: var(--accent-2);  }  &__age {    color: var(--text-mute);    font-size: 0.85rem;    white-space: nowrap;  }  &__desc {    margin: 0.35rem 0 0;    color: var(--text);  }  &__last {    margin: 0.35rem 0 0;    color: var(--text-mute);    font-size: 0.88rem;  }  &__last-sha {    font-family: var(--mono);    color: var(--accent);    margin-right: 0.4rem;  }}// --- repo head ---.repo-head {  margin-bottom: 2rem;  &__title-row {    display: flex;    align-items: baseline;    gap: 0.75rem;    flex-wrap: wrap;    h1 {      font-family: var(--mono);      font-size: 1.75rem;      margin: 0;    }  }  &__branch {    background: var(--surface-2);    color: var(--accent-2);    border: 1px solid var(--border);    padding: 0.1rem 0.5rem;    border-radius: 3px;    font-family: var(--mono);    font-size: 0.85rem;  }  &__desc {    color: var(--text-mute);    margin: 0.5rem 0 1rem;  }}.clone-box {  display: flex;  align-items: center;  gap: 0.75rem;  background: var(--surface);  border: 1px solid var(--border);  border-radius: 4px;  padding: 0.5rem 0.75rem;  margin-bottom: 1rem;  max-width: 640px;  label {    color: var(--text-mute);    font-size: 0.85rem;    text-transform: uppercase;    letter-spacing: 0.05em;  }  input {    flex: 1;    background: transparent;    border: 0;    color: var(--text);    font-size: 0.9rem;    outline: none;    padding: 0.25rem 0;  }}.repo-nav {  display: flex;  gap: 1.25rem;  border-bottom: 1px solid var(--border);  padding-bottom: 0.5rem;  font-family: var(--mono);  font-size: 0.92rem;  a {    color: var(--text-mute);    &:hover { color: var(--accent-2); text-decoration: none; }  }}// --- two column layout (overview) ---.two-col {  display: grid;  // `minmax(0, ...)` is load-bearing: CSS grid items default to min-width:  // auto, so an unwrappable child (e.g. a wide <pre> inside the README)  // would force the main column wider than its 2fr share and crush the  // sidebar. Capping the min at 0 makes the columns respect the fr ratio  // regardless of content width; overflow scrolls within the child.  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);  gap: 2rem;  @media (max-width: 760px) {    grid-template-columns: minmax(0, 1fr);  }}.col-main { min-width: 0; }.col-side { min-width: 0; }.side-h {  font-family: var(--mono);  font-size: 0.85rem;  text-transform: uppercase;  letter-spacing: 0.06em;  color: var(--text-mute);  margin: 0 0 0.5rem;}.commit-list {  list-style: none;  margin: 0;  padding: 0;  li {    border-bottom: 1px dashed var(--border);    padding: 0.55rem 0;    font-size: 0.92rem;  }  .sha {    color: var(--accent);    margin-right: 0.4rem;  }  .summary { display: block; color: var(--text); }  .meta { color: var(--text-mute); font-size: 0.82rem; }}// --- readme ---.readme {  background: var(--surface);  border: 1px solid var(--border);  border-radius: 4px;  padding: 1.5rem 2rem;  h1, h2, h3, h4 {    font-family: var(--mono);    letter-spacing: -0.01em;  }  h1 { font-size: 1.7rem; margin-top: 0; }  h2 { font-size: 1.35rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }  h3 { font-size: 1.1rem; }  p, ul, ol { line-height: 1.65; }  code { background: var(--code-bg); padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.92em; }  pre {    background: var(--code-bg);    padding: 1rem;    border-radius: 4px;    overflow-x: auto;    code { background: transparent; padding: 0; }  }  blockquote {    border-left: 3px solid var(--accent);    padding-left: 1rem;    color: var(--text-mute);    margin: 1rem 0;  }  table {    border-collapse: collapse;    margin: 1rem 0;    th, td {      border: 1px solid var(--border);      padding: 0.35rem 0.75rem;    }    th { background: var(--surface-2); }  }  hr { border: 0; border-top: 1px solid var(--border); }}// --- log ---.log-list {  list-style: none;  padding: 0;  margin: 0;}.log-row {  display: flex;  gap: 1rem;  padding: 0.75rem 0;  border-bottom: 1px solid var(--border);  .sha { color: var(--accent); flex-shrink: 0; }  .summary { margin: 0; }  .meta { margin: 0; color: var(--text-mute); font-size: 0.85rem; }}// --- tree ---.tree {  width: 100%;  border-collapse: collapse;  font-family: var(--mono);  font-size: 0.92rem;  tr { border-bottom: 1px solid var(--border); }  td { padding: 0.5rem 0.75rem; }  &__name { width: 100%; }  &__name--tree a::before { content: ""; color: var(--accent-2); }  &__name--blob a::before { content: ""; color: var(--text-mute); }  &__size { color: var(--text-mute); text-align: right; white-space: nowrap; }}// --- blob view ---.blob-head {  display: flex;  justify-content: space-between;  align-items: center;  background: var(--surface);  border: 1px solid var(--border);  border-bottom: 0;  padding: 0.5rem 0.75rem;  border-radius: 4px 4px 0 0;  font-size: 0.85rem;  color: var(--text-mute);  font-family: var(--mono);}.blob {  border: 1px solid var(--border);  border-radius: 0 0 4px 4px;  overflow-x: auto;  background: var(--code-bg);  .hl {    margin: 0;    padding: 0.75rem 0;    font-family: var(--mono);    font-size: 0.85rem;    line-height: 1.55;  }  .ln {    display: inline-block;    width: 4ch;    padding-right: 1rem;    text-align: right;    color: var(--text-mute);    user-select: none;    &::before { content: attr(data-n); }  }  .l { display: inline; }}// --- diff ---.commit-head {  margin-bottom: 1.5rem;  h1 {    font-family: var(--mono);    font-size: 1.35rem;    margin: 0 0 0.5rem;  }  .sha { color: var(--accent); }  .meta { color: var(--text-mute); margin: 0 0 1rem; }}.commit-msg {  background: var(--surface);  border: 1px solid var(--border);  padding: 0.75rem 1rem;  border-radius: 4px;  font-size: 0.9rem;  white-space: pre-wrap;  margin: 0;}.file-diff {  margin-top: 1.5rem;  border: 1px solid var(--border);  border-radius: 4px;  overflow: hidden;  &__head {    background: var(--surface);    padding: 0.5rem 0.75rem;    display: flex;    align-items: center;    gap: 0.75rem;    font-family: var(--mono);    font-size: 0.88rem;  }}.status {  font-family: var(--mono);  font-size: 0.75rem;  text-transform: uppercase;  padding: 0.05rem 0.45rem;  border-radius: 3px;  letter-spacing: 0.05em;  &--added { background: rgba(149, 200, 149, 0.15); color: var(--add-fg); }  &--deleted { background: rgba(208, 133, 133, 0.15); color: var(--del-fg); }  &--modified { background: var(--surface-2); color: var(--text-mute); }  &--renamed { background: rgba(196, 165, 116, 0.15); color: var(--accent-2); }}.hunk {  margin: 0;  padding: 0.5rem 0;  background: var(--code-bg);  font-family: var(--mono);  font-size: 0.82rem;  line-height: 1.35;  overflow-x: auto;}.hunk-hdr {  display: block;  padding: 0.15rem 0.75rem;  color: var(--hunk-fg);  background: rgba(138, 169, 196, 0.08);}.line {  display: block;  padding: 0 0.75rem;  white-space: pre;  &--add { background: var(--add-bg); color: var(--add-fg);    &::before { content: "+"; display: inline-block; width: 1.2ch; color: var(--add-fg); opacity: 0.6; }  }  &--del { background: var(--del-bg); color: var(--del-fg);    &::before { content: "-"; display: inline-block; width: 1.2ch; color: var(--del-fg); opacity: 0.6; }  }  &--ctx { color: var(--text);    &::before { content: " "; display: inline-block; width: 1.2ch; }  }}// --- footer ---.footer {  position: relative;  margin-top: 4rem;  padding: 3.5rem 0 3rem;  border-top: 1px solid var(--border);  background: linear-gradient(180deg, var(--surface) 0%, var(--bg) 100%);  overflow: hidden;  &__grid {    display: grid;    gap: 2.5rem;    grid-template-columns: minmax(0, 2.4fr) minmax(0, 1fr) minmax(0, 1fr);    position: relative;    z-index: 1;    @media (max-width: 760px) {      grid-template-columns: minmax(0, 1fr);      gap: 2rem;    }  }  &__label {    font-family: var(--mono);    font-size: 0.75rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: var(--accent-2);    opacity: 0.7;    margin-bottom: 0.9rem;  }  &__about p {    color: var(--text-mute);    font-size: 0.9rem;    line-height: 1.7;    margin: 0 0 0.85rem;    max-width: 56ch;    a {      color: var(--text);      text-decoration: underline;      text-underline-offset: 2px;      text-decoration-color: var(--border);      &:hover { color: var(--accent-2); text-decoration-color: var(--accent-2); }    }  }  &__tagline {    font-style: italic;    color: var(--text-mute);    opacity: 0.75;  }  &__col {    ul {      list-style: none;      margin: 0;      padding: 0;    }    li { margin-bottom: 0.55rem; }    a {      color: var(--text-mute);      font-size: 0.9rem;      transition: color 150ms ease;      &:hover { color: var(--accent-2); text-decoration: none; }    }  }  // Tree-rings flourish, echoing the brand mark. Anchored bottom-right,  // bled off the edge so only an arc is visible. `currentColor` so the  // hue follows the footer text color and the opacity below sets it.  &-rings {    position: absolute;    right: -120px;    bottom: -120px;    width: 460px;    height: 460px;    color: var(--accent);    opacity: 0.07;    pointer-events: none;    z-index: 0;  }}// Slim copyright bar, like blog/status/analytics..footer-bar {  background: var(--code-bg);  border-top: 1px solid var(--border);  padding: 0.9rem 0;  color: var(--text-mute);  font-size: 0.78rem;  &__row {    display: flex;    align-items: center;    justify-content: space-between;    gap: 1rem;    @media (max-width: 480px) {      flex-direction: column;      gap: 0.6rem;      text-align: center;    }  }  &__link {    display: inline-flex;    color: var(--text-mute);    transition: color 150ms ease;    &:hover { color: var(--accent-2); }  }}
added frontend/vite.config.js
@@ -0,0 +1,31 @@import { resolve } from "path";import { defineConfig } from "vite";// Vite output goes to ../dist; Rust serves it at /static/.// Single entry point — heartwood has no per-page scripts to split.export default defineConfig({  base: "/static/",  publicDir: resolve(__dirname, "static_src/public"),  build: {    outDir: resolve(__dirname, "../dist"),    emptyOutDir: true,    manifest: true,    rollupOptions: {      input: {        index: resolve(__dirname, "static_src/index.js"),      },      output: {        entryFileNames: "assets/[name]-[hash].js",        chunkFileNames: "assets/[name]-[hash].js",        assetFileNames: "assets/[name]-[hash][extname]",      },    },  },  css: {    preprocessorOptions: {      scss: {        quietDeps: true,      },    },  },});
added samplefiles/Caddyfile.sample
@@ -0,0 +1,5 @@heartwood.bythewood.me {  reverse_proxy heartwood:8000  import common}
added samplefiles/env.sample
@@ -0,0 +1,18 @@# Production .env for heartwood (drop in next to docker-compose.yml).# All values shown are defaults; the only one you'll normally want to# override is BASE_URL when running behind a reverse proxy on a custom path.# HTTP port the binary listens on inside the container (Caddy reaches us# by name on the bythewood-edge network).PORT=8000# Where the bare repos live on the host. Mounted into the container in# docker-compose.yml as a read-only bind.HEARTWOOD_REPO_ROOT=/srv/git# Public origin used for clone URLs and atom feed self-links.HEARTWOOD_CLONE_BASE=https://heartwood.bythewood.me# Optional cosmetics.HEARTWOOD_TITLE=heartwoodHEARTWOOD_TAGLINE=every commit a ring
added samplefiles/post-receive.sample
@@ -0,0 +1,27 @@#!/bin/sh## post-receive hook generated for heartwood by taproot's quickstart.sh.# Lives on the alpine server at /srv/git/heartwood.git/hooks/post-receive.## Identical to every other project's hook: pull, rebuild, reattach to the# shared bythewood-edge network so Caddy can find us.while read oldrev newrev ref; do  if [ "$ref" = "refs/heads/master" ]; then    unset GIT_DIR    START_TIME=$(date +%s)    cd /srv/docker/heartwood    git pull    docker compose up --build --detach    # Reattach every container in the project to the shared edge network.    # Resolved via `compose ps` (not `heartwood`) so this works regardless    # of the compose service name or container_name override.    for cid in $(docker compose ps -q); do      docker network connect bythewood-edge "$cid" 2>/dev/null || true    done    docker container prune --force    docker image prune --force    END_TIME=$(date +%s)    echo "Total build time: $((END_TIME - START_TIME))s"  fidone
added src/app.rs
@@ -0,0 +1,94 @@use axum::{http::header, middleware as axum_middleware, Router};use minijinja::Environment;use std::path::PathBuf;use std::sync::Arc;use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use crate::middleware::log_requests;use crate::routes;use crate::templates;#[derive(Clone)]pub struct AppState {    pub env: Arc<Environment<'static>>,    pub config: Arc<Config>,}#[derive(Debug, Clone)]pub struct Config {    pub root: PathBuf,    pub repo_root: PathBuf,    pub base_url: String,    pub clone_base: String,    pub site_title: String,    pub site_tagline: String,}impl AppState {    pub fn from_env() -> Self {        let root: PathBuf = std::env::var("HEARTWOOD_ROOT")            .map(PathBuf::from)            .unwrap_or_else(|_| PathBuf::from("."));        let repo_root: PathBuf = std::env::var("HEARTWOOD_REPO_ROOT")            .map(PathBuf::from)            .unwrap_or_else(|_| PathBuf::from("/srv/git"));        let base_url = std::env::var("BASE_URL").unwrap_or_default();        let clone_base = std::env::var("HEARTWOOD_CLONE_BASE")            .unwrap_or_else(|_| "https://heartwood.bythewood.me".to_string());        let site_title =            std::env::var("HEARTWOOD_TITLE").unwrap_or_else(|_| "heartwood".to_string());        let site_tagline = std::env::var("HEARTWOOD_TAGLINE")            .unwrap_or_else(|_| "every commit a ring".to_string());        let templates_dir = root.join("templates");        let manifest_path = root.join("dist/.vite/manifest.json");        let env = Arc::new(templates::build_env(&templates_dir, &manifest_path));        let config = Arc::new(Config {            root,            repo_root,            base_url,            clone_base,            site_title,            site_tagline,        });        Self { env, config }    }}pub fn router(state: AppState) -> Router {    let dist_dir = state.config.root.join("dist");    let static_cache = SetResponseHeaderLayer::if_not_present(        header::CACHE_CONTROL,        header::HeaderValue::from_static("public, max-age=31536000, immutable"),    );    Router::new()        // Smart-HTTP clone endpoints live at /:name.git/... and need to be        // matched before the catch-all repo routes; merge clone first.        .merge(routes::clone::router())        // seo routes (favicon, robots) before the repo catch-all so        // /favicon.ico doesn't hit /:name.        .merge(routes::seo::router())        .merge(routes::index::router())        .merge(routes::atom::router())        .merge(routes::log::router())        .merge(routes::commit::router())        .merge(routes::tree::router())        .merge(routes::blob::router())        // repo landing page is the most general single-segment route, so        // merge it last to avoid swallowing /static, /favicon.ico, etc.        .merge(routes::repo::router())        .nest_service(            "/static",            tower::ServiceBuilder::new()                .layer(static_cache)                .service(ServeDir::new(&dist_dir)),        )        .fallback(crate::middleware::not_found)        .layer(axum_middleware::from_fn(log_requests))        .with_state(state)}
added src/bin/seed.rs
@@ -0,0 +1,1475 @@//! Generate fake-but-realistic bare git repos under `fixtures/git/` so the//! heartwood landing page has something to render in dev. Each repo gets a//! month of commit history with multiple authors, a per-archetype file shape//! (Rust crate, TS lib, Python package, markdown blog, dotfiles), and commit//! messages drawn from a per-archetype corpus.//!//! Deterministic: pass `--seed N` to reproduce the same set of repos. Default//! seed is fine; the same invocation always yields the same fixtures.//!//! Usage://!   cargo run --bin seed//!   cargo run --bin seed -- --count 12 --days 45//!   cargo run --bin seed -- --reset      # wipe fixtures/git/ firstuse std::collections::HashSet;use std::fs;use std::io::Write;use std::path::{Path, PathBuf};use std::process::Command;const DEFAULT_COUNT: usize = 8;const DEFAULT_DAYS: i64 = 30;const DEFAULT_SEED: u64 = 0xC0DE_FEED;const DEST: &str = "fixtures/git";// ---------- PRNG ------------------------------------------------------------/// Xorshift64. Deterministic, zero deps, plenty good for picking from small/// pools. Seeded from CLI so a given run is reproducible.struct Rng(u64);impl Rng {    fn new(seed: u64) -> Self {        Self(if seed == 0 { 0xCAFE_BABE } else { seed })    }    fn next_u64(&mut self) -> u64 {        let mut x = self.0;        x ^= x << 13;        x ^= x >> 7;        x ^= x << 17;        self.0 = x;        x    }    fn range(&mut self, max: usize) -> usize {        (self.next_u64() as usize) % max.max(1)    }    fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {        &items[self.range(items.len())]    }    /// Returns true with probability `n_in_10 / 10`.    fn chance(&mut self, n_in_10: usize) -> bool {        self.range(10) < n_in_10    }}// ---------- authors ---------------------------------------------------------struct Author {    name: &'static str,    email: &'static str,}const AUTHORS: &[Author] = &[    Author { name: "Isaac Bythewood", email: "isaac@bythewood.me" },    Author { name: "Anna Holm",       email: "anna@holm.dev" },    Author { name: "Jules Sato",      email: "jules@sato.io" },    Author { name: "Maren Akkerman",  email: "maren@akkerman.nl" },    Author { name: "Felix Ortiz",     email: "felix@ortiz.codes" },];/// 70% Isaac, 30% one of the others. Mirrors the look of a personal repo with/// occasional drive-by contributions.fn pick_author(rng: &mut Rng) -> &'static Author {    if rng.chance(7) {        &AUTHORS[0]    } else {        &AUTHORS[1 + rng.range(AUTHORS.len() - 1)]    }}// ---------- archetypes ------------------------------------------------------/// A single mutation applied per commit, paired with the commit messages/// that plausibly describe it. Bundling op + messages stops the message and/// diff from drifting apart (a commit titled "switch to thiserror" that/// actually appends "" to README is the giveaway that this is fake).struct Patch {    op: PatchOp,    messages: &'static [&'static str],}enum PatchOp {    /// Create the file with this body. If it already exists, the patch is a    /// no-op and the seeder picks a different patch.    Create { path: &'static str, body: &'static str },    /// Append a single line. If the line is already the file's last line,    /// the patch is a no-op and the seeder picks a different patch.    Append { path: &'static str, line: &'static str },}struct Archetype {    kind: &'static str,    description: &'static str,    names: &'static [&'static str],    initial: &'static [(&'static str, &'static str)],    patches: &'static [Patch],}// --- rust crate ---const RUST_LIB_RS: &str = "//! {name}: a small Rust library.\n\\n\pub fn version() -> &'static str {\n\    env!(\"CARGO_PKG_VERSION\")\n\}\n\\n\#[cfg(test)]\n\mod tests {\n\    use super::*;\n\\n\    #[test]\n\    fn version_is_set() {\n\        assert!(!version().is_empty());\n\    }\n\}\n";const RUST_CARGO_TOML: &str = "[package]\n\name = \"{name}\"\n\version = \"0.1.0\"\n\edition = \"2021\"\n\\n\[dependencies]\n";const RUST_README: &str = "# {name}\n\\n\A small Rust crate that does one thing well.\n\\n\The API surface is intentionally narrow: a handful of types and free\n\functions, no async runtime baked in.\n\\n\## Quick example\n\\n\    use {snake}::version;\n\\n\    println!(\"{}\", version());\n\\n\## License\n\\n\BSD-2-Clause.\n";const RUST_GITIGNORE: &str = "/target\nCargo.lock\n";const RUST_PARSE_RS: &str = "//! Tiny hand-written parser. Greedy, not particularly fast, but the\n\//! tokenizer is straightforward enough to step through in a debugger.\n\\n\pub fn parse(input: &str) -> Result<Vec<String>, ParseError> {\n\    let mut out = Vec::new();\n\    for token in input.split_whitespace() {\n\        if token.is_empty() {\n\            return Err(ParseError::Empty);\n\        }\n\        out.push(token.to_string());\n\    }\n\    Ok(out)\n\}\n\\n\#[derive(Debug)]\n\pub enum ParseError {\n\    Empty,\n\    Unterminated,\n\}\n";const RUST_ERROR_RS: &str = "use std::fmt;\n\\n\#[derive(Debug)]\n\pub enum Error {\n\    Io(std::io::Error),\n\    Parse(crate::parse::ParseError),\n\}\n\\n\impl fmt::Display for Error {\n\    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n\        match self {\n\            Error::Io(e) => write!(f, \"io: {}\", e),\n\            Error::Parse(e) => write!(f, \"parse: {:?}\", e),\n\        }\n\    }\n\}\n\\n\impl std::error::Error for Error {}\n";const RUST_UTIL_RS: &str = "/// Pad `s` on the right with spaces up to `width`.\n\pub fn pad_right(s: &str, width: usize) -> String {\n\    if s.len() >= width { s.to_string() } else { format!(\"{:<w$}\", s, w = width) }\n\}\n";const RUST_BENCH_RS: &str = "#![feature(test)]\n\extern crate test;\n\\n\use test::Bencher;\n\use {name}::parse::parse;\n\\n\#[bench]\n\fn parse_short(b: &mut Bencher) {\n\    b.iter(|| parse(\"one two three four\"));\n\}\n";const RUST_ARCHETYPE: Archetype = Archetype {    kind: "rust-crate",    description: "a small rust library",    names: &[        "roman-runes",        "axum-knife",        "beam-walker",        "sextant",        "basalt",        "copper-net",        "runesmith",        "oxide-quill",        "ferroquill",    ],    initial: &[        ("Cargo.toml", RUST_CARGO_TOML),        ("src/lib.rs", RUST_LIB_RS),        ("README.md", RUST_README),        (".gitignore", RUST_GITIGNORE),    ],    patches: &[        Patch {            op: PatchOp::Create { path: "src/parse.rs", body: RUST_PARSE_RS },            messages: &[                "split parse into its own module",                "first cut of the tokenizer",                "carve parse out of lib.rs",            ],        },        Patch {            op: PatchOp::Create { path: "src/error.rs", body: RUST_ERROR_RS },            messages: &[                "tighten error type",                "introduce Error enum",                "give errors a Display impl",            ],        },        Patch {            op: PatchOp::Create { path: "src/util.rs", body: RUST_UTIL_RS },            messages: &[                "add pad_right util",                "extract padding helper",                "pull util out of lib.rs",            ],        },        Patch {            op: PatchOp::Create { path: "benches/parse.rs", body: RUST_BENCH_RS },            messages: &["add bench skeleton", "bench parse on a short input"],        },        Patch {            op: PatchOp::Append { path: "src/lib.rs", line: "pub mod parse;" },            messages: &["wire parse into lib.rs", "expose parse module"],        },        Patch {            op: PatchOp::Append { path: "src/lib.rs", line: "pub mod error;" },            messages: &["expose error module", "wire error into lib.rs"],        },        Patch {            op: PatchOp::Append { path: "src/lib.rs", line: "pub mod util;" },            messages: &["expose util module", "promote util to pub"],        },        Patch {            op: PatchOp::Append { path: "Cargo.toml", line: "thiserror = \"1\"" },            messages: &["switch to thiserror for Error", "add thiserror dep"],        },        Patch {            op: PatchOp::Append { path: "Cargo.toml", line: "anyhow = \"1\"" },            messages: &["pull anyhow for the examples", "add anyhow dep"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "Tested against rust 1.75 and current stable.",            },            messages: &["note MSRV in the readme", "mention tested rust versions"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: "*.rs.bk" },            messages: &["ignore rustfmt backup files"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: "perf.data*" },            messages: &["ignore perf data files"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: "/criterion" },            messages: &["ignore criterion output dir"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: ".envrc" },            messages: &["ignore .envrc (direnv)"],        },        Patch {            op: PatchOp::Append { path: "Cargo.toml", line: "serde = { version = \"1\", features = [\"derive\"] }" },            messages: &["pull serde for the public types"],        },        Patch {            op: PatchOp::Append { path: "Cargo.toml", line: "log = \"0.4\"" },            messages: &["add a log facade"],        },        Patch {            op: PatchOp::Append { path: "Cargo.toml", line: "regex = \"1\"" },            messages: &["lean on regex for the trickier patterns"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "MSRV: rust 1.75. Older toolchains may compile but aren't tested.",            },            messages: &["pin MSRV in the readme"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "Issues and patches welcome. The repo lives at git.bythewood.me/{name}.",            },            messages: &["link the source in the readme"],        },        Patch {            op: PatchOp::Append { path: "src/lib.rs", line: "pub use parse::parse;" },            messages: &["re-export parse() at the crate root"],        },        Patch {            op: PatchOp::Create {                path: "rustfmt.toml",                body: "edition = \"2021\"\nmax_width = 100\nuse_field_init_shorthand = true\n",            },            messages: &["pin rustfmt settings"],        },        Patch {            op: PatchOp::Create {                path: "CHANGELOG.md",                body: "# Changelog\n\n## Unreleased\n\n- Initial slice.\n",            },            messages: &["start a changelog"],        },        Patch {            op: PatchOp::Create {                path: ".cargo/config.toml",                body: "[build]\nrustflags = [\"-D\", \"warnings\"]\n",            },            messages: &["fail the build on warnings"],        },        Patch {            op: PatchOp::Create {                path: "examples/parse_one.rs",                body: "use {snake}::parse::parse;\n\nfn main() {\n    println!(\"{:?}\", parse(\"one two three\"));\n}\n",            },            messages: &["add a parse_one example"],        },        Patch {            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Tighten error type." },            messages: &["changelog: error tightening"],        },        Patch {            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Promote util to pub." },            messages: &["changelog: util pub"],        },        Patch {            op: PatchOp::Append {                path: "src/lib.rs",                line: "/// Re-exports the canonical parser. See [`parse`] for the full API.",            },            messages: &["doc-comment the parse re-export"],        },    ],};// --- typescript lib ---const TS_PKG_JSON: &str = "{\n\  \"name\": \"{name}\",\n\  \"version\": \"0.1.0\",\n\  \"type\": \"module\",\n\  \"main\": \"dist/index.js\",\n\  \"types\": \"dist/index.d.ts\",\n\  \"scripts\": {\n\    \"build\": \"tsc\",\n\    \"test\": \"bun test\"\n\  }\n\}\n";const TS_TSCONFIG: &str = "{\n\  \"compilerOptions\": {\n\    \"target\": \"ES2022\",\n\    \"module\": \"ESNext\",\n\    \"moduleResolution\": \"bundler\",\n\    \"declaration\": true,\n\    \"outDir\": \"dist\",\n\    \"strict\": true\n\  },\n\  \"include\": [\"src\"]\n\}\n";const TS_INDEX: &str = "export interface Options {\n\  width?: number;\n\  prefix?: string;\n\}\n\\n\export function pad(s: string, opts: Options = {}): string {\n\  const width = opts.width ?? 8;\n\  const prefix = opts.prefix ?? \"\";\n\  if (s.length >= width) return prefix + s;\n\  return prefix + s + \" \".repeat(width - s.length);\n\}\n";const TS_README: &str = "# {name}\n\\n\Tiny TypeScript helper, zero runtime dependencies.\n\\n\## Install\n\\n\    bun add {name}\n\\n\## Use\n\\n\    import { pad } from \"{name}\";\n\\n\    pad(\"hi\", { width: 8 });\n";const TS_GITIGNORE: &str = "node_modules\ndist\nbun.lock\n";const TS_STRINGS_TS: &str = "export function capitalize(s: string): string {\n\  if (!s) return s;\n\  return s[0].toUpperCase() + s.slice(1);\n\}\n\\n\export function kebab(s: string): string {\n\  return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`).replace(/^-/, \"\");\n\}\n";const TS_TEST: &str = "import { describe, it, expect } from \"bun:test\";\n\import { pad } from \"./index\";\n\\n\describe(\"pad\", () => {\n\  it(\"pads short strings to width\", () => {\n\    expect(pad(\"hi\", { width: 5 })).toBe(\"hi   \");\n\  });\n\});\n";const TS_ARCHETYPE: Archetype = Archetype {    kind: "ts-lib",    description: "a tiny typescript helper",    names: &["spindrift", "kelp", "sailcloth", "dunelight", "vellum", "papyrus", "marblefall"],    initial: &[        ("package.json", TS_PKG_JSON),        ("tsconfig.json", TS_TSCONFIG),        ("src/index.ts", TS_INDEX),        ("README.md", TS_README),        (".gitignore", TS_GITIGNORE),    ],    patches: &[        Patch {            op: PatchOp::Create { path: "src/strings.ts", body: TS_STRINGS_TS },            messages: &["add capitalize + kebab", "split string helpers into a module"],        },        Patch {            op: PatchOp::Create { path: "src/index.test.ts", body: TS_TEST },            messages: &["add bun:test smoke test", "tests: pad pads short strings"],        },        Patch {            op: PatchOp::Append {                path: "src/index.ts",                line: "export * from \"./strings\";",            },            messages: &["re-export strings from the entrypoint", "wire strings into the public API"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "Targets ES2022. Runs on bun, node 20+, and modern browsers.",            },            messages: &["note runtime targets in the readme"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: "*.tsbuildinfo" },            messages: &["ignore tsbuildinfo"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: ".turbo" },            messages: &["ignore turbo cache"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: "coverage" },            messages: &["ignore coverage reports"],        },        Patch {            op: PatchOp::Append {                path: "src/index.ts",                line: "export const VERSION = \"0.1.0\";",            },            messages: &["expose VERSION constant"],        },        Patch {            op: PatchOp::Append {                path: "package.json",                line: "  \"keywords\": [\"strings\", \"utility\"],",            },            messages: &["add keywords to package.json"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "Zero runtime dependencies. ESM-only.",            },            messages: &["note ESM-only in the readme"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "Source lives at git.bythewood.me/{name}.",            },            messages: &["link the source"],        },        Patch {            op: PatchOp::Create {                path: "biome.json",                body: "{\n  \"$schema\": \"https://biomejs.dev/schemas/1.9.0/schema.json\",\n  \"linter\": { \"enabled\": true },\n  \"formatter\": { \"indentStyle\": \"space\", \"indentWidth\": 2 }\n}\n",            },            messages: &["adopt biome for lint + format"],        },        Patch {            op: PatchOp::Create {                path: "CHANGELOG.md",                body: "# Changelog\n\n## Unreleased\n\n- Initial slice.\n",            },            messages: &["start a changelog"],        },        Patch {            op: PatchOp::Create {                path: "src/numbers.ts",                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",            },            messages: &["add numeric helpers"],        },        Patch {            op: PatchOp::Append {                path: "src/index.ts",                line: "export * from \"./numbers\";",            },            messages: &["re-export numeric helpers"],        },        Patch {            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Add strings module." },            messages: &["changelog: strings"],        },        Patch {            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Add numbers module." },            messages: &["changelog: numbers"],        },        Patch {            op: PatchOp::Create {                path: ".github/dependabot.yml",                body: "version: 2\nupdates:\n  - package-ecosystem: npm\n    directory: \"/\"\n    schedule:\n      interval: weekly\n",            },            messages: &["wire dependabot for npm"],        },    ],};// --- python package ---const PY_PYPROJECT: &str = "[project]\n\name = \"{name}\"\n\version = \"0.1.0\"\n\requires-python = \">=3.11\"\n\description = \"\"\n\readme = \"README.md\"\n\dependencies = []\n\\n\[build-system]\n\requires = [\"hatchling\"]\n\build-backend = \"hatchling.build\"\n";const PY_INIT: &str = "from .core import run, Result\n\\n\__all__ = [\"run\", \"Result\"]\n\__version__ = \"0.1.0\"\n";const PY_CORE: &str = "from dataclasses import dataclass\n\\n\\n\@dataclass(frozen=True, slots=True)\n\class Result:\n\    ok: bool\n\    value: str | None = None\n\\n\\n\def run(query: str) -> Result:\n\    if not query.strip():\n\        return Result(ok=False)\n\    return Result(ok=True, value=query.strip().lower())\n";const PY_README: &str = "# {name}\n\\n\A small Python package. Pure stdlib at the core; optional extras for the CLI.\n\\n\## Install\n\\n\    uv pip install {name}\n\\n\## Use\n\\n\    from {snake} import run\n\\n\    print(run(\"  hello  \").value)  # 'hello'\n";const PY_GITIGNORE: &str = "__pycache__\n.venv\ndist\n*.egg-info\n";const PY_TEST: &str = "from {snake} import run\n\\n\\n\def test_run_strips_and_lowercases():\n\    assert run(\"  Hello  \").value == \"hello\"\n\\n\\n\def test_run_rejects_blank():\n\    assert run(\"   \").ok is False\n";const PY_CLI: &str = "\"\"\"Tiny CLI for {snake}.\"\"\"\n\import sys\n\\n\from .core import run\n\\n\\n\def main() -> int:\n\    if len(sys.argv) < 2:\n\        print(\"usage: {name} <query>\", file=sys.stderr)\n\        return 2\n\    r = run(sys.argv[1])\n\    if not r.ok:\n\        print(\"no result\", file=sys.stderr)\n\        return 1\n\    print(r.value)\n\    return 0\n";const PY_ARCHETYPE: Archetype = Archetype {    kind: "python-pkg",    description: "a small python package",    names: &["cinnabar", "vermilion", "ochre", "indigo", "malachite", "citrine"],    initial: &[        ("pyproject.toml", PY_PYPROJECT),        ("src/{snake}/__init__.py", PY_INIT),        ("src/{snake}/core.py", PY_CORE),        ("README.md", PY_README),        (".gitignore", PY_GITIGNORE),    ],    patches: &[        Patch {            op: PatchOp::Create { path: "src/{snake}/cli.py", body: PY_CLI },            messages: &["add cli entry point", "scaffold {snake} cli", "wire main() for cli"],        },        Patch {            op: PatchOp::Create { path: "tests/test_core.py", body: PY_TEST },            messages: &["tests: smoke for run()", "add core tests"],        },        Patch {            op: PatchOp::Append { path: "pyproject.toml", line: "[project.scripts]" },            messages: &["reserve entry-points table"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "Type-annotated, mypy-clean on strict.",            },            messages: &["note mypy strict in the readme"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: ".mypy_cache" },            messages: &["ignore mypy cache"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: ".ruff_cache" },            messages: &["ignore ruff cache"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: ".pytest_cache" },            messages: &["ignore pytest cache"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: ".coverage" },            messages: &["ignore coverage data"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: "*.egg-info" },            messages: &["ignore egg-info"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "Tested on CPython 3.11 and 3.12.",            },            messages: &["note tested CPython versions"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "Source lives at git.bythewood.me/{name}.",            },            messages: &["link the source"],        },        Patch {            op: PatchOp::Create {                path: "src/{snake}/parse.py",                body: "from __future__ import annotations\n\n\ndef tokenize(s: str) -> list[str]:\n    return [tok for tok in s.split() if tok]\n",            },            messages: &["pull tokenize into its own module"],        },        Patch {            op: PatchOp::Create {                path: "src/{snake}/__main__.py",                body: "from .cli import main\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n",            },            messages: &["allow `python -m {snake}`"],        },        Patch {            op: PatchOp::Create {                path: "tests/conftest.py",                body: "import pytest\n\n\n@pytest.fixture\ndef sample():\n    return \"  Hello  \"\n",            },            messages: &["add conftest fixture"],        },        Patch {            op: PatchOp::Create {                path: "CHANGELOG.md",                body: "# Changelog\n\n## Unreleased\n\n- Initial slice.\n",            },            messages: &["start a changelog"],        },        Patch {            op: PatchOp::Create {                path: ".python-version",                body: "3.12\n",            },            messages: &["pin python to 3.12"],        },        Patch {            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Add cli entry point." },            messages: &["changelog: cli entry point"],        },        Patch {            op: PatchOp::Append { path: "CHANGELOG.md", line: "- Tighten Result." },            messages: &["changelog: Result tightening"],        },        Patch {            op: PatchOp::Append {                path: "pyproject.toml",                line: "{snake} = \"{snake}.cli:main\"",            },            messages: &["wire console_scripts entry"],        },    ],};// --- markdown blog ---const BLOG_README: &str = "# {name}\n\\n\Personal notes. Mostly things I want to come back to.\n\\n\Posts live in `posts/` as plain markdown, filenames prefixed with the date.\n";const BLOG_ABOUT: &str = "# About\n\\n\This is a small markdown notebook. The build script is whatever static site\n\generator I'm using this week; the source is just files.\n";const BLOG_POST_1: &str = "# A note on patience\n\\n\The fastest path is rarely the straightest, and the straightest path rarely\n\the most interesting. Some of the better turns I've taken came from sitting\n\with a problem long enough to find a third option.\n";const BLOG_POST_2: &str = "# On reading old code\n\\n\Code that has survived a few years tends to teach you something. Not the\n\\"this is how to write code\" kind of teaching, more like the geology of a\n\hillside: you can see where things shifted and roughly when.\n";const BLOG_POST_3: &str = "# Small tools, sharp edges\n\\n\The smaller the tool, the more it pays to keep the edge keen. A 200-line\n\script with crisp behavior outlasts a 2000-line system that almost works.\n";const BLOG_POST_4: &str = "# Notes from the porch\n\\n\Rain since morning. The trees off the porch are picking up a slow, weighted\n\sound that fits the kind of work I want to do today: quiet, no rush.\n";const BLOG_GITIGNORE: &str = ".cache\nbuild\n";const BLOG_ARCHETYPE: Archetype = Archetype {    kind: "blog",    description: "personal markdown notebook",    names: &["backwood-notes", "longshore", "weathersong", "dim-burrows", "ash-and-rime"],    initial: &[        ("README.md", BLOG_README),        ("about.md", BLOG_ABOUT),        (".gitignore", BLOG_GITIGNORE),        ("posts/2025-04-12-patience.md", BLOG_POST_1),    ],    patches: &[        Patch {            op: PatchOp::Create {                path: "posts/2025-04-19-old-code.md",                body: BLOG_POST_2,            },            messages: &["post: on reading old code"],        },        Patch {            op: PatchOp::Create {                path: "posts/2025-04-26-small-tools.md",                body: BLOG_POST_3,            },            messages: &["post: small tools, sharp edges"],        },        Patch {            op: PatchOp::Create {                path: "posts/2025-05-03-porch-notes.md",                body: BLOG_POST_4,            },            messages: &["post: notes from the porch"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "Built whenever I have time; please don't link-aggregate.",            },            messages: &["readme: ask not to be link-aggregated"],        },        Patch {            op: PatchOp::Append {                path: "about.md",                line: "Contact through the address on isaacbythewood.com.",            },            messages: &["about: add contact line"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: ".DS_Store" },            messages: &["stop checking in .DS_Store"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: "drafts/" },            messages: &["ignore the drafts dir"],        },        Patch {            op: PatchOp::Create {                path: "posts/2025-05-10-quiet-tools.md",                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",            },            messages: &["post: quiet tools"],        },        Patch {            op: PatchOp::Create {                path: "posts/2025-05-17-walking-distance.md",                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",            },            messages: &["post: walking distance"],        },        Patch {            op: PatchOp::Create {                path: "posts/2025-05-24-stone-light.md",                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",            },            messages: &["post: stone light"],        },        Patch {            op: PatchOp::Create {                path: "feed.xml",                body: "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n  <title>{name}</title>\n</feed>\n",            },            messages: &["scaffold atom feed"],        },        Patch {            op: PatchOp::Create {                path: "build.sh",                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",            },            messages: &["add build.sh"],        },        Patch {            op: PatchOp::Create {                path: "template.html",                body: "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><title>{{ title }}</title></head>\n<body>{{ content }}</body>\n</html>\n",            },            messages: &["add minimal page template"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "Built with a 40-line shell script. The output is plain HTML.",            },            messages: &["readme: note the tiny build"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: "_site/" },            messages: &["ignore the _site output dir"],        },        Patch {            op: PatchOp::Append { path: ".gitignore", line: "node_modules/" },            messages: &["ignore node_modules (whenever I dabble)"],        },    ],};// --- dotfiles ---const DOT_README: &str = "# {name}\n\\n\My dotfiles. Bootstrapped via `bin/install`, which symlinks `config/` into\n\`$HOME`. Tested on macOS and recent Debian.\n";const DOT_INSTALL: &str = "#!/bin/sh\n\set -eu\n\\n\HERE=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\n\for src in \"$HERE\"/config/.*; do\n\    name=$(basename \"$src\")\n\    case \"$name\" in . | .. ) continue ;; esac\n\    ln -snf \"$src\" \"$HOME/$name\"\n\done\n\echo done.\n";const DOT_ZSHRC: &str = "# zsh: small + fast. no oh-my-zsh.\n\\n\export EDITOR=nvim\n\export PAGER=less\n\\n\HISTFILE=~/.zsh_history\n\HISTSIZE=10000\n\SAVEHIST=10000\n\\n\setopt SHARE_HISTORY HIST_IGNORE_DUPS INC_APPEND_HISTORY\n\\n\alias ll='ls -lah'\n\alias gs='git status'\n\alias gd='git diff'\n";const DOT_TMUX: &str = "# tmux: prefix on ctrl-a, vim keys, no mouse\n\\n\unbind C-b\n\set -g prefix C-a\n\bind C-a send-prefix\n\\n\set -g default-terminal \"tmux-256color\"\n\set -g escape-time 10\n\\n\bind h select-pane -L\n\bind j select-pane -D\n\bind k select-pane -U\n\bind l select-pane -R\n";const DOT_NVIM: &str = "-- neovim: lean. lazy.nvim handles plugins.\n\\n\vim.g.mapleader = ' '\n\vim.opt.number = true\n\vim.opt.relativenumber = true\n\vim.opt.expandtab = true\n\vim.opt.shiftwidth = 4\n\vim.opt.tabstop = 4\n\vim.opt.smartcase = true\n\\n\vim.keymap.set('n', '<leader>w', ':write<CR>')\n";const DOT_GITCONFIG: &str = "[user]\n\    name = Isaac Bythewood\n\    email = isaac@bythewood.me\n\[init]\n\    defaultBranch = master\n\[push]\n\    autoSetupRemote = true\n\[pull]\n\    ff = only\n";const DOT_ARCHETYPE: Archetype = Archetype {    kind: "dotfiles",    description: "personal dotfiles",    names: &["paperhouse", "swale", "hearthstone", "hush-hollow", "gypsum"],    initial: &[        ("README.md", DOT_README),        ("bin/install", DOT_INSTALL),        ("config/.zshrc", DOT_ZSHRC),    ],    patches: &[        Patch {            op: PatchOp::Create { path: "config/.tmux.conf", body: DOT_TMUX },            messages: &[                "tmux: rebind prefix to ctrl-a",                "tmux: vim keys for pane nav",                "add tmux config",            ],        },        Patch {            op: PatchOp::Create { path: "config/nvim/init.lua", body: DOT_NVIM },            messages: &[                "neovim: switch to lazy.nvim",                "neovim: relative line numbers",                "first cut of init.lua",            ],        },        Patch {            op: PatchOp::Create { path: "config/.gitconfig", body: DOT_GITCONFIG },            messages: &["git: default branch master, push autoSetup", "add gitconfig"],        },        Patch {            op: PatchOp::Append { path: "config/.zshrc", line: "alias gp='git push'" },            messages: &["zsh: alias gp"],        },        Patch {            op: PatchOp::Append {                path: "config/.zshrc",                line: "alias gpl='git pull --ff-only'",            },            messages: &["zsh: alias gpl with ff-only"],        },        Patch {            op: PatchOp::Append {                path: "config/.zshrc",                line: "alias t='tmux attach || tmux'",            },            messages: &["zsh: alias t for tmux attach"],        },        Patch {            op: PatchOp::Append {                path: "config/.tmux.conf",                line: "set -g status-style fg=white,bg=default",            },            messages: &["tmux: lean status line"],        },        Patch {            op: PatchOp::Append {                path: "README.md",                line: "`bin/install` is idempotent: it just refreshes the symlinks.",            },            messages: &["readme: note install is idempotent"],        },        Patch {            op: PatchOp::Append { path: "config/.zshrc", line: "alias k='kubectl'" },            messages: &["zsh: alias k for kubectl"],        },        Patch {            op: PatchOp::Append { path: "config/.zshrc", line: "alias d='docker'" },            messages: &["zsh: alias d for docker"],        },        Patch {            op: PatchOp::Append {                path: "config/.zshrc",                line: "export FZF_DEFAULT_COMMAND='fd --hidden --follow'",            },            messages: &["zsh: faster fzf default command"],        },        Patch {            op: PatchOp::Append {                path: "config/.tmux.conf",                line: "set -g history-limit 50000",            },            messages: &["tmux: bigger scrollback"],        },        Patch {            op: PatchOp::Append {                path: "config/.tmux.conf",                line: "set -g renumber-windows on",            },            messages: &["tmux: renumber windows on close"],        },        Patch {            op: PatchOp::Append {                path: "config/.gitconfig",                line: "[core]\n    excludesfile = ~/.gitignore_global",            },            messages: &["git: global excludes file"],        },        Patch {            op: PatchOp::Append {                path: "config/.gitconfig",                line: "[alias]\n    co = checkout\n    br = branch\n    st = status -sb",            },            messages: &["git: short aliases"],        },        Patch {            op: PatchOp::Create {                path: "config/.gitignore_global",                body: ".DS_Store\nThumbs.db\n*.swp\n*.swo\n*~\n.idea/\n.vscode/\n",            },            messages: &["add global gitignore"],        },        Patch {            op: PatchOp::Create {                path: "bin/uninstall",                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",            },            messages: &["add uninstall script"],        },        Patch {            op: PatchOp::Create {                path: "config/.editorconfig",                body: "root = true\n\n[*]\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\nindent_style = space\nindent_size = 4\n",            },            messages: &["add editorconfig"],        },        Patch {            op: PatchOp::Create {                path: "config/starship.toml",                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",            },            messages: &["adopt starship prompt"],        },        Patch {            op: PatchOp::Append {                path: "config/.zshrc",                line: "eval \"$(starship init zsh)\"",            },            messages: &["zsh: enable starship"],        },    ],};const ARCHETYPES: &[&Archetype] = &[    &RUST_ARCHETYPE,    &TS_ARCHETYPE,    &PY_ARCHETYPE,    &BLOG_ARCHETYPE,    &DOT_ARCHETYPE,];// ---------- main ------------------------------------------------------------struct Opts {    count: usize,    days: i64,    seed: u64,    reset: bool,}fn parse_args() -> Result<Opts, String> {    let mut opts = Opts {        count: DEFAULT_COUNT,        days: DEFAULT_DAYS,        seed: DEFAULT_SEED,        reset: false,    };    let args: Vec<String> = std::env::args().skip(1).collect();    let mut i = 0;    while i < args.len() {        match args[i].as_str() {            "--count" => {                i += 1;                opts.count = args                    .get(i)                    .ok_or("--count needs a value")?                    .parse()                    .map_err(|e: std::num::ParseIntError| e.to_string())?;            }            "--days" => {                i += 1;                opts.days = args                    .get(i)                    .ok_or("--days needs a value")?                    .parse()                    .map_err(|e: std::num::ParseIntError| e.to_string())?;            }            "--seed" => {                i += 1;                opts.seed = args                    .get(i)                    .ok_or("--seed needs a value")?                    .parse()                    .map_err(|e: std::num::ParseIntError| e.to_string())?;            }            "--reset" => opts.reset = true,            "-h" | "--help" => {                print_usage();                std::process::exit(0);            }            other => return Err(format!("unknown arg: {other}")),        }        i += 1;    }    Ok(opts)}fn print_usage() {    eprintln!(        "seed: generate fake bare git repos under {DEST}/\n\         \n\         Usage:\n  \           cargo run --bin seed                       defaults: 8 repos, 30 days, seed 0xC0DEFEED\n  \           cargo run --bin seed -- --count N          number of repos to generate\n  \           cargo run --bin seed -- --days D           days of history per repo\n  \           cargo run --bin seed -- --seed N           PRNG seed (reproducible)\n  \           cargo run --bin seed -- --reset            wipe {DEST}/ first\n"    );}fn main() {    if let Err(e) = run() {        eprintln!("seed: {e}");        std::process::exit(1);    }}fn run() -> Result<(), String> {    let opts = parse_args()?;    let dest = PathBuf::from(DEST);    if opts.reset && dest.exists() {        fs::remove_dir_all(&dest).map_err(|e| format!("reset {DEST}: {e}"))?;    }    fs::create_dir_all(&dest).map_err(|e| format!("mkdir {DEST}: {e}"))?;    let mut rng = Rng::new(opts.seed);    let chosen = pick_repos(&mut rng, opts.count);    let now = unix_now();    let oldest = now - opts.days * 86_400;    for (arch, name) in &chosen {        let target = dest.join(format!("{name}.git"));        if target.exists() {            println!("  skip   {name} (already in {DEST}/)");            continue;        }        println!("  seed   {name} ({})", arch.kind);        seed_one(&target, arch, name, oldest, opts.days, &mut rng)            .map_err(|e| format!("seed {name}: {e}"))?;    }    Ok(())}/// Pick `count` (archetype, name) pairs, no duplicate names. First pass/// guarantees one repo per archetype (so the landing page always shows the/// full variety); any remaining slots are filled by random archetype + name.fn pick_repos(rng: &mut Rng, count: usize) -> Vec<(&'static Archetype, &'static str)> {    let mut chosen = Vec::with_capacity(count);    let mut used: HashSet<&'static str> = HashSet::new();    let total_names: usize = ARCHETYPES.iter().map(|a| a.names.len()).sum();    let cap = count.min(total_names);    // First pass: one per archetype.    for arch in ARCHETYPES {        if chosen.len() >= cap {            break;        }        let name = *rng.pick(arch.names);        if used.insert(name) {            chosen.push((*arch, name));        }    }    // Fill remaining slots with uniformly random archetype + name.    let mut guard = 0usize;    while chosen.len() < cap && guard < cap * 50 {        let arch = *rng.pick(ARCHETYPES);        let name = *rng.pick(arch.names);        if used.insert(name) {            chosen.push((arch, name));        }        guard += 1;    }    chosen}// ---------- per-repo synthesis ---------------------------------------------fn seed_one(    target: &Path,    arch: &Archetype,    name: &str,    oldest: i64,    days: i64,    rng: &mut Rng,) -> Result<(), String> {    // Build the history in a temp working dir, then bare-clone into the    // fixtures dir. Doing the bare clone at the end lets us use the regular    // working-tree commit flow (which is much simpler than driving    // commit-tree directly).    let work = std::env::temp_dir().join(format!("heartwood-seed-{name}"));    if work.exists() {        fs::remove_dir_all(&work).map_err(|e| e.to_string())?;    }    fs::create_dir_all(&work).map_err(|e| e.to_string())?;    git(&work, &["init", "-q", "-b", "master"])?;    // Initial files + commit. Always Isaac on the first commit; reads as the    // "this is the operator's repo" handshake.    for (path, body) in arch.initial {        let real_path = expand(path, name);        let real_body = expand(body, name);        write_under(&work, &real_path, &real_body).map_err(|e| e.to_string())?;    }    git(&work, &["add", "."])?;    let t = oldest + rng.range(86_400) as i64;    commit(&work, "initial commit", t, &AUTHORS[0])?;    // Subsequent commits, spread across the remaining days.    for day in 1..days {        // Most days have 0 to 2 commits; ~30% are busy with up to 5.        let n = if rng.chance(3) {            1 + rng.range(5)        } else if rng.chance(6) {            1 + rng.range(2)        } else {            0        };        for _ in 0..n {            // Pick a patch, apply it, check it actually produced a diff. If            // the patch was a no-op (file already exists with same content,            // or trailing line is already present), try a different one. Up            // to a handful of attempts; give up silently if every patch in            // the pool has already been applied to this repo.            let mut produced_change = false;            for _ in 0..6 {                let patch = rng.pick(arch.patches);                if !apply_patch(&work, &patch.op, name).map_err(|e| e.to_string())? {                    continue;                }                git(&work, &["add", "."])?;                if !staged_is_empty(&work)? {                    // Pick a message from this patch's own pool.                    let tmpl: &&str = rng.pick(patch.messages);                    let msg = expand(tmpl, name);                    let ts = oldest + day * 86_400 + rng.range(86_400) as i64;                    commit(&work, &msg, ts, pick_author(rng))?;                    produced_change = true;                    break;                }            }            // If every retry was a no-op, drop the commit slot rather than            // emit an empty commit.            let _ = produced_change;        }    }    // Bare clone into the destination.    let status = Command::new("git")        .args(["clone", "--bare", "--quiet"])        .arg(&work)        .arg(target)        .status()        .map_err(|e| format!("clone: {e}"))?;    if !status.success() {        return Err(format!("clone exited {:?}", status.code()));    }    // Per-repo description (heartwood reads `description` for the landing    // page). git's stock placeholder is filtered out in src/git.rs.    fs::write(target.join("description"), format!("{}\n", arch.description))        .map_err(|e| e.to_string())?;    let _ = fs::remove_dir_all(&work);    Ok(())}/// Apply a patch to the working tree. Returns `Ok(true)` if the disk actually/// changed, `Ok(false)` if the patch was a no-op for this repo (file already/// created, line already present). The caller skips committing on `false`.fn apply_patch(work: &Path, op: &PatchOp, name: &str) -> std::io::Result<bool> {    match op {        PatchOp::Create { path, body } => {            let real_path = expand(path, name);            let full = work.join(&real_path);            if full.exists() {                return Ok(false);            }            let real_body = expand(body, name);            write_under(work, &real_path, &real_body)?;            Ok(true)        }        PatchOp::Append { path, line } => {            let real_path = expand(path, name);            let real_line = expand(line, name);            let full = work.join(&real_path);            if let Some(p) = full.parent() {                fs::create_dir_all(p)?;            }            // If the line is already present (anywhere in the file), treat            // this patch as exhausted for this repo. Avoids the README            // sprouting duplicate sentences when the same Append fires twice.            if let Ok(existing) = fs::read_to_string(&full) {                if existing.lines().any(|l| l == real_line) {                    return Ok(false);                }            }            let mut f = fs::OpenOptions::new()                .create(true)                .append(true)                .open(&full)?;            f.write_all(real_line.as_bytes())?;            f.write_all(b"\n")?;            Ok(true)        }    }}/// True iff `git add .` produced no staged changes.fn staged_is_empty(work: &Path) -> Result<bool, String> {    let out = Command::new("git")        .current_dir(work)        .args(["diff", "--cached", "--quiet"])        .status()        .map_err(|e| format!("git diff --cached: {e}"))?;    // `git diff --quiet` exits 0 if no diff, 1 if there is one.    Ok(out.success())}fn write_under(work: &Path, rel: &str, body: &str) -> std::io::Result<()> {    let full = work.join(rel);    if let Some(p) = full.parent() {        fs::create_dir_all(p)?;    }    fs::write(full, body)}/// Expand templating placeholders. Two substitutions:///   `{name}`:  the repo's slug as-is (e.g. `copper-net`).///   `{snake}`: the same with hyphens turned into underscores. Used inside///               Rust / Python code blocks where hyphens are illegal as///               identifiers (`copper-net::version` is wrong;///               `copper_net::version` is right)./// Anything else with braces is left untouched, so format-style examples like/// `println!("{}", x)` in file bodies pass through cleanly.fn expand(s: &str, name: &str) -> String {    s.replace("{name}", name)        .replace("{snake}", &name.replace('-', "_"))}// ---------- git plumbing ----------------------------------------------------fn git(cwd: &Path, args: &[&str]) -> Result<(), String> {    let s = Command::new("git")        .current_dir(cwd)        .args(args)        .status()        .map_err(|e| format!("git {args:?}: {e}"))?;    if !s.success() {        return Err(format!("git {args:?} exited {:?}", s.code()));    }    Ok(())}fn commit(cwd: &Path, message: &str, ts: i64, author: &Author) -> Result<(), String> {    let date = format!("{ts} +0000");    let s = Command::new("git")        .current_dir(cwd)        // Always go through env-vars so author + committer + their dates all        // agree. `git commit --date=` only sets author date; without the        // committer envs the commit hash would drift between runs.        .env("GIT_AUTHOR_NAME", author.name)        .env("GIT_AUTHOR_EMAIL", author.email)        .env("GIT_AUTHOR_DATE", &date)        .env("GIT_COMMITTER_NAME", author.name)        .env("GIT_COMMITTER_EMAIL", author.email)        .env("GIT_COMMITTER_DATE", &date)        .args(["commit", "-q", "-m", message])        .status()        .map_err(|e| format!("git commit: {e}"))?;    if !s.success() {        return Err(format!("git commit exited {:?}", s.code()));    }    Ok(())}fn unix_now() -> i64 {    std::time::SystemTime::now()        .duration_since(std::time::UNIX_EPOCH)        .map(|d| d.as_secs() as i64)        .unwrap_or(0)}
added src/git.rs
@@ -0,0 +1,515 @@//! Thin wrappers over `gix` that pre-shape data into the structs templates//! want. Every function here is synchronous and blocking; routes call them//! inside `tokio::task::spawn_blocking` if they're touching anything heavier//! than HEAD metadata.use anyhow::{anyhow, Context, Result};use serde::Serialize;use std::path::{Path, PathBuf};/// Suffix used by the bare repos we expose. `/srv/git/foo.git/` is the/// canonical layout; anything without `.git` is ignored on discovery so a/// stray directory doesn't surface as a repo.pub const BARE_SUFFIX: &str = ".git";/// Maximum blob size we'll load into memory for /blob and /raw views./// Anything larger is almost certainly a binary asset best fetched via/// `git clone`; serving it inline would risk OOMing the container under/// concurrent requests.pub const MAX_BLOB_SIZE: u64 = 25 * 1024 * 1024;#[derive(Debug, Clone, Serialize)]pub struct RepoSummary {    pub name: String,    pub description: String,    pub default_branch: String,    pub head_summary: Option<String>,    pub head_time: Option<i64>,    pub head_id: Option<String>,    pub clone_url: String,}#[derive(Debug, Clone, Serialize)]pub struct CommitInfo {    pub id: String,    pub short_id: String,    pub summary: String,    pub message: String,    pub author: String,    pub author_email: String,    pub time: i64,    pub parents: Vec<String>,}#[derive(Debug, Clone, Serialize)]pub struct TreeEntry {    pub name: String,    pub kind: &'static str, // "tree" | "blob" | "commit" (submodule) | "link"    pub mode: String,    pub size: Option<u64>,    pub id: String,}#[derive(Debug, Clone, Serialize)]pub struct BlobInfo {    pub data: Vec<u8>,    pub size: u64,    pub is_binary: bool,}#[derive(Debug, Clone, Serialize)]pub struct FileDiff {    pub path: String,    pub old_path: Option<String>,    pub status: &'static str, // "added" | "deleted" | "modified" | "renamed"    pub hunks: Vec<DiffHunk>,    pub is_binary: bool,}#[derive(Debug, Clone, Serialize)]pub struct DiffHunk {    pub header: String,    pub lines: Vec<DiffLine>,}#[derive(Debug, Clone, Serialize)]pub struct DiffLine {    pub kind: &'static str, // "add" | "del" | "ctx" | "hdr"    pub text: String,}/// Walk `root` once and return one entry per `*.git` directory that contains/// a HEAD ref. Sorted by most-recently-touched HEAD first so the landing page/// reads "what's been active" without further sorting in the template.pub fn discover(root: &Path, clone_base: &str) -> Result<Vec<RepoSummary>> {    let mut out = Vec::new();    let entries = match std::fs::read_dir(root) {        Ok(e) => e,        Err(e) => {            tracing::warn!("repo root {}: {}", root.display(), e);            return Ok(out);        }    };    for entry in entries.flatten() {        let path = entry.path();        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {            continue;        };        if !name.ends_with(BARE_SUFFIX) {            continue;        }        if !path.is_dir() {            continue;        }        match repo_summary(&path, clone_base) {            Ok(s) => out.push(s),            Err(e) => tracing::warn!("skipping {}: {:#}", path.display(), e),        }    }    out.sort_by(|a, b| b.head_time.cmp(&a.head_time).then(a.name.cmp(&b.name)));    Ok(out)}fn short_name(path: &Path) -> String {    let name = path        .file_name()        .and_then(|s| s.to_str())        .unwrap_or_default();    name.strip_suffix(BARE_SUFFIX).unwrap_or(name).to_string()}pub fn open(repo_root: &Path, name: &str) -> Result<gix::Repository> {    let path = resolve_path(repo_root, name)?;    Ok(gix::open(&path).with_context(|| format!("open {}", path.display()))?)}/// Resolve `name` (the URL slug, e.g. `analytics` or `blog.bythewood.me`)/// to the on-disk bare repo path. Rejects any name containing a path/// separator or starting with a dot so a request for `../etc/passwd` can't/// escape the repo root.pub fn resolve_path(repo_root: &Path, name: &str) -> Result<PathBuf> {    if name.is_empty()        || name.contains('/')        || name.contains('\\')        || name.starts_with('.')        || name.contains("..")    {        return Err(anyhow!("invalid repo name: {name}"));    }    // Append `.git` to the FULL name, not via Path::set_extension — that    // would clobber the existing extension on names like `blog.bythewood.me`    // (giving `blog.bythewood.git`, which doesn't exist).    let p = repo_root.join(format!("{name}.git"));    if !p.exists() {        return Err(anyhow!("repo not found: {name}"));    }    Ok(p)}pub fn repo_summary(path: &Path, clone_base: &str) -> Result<RepoSummary> {    let name = short_name(path);    let repo = gix::open(path).with_context(|| format!("open {}", path.display()))?;    let description = read_description(path);    let default_branch = read_default_branch(&repo);    let clone_url = format!(        "{}/{}.git",        clone_base.trim_end_matches('/'),        name    );    let head = match repo.head_commit() {        Ok(c) => Some(c),        Err(_) => None,    };    let (head_summary, head_time, head_id) = if let Some(c) = head {        let id = c.id().to_string();        let summary = c            .message()            .ok()            .and_then(|m| Some(m.summary().to_string()))            .unwrap_or_default();        let time = c.time().ok().map(|t| t.seconds);        (Some(summary), time, Some(id))    } else {        (None, None, None)    };    Ok(RepoSummary {        name,        description,        default_branch,        head_summary,        head_time,        head_id,        clone_url,    })}/// Read git's per-repo `description` file. The default text shipped by git/// (`Unnamed repository; edit this file 'description' to name the repository.`)/// is treated as empty so the landing page doesn't show that boilerplate.fn read_description(path: &Path) -> String {    let raw = std::fs::read_to_string(path.join("description")).unwrap_or_default();    let trimmed = raw.trim();    if trimmed.starts_with("Unnamed repository") {        String::new()    } else {        trimmed.to_string()    }}fn read_default_branch(repo: &gix::Repository) -> String {    // HEAD is a symbolic ref like `refs/heads/master`; strip the prefix.    if let Ok(head) = repo.head() {        if let Some(name) = head.referent_name() {            let s = name.as_bstr().to_string();            if let Some(b) = s.strip_prefix("refs/heads/") {                return b.to_string();            }            return s;        }    }    "master".to_string()}/// Resolve a revspec (branch name, tag, full or short oid) into a commit oid.pub fn resolve_rev(repo: &gix::Repository, rev: &str) -> Result<gix::ObjectId> {    let id = repo        .rev_parse_single(rev)        .map_err(|e| anyhow!("revspec {rev}: {e}"))?        .detach();    let obj = repo.find_object(id)?;    let commit = obj.peel_to_kind(gix::object::Kind::Commit)?;    Ok(commit.id)}pub fn commit_info(repo: &gix::Repository, oid: gix::ObjectId) -> Result<CommitInfo> {    let commit = repo.find_commit(oid)?;    let id = commit.id().to_string();    let short_id = id.chars().take(8).collect();    let msg = commit.message()?;    let summary = msg.summary().to_string();    let message = String::from_utf8_lossy(commit.message_raw()?.as_ref()).to_string();    let sig = commit.author()?;    let author = sig.name.to_string();    let author_email = sig.email.to_string();    let time = sig.time.seconds;    let parents = commit.parent_ids().map(|p| p.to_string()).collect();    Ok(CommitInfo {        id,        short_id,        summary,        message,        author,        author_email,        time,        parents,    })}pub fn recent_commits(    repo: &gix::Repository,    start: gix::ObjectId,    limit: usize,) -> Result<Vec<CommitInfo>> {    let mut out = Vec::with_capacity(limit);    let walk = repo.rev_walk([start]).all()?;    for info in walk.take(limit) {        let info = info?;        out.push(commit_info(repo, info.id)?);    }    Ok(out)}/// Walk a tree at `rev` / `path`. `path` is a `/`-joined string ("", "src",/// "src/routes"), not pre-split.pub fn list_tree(    repo: &gix::Repository,    rev: gix::ObjectId,    path: &str,) -> Result<(Vec<TreeEntry>, Vec<String>)> {    let commit = repo.find_commit(rev)?;    let mut tree = commit.tree()?;    let breadcrumb: Vec<String> = path        .split('/')        .filter(|p| !p.is_empty())        .map(|s| s.to_string())        .collect();    if !breadcrumb.is_empty() {        let entry = tree            .peel_to_entry_by_path(std::path::PathBuf::from(path))?            .ok_or_else(|| anyhow!("path not found: {path}"))?;        if !entry.mode().is_tree() {            return Err(anyhow!("not a tree: {path}"));        }        tree = entry.object()?.try_into_tree().map_err(|_| anyhow!("not a tree: {path}"))?;    }    let mut entries = Vec::new();    for entry_ref in tree.iter() {        let entry_ref = entry_ref?;        let name = entry_ref.filename().to_string();        let mode = entry_ref.mode();        let kind = if mode.is_tree() {            "tree"        } else if mode.is_link() {            "link"        } else if mode.is_commit() {            "commit"        } else {            "blob"        };        let size = if kind == "blob" {            repo.find_object(entry_ref.oid())                .ok()                .and_then(|o| o.try_into_blob().ok())                .map(|b| b.data.len() as u64)        } else {            None        };        entries.push(TreeEntry {            name,            kind,            mode: format!("{:o}", *mode),            size,            id: entry_ref.oid().to_string(),        });    }    // Trees first, then alphabetical within each group.    entries.sort_by(|a, b| {        let group = |k| if k == "tree" { 0 } else { 1 };        group(a.kind)            .cmp(&group(b.kind))            .then(a.name.cmp(&b.name))    });    Ok((entries, breadcrumb))}pub fn read_blob(repo: &gix::Repository, rev: gix::ObjectId, path: &str) -> Result<BlobInfo> {    let commit = repo.find_commit(rev)?;    let mut tree = commit.tree()?;    if path.is_empty() {        return Err(anyhow!("empty path"));    }    let entry = tree        .peel_to_entry_by_path(std::path::PathBuf::from(path))?        .ok_or_else(|| anyhow!("path not found: {path}"))?;    // Peek the object header before loading: try_into_blob() below would    // otherwise pull the entire blob into memory, so a 500MB asset in a    // repo could OOM the container under concurrent requests.    let header = repo.find_header(entry.oid())?;    if header.size() > MAX_BLOB_SIZE {        return Err(anyhow!(            "blob too large to display ({} bytes; cap is {} bytes)",            header.size(),            MAX_BLOB_SIZE        ));    }    let blob = entry        .object()?        .try_into_blob()        .map_err(|_| anyhow!("not a blob: {path}"))?;    let data = blob.data.clone();    let size = data.len() as u64;    let is_binary = looks_binary(&data);    Ok(BlobInfo { data, size, is_binary })}/// "Binary" detection mirrors what git itself does: a NUL byte in the first/// 8KB. Good enough for the file-view branching (text → syntect, binary →/// download link).fn looks_binary(data: &[u8]) -> bool {    data.iter().take(8192).any(|&b| b == 0)}/// Find the README at the repo root (case-insensitive, .md / .markdown / .rst/// / no extension). Returns the bytes if found.pub fn read_readme(repo: &gix::Repository, rev: gix::ObjectId) -> Option<(String, Vec<u8>)> {    let commit = repo.find_commit(rev).ok()?;    let tree = commit.tree().ok()?;    let mut candidates: Vec<(String, gix::ObjectId)> = Vec::new();    for e in tree.iter() {        let Ok(e) = e else { continue };        if !e.mode().is_blob() {            continue;        }        let name = e.filename().to_string();        let lower = name.to_ascii_lowercase();        if lower == "readme"            || lower == "readme.md"            || lower == "readme.markdown"            || lower == "readme.txt"            || lower == "readme.rst"        {            candidates.push((name, e.oid().into()));        }    }    // Prefer .md, then no-extension, then anything else.    candidates.sort_by_key(|(n, _)| {        let l = n.to_ascii_lowercase();        if l.ends_with(".md") || l.ends_with(".markdown") {            0        } else if !l.contains('.') {            1        } else {            2        }    });    let (name, oid) = candidates.into_iter().next()?;    let blob = repo.find_object(oid).ok()?.try_into_blob().ok()?;    Some((name, blob.data.clone()))}/// Render `git show --format= --patch <oid>` for a single commit and parse/// the unified-diff output into per-file hunks. We shell out instead of/// driving `gix-diff` directly: git is the canonical implementation of/// unified diff and it's already on the runtime image (we need it for/// `git http-backend`), so the marginal cost is one extra fork per commit/// view, not a new dependency.pub fn diff_commit(repo_path: &Path, oid: gix::ObjectId) -> Result<Vec<FileDiff>> {    let output = std::process::Command::new("git")        .arg("-c")        // Don't octal-escape non-ASCII filenames; we parse the unified-diff        // header by string-matching `diff --git a/... b/...` and quoted paths        // would break that (and the resulting `path` would render as gibberish).        .arg("core.quotePath=false")        .arg("-C")        .arg(repo_path)        .arg("show")        .arg("--format=")        .arg("--patch")        .arg("--no-color")        .arg("-M")        .arg(oid.to_string())        .output()        .context("spawn git show")?;    if !output.status.success() {        return Err(anyhow!(            "git show exited {:?}: {}",            output.status.code(),            String::from_utf8_lossy(&output.stderr)        ));    }    Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))}fn parse_unified_diff(text: &str) -> Vec<FileDiff> {    let mut files: Vec<FileDiff> = Vec::new();    let mut current_file: Option<FileDiff> = None;    let mut current_hunk: Option<DiffHunk> = None;    let flush_hunk = |file: &mut FileDiff, hunk: &mut Option<DiffHunk>| {        if let Some(h) = hunk.take() {            file.hunks.push(h);        }    };    for line in text.lines() {        if let Some(rest) = line.strip_prefix("diff --git ") {            if let Some(mut f) = current_file.take() {                flush_hunk(&mut f, &mut current_hunk);                files.push(f);            }            // `diff --git a/foo b/foo`: take the b-path (post-rename side).            let parts: Vec<&str> = rest.split(' ').collect();            let new_path = parts                .last()                .map(|s| s.trim_start_matches("b/").to_string())                .unwrap_or_default();            current_file = Some(FileDiff {                path: new_path,                old_path: None,                status: "modified",                hunks: Vec::new(),                is_binary: false,            });            continue;        }        let Some(file) = current_file.as_mut() else { continue };        if line.starts_with("new file mode") {            file.status = "added";        } else if line.starts_with("deleted file mode") {            file.status = "deleted";        } else if line.starts_with("rename from ") {            file.status = "renamed";            file.old_path = Some(line.trim_start_matches("rename from ").to_string());        } else if line.starts_with("Binary files ") {            file.is_binary = true;        } else if line.starts_with("@@") {            flush_hunk(file, &mut current_hunk);            current_hunk = Some(DiffHunk {                header: line.to_string(),                lines: Vec::new(),            });        } else if let Some(hunk) = current_hunk.as_mut() {            let (kind, body): (&'static str, &str) = if let Some(rest) = line.strip_prefix('+') {                if line.starts_with("+++") {                    continue;                }                ("add", rest)            } else if let Some(rest) = line.strip_prefix('-') {                if line.starts_with("---") {                    continue;                }                ("del", rest)            } else if let Some(rest) = line.strip_prefix(' ') {                ("ctx", rest)            } else {                continue;            };            hunk.lines.push(DiffLine {                kind,                text: body.to_string(),            });        }    }    if let Some(mut f) = current_file.take() {        if let Some(h) = current_hunk.take() {            f.hunks.push(h);        }        files.push(f);    }    files}
added src/highlight.rs
@@ -0,0 +1,61 @@use std::sync::OnceLock;use syntect::highlighting::{Theme, ThemeSet};use syntect::html::{styled_line_to_highlighted_html, IncludeBackground};use syntect::parsing::SyntaxSet;use syntect::util::LinesWithEndings;fn syntax_set() -> &'static SyntaxSet {    static CELL: OnceLock<SyntaxSet> = OnceLock::new();    CELL.get_or_init(SyntaxSet::load_defaults_newlines)}fn theme() -> &'static Theme {    static CELL: OnceLock<Theme> = OnceLock::new();    CELL.get_or_init(|| {        let mut ts = ThemeSet::load_defaults();        // base16-eighties.dark reads well on the heartwood palette and ships        // with syntect's default themes, so no theme files to vendor.        ts.themes            .remove("base16-eighties.dark")            .or_else(|| ts.themes.remove("Solarized (dark)"))            .unwrap_or_else(|| ts.themes.into_values().next().expect("at least one theme"))    })}/// `filename` is used only to pick the syntax; pass the blob basename.pub fn highlight(source: &str, filename: &str) -> String {    let ss = syntax_set();    let theme = theme();    let syntax = ss        .find_syntax_for_file(filename)        .ok()        .flatten()        .or_else(|| {            let ext = std::path::Path::new(filename)                .extension()                .and_then(|e| e.to_str())                .unwrap_or("");            ss.find_syntax_by_extension(ext)        })        .unwrap_or_else(|| ss.find_syntax_plain_text());    let mut highlighter = syntect::easy::HighlightLines::new(syntax, theme);    let mut out = String::from("<pre class=\"hl\"><code>");    for (i, line) in LinesWithEndings::from(source).enumerate() {        let ranges = highlighter            .highlight_line(line, ss)            .unwrap_or_else(|_| vec![(Default::default(), line)]);        out.push_str(&format!(            "<span class=\"ln\" data-n=\"{}\"></span>",            i + 1        ));        out.push_str("<span class=\"l\">");        let html = styled_line_to_highlighted_html(&ranges, IncludeBackground::No)            .unwrap_or_else(|_| line.to_string());        out.push_str(&html);        out.push_str("</span>");    }    out.push_str("</code></pre>");    out}
added src/http_backend.rs
@@ -0,0 +1,176 @@//! Smart-HTTP clone via a `git http-backend` subprocess.//!//! `git http-backend` is the CGI program that ships with git. It expects//! request data in CGI env vars + stdin, and writes CGI-style output to//! stdout: a small block of `Key: Value` headers, a blank line, then the//! response body.//!//! We spawn it per request, pump the request body to its stdin, parse the//! CGI headers off stdout, and stream the rest as the response.use anyhow::{anyhow, Context, Result};use axum::{    body::Body,    http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri},    response::Response,};use bytes::Bytes;use futures_util::StreamExt;use std::path::Path;use std::process::Stdio;use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};use tokio::process::Command;pub struct CgiRequest<'a> {    pub repo_root: &'a Path,    pub method: Method,    pub path_info: String,    pub query: String,    pub content_type: Option<String>,    pub remote_addr: String,    pub headers: HeaderMap,}pub async fn serve(req: CgiRequest<'_>, body: Body) -> Result<Response> {    let mut cmd = Command::new("git");    cmd.arg("http-backend")        .env_clear()        .env("PATH", std::env::var("PATH").unwrap_or_default())        .env("GIT_PROJECT_ROOT", req.repo_root)        .env("GIT_HTTP_EXPORT_ALL", "1")        .env("REQUEST_METHOD", req.method.as_str())        .env("PATH_INFO", &req.path_info)        .env("QUERY_STRING", &req.query)        .env("REMOTE_ADDR", &req.remote_addr)        .env("HTTP_HOST", host_header(&req.headers).unwrap_or(""))        .env("SERVER_PROTOCOL", "HTTP/1.1");    if let Some(ct) = req.content_type {        cmd.env("CONTENT_TYPE", ct);    }    if let Some(ce) = req.headers.get("content-encoding").and_then(|v| v.to_str().ok()) {        cmd.env("HTTP_CONTENT_ENCODING", ce);    }    if let Some(ua) = req.headers.get("user-agent").and_then(|v| v.to_str().ok()) {        cmd.env("HTTP_USER_AGENT", ua);    }    if let Some(acc) = req.headers.get("accept").and_then(|v| v.to_str().ok()) {        cmd.env("HTTP_ACCEPT", acc);    }    if let Some(gp) = req        .headers        .get("git-protocol")        .and_then(|v| v.to_str().ok())    {        // protocol v2 negotiation header; git http-backend needs this in env        // form to switch protocols.        cmd.env("HTTP_GIT_PROTOCOL", gp);    }    cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());    let mut child = cmd.spawn().context("spawn git http-backend")?;    let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;    let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;    let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;    // Pump the request body into the child. POST upload-pack bodies can be    // megabytes; stream chunk by chunk.    let writer = tokio::spawn(async move {        let mut stream = body.into_data_stream();        while let Some(chunk) = stream.next().await {            match chunk {                Ok(bytes) => {                    if let Err(e) = stdin.write_all(&bytes).await {                        tracing::warn!("write to git http-backend stdin: {e}");                        break;                    }                }                Err(e) => {                    tracing::warn!("client body error: {e}");                    break;                }            }        }        let _ = stdin.shutdown().await;    });    tokio::spawn(async move {        let mut buf = String::new();        if stderr.read_to_string(&mut buf).await.is_ok() && !buf.is_empty() {            tracing::warn!("git http-backend stderr: {}", buf.trim());        }    });    let mut reader = BufReader::new(stdout);    let (status, headers) = parse_cgi_headers(&mut reader).await?;    // Whatever's left in `reader` after the blank line is the response body.    // Stream it back as a byte stream rather than buffering — pack files can    // be large.    let stream = async_stream::stream! {        let mut buf = vec![0u8; 32 * 1024];        let mut reader = reader;        loop {            match reader.read(&mut buf).await {                Ok(0) => break,                Ok(n) => yield Ok::<_, std::io::Error>(Bytes::copy_from_slice(&buf[..n])),                Err(e) => {                    yield Err(e);                    break;                }            }        }        // Reap the child so we don't leak zombies.        let _ = child.wait().await;        let _ = writer.await;    };    let mut resp = Response::builder().status(status);    for (k, v) in &headers {        resp = resp.header(k, v);    }    Ok(resp.body(Body::from_stream(stream))?)}fn host_header(h: &HeaderMap) -> Option<&str> {    h.get("host").and_then(|v| v.to_str().ok())}/// Read CGI-style "Key: Value\r\n" header lines until a blank line. The first/// `Status: NNN reason` line, if present, becomes the HTTP status.async fn parse_cgi_headers<R: tokio::io::AsyncBufRead + Unpin>(    r: &mut R,) -> Result<(StatusCode, Vec<(HeaderName, HeaderValue)>)> {    let mut status = StatusCode::OK;    let mut headers = Vec::new();    let mut line = String::new();    loop {        line.clear();        let n = r.read_line(&mut line).await?;        if n == 0 {            break;        }        let trimmed = line.trim_end_matches(['\r', '\n']);        if trimmed.is_empty() {            break;        }        let Some((k, v)) = trimmed.split_once(':') else { continue };        let key = k.trim();        let val = v.trim();        if key.eq_ignore_ascii_case("Status") {            // Form: `Status: 404 Not Found` or `Status: 200`.            let code: u16 = val.split_whitespace().next().and_then(|s| s.parse().ok()).unwrap_or(200);            status = StatusCode::from_u16(code).unwrap_or(StatusCode::OK);            continue;        }        let Ok(name) = HeaderName::try_from(key) else { continue };        let Ok(value) = HeaderValue::try_from(val) else { continue };        headers.push((name, value));    }    Ok((status, headers))}pub fn extract_query(uri: &Uri) -> String {    uri.query().unwrap_or_default().to_string()}
added src/main.rs
@@ -0,0 +1,43 @@mod app;mod git;pub use app::AppState;mod highlight;mod http_backend;mod markdown;mod middleware;mod render;mod routes;mod templates;use std::net::SocketAddr;#[tokio::main]async fn main() -> anyhow::Result<()> {    dotenvy::dotenv().ok();    tracing_subscriber::fmt()        .with_env_filter(            tracing_subscriber::EnvFilter::try_from_default_env()                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),        )        .init();    let port: u16 = std::env::var("PORT")        .ok()        .and_then(|v| v.parse().ok())        .unwrap_or(8000);    let state = app::AppState::from_env();    let router = app::router(state);    let addr = SocketAddr::from(([0, 0, 0, 0], port));    let listener = tokio::net::TcpListener::bind(addr).await?;    tracing::info!("heartwood listening on http://{addr}");    // ConnectInfo so clone routes can pass REMOTE_ADDR to git http-backend.    axum::serve(        listener,        router.into_make_service_with_connect_info::<SocketAddr>(),    )    .await?;    Ok(())}
added src/markdown.rs
@@ -0,0 +1,19 @@//! Markdown rendering for READMEs. pulldown-cmark with tables, footnotes,//! strikethrough, and task-list extensions enabled (the subset Github lets//! you use in a README), then run through ammonia so any inline <script>//! or other dangerous HTML in the markdown source is stripped.use pulldown_cmark::{Options, Parser};pub fn render(input: &str) -> String {    let mut opts = Options::empty();    opts.insert(Options::ENABLE_TABLES);    opts.insert(Options::ENABLE_FOOTNOTES);    opts.insert(Options::ENABLE_STRIKETHROUGH);    opts.insert(Options::ENABLE_TASKLISTS);    opts.insert(Options::ENABLE_SMART_PUNCTUATION);    let parser = Parser::new_ext(input, opts);    let mut html = String::with_capacity(input.len());    pulldown_cmark::html::push_html(&mut html, parser);    ammonia::clean(&html)}
added src/middleware.rs
@@ -0,0 +1,45 @@use axum::{    extract::{Request, State},    http::StatusCode,    middleware::Next,    response::{IntoResponse, Response},};use chrono::Local;use std::time::Instant;use crate::AppState;pub async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m",        300..=399 => "\x1b[36m",        400..=499 => "\x1b[33m",        _ => "\x1b[31m",    };    eprintln!("{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}");    response}/// Router fallback: render the themed 404 shell so unmatched URLs match the/// look of a missing-repo page instead of axum's default plain-text body.pub async fn not_found(State(state): State<AppState>, req: Request) -> Response {    let path = req.uri().path().to_string();    let body = crate::render::render(        &state,        "not_found.html",        &path,        minijinja::context! { name => path.trim_start_matches('/') },    );    (StatusCode::NOT_FOUND, body).into_response()}
added src/render.rs
@@ -0,0 +1,56 @@use axum::{    http::StatusCode,    response::{Html, IntoResponse, Response},};use chrono::Datelike;use crate::templates::RequestCtx;use crate::AppState;pub fn render(    state: &AppState,    template: &str,    path: &str,    extra: minijinja::Value,) -> Response {    let tmpl = match state.env.get_template(template) {        Ok(t) => t,        Err(e) => {            tracing::error!("template '{}': {}", template, e);            return (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response();        }    };    let body = tmpl.render(minijinja::context! {        request => RequestCtx { path: path.to_string() },        now => minijinja::context! { year => chrono::Local::now().year() },        site => minijinja::context! {            title => &state.config.site_title,            tagline => &state.config.site_tagline,            clone_base => &state.config.clone_base,        },        base_url => &state.config.base_url,        ..extra    });    match body {        Ok(s) => Html(s).into_response(),        Err(e) => {            tracing::error!("render '{}': {}", template, e);            (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()        }    }}/// Log the underlying reason, then render the themed 404. Used everywhere/// instead of returning the raw error string so filesystem paths and gix/// internals don't leak into the response.pub fn not_found(state: &AppState, name: &str, reason: impl std::fmt::Display) -> Response {    tracing::info!("{name}: {reason}");    let body = render(        state,        "not_found.html",        &format!("/{name}"),        minijinja::context! { name => name },    );    (StatusCode::NOT_FOUND, body).into_response()}
added src/routes/atom.rs
@@ -0,0 +1,67 @@use axum::{    extract::{Path, State},    http::header,    response::{IntoResponse, Response},    routing::get,    Router,};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new().route("/{name}/atom.xml", get(feed))}async fn feed(Path(name): Path<String>, State(state): State<AppState>) -> Response {    let repo_root = state.config.repo_root.clone();    let clone_base = state.config.clone_base.clone();    let name_for_blocking = name.clone();    let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {        let repo = crate::git::open(&repo_root, &name_for_blocking)?;        let summary = crate::git::repo_summary(            &crate::git::resolve_path(&repo_root, &name_for_blocking)?,            &clone_base,        )?;        let head = repo.head_commit()?;        let commits = crate::git::recent_commits(&repo, head.id, 25)?;        Ok((summary, commits))    })    .await;    let (summary, commits) = match result {        Ok(Ok(v)) => v,        Ok(Err(e)) => return (axum::http::StatusCode::NOT_FOUND, format!("{e}")).into_response(),        Err(e) => {            tracing::error!("atom join: {e}");            return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "error").into_response();        }    };    let body = match state.env.get_template("atom.xml") {        Ok(t) => match t.render(minijinja::context! {            repo => &summary,            commits => &commits,            base_url => &state.config.base_url,            clone_base => &state.config.clone_base,            now => minijinja::context! { rfc3339 => chrono::Utc::now().to_rfc3339() },        }) {            Ok(b) => b,            Err(e) => {                tracing::error!("atom render: {e}");                return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error")                    .into_response();            }        },        Err(e) => {            tracing::error!("atom template: {e}");            return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error")                .into_response();        }    };    let mut resp = body.into_response();    resp.headers_mut().insert(        header::CONTENT_TYPE,        "application/atom+xml; charset=utf-8".parse().unwrap(),    );    resp}
added src/routes/blob.rs
@@ -0,0 +1,102 @@use axum::{    extract::{Path, State},    http::header,    response::{IntoResponse, Response},    routing::get,    Router,};use crate::render::{not_found, render};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/{name}/blob/{rev}/{*path}", get(blob_page))        .route("/{name}/raw/{rev}/{*path}", get(blob_raw))}async fn blob_page(    Path((name, rev, path)): Path<(String, String, String)>,    State(state): State<AppState>,) -> Response {    let repo_root = state.config.repo_root.clone();    let clone_base = state.config.clone_base.clone();    let name_for_blocking = name.clone();    let rev_for_blocking = rev.clone();    let path_for_blocking = path.clone();    let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {        let repo = crate::git::open(&repo_root, &name_for_blocking)?;        let summary = crate::git::repo_summary(            &crate::git::resolve_path(&repo_root, &name_for_blocking)?,            &clone_base,        )?;        let oid = crate::git::resolve_rev(&repo, &rev_for_blocking)?;        let blob = crate::git::read_blob(&repo, oid, &path_for_blocking)?;        Ok((summary, blob))    })    .await;    match result {        Ok(Ok((summary, blob))) => {            let basename = path.rsplit('/').next().unwrap_or(&path).to_string();            let highlighted = if blob.is_binary {                None            } else {                let text = String::from_utf8_lossy(&blob.data).into_owned();                Some(crate::highlight::highlight(&text, &basename))            };            render(                &state,                "blob.html",                &format!("/{name}/blob/{rev}/{path}"),                minijinja::context! {                    repo => &summary,                    rev => &rev,                    path => &path,                    basename => basename,                    size => blob.size,                    is_binary => blob.is_binary,                    highlighted => highlighted.map(minijinja::Value::from_safe_string),                },            )        }        Ok(Err(e)) => not_found(&state, &name, e),        Err(e) => {            tracing::error!("blob_page join: {e}");            (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()        }    }}async fn blob_raw(    Path((name, rev, path)): Path<(String, String, String)>,    State(state): State<AppState>,) -> Response {    let repo_root = state.config.repo_root.clone();    let name_for_blocking = name.clone();    let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {        let repo = crate::git::open(&repo_root, &name_for_blocking)?;        let oid = crate::git::resolve_rev(&repo, &rev)?;        let blob = crate::git::read_blob(&repo, oid, &path)?;        let mime = mime_guess::from_path(&path)            .first_or_octet_stream()            .essence_str()            .to_string();        Ok((blob.data, mime))    })    .await;    match result {        Ok(Ok((data, mime))) => {            let mut resp = data.into_response();            let value = mime                .parse()                .unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream"));            resp.headers_mut().insert(header::CONTENT_TYPE, value);            resp        }        Ok(Err(e)) => not_found(&state, &name, e),        Err(e) => {            tracing::error!("blob_raw join: {e}");            (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "error").into_response()        }    }}
added src/routes/clone.rs
@@ -0,0 +1,111 @@//! Smart-HTTP clone endpoints. Captures any URL whose first segment ends in//! `.git`, hands the rest off to `git http-backend` as PATH_INFO.use axum::{    extract::{ConnectInfo, Path, Request, State},    http::StatusCode,    response::{IntoResponse, Response},    routing::{any, get, post},    Router,};use std::net::SocketAddr;use crate::http_backend::{self, CgiRequest};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/{name_git}/info/refs", get(info_refs))        .route("/{name_git}/git-upload-pack", post(upload_pack))        // git push would be /git-receive-pack; we expose it as a 405 so the        // remote prints a helpful error rather than a generic 404.        .route("/{name_git}/git-receive-pack", any(receive_pack_forbidden))}fn strip_git_suffix(name_git: &str) -> Option<&str> {    name_git.strip_suffix(".git")}fn validate_name(name: &str) -> Result<(), Response> {    if name.is_empty()        || name.contains('/')        || name.contains('\\')        || name.starts_with('.')        || name.contains("..")    {        Err((StatusCode::NOT_FOUND, "no such repo").into_response())    } else {        Ok(())    }}async fn info_refs(    Path(name_git): Path<String>,    State(state): State<AppState>,    ConnectInfo(addr): ConnectInfo<SocketAddr>,    req: Request,) -> Response {    let Some(name) = strip_git_suffix(&name_git) else {        return (StatusCode::NOT_FOUND, "no such repo").into_response();    };    if let Err(r) = validate_name(name) {        return r;    }    // Bare repos live as /srv/git/<name>.git/. We hand the parent dir to    // git http-backend as GIT_PROJECT_ROOT and set PATH_INFO to the rest.    let path_info = format!("/{name}.git/info/refs");    serve(state, req, addr, path_info).await}async fn upload_pack(    Path(name_git): Path<String>,    State(state): State<AppState>,    ConnectInfo(addr): ConnectInfo<SocketAddr>,    req: Request,) -> Response {    let Some(name) = strip_git_suffix(&name_git) else {        return (StatusCode::NOT_FOUND, "no such repo").into_response();    };    if let Err(r) = validate_name(name) {        return r;    }    let path_info = format!("/{name}.git/git-upload-pack");    serve(state, req, addr, path_info).await}async fn receive_pack_forbidden() -> Response {    (        StatusCode::METHOD_NOT_ALLOWED,        "heartwood is read-only; push to the server's git remote directly",    )        .into_response()}async fn serve(state: AppState, req: Request, addr: SocketAddr, path_info: String) -> Response {    let (parts, body) = req.into_parts();    let query = http_backend::extract_query(&parts.uri);    let content_type = parts        .headers        .get(axum::http::header::CONTENT_TYPE)        .and_then(|v| v.to_str().ok())        .map(|s| s.to_string());    let method = parts.method.clone();    let cgi = CgiRequest {        repo_root: &state.config.repo_root,        method,        path_info,        query,        content_type,        remote_addr: addr.ip().to_string(),        headers: parts.headers,    };    match http_backend::serve(cgi, body).await {        Ok(resp) => resp,        Err(e) => {            tracing::error!("http-backend: {e:#}");            (StatusCode::BAD_GATEWAY, "clone unavailable").into_response()        }    }}
added src/routes/commit.rs
@@ -0,0 +1,50 @@use axum::{    extract::{Path, State},    response::{IntoResponse, Response},    routing::get,    Router,};use crate::render::{not_found, render};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new().route("/{name}/commit/{sha}", get(commit_page))}async fn commit_page(    Path((name, sha)): Path<(String, String)>,    State(state): State<AppState>,) -> Response {    let repo_root = state.config.repo_root.clone();    let clone_base = state.config.clone_base.clone();    let name_for_blocking = name.clone();    let sha_for_blocking = sha.clone();    let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {        let repo_path = crate::git::resolve_path(&repo_root, &name_for_blocking)?;        let repo = crate::git::open(&repo_root, &name_for_blocking)?;        let summary = crate::git::repo_summary(&repo_path, &clone_base)?;        let oid = crate::git::resolve_rev(&repo, &sha_for_blocking)?;        let commit = crate::git::commit_info(&repo, oid)?;        let files = crate::git::diff_commit(&repo_path, oid)?;        Ok((summary, commit, files))    })    .await;    match result {        Ok(Ok((summary, commit, files))) => render(            &state,            "commit.html",            &format!("/{name}/commit/{sha}"),            minijinja::context! {                repo => &summary,                commit => &commit,                files => &files,            },        ),        Ok(Err(e)) => not_found(&state, &name, e),        Err(e) => {            tracing::error!("commit_page join: {e}");            (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()        }    }}
added src/routes/index.rs
@@ -0,0 +1,24 @@use axum::{extract::State, response::Response, routing::get, Router};use crate::render::render;use crate::AppState;pub fn router() -> Router<AppState> {    Router::new().route("/", get(index))}async fn index(State(state): State<AppState>) -> Response {    let repo_root = state.config.repo_root.clone();    let clone_base = state.config.clone_base.clone();    let repos = tokio::task::spawn_blocking(move || crate::git::discover(&repo_root, &clone_base))        .await        .ok()        .and_then(|r| r.ok())        .unwrap_or_default();    render(        &state,        "index.html",        "/",        minijinja::context! { repos => repos },    )}
added src/routes/log.rs
@@ -0,0 +1,63 @@use axum::{    extract::{Path, Query, State},    response::{IntoResponse, Response},    routing::get,    Router,};use serde::Deserialize;use crate::render::{not_found, render};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new().route("/{name}/log", get(log_page))}#[derive(Debug, Deserialize)]pub struct LogQuery {    #[serde(default)]    pub rev: Option<String>,    #[serde(default)]    pub limit: Option<usize>,}async fn log_page(    Path(name): Path<String>,    Query(q): Query<LogQuery>,    State(state): State<AppState>,) -> Response {    let repo_root = state.config.repo_root.clone();    let clone_base = state.config.clone_base.clone();    let rev = q.rev.clone();    let limit = q.limit.unwrap_or(100).min(500);    let name_for_blocking = name.clone();    let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {        let repo = crate::git::open(&repo_root, &name_for_blocking)?;        let summary = crate::git::repo_summary(            &crate::git::resolve_path(&repo_root, &name_for_blocking)?,            &clone_base,        )?;        let rev_used = rev.unwrap_or_else(|| summary.default_branch.clone());        let oid = crate::git::resolve_rev(&repo, &rev_used)?;        let commits = crate::git::recent_commits(&repo, oid, limit)?;        Ok((summary, rev_used, commits))    })    .await;    match result {        Ok(Ok((summary, rev_used, commits))) => render(            &state,            "log.html",            &format!("/{name}/log"),            minijinja::context! {                repo => &summary,                rev => rev_used,                commits => &commits,            },        ),        Ok(Err(e)) => not_found(&state, &name, e),        Err(e) => {            tracing::error!("log_page join: {e}");            (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()        }    }}
added src/routes/mod.rs
@@ -0,0 +1,9 @@pub mod atom;pub mod blob;pub mod clone;pub mod commit;pub mod index;pub mod log;pub mod repo;pub mod seo;pub mod tree;
added src/routes/repo.rs
@@ -0,0 +1,61 @@use axum::{    extract::{Path, State},    response::{IntoResponse, Response},    routing::get,    Router,};use crate::render::{not_found, render};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new().route("/{name}", get(repo_page))}async fn repo_page(Path(name): Path<String>, State(state): State<AppState>) -> Response {    let repo_root = state.config.repo_root.clone();    let clone_base = state.config.clone_base.clone();    let name_for_blocking = name.clone();    let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {        let repo = crate::git::open(&repo_root, &name_for_blocking)?;        let summary = crate::git::repo_summary(            &crate::git::resolve_path(&repo_root, &name_for_blocking)?,            &clone_base,        )?;        let head = repo.head_commit().ok();        let commits = if let Some(h) = head.as_ref() {            crate::git::recent_commits(&repo, h.id, 10).unwrap_or_default()        } else {            Vec::new()        };        let readme_html = head.as_ref().and_then(|h| {            crate::git::read_readme(&repo, h.id).map(|(_, bytes)| {                let text = String::from_utf8_lossy(&bytes).into_owned();                crate::markdown::render(&text)            })        });        Ok((summary, commits, readme_html))    })    .await;    let (summary, commits, readme_html) = match result {        Ok(Ok(v)) => v,        Ok(Err(e)) => return not_found(&state, &name, e),        Err(e) => {            tracing::error!("repo_page join: {e}");            return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error")                .into_response();        }    };    render(        &state,        "repo.html",        &format!("/{name}"),        minijinja::context! {            repo => &summary,            commits => &commits,            readme_html => readme_html.map(minijinja::Value::from_safe_string),        },    )}
added src/routes/seo.rs
@@ -0,0 +1,46 @@use axum::{    extract::State,    http::{header, StatusCode},    response::{IntoResponse, Response},    routing::get,    Router,};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/favicon.ico", get(favicon))        .route("/favicon.svg", get(favicon))        .route("/robots.txt", get(robots))}/// Serve the favicon from the built `dist/` directory. Vite copies anything in/// `frontend/static_src/public/` into the build output, so `dist/favicon.svg`/// is what ships in the runtime image (the `frontend/` source tree is not).async fn favicon(State(state): State<AppState>) -> Response {    let full = state.config.root.join("dist/favicon.svg");    match std::fs::read(&full) {        Ok(data) => {            let mut resp = data.into_response();            resp.headers_mut()                .insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());            resp.headers_mut().insert(                header::CACHE_CONTROL,                "public, max-age=86400".parse().unwrap(),            );            resp        }        Err(_) => (StatusCode::NOT_FOUND, "no favicon").into_response(),    }}async fn robots() -> Response {    let body = "User-agent: *\nAllow: /\n";    let mut resp = body.into_response();    resp.headers_mut().insert(        header::CONTENT_TYPE,        "text/plain; charset=utf-8".parse().unwrap(),    );    resp}
added src/routes/tree.rs
@@ -0,0 +1,67 @@use axum::{    extract::{Path, State},    response::{IntoResponse, Response},    routing::get,    Router,};use crate::render::{not_found, render};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/{name}/tree/{rev}", get(tree_root))        .route("/{name}/tree/{rev}/{*path}", get(tree_nested))}async fn tree_root(    Path((name, rev)): Path<(String, String)>,    State(state): State<AppState>,) -> Response {    tree_inner(state, name, rev, String::new()).await}async fn tree_nested(    Path((name, rev, path)): Path<(String, String, String)>,    State(state): State<AppState>,) -> Response {    tree_inner(state, name, rev, path).await}async fn tree_inner(state: AppState, name: String, rev: String, path: String) -> Response {    let repo_root = state.config.repo_root.clone();    let clone_base = state.config.clone_base.clone();    let name_for_blocking = name.clone();    let rev_for_blocking = rev.clone();    let path_for_blocking = path.clone();    let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {        let repo = crate::git::open(&repo_root, &name_for_blocking)?;        let summary = crate::git::repo_summary(            &crate::git::resolve_path(&repo_root, &name_for_blocking)?,            &clone_base,        )?;        let oid = crate::git::resolve_rev(&repo, &rev_for_blocking)?;        let (entries, breadcrumb) = crate::git::list_tree(&repo, oid, &path_for_blocking)?;        Ok((summary, entries, breadcrumb))    })    .await;    match result {        Ok(Ok((summary, entries, breadcrumb))) => render(            &state,            "tree.html",            &format!("/{name}/tree/{rev}/{path}"),            minijinja::context! {                repo => &summary,                rev => &rev,                path => &path,                entries => &entries,                breadcrumb => &breadcrumb,            },        ),        Ok(Err(e)) => not_found(&state, &name, e),        Err(e) => {            tracing::error!("tree_page join: {e}");            (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()        }    }}
added src/templates.rs
@@ -0,0 +1,195 @@use minijinja::value::Value;use minijinja::{path_loader, AutoEscape, Environment, Error, ErrorKind, Output, State};use serde::Serialize;use serde_json::Value as JsonValue;use std::path::Path;/// Jinja2-faithful HTML formatter — does NOT escape `/`, so vite asset URLs/// like `/static/base-abc123.js` come through clean instead of `&#x2f;...`.fn jinja2_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {    if value.is_safe() {        write!(out, "{value}").map_err(Error::from)?;        return Ok(());    }    let auto_escape = match state.auto_escape() {        AutoEscape::Html => true,        AutoEscape::None => false,        _ => return minijinja::escape_formatter(out, state, value),    };    if !auto_escape {        write!(out, "{value}").map_err(Error::from)?;        return Ok(());    }    if let Some(s) = value.as_str() {        write_jinja2_html(out, s).map_err(Error::from)?;    } else if value.is_undefined() || value.is_none() {        // emit nothing    } else {        let stringified = value.to_string();        write_jinja2_html(out, &stringified).map_err(Error::from)?;    }    Ok(())}fn write_jinja2_html(out: &mut Output, s: &str) -> std::fmt::Result {    let mut last = 0;    for (i, b) in s.bytes().enumerate() {        let escape = match b {            b'&' => "&amp;",            b'<' => "&lt;",            b'>' => "&gt;",            b'"' => "&#34;",            b'\'' => "&#39;",            _ => continue,        };        if last < i {            out.write_str(&s[last..i])?;        }        out.write_str(escape)?;        last = i + 1;    }    if last < s.len() {        out.write_str(&s[last..])?;    }    Ok(())}#[derive(Debug, Clone, Serialize)]pub struct RequestCtx {    pub path: String,}fn read_manifest(path: &Path) -> JsonValue {    let text = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());    serde_json::from_str(&text).unwrap_or(JsonValue::Null)}fn lookup_asset(manifest: &JsonValue, entry: &str, kind: &str) -> String {    if let Some(chunk) = manifest.get(entry) {        if kind == "css" {            if let Some(css_arr) = chunk.get("css").and_then(|v| v.as_array()) {                if let Some(first) = css_arr.first().and_then(|v| v.as_str()) {                    return format!("/static/{first}");                }            }        }        if let Some(file) = chunk.get("file").and_then(|v| v.as_str()) {            return format!("/static/{file}");        }    }    format!("/static/{entry}")}pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'static> {    let mut env = Environment::new();    env.set_loader(path_loader(templates_dir));    env.set_formatter(jinja2_html_formatter);    // Without this, every {{ ... }} renders raw — minijinja v2 ships no    // autoescape by default. The default callback enables HTML escape on    // .html/.htm/.xml, which is what we want for templates and atom feeds.    env.set_auto_escape_callback(minijinja::default_auto_escape_callback);    #[cfg(debug_assertions)]    {        let path = manifest_path.to_path_buf();        env.add_function(            "vite_asset",            move |entry: String, kind: Option<String>| -> Result<String, Error> {                let kind = kind.unwrap_or_else(|| "file".to_string());                let manifest = read_manifest(&path);                Ok(lookup_asset(&manifest, &entry, &kind))            },        );    }    #[cfg(not(debug_assertions))]    {        let manifest = read_manifest(manifest_path);        env.add_function(            "vite_asset",            move |entry: String, kind: Option<String>| -> Result<String, Error> {                let kind = kind.unwrap_or_else(|| "file".to_string());                Ok(lookup_asset(&manifest, &entry, &kind))            },        );    }    env.add_filter("shortsha", shortsha_filter);    env.add_filter("naturaltime", naturaltime_filter);    env.add_filter("rfc3339", rfc3339_filter);    env.add_filter("filesize", filesize_filter);    env.add_filter("urlencode", urlencode_filter);    env}fn shortsha_filter(value: Value) -> Result<String, Error> {    let s = value        .as_str()        .map(|s| s.to_string())        .unwrap_or_else(|| value.to_string());    Ok(s.chars().take(8).collect())}fn rfc3339_filter(value: Value) -> Result<String, Error> {    let ts = value.as_i64().ok_or_else(|| {        Error::new(ErrorKind::InvalidOperation, "rfc3339 expects an integer")    })?;    let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0).ok_or_else(|| {        Error::new(ErrorKind::InvalidOperation, "rfc3339: timestamp out of range")    })?;    Ok(dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))}fn urlencode_filter(value: Value) -> Result<String, Error> {    let s = value        .as_str()        .map(|s| s.to_string())        .unwrap_or_else(|| value.to_string());    Ok(urlencoding::encode(&s).into_owned())}fn filesize_filter(value: Value) -> Result<String, Error> {    let bytes = value.as_i64().ok_or_else(|| {        Error::new(ErrorKind::InvalidOperation, "filesize expects an integer")    })?;    let b = bytes as f64;    Ok(if b < 1024.0 {        format!("{} B", bytes)    } else if b < 1024.0 * 1024.0 {        format!("{:.1} KB", b / 1024.0)    } else if b < 1024.0 * 1024.0 * 1024.0 {        format!("{:.1} MB", b / (1024.0 * 1024.0))    } else {        format!("{:.1} GB", b / (1024.0 * 1024.0 * 1024.0))    })}/// Render a unix-seconds timestamp as "5 minutes ago" / "3 days ago" / etc.fn naturaltime_filter(value: Value) -> Result<String, Error> {    let secs_ago = if let Some(ts) = value.as_i64() {        chrono::Utc::now().timestamp().saturating_sub(ts)    } else {        return Ok(value.to_string());    };    if secs_ago < 0 {        return Ok("in the future".to_string());    }    Ok(if secs_ago < 60 {        "just now".to_string()    } else if secs_ago < 3600 {        let m = secs_ago / 60;        format!("{m} minute{} ago", if m == 1 { "" } else { "s" })    } else if secs_ago < 86_400 {        let h = secs_ago / 3600;        format!("{h} hour{} ago", if h == 1 { "" } else { "s" })    } else if secs_ago < 86_400 * 30 {        let d = secs_ago / 86_400;        format!("{d} day{} ago", if d == 1 { "" } else { "s" })    } else if secs_ago < 86_400 * 365 {        let m = secs_ago / (86_400 * 30);        format!("{m} month{} ago", if m == 1 { "" } else { "s" })    } else {        let y = secs_ago / (86_400 * 365);        format!("{y} year{} ago", if y == 1 { "" } else { "s" })    })}
added templates/atom.xml
@@ -0,0 +1,22 @@<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom">  <title>{{ repo.name }}</title>  <subtitle>{{ repo.description }}</subtitle>  <link rel="self" href="{{ clone_base }}/{{ repo.name }}/atom.xml"/>  <link rel="alternate" type="text/html" href="{{ clone_base }}/{{ repo.name }}"/>  <id>{{ clone_base }}/{{ repo.name }}</id>  <updated>{{ now.rfc3339 }}</updated>  {% for c in commits %}  <entry>    <id>{{ clone_base }}/{{ repo.name }}/commit/{{ c.id }}</id>    <title>{{ c.summary }}</title>    <link rel="alternate" type="text/html" href="{{ clone_base }}/{{ repo.name }}/commit/{{ c.id }}"/>    <author>      <name>{{ c.author }}</name>      <email>{{ c.author_email }}</email>    </author>    <updated>{{ c.time|rfc3339 }}</updated>    <content type="text">{{ c.message }}</content>  </entry>  {% endfor %}</feed>
added templates/base.html
@@ -0,0 +1,97 @@<!doctype html><html lang="en"><head>  <meta charset="utf-8">  <meta name="viewport" content="width=device-width, initial-scale=1">  <title>{% block title %}{% endblock %}{% if self.title() %} · {% endif %}{{ site.title }}</title>  <meta name="description" content="{% block description %}{{ site.tagline }}{% endblock %}">  {% if base_url %}<base href="{{ base_url }}">{% endif %}  <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">  {% block extra_head %}{% endblock %}  <link href="{{ vite_asset('static_src/index.js', 'css') }}" rel="stylesheet"></head><body>  <header class="topbar">    <div class="container topbar__row">      <a class="brand" href="/">        <svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">          <circle class="brand-mark__r1" cx="32" cy="32" r="28" fill="none" stroke="#6b9e78" stroke-width="2"/>          <circle class="brand-mark__r2" cx="32" cy="32" r="20" fill="none" stroke="#8aae8e" stroke-width="1.5"/>          <circle class="brand-mark__r3" cx="32" cy="32" r="13" fill="none" stroke="#c4a574" stroke-width="1.5"/>          <circle class="brand-mark__core" cx="32" cy="32" r="7"  fill="#b8543a"/>          <line x1="32" y1="4" x2="32" y2="11" stroke="#3d2f24" stroke-width="2"/>        </svg>        <span class="brand-text">{{ site.title }}</span>      </a>      <span class="topbar__divider" aria-hidden="true"></span>      <span class="tagline">{{ site.tagline }}</span>      <nav class="topnav" aria-label="primary">        <a href="/" class="topnav__link {% if request.path == '/' %}is-active{% endif %}">          <span class="topnav__dot" aria-hidden="true"></span>repos        </a>      </nav>    </div>  </header>  {% block breadcrumbs %}{% endblock %}  <main>    <div class="container">      {% block main %}{% endblock %}    </div>  </main>  <footer class="footer">    <svg class="footer-rings" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" aria-hidden="true">      <circle cx="200" cy="200" r="190" fill="none" stroke="currentColor" stroke-width="1"/>      <circle cx="200" cy="200" r="158" fill="none" stroke="currentColor" stroke-width="1"/>      <circle cx="200" cy="200" r="128" fill="none" stroke="currentColor" stroke-width="1"/>      <circle cx="200" cy="200" r="100" fill="none" stroke="currentColor" stroke-width="1"/>      <circle cx="200" cy="200" r="74"  fill="none" stroke="currentColor" stroke-width="1"/>      <circle cx="200" cy="200" r="50"  fill="none" stroke="currentColor" stroke-width="1"/>      <circle cx="200" cy="200" r="28"  fill="none" stroke="currentColor" stroke-width="1"/>    </svg>    <div class="container footer__grid">      <div class="footer__about">        <div class="footer__label">// {{ site.title }}</div>        <p>Minimal git repo browser by          <a href="https://isaacbythewood.com/">Isaac Bythewood</a>.          Just enough chrome to read code: README and recent commits, tree          and blob browsing with syntax highlighting, unified diffs per          commit, and an Atom feed. Clone over HTTPS. Read-only,          single-operator, no Github required.</p>        <p class="footer__tagline">{{ site.tagline }}.</p>      </div>      <div class="footer__col">        <div class="footer__label">// Pages</div>        <ul>          <li><a href="/">Repos</a></li>          <li><a href="https://github.com/overshard/heartwood" target="_blank" rel="noopener">Source</a></li>        </ul>      </div>      <div class="footer__col">        <div class="footer__label">// Links</div>        <ul>          <li><a href="https://isaacbythewood.com/" target="_blank" rel="noopener">Portfolio</a></li>          <li><a href="https://blog.bythewood.me/" target="_blank" rel="noopener">Blog</a></li>          <li><a href="https://analytics.bythewood.me/" target="_blank" rel="noopener">Analytics</a></li>          <li><a href="https://status.bythewood.me/" target="_blank" rel="noopener">Status</a></li>        </ul>      </div>    </div>  </footer>  <div class="footer-bar">    <div class="container footer-bar__row">      <small>&copy; {{ now.year }} Isaac Bythewood · Some rights reserved</small>      <a href="https://github.com/overshard/heartwood" target="_blank" rel="noopener" class="footer-bar__link" aria-label="GitHub">        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">          <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>        </svg>      </a>    </div>  </div>  <script type="module" src="{{ vite_asset('static_src/index.js') }}"></script></body></html>
added templates/blob.html
@@ -0,0 +1,39 @@{% extends "base.html" %}{% block title %}{{ repo.name }} · {{ path }}{% endblock %}{% block breadcrumbs %}<div class="breadcrumbs">  <div class="container">    <a href="/">repos</a>    <span class="breadcrumbs__sep">/</span>    <a href="/{{ repo.name }}">{{ repo.name }}</a>    <span class="breadcrumbs__sep">/</span>    <a href="/{{ repo.name }}/tree/{{ rev }}">tree</a>    {% set parts = path|split('/') %}    {% set cumulative = '' %}    {% for part in parts %}      <span class="breadcrumbs__sep">/</span>      {% if not loop.last %}        {% set cumulative = cumulative ~ '/' ~ part %}        <a href="/{{ repo.name }}/tree/{{ rev }}{{ cumulative }}">{{ part }}</a>      {% else %}        <strong>{{ part }}</strong>      {% endif %}    {% endfor %}    <span class="breadcrumbs__ref">@ {{ rev }}</span>  </div></div>{% endblock %}{% block main %}<div class="blob-head">  <span class="blob-head__size">{{ size|filesize }}</span>  <a class="blob-head__raw" href="/{{ repo.name }}/raw/{{ rev }}/{{ path }}">raw</a></div>{% if is_binary %}<p class="empty">binary file. <a href="/{{ repo.name }}/raw/{{ rev }}/{{ path }}">download raw</a></p>{% else %}<div class="blob">{{ highlighted }}</div>{% endif %}{% endblock %}
added templates/commit.html
@@ -0,0 +1,45 @@{% extends "base.html" %}{% block title %}{{ repo.name }} · {{ commit.short_id }}{% endblock %}{% block breadcrumbs %}<div class="breadcrumbs">  <div class="container">    <a href="/">repos</a> / <a href="/{{ repo.name }}">{{ repo.name }}</a> / <a href="/{{ repo.name }}/log">log</a> / <strong>{{ commit.short_id }}</strong>  </div></div>{% endblock %}{% block main %}<section class="commit-head">  <h1>{{ commit.summary }}</h1>  <p class="meta">    <span class="sha">{{ commit.short_id }}</span>    by <strong>{{ commit.author }}</strong>    · {{ commit.time|naturaltime }}  </p>  {% if commit.message and commit.message|length > commit.summary|length + 1 %}  <pre class="commit-msg">{{ commit.message }}</pre>  {% endif %}</section><section class="diff">  {% for f in files %}  <article class="file-diff">    <header class="file-diff__head">      <span class="status status--{{ f.status }}">{{ f.status }}</span>      <span class="path">{% if f.old_path %}{{ f.old_path }} → {% endif %}{{ f.path }}</span>    </header>    {% if f.is_binary %}    <p class="empty">binary file</p>    {% else %}    {% for h in f.hunks %}    {# No literal newlines inside the <pre>: .line is display:block so each       span already starts a new line. A trailing \n in the source would       double the vertical space per line. #}    <pre class="hunk"><span class="hunk-hdr">{{ h.header }}</span>{% for l in h.lines %}<span class="line line--{{ l.kind }}">{{ l.text }}</span>{% endfor %}</pre>    {% endfor %}    {% endif %}  </article>  {% endfor %}</section>{% endblock %}
added templates/index.html
@@ -0,0 +1,32 @@{% extends "base.html" %}{% block title %}repositories{% endblock %}{% block main %}<section class="hero">  <h1>repositories</h1>  <p class="lede">code from <a href="https://isaacbythewood.com">isaac bythewood</a>, served straight from the bare repos.</p></section>{% if repos %}<ul class="repo-list">  {% for repo in repos %}  <li class="repo-row">    <div class="repo-row__head">      <a class="repo-row__name" href="/{{ repo.name }}">{{ repo.name }}</a>      {% if repo.head_time %}      <span class="repo-row__age" title="{{ repo.head_id|shortsha }}">{{ repo.head_time|naturaltime }}</span>      {% endif %}    </div>    {% if repo.description %}    <p class="repo-row__desc">{{ repo.description }}</p>    {% endif %}    {% if repo.head_summary %}    <p class="repo-row__last"><span class="repo-row__last-sha">{{ repo.head_id|shortsha }}</span> {{ repo.head_summary }}</p>    {% endif %}  </li>  {% endfor %}</ul>{% else %}<p class="empty">No repositories yet.</p>{% endif %}{% endblock %}
added templates/log.html
@@ -0,0 +1,26 @@{% extends "base.html" %}{% block title %}{{ repo.name }} · log{% endblock %}{% block breadcrumbs %}<div class="breadcrumbs">  <div class="container">    <a href="/">repos</a> / <a href="/{{ repo.name }}">{{ repo.name }}</a> / <strong>log</strong>    <span class="breadcrumbs__ref">@ {{ rev }}</span>  </div></div>{% endblock %}{% block main %}<h1>log</h1><ul class="log-list">  {% for c in commits %}  <li class="log-row">    <a class="sha" href="/{{ repo.name }}/commit/{{ c.id }}">{{ c.short_id }}</a>    <div class="log-row__body">      <p class="summary">{{ c.summary }}</p>      <p class="meta">{{ c.author }} · {{ c.time|naturaltime }}</p>    </div>  </li>  {% endfor %}</ul>{% endblock %}
added templates/not_found.html
@@ -0,0 +1,10 @@{% extends "base.html" %}{% block title %}not found{% endblock %}{% block main %}<section class="hero">  <h1>not found</h1>  <p class="lede">no repository named <code>{{ name }}</code>.</p>  <p><a href="/">back to repo list</a></p></section>{% endblock %}
added templates/repo.html
@@ -0,0 +1,58 @@{% extends "base.html" %}{% block title %}{{ repo.name }}{% endblock %}{% block breadcrumbs %}<div class="breadcrumbs">  <div class="container">    <a href="/">repos</a> / <strong>{{ repo.name }}</strong>  </div></div>{% endblock %}{% block main %}<section class="repo-head">  <div class="repo-head__title-row">    <h1>{{ repo.name }}</h1>    <span class="repo-head__branch">{{ repo.default_branch }}</span>  </div>  {% if repo.description %}<p class="repo-head__desc">{{ repo.description }}</p>{% endif %}  <div class="clone-box">    <label>clone</label>    <input type="text" readonly value="git clone {{ repo.clone_url }}">  </div>  <nav class="repo-nav">    <a href="/{{ repo.name }}">overview</a>    <a href="/{{ repo.name }}/tree/{{ repo.default_branch }}">tree</a>    <a href="/{{ repo.name }}/log">log</a>    <a href="/{{ repo.name }}/atom.xml">atom</a>  </nav></section><div class="two-col">  <section class="col-main">    {% if readme_html %}    <article class="readme">{{ readme_html }}</article>    {% else %}    <p class="empty">No README in this repo.</p>    {% endif %}  </section>  <aside class="col-side">    <h2 class="side-h">recent commits</h2>    {% if commits %}    <ul class="commit-list">      {% for c in commits %}      <li>        <a class="sha" href="/{{ repo.name }}/commit/{{ c.id }}">{{ c.short_id }}</a>        <span class="summary">{{ c.summary }}</span>        <span class="meta">{{ c.author }} · {{ c.time|naturaltime }}</span>      </li>      {% endfor %}    </ul>    <p><a href="/{{ repo.name }}/log">all commits →</a></p>    {% else %}    <p class="empty">No commits yet.</p>    {% endif %}  </aside></div>{% endblock %}
added templates/tree.html
@@ -0,0 +1,50 @@{% extends "base.html" %}{% block title %}{{ repo.name }} · {{ path or '/' }}{% endblock %}{% block breadcrumbs %}<div class="breadcrumbs">  <div class="container">    <a href="/">repos</a>    <span class="breadcrumbs__sep">/</span>    <a href="/{{ repo.name }}">{{ repo.name }}</a>    <span class="breadcrumbs__sep">/</span>    <a href="/{{ repo.name }}/tree/{{ rev }}">tree</a>    {% set cumulative = '' %}    {% for part in breadcrumb %}      {% set cumulative = cumulative ~ '/' ~ part %}      <span class="breadcrumbs__sep">/</span>      <a href="/{{ repo.name }}/tree/{{ rev }}{{ cumulative }}">{{ part }}</a>    {% endfor %}    <span class="breadcrumbs__ref">@ {{ rev }}</span>  </div></div>{% endblock %}{% block main %}<table class="tree">  <tbody>    {% if breadcrumb %}    {% set parent_path = breadcrumb[:-1]|join('/') %}    <tr>      <td class="tree__name"><a href="/{{ repo.name }}/tree/{{ rev }}{% if parent_path %}/{{ parent_path }}{% endif %}">..</a></td>      <td class="tree__size"></td>    </tr>    {% endif %}    {% for e in entries %}    <tr>      <td class="tree__name tree__name--{{ e.kind }}">        {% set basepath = breadcrumb|join('/') %}        {% if e.kind == 'tree' %}        <a href="/{{ repo.name }}/tree/{{ rev }}/{% if basepath %}{{ basepath }}/{% endif %}{{ e.name }}">{{ e.name }}/</a>        {% elif e.kind == 'blob' %}        <a href="/{{ repo.name }}/blob/{{ rev }}/{% if basepath %}{{ basepath }}/{% endif %}{{ e.name }}">{{ e.name }}</a>        {% else %}        <span>{{ e.name }}</span>        {% endif %}      </td>      <td class="tree__size">{% if e.size is not none %}{{ e.size|filesize }}{% endif %}</td>    </tr>    {% endfor %}  </tbody></table>{% endblock %}