@@ -61,7 +61,7 @@ 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).returns 405 (repos is read-only).**Templates (`templates/`):** Jinja2-compatible. `base.html` is the shell;`index.html` is the repo list; `repo.html` is README + recent commits +
@@ -96,21 +96,21 @@ URL gets the themed shell instead of a plain-text 404.All 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.- `REPOS_ROOT=.` — project root (where `templates/` and `dist/` live).- `REPOS_REPO_ROOT=/srv/git` — directory containing `<name>.git/` bare repos.- `REPOS_CLONE_BASE=https://repos.bythewood.me` — public origin used in clone URLs and atom self-links.- `REPOS_TITLE=repos` — topbar title.- `REPOS_TAGLINE=` — optional tagline; hidden in the topbar and footer when empty.- `BASE_URL=` — optional `<base href>` if served on a subpath.In dev, `make run` sets `HEARTWOOD_REPO_ROOT=./fixtures/git` so you don'tIn dev, `make run` sets `REPOS_REPO_ROOT=./fixtures/git` so you don'tneed a real `/srv/git/`.## Layout```heartwood/repos/├── Cargo.toml, Cargo.lock├── Makefile, README.md, LICENSE.md, CLAUDE.md├── src/
@@ -146,7 +146,7 @@ heartwood/## Key Routes- `/` — repo list (auto-discovered from `HEARTWOOD_REPO_ROOT`)- `/` — repo list (auto-discovered from `REPOS_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
@@ -1239,33 +1239,6 @@ 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"
@@ -2054,6 +2027,33 @@ version = "0.8.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"[[package]]name = "repos"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 = "rustix"version = "0.38.44"
@@ -1,10 +1,10 @@[package]name = "heartwood"name = "repos"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"default-run = "repos"[dependencies]axum = { version = "0.8", features = ["macros"] }
@@ -30,7 +30,7 @@ async-stream = "0.3"mime_guess = "2"[profile.release]# Match darkfurrow: small + fast. Heartwood has no chunky dep tree# Match darkfurrow: small + fast. Repos has no chunky dep tree# (no typst, no chromium) so LTO is tractable.lto = truecodegen-units = 1
@@ -16,7 +16,7 @@ RUN 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 cp target/release/repos /app/repos# ----- runtime -----FROM alpine:3.23
@@ -36,7 +36,7 @@ RUN git config --system --add safe.directory '*'WORKDIR /appCOPY --from=builder /app/heartwood ./heartwoodCOPY --from=builder /app/repos ./reposCOPY --from=builder /app/dist ./distCOPY templates ./templates
@@ -46,7 +46,7 @@ RUN addgroup -S -g 1000 app && \USER appENV PORT=8000ENV HEARTWOOD_REPO_ROOT=/srv/gitENV REPOS_REPO_ROOT=/srv/gitEXPOSE 8000CMD ["./heartwood"]CMD ["./repos"]
@@ -4,7 +4,7 @@ PORT ?= 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/gitREPOS_REPO_ROOT ?= $(CURDIR)/fixtures/git.DEFAULT_GOAL := run.PHONY: run build start clean push seed seed-reset
@@ -15,7 +15,7 @@ HEARTWOOD_REPO_ROOT ?= $(CURDIR)/fixtures/gitrun: 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 PORT=$(PORT) REPOS_REPO_ROOT=$(REPOS_REPO_ROOT) $(CARGO) run# Production build (Vite assets + release binary)build: frontend/node_modules
@@ -24,7 +24,7 @@ build: frontend/node_modules# Run the release binary (after `make build`)start: PORT=$(PORT) ./target/release/heartwood PORT=$(PORT) ./target/release/repos# Wipe regenerable output. Leaves fixtures/ alone so `make run` after `make# clean` doesn't blow away your seeded repos.
@@ -1,4 +1,4 @@# Heartwood# ReposA 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.
@@ -44,7 +44,7 @@ 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`)- `git` on `PATH` (repos shells out to it for `http-backend` and `show`)## Running locally
@@ -55,7 +55,7 @@ For local development:`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.point `REPOS_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 of
@@ -80,14 +80,14 @@ All 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 || `REPOS_ROOT` | no (default `.`) | Project root (where `templates/` and `dist/` live) || `REPOS_REPO_ROOT` | no (default `/srv/git`) | Directory of `<name>.git/` bare repos || `REPOS_CLONE_BASE` | no (default `https://repos.bythewood.me`) | Public origin used in clone URLs and atom self-links || `REPOS_TITLE` | no (default `repos`) | Topbar title || `REPOS_TAGLINE` | no (default empty) | Optional topbar tagline; hidden when empty || `BASE_URL` | no | `<base href>` if served on a subpath |In dev, `make run` sets `HEARTWOOD_REPO_ROOT` to `./fixtures/git` so you don'tIn dev, `make run` sets `REPOS_REPO_ROOT` to `./fixtures/git` so you don'tneed a real `/srv/git/`.
@@ -96,7 +96,7 @@ need a real `/srv/git/`.| Target | What it does ||---|---|| `make run` (default) | Vite watch + `cargo run` on port 8000 || `make build` | Vite assets + release binary (`target/release/heartwood`) || `make build` | Vite assets + release binary (`target/release/repos`) || `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/` |
@@ -111,7 +111,7 @@ There are no tests or linters configured.## Key Routes- `/`: repo list (auto-discovered from `HEARTWOOD_REPO_ROOT`, sorted by most-recent HEAD)- `/`: repo list (auto-discovered from `REPOS_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
@@ -132,19 +132,19 @@ 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 --bare mkdir -p /srv/git/repos.git && cd /srv/git/repos.git && git init --bareLocal: git remote add server root@heartwood.example.com:/srv/git/heartwood.git git remote add server root@repos.example.com:/srv/git/repos.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 mkdir -p /srv/docker && cd /srv/docker && git clone /srv/git/repos.git repos && cd /srv/docker/repos 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 cp samplefiles/env.sample .env # edit REPOS_CLONE_BASE and REPOS_TITLE if you want cp samplefiles/post-receive.sample /srv/git/repos.git/hooks/post-receive && chmod +x /srv/git/repos.git/hooks/post-receive docker-compose up --build --detach rc-update add caddy boot && service caddy start
modified
docker-compose.yml
@@ -1,11 +1,11 @@services: web: container_name: heartwood container_name: repos build: . init: true env_file: .env environment: HEARTWOOD_REPO_ROOT: /srv/git REPOS_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
modified
frontend/static_src/styles/base.scss
@@ -1,4 +1,4 @@// heartwood — palette evokes the dense red-brown center of a tree.// repos — palette evokes the dense red-brown center of a tree (heartwood).:root { --bg: #1a1410;
@@ -7,7 +7,7 @@ --border: #3d2f24; --text: #e8dccc; --text-mute: #9c8a72; --accent: #b8543a; // heartwood red --accent: #b8543a; // heartwood-red core --accent-2: #c4a574; // sap gold --green: #6b9e78; --code-bg: #15100c;
@@ -68,7 +68,7 @@ code, pre, .sha, .clone-box input { 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. // mark: red core, sap-gold middle ring, green outer ring. // Reads as a tree-ring slice across the top of every page. &::before { content: "";
modified
frontend/vite.config.js
@@ -2,7 +2,7 @@ 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.// Single entry point, no per-page scripts to split.export default defineConfig({ base: "/static/", publicDir: resolve(__dirname, "static_src/public"),
modified
samplefiles/Caddyfile.sample
@@ -1,5 +1,5 @@heartwood.bythewood.me { reverse_proxy heartwood:8000repos.bythewood.me { reverse_proxy repos:8000 import common}
modified
samplefiles/env.sample
@@ -1,4 +1,4 @@# Production .env for heartwood (drop in next to docker-compose.yml).# Production .env for repos (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.
@@ -8,11 +8,11 @@ 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/gitREPOS_REPO_ROOT=/srv/git# Public origin used for clone URLs and atom feed self-links.HEARTWOOD_CLONE_BASE=https://heartwood.bythewood.meREPOS_CLONE_BASE=https://repos.bythewood.me# Optional cosmetics.HEARTWOOD_TITLE=heartwoodHEARTWOOD_TAGLINE=every commit a ringREPOS_TITLE=reposREPOS_TAGLINE=
modified
samplefiles/post-receive.sample
@@ -1,7 +1,7 @@#!/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.# post-receive hook generated for repos by taproot's quickstart.sh.# Lives on the alpine server at /srv/git/repos.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.
@@ -10,11 +10,11 @@ while read oldrev newrev ref; do if [ "$ref" = "refs/heads/master" ]; then unset GIT_DIR START_TIME=$(date +%s) cd /srv/docker/heartwood cd /srv/docker/repos 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 # Resolved via `compose ps` (not `repos`) 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
@@ -27,19 +27,17 @@ pub struct Config {impl AppState { pub fn from_env() -> Self { let root: PathBuf = std::env::var("HEARTWOOD_ROOT") let root: PathBuf = std::env::var("REPOS_ROOT") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(".")); let repo_root: PathBuf = std::env::var("HEARTWOOD_REPO_ROOT") let repo_root: PathBuf = std::env::var("REPOS_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 clone_base = std::env::var("REPOS_CLONE_BASE") .unwrap_or_else(|_| "https://repos.bythewood.me".to_string()); let site_title = std::env::var("REPOS_TITLE").unwrap_or_else(|_| "repos".to_string()); let site_tagline = std::env::var("REPOS_TAGLINE").unwrap_or_default(); let templates_dir = root.join("templates"); let manifest_path = root.join("dist/.vite/manifest.json");
@@ -1,5 +1,5 @@//! 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//! repos 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.
@@ -1280,7 +1280,7 @@ fn seed_one( // 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}")); let work = std::env::temp_dir().join(format!("repos-seed-{name}")); if work.exists() { fs::remove_dir_all(&work).map_err(|e| e.to_string())?; }
@@ -1349,7 +1349,7 @@ fn seed_one( return Err(format!("clone exited {:?}", status.code())); } // Per-repo description (heartwood reads `description` for the landing // Per-repo description (repos 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())?;
modified
src/highlight.rs
@@ -13,7 +13,7 @@ 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 // base16-eighties.dark reads well on the dark palette and ships // with syntect's default themes, so no theme files to vendor. ts.themes .remove("base16-eighties.dark")
@@ -32,7 +32,7 @@ async fn main() -> anyhow::Result<()> { let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!("heartwood listening on http://{addr}"); tracing::info!("repos listening on http://{addr}"); // ConnectInfo so clone routes can pass REMOTE_ADDR to git http-backend. axum::serve( listener,
modified
src/routes/clone.rs
@@ -76,7 +76,7 @@ async fn upload_pack(async fn receive_pack_forbidden() -> Response { ( StatusCode::METHOD_NOT_ALLOWED, "heartwood is read-only; push to the server's git remote directly", "repos is read-only; push to the server's git remote directly", ) .into_response()}
modified
templates/base.html
@@ -4,7 +4,7 @@ <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 %}"> <meta name="description" content="{% block description %}minimal git repo browser{% endblock %}"> {% if base_url %}<base href="{{ base_url }}">{% endif %} <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> {% block extra_head %}{% endblock %}
@@ -23,8 +23,10 @@ </svg> <span class="brand-text">{{ site.title }}</span> </a> {% if site.tagline %} <span class="topbar__divider" aria-hidden="true"></span> <span class="tagline">{{ site.tagline }}</span> {% endif %} <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
@@ -60,13 +62,13 @@ 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> {% if site.tagline %}<p class="footer__tagline">{{ site.tagline }}.</p>{% endif %} </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> <li><a href="https://github.com/overshard/repos" target="_blank" rel="noopener">Source</a></li> </ul> </div> <div class="footer__col">
@@ -84,7 +86,7 @@ <div class="footer-bar"> <div class="container footer-bar__row"> <small>© {{ 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"> <a href="https://github.com/overshard/repos" 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>