clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Import forge

cat committed b10421e
A personal forge built from plain git: an ssh account with forced
commands and hooks, stagit-style static pages built with 11ty, stacked
reviews with per-push revisions, signed local CI attestations, sites
and previews shipped as refs, backups with restic, and a pages mirror
for an edge to serve while the forge is down.
commit
b10421e1e036b434da77b80265b008e173d64ca4
author
cat <admin@dev.site>
kind
feat risk score 0.4 of 3, Jev's estimate
92 files changed, +16054 -0
A.dockerignore7+
A.forge/ci91+
A.forge/sites2+
A.gitignore6+
ADockerfile37+
AREADME.md757+++++++
Abin/forge2284+++++++++++++++++++++
Ademo/dev.ts63+
Ademo/make-demo.sh515+++++
Ademo/serve.ts66+
Adeploy/edge/Caddyfile62+
Adeploy/edge/Dockerfile15+
Adeploy/edge/fly.toml45+
Adeploy/edge/start.sh69+
Adeploy/slate/compose.yaml34+
Adeploy/slate/deploy.sh9+
Aeleventy.config.ts81+
Aguide/guide.css375+++
Aguide/guide.js22+
Aguide/index.html344+++
Aguide/internals.html267++
Alib/assets.ts112+
Alib/attest.ts119+
Alib/cache.ts58+
Alib/check-links.ts43+
Alib/diff.ts106+
Alib/discussion.ts83+
Alib/events-server.ts107+
Alib/forge.ts511+++++
Alib/git.ts68+
Alib/highlight.ts136+
Alib/hosts.ts186++
Alib/jev-commits.ts374+++
Alib/jev-fill.ts59+
Alib/jev-reviews.ts418++++
Alib/jev-secrets.ts373+++
Alib/jev.ts190++
Alib/markdown.ts118+
Alib/metrics-report.ts56+
Alib/metrics.ts97+
Alib/pages-server.ts358+++
Alib/render-page.ts186++
Alib/render.ts205++
Alib/repo.ts332+++
Alib/review-list.ts92+
Alib/reviews.ts162+
Alib/search.ts33+
Alib/sites.ts62+
Alib/types.ts380+++
Alib/urls.ts64+
Apackage-lock.json3296++++++++++++++++++++++++++++++
Apackage.json27+
Aserver/Caddyfile.example27+
Aserver/container/forge-admin9+
Aserver/container/sshd.conf11+
Aserver/container/start.sh148+
Aserver/provision.sh113+
Asite/_includes/base.njk72+
Asite/_includes/discussion.njk50+
Asite/_includes/help.njk18+
Asite/_includes/logo.njk1+
Asite/_includes/macros.njk119+
Asite/_includes/search.njk32+
Asite/assets/diffs.ts174++
Asite/assets/fixi.js91+
Asite/assets/keys.ts179++
Asite/assets/live.ts60+
Asite/assets/logo.svg1+
Asite/assets/search.ts240++
Asite/assets/ssexi.js90+
Asite/assets/style.css359+++
Asite/assets/ui.ts97+
Asite/index.njk17+
Asite/owner.njk23+
Asite/repo/atom.njk22+
Asite/repo/blob.njk21+
Asite/repo/branches.njk8+
Asite/repo/commit.njk42+
Asite/repo/home.njk33+
Asite/repo/log.njk10+
Asite/repo/refs.njk32+
Asite/repo/releases.njk30+
Asite/repo/repo.11tydata.json4+
Asite/repo/review-diff.njk33+
Asite/repo/review.njk130+
Asite/repo/reviews.njk43+
Asite/repo/revision.njk46+
Asite/repo/sites.njk41+
Asite/repo/tree.njk16+
Asite/repo/wiki.njk27+
Asite/site.11tydata.json3+
Atsconfig.json20+
A .dockerignore +7 -0
A .forge/ci +91 -0
@@ -0,0 +1,91 @@
1+#!/bin/sh2+# CI for the forge itself: lint the CLI, type-check, then build the demo end to end.3+set -e4+sh -n bin/forge demo/make-demo.sh5+npm ci --silent --no-audit --no-fund6+npx tsc -p .7+# Strict templates: printing an undefined or null value fails the build.8+FORGE_STRICT=1 sh demo/make-demo.sh >/dev/null9+test -f demo/root/.forge/www/dami/greet/reviews/3/index.html10+grep -q 'class="ci pass"' demo/root/.forge/www/dami/greet/reviews/3/index.html11+# Highlighting in file pages, diffs (with a 4-digit gutter) and markdown fences; search indexes.12+www=demo/root/.forge/www13+grep -q 'class="hljs-title class_"' $www/dami/greet/blob/main/web/greet.js/index.html14+grep -q 'style="--w:4".*class="hljs-' $www/dami/greet/reviews/7/index.html15+grep -q '<code class="language-go">resp, err := http.Get(<span class="hljs-string">' $www/dami/greet/blob/main/docs/examples.md/index.html16+grep -q '"reviews":\[\[7,"Regional notes"' $www/dami/greet/search.json17+grep -q '"dami/greet","say hello, with flags"' $www/repos.json18+grep -q 'data-search="/dami/greet/search.json"' $www/dami/greet/index.html19+# Templates render the same without 11ty (lib/render-page.ts), with the20+# demo build's settings; every asset a page links exists under its hashed21+# name; link previews.22+tmp=$(mktemp -d)23+FORGE_ROOT=$PWD/demo/root FORGE_SSH=git@forge.local FORGE_CACHE=$tmp FORGE_STRICT=1 node lib/render-page.ts check $www24+rm -rf "$tmp"25+for a in $(grep -rhoE '(href|src|data-mermaid)="/assets/[^"]+"' $www --include=index.html | sed -E 's/^[a-z-]+="//; s/"$//' | sort -u); do26+ test -f "$www$a" || { echo "missing asset $a"; exit 1; }27+done28+grep -q '<meta property="og:title" content="#3 Usage and --help - dami/greet">' $www/dami/greet/reviews/3/index.html29+# Review discussion: description, an outdated comment, a stale approval, a30+# comment inline in the combined diff, approvals in the list, reviews.json.31+rv=demo/root/.forge/www/dami/greet32+grep -q 'class="box review-desc"' $rv/reviews/3/index.html33+grep -q 'class="comment outdated"' $rv/reviews/3/index.html34+grep -q 'changed since' $rv/reviews/3/index.html35+grep -q 'class="cmt" id="comment-3"' $rv/reviews/3/diff/index.html36+grep -q 'class="appr"' $rv/reviews/index.html37+node lib/review-list.ts $rv/reviews.json | grep -q '#2 .*approved by ana'38+# Deleting a branch removes its previews; gc then frees their files.39+w=demo/root/.forge/www40+test ! -e "$w/previews/dami/greet/docs/dami~old-idea"41+if grep -q old-idea "$w/dami/greet/sites/index.html"; then echo "sites page lists a deleted preview"; exit 1; fi42+# Push to create, and landing on the server: ana/notes was made by a push and43+# its review landed by ssh land, as dami/greet's first was; the hooks logged44+# the deleted branches, and main is where they were.45+n=demo/root/ana/notes.git46+test -x $n/hooks/pre-receive47+grep -q '^deleted ' $n/forge/reviews/148+test "$(git --git-dir=$n rev-parse main)" = "$(awk '$1 == "rev" { s = $3 } END { print s }' $n/forge/reviews/1)"49+grep -q '^deleted ' demo/root/dami/greet.git/forge/reviews/150+# Pinned revisions stay out of what clients see; the build wrote a commit-graph.51+g=demo/root/dami/greet.git52+test "$(git ls-remote $g | grep -c refs/forge/)" = 053+test "$(git --git-dir=$g for-each-ref refs/forge/ | wc -l)" -gt 054+test -f $g/objects/info/commit-graphs/commit-graph-chain55+gc=$(FORGE_ROOT=$PWD/demo/root sh bin/forge gc --age 0)56+printf '%s\n' "$gc" | grep -q '^removed store'57+test -f "$w/sites/dami/greet/docs/index.html"58+# After gc: a multi-pack index with a bitmap, packed refs, and old revisions59+# still there for range-diffs (#4 was rebased between its last two).60+test -f $g/objects/pack/multi-pack-index61+ls $g/objects/pack/multi-pack-index-*.bitmap >/dev/null62+grep -q ' refs/forge/keep/' $g/packed-refs63+git --git-dir=$g range-diff --no-color $(awk '$1 == "rev" { r = p; p = $5 ".." $3 } END { print r, p }' $g/forge/reviews/4) | grep -q .64+# The guide: there, deployed in the demo, and no broken internal links.65+node lib/check-links.ts guide66+test -f "$w/sites/dami/forge/guide/index.html"67+# Backups: stage the demo forge the way forge backup does, restore that into68+# an empty root, and check the forge comes back whole: hooks, keys, refs,69+# review logs and comments, sites, pages. The restic round trip needs70+# restic, which the container image has.71+r=$(mktemp -d "${TMPDIR:-/tmp}/forge-ci-restore.XXXXXX")72+FORGE_ROOT=$PWD/demo/root sh bin/forge backup --dry-run 2>/dev/null73+FORGE_ROOT=$r sh bin/forge restore --from demo/root/.forge/backup/stage >/dev/null 2>&174+test -x "$r/dami/greet.git/hooks/pre-receive"75+grep -q 'shell ana"' "$r/.ssh/authorized_keys"76+diff -r demo/root/dami/greet.git/forge "$r/dami/greet.git/forge"77+for x in dami/greet ana/dotfiles; do78+ git --git-dir="$r/$x.git" fsck --no-dangling --no-progress79+ test "$(git --git-dir="demo/root/$x.git" for-each-ref | wc -l)" = "$(git --git-dir="$r/$x.git" for-each-ref | wc -l)"80+done81+test -f "$r/.forge/www/dami/greet/reviews/3/index.html"82+test -f "$r/.forge/www/sites/dami/greet/docs/index.html"83+if command -v restic >/dev/null 2>&1; then84+ export FORGE_BACKUP_REPOSITORY="$r/restic" FORGE_BACKUP_PASSWORD=ci85+ FORGE_ROOT=$PWD/demo/root sh bin/forge backup 2>/dev/null86+ FORGE_ROOT=$PWD/demo/root sh bin/forge backups --status | grep -q 'restore drill: ok'87+else88+ echo "restic is not installed: skipped the restic round trip"89+fi90+rm -rf "$r"91+echo ok
A .forge/sites +2 -0
A .gitignore +6 -0
A Dockerfile +37 -0
@@ -0,0 +1,37 @@
1+# The forge as one container: sshd (git over ssh), Caddy (pages, sites, https2+# clones), and optionally tailcat, which publishes both ports over WireGuard3+# to an edge without opening any port on the host. All state lives in4+# /srv/git, which should be a volume.5+FROM golang:1.27.2 AS tailcat6+RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tailscale/tailcat/cmd/tailcat@v0.7.07+8+# rclone syncs the pages mirror (forge mirror). Debian's 1.60 predates R29+# support for its uploads (R2 answers 501), so build a pinned release; the Go10+# checksum database verifies the source, as for tailcat.11+FROM golang:1.27.2 AS rclone12+RUN CGO_ENABLED=0 GOBIN=/out go install github.com/rclone/rclone@v1.75.213+14+# Node 22 runs the TypeScript sources directly (type stripping).15+FROM node:22-trixie-slim16+17+RUN apt-get update \18+ && apt-get install -y --no-install-recommends git openssh-server caddy ca-certificates tini fcgiwrap restic \19+ && rm -rf /var/lib/apt/lists/* \20+ && adduser --system --group --shell /bin/sh --home /srv/git git \21+ && usermod -aG git caddy \22+ && rm -f /etc/ssh/ssh_host_*23+24+COPY --from=tailcat /out/tailcat /usr/local/bin/tailcat25+COPY --from=rclone /out/rclone /usr/local/bin/rclone26+COPY server/container/sshd.conf /etc/ssh/sshd_config.d/forge.conf27+28+WORKDIR /opt/forge29+COPY package.json package-lock.json ./30+RUN npm ci --omit=dev --no-audit --no-fund31+COPY . .32+RUN chmod -R a+rX /opt/forge && ln -s /opt/forge/server/container/forge-admin /usr/local/bin/forge-admin33+34+EXPOSE 2222 808035+# -s: reap background builds even when tini is not PID 1 (as on Fly).36+ENTRYPOINT ["tini", "-s", "--"]37+CMD ["/opt/forge/server/container/start.sh"]
A README.md +757 -0
@@ -0,0 +1,757 @@
1+# forge2+3+A personal forge built from plain git. The server is the setup from4+[Git on the Server](https://git-scm.com/book/en/v2/Git-on-the-Server-Setting-Up-the-Server):5+a `git` user, SSH keys, and bare repositories. Pushes run two hooks, and an6+[11ty](https://www.11ty.dev/) build writes static pages in the style of7+[stagit](https://git.suckless.org/). There is no database: the state is files8+in the bare repos and a directory of HTML that any static file server can9+serve. Two small Node servers sit next to it for what static files can't do:10+one streams live page updates, the other serves repo sites on their own11+hostnames (see [What runs](#what-runs)).12+13+It adds what stagit doesn't have, borrowing from [args.io](https://args.io):14+15+- **Every branch is browsable.** Log, files, and blobs for each branch, plus a16+ refs page with ahead and behind counts.17+- **Reviews are stacked diffs.** Any pushed branch is a review. A branch built18+ on another review's branch is detected as the next layer of a stack. A19+ review page is its commits' diffs in order, plus a combined diff.20+- **Revisions are recorded per push.** Force-pushing a review records a new21+ revision. Its page shows what changed: the diff between the two tips, or22+ after a rebase, each commit paired with its earlier version (`git range-diff`).23+- **Discussion from the terminal.** `forge comment` and `forge approve` add24+ comments (on a line or the whole review) and approvals, which the review25+ page shows. Approvals are keyed by tree like CI, and `main` can require one26+ from someone other than the author. `forge land` checks both and27+ fast-forwards.28+- **CI runs on your machine.** `forge attest` runs `.forge/ci` in a clean29+ worktree. It signs the result with the SSH key you push with and stores it30+ as a git note. Pages show the result for each commit, and `main` can require31+ a verified pass to accept a push.32+- **Sites ship with repos.** Docs, Docusaurus builds, or anything static is33+ committed to `refs/sites/<name>` and served from the forge. Each review34+ branch gets its own preview. The wiki is markdown in `wiki/`.35+- **Keyboard navigation and clean URLs**, e.g. `/dami/greet/tree/main/greet.sh/`.36+- **Search and syntax highlighting.** `/` searches a repo's commits, reviews,37+ wiki and file paths from a static index; code is highlighted at build time.38+39+The [guide](guide/index.html) explains all of this with diagrams. It is a40+static site in `guide/`, declared in `.forge/sites`, so the forge serves it41+next to this repo after `forge site deploy guide`.42+43+## Try it44+45+```sh46+npm install47+sh demo/make-demo.sh # throwaway forge in demo/root, real hooks over file:// pushes48+python3 -m http.server 4817 -d demo/root/.forge/www49+```50+51+The demo creates two users (`dami`, `ana`) and a repo with:52+53+- a landed review and a two-layer stack, with both layers rewritten once,54+ and a description, comments and approvals on it (one comment outdated by55+ the rewrite, one approval stale);56+- a contribution from `ana` whose CI fails and then passes, and which `main`57+ turns away because nobody approved it;58+- a draft that grows, then is rebased onto a newer `main` and reworked;59+- an abandoned branch, a wiki, and a docs site with a preview;60+- a standalone site that `ana` owns;61+- code in JavaScript, Go, CSS, Python and markdown fences, and a review of a62+ 1200-line file with long lines.63+64+Its clock is fixed: commits get set dates, and the hooks stamp reviews with65+`FORGE_NOW` (an override for the demo and tests) instead of the wall clock.66+67+## How it fits together68+69+```70+your machine server (git user, /srv/git)71+------------ ---------------------------72+git push ─────────── ssh, forced command ────> forge shell <user> -> git-shell73+forge attest -> refs/notes/ci (signed) pre-receive: who may push what; CI and74+ approval gates on main75+forge site deploy -> refs/sites/<name> post-receive: record review revisions,76+ refs/site-previews/... extract sites, rebuild pages77+ 11ty build -> /srv/git/.forge/www (static)78+ events.jsonl -> live page updates79+forge comment, approve ─── ssh ──────────────> comment, approve: next to the review log80+```81+82+### What runs83+84+| Process | Why |85+| --- | --- |86+| sshd | git over ssh. Every key is forced through `forge shell <name>`. |87+| Caddy | Serves `.forge/www` as plain files, read-only HTTPS clones (`git http-backend` through fcgiwrap), and `go get` meta tags; proxies `/events/` and, with a pages domain, sites. |88+| `forge build` | Runs 11ty after each push, in the background and one repo at a time, then swaps the new pages into `www/`. Unchanged pages are hard links to the last build's. First it adds the push's commits to the repo's commit-graph (see [Cleaning up](#cleaning-up)). |89+| `lib/events-server.ts` | Tails `.forge/events.jsonl`, which hooks and builds append to, and streams it as server-sent events at `/events/<owner>/<repo>/`. Open pages say who pushed and swap in fresh content when the build finishes. Without it pages still work; their status line says offline. |90+| `lib/pages-server.ts` | Only with a pages domain. Serves each site on its own hostname the way Cloudflare Pages does (`_headers`, `_redirects`, pretty URLs, the nearest `404.html` or a single-page-app fallback) and redirects old `/sites/...` paths there. Runs as the caddy user, since deploys are untrusted content. |91+| `forge backup` | Daily, from a loop in `start.sh`. Snapshots the forge with restic into a bucket, then checks the snapshots and test-restores the new one (see [Backups and moving hosts](#backups-and-moving-hosts)). |92+| `forge mirror` | After builds, in the background: syncs the forge's own pages to a bucket with rclone, for the edge to serve while the forge is down. |93+94+Without a pages domain, Caddy serves sites straight from `/sites/...` and95+`/previews/...` on the forge's own origin, and `_headers` and `_redirects` don't96+apply. `forge server caddyfile <address> [<pages-domain>]` prints the Caddy97+config for either setup. It serves assets, whose names carry a hash of their98+contents, with `Cache-Control: public, max-age=31536000, immutable`, and99+pages and the JSON and text indexes they fetch with `no-cache`, so browsers100+revalidate them with the ETag and Last-Modified Caddy sends and get a 304101+when nothing changed. `demo/serve.ts` sends the same headers.102+103+### Identity and permissions104+105+`/srv/git/.forge/keys` holds `name ssh-ed25519 AAAA...` lines and is the only106+source of identity. `forge keys sync` generates two files from it:107+108+- `authorized_keys`, which forces every key through `forge shell <name>`, so109+ hooks know who pushed;110+- `allowed_signers`, so the same keys verify CI attestations and signed site111+ deploys.112+113+Repos live at `/srv/git/<owner>/<repo>.git`, and `<owner>` owns them. The owner114+and anyone listed in `<repo>.git/forge/collaborators` can push anything. Other115+users can push branches and previews under their own name (`ana/...`) and add116+CI notes, which must fast-forward. Nobody can force-push `main` unless117+`forge.allowforce` is set.118+119+`ssh git@git.example.com new <name>` creates a repo under your name, and so120+does pushing to one that doesn't exist yet: `forge shell` sees the push before121+git does, creates the repo the same way (same name rules, hooks installed),122+prints `created dami/notes`, and lets the push go on.123+124+```sh125+git push git@git.example.com:dami/notes.git main126+```127+128+The first branch pushed becomes the default if it isn't `main`. A push to a129+missing repo under someone else's name, or a clone or fetch of one, fails130+as before.131+132+### Reviews133+134+`post-receive` appends each push of a non-default branch to135+`<repo>.git/forge/reviews/<id>` and pins the tip under `refs/forge/keep/` so136+old revisions survive. The generator works out the rest from refs:137+138+- **Stack:** the parent is the nearest other review whose tip lies inside this139+ branch.140+- **Layer:** the commits between the parent (or `main`) and the tip.141+- **Status:** merged if the tip is in `main`; closed if the branch was deleted;142+ draft if the branch is `wip/...` or pushed with `-o draft`.143+144+Push options set metadata: `forge push -t "Title" -d "Description" --draft --target <branch>`.145+The description is one line (`\n` makes a line break, and so does a real146+newline in the argument) and renders as markdown under the title, sanitized147+like a README. Each push that creates or updates a review prints its link,148+`review #7: https://git.example.com/dami/greet/reviews/7/ (on #6 dami/flags)`,149+from `FORGE_BASE_URL` (just the path if that isn't set).150+151+### Comments and approvals152+153+Both are written from the terminal over the forge's ssh account, and the154+pages rebuild in the background, so an open review page updates live.155+156+```sh157+forge reviews # open and draft reviews: CI, approvals158+forge checkout 7 # fetch the branch, switch to a tracking branch159+forge comment -m "Looks good" # the current branch's review160+forge comment 7 greet.sh:12 # a line comment; the message from $EDITOR or stdin161+forge approve 7162+forge land 7 # what the server gates check, then fast-forward163+ssh git@git.example.com land dami/greet 7 # the forge lands it itself; no clone needed164+```165+166+- **Comments** are files, `<repo>.git/forge/comments/<review>/<n>`: header167+ lines (time, user, the review's tip and revision, and for a line comment168+ the path, the new-side line number and that line's text), a blank line,169+ then the markdown message (at most 32 KiB). Any registered user can170+ comment. Once the review gets a new revision, older comments show as171+ outdated and still quote the line they were about. Line comments on the172+ current revision also appear under their line in the combined diff. A173+ comment made from a checkout belongs to the revision checked out.174+- **Approvals** are lines in the review log, `approve <ts> <user> <sha>175+ <tree>`. Like CI results they are keyed by tree, so rewording or176+ reordering commits keeps them; if the tree changes, the page shows177+ "approved r1, changed since". Authors can't approve their own review.178+- **Gate:** `git config forge.requireapproval true` in a bare repo means a179+ push that moves the default branch needs an approval of the new tip's tree180+ from someone other than that review's author. It works alongside181+ `forge.requireci`.182+- **Landing:** `forge land [<id>|<branch>]` checks that every commit between183+ the target and the branch has a verified passing attestation and, if the184+ repo requires it, an approval (`--force` turns the local checks into185+ warnings; the server's gates still apply). It refuses anything that isn't a186+ fast-forward, pushes the branch to its target (the target of the stack's187+ lowest layer), then deletes the landed branch and the stack layers below188+ it.189+- **Landing on the server:** `ssh git@host land <owner>/<repo> <id|branch>`,190+ or `forge land --server` from a clone, lands a review without fetching191+ anything, for agents or a phone. The forge resolves the review's tip and192+ target from the review logs and refs, then pushes inside itself, from the193+ bare repo to the bare repo, as you: the tip to the target and deletions of194+ the branch and its landed layers, in one atomic push with leases. So195+ `pre-receive` applies exactly the rules any push meets (who may move the196+ default branch, `forge.requireci`, `forge.requireapproval`, fast-forward197+ only unless `forge.allowforce`), and `post-receive` does what it does after198+ one: the review logs record the deleted branches, their previews go, an199+ event is appended and the pages rebuild. If it refuses, nothing changes.200+201+`forge reviews`, `checkout` and `land` read `/<owner>/<repo>/reviews.json`,202+which the page build writes; `ssh ... land` reads the review logs, so it can203+land a review pushed a moment ago. The ssh commands live in204+`git-shell-commands/`; on an existing server, run `forge server init` again to205+add them (the container does that on every boot).206+207+### CI attestations, and what they prove208+209+`forge attest [--stack]` checks out each commit into a temporary worktree and210+runs `.forge/ci`. If a `flake.nix` exists, it runs inside `nix develop`. It211+then writes a statement like this and signs it with212+`ssh-keygen -Y sign -n forge-ci`:213+214+```215+forge-attestation 1216+commit 2aa8af0...217+tree 58945d0...218+script .forge/ci 99a0d5e...219+result pass220+exit 0221+duration 41s222+runner dami@mbp223+system Darwin 25.5.0 arm64224+env host225+log sha256:... 120 lines226+|ok greet --name ana227+-----BEGIN SSH SIGNATURE-----228+```229+230+The statement is appended to `refs/notes/ci`, and `forge push` merges and231+pushes those notes with the branch. Results are keyed by **tree**, so a232+reworded or reordered commit with an identical tree keeps its result without233+re-running.234+235+What a verified attestation proves: the holder of a registered key claims that236+this exact tree passed this exact script on a machine they describe. Tampering237+is caught; changing `result fail` to `result pass` breaks the signature. A238+dishonest key holder is not caught. To make the claim stronger:239+240+- keep the key in hardware (a Secure Enclave key via241+ [Secretive](https://github.com/maxgoedjen/secretive), or a FIDO `sk-` key)242+ so it is bound to one device;243+- use Nix so the environment is pinned by `flake.lock`, which is recorded in244+ the statement;245+- have a second person run `forge attest` on their machine. Attestations from246+ several signers stack up on the same tree.247+248+To require a verified pass before `main` moves, set249+`git config forge.requireci tip` (or `all`, for every new commit) in the bare250+repo.251+252+### Sites253+254+A site deploy is a commit on `refs/sites/<name>` whose tree is the built255+output, so git provides what nzip builds by hand:256+257+- content-addressed storage and dedup across deploys;258+- history, with revert by pushing an older deploy;259+- provenance through commit trailers (`Source-Commit`, `Build`, `Built-By`),260+ plus an SSH signature when `gpg.format=ssh` is set.261+262+Declare sites in `.forge/sites`:263+264+```265+# name output build command266+docs build npm ci && npm run build267+```268+269+`forge site deploy docs` builds from a clean worktree of `HEAD` and pushes the270+result. On `main` it goes to `refs/sites/docs`. On any other branch it becomes271+that review's preview, `refs/site-previews/docs/<branch>`. The build gets272+`FORGE_SITE_BASE`, the path it will be served from, so Docusaurus can use273+`baseUrl: process.env.FORGE_SITE_BASE`. `forge site push <dir> <name>`274+publishes a directory you already have.275+276+`post-receive` extracts each tree once into `.forge/store/<tree>/`, then points277+`www/sites/<owner>/<repo>/<name>` at it with a symlink. Only the owner can278+deploy `refs/sites/*`.279+280+Deleting a branch deletes its previews: `post-receive` removes281+`refs/site-previews/*/<branch>`, the served link, and the preview's hostname,282+so the sites and review pages stop listing it. Previews of merged branches283+stay until their branch is deleted. Their extracted files stay in the store284+until `forge gc` (below).285+286+### Search and highlighting287+288+`/`, or the search button in the header, opens a search over the repo's289+commits (subject, body, sha, author), reviews (number, title, branch,290+author), wiki pages, and file paths on the default branch. On the index and291+owner pages it searches repo names and descriptions. There is no server: each292+build writes `/<owner>/<repo>/search.json` (and `/repos.json`), which the page293+fetches the first time the search opens, and matching runs in the browser.294+Commit bodies are cut to 240 characters and wiki pages to 2000, to keep the295+index small. File contents are not searched; a full-text code index296+is too big to ship as a static file. `t` still finds files by name on the297+current branch.298+299+Files, diffs and fenced code in markdown are highlighted at build time with300+[highlight.js](https://highlightjs.org/), so pages need no script for it. The301+language comes from the file name, or the `#!` line of an extensionless302+script; unknown types and files over 256K stay plain rather than being303+guessed at. Each diff hunk's old and new sides are highlighted as whole text,304+so strings and comments that span lines mostly come out right. Highlighted305+files are cached in `.forge/cache/highlight/` by blob id, so pushing a new306+branch doesn't redo files that other branches already have. Token colors are307+CSS variables (`--tok-*`) in `style.css`.308+309+## Hosting310+311+The forge is one container (`Dockerfile`): sshd for git, Caddy for pages,312+sites, and HTTPS clones (with fcgiwrap for `git http-backend`), the events and313+pages servers, and tailcat. `server/container/start.sh` starts them all. All314+state is in `/srv/git`, a volume.315+316+### Home machine behind a public edge (how src.rest runs)317+318+```319+internet ── :22, :443 ──> Fly edge (deploy/edge) ── tailcat (WireGuard) ──> forge container on slate320+ TLS ends here accepts only the publishes no ports;321+ edge's client key the host stays closed322+```323+324+[tailcat](https://github.com/tailscale/tailcat) gives the edge and the container a325+point-to-point WireGuard tunnel using Tailscale's data plane without a tailnet:326+no accounts, no control plane, nothing on your own tailnet. The container's327+tailcat server keeps its key on the volume (stable address) and only accepts328+the edge's client key (`FORGE_TAILCAT_ALLOW`). The edge is stateless; its key329+and the container's address are Fly secrets (`TAILCAT_CLIENT_KEY`,330+`FORGE_TAILCAT_ADDR`).331+332+On the edge, Caddy takes the public HTTP port and passes everything through333+the tunnel. When slate doesn't answer (the tunnel or the box is down, a334+timeout, or a 502, 503 or 504), Caddy serves GET and HEAD requests for the335+forge's own pages from the [pages mirror](#the-pages-mirror) instead, with336+`X-Forge-Offline: mirror` and `Cache-Control: no-store`. Sites, clones, live337+updates and pushes need slate; they get a 503, or for ssh no connection,338+until it's back. Caddy checks slate every 10 seconds, so only the first339+request after it goes away waits. `deploy/edge/fly.toml` sets `FORGE_DOMAIN`340+(only that host falls back) and `FORGE_MIRROR_URL`, the mirror's public URL;341+without it there is no fallback.342+343+On slate, everything lives in `/srv/forge` (`src/`, `data/`, `forge.env`):344+345+```sh346+deploy/slate/deploy.sh # rsync this checkout, docker compose up -d --build347+ssh slate docker exec forge forge-admin user add ana 'ssh-ed25519 AAAA...'348+```349+350+The edge (`deploy/edge`) needs a dedicated IPv4 on Fly, because raw TCP on351+port 22 doesn't work on shared addresses. DNS for `src.rest` and352+`pages.src.rest` points at the edge's addresses, DNS-only on Cloudflare353+(SSH can't go through Cloudflare's proxy). To rotate the edge key:354+`tailcat genkey --client --key=edge.private.json`, put its nodekey in355+`/srv/forge/forge.env`, its file in the `TAILCAT_CLIENT_KEY` secret, and redeploy both.356+357+### A plain VPS instead358+359+`server/provision.sh` turns a fresh Debian 13 or Ubuntu 24.04 box into the360+forge directly (git, sshd, Node, Caddy with automatic TLS, and systemd units361+for the events and pages servers). It needs Node 22.18 or newer, which runs362+the TypeScript sources as they are; those distros ship older ones, so install363+a current Node first:364+365+```sh366+rsync -a --exclude node_modules --exclude demo/root --exclude demo/work ./ root@HOST:/opt/forge/367+scp ~/.ssh/id_ed25519.pub root@HOST:/tmp/me.pub368+ssh root@HOST sh /opt/forge/server/provision.sh git.example.com dami /tmp/me.pub [pages.example.com]369+```370+371+Either way: every key in `authorized_keys` is forced through `forge shell`, so372+administer as root (`forge-admin ...` in the container, or373+`sudo -u git /opt/forge/bin/forge ...` on a VPS), never by logging in as374+`git`. Back up with `forge backup` ([Backups and moving375+hosts](#backups-and-moving-hosts)). Builds run in the background after each376+push (log: `.forge/build.log`); `forge build` rebuilds everything.377+378+### Cleaning up379+380+Every distinct deploy is extracted once into `.forge/store/<tree>/`, and the381+old tree stays when a newer deploy takes its link. `forge gc` removes store382+trees that no `www/sites` or `www/previews` link points at (hostnames point383+at those links), hostnames whose deploy is gone, and previews of review384+branches that no longer exist. A deploy extracts its tree before it links it,385+so store entries changed in the last 10 minutes are kept (`--age <minutes>`),386+which makes gc safe to run while people push. `--dry-run` lists what it would387+remove.388+389+Then it maintains every repo:390+391+- `git repack -d --geometric=2 --write-midx --write-bitmap-index`: packs392+ are merged only until each is at least twice the size of the next, so a393+ run rewrites what is new rather than the whole repo, and loose objects go394+ into the new pack. A multi-pack index with a reachability bitmap serves395+ clones and fetches from all the packs at once. It deletes only packs it396+ rewrote and prunes nothing, so pushes can land meanwhile.397+- the commit-graph, rewritten as one layer with changed-path Bloom filters398+ for every commit (filters it already has are reused);399+- `git pack-refs --all`, since every review revision pins a loose400+ `refs/forge/keep/<sha>`.401+402+Between runs, each build after a push adds a small commit-graph layer for the403+new commits (about 20 ms), so `git log -- <path>` and blame skip commits404+that don't touch the path. That happens in the background build, not while405+the pusher waits.406+407+Run it daily:408+409+```sh410+# the host's crontab, for the container411+17 4 * * * docker exec forge forge-admin gc412+# on a VPS: crontab -u git -e413+PATH=/usr/local/bin:/usr/bin:/bin414+17 4 * * * /opt/forge/bin/forge gc415+```416+417+Settings forge gives every repo, when it is created and again on418+`forge server init` (which the container runs on every boot): its hooks,419+`receive.advertisePushOptions true` for review metadata,420+`transfer.hideRefs refs/forge`, so clones, fetches and pushes don't see the421+pinned revisions (the build reads them directly, and pushes to them are422+refused), and `commitGraph.changedPathsVersion 2`, Bloom filters that hash423+non-ASCII paths the same on every platform. The dumb-HTTP `info/refs` file424+still lists `refs/forge`; git clients use the smart protocol.425+426+### Moving repos over427+428+```sh429+git config --global forge.host git@git.example.com430+forge import https://github.com/you/thing.git # default branch + tags431+forge import --branches --desc "..." <any-git-url> # every branch432+```433+434+Imports keep the source's default branch and don't turn existing branches into435+reviews. To move every public repo from a GitHub account:436+437+```sh438+gh repo list you --limit 200 --json url,description,isPrivate \439+ --jq '.[] | select(.isPrivate | not) | [.url, (.description // "")] | @tsv' |440+while IFS="$(printf '\t')" read -r url desc; do forge import --desc "$desc" "$url"; done441+```442+443+Then point existing clones at the forge:444+`git remote set-url origin git@git.example.com:dami/<repo>.git`.445+446+Everything on the forge is public: pages, sites, and HTTPS clones. Keep447+private repos where they are for now, or put the whole forge behind auth (e.g.448+`basic_auth` in the Caddyfile, or Tailscale).449+450+## Backups and moving hosts451+452+`forge backup` snapshots the forge with [restic](https://restic.net) into an453+S3-compatible bucket, Cloudflare R2 for src.rest. Snapshots are encrypted with454+a password you keep, and deduplicated, so a day's snapshot of a forge that455+barely changed costs kilobytes. In the container one runs every day at456+`FORGE_BACKUP_AT` (UTC, default `03:30`), or soon after boot if that day's457+hasn't run, so a box that was off catches up; a failed one is retried hourly.458+459+### What a backup holds460+461+Everything in `/srv/git` that can't be rebuilt:462+463+- every bare repo, `<owner>/<repo>.git`, with its config and its whole464+ `forge/` directory: review logs, comments, collaborators, and anything else465+ kept there;466+- `.forge/keys`, `.forge/config`, and `.forge/www/hosts/`, which hostname each467+ site has;468+- `.hostkeys/`, so clients see the same ssh host key, and `.tailcat/`, so a469+ restored box keeps its tunnel address and the edge finds it;470+- `.ssh/`, and anything else under `/srv/git` or `.forge/` that isn't left out471+ below, so state added later is backed up by default.472+473+Left out, because a restore rebuilds it or every boot rewrites it: the pages in474+`.forge/www`, the extracted sites in `.forge/store` (sites are git refs),475+`.forge/cache`, `authorized_keys`, `allowed_signers`, `git-shell-commands/`,476+hooks, `.forge/secrets` and `.forge/backup.env` (both written from `forge.env`477+at boot), and logs, locks and scratch files.478+479+### Consistent while people push480+481+A backup doesn't read the live repos. It stages a copy first, per repo: the482+review logs, comments and config; then the refs, read once by git; then the483+objects, as hard links (free, since git never changes an object file once484+written), loose ones before packs. Git writes objects before the refs and logs485+that name them, so everything the copied refs and logs mention is in the copy,486+even mid-push or mid-repack. Each staged repo must pass `git fsck487+--connectivity-only`, or is staged again. Nothing waits on a lock: pushes,488+comments and `forge gc` carry on during a backup.489+490+### Every run checks itself491+492+After uploading, a backup493+494+1. forgets and prunes old snapshots: `--keep-daily 7 --keep-weekly 5` by495+ default (`FORGE_BACKUP_KEEP_DAILY`, `FORGE_BACKUP_KEEP_WEEKLY`; a daily of 0496+ keeps only the newest snapshot of each week);497+2. runs `restic check`, and about once a week also reads a fifth of the stored498+ data, a different fifth each week;499+3. restores the new snapshot into a scratch directory and drills it: the same500+ files as staged, a clean full `git fsck` in every repo, and the ref count501+ each live repo had.502+503+A failure goes to `.forge/backup.log`, makes the command exit non-zero, and is504+recorded in `.forge/backup.status`. A backup also refuses a forge with no505+repos, or with under half the repos of the last backup (`--force` overrides):506+retention keeps one snapshot a day, and a new box backing itself up before507+its restore must not push the good ones out.508+509+```sh510+docker exec forge forge-admin backups # health, then the snapshots511+docker exec forge forge-admin backups --status # health only; exits 1 if failed, stale (36h) or off512+docker exec forge forge-admin backup # one now513+docker exec forge forge-admin backup --dry-run # stage it and say what would be added514+```515+516+The health line is `last good backup: 5h ago ...` with the drill and check517+results, or, loudly, `LAST BACKUP FAILED`, `BACKUPS ARE STALE`, `NO GOOD518+BACKUP YET` or `BACKUPS ARE OFF`. The daily run prints it to the container log519+(`docker logs forge | grep backup:`). To hear about trouble, run `--status`520+from cron on another machine: `ssh slate docker exec forge forge-admin backups --status`.521+522+### The pages mirror523+524+After a build, `forge mirror` syncs the forge's own pages (not sites, previews525+or their hostnames) to a second bucket with rclone, in the background. It526+waits `FORGE_MIRROR_DELAY` seconds (30) after each build and only the newest527+one syncs, so a burst of pushes makes one sync. It never replaces the copy528+with an empty forge. The edge serves this copy while slate is down (see529+[above](#home-machine-behind-a-public-edge-how-srcrest-runs)). It holds530+public content only, the pages the forge serves anyway.531+532+### Setting it up533+534+What you set up yourself, once:535+536+1. **Two R2 buckets**: `forge-backup`, private, and `forge-pages`, with public537+ access on, through a custom domain such as `mirror.src.rest` (best) or its538+ `r2.dev` URL (rate-limited, fine for an offline copy).539+2. **An R2 API token** with Object Read & Write on both buckets (or a token540+ each). Note its access key ID and secret, and the account's S3 endpoint,541+ `https://<account-id>.r2.cloudflarestorage.com`.542+3. **A restic password**, e.g. `openssl rand -hex 32`. **Put it in your543+ password manager, off slate, before the first backup.** Snapshots are544+ encrypted with it: without it nobody can read them, not you and not545+ Cloudflare, and a dead slate would take the only copy with it. Keep all of546+ `forge.env` there too; a new box needs it.547+4. **`/srv/forge/forge.env` on slate**, then `deploy/slate/deploy.sh`:548+549+ ```sh550+ FORGE_BACKUP_REPOSITORY=s3:https://<account-id>.r2.cloudflarestorage.com/forge-backup551+ FORGE_BACKUP_PASSWORD=<the restic password>552+ FORGE_BACKUP_KEY_ID=<access key id>553+ FORGE_BACKUP_SECRET=<secret access key>554+ FORGE_MIRROR_S3=https://<account-id>.r2.cloudflarestorage.com/forge-pages555+ FORGE_MIRROR_KEY_ID=<access key id>556+ FORGE_MIRROR_SECRET=<secret access key>557+ # optional: FORGE_BACKUP_AT=03:30 FORGE_BACKUP_KEEP_DAILY=7 FORGE_BACKUP_KEEP_WEEKLY=5 FORGE_MIRROR_DELAY=30558+ ```559+560+ At every boot the container writes these to `.forge/backup.env`, which561+ only git and root can read.562+5. **The edge**: set `FORGE_MIRROR_URL` in `deploy/edge/fly.toml` to the563+ bucket's public URL (`FORGE_DOMAIN` is set already), then `fly deploy`. Its564+ secrets are unchanged: `TAILCAT_CLIENT_KEY` and `FORGE_TAILCAT_ADDR`.565+6. **Check it**: `docker exec forge forge-admin backup`, then `backups`, and566+ `forge-admin mirror` for the first sync instead of waiting for a push.567+568+### Restoring, or moving to another host569+570+`forge restore [<snapshot>|latest]` brings a forge back into an empty571+`/srv/git`. It restores the files, rebuilds what a backup leaves out (what572+`forge server init` and `forge keys sync` write, hooks in every repo, pinned573+review revisions, extracted sites) and runs `forge build`. It refuses a forge574+that already has repos. The host's own `.forge/config` wins over the backup's,575+since it is written for the domain that host serves, and users from both are576+kept. `forge restore <snapshot> --to <dir>` only unpacks the files, to look at577+them or to copy one repo back by hand. Any machine with restic and the578+password can read the snapshots too: `restic -r <repository> restore latest579+--target <dir>`, then `forge restore --from <dir>` on the new forge.580+581+**A new slate**, or any box with Docker. This is also how the forge moves:582+583+```sh584+# Docker, this repo in /srv/forge/src, forge.env from your password manager585+# in /srv/forge/forge.env, an empty /srv/forge/data586+cd /srv/forge/src/deploy/slate587+docker compose build588+docker compose run --rm forge forge-admin restore latest589+docker compose up -d590+docker exec forge forge-admin backups591+```592+593+Turn the old forge off first, since it has the same tailcat key. The restored594+key keeps the tunnel address, so the edge finds the new box by itself, with595+no change on Fly, and serves the offline copy until then. If the forge already596+booted on the new box, run `docker exec forge forge-admin restore latest`, then597+`docker compose restart forge`, so sshd and tailcat load the restored keys.598+599+**A plain VPS**: provision it as above (that installs restic and rclone too),600+write the `FORGE_BACKUP_*` lines into `/srv/git/.forge/backup.env` (quote601+values with spaces or `$` in them), restore as git, and point DNS at it:602+603+```sh604+ssh root@HOST 'install -o git -g git -m 600 /dev/stdin /srv/git/.forge/backup.env' <backup.env605+ssh root@HOST sudo -u git /opt/forge/bin/forge restore latest606+# optional: keep the ssh host key clients know, or they see a changed key once607+ssh root@HOST 'cp /srv/git/.hostkeys/ssh_host_* /etc/ssh/ && systemctl restart ssh'608+# daily backups, from crontab -u git -e609+PATH=/usr/local/bin:/usr/bin:/bin610+30 3 * * * /opt/forge/bin/forge backup611+```612+613+## Developer setup614+615+```sh616+ln -s /path/to/forge/bin/forge ~/bin/forge617+git config --global forge.signingkey ~/.ssh/id_ed25519.pub # .pub uses ssh-agent618+ssh git@git.example.com signers > ~/.config/forge/allowed_signers619+git config --global forge.allowedsigners ~/.config/forge/allowed_signers620+git config --global forge.host git@src.rest # for forge import621+```622+623+Day to day:624+625+```sh626+git switch -c dami/topic && git commit ...627+forge attest --stack # CI for every commit on top of main628+forge push -t "Title" # branch + notes; becomes a review629+forge land # CI and approval checks, then fast-forward main630+forge land --server # or let the forge land it, with its gates only631+```632+633+For a stack, `git rebase -i --update-refs` keeps the layers in sync, and634+`forge push --stack` pushes every layer, lowest first.635+636+Working on forge itself: after `sh demo/make-demo.sh`, `npm run dev` serves637+the demo forge at http://localhost:8080/ (`npm run dev -- <port>` for638+another) and rebuilds it into `_site/` whenever a template, asset, `lib/` or639+`eleventy.config.ts` changes, reloading open tabs. Each build is a fresh 11ty640+process, since a running one can't pick up changed TypeScript modules. It641+only reads `demo/root`: no incremental build, and caches go to `.cache/dev/`.642+Live updates have no event stream there, so the status line stays empty.643+644+## Reading pages645+646+A review page reads top to bottom: the stack, the layer's commits, then each647+commit's diff. While you read the diffs, a bar sticks to the top with the648+layers below and above and the previous and next commit. Every file folds649+(click its header). Its Viewed box folds it and counts toward "3 of 7 files650+viewed". Viewed marks are kept in the browser's localStorage, keyed by repo,651+path and a hash of that file's diff, so they survive a force-push that leaves652+the file alone and clear when the file changes.653+654+"What changed since rN" compares a revision with the one before. If both were655+pushed on the same base, it is the diff between the two tips. If the branch656+was rebased, that diff would include everything that landed meanwhile, so657+each commit is paired with its earlier version instead and labelled658+unchanged, rewritten, added or dropped.659+660+Review lists nest stacks, each layer under the one below it, and a revised661+review links its latest change (`r2`). Times are written in UTC and shown662+relative ("3h ago", then "Sep 1") by the browser, with the local time on663+hover, so static pages don't go stale. On phones, long code lines wrap unless664+you turn it off with the Wrap button, which is remembered.665+666+Keyboard shortcuts (`?` lists them):667+668+| Key | Does |669+| --- | --- |670+| `/` | search |671+| `t` or `p` | find a file on this branch |672+| `j` `k`, `Enter` or `o` | move through a list, open the row |673+| `n` `N` | next or previous commit on a review, file elsewhere |674+| `[` `]` | layer below or above in a stack |675+| `x` | fold or unfold the file in view |676+| `v` | mark the file in view as viewed |677+| `h` `g` | repo home, all repositories |678+| `f` `l` `b` `r` `w` `s` | code, commits, branches, reviews, wiki, sites |679+| `u` | up a level |680+681+## Templates682+683+Pages are Nunjucks templates in `site/`. The data layer in `lib/` reads git684+with plain `git` commands and hands 11ty flat page lists (`forge.pages.logs`,685+`.reviews`, `.blobs`, ...), one item per page. Large diffs and files are686+rendered by filters when a page is written, so the build never holds all the687+markup at once.688+689+Every page extends `site/_includes/base.njk` and fills its blocks: `content`,690+`head` (the page's description and its OpenGraph tags, so links pasted into691+chat apps preview well; `og:url` needs `FORGE_BASE_URL`), `scripts` (the diff692+pages add `diffs.js`) and `heading`. Front matter shared by many templates,693+like the pagination alias and permalink, is in directory data files694+(`site/site.11tydata.json`, `site/repo/repo.11tydata.json`), so a repo page's695+front matter is just the list it paginates:696+697+```njk698+---699+pagination:700+ data: forge.pages.commits701+---702+{% extends "base.njk" %}703+{% import "macros.njk" as m with context %}704+{% block content %}705+...706+{% endblock %}707+```708+709+`site/assets/style.css` holds what every page needs: colors, layout, lists,710+code views. CSS for one feature sits next to its markup in a `{% css %}`711+block, in the template, include or macro that renders it (the discussion,712+the search overlay, the shortcuts dialog, the review bar, review lists, the713+wiki), and 11ty's bundle plugin gives each page one file of the blocks it714+rendered, `/assets/<hash>.css`, shared by pages of a kind. Import715+`macros.njk` with context, or blocks in macros don't reach the bundle.716+717+Assets ship under content-hashed names (`/assets/style.<hash>.css`,718+`ui.<hash>.js`, mermaid), so browsers can cache them for good. The same719+sources give the same names, and pages' fingerprints include them, so720+unchanged pages are still hard-linked. A build keeps the files of the last721+five code versions (listed in `assets/versions.json`), because pages built by722+older code stay live for a while: a full `forge build` swaps one repo at a723+time, and a repo whose build fails keeps its old pages.724+725+The templates render without 11ty too. `lib/render-page.ts` sets up plain726+Nunjucks with the same options, filters and bundle tags, so a server can727+render a page on demand:728+729+```ts730+const render = pageRenderer();731+const html = render('repo/commit.njk', { forge, p });732+```733+734+`node lib/render-page.ts check <www>` renders every page that way and735+compares it with 11ty's; CI runs it on the demo. With `FORGE_STRICT=1` (CI736+sets it), printing an undefined or null value is an error, so a value that737+may be missing needs an explicit check or `or ''`.738+739+| URL | Page |740+| --- | --- |741+| `/<owner>/<repo>/` | open reviews, recent commits, README |742+| `/<owner>/<repo>/log/<branch>/` | log for a branch (`/` in branch names becomes `~`) |743+| `/<owner>/<repo>/tree/<branch>/<path>/` | directory |744+| `/<owner>/<repo>/blob/<branch>/<path>/` | file |745+| `/<owner>/<repo>/commit/<sha>/` | commit |746+| `/<owner>/<repo>/reviews/<id>/` | stacked diff view; `diff/` combined, `r<n>/` what changed since the revision before |747+| `/<owner>/<repo>/wiki/<page>/` | wiki |748+| `/<owner>/<repo>/reviews.json` | the review list as data, for `forge reviews` |749+| `/<owner>/<repo>/sites/` | deploys and previews |750+| `/sites/<owner>/<repo>/<name>/` | a deployed site |751+752+## Not yet753+754+- Commenting and approving from the web pages; for now both come from the755+ terminal.756+- Bugs or issues in the args.io style.757+- Private repos.
A bin/forge +2284 -0
@@ -0,0 +1,2284 @@
1+#!/bin/sh2+# forge: a git-native personal forge.3+#4+# Developer commands run inside a clone:5+# forge attest [--stack] [--force] [--clean] [rev...] run .forge/ci in a worktree of the commit, sign the result6+# forge verify [rev...] check signed CI attestations7+# forge push [--stack] [-t title] [-d description] [--draft|--ready] [--target branch]8+# forge reviews open and draft reviews of this repo9+# forge checkout <id> fetch a review's branch and switch to it10+# forge comment [<id>|<branch>] [<path>:<line>] [-m msg] comment on a review (default: this branch's)11+# forge approve [<id>|<branch>] approve a review's current tree12+# forge land [--force] [<id>|<branch>] check CI and approval, fast-forward the target, delete landed branches13+# forge land --server [<id>|<branch>] the same, done by the forge itself (ssh land), no local checks14+# forge site deploy [--here] [name...] build .forge/sites entries and publish them15+# forge site push <dir> <name> publish a prebuilt directory16+# forge import [--to git@host] [--branches] <git-url> copy a repo into your forge account17+#18+# Server commands run as the git user (see README.md):19+# forge server init20+# forge user add <name> <pubkey-file|key|-> (user rm <name> revokes keys)21+# forge keys sync22+# forge repo new <owner>/<name> [description]23+# forge repo import <owner>/<name> <url> [description] (server-side fetch)24+# forge build [owner/repo]25+# forge jev [owner/repo] ask Jev about new commits and reviews (needs TYPESAFE_API_KEY)26+# forge metrics [n] [owner/repo] recent builds and where their time went27+# forge gc [--dry-run] [--age minutes] remove unused site files and hostnames; repack, commit-graph28+# forge backup [--dry-run] [--force] snapshot the forge with restic, then check it and test-restore it29+# forge backups [--status] the last backup's health, then the snapshots30+# forge restore [<snapshot>|latest] [--to <dir>] [--from <dir>] bring the forge back from a backup31+# forge mirror [--dry-run] sync the pages to the mirror bucket (builds do this by themselves)32+# forge shell <user> forced command in authorized_keys33+# forge hook pre-receive|post-receive34+set -eu35+36+self=$037+while [ -L "$self" ]; do38+ l=$(readlink "$self")39+ case $l in /*) self=$l ;; *) self=$(dirname -- "$self")/$l ;; esac40+done41+FORGE_HOME=$(CDPATH= cd -- "$(dirname -- "$self")/.." && pwd)42+FORGE_BIN=$FORGE_HOME/bin/forge43+NS=forge-ci44+ZERO=000000000000000000000000000000000000000045+CI_SCRIPT=.forge/ci46+47+die() { printf 'forge: %s\n' "$*" >&2; exit 1; }48+say() { printf '%s\n' "$*" >&2; }49+50+usage() { sed -n '2,/^set -eu/{/^#/s/^# \{0,1\}//p;}' "$0"; }51+52+# ---------------------------------------------------------------- shared53+54+# Print every note in a notes commit (default refs/notes/ci).55+notes_dump() {56+ n=${1:-refs/notes/ci}57+ git rev-parse -q --verify "$n^{commit}" >/dev/null || return 058+ git ls-tree -r "$n" | awk '$2 == "blob" { print $3 }' | git cat-file --batch59+}60+61+# Split attestation blocks about tree $1 into $2/N.payload and $2/N.sig and62+# print "N result" for each. Log lines start with "|" and cannot fake a header.63+split_blocks() {64+ LC_ALL=C awk -v t="$1" -v d="$2" '65+ /^forge-attestation 1$/ { inb = 1; n++; sig = 0; p = d "/" n ".payload"; s = d "/" n ".sig"; ok = 0; res = "" }66+ !inb { next }67+ /^-----BEGIN SSH SIGNATURE-----$/ { sig = 1 }68+ { if (sig) print > s; else print > p }69+ !sig && $1 == "tree" && $2 == t { ok = 1 }70+ !sig && $1 == "result" { res = $2 }71+ /^-----END SSH SIGNATURE-----$/ { inb = 0; close(p); close(s); if (ok) print n, res }72+ '73+}74+75+# verify_block payload sig allowed_signers: print the signer on success.76+verify_block() {77+ pr=$(ssh-keygen -Y find-principals -s "$2" -f "$3" 2>/dev/null | head -n 1) || return 178+ [ -n "$pr" ] || return 179+ ssh-keygen -Y verify -f "$3" -I "$pr" -n "$NS" -s "$2" <"$1" >/dev/null 2>&1 || return 180+ printf '%s' "$pr"81+}82+83+# verify_tree tree label: report attestations; succeed if a verified pass exists.84+# Uses $notes, $signers, $quiet.85+verify_tree() {86+ vt=$(mktemp -d "${TMPDIR:-/tmp}/forge-verify.XXXXXX")87+ notes_dump "$notes" | split_blocks "$1" "$vt" >"$vt/list"88+ found=0 pass=089+ while read -r n res; do90+ found=191+ runner=$(sed -n 's/^runner //p' "$vt/$n.payload")92+ if [ -n "$signers" ] && pr=$(verify_block "$vt/$n.payload" "$vt/$n.sig" "$signers"); then93+ [ "$res" = pass ] && pass=194+ [ "$quiet" = 1 ] || printf '%s %s, signed by %s (%s)\n' "$2" "$res" "$pr" "$runner"95+ else96+ [ "$quiet" = 1 ] || printf '%s %s, UNVERIFIED signature (%s)\n' "$2" "$res" "$runner"97+ fi98+ done <"$vt/list"99+ [ "$found" = 1 ] || [ "$quiet" = 1 ] || printf '%s no attestation\n' "$2"100+ rm -rf "$vt"101+ [ "$pass" = 1 ]102+}103+104+sha256() {105+ if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1106+ else shasum -a 256 "$1" | cut -d' ' -f1; fi107+}108+109+# Make log text safe to sign and to store in a note: valid UTF-8, no escape110+# sequences or carriage returns, no trailing whitespace.111+sanitize() {112+ e=$(printf '\033')113+ { iconv -c -f UTF-8 -t UTF-8 2>/dev/null || LC_ALL=C tr -cd '\11\12\40-\176'; } |114+ LC_ALL=C sed -e "s/${e}\[[0-9;?]*[A-Za-z]//g" -e 's/\r$//' -e 's/.*\r//' |115+ LC_ALL=C tr -d '\000-\010\013\014\016-\037' |116+ LC_ALL=C sed 's/[[:space:]]*$//'117+}118+119+enc_ref() { printf '%s' "$1" | tr / '~'; }120+121+# ---------------------------------------------------------------- developer side122+123+remote() { git config --get forge.remote || echo origin; }124+125+default_branch() {126+ b=$(git symbolic-ref -q --short "refs/remotes/$(remote)/HEAD" 2>/dev/null || true)127+ b=${b#*/}128+ [ -n "$b" ] || b=$(git config --get init.defaultBranch || echo main)129+ printf '%s' "$b"130+}131+132+stack_base() {133+ b=$(git config --get forge.base || true)134+ [ -n "$b" ] && { printf '%s' "$b"; return; }135+ r=$(remote) d=$(default_branch)136+ for c in "$r/$d" "$d"; do137+ git rev-parse -q --verify "$c^{commit}" >/dev/null && { printf '%s' "$c"; return; }138+ done139+}140+141+signing_key() {142+ k=${FORGE_SIGNING_KEY:-$(git config --get forge.signingkey || git config --get user.signingkey || true)}143+ if [ -z "$k" ]; then144+ for f in "$HOME/.ssh/id_ed25519" "$HOME/.ssh/id_ecdsa" "$HOME/.ssh/id_rsa"; do145+ [ -f "$f" ] && { k=$f; break; }146+ done147+ fi148+ [ -n "$k" ] || die "no signing key: set forge.signingkey (an SSH key; a .pub path uses ssh-agent)"149+ case $k in150+ "~/"*) k=$HOME/${k#"~/"} ;;151+ key::*) f=$(mktemp); printf '%s\n' "${k#key::}" >"$f"; k=$f ;;152+ esac153+ printf '%s' "$k"154+}155+156+# Where CI worktrees live. Not under a hidden directory: tools such as157+# RuboCop skip files whose path has a dot-directory in it.158+ci_dir() {159+ d=${FORGE_CI_DIR:-$(git config --get forge.cidir || true)}160+ if [ -z "$d" ]; then161+ if [ -d "$HOME/Library/Caches" ]; then d=$HOME/Library/Caches/forge/ci162+ else d=/var/tmp/forge-ci-$(id -u); fi163+ fi164+ case $d in "~/"*) d=$HOME/${d#"~/"} ;; esac165+ printf '%s' "$d"166+}167+168+# Check out commit $1 in this repo's CI worktree and print its path. The path169+# is stable (one per repo, see ci_dir) so build caches keyed by absolute path,170+# like Go's, stay warm between runs. Each run resets tracked and untracked171+# files to exactly the commit; ignored files (target/, node_modules/) stay172+# unless $2 is 1 (attest --clean). If another attest holds the worktree, fall173+# back to a throwaway one. Sets ci_wt and ci_lock.174+ci_checkout() {175+ ci_id=$(git rev-parse --path-format=absolute --git-common-dir | { sha256sum 2>/dev/null || shasum -a 256; } | cut -c1-12)176+ ci_wt=$(ci_dir)/$ci_id177+ ci_lock=$ci_wt.lock178+ mkdir -p "$(dirname "$ci_wt")"179+ chmod 700 "$(dirname "$ci_wt")" 2>/dev/null || true180+ if ! mkdir "$ci_lock" 2>/dev/null; then181+ ci_lock=182+ ci_wt=$(mktemp -d "${TMPDIR:-/tmp}/forge-ci.XXXXXX")/wt183+ git worktree add --detach --quiet "$ci_wt" "$1"184+ return185+ fi186+ if git -C "$ci_wt" rev-parse --git-dir >/dev/null 2>&1; then187+ git -C "$ci_wt" checkout -q --detach --force "$1"188+ if [ "${2:-0}" = 1 ]; then git -C "$ci_wt" clean -q -ffdx; else git -C "$ci_wt" clean -q -ffd; fi189+ else190+ rm -rf "$ci_wt"191+ git worktree prune192+ git worktree add --detach --quiet "$ci_wt" "$1"193+ fi194+}195+196+ci_release() {197+ if [ -n "$ci_lock" ]; then rmdir "$ci_lock"198+ else git worktree remove --force "$ci_wt"; rm -rf "$(dirname "$ci_wt")"; fi199+}200+201+# Has any attestation already passed for this tree?202+tree_passed() {203+ notes_dump refs/notes/ci | LC_ALL=C awk -v t="$1" '204+ /^forge-attestation 1$/ { ok = 0; res = "" }205+ $1 == "tree" && $2 == t { ok = 1 }206+ $1 == "result" { res = $2 }207+ /^-----END SSH SIGNATURE-----$/ { if (ok && res == "pass") found = 1 }208+ END { exit !found }'209+}210+211+attest_one() {212+ c=$1 key=$2 force=$3 clean=${4:-0}213+ sha=$(git rev-parse "$c")214+ tree=$(git rev-parse "$c^{tree}")215+ short=$(git rev-parse --short "$c")216+ subject=$(git log -1 --format=%s "$c")217+ if [ "$force" = 0 ] && tree_passed "$tree"; then218+ say "$short already passed (same tree): $subject"219+ return 0220+ fi221+ blob=$(git rev-parse -q --verify "$c:$CI_SCRIPT" 2>/dev/null) || { say "$short has no $CI_SCRIPT: $subject"; return 1; }222+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/forge-ci.XXXXXX")223+ ci_checkout "$sha" "$clean"224+ say "$short running $CI_SCRIPT: $subject"225+ if [ -x "$ci_wt/$CI_SCRIPT" ]; then run="./$CI_SCRIPT"; else run="sh ./$CI_SCRIPT"; fi226+ envdesc=host227+ if [ -f "$ci_wt/flake.nix" ] && command -v nix >/dev/null 2>&1 && [ "$(git config --bool forge.nix || echo true)" = true ]; then228+ envdesc="nix flake.lock:$(git rev-parse -q --verify "$sha:flake.lock" 2>/dev/null || echo none)"229+ run="nix develop --command $run"230+ fi231+ started=$(date -u +%Y-%m-%dT%H:%M:%SZ)232+ t0=$(date +%s)233+ { (234+ set +e235+ cd "$ci_wt" || exit236+ FORGE_CI=1 FORGE_COMMIT=$sha $run </dev/null237+ echo $? >"$tmp/code"238+ ) 2>&1 | tee "$tmp/log" | sed 's/^/ | /' >&2; } || true239+ code=$(cat "$tmp/code" 2>/dev/null || echo 255)240+ dur=$(($(date +%s) - t0))241+ ci_release242+ result=fail243+ [ "$code" = 0 ] && result=pass244+ {245+ echo "forge-attestation 1"246+ echo "type ci"247+ echo "commit $sha"248+ echo "tree $tree"249+ echo "script $CI_SCRIPT $blob"250+ echo "result $result"251+ echo "exit $code"252+ echo "started $started"253+ echo "duration ${dur}s"254+ echo "runner $(id -un)@$(hostname -s 2>/dev/null || hostname)"255+ echo "system $(uname -srm)"256+ echo "env $envdesc"257+ echo "log sha256:$(sha256 "$tmp/log") $(wc -l <"$tmp/log" | tr -d ' ') lines"258+ tail -n "${FORGE_LOG_LINES:-60}" "$tmp/log" | sanitize | sed 's/^/|/'259+ } >"$tmp/statement"260+ ssh-keygen -Y sign -n "$NS" -f "$key" "$tmp/statement" 2>"$tmp/sign.err" || { cat "$tmp/sign.err" >&2; rm -rf "$tmp"; die "signing failed"; }261+ cat "$tmp/statement" "$tmp/statement.sig" >"$tmp/note"262+ strip=263+ git notes add -h 2>&1 | grep -q -- 'stripspace' && strip=--no-stripspace264+ git notes --ref=ci append $strip -F "$tmp/note" "$sha"265+ rm -rf "$tmp"266+ say "$short $result in ${dur}s, signed"267+ [ "$result" = pass ]268+}269+270+cmd_attest() {271+ stack=0 force=0 clean=0272+ while [ $# -gt 0 ]; do273+ case $1 in274+ --stack) stack=1 ;;275+ -f | --force) force=1 ;;276+ --clean) clean=1 ;;277+ -*) die "attest: unknown flag $1" ;;278+ *) break ;;279+ esac280+ shift281+ done282+ [ $# -eq 0 ] && set -- HEAD283+ if [ "$stack" = 1 ]; then284+ base=$(stack_base)285+ [ -n "$base" ] || die "attest --stack: no base branch (set forge.base)"286+ revs=$(git rev-list --reverse "$base..$1")287+ [ -n "$revs" ] || die "attest --stack: no commits between $base and $1"288+ else289+ revs=$(for r in "$@"; do git rev-parse --verify "$r^{commit}"; done)290+ fi291+ key=$(signing_key)292+ failed=0293+ for c in $revs; do attest_one "$c" "$key" "$force" "$clean" || failed=$((failed + 1)); done294+ [ "$failed" = 0 ] || die "$failed commit(s) did not pass"295+}296+297+cmd_verify() {298+ notes=refs/notes/ci quiet=0 tree=299+ signers=$(git config --get forge.allowedsigners || git config --get gpg.ssh.allowedSignersFile || true)300+ while [ $# -gt 0 ]; do301+ case $1 in302+ --notes) notes=$2; shift ;;303+ --signers) signers=$2; shift ;;304+ --tree) tree=$2; shift ;;305+ -q | --quiet) quiet=1 ;;306+ -*) die "verify: unknown flag $1" ;;307+ *) break ;;308+ esac309+ shift310+ done311+ case $signers in "~/"*) signers=$HOME/${signers#"~/"} ;; esac312+ [ -n "$signers" ] || say "forge: no allowed signers file (set forge.allowedsigners); signatures cannot be checked"313+ if [ -n "$tree" ]; then314+ verify_tree "$tree" "$tree"315+ return316+ fi317+ [ $# -eq 0 ] && set -- HEAD318+ ok=0319+ for r in "$@"; do320+ c=$(git rev-parse --verify "$r^{commit}")321+ verify_tree "$(git rev-parse "$c^{tree}")" "$(git rev-parse --short "$c")" || ok=1322+ done323+ return $ok324+}325+326+# Merge the remote's CI notes into ours so the push fast-forwards.327+sync_notes() {328+ if git fetch -q "$1" "+refs/notes/ci:refs/notes/forge-remote-ci" 2>/dev/null; then329+ if git rev-parse -q --verify refs/notes/ci >/dev/null; then330+ git notes --ref=ci merge -q -s union refs/notes/forge-remote-ci331+ else332+ git update-ref refs/notes/ci refs/notes/forge-remote-ci333+ fi334+ fi335+}336+337+cmd_push() {338+ stack=0 r=$(remote)339+ title= draft= target= desc= hasdesc=0340+ while [ $# -gt 0 ]; do341+ case $1 in342+ --stack) stack=1 ;;343+ -t | --title) title=$2; shift ;;344+ # Push options are single lines, so line breaks travel as \n.345+ -d | --description) desc=$(printf '%s' "$2" | awk '{ printf "%s%s", (NR > 1 ? "\\n" : ""), $0 }'); hasdesc=1; shift ;;346+ --draft) draft=draft ;;347+ --ready) draft=ready ;;348+ --target) target=$2; shift ;;349+ -r | --remote) r=$2; shift ;;350+ *) die "push: unknown argument $1" ;;351+ esac352+ shift353+ done354+ branch=$(git symbolic-ref -q --short HEAD) || die "push: HEAD is detached"355+ if [ "$stack" = 1 ]; then356+ base=$(stack_base)357+ [ -n "$base" ] || die "push --stack: no base branch"358+ # Lower layers first, so each push records its own revision.359+ branches=$(git for-each-ref --format='%(refname:short)' --merged HEAD --no-merged "$base" refs/heads/ |360+ while read -r b; do printf '%s %s\n' "$(git rev-list --count "$base..$b")" "$b"; done | sort -n | cut -d' ' -f2-)361+ else362+ branches=$branch363+ fi364+ sync_notes "$r"365+ notes=366+ git rev-parse -q --verify refs/notes/ci >/dev/null && notes=refs/notes/ci:refs/notes/ci367+ for b in $branches; do368+ set --369+ if [ "$b" = "$branch" ]; then370+ [ -n "$title" ] && set -- "$@" -o "title=$title"371+ [ "$hasdesc" = 1 ] && set -- "$@" -o "description=$desc"372+ [ -n "$draft" ] && set -- "$@" -o "$draft"373+ [ -n "$target" ] && set -- "$@" -o "target=$target"374+ fi375+ # Reviews are rewritten as they evolve; the lease protects others' pushes.376+ lease=--force-with-lease=refs/heads/$b377+ [ "$b" = "$(default_branch)" ] && lease=378+ git push -q $lease "$@" "$r" "refs/heads/$b:refs/heads/$b" $notes379+ say "pushed $b"380+ done381+}382+383+remote_slug() {384+ url=$(git remote get-url "$(remote)" 2>/dev/null) || die "no remote $(remote)"385+ p=${url%/}386+ p=${p%.git}387+ repo=${p##*/}388+ rest=${p%/*}389+ owner=${rest##*/}390+ owner=${owner##*:}391+ printf '%s %s' "$owner" "$repo"392+}393+394+# Run a command on the forge behind the remote, over its ssh account.395+forge_ssh() {396+ url=$(git remote get-url "$(remote)" 2>/dev/null) || return 1397+ case $url in398+ ssh://*)399+ h=${url#ssh://}400+ h=${h%%/*}401+ case $h in *:*) set -- -p "${h##*:}" "${h%:*}" "$@" ;; *) set -- "$h" "$@" ;; esac402+ ;;403+ *://*) say "forge: $(remote) is not an ssh remote"; return 1 ;;404+ *:*) set -- "${url%%:*}" "$@" ;;405+ *) say "forge: $(remote) is not an ssh remote"; return 1 ;;406+ esac407+ ${GIT_SSH_COMMAND:-ssh} "$@"408+}409+410+# site_url name branch: where a deploy will be served. A forge with a pages411+# domain gives each site its own hostname and says which; otherwise sites412+# live under paths on the forge.413+site_url() {414+ set -- "$1" "$2" $(remote_slug)415+ b=416+ [ "$2" = "$(default_branch)" ] || b=$2417+ if u=$(forge_ssh site-url "$3/$4" "$1" ${b:+"$b"} 2>/dev/null </dev/null) && [ -n "$u" ]; then418+ printf '%s' "$u"419+ return420+ fi421+ if [ "$2" = "$(default_branch)" ]; then422+ t=$(git config --get forge.sitesurl || echo '/sites/{owner}/{repo}/{site}/')423+ else424+ t=$(git config --get forge.previewsurl || echo '/previews/{owner}/{repo}/{site}/{branch}/')425+ fi426+ printf '%s' "$t" | sed -e "s|{owner}|$3|g" -e "s|{repo}|$4|g" -e "s|{site}|$1|g" -e "s|{branch}|$(enc_ref "$2")|g"427+}428+429+# publish dir name source-commit branch build-command [url]430+publish_dir() {431+ dir=$1 name=$2 src=$3 br=$4 build=$5 url=${6:-}432+ r=$(remote)433+ if [ "$br" = "$(default_branch)" ]; then ref=refs/sites/$name; else ref=refs/site-previews/$name/$br; fi434+ git fetch -q "$r" "+$ref:$ref" 2>/dev/null || true435+ gitdir=$(git rev-parse --path-format=absolute --git-common-dir)436+ idx=$(mktemp "${TMPDIR:-/tmp}/forge-index.XXXXXX")437+ rm -f "$idx"438+ GIT_INDEX_FILE=$idx git --git-dir="$gitdir" --work-tree="$dir" -C "$dir" add -A --force .439+ tree=$(GIT_INDEX_FILE=$idx git --git-dir="$gitdir" write-tree)440+ rm -f "$idx"441+ parent=$(git rev-parse -q --verify "$ref" || true)442+ if [ -n "$parent" ] && [ "$(git rev-parse "$parent^{tree}")" = "$tree" ]; then443+ say "site $name: unchanged"444+ return 0445+ fi446+ msg=$(printf 'site %s: %s\n\nSource-Commit: %s\nSource-Ref: %s\nBuild: %s\nBuilt-By: %s@%s\n' \447+ "$name" "$(git log -1 --format=%s "$src" 2>/dev/null || echo "$dir")" "$src" "$br" "$build" "$(id -un)" "$(hostname -s 2>/dev/null || hostname)")448+ # Signed with the key that signs CI results, so the forge can show who449+ # deployed it.450+ commit=451+ if k=$(signing_key 2>/dev/null); then452+ commit=$(printf '%s\n' "$msg" | git -c gpg.format=ssh -c "user.signingkey=$k" commit-tree -S "$tree" ${parent:+-p "$parent"}) ||453+ say "site $name: could not sign with $k; deploying unsigned"454+ fi455+ [ -n "$commit" ] || commit=$(printf '%s\n' "$msg" | git commit-tree "$tree" ${parent:+-p "$parent"})456+ git update-ref "$ref" "$commit"457+ git push -q "$r" "+$ref:$ref"458+ say "site $name: deployed $(git rev-parse --short "$commit") -> ${url:-$(site_url "$name" "$br")}"459+}460+461+site_deploy() {462+ here=0463+ [ "${1:-}" = --here ] && { here=1; shift; }464+ src=$(git rev-parse HEAD)465+ br=$(git symbolic-ref -q --short HEAD || echo "$(default_branch)")466+ list=$(mktemp)467+ if [ "$here" = 1 ]; then cat .forge/sites >"$list" 2>/dev/null || true468+ else git show "$src:.forge/sites" >"$list" 2>/dev/null || true; fi469+ [ -s "$list" ] || { rm -f "$list"; die "no .forge/sites in $( [ "$here" = 1 ] && echo 'the work tree' || echo HEAD)"; }470+ want=" $* "471+ any=0472+ while read -r name out cmd; do473+ case $name in '' | '#'*) continue ;; esac474+ [ $# -eq 0 ] || case $want in *" $name "*) ;; *) continue ;; esac475+ any=1476+ if [ "$here" = 1 ]; then root=$(git rev-parse --show-toplevel); tmp=477+ else478+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/forge-site.XXXXXX")479+ git worktree add --detach --quiet "$tmp/wt" "$src" </dev/null480+ root=$tmp/wt481+ fi482+ url=$(site_url "$name" "$br")483+ base=$(printf '%s' "$url" | sed 's|^[a-z]*://[^/]*||')484+ if [ -n "$cmd" ] && [ "$cmd" != - ]; then485+ say "site $name: $cmd"486+ (cd "$root" && FORGE_SITE=$name FORGE_SITE_URL=$url FORGE_SITE_BASE=$base sh -c "$cmd" </dev/null) ||487+ die "site $name: build failed"488+ fi489+ [ -d "$root/$out" ] || die "site $name: build produced no $out/"490+ publish_dir "$root/$out" "$name" "$src" "$br" "${cmd:--}" "$url" </dev/null491+ [ -n "$tmp" ] && { git worktree remove --force "$tmp/wt"; rm -rf "$tmp"; }492+ done <"$list"493+ rm -f "$list"494+ [ "$any" = 1 ] || die "no matching sites in .forge/sites"495+}496+497+cmd_site() {498+ sub=${1:-}499+ [ $# -gt 0 ] && shift500+ case $sub in501+ deploy) site_deploy "$@" ;;502+ push)503+ [ $# -eq 2 ] || die "usage: forge site push <dir> <name>"504+ [ -d "$1" ] || die "no directory $1"505+ d=$(CDPATH= cd -- "$1" && pwd)506+ br=$(git symbolic-ref -q --short HEAD || default_branch)507+ publish_dir "$d" "$2" "$(git rev-parse HEAD)" "$br" -508+ ;;509+ url) site_url "$1" "${2:-$(default_branch)}"; echo ;;510+ *) die "usage: forge site deploy [--here] [name...] | push <dir> <name> | url <name> [branch]" ;;511+ esac512+}513+514+# Copy a repo from anywhere git can clone into your forge account.515+cmd_import() {516+ to=$(git config --get forge.host || true) name= desc= branches=0517+ while [ $# -gt 0 ]; do518+ case $1 in519+ --to) to=$2; shift ;;520+ --name) name=$2; shift ;;521+ --desc) desc=$2; shift ;;522+ --branches) branches=1 ;;523+ -*) die "import: unknown flag $1" ;;524+ *) break ;;525+ esac526+ shift527+ done528+ [ $# -eq 1 ] || die "usage: forge import [--to git@host] [--name n] [--desc text] [--branches] <git-url>"529+ [ -n "$to" ] || die "import: pass --to git@host or set git config --global forge.host git@host"530+ src=$1531+ [ -n "$name" ] || { name=${src%/}; name=${name%.git}; name=${name##*/}; name=${name##*:}; }532+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/forge-import.XXXXXX")533+ git clone -q --bare "$src" "$tmp/r.git"534+ head=$(git --git-dir="$tmp/r.git" symbolic-ref --short HEAD 2>/dev/null || echo main)535+ # git-shell re-splits the command line, so quote the description for it.536+ q=$(printf '%s' "$desc" | sed "s/'/'\\\\''/g")537+ out=$(${GIT_SSH_COMMAND:-ssh} "$to" new "$name" "'$q'" 2>&1) || case $out in538+ *exists*) ;;539+ *) rm -rf "$tmp"; die "import: $out" ;;540+ esac541+ # Push to the host as given; the server may advertise a different name.542+ path=$(printf '%s\n' "$out" | sed -n 's/^git remote add origin [^:]*://p')543+ [ -n "$path" ] || path="$(${GIT_SSH_COMMAND:-ssh} "$to" whoami)/$name.git"544+ url=$to:$path545+ if [ "$branches" = 1 ]; then set -- '+refs/heads/*:refs/heads/*'; else set -- "+refs/heads/$head:refs/heads/$head"; fi546+ git --git-dir="$tmp/r.git" push -q -o noreview -o "head=$head" "$url" "$@" '+refs/tags/*:refs/tags/*'547+ rm -rf "$tmp"548+ say "imported $src -> $url"549+}550+551+# ---------------------------------------------------------------- reviews from the terminal552+# These talk to the forge over its ssh account (forge_ssh); the server side553+# is cmd_shell_cmd.554+555+# Quote an argument for git-shell, which re-splits the command line.556+shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }557+558+# owner/repo of the remote.559+remote_repo() { s=$(remote_slug) || exit 1; printf '%s/%s' "${s% *}" "${s#* }"; }560+561+# The review a command is about: an id or branch, else the current branch,562+# whose tip is then the commit you are looking at. Sets rv and at.563+review_arg() {564+ at=565+ if [ -n "$1" ]; then rv=$1; return; fi566+ rv=$(git symbolic-ref -q --short HEAD) || die "HEAD is detached; name the review (forge reviews lists them)"567+ at=$(git rev-parse HEAD)568+}569+570+# The forge's review list as tab-separated rows (lib/review-list.ts):571+# id status branch base parent tip author title, "-" for empty fields.572+load_rows() {573+ repo=$(remote_repo) || exit 1574+ rows=$(forge_ssh reviews "$repo" --tsv </dev/null) || die "could not get the review list from the forge"575+}576+577+# review_row id|branch: that review's row; for a branch, its newest review.578+review_row() {579+ case $1 in580+ *[!0-9]*) printf '%s\n' "$rows" | awk -F '\t' -v w="$1" '$3 == w' | tail -n 1 ;;581+ *) printf '%s\n' "$rows" | awk -F '\t' -v w="$1" '$1 == w' ;;582+ esac583+}584+585+cmd_reviews() {586+ [ $# -eq 0 ] || die "usage: forge reviews"587+ repo=$(remote_repo) || exit 1588+ forge_ssh reviews "$repo" </dev/null589+}590+591+cmd_comment() {592+ msg= hasmsg=0 which= where=593+ while [ $# -gt 0 ]; do594+ case $1 in595+ -m | --message) [ $# -ge 2 ] || die "comment: -m needs a message"; msg=$2; hasmsg=1; shift ;;596+ -*) die "comment: unknown flag $1" ;;597+ # Branch names can't contain ":", so this is a line.598+ *:*) where=$1 ;;599+ *) which=$1 ;;600+ esac601+ shift602+ done603+ case $where in '' | ?*:[1-9]*) ;; *) die "comment: want <path>:<line>, got $where" ;; esac604+ case ${where##*:} in *[!0-9]*) die "comment: want <path>:<line>, got $where" ;; esac605+ review_arg "$which"606+ repo=$(remote_repo) || exit 1607+ f=$(mktemp "${TMPDIR:-/tmp}/forge-comment.XXXXXX")608+ if [ "$hasmsg" = 1 ]; then printf '%s\n' "$msg" >"$f"609+ elif [ ! -t 0 ]; then cat >"$f"610+ else611+ printf '\n# Comment on review %s%s. Markdown is fine. Lines starting with #\n# are dropped; an empty message sends nothing.\n' "$rv" "${where:+ at $where}" >"$f"612+ sh -c "$(git var GIT_EDITOR) \"\$@\"" editor "$f" || die "comment: the editor failed; your text is in $f"613+ grep -v '^#' "$f" >"$f.tmp" || true614+ mv "$f.tmp" "$f"615+ fi616+ grep -q '[^[:space:]]' "$f" || { rm -f "$f"; die "comment: empty message, nothing sent"; }617+ set -- comment "$repo" "$(shq "$rv")"618+ [ -n "$at" ] && set -- "$@" --at "$at"619+ [ -n "$where" ] && set -- "$@" --line "$(shq "$where")"620+ if ! forge_ssh "$@" <"$f"; then621+ [ "$hasmsg" = 1 ] && { rm -f "$f"; exit 1; }622+ die "comment: not sent; your text is in $f"623+ fi624+ rm -f "$f"625+}626+627+cmd_approve() {628+ [ $# -le 1 ] || die "usage: forge approve [<id>|<branch>]"629+ review_arg "${1:-}"630+ repo=$(remote_repo) || exit 1631+ set -- approve "$repo" "$(shq "$rv")"632+ [ -n "$at" ] && set -- "$@" --at "$at"633+ forge_ssh "$@" </dev/null || exit 1634+}635+636+cmd_checkout() {637+ [ $# -eq 1 ] || die "usage: forge checkout <id>|<branch>"638+ r=$(remote) b=$1639+ case $1 in640+ *[!0-9]*) ;;641+ *)642+ load_rows643+ row=$(review_row "$1")644+ [ -n "$row" ] || die "checkout: no review #$1 (forge reviews lists them)"645+ b=$(printf '%s\n' "$row" | cut -f 3)646+ ;;647+ esac648+ git fetch -q "$r" "+refs/heads/$b:refs/remotes/$r/$b" || die "checkout: no branch $b on $r"649+ if git show-ref -q --verify "refs/heads/$b"; then650+ git switch -q "$b"651+ if git merge-base --is-ancestor HEAD "refs/remotes/$r/$b"; then652+ git merge -q --ff-only "refs/remotes/$r/$b"653+ else654+ say "your $b differs from the review (rewritten, or local commits); to take the review's: git reset --hard $r/$b"655+ fi656+ else657+ git switch -q -c "$b" --track "$r/$b"658+ fi659+ say "on $b: $(git log -1 --format='%h %s')"660+}661+662+# The open stack layers below review $1 (a parent id from the rows, or -)663+# whose branches, read as $3<branch>, are inside commit $2: landing $2 lands664+# them too. Prints " <branch>" for each.665+layers_in() {666+ p=$1 i=0667+ while [ "$p" != - ] && [ "$i" -lt 50 ]; do668+ prow=$(review_row "$p")669+ [ -n "$prow" ] || break670+ IFS=$tab read -r _ pstatus pbranch _ p _ <<-EOF671+ $prow672+ EOF673+ case $pstatus in open | draft) ;; *) break ;; esac674+ if git merge-base --is-ancestor "$3$pbranch" "$2" 2>/dev/null; then printf ' %s' "$pbranch"; fi675+ i=$((i + 1))676+ done677+}678+679+# Land a review the way the server gates would: every commit has a verified680+# passing attestation, and an approval if the repo wants one. Then681+# fast-forward the target, and delete the branch and the stack layers below682+# it, which are now in the target too. --server asks the forge to do it683+# (sc_land), where only its gates apply.684+cmd_land() {685+ force=0 server=0686+ while [ $# -gt 0 ]; do687+ case $1 in688+ -f | --force) force=1 ;;689+ --server) server=1 ;;690+ -*) die "land: unknown flag $1" ;;691+ *) break ;;692+ esac693+ shift694+ done695+ [ $# -le 1 ] || die "usage: forge land [--force|--server] [<id>|<branch>]"696+ which=${1:-}697+ [ -n "$which" ] || which=$(git symbolic-ref -q --short HEAD) || die "land: HEAD is detached; name the review"698+ r=$(remote)699+ if [ "$server" = 1 ]; then700+ [ "$force" = 0 ] || die "land: --force relaxes local checks, and --server has none; the forge's gates always apply"701+ repo=$(remote_repo) || exit 1702+ forge_ssh land "$repo" "$(shq "$which")" </dev/null || exit 1703+ git fetch -q --prune "$r" || true704+ return705+ fi706+ load_rows707+ row=$(review_row "$which")708+ [ -n "$row" ] || die "land: no review $which (forge reviews lists them)"709+ tab=$(printf '\t')710+ IFS=$tab read -r id status branch onto parent _ author title <<-EOF711+ $row712+ EOF713+ case $status in open | draft) ;; *) die "land: #$id is $status" ;; esac714+ git fetch -q "$r" "+refs/heads/*:refs/remotes/$r/*" || die "land: fetch from $r failed"715+ tip=$(git rev-parse -q --verify "refs/remotes/$r/$branch") || die "land: $branch is gone from $r"716+ base=$(git rev-parse -q --verify "refs/remotes/$r/$onto") || die "land: $r has no $onto"717+ git merge-base --is-ancestor "$tip" "$base" && die "land: $branch is already in $onto"718+ git merge-base --is-ancestor "$base" "$tip" ||719+ die "land: $branch doesn't fast-forward $onto; rebase it: git rebase $r/$onto && forge push"720+ say "landing #$id $title: $branch -> $onto"721+722+ sync_notes "$r"723+ notes=refs/notes/ci quiet=1 tmp=724+ signers=$(git config --get forge.allowedsigners || git config --get gpg.ssh.allowedSignersFile || true)725+ case $signers in "~/"*) signers=$HOME/${signers#"~/"} ;; esac726+ if [ -z "$signers" ]; then727+ tmp=$(mktemp)728+ forge_ssh signers </dev/null >"$tmp" 2>/dev/null && signers=$tmp729+ fi730+ bad=0731+ for c in $(git rev-list --reverse "$base..$tip"); do732+ verify_tree "$(git rev-parse "$c^{tree}")" "$c" && continue733+ bad=$((bad + 1))734+ say " $(git log -1 --format='%h %s' "$c"): no verified passing CI"735+ done736+ [ -n "$tmp" ] && rm -f "$tmp"737+ if [ "$bad" -gt 0 ]; then738+ [ "$force" = 1 ] || die "land: $bad commit(s) without a verified pass (forge attest --stack && forge push, or land --force)"739+ say "warning: landing without CI on $bad commit(s)"740+ fi741+742+ appr=$(forge_ssh approvals "$repo" "$(git rev-parse "$tip^{tree}")" </dev/null) || die "land: could not check approvals"743+ who=$(printf '%s\n' "$appr" | sed 1d | tr '\n' ' ' | sed 's/ *$//')744+ if [ -n "$who" ]; then745+ say "approved by $who"746+ elif [ "$(printf '%s\n' "$appr" | head -n 1)" = required ] && [ "$force" = 0 ]; then747+ die "land: $onto needs an approval of #$id from someone other than $author (forge approve $id)"748+ else749+ say "warning: #$id is not approved"750+ fi751+752+ set -- "$tip:refs/heads/$onto"753+ git rev-parse -q --verify refs/notes/ci >/dev/null && set -- "$@" refs/notes/ci:refs/notes/ci754+ git push -q "$r" "$@" || die "land: $r refused the push"755+ say "landed #$id on $onto at $(git rev-parse --short "$tip")"756+757+ gone=$branch$(layers_in "$parent" "$tip" "refs/remotes/$r/")758+ git push -q "$r" --delete $gone && say "deleted $gone"759+}760+761+# ---------------------------------------------------------------- server side762+763+load_config() {764+ umask 022765+ mkdir -p "${FORGE_ROOT:-$HOME}"766+ FORGE_ROOT=$(CDPATH= cd -- "${FORGE_ROOT:-$HOME}" && pwd)767+ # Export everything the config sets: the page builder (node) reads it too.768+ if [ -f "$FORGE_ROOT/.forge/config" ]; then769+ set -a770+ . "$FORGE_ROOT/.forge/config"771+ set +a772+ fi773+ if [ -r "$FORGE_ROOT/.forge/secrets" ]; then774+ set -a775+ . "$FORGE_ROOT/.forge/secrets"776+ set +a777+ fi778+ FORGE_STATE=$FORGE_ROOT/.forge779+ FORGE_WWW=${FORGE_WWW:-$FORGE_STATE/www}780+ FORGE_AUTHORIZED_KEYS=${FORGE_AUTHORIZED_KEYS:-$FORGE_ROOT/.ssh/authorized_keys}781+ export FORGE_ROOT FORGE_STATE FORGE_WWW782+}783+784+valid_name() {785+ case $1 in786+ '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | git-shell-commands) return 1 ;;787+ esac788+}789+790+cmd_server_init() {791+ load_config792+ mkdir -p "$FORGE_STATE" "$FORGE_WWW" "$FORGE_STATE/store" "$FORGE_ROOT/git-shell-commands"793+ touch "$FORGE_STATE/keys"794+ [ -f "$FORGE_STATE/config" ] || cat >"$FORGE_STATE/config" <<-EOF795+ # Sourced by forge. Uncomment and edit.796+ # FORGE_NAME=forge797+ # FORGE_BASE_URL=https://git.example.com798+ # FORGE_SSH=git@git.example.com799+ # FORGE_WWW=$FORGE_WWW800+ # FORGE_SITES_URL=/sites/{owner}/{repo}/{site}/801+ # FORGE_PREVIEWS_URL=/previews/{owner}/{repo}/{site}/{branch}/802+ EOF803+ for c in new ls whoami signers site-url reviews comment approve approvals land help; do804+ printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"805+ chmod +x "$FORGE_ROOT/git-shell-commands/$c"806+ done807+ # Existing repos get this forge's hooks and current settings (the808+ # container runs this on every boot).809+ for d in "$FORGE_ROOT"/*/*.git; do810+ if [ -f "$d/HEAD" ]; then repo_setup "$d"; fi811+ done812+ cmd_keys_sync813+ say "initialized $FORGE_ROOT (state in $FORGE_STATE, pages in $FORGE_WWW)"814+}815+816+# Print a Caddyfile. <address> is a Caddy site address for the forge (a domain817+# for automatic TLS, or http://... behind a TLS-terminating proxy). With a pages818+# domain, sites are served only from hostnames under it, one per site.819+cmd_server_caddyfile() {820+ [ $# -ge 1 ] || die "usage: forge server caddyfile <address> [<pages-domain> [<pages-address>]]"821+ load_config822+ addr=$1 pages=${2:-} paddr=${3:-${2:-}}823+ # Go module paths: <domain>/<owner>/<repo>/... answers ?go-get=1 with the824+ # repo's https clone. The domain is fixed so the import path is canonical.825+ gohost=${FORGE_BASE_URL#*://}826+ gohost=${gohost%%/*}827+ [ -n "$gohost" ] || gohost={host}828+ cat <<EOF829+$addr {830+ # Compress pages and assets, never the event stream (it would buffer).831+ encode {832+ gzip833+ match {834+ header Content-Type text/html*835+ header Content-Type text/css*836+ header Content-Type text/plain*837+ header Content-Type text/xml*838+ header Content-Type application/javascript*839+ header Content-Type application/json*840+ header Content-Type image/svg+xml*841+ }842+ }843+ handle /events/* {844+ reverse_proxy 127.0.0.1:${FORGE_EVENTS_PORT:-8091} {845+ flush_interval -1846+ }847+ }848+ @goget {849+ query go-get=1850+ path_regexp goget ^/([A-Za-z0-9_-][^/]*)/([^/]+?)(\.git)?(/.*)?$851+ }852+ handle @goget {853+ header Content-Type "text/html; charset=utf-8"854+ respond "<!DOCTYPE html><meta name=\"go-import\" content=\"$gohost/{re.goget.1}/{re.goget.2} git https://$gohost/{re.goget.1}/{re.goget.2}.git\"><p>go get $gohost/{re.goget.1}/{re.goget.2}</p>" 200855+ }856+EOF857+ # Smart HTTP for clones and fetches (git http-backend through fcgiwrap):858+ # packs only what the client needs and supports shallow fetches, which the859+ # go command uses. Read-only: only upload-pack is routed; pushes are ssh.860+ if command -v fcgiwrap >/dev/null 2>&1; then861+ cat <<EOF862+ @smart path_regexp smart ^/[A-Za-z0-9_-][^/]*/[^/]+\\.git/(info/refs|git-upload-pack)$863+ handle @smart {864+ reverse_proxy unix//run/fcgiwrap.socket {865+ transport fastcgi {866+ env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend867+ env GIT_PROJECT_ROOT $FORGE_ROOT868+ env GIT_HTTP_EXPORT_ALL 1869+ env PATH_INFO {path}870+ }871+ }872+ }873+EOF874+ fi875+ cat <<EOF876+ @repo path_regexp repo ^/[A-Za-z0-9_-][^/]*/[^/]+\\.git(/.*)?$877+ handle @repo {878+ root * $FORGE_ROOT879+ file_server880+ }881+EOF882+ # Old site paths redirect to each site's hostname (lib/pages-server.ts).883+ [ -n "$pages" ] && printf '\t@sites path /sites/* /previews/*\n\thandle @sites {\n\t\treverse_proxy 127.0.0.1:%s\n\t}\n' "${FORGE_PAGES_PORT:-8092}"884+ # Assets have content-hashed names (lib/assets.ts), so they never change.885+ # Pages and the JSON and text indexes they fetch revalidate on every use:886+ # Caddy sends ETag and Last-Modified, so an unchanged one costs a 304.887+ # Sites served from here keep their own caching.888+ hashed='^/assets/([^/]+\.)?[0-9A-Za-z_-]{10}\.[a-z]+$'889+ cat <<EOF890+ handle {891+ root * $FORGE_WWW892+ @hashed path_regexp $hashed893+ header @hashed Cache-Control "public, max-age=31536000, immutable"894+ @fresh {895+ not path_regexp $hashed896+ not path /sites/* /previews/*897+ }898+ header @fresh Cache-Control no-cache899+ file_server900+ }901+}902+EOF903+ [ -n "$pages" ] || return 0904+ # Each site has its own hostname, <label>.<pages-domain>; a wildcard905+ # certificate (or TLS in front, as on src.rest) covers them.906+ wild=${paddr%"$pages"*}*.$pages${paddr#*"$pages"}907+ cat <<EOF908+909+$paddr, $wild {910+ encode gzip911+ reverse_proxy 127.0.0.1:${FORGE_PAGES_PORT:-8092}912+}913+EOF914+}915+916+# keys holds "name ssh-ed25519 AAAA... comment" lines; it is the only source917+# of identity. authorized_keys forces every key through `forge shell <name>`,918+# and allowed_signers lets the same keys sign CI attestations and site deploys.919+cmd_keys_sync() {920+ load_config921+ mkdir -p "$(dirname "$FORGE_AUTHORIZED_KEYS")"922+ chmod 700 "$(dirname "$FORGE_AUTHORIZED_KEYS")" 2>/dev/null || true923+ ak=$FORGE_AUTHORIZED_KEYS.tmp as=$FORGE_STATE/allowed_signers.tmp924+ : >"$ak"925+ : >"$as"926+ while read -r name key; do927+ case $name in '' | '#'*) continue ;; esac928+ printf 'restrict,command="%s shell %s" %s\n' "$FORGE_BIN" "$name" "$key" >>"$ak"929+ printf '%s namespaces="%s,git" %s\n' "$name" "$NS" "$key" >>"$as"930+ done <"$FORGE_STATE/keys"931+ chmod 600 "$ak"932+ mv "$ak" "$FORGE_AUTHORIZED_KEYS"933+ mv "$as" "$FORGE_STATE/allowed_signers"934+}935+936+cmd_user() {937+ if [ "${1:-}" = rm ] && [ $# -eq 2 ]; then938+ load_config939+ grep -q "^$2 " "$FORGE_STATE/keys" || die "no user $2"940+ grep -v "^$2 " "$FORGE_STATE/keys" >"$FORGE_STATE/keys.tmp" || true941+ mv "$FORGE_STATE/keys.tmp" "$FORGE_STATE/keys"942+ cmd_keys_sync943+ say "removed $2's keys (repos under $FORGE_ROOT/$2 are kept)"944+ return945+ fi946+ [ "${1:-}" = add ] && [ $# -eq 3 ] || die "usage: forge user add <name> <pubkey-file|'ssh-ed25519 AAAA...'|-> | user rm <name>"947+ load_config948+ valid_name "$2" || die "invalid user name: $2"949+ if [ "$3" = - ]; then key=$(cat); elif [ -f "$3" ]; then key=$(cat "$3"); else key=$3; fi950+ case $key in ssh-* | ecdsa-* | sk-*) ;; *) die "not an SSH public key" ;; esac951+ printf '%s %s\n' "$2" "$key" >>"$FORGE_STATE/keys"952+ mkdir -p "$FORGE_ROOT/$2"953+ cmd_keys_sync954+ say "added $2"955+}956+957+install_hooks() {958+ rm -f "$1"/hooks/*.sample959+ for h in pre-receive post-receive; do960+ printf '#!/bin/sh\nexec "%s" hook %s\n' "$FORGE_BIN" "$h" >"$1/hooks/$h"961+ chmod +x "$1/hooks/$h"962+ done963+}964+965+# Hooks and settings for a repo; run when it is created and again by forge966+# server init. Push options carry review metadata. refs/forge (every review967+# revision's pinned tip) is hidden from clones, fetches and pushes; the page968+# build reads it directly. Changed-path Bloom filters use version 2, which969+# hashes non-ASCII paths the same on every platform (older git ignores it).970+repo_setup() {971+ install_hooks "$1"972+ git --git-dir="$1" config receive.advertisePushOptions true973+ git --git-dir="$1" config --replace-all transfer.hideRefs refs/forge '^refs/forge$'974+ # transfer.hideRefs covers both of these, which repos used to get.975+ for k in uploadpack.hideRefs receive.hideRefs; do git --git-dir="$1" config --unset-all "$k" '^refs/forge$' || true; done976+ git --git-dir="$1" config commitGraph.changedPathsVersion 2977+}978+979+# Server-side import: fetch every branch and tag from a URL into a new repo.980+# Hooks don't run on fetch, so branches don't become reviews.981+repo_import() {982+ slug=$1 url=$2 desc=${3:-}983+ cmd_repo new "$slug" "$desc"984+ dir=$FORGE_ROOT/$slug.git985+ git --git-dir="$dir" fetch -q "$url" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' || {986+ rm -rf "$dir"987+ die "import of $url failed"988+ }989+ head=$(git ls-remote --symref "$url" HEAD 2>/dev/null | sed -n 's|^ref: refs/heads/\(.*\) HEAD$|\1|p')990+ [ -n "$head" ] && git --git-dir="$dir" rev-parse -q --verify "refs/heads/$head" >/dev/null &&991+ git --git-dir="$dir" symbolic-ref HEAD "refs/heads/$head"992+ git --git-dir="$dir" update-server-info993+ say "imported $url -> $slug ($(git --git-dir="$dir" for-each-ref refs/heads | wc -l | tr -d ' ') branches)"994+}995+996+cmd_repo() {997+ if [ "${1:-}" = import ] && [ $# -ge 3 ]; then998+ load_config999+ repo_import "$2" "$3" "${4:-}"1000+ return1001+ fi1002+ [ "${1:-}" = new ] && [ $# -ge 2 ] || die "usage: forge repo new <owner>/<name> [description] | repo import <owner>/<name> <url> [description]"1003+ load_config1004+ owner=${2%%/*} name=${2#*/}1005+ valid_name "$owner" && valid_name "$name" && [ "$owner/$name" = "$2" ] || die "invalid repo: $2"1006+ dir=$FORGE_ROOT/$owner/$name.git1007+ [ -e "$dir" ] && die "$owner/$name exists"1008+ git init -q --bare "$dir"1009+ git --git-dir="$dir" symbolic-ref HEAD refs/heads/main1010+ printf '%s\n' "${3:-}" >"$dir/description"1011+ printf '%s\n' "$owner" >"$dir/owner"1012+ repo_setup "$dir"1013+ touch "$dir/git-daemon-export-ok"1014+ git --git-dir="$dir" update-server-info1015+ say "created $owner/$name"1016+}1017+1018+cmd_shell() {1019+ user=${1:?usage: forge shell <user>}1020+ load_config1021+ export FORGE_USER=$user1022+ if [ -z "${SSH_ORIGINAL_COMMAND:-}" ]; then1023+ printf 'Hi %s. This is a git-only account; try: ssh %s help\n' "$user" "${FORGE_SSH:-git@host}"1024+ exit 01025+ fi1026+ cd "$FORGE_ROOT"1027+ push_create1028+ exec git-shell -c "$SSH_ORIGINAL_COMMAND"1029+}1030+1031+# A push to <user>/<name>.git that doesn't exist yet creates it first, the1032+# way ssh <host> new <name> does, and the push goes on into it. Anything1033+# else that names a missing repo is left to git-shell, which says so.1034+push_create() {1035+ case $SSH_ORIGINAL_COMMAND in1036+ "git-receive-pack '"*"'" | "git receive-pack '"*"'") ;;1037+ *) return 0 ;;1038+ esac1039+ p=${SSH_ORIGINAL_COMMAND#*"'"}1040+ p=${p%"'"}1041+ p=${p%/}1042+ p=${p%.git}1043+ [ ! -e "$p.git" ] && [ ! -e "$p" ] || return 01044+ case $p in1045+ "$user"/*) ;;1046+ */*)1047+ valid_name "${p%%/*}" && die "no repo $p; only ${p%%/*} can create repos under ${p%%/*}/"1048+ return 01049+ ;;1050+ *) return 0 ;;1051+ esac1052+ # cmd_repo validates the name; if it fails because a concurrent push1053+ # just created the repo, carry on.1054+ (cmd_repo new "$p") || [ -e "$p.git" ] || exit 11055+}1056+1057+# ---------------------------------------------------------------- review discussion (server)1058+1059+# The newest review log in $1 for branch $2 that wasn't deleted.1060+branch_review() {1061+ grep -lxF "branch $2" "$1"/* 2>/dev/null | while read -r x; do grep -q '^deleted ' "$x" || echo "$x"; done | tail -n 11062+}1063+1064+# open_repo owner/repo: set RDIR, or die.1065+open_repo() {1066+ o=${1%%/*} n=${1#*/}1067+ valid_name "$o" && valid_name "$n" && [ "$o/$n" = "$1" ] && [ -f "$FORGE_ROOT/$1.git/HEAD" ] || die "no repo $1"1068+ RDIR=$FORGE_ROOT/$1.git1069+}1070+1071+# open_review owner/repo id|branch: set RDIR RF (the review log) RID RBRANCH1072+# RAUTHOR RTIP RLIVE, or die. A branch names its open review.1073+open_review() {1074+ open_repo "$1"1075+ case $2 in1076+ '' | *[!0-9]*) RF=$(branch_review "$RDIR/forge/reviews" "$2") ;;1077+ *) RF=$RDIR/forge/reviews/$2 ;;1078+ esac1079+ [ -n "$RF" ] && [ -f "$RF" ] || die "no review $2 in $1"1080+ RID=${RF##*/}1081+ RBRANCH=$(sed -n 's/^branch //p' "$RF" | head -n 1)1082+ RAUTHOR=$(awk '$1 == "created" { print $3; exit }' "$RF")1083+ RLIVE=01084+ if ! grep -q '^deleted ' "$RF" && RTIP=$(git --git-dir="$RDIR" rev-parse -q --verify "refs/heads/$RBRANCH^{commit}"); then1085+ RLIVE=11086+ else1087+ RTIP=$(awk '$1 == "rev" { s = $3 } END { print s }' "$RF")1088+ fi1089+}1090+1091+# rev_of sha: the revision of review $RF whose tip was sha.1092+rev_of() { awk -v s="$1" '$1 == "rev" { n++; if ($3 == s) r = n } END { if (!r) exit 1; print r }' "$RF"; }1093+1094+review_url() { u=${FORGE_BASE_URL:-}; printf '%s/%s/reviews/%s/' "${u%/}" "$1" "$2"; }1095+1096+# Who approved tree $2 on a review in repo dir $1 that they didn't open.1097+approvers() {1098+ for f in "$1"/forge/reviews/*; do1099+ [ -f "$f" ] && awk -v t="$2" '$1 == "created" { a = $3 } $1 == "approve" && $5 == t && $3 != a { print $3 }' "$f"1100+ done | sort -u1101+}1102+1103+# rebuild <owner>/<repo> <reason>: refresh a repo's pages after a change, in1104+# the background unless FORGE_SYNC_BUILD (or not at all with FORGE_NO_BUILD).1105+# Jev's answers are asked for first, so the build can show them.1106+rebuild() {1107+ if [ "${FORGE_NO_BUILD:-0}" = 1 ]; then1108+ :1109+ elif [ "${FORGE_SYNC_BUILD:-0}" = 1 ]; then1110+ "$FORGE_BIN" jev "$1" >&21111+ FORGE_BUILD_REASON=$2 "$FORGE_BIN" build "$1" >&21112+ else1113+ (cd / && FORGE_BUILD_REASON=$2 nohup sh -c '"$0" jev "$1"; exec "$0" build "$1"' "$FORGE_BIN" "$1" </dev/null >>"$FORGE_STATE/build.log" 2>&1 &)1114+ fi1115+}1116+1117+# forge jev [owner/repo]: ask Jev about what the repo's pages show and store1118+# the answers (lib/jev-fill.ts). Without TYPESAFE_API_KEY it does nothing,1119+# and a failure never stops the build that follows.1120+cmd_jev() {1121+ load_config1122+ [ -n "${TYPESAFE_API_KEY:-}" ] && [ "${FORGE_JEV:-1}" != 0 ] || return 01123+ node "$FORGE_HOME/lib/jev-fill.ts" "$@" || say "jev: failed for ${1:-all repos}"1124+}1125+1126+# After a comment or approval: tell open pages, and rebuild the repo the way1127+# post-receive does after a push.1128+review_changed() {1129+ printf '{"t":%s000,"repo":"%s","type":"%s","user":"%s","review":%s}\n' "$(date +%s)" "$1" "$2" "$user" "$RID" >>"$FORGE_STATE/events.jsonl"1130+ rebuild "$1" "$2"1131+}1132+1133+# comment <owner>/<repo> <id|branch> [--at <sha>] [--line <path>:<line>] <message1134+#1135+# Stored as <repo>.git/forge/comments/<review>/<n>: header lines, a blank1136+# line, the message. A line comment records the line's text too, so it still1137+# reads right once the review moves on.1138+sc_comment() {1139+ [ $# -ge 2 ] || die "usage: comment <owner>/<repo> <id|branch> [--at <sha>] [--line <path>:<line>] <message"1140+ open_review "$1" "$2"1141+ slug=$1 at= where=1142+ shift 21143+ while [ $# -gt 0 ]; do1144+ case $1 in1145+ --at) [ $# -ge 2 ] || die "comment: --at needs a commit"; at=$2; shift ;;1146+ --line) [ $# -ge 2 ] || die "comment: --line needs <path>:<line>"; where=$2; shift ;;1147+ *) die "comment: unknown argument $1" ;;1148+ esac1149+ shift1150+ done1151+ sha=$RTIP1152+ # You commented on the revision you have checked out; if the review has1153+ # moved on since, the comment belongs to that older revision.1154+ if [ -n "$at" ] && [ "$at" != "$RTIP" ]; then1155+ rev_of "$at" >/dev/null || die "$(printf %.7s "$at") isn't a pushed revision of #$RID; push it, or name the review by number"1156+ sha=$at1157+ fi1158+ path= line= text=1159+ if [ -n "$where" ]; then1160+ path=${where%:*} line=${where##*:}1161+ case $line in '' | 0* | *[!0-9]* | ?????????*) die "comment: want <path>:<line>, got $where" ;; esac1162+ # One header line each: no control characters (newlines) in the path.1163+ case $path in '' | /* | *[[:cntrl:]]*) die "comment: want <path>:<line>, got $where" ;; esac1164+ [ "$(git --git-dir="$RDIR" cat-file -t "$sha:$path" 2>/dev/null)" = blob ] || die "comment: no file $path in #$RID at $(printf %.7s "$sha")"1165+ lines=$(git --git-dir="$RDIR" cat-file blob "$sha:$path" | awk 'END { print NR }')1166+ [ "$line" -le "$lines" ] || die "comment: $path has $lines lines at $(printf %.7s "$sha")"1167+ text=$(git --git-dir="$RDIR" cat-file blob "$sha:$path" | sed -n "${line}{p;q;}" | cut -c 1-300 | sanitize)1168+ fi1169+ d=$RDIR/forge/comments/$RID1170+ mkdir -p "$d"1171+ tmp=$d/.new.$$1172+ # A cap, so a stray pipe can't fill the disk.1173+ head -c 32769 >"$tmp.raw"1174+ [ "$(wc -c <"$tmp.raw")" -le 32768 ] || { rm -f "$tmp.raw"; die "comment: longer than 32 KiB"; }1175+ sanitize <"$tmp.raw" | sed '/./,$!d' >"$tmp.body"1176+ rm -f "$tmp.raw"1177+ grep -q '[^[:space:]]' "$tmp.body" || { rm -f "$tmp.body"; die "comment: empty message"; }1178+ rev=$(rev_of "$sha" || true)1179+ {1180+ printf 'time %s\nuser %s\nsha %s\n' "${FORGE_NOW:-$(date +%s)}" "$user" "$sha"1181+ if [ -n "$rev" ]; then printf 'rev %s\n' "$rev"; fi1182+ if [ -n "$path" ]; then printf 'path %s\nline %s\ntext %s\n' "$path" "$line" "$text"; fi1183+ echo1184+ cat "$tmp.body"1185+ } >"$tmp"1186+ rm -f "$tmp.body"1187+ k=$(ls "$d" | grep -E '^[0-9]+$' | sort -n | tail -n 1)1188+ k=$((${k:-0} + 1))1189+ # ln fails if the name is taken, so comments sent at once get their own numbers.1190+ until ln "$tmp" "$d/$k" 2>/dev/null; do1191+ k=$((k + 1))1192+ [ "$k" -lt 1000000 ] || { rm -f "$tmp"; die "comment: could not store it"; }1193+ done1194+ rm -f "$tmp"1195+ review_changed "$slug" comment1196+ echo "comment $k on #$RID${rev:+ r$rev}: $(review_url "$slug" "$RID")#comment-$k"1197+ [ "$sha" = "$RTIP" ] || echo "(#$RID has a newer revision, so this shows as outdated)"1198+}1199+1200+# approve <owner>/<repo> <id|branch> [--at <sha>]: approve the tip's tree.1201+sc_approve() {1202+ [ $# -ge 2 ] || die "usage: approve <owner>/<repo> <id|branch> [--at <sha>]"1203+ open_review "$1" "$2"1204+ slug=$1 at=1205+ [ "${3:-}" = --at ] && at=${4:-}1206+ [ "$RLIVE" = 1 ] || die "#$RID is closed"1207+ [ "$user" != "$RAUTHOR" ] || die "you opened #$RID; an approval has to come from someone else"1208+ rev=$(rev_of "$RTIP" || true)1209+ [ -z "$at" ] || [ "$at" = "$RTIP" ] ||1210+ die "#$RID has moved on to ${rev:+r$rev }$(printf %.7s "$RTIP") since $(printf %.7s "$at"); run forge checkout $RID and look again"1211+ tree=$(git --git-dir="$RDIR" rev-parse "$RTIP^{tree}")1212+ if awk -v u="$user" -v t="$tree" '$1 == "approve" && $3 == u && $5 == t { f = 1 } END { exit !f }' "$RF"; then1213+ echo "you already approved this tree of #$RID"1214+ return1215+ fi1216+ echo "approve ${FORGE_NOW:-$(date +%s)} $user $RTIP $tree" >>"$RF"1217+ review_changed "$slug" approve1218+ echo "approved #$RID${rev:+ r$rev} ($(printf %.7s "$RTIP")): $(review_url "$slug" "$RID")"1219+}1220+1221+# land <owner>/<repo> <id|branch>: land a review without a clone, for agents1222+# and phones. The forge pushes inside itself, from the bare repo to the bare1223+# repo, as the caller (FORGE_USER), so pre-receive applies the gates any push1224+# meets and post-receive does what it does after one: logs the deleted1225+# branches, drops their previews, appends the event, rebuilds the pages.1226+sc_land() {1227+ [ $# -eq 2 ] || die "usage: land <owner>/<repo> <id|branch>"1228+ open_repo "$1"1229+ slug=$1 tab=$(printf '\t')1230+ export GIT_DIR=$RDIR1231+ # From the review logs and refs, not reviews.json: a review pushed a1232+ # moment ago isn't in the last build.1233+ rows=$(node "$FORGE_HOME/lib/review-list.ts" --repo "$RDIR" --tsv) || die "land: could not read the reviews of $slug"1234+ row=$(review_row "$2")1235+ [ -n "$row" ] || die "land: no review $2 in $slug"1236+ IFS=$tab read -r id status branch onto parent _ _ title <<-EOF1237+ $row1238+ EOF1239+ case $status in open | draft) ;; *) die "land: #$id is $status" ;; esac1240+ tip=$(git rev-parse -q --verify "refs/heads/$branch^{commit}") || die "land: $branch is gone"1241+ base=$(git rev-parse -q --verify "refs/heads/$onto^{commit}") || die "land: $slug has no $onto"1242+ git merge-base --is-ancestor "$tip" "$base" && die "land: $branch is already in $onto"1243+ if ! git merge-base --is-ancestor "$base" "$tip"; then1244+ [ "$(git config --bool forge.allowforce || echo false)" = true ] ||1245+ die "land: $branch doesn't fast-forward $onto; rebase it: git rebase origin/$onto && forge push"1246+ say "land: $branch doesn't fast-forward $onto; forge.allowforce lets it through"1247+ fi1248+ gone=$branch$(layers_in "$parent" "$tip" refs/heads/)1249+ say "landing #$id $title: $branch -> $onto"1250+ # Leases: if anything moved since we looked, nothing changes.1251+ set -- --atomic "--force-with-lease=refs/heads/$onto:$base"1252+ for b in $gone; do set -- "$@" "--force-with-lease=refs/heads/$b:$(git rev-parse "refs/heads/$b")"; done1253+ set -- "$@" "$RDIR" "$tip:refs/heads/$onto"1254+ for b in $gone; do set -- "$@" ":refs/heads/$b"; done1255+ # The hooks' messages come back as "remote: ...". git's own lines name1256+ # server paths, so of those only refusals that the hooks didn't explain1257+ # are passed on.1258+ if out=$(git push -q "$@" 2>&1); then ok=1; else ok=0; fi1259+ printf '%s\n' "$out" | awk '1260+ sub(/^remote: /, "") { sub(/[ \t]+$/, ""); print; next }1261+ /^ *! \[/ && !/\((pre-receive hook declined|atomic push failed)\)$/ {1262+ sub(/.* -> /, ""); sub(/ \(/, ": "); sub(/\)$/, ""); sub(/stale info$/, "moved meanwhile; try again"); print "land: " $01263+ }' >&21264+ [ "$ok" = 1 ] || die "land: refused; nothing changed"1265+ echo "landed #$id on $onto at $(git rev-parse --short "$tip"); deleted $gone"1266+}1267+1268+# Commands reachable over ssh through git-shell-commands/.1269+cmd_shell_cmd() {1270+ load_config1271+ user=${FORGE_USER:?no FORGE_USER}1272+ c=$11273+ shift1274+ case $c in1275+ new)1276+ [ $# -ge 1 ] || die "usage: ssh <host> new <name> [description]"1277+ n=$11278+ shift1279+ cmd_repo new "$user/$n" "$*"1280+ echo "git remote add origin ${FORGE_SSH:-git@host}:$user/$n.git"1281+ ;;1282+ ls) for d in "$FORGE_ROOT/$user"/*.git; do [ -d "$d" ] && basename "$d" .git; done | sort -f ;;1283+ whoami) echo "$user" ;;1284+ signers) cat "$FORGE_STATE/allowed_signers" ;;1285+ site-url) node "$FORGE_HOME/lib/hosts.ts" url "$@" ;;1286+ reviews)1287+ [ $# -ge 1 ] || die "usage: reviews <owner>/<repo> [--tsv]"1288+ open_repo "$1"1289+ [ -f "$FORGE_WWW/$1/reviews.json" ] || die "no review list for $1 yet; it is written by the page build"1290+ case ${2:-} in '' | --tsv) ;; *) die "usage: reviews <owner>/<repo> [--tsv]" ;; esac1291+ node "$FORGE_HOME/lib/review-list.ts" "$FORGE_WWW/$1/reviews.json" ${2:+"$2"}1292+ ;;1293+ comment) sc_comment "$@" ;;1294+ approve) sc_approve "$@" ;;1295+ land) sc_land "$@" ;;1296+ approvals)1297+ # For forge land: whether the repo requires an approval, then who approved this tree.1298+ [ $# -eq 2 ] || die "usage: approvals <owner>/<repo> <tree>"1299+ open_repo "$1"1300+ if [ "$(git --git-dir="$RDIR" config --bool forge.requireapproval || echo false)" = true ]; then echo required; else echo optional; fi1301+ approvers "$RDIR" "$2"1302+ ;;1303+ *) printf 'commands:\n new <name> [description] create %s/<name>\n ls list your repos\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;1304+ esac1305+}1306+1307+# Run inside a bare repo from a hook: sets OWNER NAME DEFAULT ADMIN.1308+repo_ctx() {1309+ load_config1310+ GIT_DIR=$(CDPATH= cd -- "${GIT_DIR:-.}" && pwd)1311+ NAME=$(basename "$GIT_DIR" .git)1312+ OWNER=$(basename "$(dirname "$GIT_DIR")")1313+ DEFAULT=$(git symbolic-ref --short HEAD 2>/dev/null || echo main)1314+ USER_=${FORGE_USER:-}1315+ ADMIN=01316+ if [ -z "$USER_" ] || [ "$USER_" = "$OWNER" ] || grep -qxF "$USER_" "$GIT_DIR/forge/collaborators" 2>/dev/null; then1317+ ADMIN=11318+ fi1319+}1320+1321+deny() { printf 'forge: %s\n' "$*" >&2; exit 1; }1322+1323+# Everything here is public, so refuse a push that adds a credential. The1324+# script prints what it found and exits 3 only then; any other failure of the1325+# check (node exits 1 on a broken import) lets the push through.1326+scan_secrets() {1327+ printf '%s\n' "$updates" | node "$FORGE_HOME/lib/jev-secrets.ts" && return 01328+ [ $? != 3 ] || deny "push refused by the secret check"1329+ say "forge: secret check did not run; push not checked"1330+}1331+1332+hook_pre_receive() {1333+ repo_ctx1334+ # The default branch first, so a refused push explains that one first.1335+ updates=$(awk -v d="refs/heads/$DEFAULT" '$3 == d { print; next } { r = r $0 "\n" } END { printf "%s", r }')1336+ notes_new=1337+ while read -r old new ref; do1338+ [ -n "$ref" ] || continue1339+ mine=01340+ case $ref in1341+ refs/heads/"$USER_"/* | refs/site-previews/*/"$USER_"/*) mine=1 ;;1342+ esac1343+ case $ref in1344+ refs/forge/*) deny "$ref is internal" ;;1345+ "refs/heads/$DEFAULT")1346+ [ "$ADMIN" = 1 ] || deny "only $OWNER can push $DEFAULT; push a branch under $USER_/ for review"1347+ [ "$new" = "$ZERO" ] && deny "refusing to delete $DEFAULT"1348+ if [ "$old" != "$ZERO" ] && ! git merge-base --is-ancestor "$old" "$new" &&1349+ [ "$(git config --bool forge.allowforce || echo false)" != true ]; then1350+ deny "$DEFAULT does not accept force pushes (git config forge.allowforce true to allow)"1351+ fi1352+ ;;1353+ refs/heads/* | refs/site-previews/*) [ "$ADMIN" = 1 ] || [ "$mine" = 1 ] || deny "$ref: push under refs/heads/$USER_/" ;;1354+ refs/tags/* | refs/sites/*) [ "$ADMIN" = 1 ] || deny "$ref: only $OWNER can update this" ;;1355+ refs/notes/ci)1356+ # Anyone may add attestations (they are signed); only admins may rewrite.1357+ if [ "$ADMIN" = 0 ]; then1358+ [ "$new" = "$ZERO" ] && deny "refs/notes/ci cannot be deleted"1359+ [ "$old" = "$ZERO" ] || git merge-base --is-ancestor "$old" "$new" ||1360+ deny "refs/notes/ci must fast-forward (forge push merges notes for you)"1361+ fi1362+ notes_new=$new1363+ ;;1364+ *) [ "$ADMIN" = 1 ] || deny "$ref: not allowed" ;;1365+ esac1366+ done <<-EOF1367+ $updates1368+ EOF1369+1370+ scan_secrets1371+1372+ # Approvals are keyed by tree, like CI, and must come from someone other1373+ # than the review's author (see approvers).1374+ if [ "$(git config --bool forge.requireapproval || echo false)" = true ]; then1375+ while read -r old new ref; do1376+ [ "$ref" = "refs/heads/$DEFAULT" ] && [ "$new" != "$ZERO" ] || continue1377+ [ -n "$(approvers "$GIT_DIR" "$(git rev-parse "$new^{tree}")")" ] ||1378+ deny "$DEFAULT: $(git rev-parse --short "$new") is not approved; someone other than the review's author has to run forge approve <review>"1379+ done <<-EOF1380+ $updates1381+ EOF1382+ fi1383+1384+ require=$(git config --get forge.requireci || true)1385+ [ -n "$require" ] || exit 01386+ notes=${notes_new:-refs/notes/ci}1387+ [ "$notes" = "$ZERO" ] && notes=refs/notes/ci1388+ signers=$FORGE_STATE/allowed_signers quiet=11389+ while read -r old new ref; do1390+ [ "$ref" = "refs/heads/$DEFAULT" ] && [ "$new" != "$ZERO" ] || continue1391+ if [ "$require" = all ] && [ "$old" != "$ZERO" ]; then commits=$(git rev-list "$old..$new")1392+ else commits=$new; fi1393+ for c in $commits; do1394+ verify_tree "$(git rev-parse "$c^{tree}")" "$c" ||1395+ deny "$DEFAULT: $(git rev-parse --short "$c") has no verified passing CI attestation (run: forge attest$( [ "$require" = all ] && echo ' --stack'); forge push)"1396+ done1397+ done <<-EOF1398+ $updates1399+ EOF1400+}1401+1402+record_review() {1403+ b=$1 new=$21404+ dir=$GIT_DIR/forge/reviews1405+ mkdir -p "$dir"1406+ f=$(branch_review "$dir" "$b")1407+ if [ "$new" = "$ZERO" ]; then1408+ [ -n "$f" ] && echo "deleted $NOW ${USER_:-admin}" >>"$f"1409+ return 01410+ fi1411+ if [ -z "$f" ]; then1412+ last=$(ls "$dir" | grep -E '^[0-9]+$' | sort -n | tail -n 1)1413+ f=$dir/$((${last:-0} + 1))1414+ printf 'branch %s\ncreated %s %s\n' "$b" "$NOW" "${USER_:-admin}" >"$f"1415+ fi1416+ printf '%s\n' "$PUSH_OPTS" | while IFS= read -r o; do1417+ # printf, not echo: some shells' echo expands the \n in descriptions.1418+ case $o in title=* | description=* | target=* | draft | ready) printf 'opt %s\n' "$o" >>"$f" ;; esac1419+ done1420+ base=$(git merge-base "$DEFAULT" "$new" 2>/dev/null || true)1421+ echo "rev $NOW $new ${USER_:-admin} $base" >>"$f"1422+ # Keep every revision reachable so old range-diffs survive force pushes.1423+ git update-ref "refs/forge/keep/$new" "$new"1424+ review_link "${f##*/}" "$new" "$b"1425+}1426+1427+# Tell the pusher where review $1 (tip $2, branch $3) is, and the review it1428+# is stacked on: the nearest branch inside it that isn't in the default branch.1429+review_link() {1430+ on= most=0 inside=1431+ git rev-parse -q --verify "refs/heads/$DEFAULT" >/dev/null &&1432+ inside=$(git for-each-ref --format='%(refname:lstrip=2)' --merged "$2" --no-merged "$DEFAULT" refs/heads/)1433+ for p in $inside; do1434+ [ "$p" != "$3" ] || continue1435+ k=$(git rev-list --count "$DEFAULT..refs/heads/$p")1436+ [ "$k" -gt "$most" ] || continue1437+ pf=$(branch_review "$GIT_DIR/forge/reviews" "$p")1438+ if [ -n "$pf" ]; then most=$k on=" (on #${pf##*/} $p)"; fi1439+ done1440+ say "review #$1: $(review_url "$OWNER/$NAME" "$1")$on"1441+}1442+1443+# Extract a site tree into the content store and point its URL at it.1444+deploy_site() {1445+ link=$FORGE_WWW/$11446+ if [ "$2" = "$ZERO" ]; then1447+ rm -f "$link"1448+ [ -z "${FORGE_PAGES_DOMAIN:-}" ] || node "$FORGE_HOME/lib/hosts.ts" unregister "$1" || true1449+ return 01450+ fi1451+ tree=$(git rev-parse "$2^{tree}")1452+ store=$FORGE_STATE/store/$tree1453+ if [ ! -d "$store" ]; then1454+ mkdir -p "$store.tmp.$$"1455+ git archive "$2" | tar -x -C "$store.tmp.$$"1456+ mv "$store.tmp.$$" "$store"1457+ fi1458+ # A reused tree counts as new, so a concurrent forge gc leaves it alone.1459+ touch -c "$store"1460+ mkdir -p "$(dirname "$link")"1461+ ln -sfn "$store" "$link"1462+ # Its own hostname, when the forge has a pages domain (lib/hosts.ts).1463+ if [ -n "${FORGE_PAGES_DOMAIN:-}" ]; then1464+ if u=$(node "$FORGE_HOME/lib/hosts.ts" register "$1" 2>&1); then say "site ${1#*/*/*/}: $u"; else say "forge: $u"; fi1465+ fi1466+}1467+1468+# drop_preview <ref>: delete a preview ref, its served link and its hostname.1469+# Runs in a repo (GIT_DIR) with OWNER and NAME set.1470+drop_preview() {1471+ pv=${1#refs/site-previews/}1472+ git update-ref -d "$1"1473+ deploy_site "previews/$OWNER/$NAME/${pv%%/*}/$(enc_ref "${pv#*/}")" "$ZERO"1474+}1475+1476+# A deleted branch takes its site previews with it.1477+drop_previews() {1478+ for p in $(git for-each-ref --format='%(refname)' refs/site-previews/); do1479+ pv=${p#refs/site-previews/}1480+ [ "${pv#*/}" = "$1" ] || continue1481+ if drop_preview "$p"; then say "removed preview ${pv%%/*} of $1"; else say "forge: could not remove $p"; fi1482+ done1483+}1484+1485+hook_post_receive() {1486+ repo_ctx1487+ # FORGE_NOW pins the clock for review logs; the demo and tests set it.1488+ NOW=${FORGE_NOW:-$(date +%s)}1489+ PUSH_OPTS=1490+ i=01491+ while [ "$i" -lt "${GIT_PUSH_OPTION_COUNT:-0}" ]; do1492+ eval "o=\${GIT_PUSH_OPTION_$i}"1493+ PUSH_OPTS="$PUSH_OPTS$o1494+"1495+ i=$((i + 1))1496+ done1497+ # Imports push with -o noreview so existing branches don't become reviews,1498+ # and -o head=<branch> to keep the source repo's default branch.1499+ noreview=01500+ case "1501+$PUSH_OPTS" in *"1502+noreview1503+"*) noreview=1 ;; esac1504+ head=$(printf '%s' "$PUSH_OPTS" | sed -n 's/^head=//p' | tail -n 1)1505+ if [ -n "$head" ] && [ "$ADMIN" = 1 ] && git rev-parse -q --verify "refs/heads/$head" >/dev/null; then1506+ git symbolic-ref HEAD "refs/heads/$head"1507+ DEFAULT=$head1508+ fi1509+ updates=$(cat)1510+ # A repo without its default branch yet (one just created by a push)1511+ # takes the first branch pushed, so pushing master makes master the1512+ # default rather than a review.1513+ if [ "$ADMIN" = 1 ] && ! git rev-parse -q --verify "refs/heads/$DEFAULT" >/dev/null; then1514+ first=$(printf '%s\n' "$updates" | awk -v z="$ZERO" '$2 != z && $3 ~ /^refs\/heads\// { print substr($3, 12); exit }')1515+ if [ -n "$first" ]; then1516+ git symbolic-ref HEAD "refs/heads/$first"1517+ DEFAULT=$first1518+ fi1519+ fi1520+ PUSHED=1521+ while read -r old new ref; do1522+ case $ref in refs/heads/* | refs/tags/* | refs/sites/*) PUSHED="$PUSHED $ref" ;; esac1523+ [ "$new" != "$ZERO" ] || case $ref in refs/heads/*) drop_previews "${ref#refs/heads/}" ;; esac1524+ case $ref in1525+ "refs/heads/$DEFAULT") ;;1526+ refs/heads/*) [ "$noreview" = 1 ] || record_review "${ref#refs/heads/}" "$new" ;;1527+ refs/sites/*) deploy_site "sites/$OWNER/$NAME/${ref#refs/sites/}" "$new" ;;1528+ refs/site-previews/*)1529+ rest=${ref#refs/site-previews/}1530+ deploy_site "previews/$OWNER/$NAME/${rest%%/*}/$(enc_ref "${rest#*/}")" "$new"1531+ ;;1532+ esac1533+ done <<-EOF1534+ $updates1535+ EOF1536+ git update-server-info1537+ refs=$(printf '%s' "$PUSHED" | tr -d '"\\')1538+ printf '{"t":%s000,"repo":"%s","type":"push","user":"%s","refs":"%s"}\n' "$(date +%s)" "$OWNER/$NAME" "${USER_:-admin}" "$refs" >>"$FORGE_STATE/events.jsonl"1539+ rebuild "$OWNER/$NAME" push1540+}1541+1542+swap() {1543+ rm -rf "$2.old"1544+ [ -e "$2" ] && mv "$2" "$2.old"1545+ mkdir -p "$(dirname "$2")"1546+ mv "$1" "$2"1547+ rm -rf "$2.old"1548+}1549+1550+# write_graph <git-dir> [replace]: the commit-graph, with changed-path Bloom1551+# filters, so git log -- <path> and blame skip commits that don't touch the1552+# path. Split: each write adds a small layer for new commits and merges1553+# layers as they grow; replace (forge gc) rewrites the chain as one,1554+# reusing the filters it already has.1555+write_graph() {1556+ git --git-dir="$1" commit-graph write --reachable --changed-paths --no-progress --split${2:+=$2} ||1557+ say "forge: commit-graph write in $1 failed"1558+}1559+1560+# Without arguments, rebuild repo by repo so memory stays bounded by the1561+# largest repo, then drop pages for repos and owners that no longer exist.1562+build_all() {1563+ # Same order as the pages list them: ignoring case.1564+ repos=$(cd "$FORGE_ROOT" && for d in */*.git; do [ -f "$d/HEAD" ] && printf '%s\n' "${d%.git}"; done | sort -f)1565+ for r in $repos; do "$FORGE_BIN" build "$r" || say "build of $r failed"; done1566+ # Index and owner pages only (no repo is named "-").1567+ "$FORGE_BIN" build -1568+ for w in "$FORGE_WWW"/*/*/; do1569+ [ -d "$w" ] || continue1570+ slug=${w#"$FORGE_WWW"/}1571+ slug=${slug%/}1572+ case $slug in assets/* | sites/* | previews/* | hosts/*) continue ;; esac1573+ [ -f "$FORGE_ROOT/$slug.git/HEAD" ] || rm -rf "$w"1574+ done1575+ for w in "$FORGE_WWW"/*/; do1576+ b=$(basename "$w")1577+ case $b in assets | sites | previews | hosts) continue ;; esac1578+ [ -d "$FORGE_ROOT/$b" ] || rm -rf "$w"1579+ done1580+ if [ -d "$FORGE_WWW/hosts" ]; then node "$FORGE_HOME/lib/hosts.ts" prune; fi1581+}1582+1583+cmd_build() {1584+ load_config1585+ [ $# -gt 0 ] || { build_all; return; }1586+ only=$11587+ lock=$FORGE_STATE/build.lock1588+ n=01589+ until mkdir "$lock" 2>/dev/null; do1590+ n=$((n + 1))1591+ [ "$n" -lt 600 ] || die "build lock held: $lock"1592+ sleep 11593+ done1594+ out=$FORGE_STATE/build.$$1595+ live=$FORGE_WWW1596+ trap 'rm -rf "$out"; rmdir "$lock" 2>/dev/null' EXIT1597+ rm -rf "$out"1598+ t0=$(date +%s)1599+ # The pusher isn't waiting on this, so the commit-graph is brought up to1600+ # date here; the build's git log calls use it too.1601+ if [ -f "$FORGE_ROOT/$only.git/HEAD" ]; then write_graph "$FORGE_ROOT/$only.git"; fi1602+ (cd "$FORGE_HOME" && FORGE_ONLY=$only FORGE_WWW=$out FORGE_LIVE_WWW=$live FORGE_BUILD_STARTED=$((t0 * 1000)) FORGE_BUILD_REASON=${FORGE_BUILD_REASON:-manual} \1603+ "$FORGE_HOME/node_modules/.bin/eleventy" --config=eleventy.config.ts --quiet --output="$out")1604+ mkdir -p "$FORGE_WWW"1605+ # Swap generated pages in. sites/, previews/ and hosts/ belong to the hooks. A1606+ # single-repo build refreshes owner index pages but only replaces that repo.1607+ for e in "$out"/*; do1608+ b=$(basename "$e")1609+ case $b in sites | previews | hosts) continue ;; esac1610+ if [ -n "$only" ] && [ -d "$e" ] && [ "$b" != assets ]; then1611+ mkdir -p "$FORGE_WWW/$b"1612+ [ -f "$e/index.html" ] && cp "$e/index.html" "$FORGE_WWW/$b/index.html"1613+ [ "$b" = "${only%%/*}" ] || continue1614+ if [ -d "$out/$only" ]; then swap "$out/$only" "$FORGE_WWW/$only"; else rm -rf "${FORGE_WWW:?}/$only"; fi1615+ else1616+ swap "$e" "$FORGE_WWW/$b"1617+ fi1618+ done1619+ if [ -f "$FORGE_STATE/events.jsonl" ] && [ "$(wc -c <"$FORGE_STATE/events.jsonl")" -gt 1048576 ]; then1620+ tail -n 1000 "$FORGE_STATE/events.jsonl" >"$FORGE_STATE/events.jsonl.tmp" && mv "$FORGE_STATE/events.jsonl.tmp" "$FORGE_STATE/events.jsonl"1621+ fi1622+ mirror_kick1623+ say "built ${only:-all repos} in $(($(date +%s) - t0))s -> $FORGE_WWW"1624+}1625+1626+cmd_metrics() {1627+ load_config1628+ node "$FORGE_HOME/lib/metrics-report.ts" "$@"1629+}1630+1631+# Was branch $2 ever a review in repo $1? Then its previews are leftovers.1632+was_review() { grep -qxF "branch $2" "$1"/forge/reviews/* 2>/dev/null; }1633+1634+# Clean up after deploys; safe to run from cron. Removes previews of review1635+# branches that are gone (deleted outside the hook, or before it removed1636+# previews), hostnames whose deploy is gone, and store trees that no site or1637+# preview links to (hostnames point at those links, never at the store).1638+# A deploy extracts its tree before linking it, so entries changed in the1639+# last --age minutes stay. Then repacks every repo (repack_repo).1640+cmd_gc() {1641+ load_config1642+ dry=0 age=101643+ while [ $# -gt 0 ]; do1644+ case $1 in1645+ -n | --dry-run) dry=1 ;;1646+ --age) age=${2:-}; shift ;;1647+ *) die "usage: forge gc [--dry-run] [--age minutes]" ;;1648+ esac1649+ shift1650+ done1651+ case $age in '' | *[!0-9]*) die "gc: --age takes whole minutes" ;; esac1652+ did=removed1653+ [ "$dry" = 0 ] || did='would remove'1654+ repos=$(cd "$FORGE_ROOT" && for d in */*.git; do if [ -f "$d/HEAD" ]; then echo "$d"; fi; done)1655+ for d in $repos; do1656+ GIT_DIR=$FORGE_ROOT/$d OWNER=${d%%/*} NAME=$(basename "$d" .git)1657+ export GIT_DIR1658+ for p in $(git for-each-ref --format='%(refname)' refs/site-previews/); do1659+ b=${p#refs/site-previews/*/}1660+ git rev-parse -q --verify "refs/heads/$b" >/dev/null && continue1661+ was_review "$GIT_DIR" "$b" || continue1662+ [ "$dry" = 1 ] || drop_preview "$p" || { say "forge: could not remove $p"; continue; }1663+ echo "$did preview $OWNER/$NAME ${p#refs/site-previews/}"1664+ done1665+ unset GIT_DIR1666+ done1667+ if [ -d "$FORGE_WWW/hosts" ]; then1668+ if [ "$dry" = 1 ]; then node "$FORGE_HOME/lib/hosts.ts" prune --dry-run; else node "$FORGE_HOME/lib/hosts.ts" prune; fi1669+ fi1670+ # Candidates before links: a deploy that links a tree after this looked1671+ # has touched it first, so it can't be a candidate.1672+ n=0 kb=01673+ if [ -d "$FORGE_STATE/store" ]; then1674+ set --1675+ [ "$age" = 0 ] || set -- -mmin +"$age"1676+ old=$(find "$FORGE_STATE/store" -mindepth 1 -maxdepth 1 "$@")1677+ used=$(for l in "$FORGE_WWW"/sites/*/*/* "$FORGE_WWW"/previews/*/*/*/*; do if [ -L "$l" ]; then basename "$(readlink "$l")"; fi; done)1678+ for s in $old; do1679+ printf '%s\n' "$used" | grep -qxF "${s##*/}" && continue1680+ k=$(du -sk "$s" | cut -f1)1681+ [ "$dry" = 1 ] || rm -rf "$s"1682+ echo "$did store ${s##*/} (${k}K)"1683+ n=$((n + 1)) kb=$((kb + k))1684+ done1685+ fi1686+ if [ "$dry" = 1 ]; then1687+ echo "gc: would remove $n from the store (${kb}K); repacking skipped"1688+ return1689+ fi1690+ for d in $repos; do repack_repo "$FORGE_ROOT/$d" || say "forge: repacking $d failed"; done1691+ echo "gc: removed $n from the store (${kb}K)"1692+}1693+1694+# Geometric repacking merges packs only until each is at least twice the1695+# size of the next smaller one, so a run rewrites what is new rather than1696+# the whole repo, and loose objects go into the new pack. A multi-pack index1697+# with a reachability bitmap then serves clones and fetches from all the1698+# packs at once. It only deletes packs it has rewritten and prunes nothing,1699+# so pushes can land meanwhile. Then the commit-graph, and loose refs (one1700+# per pinned revision under refs/forge/keep) go into packed-refs.1701+repack_repo() {1702+ git --git-dir="$1" repack -d -q --geometric=2 --write-midx --write-bitmap-index &&1703+ write_graph "$1" replace &&1704+ git --git-dir="$1" pack-refs --all1705+}1706+1707+# ---------------------------------------------------------------- backups1708+#1709+# forge backup snapshots the forge's state with restic, to an S3-compatible1710+# bucket (R2 for src.rest); forge restore brings a forge back from one, here1711+# or on a new host. What is state and what is rebuilt: README.md, "Backups1712+# and moving hosts".1713+#1714+# A snapshot has to restore to repos whose refs point at objects that exist,1715+# even when it is taken mid-push, so it isn't read from the live files. Each1716+# run first stages a copy, per repo in this order:1717+# 1. everything but refs and objects: review logs, comments, forge/jev,1718+# config, HEAD;1719+# 2. the refs, read once by git into packed-refs;1720+# 3. the objects, as hard links, which cost nothing because object files1721+# never change once written. Loose objects go first, then packs, so an1722+# object that a repack moves meanwhile is still caught in its new pack.1723+# Git writes objects before the refs and logs that name them, so step 31724+# links everything steps 1 and 2 saw. A repo is staged again if a file went1725+# away mid-copy (a gc) or the copy fails git fsck --connectivity-only.1726+# Nothing takes a lock that pushes, comments or gc wait on.1727+1728+# Backup and mirror settings (FORGE_BACKUP_*, FORGE_MIRROR_*) come from the1729+# environment or .forge/backup.env, which the container writes at boot (on a1730+# VPS, you write it). Not read by load_config, so hooks and builds never hold1731+# the keys.1732+backup_env() {1733+ if [ -r "$FORGE_STATE/backup.env" ]; then . "$FORGE_STATE/backup.env"; fi1734+ BK_WORK=$FORGE_STATE/backup BK_LOG=$FORGE_STATE/backup.log BK_STATUS=$FORGE_STATE/backup.status1735+}1736+1737+restic_env() {1738+ [ -n "${FORGE_BACKUP_REPOSITORY:-}" ] && [ -n "${FORGE_BACKUP_PASSWORD:-}" ] ||1739+ die "backups are off: set FORGE_BACKUP_REPOSITORY and FORGE_BACKUP_PASSWORD (README.md, Backups and moving hosts)"1740+ command -v restic >/dev/null 2>&1 || die "restic is not installed"1741+ export RESTIC_REPOSITORY="$FORGE_BACKUP_REPOSITORY" RESTIC_PASSWORD="$FORGE_BACKUP_PASSWORD"1742+ export AWS_ACCESS_KEY_ID="${FORGE_BACKUP_KEY_ID:-}" AWS_SECRET_ACCESS_KEY="${FORGE_BACKUP_SECRET:-}"1743+ export AWS_DEFAULT_REGION="${FORGE_BACKUP_REGION:-auto}" RESTIC_CACHE_DIR="$BK_WORK/cache"1744+}1745+1746+# Backups run as root in the container (they hold the host's ssh and tailcat1747+# keys), and git won't read repos root doesn't own unless told they are safe.1748+root_reads_repos() {1749+ [ "$(id -u)" = 0 ] || return 01750+ export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0='*'1751+}1752+1753+# take_lock <dir> <seconds>: a lock directory holding its owner's pid. One1754+# whose owner is gone (a crash) is taken over; gives up after <seconds>.1755+take_lock() {1756+ tl=01757+ until mkdir "$1" 2>/dev/null; do1758+ p=$(cat "$1/pid" 2>/dev/null || true)1759+ if [ -n "$p" ] && ! { [ -d "/proc/$p" ] || kill -0 "$p"; } 2>/dev/null; then1760+ rm -rf "$1"1761+ continue1762+ fi1763+ tl=$((tl + 1))1764+ [ "$tl" -lt "$2" ] || return 11765+ sleep 11766+ done1767+ echo $$ >"$1/pid"1768+}1769+1770+trim_log() {1771+ [ -f "$1" ] && [ "$(wc -c <"$1")" -gt 1048576 ] || return 01772+ tail -n 2000 "$1" >"$1.tmp" && mv "$1.tmp" "$1"1773+}1774+1775+status_get() { sed -n "s/^$1=//p" "$BK_STATUS" 2>/dev/null | tail -n 1; }1776+1777+ago() {1778+ s=$(($(date +%s) - $1))1779+ if [ "$s" -lt 120 ]; then echo "${s}s"1780+ elif [ "$s" -lt 7200 ]; then echo "$((s / 60))m"1781+ elif [ "$s" -lt 172800 ]; then echo "$((s / 3600))h"1782+ else echo "$((s / 86400))d"; fi1783+}1784+1785+utc() { date -u -d "@$1" '+%Y-%m-%d %H:%M UTC' 2>/dev/null || date -u -r "$1" '+%Y-%m-%d %H:%M UTC'; }1786+1787+# Progress, to the terminal and to backup.log.1788+bnote() { say "$*"; printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >>"$BK_LOG"; }1789+1790+# rlog <what> <restic args...>: run restic with its output in the log (and in1791+# $BK_WORK/out); on failure, berr says what went wrong.1792+rlog() {1793+ what=$11794+ shift1795+ rc=01796+ restic "$@" >"$BK_WORK/out" 2>&1 || rc=$?1797+ cat "$BK_WORK/out" >>"$BK_LOG"1798+ [ "$rc" = 0 ] && return 01799+ berr="$what: $(grep -v '^[[:space:]]*$' "$BK_WORK/out" | tail -n 1)"1800+ return 11801+}1802+1803+# link_files <dir> <file...>: hard-link files into dir, or copy them if the1804+# dir is on another filesystem. Fails if one vanished since it was listed.1805+link_files() {1806+ d=$11807+ shift1808+ [ -e "$1" ] || return 01809+ mkdir -p "$d"1810+ ln "$@" "$d/" 2>/dev/null || cp -p "$@" "$d/"1811+}1812+1813+# copy_repo <live> <stage>: steps 1 to 3 above.1814+copy_repo() {1815+ for p in "$1"/* "$1"/.[!.]*; do1816+ [ -e "$p" ] || [ -L "$p" ] || continue1817+ case ${p##*/} in objects | refs | packed-refs | *.lock | gc.pid | gc.log) continue ;; esac1818+ cp -RPp "$p" "$2/" || return 11819+ done1820+ mkdir -p "$2/refs/heads" "$2/refs/tags"1821+ git --git-dir="$1" for-each-ref --format='%(objectname) %(refname)' >"$2/packed-refs" || return 11822+ for o in "$1"/objects/[0-9a-f][0-9a-f]; do1823+ if [ -d "$o" ]; then link_files "$2/objects/${o##*/}" "$o"/* || return 1; fi1824+ done1825+ mkdir -p "$2/objects/pack"1826+ # Not objects/info or multi-pack-index: caches that git rebuilds.1827+ link_files "$2/objects/pack" "$1"/objects/pack/pack-*1828+}1829+1830+stage_repo() {1831+ tries=01832+ while :; do1833+ rm -rf "$2"1834+ mkdir -p "$2"1835+ if ! copy_repo "$1" "$2" 2>"$BK_WORK/fsck"; then1836+ why="a file went away while it was copied (a gc?)"1837+ elif ! git --git-dir="$2" fsck --connectivity-only --no-dangling --no-progress </dev/null >"$BK_WORK/fsck" 2>&1; then1838+ why="git fsck --connectivity-only fails on the copy"1839+ else1840+ n=$(wc -l <"$2/packed-refs" | tr -d ' ')1841+ echo "repo ${2#"$st"/} $n" >>"$st/.forge/backup.manifest"1842+ return 01843+ fi1844+ cat "$BK_WORK/fsck" >>"$BK_LOG"1845+ tries=$((tries + 1))1846+ if [ "$tries" -ge 5 ]; then1847+ berr="staging ${1#"$FORGE_ROOT"/}: $why, five times"1848+ return 11849+ fi1850+ bnote "staging ${1#"$FORGE_ROOT"/} again: $why"1851+ sleep "$tries"1852+ done1853+}1854+1855+# .forge entries a backup leaves out: rebuilt (www, store, cache,1856+# allowed_signers), regenerated at boot (secrets), or logs, locks and scratch.1857+derived_state() {1858+ case $1 in1859+ www | store | cache | backup | secrets | allowed_signers | config.restored | restore.tmp | \1860+ *.log | *.lock | *.tmp | backup.* | build.* | events.* | metrics.* | mirror.*) return 0 ;;1861+ esac1862+ return 11863+}1864+1865+# stage_all <dir>: copy everything a restore needs into dir. That is all of1866+# FORGE_ROOT except caches, git-shell-commands (forge server init writes it)1867+# and the derived parts of .forge, so state added later is kept by default.1868+stage_all() {1869+ st=$11870+ rm -rf "$st"1871+ mkdir -p "$st/.forge"1872+ printf '# forge backup of %s, staged %s\n' "$FORGE_ROOT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$st/.forge/backup.manifest"1873+ for p in "$FORGE_ROOT"/* "$FORGE_ROOT"/.[!.]*; do1874+ [ -e "$p" ] || [ -L "$p" ] || continue1875+ b=${p##*/}1876+ case $b in1877+ .forge)1878+ for q in "$p"/* "$p"/.[!.]*; do1879+ [ -e "$q" ] || [ -L "$q" ] || continue1880+ derived_state "${q##*/}" || cp -RPp "$q" "$st/.forge/" || return 11881+ done1882+ # Site hostnames are state: a label that collided got a hashed one.1883+ if [ -d "$p/www/hosts" ]; then1884+ mkdir -p "$st/.forge/www"1885+ cp -RPp "$p/www/hosts" "$st/.forge/www/" || return 11886+ fi1887+ ;;1888+ .cache | .npm | git-shell-commands | lost+found) ;;1889+ .*) cp -RPp "$p" "$st/" || return 1 ;;1890+ *)1891+ if [ -d "$p" ] && [ ! -L "$p" ]; then1892+ mkdir -p "$st/$b"1893+ for r in "$p"/* "$p"/.[!.]*; do1894+ [ -e "$r" ] || [ -L "$r" ] || continue1895+ if [ -f "$r/HEAD" ] && [ -d "$r/objects" ]; then stage_repo "$r" "$st/$b/${r##*/}" || return 11896+ else cp -RPp "$r" "$st/$b/" || return 1; fi1897+ done1898+ else1899+ cp -RPp "$p" "$st/" || return 11900+ fi1901+ ;;1902+ esac1903+ done1904+}1905+1906+# Restore the new snapshot into a scratch directory and check that it is1907+# what was staged: the same files, a clean full git fsck in every repo, and1908+# the ref counts the manifest took from the live repos while staging.1909+drill() {1910+ dr=$BK_WORK/drill1911+ rm -rf "$dr"1912+ bnote "drill: restoring $snap"1913+ rlog "drill: restic restore" restore "$snap" --target "$dr" || return 11914+ (cd "$1" && find . -type f -o -type l | LC_ALL=C sort) >"$BK_WORK/want"1915+ (cd "$dr" && find . -type f -o -type l | LC_ALL=C sort) >"$BK_WORK/got"1916+ if ! cmp -s "$BK_WORK/want" "$BK_WORK/got"; then1917+ berr="drill: the restore doesn't match what was staged ($(LC_ALL=C comm -3 "$BK_WORK/want" "$BK_WORK/got" | wc -l | tr -d ' ') paths differ)"1918+ return 11919+ fi1920+ while read -r kind slug refs; do1921+ [ "$kind" = repo ] || continue1922+ if ! git --git-dir="$dr/$slug" fsck --no-dangling --no-progress </dev/null >"$BK_WORK/out" 2>&1; then1923+ cat "$BK_WORK/out" >>"$BK_LOG"1924+ berr="drill: git fsck fails in the restored $slug"1925+ return 11926+ fi1927+ n=$(git --git-dir="$dr/$slug" for-each-ref </dev/null | wc -l | tr -d ' ')1928+ [ "$n" = "$refs" ] || { berr="drill: the restored $slug has $n refs, the live one had $refs"; return 1; }1929+ done <"$dr/.forge/backup.manifest"1930+ rm -rf "$dr"1931+ drilled="ok, $nrepos repos restored, git fsck clean, refs match"1932+}1933+1934+backup_run() {1935+ st=$BK_WORK/stage1936+ bnote "staging $FORGE_ROOT"1937+ stage_all "$st" || { berr=${berr:-staging failed}; return 1; }1938+ nrepos=$(grep -c '^repo ' "$st/.forge/backup.manifest" || true)1939+ size=$(du -sk "$st" | cut -f1)1940+ bnote "staged $nrepos repos, $((size / 1024)) MiB"1941+ # Retention keeps one snapshot a day, so a fresh or broken host must not1942+ # push good ones out: no repos, or under half the last backup's (its1943+ # manifest is kept, and restored, as .forge/backup.manifest), needs --force.1944+ prev=$(grep -c '^repo ' "$FORGE_STATE/backup.manifest" 2>/dev/null || true)1945+ if [ "$force" = 0 ] && [ "$nrepos" = 0 ]; then1946+ berr="refusing to back up a forge with no repos (a new host before its restore?); forge backup --force if that is right"1947+ return 11948+ fi1949+ if [ "$force" = 0 ] && [ $((nrepos * 2)) -lt "${prev:-0}" ]; then1950+ berr="refusing to back up $nrepos repos when the last backup had $prev; forge backup --force if that is right"1951+ return 11952+ fi1953+ cd "$st"1954+ if [ "$dry" = 1 ]; then1955+ if [ -n "${FORGE_BACKUP_REPOSITORY:-}" ] && command -v restic >/dev/null 2>&1; then1956+ restic_env1957+ rlog "restic backup --dry-run" backup --dry-run --tag forge . || return 11958+ sed -n 's/^Would add to the repository: /would add /p' "$BK_WORK/out" >&21959+ else1960+ say "restic isn't set up here, so this only staged"1961+ fi1962+ say "staged in $st (kept to look at; the next backup replaces it)"1963+ return 01964+ fi1965+ rc=01966+ restic cat config >/dev/null 2>"$BK_WORK/out" || rc=$?1967+ if [ "$rc" = 10 ]; then1968+ bnote "creating the restic repository $RESTIC_REPOSITORY"1969+ rlog "restic init" init || return 11970+ elif [ "$rc" != 0 ]; then1971+ berr="restic: $(tail -n 1 "$BK_WORK/out")"1972+ return 11973+ fi1974+ # Locks left by a run that died.1975+ rlog "restic unlock" unlock || return 11976+ bnote "uploading"1977+ rlog "restic backup" backup --tag forge . || return 11978+ snap=$(sed -n 's/^snapshot \([0-9a-f]*\) saved$/\1/p' "$BK_WORK/out" | tail -n 1)1979+ added=$(sed -n 's/^Added to the repository: //p' "$BK_WORK/out" | tail -n 1)1980+ [ -n "$snap" ] || { berr="restic backup: it didn't say which snapshot it saved"; return 1; }1981+ cd /1982+ bnote "saved snapshot $snap (added $added)"1983+ set --1984+ [ "$kd" = 0 ] || set -- "$@" --keep-daily "$kd"1985+ [ "$kw" = 0 ] || set -- "$@" --keep-weekly "$kw"1986+ if [ $# -gt 0 ]; then rlog "restic forget" forget --group-by '' --prune "$@" || return 1; fi1987+ # A light check every run. About once a week it also reads a fifth of the1988+ # data, a different fifth each week, so five weeks read all of it.1989+ if [ $(($(date +%s) - ${full:-0})) -ge 518400 ]; then1990+ part=$(($(date -u +%V | sed 's/^0//') % 5 + 1))1991+ bnote "checking, and reading data part $part/5"1992+ rlog "restic check" check --read-data-subset "$part/5" || return 11993+ full=$(date +%s) check="ok, read data part $part/5 on $(date -u +%Y-%m-%d)"1994+ else1995+ bnote "checking"1996+ rlog "restic check" check || return 11997+ check="ok, structure (data part read $(utc "$full"))"1998+ fi1999+ drill "$st" || return 12000+ cp "$st/.forge/backup.manifest" "$FORGE_STATE/backup.manifest"2001+ rm -rf "$st" "$BK_WORK/want" "$BK_WORK/got" "$BK_WORK/out" "$BK_WORK/fsck"2002+}2003+2004+write_status() {2005+ {2006+ echo "time=$(date +%s)"2007+ echo "result=$1"2008+ if [ "$1" = ok ]; then2009+ printf 'ok_time=%s\nok_day=%s\nsnapshot=%s\nrepos=%s\nsize=%s\nadded=%s\ncheck=%s\ndrill=%s\nfull_check=%s\n' \2010+ "$(date +%s)" "$(date -u +%Y-%m-%d)" "$snap" "$nrepos" "$size" "$added" "$check" "$drilled" "$full"2011+ else2012+ echo "error=$berr"2013+ for k in ok_time ok_day snapshot repos size added check drill full_check; do2014+ v=$(status_get "$k")2015+ [ -z "$v" ] || echo "$k=$v"2016+ done2017+ fi2018+ } >"$BK_STATUS.tmp" && mv "$BK_STATUS.tmp" "$BK_STATUS"2019+}2020+2021+cmd_backup() {2022+ load_config2023+ backup_env2024+ dry=0 force=02025+ for a in "$@"; do2026+ case $a in -n | --dry-run) dry=1 ;; -f | --force) force=1 ;; *) die "usage: forge backup [--dry-run] [--force]" ;; esac2027+ done2028+ [ "$dry" = 1 ] || restic_env2029+ kd=${FORGE_BACKUP_KEEP_DAILY:-7} kw=${FORGE_BACKUP_KEEP_WEEKLY:-5}2030+ case $kd$kw in *[!0-9]*) die "backup: FORGE_BACKUP_KEEP_DAILY and FORGE_BACKUP_KEEP_WEEKLY take whole numbers" ;; esac2031+ root_reads_repos2032+ (umask 077 && mkdir -p "$BK_WORK")2033+ take_lock "$FORGE_STATE/backup.lock" 5 || die "backup: another backup is running ($FORGE_STATE/backup.lock)"2034+ trap 'rm -rf "$FORGE_STATE/backup.lock"' EXIT2035+ trim_log "$BK_LOG"2036+ berr= snap= nrepos=0 size=0 added= check= drilled= full=$(status_get full_check)2037+ t0=$(date +%s)2038+ if backup_run; then2039+ [ "$dry" = 1 ] && return 02040+ write_status ok2041+ bnote "backup ok: snapshot $snap, $nrepos repos, in $(($(date +%s) - t0))s"2042+ else2043+ cd /2044+ rm -rf "$BK_WORK/drill"2045+ [ "$dry" = 1 ] || write_status failed2046+ bnote "BACKUP FAILED: $berr (details in $BK_LOG)"2047+ exit 12048+ fi2049+}2050+2051+# The last backup's health. Fails, loudly, when backups are off, the last one2052+# failed, or there is no good one from the last 36 hours.2053+backup_health() {2054+ if [ -z "${FORGE_BACKUP_REPOSITORY:-}" ]; then2055+ echo "BACKUPS ARE OFF: set FORGE_BACKUP_REPOSITORY and FORGE_BACKUP_PASSWORD (README.md, Backups and moving hosts)"2056+ return 12057+ fi2058+ bad=02059+ if [ "$(status_get result)" = failed ]; then2060+ echo "LAST BACKUP FAILED, $(ago "$(status_get time)") ago: $(status_get error)"2061+ bad=12062+ fi2063+ okt=$(status_get ok_time)2064+ if [ -z "$okt" ]; then2065+ echo "NO GOOD BACKUP YET: forge backup takes one now"2066+ return 12067+ fi2068+ if [ $(($(date +%s) - okt)) -gt 129600 ]; then2069+ echo "BACKUPS ARE STALE: the last good one is $(ago "$okt") old"2070+ bad=12071+ fi2072+ sz=$(status_get size)2073+ echo "last good backup: $(ago "$okt") ago ($(utc "$okt")), snapshot $(status_get snapshot), $(status_get repos) repos, $((${sz:-0} / 1024)) MiB"2074+ echo " restore drill: $(status_get drill)"2075+ echo " restic check: $(status_get check)"2076+ return "$bad"2077+}2078+2079+cmd_backups() {2080+ load_config2081+ backup_env2082+ case ${1:-} in '' | --status) ;; *) die "usage: forge backups [--status]" ;; esac2083+ ok=02084+ backup_health || ok=12085+ if [ -z "${1:-}" ] && [ -n "${FORGE_BACKUP_REPOSITORY:-}" ]; then2086+ restic_env2087+ echo2088+ restic snapshots --compact || ok=12089+ fi2090+ return "$ok"2091+}2092+2093+# What a backup leaves out, rebuilt for a forge whose files were just2094+# restored: what server init and keys sync write, hooks pointing at this2095+# host's forge, pinned review revisions, extracted sites, every page.2096+restore_finish() {2097+ load_config2098+ cmd_server_init2099+ repos=$(cd "$FORGE_ROOT" && for d in */*.git; do if [ -f "$d/HEAD" ]; then echo "$d"; fi; done)2100+ for d in $repos; do2101+ GIT_DIR=$FORGE_ROOT/$d OWNER=${d%%/*} NAME=$(basename "$d" .git)2102+ export GIT_DIR2103+ install_hooks "$GIT_DIR"2104+ # A push during the backup can log a revision whose keep ref came2105+ # after the refs were read; pin every logged revision that's here.2106+ cat "$GIT_DIR"/forge/reviews/* 2>/dev/null | awk '$1 == "rev" { print $3 }' | sort -u |2107+ git cat-file --batch-check='%(objectname) %(objecttype)' |2108+ awk '$2 == "commit" { print "update refs/forge/keep/" $1 " " $1 }' | git update-ref --stdin2109+ for ref in $(git for-each-ref --format='%(refname)' refs/sites/ refs/site-previews/); do2110+ case $ref in2111+ refs/sites/*) deploy_site "sites/$OWNER/$NAME/${ref#refs/sites/}" "$ref" ;;2112+ *)2113+ rest=${ref#refs/site-previews/}2114+ deploy_site "previews/$OWNER/$NAME/${rest%%/*}/$(enc_ref "${rest#*/}")" "$ref"2115+ ;;2116+ esac2117+ done2118+ git update-server-info2119+ unset GIT_DIR2120+ done2121+ "$FORGE_BIN" build2122+ say "restored the forge in $FORGE_ROOT ($(printf '%s\n' "$repos" | grep -c . || true) repos)"2123+}2124+2125+cmd_restore() {2126+ load_config2127+ backup_env2128+ snap= from= to= finish=02129+ while [ $# -gt 0 ]; do2130+ case $1 in2131+ --to) [ $# -ge 2 ] || die "restore: --to needs a directory"; to=$2; shift ;;2132+ --from) [ $# -ge 2 ] || die "restore: --from needs a directory"; from=$2; shift ;;2133+ --finish) finish=1 ;;2134+ -*) die "usage: forge restore [<snapshot>|latest] [--to <dir>] [--from <dir>]" ;;2135+ *) [ -z "$snap" ] || die "restore: one snapshot at a time"; snap=$1 ;;2136+ esac2137+ shift2138+ done2139+ if [ "$finish" = 1 ]; then restore_finish; return; fi2140+ root_reads_repos2141+ if [ -n "$to" ]; then2142+ # Files only, to look at or to copy a repo back by hand.2143+ [ -z "$from" ] || die "restore: --to takes files from a snapshot, not --from"2144+ [ ! -e "$to" ] || [ -z "$(ls -A "$to")" ] || die "restore: $to is not empty"2145+ restic_env2146+ restic restore "${snap:-latest}" --target "$to" || die "restore: restic restore failed"2147+ say "restored ${snap:-latest} into $to: files only, no hooks, keys or pages set up there"2148+ return2149+ fi2150+ for d in "$FORGE_ROOT"/*/*.git; do2151+ [ ! -f "$d/HEAD" ] || die "restore: $FORGE_ROOT already has repos (${d#"$FORGE_ROOT"/}); restore onto a fresh forge, or look at a backup with --to <dir>"2152+ done2153+ # No scheduled backup of a half-restored forge.2154+ mkdir -p "$FORGE_STATE"2155+ take_lock "$FORGE_STATE/backup.lock" 5 || die "restore: a backup is running ($FORGE_STATE/backup.lock)"2156+ trap 'rm -rf "$FORGE_STATE/backup.lock"' EXIT2157+ keep=$(mktemp -d "${TMPDIR:-/tmp}/forge-restore.XXXXXX")2158+ for f in config keys; do if [ -f "$FORGE_STATE/$f" ]; then cp -p "$FORGE_STATE/$f" "$keep/$f"; fi; done2159+ if [ -n "$from" ]; then2160+ [ -f "$from/.forge/keys" ] || die "restore: $from doesn't look like a forge backup (no .forge/keys)"2161+ say "restoring from $from into $FORGE_ROOT"2162+ cp -RPp "$from"/. "$FORGE_ROOT"/ || die "restore: copying $from failed"2163+ else2164+ restic_env2165+ say "restoring ${snap:-latest} into $FORGE_ROOT"2166+ restic restore "${snap:-latest}" --target "$FORGE_ROOT" || die "restore: restic restore failed"2167+ fi2168+ # This host's config wins: start.sh and provision.sh write it for the2169+ # domain the host serves. The backup's copy is kept next to it.2170+ if [ -f "$keep/config" ]; then2171+ cmp -s "$keep/config" "$FORGE_STATE/config" || mv "$FORGE_STATE/config" "$FORGE_STATE/config.restored"2172+ cp -p "$keep/config" "$FORGE_STATE/config"2173+ fi2174+ # Users from both: the backup's, and any this host already added.2175+ touch "$FORGE_STATE/keys"2176+ if [ -f "$keep/keys" ]; then grep -vxF -f "$FORGE_STATE/keys" "$keep/keys" >>"$FORGE_STATE/keys" || true; fi2177+ rm -rf "$keep"2178+ # As root: the files are git's, except the host and tailcat keys, and the2179+ # rest of the work runs as git so pages and hooks are git's too.2180+ if [ "$(id -u)" = 0 ] && id -u git >/dev/null 2>&1 && [ "$(id -u git)" != 0 ]; then2181+ chown -R git:git "$FORGE_ROOT"2182+ for d in .hostkeys .tailcat; do if [ -e "$FORGE_ROOT/$d" ]; then chown -R root:root "$FORGE_ROOT/$d"; fi; done2183+ runuser -u git -- env FORGE_ROOT="$FORGE_ROOT" "$FORGE_BIN" restore --finish2184+ else2185+ restore_finish2186+ fi2187+}2188+2189+# ---------------------------------------------------------------- pages mirror2190+#2191+# The forge's own pages, without sites, previews or hostnames, are synced to2192+# a bucket that the edge serves while this box is down (deploy/edge). Builds2193+# start it: each stamps mirror.want and waits FORGE_MIRROR_DELAY seconds, and2194+# only the newest stamp syncs, so a burst of pushes makes one sync.2195+2196+mirror_kick() {2197+ (backup_env && [ -n "${FORGE_MIRROR_S3:-}" ]) || return 02198+ t=$$.$(date +%s)2199+ printf '%s\n' "$t" >"$FORGE_STATE/mirror.want"2200+ (2201+ # Not the build's EXIT trap: that frees the build lock.2202+ trap - EXIT2203+ backup_env2204+ cd /2205+ sleep "${FORGE_MIRROR_DELAY:-30}"2206+ [ "$(cat "$FORGE_STATE/mirror.want" 2>/dev/null)" = "$t" ] || exit 02207+ exec "$FORGE_BIN" mirror2208+ ) </dev/null >>"$FORGE_STATE/mirror.log" 2>&1 &2209+}2210+2211+cmd_mirror() {2212+ load_config2213+ backup_env2214+ dry=2215+ case ${1:-} in '') ;; -n | --dry-run) dry=--dry-run ;; *) die "usage: forge mirror [--dry-run]" ;; esac2216+ [ -n "${FORGE_MIRROR_S3:-}" ] || die "mirror: not configured (FORGE_MIRROR_S3; see README.md)"2217+ u=${FORGE_MIRROR_S3#*://}2218+ case $u in */?*) ;; *) die "mirror: FORGE_MIRROR_S3 wants https://<endpoint>/<bucket>, got $FORGE_MIRROR_S3" ;; esac2219+ command -v rclone >/dev/null 2>&1 || die "mirror: rclone is not installed"2220+ # Never replace the copy the edge falls back to with an empty one, as a2221+ # fresh host would before its restore.2222+ [ -f "$FORGE_WWW/index.html" ] || die "mirror: no pages in $FORGE_WWW yet"2223+ ls -d "$FORGE_ROOT"/*/*.git >/dev/null 2>&1 || die "mirror: no repos here yet, so the offline copy stays as it is"2224+ take_lock "$FORGE_STATE/mirror.lock" 900 || die "mirror: another sync has held $FORGE_STATE/mirror.lock for 15 minutes"2225+ trap 'rm -rf "$FORGE_STATE/mirror.lock"' EXIT2226+ trim_log "$FORGE_STATE/mirror.log"2227+ t0=$(date +%s)2228+ RCLONE_S3_PROVIDER=${FORGE_MIRROR_PROVIDER:-Cloudflare} RCLONE_S3_ENDPOINT=${FORGE_MIRROR_S3%%://*}://${u%%/*} \2229+ RCLONE_S3_ACCESS_KEY_ID=${FORGE_MIRROR_KEY_ID:-} RCLONE_S3_SECRET_ACCESS_KEY=${FORGE_MIRROR_SECRET:-} \2230+ RCLONE_S3_REGION=${FORGE_MIRROR_REGION:-auto} RCLONE_S3_NO_CHECK_BUCKET=true \2231+ rclone sync "$FORGE_WWW" ":s3:${u#*/}" --config "" --checksum --fast-list --transfers 16 --checkers 32 $dry \2232+ --exclude '/sites/**' --exclude '/previews/**' --exclude '/hosts/**' --exclude '*.old/**' ||2233+ die "mirror: rclone sync failed"2234+ say "$(date -u +%Y-%m-%dT%H:%M:%SZ) mirror: synced $FORGE_WWW to ${u#*/} in $(($(date +%s) - t0))s"2235+}2236+2237+cmd_hook() {2238+ case ${1:-} in2239+ pre-receive) hook_pre_receive ;;2240+ post-receive) hook_post_receive ;;2241+ *) die "unknown hook ${1:-}" ;;2242+ esac2243+}2244+2245+# ---------------------------------------------------------------- main2246+2247+[ $# -gt 0 ] || { usage; exit 1; }2248+cmd=$12249+shift2250+case $cmd in2251+attest) cmd_attest "$@" ;;2252+import) cmd_import "$@" ;;2253+verify) cmd_verify "$@" ;;2254+push) cmd_push "$@" ;;2255+reviews) cmd_reviews "$@" ;;2256+checkout) cmd_checkout "$@" ;;2257+comment) cmd_comment "$@" ;;2258+approve) cmd_approve "$@" ;;2259+land) cmd_land "$@" ;;2260+site) cmd_site "$@" ;;2261+server)2262+ case ${1:-} in2263+ init) cmd_server_init ;;2264+ caddyfile) shift; cmd_server_caddyfile "$@" ;;2265+ *) die "usage: forge server init | caddyfile <address> [pages-domain [pages-address]]" ;;2266+ esac2267+ ;;2268+user) cmd_user "$@" ;;2269+keys) [ "${1:-}" = sync ] || die "usage: forge keys sync"; cmd_keys_sync ;;2270+repo) cmd_repo "$@" ;;2271+build) cmd_build "$@" ;;2272+jev) cmd_jev "$@" ;;2273+metrics) cmd_metrics "$@" ;;2274+gc) cmd_gc "$@" ;;2275+backup) cmd_backup "$@" ;;2276+backups) cmd_backups "$@" ;;2277+restore) cmd_restore "$@" ;;2278+mirror) cmd_mirror "$@" ;;2279+shell) cmd_shell "$@" ;;2280+shell-cmd) cmd_shell_cmd "$@" ;;2281+hook) cmd_hook "$@" ;;2282+-h | --help | help) usage ;;2283+*) die "unknown command $cmd (try forge help)" ;;2284+esac
A demo/dev.ts +63 -0
@@ -0,0 +1,63 @@
1+// npm run dev [-- <port>]: the demo forge on http://localhost:8080/ (after2+// sh demo/make-demo.sh), rebuilt into _site/ when templates, assets, lib/ or3+// the 11ty config change, and reloaded in open tabs. 11ty's own --serve4+// can't do lib/: Node keeps the TypeScript modules it imported, so each5+// build here is a new 11ty process, served by 11ty's dev server.6+//7+// It reads demo/root and writes only _site/ and .cache/dev/: no incremental8+// build (FORGE_LIVE_WWW), no metrics or events in the demo's state.9+import { spawn } from 'node:child_process';10+import fs from 'node:fs';11+import path from 'node:path';12+import { createRequire } from 'node:module';13+import { fileURLToPath } from 'node:url';14+15+interface DevServer {16+ serve(port: number): void;17+ reload(event: object): void;18+}19+20+const app = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');21+const port = Number(process.argv[2] || 8080);22+const env: NodeJS.ProcessEnv = { ...process.env, FORGE_ROOT: path.join(app, 'demo/root'), FORGE_CACHE: path.join(app, '.cache/dev'), FORGE_METRICS: '0' };23+delete env.FORGE_LIVE_WWW;24+delete env.FORGE_WWW;25+delete env.FORGE_ONLY;26+27+const require = createRequire(import.meta.url);28+const server = (require('@11ty/eleventy-dev-server') as { getServer(name: string, dir: string, opts: object): DevServer })29+ .getServer('forge', path.join(app, '_site'), {});30+31+// One build at a time; changes during a build queue one more.32+let building = false;33+let again = false;34+function build(): void {35+ if (building) {36+ again = true;37+ return;38+ }39+ building = true;40+ const t = Date.now();41+ const eleventy = spawn(process.execPath, [path.join(app, 'node_modules/.bin/eleventy'), '--config=eleventy.config.ts', '--quiet'], { cwd: app, env, stdio: 'inherit' });42+ eleventy.on('exit', (code) => {43+ building = false;44+ if (code === 0) {45+ console.log(`built in ${Date.now() - t}ms`);46+ server.reload({});47+ }48+ if (again) {49+ again = false;50+ build();51+ }52+ });53+}54+55+// Editors save in bursts; wait for a quiet moment.56+let timer: ReturnType<typeof setTimeout> | undefined;57+const changed = (): void => {58+ clearTimeout(timer);59+ timer = setTimeout(build, 100);60+};61+for (const p of ['site', 'lib', 'eleventy.config.ts']) fs.watch(path.join(app, p), { recursive: true }, changed);62+build();63+server.serve(port);
A demo/make-demo.sh +515 -0
@@ -0,0 +1,515 @@
1+#!/bin/sh2+# Build a throwaway forge in demo/root: two users, a repo with a stacked3+# review that gets rewritten, signed CI attestations (passing and failing),4+# a wiki, a docs site with a review preview, and a user's standalone site.5+# Pushes go over the local file transport, so the real hooks run.6+set -eu7+here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)8+app=$(dirname "$here")9+forge=$app/bin/forge10+work=$here/work11+12+export FORGE_ROOT=$here/root13+export FORGE_WWW=$FORGE_ROOT/.forge/www14+export FORGE_SSH=git@forge.local15+export FORGE_NO_BUILD=116+export GIT_CONFIG_GLOBAL=$work/gitconfig17+export GIT_CONFIG_NOSYSTEM=118+19+rm -rf "$FORGE_ROOT" "$work"20+mkdir -p "$work/keys"21+cat >"$GIT_CONFIG_GLOBAL" <<'EOF'22+[init]23+ defaultBranch = main24+[advice]25+ detachedHead = false26+EOF27+28+t=$(date -u -j -f %Y-%m-%d 2026-09-01 +%s 2>/dev/null || date -u -d 2026-09-01 +%s)29+# The hooks stamp reviews with FORGE_NOW, so the demo's clock moves with its commits.30+tick() { t=$((t + ${1:-5400})); export GIT_AUTHOR_DATE="@$t +0000" GIT_COMMITTER_DATE="@$t +0000" FORGE_NOW=$t; }31+commit() {32+ tick33+ git add -A34+ if [ $# -gt 1 ]; then git commit -q -m "$1" -m "$2"; else git commit -q -m "$1"; fi35+}36+step() { printf '\n== %s\n' "$*"; }37+# Comments and approvals go over ssh in real use (forge comment, forge38+# approve); here the server side runs directly, as that user.39+as() { u=$1; shift; tick 900; FORGE_USER=$u FORGE_NOW=$t "$forge" shell-cmd "$@"; }40+41+step "server: init, users, repos"42+"$forge" server init43+for u in dami ana; do44+ ssh-keygen -q -t ed25519 -N '' -C "$u@demo" -f "$work/keys/$u"45+ "$forge" user add "$u" "$work/keys/$u.pub"46+done47+"$forge" repo new dami/greet "say hello, with flags"48+"$forge" repo new ana/dotfiles "ana's dotfiles and home page"49+50+# ssh without sshd, for the steps that go through the forced command (push51+# to create, forge land --server): what authorized_keys runs for $FORGE_USER,52+# with HOME at the forge, where git-shell finds its commands.53+cat >"$work/ssh" <<EOF54+#!/bin/sh55+while :; do case \$1 in -o | -p) shift 2 ;; -*) shift ;; *) break ;; esac; done56+shift57+HOME="$FORGE_ROOT" SSH_ORIGINAL_COMMAND=\$* exec "$forge" shell "\$FORGE_USER"58+EOF59+chmod +x "$work/ssh"60+61+clone() { # user repo dir62+ git clone -q "$FORGE_ROOT/$2.git" "$3"63+ git -C "$3" config user.name "$(printf '%s' "$1" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')"64+ git -C "$3" config user.email "$1@example.com"65+ git -C "$3" config forge.signingkey "$work/keys/$1"66+ git -C "$3" config forge.allowedsigners "$FORGE_ROOT/.forge/allowed_signers"67+ git -C "$3" config gpg.format ssh68+ git -C "$3" config user.signingkey "$work/keys/$1"69+}70+71+step "dami: main history, CI script, wiki, docs site"72+clone dami dami/greet "$work/dami"73+cd "$work/dami"74+export FORGE_USER=dami75+cat >greet.sh <<'EOF'76+#!/bin/sh77+# greet: say hello.78+echo "hello, world"79+EOF80+cat >README.md <<'EOF'81+# greet82+83+Say hello from the shell.84+85+ sh greet.sh86+87+See the [wiki](wiki/Home.md) for design notes, or read [greet.sh](greet.sh).88+EOF89+commit "initial greet"90+91+cat >test.sh <<'EOF'92+#!/bin/sh93+# Tiny test runner: each case is "args|expected output".94+set -u95+fail=096+while IFS='|' read -r args want; do97+ [ -n "$want" ] || continue98+ got=$(sh greet.sh $args 2>&1)99+ if [ "$got" = "$want" ]; then echo "ok greet $args"100+ else echo "FAIL greet $args: got '$got', want '$want'"; fail=1; fi101+done <tests.txt102+exit $fail103+EOF104+printf '|hello, world\n' >tests.txt105+mkdir -p .forge106+cat >.forge/ci <<'EOF'107+#!/bin/sh108+# Runs on the developer's machine via `forge attest`, in a clean worktree.109+set -e110+echo "sh: $(command -v sh)"111+sh test.sh112+EOF113+chmod +x .forge/ci114+commit "add tests and a local CI script" "CI runs on your own machine: forge attest signs the result115+with your SSH key and stores it as a git note on refs/notes/ci."116+117+mkdir -p wiki118+cat >wiki/Home.md <<'EOF'119+# Home120+121+greet is deliberately small. Start with [Design](Design.md).122+123+The wiki is just markdown under `wiki/` on the default branch, so it is124+reviewed like code.125+EOF126+cat >wiki/Design.md <<'EOF'127+# Design128+129+- Flags are parsed by hand; no getopt.130+- Output goes to stdout, errors to stderr.131+132+Back to [Home](Home.md).133+EOF134+commit "add a wiki"135+136+mkdir -p docs137+cat >docs/build.sh <<'EOF'138+#!/bin/sh139+# Builds the docs site into _site/. FORGE_SITE_BASE is the path it will be served from.140+set -e141+base=${FORGE_SITE_BASE:-/}142+mkdir -p _site143+{144+ echo '<!doctype html><meta charset="utf-8"><title>greet docs</title>'145+ echo "<link rel=\"stylesheet\" href=\"${base}style.css\">"146+ echo "<h1>greet</h1><p>Built from <code>$(git rev-parse --short HEAD)</code>, served at <code>$base</code>.</p>"147+ echo '<h2>Usage</h2><pre>'148+ sh greet.sh --help 2>/dev/null || echo 'sh greet.sh'149+ echo '</pre>'150+} >_site/index.html151+printf 'body{font:16px/1.5 system-ui,sans-serif;max-width:40em;margin:2em auto;padding:0 1em}pre{background:#f4f4f4;padding:1em}\n' >_site/style.css152+EOF153+printf '# name output build command\ndocs _site sh docs/build.sh\n' >.forge/sites154+commit "add a docs site" "Declared in .forge/sites; deployed with forge site deploy."155+git tag v0.1156+"$forge" attest HEAD~3 HEAD~2 HEAD~1 HEAD || true157+git push -q origin main v0.1158+"$forge" push159+"$forge" site deploy docs160+161+step "server: require a verified passing attestation to land on main"162+git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireci tip163+164+step "dami: a small review that lands"165+git switch -q -c dami/readme166+sed -i.bak 's/Say hello from the shell./Say hello from the shell, politely./' README.md && rm README.md.bak167+commit "README: be polite"168+"$forge" attest169+"$forge" push170+"$forge" verify171+# ssh git@host land, which forge land --server runs: the forge pushes the172+# tip to main and deletes the branch, and the hooks check CI and log it.173+as dami land dami/greet dami/readme174+git switch -q main && git pull -q --ff-only origin main175+176+step "dami: a stacked review (two branches, four commits)"177+git switch -q -c dami/flags178+cat >greet.sh <<'EOF'179+#!/bin/sh180+# greet: say hello.181+name=world182+while [ $# -gt 0 ]; do183+ case $1 in184+ --name) name=$2; shift ;;185+ *) echo "greet: unknown flag $1" >&2; exit 2 ;;186+ esac187+ shift188+done189+echo "hello, $name"190+EOF191+printf -- '--name ana|hello, ana\n' >>tests.txt192+commit "parse --name" "Hand-rolled flag parsing, per wiki/Design.md."193+cat >greet.sh <<'EOF'194+#!/bin/sh195+# greet: say hello.196+name=world shout=0197+while [ $# -gt 0 ]; do198+ case $1 in199+ --name) name=$2; shift ;;200+ --shout) shout=1 ;;201+ *) echo "greet: unknown flag $1" >&2; exit 2 ;;202+ esac203+ shift204+done205+msg="hello, $name"206+[ $shout = 1 ] && msg=$(echo "$msg" | tr a-z A-Z)207+echo "$msg"208+EOF209+printf -- '--shout|HELLO, WORLD\n' >>tests.txt210+commit "add --shout"211+git switch -q -c dami/help212+cat >greet.sh <<'EOF'213+#!/bin/sh214+# greet: say hello.215+usage() { echo "usage: greet [--name NAME] [--shout]"; }216+name=world shout=0217+while [ $# -gt 0 ]; do218+ case $1 in219+ --name) name=$2; shift ;;220+ --shout) shout=1 ;;221+ -h | --help) usage; exit 0 ;;222+ *) echo "greet: unknown flag $1" >&2; usage >&2; exit 2 ;;223+ esac224+ shift225+done226+msg="hello, $name"227+[ $shout = 1 ] && msg=$(echo "$msg" | tr a-z A-Z)228+echo "$msg"229+EOF230+printf -- '--help|usage: greet [--name NAME] [--shout]\n' >>tests.txt231+commit "add --help"232+cat >>README.md <<'EOF'233+234+## Flags235+236+ --name NAME who to greet (default: world)237+ --shout uppercase the greeting238+ --help print usage239+EOF240+commit "README: document flags"241+"$forge" attest --stack242+"$forge" push --stack -t "Usage and --help" -d 'Adds `--help`, and documents every flag in the README.\n\nStacked on #2, which parses the flags. Try it with `sh greet.sh --help`.'243+"$forge" site deploy docs244+245+step "ana reviews r1: a line comment, a reply, an approval"246+as ana comment dami/greet 3 --line greet.sh:3 <<'EOF'247+Could `usage` show the defaults too? Something like:248+249+ usage: greet [--name NAME (world)] [--shout]250+EOF251+as dami comment dami/greet 3 <<'EOF'252+Good idea. The defaults go in the README for now, so the usage line stays short.253+EOF254+as ana approve dami/greet 3 </dev/null255+256+step "dami: address feedback by rewriting both layers (r2 of each review)"257+tick258+awk '{ print } /^- Output goes/ { print "- `--name` takes the next argument verbatim." }' wiki/Design.md >wiki/Design.md.new259+mv wiki/Design.md.new wiki/Design.md260+git add -A261+git commit -q --fixup="$(git rev-parse dami/flags~1)"262+GIT_SEQUENCE_EDITOR=: git rebase -q -i --autosquash --update-refs origin/main263+sed -i.bak 's/uppercase the greeting/UPPERCASE the greeting/' README.md && rm README.md.bak264+git commit -q -a --amend --no-edit265+"$forge" attest --stack266+"$forge" push --stack267+268+step "ana looks at r2: her r1 comment and approval are now outdated"269+as ana approve dami/greet 2 </dev/null270+as ana comment dami/greet 3 --line greet.sh:10 <<'EOF'271+Nice: usage goes to stderr for a bad flag, and to stdout for `--help`, so `greet --help | less` works.272+EOF273+as ana comment dami/greet 3 <<'EOF'274+Looks good. I'll approve this layer again once #2 lands.275+EOF276+277+step "dami: a draft and an abandoned branch"278+git switch -q -c wip/i18n origin/main279+printf 'hello=hello\nbye=goodbye\nthanks=thank you\n' >messages.txt280+commit "sketch: message catalog"281+"$forge" push --draft282+git switch -q -c dami/old-idea origin/main283+echo "# maybe later" >>greet.sh284+commit "try a config file"285+"$forge" push286+"$forge" site deploy docs287+git push -q origin --delete dami/old-idea288+289+step "dami: the draft grows (r2), then main moves on and it is rebased and reworked (r3)"290+git switch -q wip/i18n291+printf 'hello=bonjour\nbye=au revoir\n' >messages.fr.txt292+commit "sketch: French messages"293+"$forge" push294+git switch -q main295+sed -i.bak 's/^# greet: say hello\.$/# greet: say hello from the shell./' greet.sh && rm greet.sh.bak296+commit "greet.sh: say what it is"297+"$forge" attest298+"$forge" push299+git switch -q wip/i18n300+tick301+printf 'yes=yes\n' >>messages.txt302+git commit -q -a --fixup=HEAD~1303+GIT_SEQUENCE_EDITOR=: git rebase -q -i --autosquash main304+"$forge" push305+306+step "ana: a contribution whose CI fails, then passes"307+clone ana dami/greet "$work/ana"308+cd "$work/ana"309+export FORGE_USER=ana310+tick311+git commit -q --allow-empty -m "push straight to main"312+if git push -q origin HEAD:refs/heads/main 2>"$work/denied.txt"; then echo "unexpected: push to main allowed"; exit 1; fi313+sed 's/^/ (expected) /' "$work/denied.txt"314+git reset -q --hard origin/main315+git switch -q -c ana/wave316+sed -i.bak 's/echo "hello, world"/echo "hello, world o\/"/' greet.sh && rm greet.sh.bak317+commit "wave at the world"318+"$forge" attest || echo " (expected) CI failed; the failure is signed and pushed too"319+"$forge" push -t "Wave at the world"320+tick321+printf '|hello, world o/\n' >tests.txt322+commit "update the test for the wave"323+"$forge" attest324+"$forge" push325+# main also needs an approval from someone other than the author. Only for326+# this check: later steps push main directly, which the gate would refuse.327+git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireapproval true328+git -C "$work/dami" fetch -q origin ana/wave329+if FORGE_USER=dami git -C "$work/dami" push -q origin FETCH_HEAD:refs/heads/main 2>"$work/denied.txt"; then echo "unexpected: unapproved landing allowed"; exit 1; fi330+sed 's/^/ (expected) /' "$work/denied.txt"331+if as dami land dami/greet ana/wave 2>"$work/denied.txt"; then echo "unexpected: unapproved land allowed"; exit 1; fi332+sed 's/^/ (expected) /' "$work/denied.txt"333+git --git-dir="$FORGE_ROOT/dami/greet.git" config --unset forge.requireapproval334+335+step "ana: a standalone site in her own repo"336+clone ana ana/dotfiles "$work/ana-dotfiles"337+cd "$work/ana-dotfiles"338+printf 'set -o vi\n' >.zshrc339+commit "zshrc"340+git push -q origin main341+mkdir -p "$work/ana-home"342+printf '<!doctype html><meta charset="utf-8"><title>ana</title><h1>ana</h1><p>Pushed with <code>forge site push</code>.</p>\n' >"$work/ana-home/index.html"343+"$forge" site push "$work/ana-home" home344+345+step "ana: a repo created by pushing to it, and a review landed over ssh"346+mkdir "$work/ana-notes"347+cd "$work/ana-notes"348+git init -q349+git config user.name Ana350+git config user.email ana@example.com351+export GIT_SSH_COMMAND=$work/ssh352+printf '# notes\n\nThings to remember.\n' >README.md353+commit "notes"354+git remote add origin git@forge.local:ana/notes.git355+git push -q -u origin main356+git switch -q -c ana/todo357+printf '\n- land reviews from a phone: ssh git@forge.local land ana/notes 1\n' >>README.md358+commit "a todo list"359+"$forge" push -t "A todo list"360+"$forge" land --server361+unset GIT_SSH_COMMAND362+363+step "dami: code in a few languages, and a review of a long file (syntax highlighting)"364+cd "$work/dami"365+export FORGE_USER=dami366+git switch -q main && git pull -q --ff-only origin main367+mkdir -p web server bin368+cat >web/greet.js <<'EOF'369+// greet in the browser: the same greeting, with a template.370+import { MESSAGES } from './messages.js';371+372+/* A greeting is "hello, <name>", shouted on request.373+ Names are trimmed and must be non-empty. */374+const NAME = /^\s*(\S.*?)\s*$/;375+376+export class Greeter {377+ #shout = false;378+379+ constructor({ shout = false } = {}) {380+ this.#shout = shout;381+ }382+383+ greet(name = 'world') {384+ const m = NAME.exec(name);385+ const msg = `hello, ${m ? m[1] : 'world'}`;386+ return this.#shout ? msg.toUpperCase() : msg;387+ }388+}389+390+export function render(el, name) {391+ el.innerHTML = `392+ <p class="greeting">${new Greeter().greet(name)}</p>393+ <small>${MESSAGES.length} regional variants</small>`;394+}395+EOF396+cat >web/style.css <<'EOF'397+/* greet's page: one column, system fonts. */398+:root { --fg: #1f2328; --accent: #2ca9bc; }399+body { margin: 0 auto; max-width: 40em; padding: 2em 1em; font: 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; color: var(--fg); }400+.greeting { font-size: 2em; color: var(--accent); }401+@media (prefers-color-scheme: dark) {402+ :root { --fg: #e6edf3; }403+ body { background: #0d1117; }404+}405+EOF406+cat >server/main.go <<'EOF'407+// Command server greets over HTTP: GET /?name=ana says "hello, ana".408+package main409+410+import (411+ "fmt"412+ "log"413+ "net/http"414+ "strings"415+)416+417+const usage = `usage: server [addr]418+Serves greetings on addr (default :8080).`419+420+func greet(name string, shout bool) string {421+ if name = strings.TrimSpace(name); name == "" {422+ name = "world"423+ }424+ msg := fmt.Sprintf("hello, %s", name)425+ if shout {426+ return strings.ToUpper(msg)427+ }428+ return msg429+}430+431+func main() {432+ http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {433+ q := r.URL.Query()434+ fmt.Fprintln(w, greet(q.Get("name"), q.Has("shout")))435+ })436+ log.Fatal(http.ListenAndServe(":8080", nil)) // 0x1F90437+}438+EOF439+cat >bin/greet-py <<'EOF'440+#!/usr/bin/env python3441+"""greet, in Python, for comparison with greet.sh."""442+import sys443+444+445+def greet(name: str = "world", shout: bool = False) -> str:446+ msg = f"hello, {name}"447+ return msg.upper() if shout else msg448+449+450+if __name__ == "__main__":451+ args = sys.argv[1:]452+ print(greet(*[a for a in args if a != "--shout"][:1], shout="--shout" in args))453+EOF454+chmod +x bin/greet-py455+cat >docs/examples.md <<'EOF'456+# Examples457+458+From the shell:459+460+```sh461+sh greet.sh --name ana --shout # HELLO, ANA462+```463+464+In the browser (`web/greet.js`):465+466+```js467+import { Greeter } from './greet.js';468+const g = new Greeter({ shout: true });469+console.log(g.greet('ana')); // "HELLO, ANA"470+```471+472+Over HTTP (`server/main.go`):473+474+```go475+resp, err := http.Get("http://localhost:8080/?name=ana")476+```477+478+A change, as a patch:479+480+```diff481+-echo "hello, world"482++echo "hello, $name"483+```484+EOF485+awk 'BEGIN {486+ print "// Regional greetings, generated. One entry per line, so diffs stay small."487+ print "export const MESSAGES = ["488+ for (i = 1; i <= 1200; i++) printf "\t{ id: %d, region: \"r%04d\", text: \"hello, world\" },\n", i, i489+ print "];"490+}' >web/messages.js491+commit "web, server and Python versions of greet" "Same greeting, three more languages, plus 1200 generated regional messages."492+"$forge" attest493+"$forge" push494+git switch -q -c dami/regions495+awk 'NR >= 1100 && NR <= 1104 { sub(/text: "hello, world"/, "text: \"hello, world\", note: \"regions from 1098 on greet in their own words; this note is deliberately long so the diff has to scroll sideways on a phone, past the sticky line numbers\"") } { print }' web/messages.js >web/messages.js.new496+mv web/messages.js.new web/messages.js497+sed -i.bak 's/"Noto Color Emoji";/"Noto Color Emoji", "Twemoji Mozilla", "Android Emoji", "EmojiSymbols", "Symbola";/' web/style.css && rm web/style.css.bak498+commit "messages: notes for the later regions" "Long lines on purpose: the review shows line numbers past 1000 and needs sideways scrolling."499+"$forge" attest500+"$forge" push -t "Regional notes"501+502+step "dami: forge's own guide, a site with no build step"503+export FORGE_USER=dami504+"$forge" repo new dami/forge "a personal git forge"505+clone dami dami/forge "$work/forge"506+cd "$work/forge"507+cp -R "$app/guide" "$app/README.md" . && mkdir .forge && cp "$app/.forge/sites" .forge/508+commit "the guide"509+git push -q origin main510+"$forge" site deploy guide511+512+step "build"513+unset FORGE_USER514+"$forge" build515+printf '\nserve it: python3 -m http.server 8080 -d %s\n' "$FORGE_WWW"
A demo/serve.ts +66 -0
@@ -0,0 +1,66 @@
1+// Local preview of the demo forge: static pages from demo/root/.forge/www2+// plus live events, the way Caddy and lib/events-server.ts serve them, with3+// the same cache headers (bin/forge server caddyfile).4+// node demo/serve.ts [port]5+import http from 'node:http';6+import fs from 'node:fs';7+import path from 'node:path';8+import { fileURLToPath } from 'node:url';9+import { serveEvents } from '../lib/events-server.ts';10+11+const here = path.dirname(fileURLToPath(import.meta.url));12+const state = process.env.FORGE_STATE || path.join(here, 'root/.forge');13+const www = path.join(state, 'www');14+const port = Number(process.argv[2] || 4817);15+const eventsPort = port + 1;16+serveEvents(state, eventsPort);17+18+const TYPES: Record<string, string> = {19+ '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml',20+ '.png': 'image/png', '.gif': 'image/gif', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.json': 'application/json',21+};22+23+// Hashed assets never change; pages and the indexes they fetch revalidate,24+// and get a 304 when unchanged. Sites keep their own caching.25+const HASHED = /^\/assets\/([^/]+\.)?[0-9A-Za-z_-]{10}\.[a-z]+$/;26+const cacheControl = (p: string): string | null =>27+ HASHED.test(p) ? 'public, max-age=31536000, immutable' : /^\/(sites|previews)\//.test(p) ? null : 'no-cache';28+29+http.createServer((req, res) => {30+ const url = new URL(req.url ?? '/', 'http://x');31+ if (url.pathname.startsWith('/events/')) {32+ const up = http.request({ host: '127.0.0.1', port: eventsPort, path: url.pathname, method: 'GET' }, (r) => {33+ res.writeHead(r.statusCode ?? 502, r.headers);34+ r.pipe(res);35+ });36+ req.on('close', () => up.destroy());37+ up.on('error', () => {38+ if (!res.headersSent) res.writeHead(502);39+ res.end();40+ });41+ up.end();42+ return;43+ }44+ let file = path.join(www, decodeURIComponent(url.pathname));45+ if (!file.startsWith(www)) {46+ res.writeHead(403).end();47+ return;48+ }49+ try {50+ let st = fs.statSync(file);51+ if (st.isDirectory()) st = fs.statSync((file = path.join(file, 'index.html')));52+ const etag = `"${Math.floor(st.mtimeMs).toString(36)}-${st.size.toString(36)}"`;53+ const headers: http.OutgoingHttpHeaders = { 'Content-Type': TYPES[path.extname(file)] ?? 'application/octet-stream', ETag: etag, 'Last-Modified': st.mtime.toUTCString() };54+ const cc = cacheControl(url.pathname);55+ if (cc) headers['Cache-Control'] = cc;56+ const since = req.headers['if-modified-since'];57+ const match = req.headers['if-none-match'];58+ if (match ? match === etag : since && Date.parse(since) >= Math.floor(st.mtimeMs / 1000) * 1000) {59+ res.writeHead(304, headers).end();60+ return;61+ }62+ res.writeHead(200, headers).end(fs.readFileSync(file));63+ } catch {64+ res.writeHead(404).end('not found');65+ }66+}).listen(port, '127.0.0.1', () => console.log(`demo forge on http://127.0.0.1:${port}/`));
A deploy/edge/Caddyfile +62 -0
@@ -0,0 +1,62 @@
1+# The edge's HTTP front (Fly terminates TLS and sends plain HTTP to :8080).2+# Everything goes to the forge through the tailcat forward on :8081. When3+# the forge doesn't answer (the tunnel or slate is down, a timeout, or it4+# says 502, 503 or 504), GET and HEAD for the forge's own pages are served5+# from the pages mirror instead (forge mirror; README.md), marked with6+# X-Forge-Offline. Sites, clones, live updates and pushes need the forge.7+# FORGE_DOMAIN the forge's hostname, e.g. src.rest; only it falls back8+# FORGE_MIRROR_URL the mirror bucket's public URL, e.g. https://mirror.src.rest;9+# without it there is no fallback10+{11+ admin off12+ auto_https off13+ persist_config off14+}15+16+:8080 {17+ reverse_proxy 127.0.0.1:8081 {18+ transport http {19+ dial_timeout 3s20+ response_header_timeout 15s21+ }22+ # The forward accepts locally even when the tunnel is dead, so a23+ # request would wait out the timeout. Checking every 10s marks the24+ # forge down, and then requests go straight to the mirror.25+ health_uri /26+ health_interval 10s27+ health_timeout 5s28+ @down status 502 503 50429+ handle_response @down {30+ error "forge answered {rp.status_code}" 50231+ }32+ }33+34+ handle_errors {35+ @copy {36+ method GET HEAD37+ host {$FORGE_DOMAIN:localhost}38+ expression `"{$FORGE_MIRROR_URL:}" != ""`39+ }40+ handle @copy {41+ header X-Forge-Offline "mirror"42+ header Cache-Control "no-store"43+ # Like the forge's file server: /a/b -> /a/b/, /a/b/ -> /a/b/index.html.44+ @bare path_regexp ^(.*/)?[^./]+$45+ redir @bare {path}/ 30846+ # No query strings: on a bucket some (?acl, ?list-type) mean something.47+ @dir path */48+ rewrite @dir {path}index.html?49+ @file not path */50+ rewrite @file {path}?51+ reverse_proxy {$FORGE_MIRROR_URL:https://mirror.invalid} {52+ header_up Host {upstream_hostport}53+ header_down -Set-Cookie54+ @miss status 403 40455+ handle_response @miss {56+ respond "The forge is offline, and this page isn't in its offline copy. Try again soon." 50357+ }58+ }59+ }60+ respond "The forge is offline for a moment. Try again soon." 50361+ }62+}
A deploy/edge/Dockerfile +15 -0
A deploy/edge/fly.toml +45 -0
A deploy/edge/start.sh +69 -0
@@ -0,0 +1,69 @@
1+#!/bin/sh2+# TAILCAT_CLIENT_KEY (secret): this edge's client key file contents.3+# FORGE_TAILCAT_ADDR (secret): the forge container's tailcat address.4+# FORGE_DOMAIN, FORGE_MIRROR_URL (fly.toml env): the offline copy (Caddyfile).5+set -eu6+: "${TAILCAT_CLIENT_KEY:?set the edge client key secret}"7+: "${FORGE_TAILCAT_ADDR:?set the forge tailcat address secret}"8+umask 0779+mkdir -p /run/tailcat10+printf '%s' "$TAILCAT_CLIENT_KEY" >/run/tailcat/client.private.json11+12+# ssh goes straight through on :2222. HTTP comes in on :8081, behind Caddy.13+forward() {14+ tailcat --key=/run/tailcat/client.private.json forward --bind=0.0.0.0 "$FORGE_TAILCAT_ADDR" 2222:2222 8081:8080 &15+ pid=$!16+}17+18+# tailcat can close its listeners on SIGTERM and then never exit, which left19+# a bare `wait` (and so this whole watchdog) hanging with the edge offline.20+# Give it 5 seconds, then kill it outright.21+stop_forward() {22+ kill "$pid" 2>/dev/null || true23+ n=024+ while kill -0 "$pid" 2>/dev/null && [ "$n" -lt 5 ]; do25+ sleep 126+ n=$((n + 1))27+ done28+ kill -9 "$pid" 2>/dev/null || true29+ wait "$pid" 2>/dev/null || true30+}31+32+# Caddy on :8080 sends requests to the forward, and serves the pages mirror33+# for those the forge doesn't answer. It runs as its own user.34+web() {35+ su -s /bin/sh caddy -c 'exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile' &36+ web=$!37+}38+39+# A running forward doesn't re-establish its session when the forge40+# container restarts (redeploy, reboot), so probe through the tunnel itself,41+# not through Caddy (which answers from the mirror), and restart the forward42+# after two failed probes. Any HTTP status line counts as reachable; only a43+# dead tunnel fails.44+forward45+web46+fails=047+while sleep 15; do48+ if ! kill -0 "$web" 2>/dev/null; then49+ echo "edge: caddy exited, restarting" >&250+ web51+ fi52+ if ! kill -0 "$pid" 2>/dev/null; then53+ echo "edge: forward exited, restarting" >&254+ forward55+ fails=056+ continue57+ fi58+ if wget -S -T 5 -O /dev/null http://127.0.0.1:8081/ 2>&1 | grep -q 'HTTP/'; then59+ fails=060+ else61+ fails=$((fails + 1))62+ fi63+ if [ "$fails" -ge 2 ]; then64+ echo "edge: forge unreachable through tailcat, restarting forward" >&265+ stop_forward66+ forward67+ fails=068+ fi69+done
A deploy/slate/compose.yaml +34 -0
A deploy/slate/deploy.sh +9 -0
A eleventy.config.ts +81 -0
@@ -0,0 +1,81 @@
1+import fs from 'node:fs';2+import path from 'node:path';3+import { current, loadForge } from './lib/forge.ts';4+import { codeVersion, record } from './lib/metrics.ts';5+import { assetUrls, shipAssets } from './lib/assets.ts';6+import { nunjucksOptions, setup, type Env } from './lib/render-page.ts';7+import type { ForgeEvent } from './lib/events-server.ts';8+9+// The parts of 11ty's config API the forge uses (11ty ships no types).10+interface EleventyConfig {11+ setNunjucksEnvironmentOptions(opts: Record<string, unknown>): void;12+ amendLibrary(engine: 'njk', fn: (env: Env) => void): void;13+ addGlobalData(name: string, fn: () => unknown): void;14+ addBundle(name: string, opts: { toFileDirectory: string }): void;15+ htmlTransformer: { remove(ext: string, match: (entry: { name?: string }) => boolean): void };16+ setQuietMode(quiet: boolean): void;17+ on(event: 'eleventy.after', fn: (args: { directories: { output: string }; results?: unknown[] }) => void): void;18+}19+20+export default function (cfg: EleventyConfig) {21+ // Options and filters are shared with lib/render-page.ts, which renders the22+ // same templates without 11ty.23+ cfg.setNunjucksEnvironmentOptions(nunjucksOptions());24+ cfg.amendLibrary('njk', setup);25+ cfg.addGlobalData('forge', () => loadForge());26+ cfg.addGlobalData('assets', assetUrls);27+ // Templates and includes carry their own CSS in {% css %} blocks; each page28+ // links one file of the blocks it rendered, /assets/<hash>.css.29+ cfg.addBundle('css', { toFileDirectory: 'assets' });30+ // The bundle plugin would parse and reprint every page to drop empty bundle31+ // links; ours never are, and big diff pages would pay for it.32+ cfg.htmlTransformer.remove('html', (e) => e.name === '11ty/eleventy-bundle/prune-empty');33+ cfg.setQuietMode(true);34+35+ cfg.on('eleventy.after', ({ directories, results }) => {36+ const out = directories.output;37+ const forge = current();38+ shipAssets(out, forge?.live ?? null, forge?.code ?? codeVersion());39+ if (!forge) return;40+ // Raw files (images, per-branch file lists) are bytes, not templates.41+ for (const f of forge.raw) {42+ const dst = path.join(out, f.path);43+ fs.mkdirSync(path.dirname(dst), { recursive: true });44+ if (f.data) fs.writeFileSync(dst, f.data);45+ }46+ // Unchanged pages: hard links to the live site's files (same filesystem).47+ let linked = 0;48+ for (const rel of forge.skipped) {49+ const src = path.join(forge.live ?? '', rel);50+ const dst = path.join(out, rel);51+ fs.mkdirSync(path.dirname(dst), { recursive: true });52+ try {53+ fs.linkSync(src, dst);54+ } catch {55+ try {56+ fs.copyFileSync(src, dst);57+ } catch {58+ continue;59+ }60+ }61+ linked++;62+ }63+ for (const { cache, fps } of forge.pageCaches) {64+ cache.pages = { code: forge.code, fps };65+ cache.save();66+ }67+ const line = process.env.FORGE_METRICS === '0' ? null : record(path.join(forge.config.state, 'metrics.jsonl'), { written: (results?.length ?? 0) + forge.raw.length, linked });68+ // Live pages: tell anyone watching this repo that its pages changed.69+ const only = process.env.FORGE_ONLY;70+ if (only && only !== '-') {71+ const ev: ForgeEvent = { t: Date.now(), repo: only, type: 'built', ms: line?.ms.total ?? null, rendered: results?.length ?? 0 };72+ fs.appendFileSync(path.join(forge.config.state, 'events.jsonl'), JSON.stringify(ev) + '\n');73+ }74+ });75+76+ return {77+ dir: { input: 'site', includes: '_includes', output: process.env.FORGE_WWW || '_site' },78+ templateFormats: ['njk'],79+ htmlTemplateEngine: 'njk',80+ };81+}
A guide/guide.css +375 -0
@@ -0,0 +1,375 @@
1+/* The forge guide: one stylesheet, light and dark, phone first. The palette2+ is the forge's own (site/assets/style.css); the page reads like a git log,3+ with each section a commit on one line. */4+:root {5+ --bg: #ffffff;6+ --fg: #1f2328;7+ --muted: #59636e;8+ --faint: #8c959f;9+ --border: #d1d9e0;10+ --subtle: #f6f8fa;11+ --hover: #eef1f4;12+ --link: #0969da;13+ --accent: #2ca9bc;14+ --ink: #0e7482;15+ --tint: rgba(44, 169, 188, 0.1);16+ --grid: rgba(31, 35, 40, 0.09);17+ --pass: #1a7f37;18+ --fail: #cf222e;19+ --warn: #9a6700;20+ --merged: #8250df;21+ --add: #116329;22+ --del: #a40e26;23+ --term-bg: #0d1117;24+ --term-fg: #e6edf3;25+ --term-dim: #8b949e;26+ --serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Charter, "Bitstream Charter", Georgia, serif;27+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;28+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;29+ --radius: 8px;30+ --rail-x: 7px;31+ --gutter: 30px;32+ color-scheme: light dark;33+}34+@media (prefers-color-scheme: dark) {35+ :root {36+ --bg: #0d1117;37+ --fg: #e6edf3;38+ --muted: #9198a1;39+ --faint: #6e7681;40+ --border: #30363d;41+ --subtle: #151b23;42+ --hover: #1c232c;43+ --link: #4493f8;44+ --ink: #3cc0d3;45+ --tint: rgba(44, 169, 188, 0.14);46+ --grid: rgba(230, 237, 243, 0.07);47+ --pass: #3fb950;48+ --fail: #ff7b72;49+ --warn: #d29922;50+ --merged: #ab7df8;51+ --add: #3fb950;52+ --del: #ff7b72;53+ --term-bg: #010409;54+ }55+}56+57+* { box-sizing: border-box; }58+html { -webkit-text-size-adjust: 100%; scroll-padding-top: 16px; }59+@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }60+body { margin: 0; background: var(--bg); color: var(--fg); font: 1.0625rem/1.65 var(--serif); font-kerning: normal; text-rendering: optimizeLegibility; }61+a { color: var(--link); text-underline-offset: 0.18em; text-decoration-thickness: 1px; }62+a:hover { text-decoration-thickness: 2px; }63+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }64+code, kbd, pre { font-family: var(--mono); }65+code { font-size: 0.84em; background: var(--subtle); border: 1px solid var(--border); border-radius: 5px; padding: 0.05em 0.32em; white-space: nowrap; }66+pre code, .plain code { background: none; border: 0; padding: 0; font-size: inherit; white-space: inherit; }67+kbd { font-size: 0.8em; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 0 0.35em; }68+h1, h2, h3 { font-weight: 600; line-height: 1.15; letter-spacing: -0.01em; }69+h3 { font-size: 1.2rem; margin: 2rem 0 0.5rem; }70+p, ul, ol, dl { margin: 0 0 1rem; }71+strong { font-weight: 650; }72+.wrap { max-width: 1180px; margin: 0 auto; padding: 0 18px; }73+.skip { position: absolute; left: -999px; top: 8px; z-index: 5; background: var(--bg); padding: 6px 10px; border: 1px solid var(--border); }74+.skip:focus { left: 8px; }75+.mono { font-family: var(--mono); }76+.muted { color: var(--muted); }77+78+/* top bar */79+.bar { border-bottom: 1px solid var(--border); font-family: var(--sans); font-size: 0.92rem; }80+.bar .wrap { display: flex; align-items: center; gap: 14px; height: 52px; }81+.brand { display: flex; align-items: center; gap: 9px; color: var(--fg); text-decoration: none; font: 600 1rem var(--mono); }82+.brand span { color: var(--muted); font-weight: 400; }83+.mark { display: block; width: 26px; height: 26px; flex: none; }84+.mark circle { stroke: currentColor; }85+.bar nav { margin-left: auto; display: flex; gap: 4px; }86+.bar nav a { color: var(--muted); text-decoration: none; padding: 6px 10px; border-radius: 6px; }87+.bar nav a:hover { background: var(--hover); color: var(--fg); }88+.bar nav a[aria-current="page"] { color: var(--fg); box-shadow: inset 0 -2px 0 var(--accent); border-radius: 0; }89+90+/* hero */91+.hero { position: relative; overflow: hidden; padding: 44px 0 36px; border-bottom: 1px solid var(--border); }92+.hero::before { content: ""; position: absolute; inset: 0; background-image: radial-gradient(var(--grid) 1.1px, transparent 1.4px); background-size: 22px 22px; -webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 95%); mask-image: linear-gradient(to bottom, #000 30%, transparent 95%); pointer-events: none; }93+.hero::after { content: ""; position: absolute; width: 340px; height: 340px; right: -190px; top: -170px; border: 2px solid var(--accent); border-radius: 50%; opacity: 0.22; pointer-events: none; }94+.hero .wrap { position: relative; display: grid; gap: 30px; }95+.eyebrow, .ref, figcaption, .label { font-family: var(--mono); font-size: 0.78rem; letter-spacing: 0.02em; }96+.eyebrow { color: var(--ink); margin: 0 0 10px; }97+.hero h1 { display: flex; align-items: center; gap: 0.18em; margin: 0 0 16px; font: 700 clamp(3.4rem, 15vw, 6.2rem)/0.95 var(--mono); letter-spacing: -0.055em; }98+.hero h1 .mark { width: 0.86em; height: 0.86em; stroke-width: 2.2; }99+.lede { font-size: 1.28rem; line-height: 1.5; max-width: 34rem; margin: 0 0 22px; }100+.lede em { font-style: italic; }101+.cta { display: flex; flex-wrap: wrap; gap: 10px; margin: 0; font-family: var(--sans); font-size: 0.95rem; }102+.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); text-decoration: none; }103+.btn:hover { border-color: var(--accent); }104+.btn.go { background: var(--fg); color: var(--bg); border-color: var(--fg); }105+.btn.go:hover { background: var(--ink); border-color: var(--ink); }106+@media (min-width: 900px) {107+ .hero { padding: 72px 0 64px; }108+ .hero::after { width: 560px; height: 560px; right: -220px; top: -250px; }109+ .hero .wrap { grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); align-items: center; gap: 48px; }110+}111+112+/* terminal */113+.term { margin: 0; border-radius: 10px; background: var(--term-bg); color: var(--term-fg); border: 1px solid #30363d; box-shadow: 0 18px 50px rgba(1, 4, 9, 0.18); overflow: hidden; min-width: 0; }114+.term .chrome { display: flex; align-items: center; gap: 6px; padding: 9px 12px; border-bottom: 1px solid #21262d; font: 0.72rem var(--mono); color: var(--term-dim); }115+.term .chrome i { width: 9px; height: 9px; border-radius: 50%; background: #30363d; }116+.term .chrome span { margin-left: 6px; }117+.term pre { margin: 0; padding: 14px 16px 16px; font-size: 0.78rem; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }118+.term .line { display: block; }119+.term .p { color: var(--accent); user-select: none; -webkit-user-select: none; }120+.term .o { color: var(--term-dim); }121+.term .r { color: #7d8590; }122+.term .ok { color: #3fb950; }123+.term .u { color: #79c0ff; }124+@media (prefers-reduced-motion: no-preference) {125+ .hero-text > *, .term { animation: rise 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) both; }126+ .hero-text > :nth-child(2) { animation-delay: 0.06s; }127+ .hero-text > :nth-child(3) { animation-delay: 0.12s; }128+ .hero-text > :nth-child(4) { animation-delay: 0.18s; }129+ .term { animation-delay: 0.22s; }130+ .term .line { animation: rise 0.35s ease-out both; animation-delay: calc(0.35s + var(--i) * 0.07s); }131+ @keyframes rise { from { opacity: 0; transform: translateY(10px); } }132+}133+134+/* page: contents, then the log of sections */135+.page { display: grid; gap: 8px; padding-top: 28px; padding-bottom: 40px; }136+.toc { font: 0.85rem/1.4 var(--mono); }137+.toc h2 { font: 600 0.72rem var(--mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 8px; }138+.toc ol { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; }139+.toc a { display: flex; gap: 8px; padding: 4px 0; color: var(--fg); text-decoration: none; }140+.toc a span { color: var(--muted); }141+.toc a:hover { color: var(--ink); }142+.toc a[aria-current="true"] { color: var(--ink); }143+.toc a[aria-current="true"] span { color: var(--accent); }144+@media (min-width: 1000px) {145+ .page { grid-template-columns: 200px minmax(0, 1fr); gap: 48px; padding-top: 44px; }146+ .toc > div { position: sticky; top: 24px; }147+ .toc ol { grid-template-columns: 1fr; }148+ .toc a { padding: 5px 0 5px 12px; border-left: 2px solid var(--border); }149+ .toc a[aria-current="true"] { border-left-color: var(--accent); }150+}151+152+.log { position: relative; padding-left: var(--gutter); min-width: 0; }153+.log::before { content: ""; position: absolute; left: var(--rail-x); top: 10px; bottom: 0; width: 2px; background: linear-gradient(var(--accent), var(--accent) calc(100% - 120px), transparent); opacity: 0.85; }154+.commit { position: relative; padding: 18px 0 30px; }155+.commit > header { position: relative; margin-bottom: 18px; }156+.commit > header::before { content: ""; position: absolute; left: calc(var(--rail-x) - var(--gutter) - 6px); top: 1px; width: 10px; height: 10px; border-radius: 50%; background: var(--bg); border: 2px solid var(--accent); }157+.commit:target > header::before, .commit > header:hover::before { background: var(--accent); }158+.ref { margin: 0 0 6px; color: var(--ink); display: flex; gap: 8px; align-items: baseline; }159+.ref b { font-weight: 600; color: var(--muted); }160+.commit h2 { font-size: clamp(1.65rem, 5.2vw, 2.3rem); margin: 0; max-width: 34ch; }161+.commit h2 a { color: inherit; text-decoration: none; }162+.prose { max-width: 41rem; }163+.prose li { margin: 0.25rem 0; }164+.prose ul { padding-left: 1.2rem; }165+.aside { margin-top: 16px; font-family: var(--sans); font-size: 0.9rem; color: var(--muted); border-left: 3px solid var(--border); padding: 2px 0 2px 12px; max-width: 41rem; }166+@media (min-width: 720px) {167+ :root { --gutter: 44px; --rail-x: 9px; }168+ .commit { padding: 26px 0 44px; }169+}170+171+/* figures */172+figure { margin: 22px 0 26px; }173+.plate { border: 1px solid var(--border); border-radius: var(--radius); background: var(--subtle); padding: 16px; overflow: hidden; }174+figcaption { color: var(--muted); margin-top: 10px; }175+figcaption b { color: var(--ink); font-weight: 600; margin-right: 6px; }176+@media (min-width: 720px) { .plate { padding: 22px 24px; } }177+178+/* fig: three parents merged into forge */179+.merge .parents { list-style: none; margin: 0; padding: 0 0 0 26px; display: grid; gap: 10px; position: relative; }180+.merge .parents::before { content: ""; position: absolute; left: 6px; top: 18px; bottom: -26px; border-left: 2px solid var(--accent); }181+.merge .parents li { position: relative; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }182+.merge .parents li::before { content: ""; position: absolute; left: -21px; top: 16px; width: 20px; border-top: 2px solid var(--accent); }183+.merge .parents b { display: block; font: 600 0.9rem var(--mono); }184+.merge .parents span { display: block; font-size: 0.95rem; color: var(--muted); line-height: 1.45; margin-top: 2px; }185+.merge .child { position: relative; margin: 14px 0 0; display: flex; align-items: center; gap: 10px; padding-left: 0; font: 700 1.15rem var(--mono); letter-spacing: -0.02em; }186+.merge .child .mark { width: 20px; height: 20px; background: var(--subtle); border-radius: 50%; position: relative; }187+.merge .child small { font: 0.78rem var(--mono); color: var(--muted); letter-spacing: 0; font-weight: 400; }188+@media (min-width: 720px) {189+ .merge .parents { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; padding: 0 0 28px; }190+ .merge .parents::before { left: calc((100% - 28px) / 6); right: calc((100% - 28px) / 6); top: auto; bottom: 0; border-left: 0; border-top: 2px solid var(--accent); }191+ .merge .parents li::before { left: 50%; top: 100%; width: 0; height: 29px; border-top: 0; border-left: 2px solid var(--accent); }192+ .merge .child { justify-content: center; margin-top: 0; padding-top: 18px; }193+ .merge .child::before { content: ""; position: absolute; left: 50%; top: 0; height: 18px; border-left: 2px solid var(--accent); }194+}195+196+/* fig: the push, as a trace down one line */197+.flow { list-style: none; margin: 0; padding: 0; position: relative; --x: 6px; }198+.flow::before { content: ""; position: absolute; left: var(--x); top: 8px; bottom: 14px; width: 2px; background: var(--border); }199+.flow > li { position: relative; padding: 0 0 16px 26px; }200+.flow > li::before { content: ""; position: absolute; left: calc(var(--x) - 5px); top: 7px; width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); border: 2px solid var(--accent); }201+.flow .where { display: block; font: 600 0.8rem var(--mono); color: var(--ink); }202+.flow p { margin: 2px 0 0; font-size: 0.98rem; }203+.flow .src { font: 0.72rem var(--mono); color: var(--muted); margin-top: 4px; }204+.flow ul { margin: 6px 0 0; padding: 0; list-style: none; font-size: 0.95rem; }205+.flow ul li { position: relative; padding-left: 16px; margin: 3px 0; }206+.flow ul li::before { content: ""; position: absolute; left: 0; top: 0.75em; width: 10px; border-top: 2px solid var(--accent); opacity: 0.6; }207+.flow .returns { padding-bottom: 18px; }208+.flow .returns::before { display: none; }209+.flow .returns span { display: block; border-top: 1px dashed var(--faint); padding-top: 6px; font: 0.75rem var(--mono); color: var(--muted); }210+@media (min-width: 720px) {211+ .flow { --x: 148px; }212+ .flow > li { display: grid; grid-template-columns: 128px 1fr; gap: 0 40px; padding-left: 0; }213+ .flow .where { text-align: right; padding-top: 1px; }214+ .flow .returns { grid-template-columns: 1fr; padding-left: 168px; }215+}216+217+/* fig: a stack of reviews, as a graph */218+.graph { list-style: none; margin: 0; padding: 0; font-size: 0.95rem; }219+.graph ol { list-style: none; margin: 0; padding: 0; }220+.graph .layer { position: relative; display: grid; gap: 4px 18px; padding: 6px 0; }221+.graph .layer::before { content: ""; position: absolute; left: 6px; top: 0; bottom: 0; width: 2px; background: var(--accent); }222+.graph > .layer:first-child::before { top: 1.3em; }223+.graph .base::before { background: var(--faint); bottom: auto; height: 1.3em; }224+.graph .c { position: relative; padding: 5px 0 5px 28px; display: flex; flex-wrap: wrap; gap: 2px 8px; align-items: baseline; }225+.graph .c::after { content: ""; position: absolute; left: 1px; top: 0.85em; width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); border: 2px solid var(--accent); }226+.graph .base .c::after { border-color: var(--faint); }227+.graph code { font-size: 0.78rem; color: var(--muted); background: none; border: 0; padding: 0; }228+.pill { font: 0.72rem var(--mono); padding: 1px 7px; border-radius: 999px; border: 1px solid var(--accent); color: var(--ink); background: var(--tint); white-space: nowrap; }229+.pill.main { border-color: var(--faint); color: var(--muted); background: none; }230+.graph .tag { margin: 0 0 4px 28px; font-family: var(--sans); font-size: 0.85rem; color: var(--muted); border-left: 2px solid var(--border); padding: 2px 0 2px 10px; }231+.graph .tag b { display: block; color: var(--fg); }232+@media (min-width: 720px) {233+ .graph .layer { grid-template-columns: minmax(0, 1fr) 210px; align-items: center; }234+ .graph .tag { margin: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; border-left: 2px solid var(--accent); }235+}236+237+/* code-ish figures: range-diff, statements */238+.code { margin: 0; font: 0.76rem/1.55 var(--mono); white-space: pre; overflow-x: auto; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; tab-size: 4; }239+.code .same { color: var(--muted); }240+.code .chg { color: var(--warn); font-weight: 600; }241+.code .add { color: var(--add); }242+.code .del { color: var(--del); }243+.code .dim { color: var(--muted); }244+.code .key { color: var(--ink); }245+.code .n { display: inline-block; min-width: 1.5em; margin-left: 8px; padding: 0 5px; border-radius: 999px; background: var(--accent); color: #fff; font-size: 0.68rem; font-weight: 700; text-align: center; line-height: 1.5; }246+@media (prefers-color-scheme: dark) { .code .n { color: #0d1117; } }247+248+.annotated { display: grid; gap: 16px; }249+.annotated ol { margin: 0; padding: 0; list-style: none; counter-reset: n; font-size: 0.95rem; }250+.annotated ol li { counter-increment: n; position: relative; padding-left: 30px; margin: 0 0 8px; }251+.annotated ol li::before { content: counter(n); position: absolute; left: 0; top: 0.2em; min-width: 1.5em; padding: 0 5px; border-radius: 999px; background: var(--accent); color: #fff; font: 700 0.68rem/1.5 var(--mono); text-align: center; }252+@media (prefers-color-scheme: dark) { .annotated ol li::before { color: #0d1117; } }253+.annotated figcaption { grid-column: 1 / -1; margin: 0; }254+@media (min-width: 900px) { .annotated { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: start; } }255+256+/* proves / doesn't */257+.verdict { display: grid; gap: 14px; margin: 18px 0 24px; }258+.verdict > div { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px 6px; border-top: 3px solid var(--pass); }259+.verdict > div.no { border-top-color: var(--fail); }260+.verdict h3 { margin: 0 0 8px; font-size: 1.02rem; }261+.verdict ul { list-style: none; padding: 0; font-size: 0.97rem; }262+.verdict li { position: relative; padding-left: 24px; margin: 0 0 8px; }263+.verdict li::before { position: absolute; left: 0; font: 700 0.95rem var(--sans); }264+.verdict .yes li::before { content: "✓"; color: var(--pass); }265+.verdict .no li::before { content: "✗"; color: var(--fail); }266+@media (min-width: 720px) { .verdict { grid-template-columns: 1fr 1fr; } }267+268+/* fig: from ref to hostname */269+.chain { list-style: none; margin: 0; padding: 0; }270+.chain li { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px 12px; align-items: baseline; }271+.chain .k { font: 0.7rem var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }272+.chain .v { font: 0.84rem var(--mono); background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; overflow-wrap: anywhere; }273+.chain .v small { display: block; font: 0.85rem var(--serif); color: var(--muted); margin-top: 2px; }274+.chain .hop { min-height: 34px; }275+.chain .hop span { grid-column: -2; position: relative; padding: 6px 0 6px 26px; font-family: var(--sans); font-size: 0.82rem; color: var(--muted); }276+.chain .hop span::before { content: ""; position: absolute; left: 11px; top: 0; bottom: 0; border-left: 2px solid var(--accent); }277+.chain .hop span::after { content: ""; position: absolute; left: 7px; bottom: 0; border: 5px solid transparent; border-top: 6px solid var(--accent); border-bottom: 0; }278+.chain .hop.up span::after { bottom: auto; top: 0; border-top: 0; border-bottom: 6px solid var(--accent); }279+@media (min-width: 480px) {280+ .chain li { grid-template-columns: 76px minmax(0, 1fr); }281+ .chain .k { text-align: right; }282+}283+.split { display: grid; gap: 18px; }284+@media (min-width: 900px) { .split { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: 28px; } }285+.split > .prose { font-size: 0.98rem; }286+287+/* tables */288+.table { overflow-x: auto; margin: 18px 0 24px; border: 1px solid var(--border); border-radius: var(--radius); }289+table { width: 100%; border-collapse: collapse; font-family: var(--sans); font-size: 0.88rem; line-height: 1.45; }290+th, td { text-align: left; vertical-align: top; padding: 9px 12px; border-bottom: 1px solid var(--border); }291+tr:last-child > * { border-bottom: 0; }292+thead th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); background: var(--subtle); font-weight: 600; }293+tbody th { font-weight: 500; }294+td.y::before { content: "✓ "; font-weight: 700; color: var(--pass); }295+td.n::before { content: "✗ "; font-weight: 700; color: var(--fail); }296+td.n { color: var(--muted); }297+td code, th code { font-size: 0.82rem; white-space: normal; }298+@media (max-width: 719px) { table { font-size: 0.84rem; } th, td { padding: 8px 9px; } }299+/* tables marked .rows restack on phones: the first cell on its own line,300+ then the rest, labelled from data-l when there is more than one */301+@media (max-width: 719px) {302+ table.rows thead { display: none; }303+ table.rows tr { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--border); }304+ table.rows tr:last-child { border-bottom: 0; }305+ table.rows th, table.rows td { border: 0; }306+ table.rows tr > :first-child { grid-column: 1 / -1; padding-bottom: 0; }307+ table.rows td:not([data-l]):not(:first-child) { grid-column: 1 / -1; padding-top: 3px; }308+ table.rows td[data-l] { position: relative; padding-top: 24px; }309+ table.rows td[data-l]::after { content: attr(data-l); position: absolute; left: 9px; top: 6px; font: 0.68rem var(--mono); color: var(--muted); }310+}311+312+313+/* fig: one key, two generated files */314+.fan { display: grid; gap: 12px; }315+.fan .file { display: block; font: 600 0.75rem var(--mono); color: var(--ink); margin-bottom: 4px; }316+.fan .box { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; }317+.fan .box > code { display: block; font-size: 0.76rem; line-height: 1.5; background: none; border: 0; padding: 0; white-space: normal; overflow-wrap: anywhere; }318+.fan .box p { margin: 6px 0 0; font-size: 0.9rem; color: var(--muted); }319+.fan .by { font: 0.75rem var(--mono); color: var(--muted); display: flex; align-items: center; gap: 10px; }320+.fan .by::before, .fan .by::after { content: ""; flex: 1; border-top: 2px solid var(--accent); opacity: 0.6; }321+.fan .outs { display: grid; gap: 12px; }322+@media (min-width: 900px) { .fan .outs { grid-template-columns: 1fr 1fr; } }323+324+/* fig: hosting */325+.hosts { display: grid; gap: 18px; margin: 22px 0 26px; }326+@media (min-width: 900px) { .hosts { grid-template-columns: 1.25fr 1fr; } }327+.hosts figure { margin: 0; }328+.hops { list-style: none; margin: 0; padding: 0; }329+.hops .node { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }330+.hops .node b { display: block; font: 600 0.88rem var(--mono); }331+.hops .node span { display: block; font-size: 0.9rem; color: var(--muted); line-height: 1.45; margin-top: 2px; }332+.hops .node.home { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }333+.hops .node ul { margin: 8px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }334+.hops .node ul li { font: 0.72rem var(--mono); border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; color: var(--fg); background: var(--subtle); }335+.hops .wire { position: relative; padding: 8px 0 8px 34px; font-family: var(--sans); font-size: 0.8rem; color: var(--muted); }336+.hops .wire::before { content: ""; position: absolute; left: 18px; top: 0; bottom: 0; border-left: 2px solid var(--accent); }337+.hops .wire.tunnel::before { border-left: 6px double var(--accent); left: 16px; }338+339+/* cheat sheet */340+.cheat { display: grid; gap: 16px; }341+@media (min-width: 900px) { .cheat { grid-template-columns: 1fr 1fr; } }342+.cheat section { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }343+.cheat h3 { margin: 0; padding: 9px 14px; font: 600 0.75rem var(--mono); text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); background: var(--subtle); border-bottom: 1px solid var(--border); }344+.cheat dl { margin: 0; }345+.cheat dl > div { padding: 9px 14px; border-bottom: 1px solid var(--border); }346+.cheat dl > div:last-child { border-bottom: 0; }347+.cheat dt { font: 0.8rem/1.5 var(--mono); overflow-wrap: anywhere; }348+.cheat dt::before { content: "$ "; color: var(--accent); }349+.cheat dt.nop::before { content: ""; }350+.cheat dd { margin: 2px 0 0; font-size: 0.9rem; color: var(--muted); line-height: 1.45; }351+352+/* not yet */353+.gaps { list-style: none; padding: 0; display: grid; gap: 10px; max-width: 41rem; }354+.gaps li { border: 1px dashed var(--border); border-radius: var(--radius); padding: 10px 14px; }355+.gaps b { font-weight: 650; }356+357+/* files and trees */358+.tree { margin: 0; font: 0.76rem/1.6 var(--mono); white-space: pre; overflow-x: auto; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; }359+.tree .c { color: var(--muted); }360+@media (max-width: 719px) { .tree .c { display: block; white-space: pre-wrap; padding-left: 3.2em; font-size: 0.92em; } }361+.tree .d { color: var(--ink); font-weight: 600; }362+363+footer.end { border-top: 1px solid var(--border); padding: 26px 0 40px; font-family: var(--sans); font-size: 0.88rem; color: var(--muted); }364+footer.end .wrap { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: center; }365+footer.end .mark { width: 20px; height: 20px; color: var(--fg); }366+footer.end p { margin: 0; }367+368+/* the internals page: a shorter hero */369+.hero.short { padding: 34px 0 30px; }370+.hero.short h1 { font-size: clamp(2.6rem, 11vw, 4.4rem); }371+.hero.short .lede { max-width: 40rem; }372+@media (min-width: 900px) { .hero.short { padding: 56px 0 48px; } .hero.short .wrap { grid-template-columns: 1fr; } }373+374+/* settings: groups of names and what they do */375+.cheat.keys dt::before { content: none; }
A guide/guide.js +22 -0
A guide/index.html +344 -0
@@ -0,0 +1,344 @@
1+<!DOCTYPE html>2+<html lang="en">3+<head>4+<meta charset="utf-8">5+<meta name="viewport" content="width=device-width, initial-scale=1">6+<title>forge: a git forge made of plain git</title>7+<meta name="description" content="How forge works: plain git over ssh, static pages built after each push, reviews as stacked branches, CI signed on your own machine, and sites that ship with repos.">8+<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">9+<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)">10+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cstyle%3Ecircle%7Bstroke:%23000%7D@media (prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23bdbdbd%7D%7D%3C/style%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M16 15 12 7 8 15Zm-1 0L11.5 8' stroke='%232ca9bc'/%3E%3C/svg%3E">11+<link rel="stylesheet" href="guide.css">12+<script src="guide.js" defer></script>13+</head>14+<body>15+<a class="skip" href="#main">Skip to the guide</a>16+<header class="bar">17+<div class="wrap">18+<a class="brand" href="./"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>forge <span>guide</span></a>19+<nav aria-label="guide"><a href="./" aria-current="page">Guide</a><a href="internals.html">Internals</a></nav>20+</div>21+</header>22+23+<section class="hero" aria-labelledby="top">24+<div class="wrap">25+<div class="hero-text">26+<p class="eyebrow">a personal git forge</p>27+<h1 id="top"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>forge</h1>28+<p class="lede">Plain git on a server, and web pages written after every push. Reviews are branches, CI runs on <em>your</em> machine and is signed with your SSH key, and any repo can ship a static site.</p>29+<p class="cta"><a class="btn go" href="#push">Follow a push</a><a class="btn" href="#cli">Cheat sheet</a><a class="btn" href="internals.html">Internals</a></p>30+</div>31+<figure class="term" aria-label="A terminal session: attest, push, deploy a preview">32+<div class="chrome" aria-hidden="true"><i></i><i></i><i></i><span>~/greet (dami/flags)</span></div>33+<pre><span class="line" style="--i:0"><span class="p">$ </span>forge attest</span><span class="line o" style="--i:1">80b9dda running .forge/ci: add --shout</span><span class="line o" style="--i:2"> | ok greet --shout</span><span class="line" style="--i:3">80b9dda <span class="ok">pass</span> in 2s, signed</span><span class="line" style="--i:4"><span class="p">$ </span>forge push -t "Flags"</span><span class="line o" style="--i:5">pushed dami/flags</span><span class="line" style="--i:6"><span class="p">$ </span>forge site deploy docs</span><span class="line o" style="--i:7">site docs: sh docs/build.sh</span><span class="line r" style="--i:8">remote: site docs/dami~flags: <span class="u">https://greet-docs--dami-flags.pages.example.com/</span></span><span class="line o" style="--i:9">site docs: deployed 942ff39 -&gt; https://greet-docs--dami-flags.pages.example.com/</span></pre>34+</figure>35+</div>36+</section>37+38+<div class="wrap page">39+<nav class="toc" aria-label="On this page">40+<div>41+<h2>On this page</h2>42+<ol>43+<li><a href="#what"><span>01</span>what and why</a></li>44+<li><a href="#push"><span>02</span>a push</a></li>45+<li><a href="#reviews"><span>03</span>reviews</a></li>46+<li><a href="#ci"><span>04</span>ci</a></li>47+<li><a href="#sites"><span>05</span>sites</a></li>48+<li><a href="#identity"><span>06</span>identity</a></li>49+<li><a href="#hosting"><span>07</span>hosting</a></li>50+<li><a href="#cli"><span>08</span>cheat sheet</a></li>51+<li><a href="#not-yet"><span>09</span>not yet</a></li>52+</ol>53+</div>54+</nav>55+56+<main id="main" class="log">57+58+<section class="commit" id="what" aria-labelledby="what-h">59+<header><p class="ref"><b>01</b> what and why</p><h2 id="what-h"><a href="#what">A forge that is mostly files</a></h2></header>60+<div class="prose">61+<p>A forge is usually an application with a database. This one is the setup from the Git book's <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Setting-Up-the-Server">Git on the Server</a> chapter: a <code>git</code> user, SSH keys and bare repositories, plus two hooks. After each push an <a href="https://www.11ty.dev/">11ty</a> build writes the repositories out as static HTML, the way <a href="https://git.suckless.org/">stagit</a> does. Reading a page is reading a file.</p>62+<p>What stagit doesn't have is borrowed from <a href="https://args.io">args.io</a>: every branch is browsable, reviews are stacked diffs, each push of a review is a revision, CI runs on your own machine, and repos can ship sites.</p>63+</div>64+<figure class="plate merge">65+<ol class="parents">66+<li><b>Git on the Server</b><span>One <code>git</code> account, SSH keys and bare repos. Identity is a file of keys, each forced through one command.</span></li>67+<li><b>stagit</b><span>Log, files, blobs and refs as plain pages, written when something is pushed instead of computed per request.</span></li>68+<li><b>args.io</b><span>Reviews as stacked branches with revisions, CI attested on your machine, a site per repo.</span></li>69+</ol>70+<p class="child"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>forge <small>a merge of three ideas</small></p>71+<figcaption><b>fig. 1</b>where it comes from</figcaption>72+</figure>73+<div class="prose">74+<p>There is no database and nothing to log in to. Everything that changes the forge is a <code>git push</code>, an ssh command, or an admin command on the server, usually by way of <code>forge</code>, one POSIX shell script. Pages also have keyboard navigation, clean URLs such as <code>/dami/greet/tree/main/greet.sh/</code>, an Atom feed per repo, a wiki from <code>wiki/</code>, syntax highlighting done at build time, and a <kbd>/</kbd> search over commits, reviews, wiki pages and paths. <kbd>?</kbd> lists the shortcuts.</p>75+</div>76+</section>77+78+<section class="commit" id="push" aria-labelledby="push-h">79+<header><p class="ref"><b>02</b> a push</p><h2 id="push-h"><a href="#push">What happens when you push</a></h2></header>80+<p class="prose">A push is the only way anything changes. Here is one, followed through the server.</p>81+<figure class="plate">82+<ol class="flow">83+<li><span class="where">your machine</span><div><p><code>git push</code>, or <code>forge push</code>, which also merges the server's CI notes with yours, pushes them along, and passes options such as a review title.</p></div></li>84+<li><span class="where">sshd</span><div><p>Your key matches a line in <code>authorized_keys</code> that forces <code>forge shell &lt;name&gt;</code>. It records who you are and hands the command to <code>git-shell</code>, which runs git and a few commands: <code>new</code>, <code>ls</code>, <code>whoami</code>, <code>signers</code>, <code>site-url</code>, <code>help</code>.</p><p class="src">bin/forge: cmd_shell</p></div></li>85+<li><span class="where">pre-receive</span><div><p>Decides who may push which refs, refuses to delete <code>main</code> or force-push it (unless the repo allows that), and, when the repo asks for it, refuses a new <code>main</code> without a verified passing CI attestation.</p><p class="src">bin/forge: hook_pre_receive</p></div></li>86+<li><span class="where">git</span><div><p>The refs move.</p></div></li>87+<li><span class="where">post-receive</span><div><p>Goes through the updated refs:</p><ul><li>a branch other than <code>main</code> gets a revision in its review log, and its tip is pinned;</li><li>a site or preview deploy is extracted and linked into the web root;</li><li>a deleted branch takes its previews with it;</li><li>the push is appended to <code>.forge/events.jsonl</code>.</li></ul><p class="src">bin/forge: hook_post_receive, record_review, deploy_site</p></div></li>88+<li class="returns"><span>your git push returns here; the rest runs in the background</span></li>89+<li class="bg"><span class="where">forge build</span><div><p>11ty rebuilds that repo's pages into a scratch directory, hard-linking every page that didn't change from the live site, swaps them in, and appends a <code>built</code> event. Builds take turns under a lock.</p><p class="src">bin/forge: cmd_build, eleventy.config.ts, lib/forge.ts</p></div></li>90+<li class="bg"><span class="where">your browser</span><div><p>Open pages hold a server-sent events stream from <code>/events/&lt;owner&gt;/&lt;repo&gt;/</code>. The push shows up as <q>dami pushed dami/flags, updating</q>; when the build lands, the page fetches itself and swaps in the new content, keeping your scroll position.</p><p class="src">lib/events-server.ts, site/assets/live.ts</p></div></li>91+</ol>92+<figcaption><b>fig. 2</b>one push, from your terminal to every open page</figcaption>93+</figure>94+<p class="prose">Building after the push rather than on each request means pages cost nothing to serve, can be cached anywhere, and keep working if the two small Node servers stop. Those only add what files can't: the live updates, and sites on their own hostnames.</p>95+</section>96+97+<section class="commit" id="reviews" aria-labelledby="reviews-h">98+<header><p class="ref"><b>03</b> reviews</p><h2 id="reviews-h"><a href="#reviews">Reviews are branches</a></h2></header>99+<p class="prose">Push any branch other than <code>main</code> and it is a review. There is nothing to open: <code>post-receive</code> appends the push to <code>&lt;repo&gt;.git/forge/reviews/&lt;id&gt;</code> and pins the tip under <code>refs/forge/keep/</code>, so every revision stays reachable after a force push. The pages work out everything else from the refs.</p>100+<figure class="plate">101+<ol class="graph" aria-label="A stack: two reviews, each two commits, on top of main">102+<li class="layer"><ol><li class="c"><code>6771779</code> README: document flags <span class="pill">dami/help</span></li><li class="c"><code>dff8bca</code> add --help</li></ol><p class="tag"><b>#3 Usage and --help</b>layer 2, targets dami/flags</p></li>103+<li class="layer"><ol><li class="c"><code>80b9dda</code> add --shout <span class="pill">dami/flags</span></li><li class="c"><code>afd124b</code> parse --name</li></ol><p class="tag"><b>#2 parse --name</b>layer 1, targets main</p></li>104+<li class="layer base"><ol><li class="c"><code>895bc48</code> README: be polite <span class="pill main">main</span></li></ol></li>105+</ol>106+<figcaption><b>fig. 3</b>a stack from the demo: dami/help was branched from dami/flags</figcaption>107+</figure>108+<div class="prose">109+<ul>110+<li><strong>Stack.</strong> A review's parent is the nearest other review whose tip lies inside its branch, so a branch built on another review's branch is detected as the next layer.</li>111+<li><strong>Layer.</strong> The commits between the parent (or <code>main</code>) and the tip. A review page is its commits' diffs in order, plus a combined diff.</li>112+<li><strong>Status.</strong> Merged once the tip is in <code>main</code>, closed when the branch is deleted, draft for <code>wip/...</code> branches or a push with <code>--draft</code>.</li>113+<li><strong>Revisions.</strong> Each push is one. Rewrite a branch and the review shows <code>git range-diff</code> between the two pushes.</li>114+</ul>115+</div>116+<figure>117+<pre class="code"><span class="chg">1: 4146e8e ! 1: afd124b parse --name</span>118+<span class="dim"> @@ tests.txt</span>119+ + ## wiki/Design.md ##120+ - Flags are parsed by hand; no getopt.121+ - Output goes to stdout, errors to stderr.122+<span class="add"> ++- `--name` takes the next argument verbatim.</span>123+<span class="same">2: ff819c4 = 2: 80b9dda add --shout</span>124+<span class="same">3: d70bd83 = 3: dff8bca add --help</span>125+<span class="chg">4: ef1d881 ! 4: 6771779 README: document flags</span>126+<span class="del"> -+ --shout uppercase the greeting</span>127+<span class="add"> ++ --shout UPPERCASE the greeting</span></pre>128+<figcaption><b>fig. 4</b>review #3, r1 against r2, shortened: <code>=</code> unchanged, <code>!</code> changed</figcaption>129+</figure>130+<div class="prose">131+<p>Push options carry the rest: <code>forge push -t "Title"</code> with <code>--draft</code> or <code>--target &lt;branch&gt;</code>, and <code>forge push --stack</code> pushes every layer, lowest first. <code>git rebase -i --update-refs</code> keeps the layers in sync while you rewrite them. Landing is a push to <code>main</code>, which must fast-forward: <code>git push origin dami/topic:main</code>.</p>132+<p>Reviews also take comments and approvals (a repo can require one before <code>main</code> moves, with <code>forge.requireapproval</code>), a description from <code>forge push -d</code>, and the command line has <code>forge reviews</code>, <code>forge checkout &lt;id&gt;</code> and <code>forge land</code>, which the forge can also do by itself, from a phone or an agent, with <code>ssh git@host land</code>. On the page, files collapse, can be marked viewed, and the stack is one click away.</p>133+</div>134+</section>135+136+<section class="commit" id="ci" aria-labelledby="ci-h">137+<header><p class="ref"><b>04</b> ci</p><h2 id="ci-h"><a href="#ci">CI runs on your machine, and is signed</a></h2></header>138+<p class="prose"><code>forge attest</code> checks each commit out into a worktree (one per repo, reused so build caches stay warm), runs <code>.forge/ci</code> in it (inside <code>nix develop</code> when there is a <code>flake.nix</code> and Nix is installed), and writes a statement. It signs that with <code>ssh-keygen -Y sign -n forge-ci</code> and the SSH key you push with, and appends it to <code>refs/notes/ci</code>.</p>139+<figure class="plate annotated">140+<pre class="code"><span class="key">forge-attestation 1</span>141+type ci142+commit ff819c459d6f222c...143+<span class="key">tree 69a735fe3cc0a8a8...</span><span class="n">1</span>144+script .forge/ci 99a0d5ea2eee...<span class="n">2</span>145+<span class="key">result pass</span><span class="n">3</span>146+exit 0147+started 2026-10-18T02:00:14Z148+duration 0s149+runner dami@Mac<span class="n">4</span>150+system Darwin 25.5.0 arm64151+env host152+log sha256:6cc79019... 4 lines153+<span class="dim">|ok greet --name ana</span>154+<span class="dim">|ok greet --shout</span>155+<span class="dim">-----BEGIN SSH SIGNATURE-----</span><span class="n">5</span>156+<span class="dim">U1NIU0lHAAAAAQAAADMAAAALc3No...</span></pre>157+<ol>158+<li><strong>The tree, not the commit.</strong> A reworded or reordered commit with the same files keeps its result, and <code>forge attest</code> skips trees that already passed unless you pass <code>--force</code>.</li>159+<li><strong>The script</strong>, by blob id, so a different <code>.forge/ci</code> is a different claim.</li>160+<li><strong>The result</strong> and exit code. A failure is signed and pushed too.</li>161+<li><strong>The runner</strong> as the signer describes it: user, host, system, and with Nix the <code>flake.lock</code> it ran under.</li>162+<li><strong>The signature</strong> over everything above. Log lines start with <code>|</code>, so output can't fake a field.</li>163+</ol>164+<figcaption><b>fig. 5</b>an attestation from the demo, shortened</figcaption>165+</figure>166+<div class="verdict">167+<div class="yes"><h3>What a verified pass proves</h3><ul><li>Someone holding a key in the forge's keys file claims that this exact tree passed this exact script, on a machine they describe.</li><li>Nobody edited it since: turning <code>result fail</code> into <code>result pass</code> breaks the signature.</li><li>Who it was: the signer is looked up in <code>allowed_signers</code>.</li></ul></div>168+<div class="no"><h3>What it doesn't</h3><ul><li>That the key holder is honest. A dishonest one can sign a pass for tests they never ran.</li><li>That the machine was clean or the environment reproducible, unless Nix pinned it.</li><li>Anything about the commits nobody attested.</li></ul></div>169+</div>170+<div class="prose">171+<p>To make the claim stronger, keep the key in hardware (a Secure Enclave key through <a href="https://github.com/maxgoedjen/secretive">Secretive</a>, or a FIDO <code>sk-</code> key) so it is bound to one device; use Nix so the environment is pinned and recorded; and have someone else run <code>forge attest</code> too. Attestations from several signers stack up on the same tree.</p>172+<p>To require a verified pass before <code>main</code> moves, set <code>git config forge.requireci tip</code> in the bare repo, or <code>all</code> to check every new commit. <code>pre-receive</code> verifies against the server's own <code>allowed_signers</code>; anyone may add notes, as long as <code>refs/notes/ci</code> only fast-forwards.</p>173+</div>174+</section>175+176+<section class="commit" id="sites" aria-labelledby="sites-h">177+<header><p class="ref"><b>05</b> sites</p><h2 id="sites-h"><a href="#sites">Sites ship with the repo</a></h2></header>178+<p class="prose">A deploy is a commit on <code>refs/sites/&lt;name&gt;</code> whose tree is the built output. So git provides content-addressed storage, dedup across deploys, history, and revert by pushing an older deploy. Trailers record the source commit, the build command and who built it, and the commit carries an SSH signature when your key can sign.</p>179+<div class="split">180+<div class="prose">181+<p>Declare sites in <code>.forge/sites</code>, one per line: a name, the output directory, and a build command, or <code>-</code> for none. This guide is <code>guide guide -</code>.</p>182+<p><code>forge site deploy &lt;name&gt;</code> builds from a clean worktree of <code>HEAD</code>, with <code>FORGE_SITE_BASE</code> set to the path it will be served from (<code>/</code> on its own hostname). From <code>main</code> it publishes the site. From any other branch it publishes that review's preview, <code>refs/site-previews/&lt;name&gt;/&lt;branch&gt;</code>, which the review page links and marks stale once the branch moves on. <code>forge site push &lt;dir&gt; &lt;name&gt;</code> publishes a directory you already built.</p>183+<p>Only the owner and collaborators deploy sites. Anyone can deploy previews of their own branches.</p>184+</div>185+<figure class="plate">186+<ol class="chain">187+<li><span class="k">ref</span><span class="v">refs/sites/docs<small>a deploy commit, signed</small></span></li>188+<li class="hop"><span>post-receive extracts its tree, once per tree</span></li>189+<li><span class="k">store</span><span class="v">.forge/store/339f775e.../</span></li>190+<li class="hop up"><span>symlink</span></li>191+<li><span class="k">served</span><span class="v">www/sites/dami/greet/docs</span></li>192+<li class="hop up"><span>symlink, with a pages domain</span></li>193+<li><span class="k">hostname</span><span class="v">www/hosts/greet-docs</span></li>194+<li class="hop up"><span>the pages server looks up the Host header</span></li>195+<li><span class="k">browser</span><span class="v">https://greet-docs.pages.example.com/</span></li>196+</ol>197+<figcaption><b>fig. 6</b>a deploy, from ref to hostname</figcaption>198+</figure>199+</div>200+<div class="prose">201+<h3>A hostname per site</h3>202+<p>With a pages domain, every deploy gets its own hostname, so sites can't read each other's cookies, storage or service workers, and each one builds for <code>/</code>. The <code>www/hosts/</code> directory of symlinks is the whole registry.</p>203+</div>204+<div class="table">205+<table>206+<thead><tr><th scope="col">Deploy</th><th scope="col">Hostname under the pages domain</th></tr></thead>207+<tbody>208+<tr><td>a site of the forge's owner: <code>dami/greet</code>, site <code>docs</code></td><td><code>greet-docs</code></td></tr>209+<tr><td>another user's site: <code>ana/dotfiles</code>, site <code>home</code></td><td><code>dotfiles-home-ana</code></td></tr>210+<tr><td>a preview: <code>docs</code> on branch <code>dami/flags</code></td><td><code>greet-docs--dami-flags</code></td></tr>211+</tbody>212+</table>213+</div>214+<div class="prose">215+<p>The pages server serves them the way Cloudflare Pages does, so a site built for there works unchanged: <code>_headers</code> and <code>_redirects</code> apply, <code>/about</code> finds <code>about.html</code> or <code>about/index.html</code>, a missing page gets the nearest <code>404.html</code> (or <code>index.html</code>, for a single-page app), and previews are sent with <code>noindex</code>. Old <code>/sites/...</code> paths redirect to the hostname. Without a pages domain, sites are plain files under <code>/sites/&lt;owner&gt;/&lt;repo&gt;/&lt;name&gt;/</code> on the forge itself.</p>216+<p>Deleting a branch deletes its previews: the ref, the served link and the hostname, so the sites and review pages stop listing them. Previews of merged branches stay until their branch is deleted. The extracted files stay in the store until <code>forge gc</code>, which removes store trees nothing links to, hostnames whose deploy is gone and leftover previews, skipping store entries changed in the last ten minutes, so it can run while people push.</p>217+</div>218+</section>219+220+<section class="commit" id="identity" aria-labelledby="identity-h">221+<header><p class="ref"><b>06</b> identity</p><h2 id="identity-h"><a href="#identity">Identity is one file of keys</a></h2></header>222+<p class="prose"><code>.forge/keys</code> holds <code>name key</code> lines and is the only source of identity. <code>forge keys sync</code> (run by <code>forge user add</code> and <code>user rm</code>) writes two files from it, so the key you push with is also the key that signs your CI results and deploys.</p>223+<figure class="plate fan">224+<div class="box"><span class="file">.forge/keys</span><code>ana ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... ana@laptop</code></div>225+<div class="by">forge keys sync</div>226+<div class="outs">227+<div class="box"><span class="file">.ssh/authorized_keys</span><code>restrict,command="/opt/forge/bin/forge shell ana" ssh-ed25519 AAAAC3Nz... ana@laptop</code><p>Who is pushing: hooks see <code>FORGE_USER=ana</code>.</p></div>228+<div class="box"><span class="file">.forge/allowed_signers</span><code>ana namespaces="forge-ci,git" ssh-ed25519 AAAAC3Nz... ana@laptop</code><p>Who signed a CI result or a deploy. <code>ssh git@host signers</code> prints it for <code>forge verify</code>.</p></div>229+</div>230+<figcaption><b>fig. 7</b>one line in, two generated files out</figcaption>231+</figure>232+<p class="prose">Repos live at <code>/srv/git/&lt;owner&gt;/&lt;repo&gt;.git</code>, and the directory names the owner. <code>ssh git@host new &lt;repo&gt;</code> creates one under your name, and so does pushing to one that isn't there yet. The owner, and anyone listed in <code>&lt;repo&gt;.git/forge/collaborators</code>, can push anything; everyone else works under their own name:</p>233+<div class="table">234+<table class="rows">235+<thead><tr><th scope="col">Ref</th><th scope="col">Owner, collaborators</th><th scope="col">Anyone else, e.g. ana</th></tr></thead>236+<tbody>237+<tr><th scope="row"><code>main</code> (the default branch)</th><td class="y" data-l="owner, collaborators">fast-forward only, unless <code>forge.allowforce</code>; never deleted</td><td class="n" data-l="anyone else">no</td></tr>238+<tr><th scope="row"><code>refs/heads/<wbr>ana/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes, her own prefix</td></tr>239+<tr><th scope="row">other branches</th><td class="y" data-l="owner, collaborators">yes</td><td class="n" data-l="anyone else">no</td></tr>240+<tr><th scope="row"><code>refs/<wbr>site-previews/<wbr>*/ana/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes</td></tr>241+<tr><th scope="row"><code>refs/<wbr>tags/*</code>, <code>refs/<wbr>sites/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="n" data-l="anyone else">no</td></tr>242+<tr><th scope="row"><code>refs/<wbr>notes/ci</code></th><td class="y" data-l="owner, collaborators">yes, and may rewrite</td><td class="y" data-l="anyone else">add only: fast-forward, no delete</td></tr>243+<tr><th scope="row"><code>refs/<wbr>forge/*</code></th><td class="n" data-l="owner, collaborators">internal</td><td class="n" data-l="anyone else">internal</td></tr>244+</tbody>245+</table>246+</div>247+<p class="prose">Removing someone's line revokes their pushes and their signatures together: their old attestations stop verifying. Administer as root on the server (<code>forge-admin ...</code> in the container), never by logging in as <code>git</code>; every key is forced through <code>forge shell</code>.</p>248+</section>249+250+<section class="commit" id="hosting" aria-labelledby="hosting-h">251+<header><p class="ref"><b>07</b> hosting</p><h2 id="hosting-h"><a href="#hosting">Where it runs</a></h2></header>252+<p class="prose">The forge is one container: sshd for git, Caddy for pages, sites and read-only HTTPS clones (<code>git http-backend</code> through fcgiwrap, and <code>go get</code> works), the events server, the pages server when there is a pages domain, and optionally tailcat. All state is in <code>/srv/git</code>, a volume. <code>forge backup</code> snapshots it every day with restic into an R2 bucket and test-restores each snapshot, and <code>forge restore</code> brings the forge back on a new box, host keys and tunnel address included.</p>253+<div class="hosts">254+<figure class="plate">255+<ol class="hops">256+<li class="node"><b>internet</b><span>ssh on :22, https on :443. DNS for the forge and pages domains points at the edge, DNS-only, since ssh can't go through Cloudflare's proxy.</span></li>257+<li class="wire">raw TCP for :22, TLS ends here for :443</li>258+<li class="node"><b>Fly edge</b><span><code>deploy/edge</code>. Stateless, with a dedicated IPv4 so raw TCP on :22 works. Its key and the container's address are Fly secrets. While the home box is down, its Caddy serves the forge's pages from a copy in R2.</span></li>259+<li class="wire tunnel">tailcat: point-to-point WireGuard on Tailscale's data plane, with no tailnet, accounts or control plane</li>260+<li class="node home"><b>forge container on a home box</b><span><code>deploy/slate</code>. Publishes no ports. Its tailcat server keeps its key on the volume and accepts only the edge's client key.</span><ul><li>sshd :2222</li><li>Caddy :8080</li><li>events :8091</li><li>pages :8092</li></ul></li>261+</ol>262+<figcaption><b>fig. 8</b>how src.rest runs</figcaption>263+</figure>264+<figure class="plate">265+<ol class="hops">266+<li class="node"><b>internet</b><span>ssh on :22, https on :443</span></li>267+<li class="wire">straight in</li>268+<li class="node home"><b>a Debian or Ubuntu VPS</b><span><code>server/provision.sh</code> installs git, sshd and Caddy, which gets certificates itself, and runs the Node servers under systemd. It needs Node 22.18 or newer.</span><ul><li>sshd :22</li><li>Caddy :443</li><li>forge-events</li><li>forge-pages</li></ul></li>269+</ol>270+<figcaption><b>fig. 9</b>or a plain VPS</figcaption>271+</figure>272+</div>273+<p class="prose">Either way, Caddy serves the pages directory as plain files and hands on only three things: clones to <code>git http-backend</code>, <code>/events/</code> to the events server and, with a pages domain, sites to the pages server. The pages server runs as the caddy user, because deploys are untrusted content. Everything on the forge is public: pages, sites and clones.</p>274+</section>275+276+<section class="commit" id="cli" aria-labelledby="cli-h">277+<header><p class="ref"><b>08</b> cheat sheet</p><h2 id="cli-h"><a href="#cli">Day to day</a></h2></header>278+<div class="cheat">279+<section aria-labelledby="cs-setup"><h3 id="cs-setup">Once</h3><dl>280+<div><dt>git config --global forge.signingkey ~/.ssh/id_ed25519.pub</dt><dd>The key that signs CI results and deploys; a <code>.pub</code> path signs through ssh-agent.</dd></div>281+<div><dt>ssh git@host signers &gt; ~/.config/forge/allowed_signers</dt><dd>Then point <code>forge.allowedsigners</code> at it, so <code>forge verify</code> can check signatures.</dd></div>282+<div><dt>ssh git@host new greet "say hello"</dt><dd>Create a repo under your name; prints the remote to add.</dd></div>283+<div><dt>git push git@host:dami/greet.git main</dt><dd>Or just push: a repo under your name that doesn't exist yet is created first, the same way.</dd></div>284+<div><dt>forge import https://github.com/you/thing.git</dt><dd>Copy a repo in (set <code>forge.host</code> first). <code>--branches</code> takes every branch.</dd></div>285+</dl></section>286+<section aria-labelledby="cs-review"><h3 id="cs-review">A review</h3><dl>287+<div><dt>git switch -c dami/topic</dt><dd>Branch under your name; once pushed, it is a review.</dd></div>288+<div><dt>forge attest --stack</dt><dd>CI for every commit on top of <code>main</code>, signed.</dd></div>289+<div><dt>forge push -t "Title"</dt><dd>Push the branch and the CI notes. <code>--draft</code>, <code>--ready</code>, <code>--target &lt;branch&gt;</code>, <code>-d</code> for a description.</dd></div>290+<div><dt>forge push --stack</dt><dd>Push every layer of a stack, lowest first.</dd></div>291+<div><dt>forge reviews</dt><dd>List reviews; <code>forge checkout &lt;id&gt;</code> fetches one.</dd></div>292+<div><dt>forge comment [&lt;id&gt;] [path:line] -m "..."</dt><dd>Comment on a review, or on one line of it. Without <code>-m</code>, reads stdin or opens <code>$EDITOR</code>.</dd></div>293+<div><dt>forge approve [&lt;id&gt;]</dt><dd>Approve the review as it is now. The approval follows the tree, so a reword keeps it.</dd></div>294+<div><dt>git push origin dami/topic:main</dt><dd>Land it (or <code>forge land</code>). <code>main</code> only fast-forwards.</dd></div>295+<div><dt>ssh git@host land dami/greet 7</dt><dd>Have the forge land review 7, no clone needed: it pushes to itself as you, so the same gates apply, and deletes the landed branches. <code>forge land --server</code> does it from a clone.</dd></div>296+</dl></section>297+<section aria-labelledby="cs-ci"><h3 id="cs-ci">CI</h3><dl>298+<div><dt>forge attest [rev...]</dt><dd>Attest given commits (default <code>HEAD</code>). <code>--force</code> re-runs a passed tree, <code>--clean</code> also removes ignored files from the worktree.</dd></div>299+<div><dt>forge verify [rev...]</dt><dd>Show each commit's attestations and who signed them.</dd></div>300+</dl></section>301+<section aria-labelledby="cs-sites"><h3 id="cs-sites">Sites</h3><dl>302+<div><dt>forge site deploy [name...]</dt><dd>Build and publish from a clean worktree of <code>HEAD</code>: the site on <code>main</code>, a preview elsewhere. <code>--here</code> builds in your work tree.</dd></div>303+<div><dt>forge site push &lt;dir&gt; &lt;name&gt;</dt><dd>Publish a directory you built yourself.</dd></div>304+<div><dt>forge site url &lt;name&gt; [branch]</dt><dd>Where a deploy is served.</dd></div>305+</dl></section>306+<section aria-labelledby="cs-server"><h3 id="cs-server">On the server, as root</h3><dl>307+<div><dt>forge-admin user add ana 'ssh-ed25519 AAAA...'</dt><dd>Add a user and key; <code>user rm ana</code> revokes their keys.</dd></div>308+<div><dt>forge-admin repo new dami/greet "description"</dt><dd>Or <code>repo import dami/greet &lt;url&gt;</code> to fetch one server side.</dd></div>309+<div><dt>forge-admin build [owner/repo]</dt><dd>Rebuild pages; <code>metrics</code> shows where builds spend their time.</dd></div>310+<div><dt>forge-admin gc [--dry-run]</dt><dd>Remove unused site files, hostnames and leftover previews, then repack each repo geometrically, with a multi-pack bitmap and a fresh commit-graph. Daily from cron.</dd></div>311+<div><dt>forge-admin backups</dt><dd>How the last backup went, then the snapshots. <code>backup</code> takes one now; the container takes one daily.</dd></div>312+<div><dt>forge-admin restore latest</dt><dd>Bring the forge back on an empty box, from the newest snapshot or a named one.</dd></div>313+</dl></section>314+<section aria-labelledby="cs-repo"><h3 id="cs-repo">In a bare repo</h3><dl>315+<div><dt>git config forge.requireci tip</dt><dd>Require a verified pass for the new tip of <code>main</code>; <code>all</code> checks every new commit.</dd></div>316+<div><dt>git config forge.allowforce true</dt><dd>Let the owner and collaborators force-push <code>main</code>.</dd></div>317+<div><dt class="nop">forge/collaborators</dt><dd>Names, one per line, who can push anything.</dd></div>318+</dl></section>319+</div>320+<p class="aside">On the server: <code>forge-admin</code> runs <code>forge</code> as the git user in the container; on a VPS, use <code>sudo -u git /opt/forge/bin/forge</code>.</p>321+</section>322+323+<section class="commit" id="not-yet" aria-labelledby="not-yet-h">324+<header><p class="ref"><b>09</b> not yet</p><h2 id="not-yet-h"><a href="#not-yet">What isn't there</a></h2></header>325+<ul class="gaps">326+<li><b>Issues.</b> Bugs and tasks in the args.io style don't exist yet.</li>327+<li><b>Private repos.</b> Everything is public. Keep private repos elsewhere, or put the whole forge behind auth, such as <code>basic_auth</code> in the Caddyfile or Tailscale.</li>328+<li><b>Notifications.</b> Nothing emails you. Open pages update themselves, and that is all.</li>329+<li><b>Buttons.</b> Pages only show things. Landing, deploying and administering happen through git, ssh and <code>forge</code>.</li>330+<li><b>CI on the server.</b> By design: it runs where the code is written, and the server checks signatures.</li>331+</ul>332+</section>333+334+</main>335+</div>336+337+<footer class="end">338+<div class="wrap">339+<svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>340+<p>This guide is <code>guide/</code> in forge's own repo, published with <code>forge site deploy guide</code>. For how the code fits together, read <a href="internals.html">the internals</a>.</p>341+</div>342+</footer>343+</body>344+</html>
A guide/internals.html +267 -0
@@ -0,0 +1,267 @@
1+<!DOCTYPE html>2+<html lang="en">3+<head>4+<meta charset="utf-8">5+<meta name="viewport" content="width=device-width, initial-scale=1">6+<title>forge internals: where things are and how they fit</title>7+<meta name="description" content="For whoever maintains forge next: the source map, state on disk, what runs, how a build works, URLs, settings and upkeep.">8+<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">9+<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)">10+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cstyle%3Ecircle%7Bstroke:%23000%7D@media (prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23bdbdbd%7D%7D%3C/style%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M16 15 12 7 8 15Zm-1 0L11.5 8' stroke='%232ca9bc'/%3E%3C/svg%3E">11+<link rel="stylesheet" href="guide.css">12+<script src="guide.js" defer></script>13+</head>14+<body>15+<a class="skip" href="#main">Skip to the internals</a>16+<header class="bar">17+<div class="wrap">18+<a class="brand" href="./"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>forge <span>guide</span></a>19+<nav aria-label="guide"><a href="./">Guide</a><a href="internals.html" aria-current="page">Internals</a></nav>20+</div>21+</header>22+23+<section class="hero short" aria-labelledby="top">24+<div class="wrap">25+<div class="hero-text">26+<p class="eyebrow">for whoever maintains it next</p>27+<h1 id="top">internals</h1>28+<p class="lede">Where the code is, what it keeps on disk, what runs, and how a push becomes pages. Read <a href="./">the guide</a> first for what forge does; this page is about how.</p>29+</div>30+</div>31+</section>32+33+<div class="wrap page">34+<nav class="toc" aria-label="On this page">35+<div>36+<h2>On this page</h2>37+<ol>38+<li><a href="#map"><span>01</span>source</a></li>39+<li><a href="#disk"><span>02</span>on disk</a></li>40+<li><a href="#run"><span>03</span>what runs</a></li>41+<li><a href="#build"><span>04</span>the build</a></li>42+<li><a href="#urls"><span>05</span>urls</a></li>43+<li><a href="#knobs"><span>06</span>settings</a></li>44+<li><a href="#upkeep"><span>07</span>upkeep</a></li>45+<li><a href="#hacking"><span>08</span>hacking</a></li>46+</ol>47+</div>48+</nav>49+50+<main id="main" class="log">51+52+<section class="commit" id="map" aria-labelledby="map-h">53+<header><p class="ref"><b>01</b> source</p><h2 id="map-h"><a href="#map">Where the code is</a></h2></header>54+<p class="prose">Four kinds of code, kept small: one shell script for everything git and ssh touch, a TypeScript data layer that Node 22 runs as it is (types are stripped, never compiled), Nunjucks templates with a base stylesheet, and container and deploy files.</p>55+<div class="table">56+<table class="rows">57+<thead><tr><th scope="col">Path</th><th scope="col">What it is</th></tr></thead>58+<tbody>59+<tr><th scope="row"><code>bin/forge</code></th><td>The whole CLI in POSIX sh. Developer commands (<code>attest</code>, <code>verify</code>, <code>push</code>, <code>site</code>, <code>import</code>) run in a clone; server commands (<code>server init</code>, <code>user</code>, <code>keys sync</code>, <code>repo</code>, <code>build</code>, <code>gc</code>, <code>shell</code>, <code>hook</code>) run as the git user. Both hooks are this script.</td></tr>60+<tr><th scope="row"><code>lib/forge.ts</code></th><td>Finds repos and flattens them into one list per page type for 11ty. Fingerprints every page so unchanged ones are skipped, and holds the filters that render heavy HTML when a page is written.</td></tr>61+<tr><th scope="row"><code>lib/repo.ts</code></th><td>Loads one bare repo: refs, commit metadata, trees, CI notes, reviews, sites. Diffs and file contents are read later, only for pages that will render.</td></tr>62+<tr><th scope="row"><code>lib/reviews.ts</code></th><td>Review logs plus live refs become stacks, layers, statuses and range-diffs.</td></tr>63+<tr><th scope="row"><code>lib/attest.ts</code></th><td>Parses attestation notes and verifies their signatures with <code>ssh-keygen</code>, remembering each result.</td></tr>64+<tr><th scope="row"><code>lib/sites.ts</code>, <code>lib/hosts.ts</code></th><td>Deploy history and signatures for the sites page; per-site hostnames, with <code>www/hosts/</code> as the registry. <code>hosts.ts</code> is also a small CLI the hooks call.</td></tr>65+<tr><th scope="row"><code>lib/events-server.ts</code>, <code>lib/pages-server.ts</code></th><td>The two long-running servers: live updates over server-sent events, and sites on their own hostnames.</td></tr>66+<tr><th scope="row"><code>lib/</code>, the rest</th><td><code>git.ts</code> wraps the git CLI, <code>diff.ts</code> parses patches, <code>render.ts</code> writes diff and code HTML, <code>markdown.ts</code> renders and sanitizes READMEs and wiki pages, <code>urls.ts</code> is the URL scheme, <code>cache.ts</code> the per-repo build cache, <code>assets.ts</code> the hashed asset names, <code>render-page.ts</code> the Nunjucks setup 11ty shares and pages rendered without 11ty, <code>metrics.ts</code> build timings, <code>types.ts</code> the model.</td></tr>67+<tr><th scope="row"><code>site/</code>, <code>eleventy.config.ts</code></th><td>Templates, each extending <code>_includes/base.njk</code> and carrying its own CSS in <code>{% css %}</code> blocks; <code>assets/style.css</code> for what every page needs; the small client scripts in <code>assets/*.ts</code>, whose types are stripped when the site is built.</td></tr>68+<tr><th scope="row"><code>server/</code></th><td>The container's <code>start.sh</code>, <code>sshd.conf</code> and <code>forge-admin</code>; <code>provision.sh</code> for a plain VPS; an example Caddyfile.</td></tr>69+<tr><th scope="row"><code>deploy/</code></th><td><code>edge/</code>, the Fly relay; <code>slate/</code>, the compose file and the rsync deploy for the home box.</td></tr>70+<tr><th scope="row"><code>demo/</code>, <code>.forge/</code>, <code>guide/</code></th><td>A throwaway forge built with the real hooks, and a local server for it; forge's own CI script and site list; this guide.</td></tr>71+</tbody>72+</table>73+</div>74+</section>75+76+<section class="commit" id="disk" aria-labelledby="disk-h">77+<header><p class="ref"><b>02</b> on disk</p><h2 id="disk-h"><a href="#disk">State on disk</a></h2></header>78+<p class="prose">Everything lives under the git user's home, <code>FORGE_ROOT</code>: <code>/srv/git</code> on a server, <code>demo/root</code> in the demo. Back up this one directory and you have the forge; <code>forge backup</code> does, leaving out what a restore rebuilds (<a href="#upkeep">upkeep</a>).</p>79+<figure>80+<pre class="tree"><span class="d">/srv/git/</span>81+├── .ssh/authorized_keys <span class="c">generated from .forge/keys82+</span>├── git-shell-commands/ <span class="c">new, ls, reviews, land, comment, approve, help...83+</span>├── <span class="d">dami/greet.git/</span> <span class="c">a bare repo; the directory names the owner84+</span>│ ├── hooks/ <span class="c">pre-receive, post-receive: exec forge hook ...85+</span>│ ├── forge/reviews/&lt;id&gt; <span class="c">one log per review86+</span>│ ├── forge/collaborators <span class="c">names that may push anything87+</span>│ ├── refs/forge/keep/&lt;sha&gt; <span class="c">every revision's tip, hidden from clients88+</span>│ ├── refs/notes/ci <span class="c">signed attestations89+</span>│ ├── refs/sites/&lt;name&gt; <span class="c">deploys90+</span>│ └── refs/site-previews/&lt;name&gt;/&lt;branch&gt;91+└── <span class="d">.forge/</span>92+ ├── keys <span class="c">name and key per line: the only identity93+</span> ├── allowed_signers <span class="c">generated from keys94+</span> ├── config <span class="c">shell variables, sourced by forge95+</span> ├── <span class="d">www/</span> <span class="c">the pages, served as plain files96+</span> │ ├── sites/, previews/ <span class="c">symlinks into store/97+</span> │ └── hosts/&lt;label&gt; <span class="c">symlinks to sites/ or previews/98+</span> ├── store/&lt;tree&gt;/ <span class="c">each deployed tree, extracted once99+</span> ├── cache/ <span class="c">facts keyed by object id, page fingerprints100+</span> ├── events.jsonl <span class="c">pushes and builds, for live pages101+</span> ├── backup.env <span class="c">backup and mirror keys, written from forge.env102+</span> ├── backup.status, backup.log <span class="c">the last backup's health, for forge backups103+</span> ├── backup/ <span class="c">staging, restic's cache, the restore drill104+</span> └── build.log, metrics.jsonl</pre>105+<figcaption><b>fig. 1</b>the forge's state, all of it</figcaption>106+</figure>107+<div class="prose">108+<p>A review log is a few lines appended by <code>post-receive</code>:</p>109+</div>110+<pre class="code">branch dami/help111+created 1788276612 dami112+opt title=Usage and --help113+rev 1788276612 ef1d881b67e0... dami 895bc48ac1d9... <span class="dim"># time, tip, who, merge-base with main</span>114+rev 1788282012 6771779c76bb... dami 895bc48ac1d9...115+deleted 1788292812 dami <span class="dim"># only once the branch is gone</span></pre>116+<p class="prose" style="margin-top:1rem">The container adds <code>.hostkeys/</code>, so clients see the same host key across deploys, and <code>.tailcat/</code>, whose server key keeps the tunnel address stable. Both are root's, and both are in every backup, so a restored box keeps its identity.</p>117+</section>118+119+<section class="commit" id="run" aria-labelledby="run-h">120+<header><p class="ref"><b>03</b> what runs</p><h2 id="run-h"><a href="#run">What runs, and why</a></h2></header>121+<p class="prose">The container's entrypoint is <code>tini</code>, which also reaps the background builds, and then <code>server/container/start.sh</code>. It creates host keys on first boot, runs <code>forge server init</code>, writes <code>.forge/config</code> from the environment, adds the owner's key, syncs keys, writes the Caddyfile with <code>forge server caddyfile</code>, and starts:</p>122+<div class="table">123+<table class="rows">124+<thead><tr><th scope="col">Process</th><th scope="col">Listens</th><th scope="col">As</th><th scope="col">Why</th></tr></thead>125+<tbody>126+<tr><th scope="row">sshd</th><td data-l="listens"><code>:2222</code></td><td data-l="as">root</td><td>git over ssh, only for the git user, keys only.</td></tr>127+<tr><th scope="row">fcgiwrap</th><td data-l="listens">unix socket</td><td data-l="as">git</td><td>Runs <code>git http-backend</code> for HTTPS clones; Caddy routes only <code>info/refs</code> and <code>git-upload-pack</code> to it.</td></tr>128+<tr><th scope="row">tailcat</th><td data-l="listens">the tunnel</td><td data-l="as">root</td><td>Only with <code>FORGE_TAILCAT_ALLOW</code>: carries :2222 and :8080 to the edge.</td></tr>129+<tr><th scope="row">events server</th><td data-l="listens"><code>127.0.0.1:8091</code></td><td data-l="as">git</td><td>Tails <code>events.jsonl</code>, streams it at <code>/events/</code>.</td></tr>130+<tr><th scope="row">pages server</th><td data-l="listens"><code>127.0.0.1:8092</code></td><td data-l="as">caddy</td><td>Only with <code>FORGE_PAGES_DOMAIN</code>: sites on their own hostnames. Runs as caddy because deploys are untrusted.</td></tr>131+<tr><th scope="row"><code>forge build</code></th><td data-l="listens"></td><td data-l="as">git</td><td>Once at boot, in the background, since templates may have changed with the image; after that, after each push.</td></tr>132+<tr><th scope="row">backup loop</th><td data-l="listens"></td><td data-l="as">root</td><td>Only with <code>FORGE_BACKUP_REPOSITORY</code>: runs <code>forge backup</code> once a day at <code>FORGE_BACKUP_AT</code>, catching up after downtime, and prints its health to the container log.</td></tr>133+<tr><th scope="row"><code>forge mirror</code></th><td data-l="listens"></td><td data-l="as">git</td><td>Only with <code>FORGE_MIRROR_S3</code>: after builds, syncs the forge's pages to the bucket the edge falls back to.</td></tr>134+<tr><th scope="row">Caddy</th><td data-l="listens"><code>:8080</code></td><td data-l="as">caddy</td><td>In the foreground. Plain HTTP; TLS ends at the edge. Pages, <code>.git</code> files, <code>go get</code> meta tags, and the two proxies.</td></tr>135+</tbody>136+</table>137+</div>138+<p class="prose">The container takes <code>FORGE_DOMAIN</code>, <code>FORGE_PAGES_DOMAIN</code>, <code>FORGE_OWNER</code>, <code>FORGE_OWNER_KEY</code>, <code>FORGE_NAME</code> and <code>FORGE_TAILCAT_ALLOW</code>, plus <code>FORGE_BACKUP_*</code> and <code>FORGE_MIRROR_*</code>, which it writes to <code>.forge/backup.env</code> for backups and the mirror. On a VPS, <code>provision.sh</code> installs the same pieces natively and runs the two Node servers as systemd units, <code>forge-events</code> and <code>forge-pages</code>, with Caddy on :443 getting its own certificates.</p>139+</section>140+141+<section class="commit" id="build" aria-labelledby="build-h">142+<header><p class="ref"><b>04</b> the build</p><h2 id="build-h"><a href="#build">How a push becomes pages</a></h2></header>143+<figure class="plate">144+<ol class="flow">145+<li><span class="where">post-receive</span><div><p>Starts <code>forge build &lt;owner&gt;/&lt;repo&gt;</code> with <code>nohup</code>, logging to <code>build.log</code>. <code>FORGE_SYNC_BUILD=1</code> runs it in the foreground instead; <code>FORGE_NO_BUILD=1</code> skips it.</p></div></li>146+<li><span class="where">lock</span><div><p><code>mkdir .forge/build.lock</code>. Builds wait their turn, for up to ten minutes.</p></div></li>147+<li><span class="where">graph</span><div><p><code>git commit-graph write --reachable --changed-paths --split</code> adds a small layer for the new commits, with Bloom filters, so <code>git log -- &lt;path&gt;</code> and blame skip commits that don't touch the path. Here rather than in the hook, so the pusher never waits on it.</p></div></li>148+<li><span class="where">data</span><div><p>11ty loads <code>lib/forge.ts</code> with <code>FORGE_ONLY</code> set, so only that repo is read. Index and owner pages use cached summaries of the others. Commit metadata, range-diffs and signature checks come from <code>.forge/cache/</code>, keyed by object ids that never change.</p></div></li>149+<li><span class="where">skip</span><div><p>Every page has a fingerprint of what it shows, plus a hash of the forge's own code. A page whose fingerprint matches the last build, and whose file still exists, isn't rendered: its file is hard-linked from the live site.</p></div></li>150+<li><span class="where">render</span><div><p>The rest render into <code>.forge/build.&lt;pid&gt;/</code>. Diffs and file views are written by filters as each page is, so memory stays bounded by the largest page, not the repo. Each page links one CSS bundle of the blocks its templates carry.</p></div></li>151+<li><span class="where">assets</span><div><p>Stylesheet, scripts and bundles get names from a hash of their contents, so browsers cache them for good. Pages from older code are still live (the other repos, mid-rebuild, or a repo whose build failed), so the files of the last five code versions are carried over from the live site; <code>assets/versions.json</code> lists them.</p></div></li>152+<li><span class="where">swap</span><div><p>The repo's directory is moved into <code>www/</code> in place of the old one, and the owner's index page is copied over. <code>sites/</code>, <code>previews/</code> and <code>hosts/</code> belong to the hooks and are never touched.</p></div></li>153+<li><span class="where">after</span><div><p>A line in <code>metrics.jsonl</code> (<code>forge metrics</code> reads it) and a <code>built</code> event that open pages act on.</p></div></li>154+</ol>155+<figcaption><b>fig. 2</b>a single-repo build</figcaption>156+</figure>157+<p class="prose"><code>forge build</code> with no argument rebuilds every repo, one at a time so memory stays bounded by the largest, then the index pages, then removes pages of repos that no longer exist and prunes dangling hostnames.</p>158+</section>159+160+<section class="commit" id="urls" aria-labelledby="urls-h">161+<header><p class="ref"><b>05</b> urls</p><h2 id="urls-h"><a href="#urls">URLs</a></h2></header>162+<p class="prose">Every page is a directory with an <code>index.html</code>, so URLs read like paths. Branch names may contain <code>/</code>, which would make <code>/tree/&lt;branch&gt;/&lt;path&gt;</code> ambiguous, so it is written <code>~</code>, which git forbids in ref names: <code>dami/flags</code> becomes <code>dami~flags</code>.</p>163+<div class="table">164+<table class="rows">165+<thead><tr><th scope="col">URL</th><th scope="col">Page</th></tr></thead>166+<tbody>167+<tr><td><code>/&lt;owner&gt;/&lt;repo&gt;/</code></td><td>open reviews, recent commits, README</td></tr>168+<tr><td><code>.../log/&lt;branch&gt;/</code></td><td>log for a branch</td></tr>169+<tr><td><code>.../tree/&lt;branch&gt;/&lt;path&gt;/</code></td><td>directory</td></tr>170+<tr><td><code>.../blob/&lt;branch&gt;/&lt;path&gt;/</code></td><td>file; markdown renders, with its source at <code>source/</code></td></tr>171+<tr><td><code>.../commit/&lt;sha&gt;/</code></td><td>commit</td></tr>172+<tr><td><code>.../reviews/&lt;id&gt;/</code></td><td>stacked diff; <code>diff/</code> combined, <code>r&lt;n&gt;/</code> range-diff</td></tr>173+<tr><td><code>.../wiki/&lt;page&gt;/</code>, <code>.../sites/</code>, <code>.../atom.xml</code></td><td>wiki, deploys and previews, feed</td></tr>174+<tr><td><code>/&lt;owner&gt;/&lt;repo&gt;.git</code></td><td>HTTPS clone</td></tr>175+<tr><td><code>/sites/&lt;owner&gt;/&lt;repo&gt;/&lt;name&gt;/</code></td><td>a site, or a redirect to its hostname</td></tr>176+<tr><td><code>/previews/.../&lt;name&gt;/&lt;branch&gt;/</code></td><td>a preview, likewise</td></tr>177+<tr><td><code>/events/&lt;owner&gt;/&lt;repo&gt;/</code></td><td>the live event stream</td></tr>178+</tbody>179+</table>180+</div>181+</section>182+183+<section class="commit" id="knobs" aria-labelledby="knobs-h">184+<header><p class="ref"><b>06</b> settings</p><h2 id="knobs-h"><a href="#knobs">Settings, wherever they live</a></h2></header>185+<div class="cheat keys">186+<section aria-labelledby="k-config"><h3 id="k-config">.forge/config, on the server</h3><dl>187+<div><dt>FORGE_NAME, FORGE_BASE_URL, FORGE_SSH</dt><dd>The forge's name, web address and ssh address, for pages and clone URLs.</dd></div>188+<div><dt>FORGE_PAGES_DOMAIN, FORGE_OWNER</dt><dd>Turn on per-site hostnames; the owner's sites get the short ones.</dd></div>189+<div><dt>FORGE_MAX_COMMITS, FORGE_MAX_FILES, FORGE_MAX_BLOB, FORGE_MAX_DIFF_LINES</dt><dd>Limits per branch log, per tree, per file shown and per diff shown: 1000, 5000, 1 MiB, 20000 lines.</dd></div>190+</dl></section>191+<section aria-labelledby="k-repo"><h3 id="k-repo">A bare repo's git config</h3><dl>192+<div><dt>forge.requireci tip | all</dt><dd>A verified pass before <code>main</code> moves, for the new tip or every new commit.</dd></div>193+<div><dt>forge.allowforce true</dt><dd>Let admins force-push <code>main</code>.</dd></div>194+<div><dt>transfer.hideRefs refs/forge</dt><dd>Set by forge on every repo, with <code>receive.advertisePushOptions true</code> and <code>commitGraph.changedPathsVersion 2</code>; see <a href="#upkeep">upkeep</a>.</dd></div>195+</dl></section>196+<section aria-labelledby="k-dev"><h3 id="k-dev">Your git config</h3><dl>197+<div><dt>forge.signingkey, forge.allowedsigners</dt><dd>Sign with this key; verify against this file.</dd></div>198+<div><dt>forge.host, forge.remote, forge.base</dt><dd>Where <code>forge import</code> goes; which remote and base branch the other commands use.</dd></div>199+<div><dt>forge.cidir, forge.nix</dt><dd>Where CI worktrees live; <code>forge.nix false</code> skips <code>nix develop</code>.</dd></div>200+</dl></section>201+<section aria-labelledby="k-backup"><h3 id="k-backup">forge.env, for backups</h3><dl>202+<div><dt>FORGE_BACKUP_REPOSITORY, _PASSWORD, _KEY_ID, _SECRET</dt><dd>The restic repository (an R2 bucket), its password, and the bucket's keys. Keep the password off the box.</dd></div>203+<div><dt>FORGE_BACKUP_AT, _KEEP_DAILY, _KEEP_WEEKLY</dt><dd>When the daily backup runs (UTC) and what retention keeps: 03:30, 7 and 5.</dd></div>204+<div><dt>FORGE_MIRROR_S3, _KEY_ID, _SECRET, _DELAY</dt><dd>The pages mirror's bucket and keys, and how long it waits after a build: 30 seconds.</dd></div>205+</dl></section>206+<section aria-labelledby="k-env"><h3 id="k-env">The environment, for tests</h3><dl>207+<div><dt>FORGE_NOW</dt><dd>Pins the clock reviews are stamped with; the demo moves it with its commits.</dd></div>208+<div><dt>FORGE_NO_BUILD, FORGE_SYNC_BUILD</dt><dd>Skip the build after a push, or run it in the foreground.</dd></div>209+<div><dt>FORGE_STRICT</dt><dd>1 makes a template that prints an undefined or null value fail the build; CI sets it.</dd></div>210+</dl></section>211+</div>212+</section>213+214+<section class="commit" id="upkeep" aria-labelledby="upkeep-h">215+<header><p class="ref"><b>07</b> upkeep</p><h2 id="upkeep-h"><a href="#upkeep">Keeping it tidy</a></h2></header>216+<div class="prose">217+<p><strong>Previews</strong> go when their branch does: <code>post-receive</code> deletes the preview ref, the served link and the hostname of a deleted branch. Previews of merged branches stay until the branch is deleted.</p>218+<p><strong>The store</strong> keeps every tree that was ever deployed until <code>forge gc</code>, which removes</p>219+<ul>220+<li>previews of review branches that are gone (deleted outside the hook, or before it cleaned up),</li>221+<li>hostnames whose deploy is gone,</li>222+<li>store trees that no <code>www/sites</code> or <code>www/previews</code> link points at.</li>223+</ul>224+<p>A deploy extracts its tree before it links it, and touches a tree it reuses, so gc skips store entries changed in the last ten minutes (<code>--age</code>) and is safe to run while people push. <code>--dry-run</code> shows what would go. Run it daily, from the host's crontab for the container (<code>docker exec forge forge-admin gc</code>) or the git user's on a VPS.</p>225+<p><strong>Repos</strong> are maintained by the same <code>forge gc</code>, in <code>repack_repo</code>: <code>git repack -d --geometric=2 --write-midx --write-bitmap-index</code> merges packs only until each is at least twice the next one's size, so a day's run rewrites the day's pushes and loose objects, not the repo, and one multi-pack index with a bitmap serves clones and fetches across the packs. It deletes only packs it rewrote and prunes nothing, so pushes can land while it runs. Then the commit-graph is rewritten as one layer (<code>--split=replace</code>, reusing the Bloom filters it has, adding any that old layers lack) and refs are packed, since every revision pins a loose <code>refs/forge/keep/&lt;sha&gt;</code>. Between runs, each build adds a commit-graph layer for its push.</p>226+<p><strong>Repo settings</strong> come from <code>repo_setup</code>, when a repo is created (<code>repo new</code>, <code>ssh new</code>, a push to a missing repo, an import) and for every repo on <code>forge server init</code>, which the container runs on boot: the hooks, <code>receive.advertisePushOptions</code>, <code>transfer.hideRefs refs/forge</code> so clones, fetches and pushes never see the pinned revisions (the build reads them directly; pre-receive also refuses them), and <code>commitGraph.changedPathsVersion 2</code>, filters that hash non-ASCII paths the same on every platform.</p>227+<p><strong>Backups</strong>: <code>forge backup</code> stages a copy of <code>/srv/git</code> without what a restore rebuilds (pages, extracted sites, caches, generated keys files, hooks, logs), uploads it with restic, prunes old snapshots, runs <code>restic check</code>, and restores the new snapshot into a scratch directory to <code>git fsck</code> every repo and compare its refs. The copy is consistent mid-push: per repo it copies review logs and comments, then reads the refs, then hard-links the objects, which git writes before anything names them. It refuses a forge with no repos, or half the last backup's, so a new box can't push good snapshots out. <code>forge-admin backups</code> says how the last one went, and loudly when it failed or is a day and a half old.</p>228+<p><strong>Moving</strong>: <code>forge restore latest</code> on an empty forge unpacks a snapshot and rebuilds the rest: what <code>server init</code> and <code>keys sync</code> write, hooks, pinned revisions, site files, pages. The restored tailcat key keeps the tunnel address, so the edge follows the forge to a new box by itself. The README walks through a new slate and a VPS.</p>229+<p><strong>The offline copy</strong>: after builds, <code>forge mirror</code> syncs the forge's own pages, without sites or previews, to an R2 bucket. When slate doesn't answer, the edge's Caddy serves them from there with <code>X-Forge-Offline: mirror</code>; sites, clones and pushes wait for slate.</p>230+<p><strong>Updates</strong>: <code>deploy/slate/deploy.sh</code> rsyncs the checkout to the box and rebuilds the container; the boot build refreshes every page. A change to <code>lib/</code> or <code>site/</code> changes the code hash in every fingerprint, so that first build renders everything.</p>231+<p><strong>Logs</strong>: <code>.forge/build.log</code> for builds, <code>.forge/events.log</code> for the events server, and <code>backup.log</code> and <code>mirror.log</code>, kept under a megabyte. <code>events.jsonl</code> is cut back to its last thousand lines once it passes a megabyte.</p>232+</div>233+</section>234+235+<section class="commit" id="hacking" aria-labelledby="hacking-h">236+<header><p class="ref"><b>08</b> hacking</p><h2 id="hacking-h"><a href="#hacking">Working on forge</a></h2></header>237+<div class="cheat">238+<section aria-labelledby="hk-run"><h3 id="hk-run">Run it</h3><dl>239+<div><dt>npm install</dt><dd>11ty, markdown-it, sanitize-html, mermaid; TypeScript only for checking.</dd></div>240+<div><dt>sh demo/make-demo.sh</dt><dd>A throwaway forge in <code>demo/root</code>: two users, stacked reviews with revisions, CI that fails and passes, a wiki, sites and previews, all through the real hooks over file:// pushes.</dd></div>241+<div><dt>node demo/serve.ts 4817</dt><dd>Serves it with live updates and cache headers, the way Caddy and the events server do.</dd></div>242+<div><dt>npm run dev</dt><dd>Serves the demo from <code>_site/</code> on :8080 and rebuilds it, reloading the browser, when templates, assets or <code>lib/</code> change. It never writes to <code>demo/root</code>.</dd></div>243+</dl></section>244+<section aria-labelledby="hk-check"><h3 id="hk-check">Check it</h3><dl>245+<div><dt>sh .forge/ci</dt><dd>Lints the shell, type-checks, builds the demo, checks a few pages, and checks this guide's links. It is forge's own CI, so <code>forge attest</code> runs it and signs the result.</dd></div>246+<div><dt>npx tsc -p .</dt><dd>Type check only. <code>tsc</code> never emits.</dd></div>247+<div><dt>node lib/check-links.ts guide</dt><dd>Every relative link and anchor in the guide resolves.</dd></div>248+<div><dt>node lib/render-page.ts check &lt;www&gt;</dt><dd>Every page renders the same with plain Nunjucks as with 11ty, the base for rendering pages on demand.</dd></div>249+</dl></section>250+</div>251+<div class="prose" style="margin-top:1.4rem">252+<p>Conventions: shell stays POSIX (<code>sh -n bin/forge</code>); TypeScript uses erasable syntax only and imports name their <code>.ts</code> files, since Node strips types and runs the sources; no frameworks; comments say why. Pages must work without JavaScript, which only adds shortcuts, live updates and conveniences.</p>253+<p>This guide is plain HTML and CSS with a few lines of script, and every link in it is relative, so it works on its own hostname and under any path. Edit <code>guide/</code>, run the checks, and publish from <code>main</code> with <code>forge site deploy guide</code>.</p>254+</div>255+</section>256+257+</main>258+</div>259+260+<footer class="end">261+<div class="wrap">262+<svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>263+<p>Back to <a href="./">the guide</a>. The README in the repo has the same facts in plain text.</p>264+</div>265+</footer>266+</body>267+</html>
A lib/assets.ts +112 -0
@@ -0,0 +1,112 @@
1+// Assets under content-hashed names, /assets/style.<hash>.css, so browsers2+// can cache them forever: the stylesheet, the logo, the vendored scripts, our3+// TypeScript with its types stripped, and mermaid. 11ty's bundle plugin4+// writes the templates' own CSS next to them, also named by a hash.5+//6+// Pages from older builds keep linking older names: forge build swaps one7+// repo at a time, and unchanged pages are hard links to the last build's.8+// So each build also carries over the files of the last few code versions,9+// recorded in assets/versions.json, and drops the rest.10+import fs from 'node:fs';11+import path from 'node:path';12+import { createHash } from 'node:crypto';13+import { stripTypeScriptTypes } from 'node:module';14+import { fileURLToPath } from 'node:url';15+16+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');17+const src = path.join(root, 'site', 'assets');18+const MERMAID = path.join(root, 'node_modules', 'mermaid', 'dist', 'mermaid.min.js');19+const VERSIONS = 'versions.json';20+// Code versions whose files stay: enough for a rebuild of every repo to finish21+// and for a repo whose build fails to keep its styles for a few deploys.22+const KEEP = 5;23+24+// The same name 11ty's bundle plugin gives a bundle: base64url sha256, 10 characters.25+export const hash = (data: string | Buffer): string => createHash('sha256').update(data).digest('base64url').slice(0, 10);26+27+export interface Asset {28+ file: string; // e.g. style.Zm9vYmFyYm.css29+ data: Buffer;30+}31+32+// By short name: style, logo, fixi, ssexi, diffs, keys, live, search, ui, mermaid.33+export type Assets = Record<string, Asset>;34+35+let memo: { key: string; assets: Assets } | null = null;36+37+export function assets(): Assets {38+ const files = [...fs.readdirSync(src).filter((f) => /\.(css|svg|js|ts)$/.test(f)).sort().map((f) => path.join(src, f)), MERMAID];39+ // Rebuilt only when a source changes (the dev server rebuilds in one process).40+ const key = files.map((f) => `${f} ${fs.statSync(f).mtimeMs}`).join('\n');41+ if (memo?.key === key) return memo.assets;42+ const out: Assets = {};43+ for (const f of files) {44+ let data = fs.readFileSync(f);45+ let ext = path.extname(f);46+ const name = path.basename(f).split('.')[0];47+ if (ext === '.ts') {48+ data = Buffer.from(stripTypeScriptTypes(data.toString('utf8')));49+ ext = '.js';50+ }51+ out[name] = { file: `${name}.${hash(data)}${ext}`, data };52+ }53+ memo = { key, assets: out };54+ return out;55+}56+57+// What templates link: assets.style is /assets/style.<hash>.css.58+export const assetUrls = (): Record<string, string> => Object.fromEntries(Object.entries(assets()).map(([k, a]) => [k, `/assets/${a.file}`]));59+60+interface Version {61+ code: string;62+ files: string[];63+}64+65+// The versions a site's assets keep. A live site's assets from before hashing66+// count as one more old version.67+function versions(dir: string, live: boolean): Version[] {68+ try {69+ return JSON.parse(fs.readFileSync(path.join(dir, VERSIONS), 'utf8'));70+ } catch {71+ const files = live && fs.existsSync(dir) ? fs.readdirSync(dir) : [];72+ return files.length ? [{ code: '', files }] : [];73+ }74+}75+76+// A file from the live site: a hard link (same filesystem), else a copy.77+function carry(from: string, to: string): boolean {78+ try {79+ fs.linkSync(from, to);80+ } catch {81+ try {82+ fs.cpSync(from, to, { recursive: true });83+ } catch {84+ return false;85+ }86+ }87+ return true;88+}89+90+// Write this build's assets into <out>/assets, next to the CSS bundles 11ty91+// wrote there, and carry over what earlier versions' pages still link from92+// <live>/assets. `code` names this version of the forge's code.93+export function shipAssets(out: string, live: string | null, code: string): void {94+ const dir = path.join(out, 'assets');95+ const was = live ? path.join(live, 'assets') : dir;96+ const prev = versions(was, !!live);97+ fs.mkdirSync(dir, { recursive: true });98+ for (const a of Object.values(assets())) {99+ const to = path.join(dir, a.file);100+ if (fs.existsSync(to) || (live && carry(path.join(was, a.file), to))) continue;101+ fs.writeFileSync(to, a.data);102+ }103+ // Files of the same code version stay current: pages linked from the last104+ // build may use bundles that no page of this build rendered.105+ const now = new Set(fs.readdirSync(dir).filter((f) => f !== VERSIONS));106+ for (const v of prev) if (v.code === code) for (const f of v.files) now.add(f);107+ const keep = [{ code, files: [...now].sort() }, ...prev.filter((v) => v.code !== code)].slice(0, KEEP);108+ if (live) {109+ for (const v of keep) v.files = v.files.filter((f) => fs.existsSync(path.join(dir, f)) || carry(path.join(was, f), path.join(dir, f)));110+ }111+ fs.writeFileSync(path.join(dir, VERSIONS), JSON.stringify(keep, null, 1) + '\n');112+}
A lib/attest.ts +119 -0
@@ -0,0 +1,119 @@
1+// CI attestations: signed statements stored as git notes under refs/notes/ci.2+//3+// A note holds one or more blocks. Each block is a statement followed by an4+// armored SSH signature (ssh-keygen -Y sign -n forge-ci) over the statement5+// bytes. Log lines are prefixed with "|" so they can never forge a header or6+// a signature boundary. See `forge attest` for the writer.7+import { spawnSync } from 'node:child_process';8+import { createHash } from 'node:crypto';9+import fs from 'node:fs';10+import os from 'node:os';11+import path from 'node:path';12+import type { Attestation, CiStatus, Verification } from './types.ts';13+14+export const NAMESPACE = 'forge-ci';15+const BLOCK = /^forge-attestation 1\n[\s\S]*?^-----END SSH SIGNATURE-----$/gm;16+17+export function parseNote(text: string): Attestation[] {18+ const out: Attestation[] = [];19+ for (const m of text.matchAll(BLOCK)) {20+ const block = m[0];21+ const at = block.search(/^-----BEGIN SSH SIGNATURE-----$/m);22+ if (at < 0) continue;23+ const payload = block.slice(0, at);24+ const fields: Record<string, string> = {};25+ const log: string[] = [];26+ for (const line of payload.split('\n').slice(1)) {27+ if (line.startsWith('|')) {28+ log.push(line.slice(1));29+ continue;30+ }31+ const sp = line.indexOf(' ');32+ if (sp > 0) fields[line.slice(0, sp)] = line.slice(sp + 1);33+ }34+ out.push({35+ payload,36+ signature: block.slice(at) + '\n',37+ commit: fields.commit,38+ tree: fields.tree,39+ result: fields.result,40+ exit: fields.exit,41+ started: fields.started,42+ duration: fields.duration,43+ runner: fields.runner,44+ system: fields.system,45+ env: fields.env,46+ script: fields.script,47+ logHash: fields.log,48+ log,49+ verification: { ok: false, signer: null, reason: 'not checked' },50+ });51+ }52+ return out;53+}54+55+// Verifies statements against an allowed_signers file, memoized on disk by56+// statement hash (statements and signatures are immutable).57+export class Verifier {58+ allowedSigners: string | null;59+ cacheFile: string | null;60+ cache: Record<string, Verification> = {};61+ dirty = false;62+63+ constructor({ allowedSigners, cacheFile }: { allowedSigners: string | null; cacheFile: string | null }) {64+ this.allowedSigners = allowedSigners && fs.existsSync(allowedSigners) ? allowedSigners : null;65+ this.cacheFile = cacheFile;66+ if (cacheFile && fs.existsSync(cacheFile)) {67+ try {68+ this.cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));69+ } catch {70+ this.cache = {};71+ }72+ }73+ }74+75+ verify(att: Attestation): Verification {76+ if (!this.allowedSigners) return { ok: false, signer: null, reason: 'no allowed_signers configured' };77+ const key = createHash('sha256').update(this.allowedSigners + '\0' + fs.statSync(this.allowedSigners).mtimeMs + '\0' + att.payload + att.signature).digest('hex');78+ const hit = this.cache[key];79+ if (hit) return hit;80+ const res = this.check(att, this.allowedSigners);81+ this.cache[key] = res;82+ this.dirty = true;83+ return res;84+ }85+86+ private check(att: Attestation, signers: string): Verification {87+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-att-'));88+ try {89+ const sig = path.join(dir, 'sig');90+ fs.writeFileSync(sig, att.signature);91+ const find = spawnSync('ssh-keygen', ['-Y', 'find-principals', '-s', sig, '-f', signers], { encoding: 'utf8' });92+ const principal = find.status === 0 ? find.stdout.split('\n')[0].trim() : null;93+ if (!principal) return { ok: false, signer: null, reason: 'signing key not in allowed_signers' };94+ const v = spawnSync('ssh-keygen', ['-Y', 'verify', '-f', signers, '-I', principal, '-n', NAMESPACE, '-s', sig], { input: att.payload, encoding: 'utf8' });95+ if (v.status !== 0) return { ok: false, signer: principal, reason: 'bad signature' };96+ return { ok: true, signer: principal, key: /key (\S+ SHA256:\S+)/.exec(v.stdout + v.stderr)?.[1] ?? null };97+ } finally {98+ fs.rmSync(dir, { recursive: true, force: true });99+ }100+ }101+102+ save(): void {103+ if (!this.dirty || !this.cacheFile) return;104+ fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true });105+ fs.writeFileSync(this.cacheFile, JSON.stringify(this.cache));106+ }107+}108+109+// Summarize attestations that apply to one tree. Attestations are keyed by110+// tree, so a reworded or re-parented commit with an identical tree keeps its111+// CI result without re-running.112+export function ciStatus(list: Attestation[] | undefined): CiStatus | null {113+ if (!list?.length) return null;114+ const verified = list.filter((a) => a.verification.ok);115+ const pool = verified.length ? verified : list;116+ const latest = [...pool].sort((a, b) => String(b.started).localeCompare(String(a.started)))[0];117+ const state = !latest.verification.ok ? 'unverified' : latest.result === 'pass' ? 'pass' : 'fail';118+ return { state, latest, count: list.length };119+}
A lib/cache.ts +58 -0
@@ -0,0 +1,58 @@
1+// Per-repo build cache: facts keyed by immutable git object ids (commit2+// metadata, range-diffs, signature checks) and the fingerprint of every page3+// the last build wrote. Lives in $FORGE_STATE/cache/repos/<owner>/<repo>.json.4+import fs from 'node:fs';5+import path from 'node:path';6+import type { CommitMeta, Config, SiteDeploy } from './types.ts';7+8+type Sig = Pick<SiteDeploy, 'signed' | 'signer'>;9+10+interface CacheData {11+ commits?: Record<string, CommitMeta>;12+ ranges?: Record<string, unknown>;13+ sigs?: Record<string, Sig>;14+ sigsKey?: string | null;15+ pages?: { code: string; fps: Record<string, string> } | null;16+}17+18+export class RepoCache {19+ file: string;20+ commits: Record<string, CommitMeta>;21+ ranges: Record<string, unknown>;22+ sigs: Record<string, Sig>;23+ sigsKey: string | null;24+ pages: { code: string; fps: Record<string, string> } | null;25+26+ constructor(file: string, data: CacheData) {27+ this.file = file;28+ this.commits = data.commits ?? {};29+ this.ranges = data.ranges ?? {};30+ this.sigs = data.sigs ?? {};31+ this.sigsKey = data.sigsKey ?? null;32+ this.pages = data.pages ?? null;33+ }34+35+ static load(cfg: Config, slug: string): RepoCache {36+ const file = path.join(cfg.cacheDir, 'repos', `${slug}.json`);37+ let data: CacheData = {};38+ try {39+ data = JSON.parse(fs.readFileSync(file, 'utf8'));40+ } catch {}41+ return new RepoCache(file, data);42+ }43+44+ // Signature checks depend on who is allowed to sign; start over when that changes.45+ useSigners(key: string): void {46+ if (this.sigsKey !== key) {47+ this.sigs = {};48+ this.sigsKey = key;49+ }50+ }51+52+ save(): void {53+ fs.mkdirSync(path.dirname(this.file), { recursive: true });54+ const tmp = `${this.file}.${process.pid}`;55+ fs.writeFileSync(tmp, JSON.stringify({ commits: this.commits, ranges: this.ranges, sigs: this.sigs, sigsKey: this.sigsKey, pages: this.pages }));56+ fs.renameSync(tmp, this.file);57+ }58+}
A lib/check-links.ts +43 -0
@@ -0,0 +1,43 @@
1+// Check a static site's internal links: every href and src in its HTML and2+// every url() in its CSS must name a file inside the site, and every #anchor3+// an id on the page it points to. Links must be relative, so the site works4+// under any base path and on its own hostname. .forge/ci runs it on the guide.5+// node lib/check-links.ts <dir>6+import fs from 'node:fs';7+import path from 'node:path';8+9+const dir = path.resolve(process.argv[2] ?? '.');10+const files = (fs.readdirSync(dir, { recursive: true }) as string[]).map((f) => path.join(dir, f)).filter((f) => fs.statSync(f).isFile());11+const ids = new Map<string, Set<string>>();12+const idsOf = (file: string): Set<string> => {13+ let s = ids.get(file);14+ if (!s) ids.set(file, (s = new Set([...fs.readFileSync(file, 'utf8').matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]))));15+ return s;16+};17+18+const errors: string[] = [];19+if (!fs.existsSync(path.join(dir, 'index.html'))) errors.push(`${dir}: no index.html`);20+for (const file of files) {21+ const text = fs.readFileSync(file, 'utf8');22+ const links = file.endsWith('.html') ? [...text.matchAll(/\s(?:href|src)="([^"]*)"/g)] : file.endsWith('.css') ? [...text.matchAll(/url\(["']?([^"')]+)/g)] : [];23+ for (const [, raw] of links) {24+ const at = `${path.relative(dir, file)}: ${raw}`;25+ if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(raw)) continue;26+ if (raw.startsWith('/')) {27+ errors.push(`${at} is absolute; use a relative link`);28+ continue;29+ }30+ const [p, frag] = raw.replace(/\?.*$/, '').split('#');31+ let target = p ? path.resolve(path.dirname(file), decodeURIComponent(p)) : file;32+ if (target !== dir && !target.startsWith(dir + path.sep)) {33+ errors.push(`${at} leaves the site`);34+ continue;35+ }36+ if (fs.existsSync(target) && fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');37+ if (!fs.existsSync(target)) errors.push(`${at} is missing`);38+ else if (frag && target.endsWith('.html') && !idsOf(target).has(frag)) errors.push(`${at} has no #${frag}`);39+ }40+}41+for (const e of errors) console.error(e);42+if (errors.length) process.exit(1);43+console.log(`${path.relative(process.cwd(), dir) || '.'}: ${files.length} files, links ok`);
A lib/diff.ts +106 -0
@@ -0,0 +1,106 @@
1+// Parse `git diff`/`git log -p` output into files and hunks.2+import type { DiffFile, Hunk, Stats } from './types.ts';3+4+const MAX_LINES_PER_FILE = 3000;5+6+function unquote(p: string): string {7+ if (!p.startsWith('"')) return p;8+ // C-style quoted path; decode octal escapes as UTF-8 bytes.9+ const bytes: number[] = [];10+ const escapes: Record<string, number> = { n: 10, t: 9, '"': 34, '\\': 92 };11+ for (let i = 1; i < p.length - 1; i++) {12+ const c = p[i];13+ if (c !== '\\') {14+ bytes.push(...Buffer.from(c));15+ continue;16+ }17+ const n = p[++i];18+ if (/[0-7]/.test(n)) {19+ bytes.push(parseInt(p.slice(i, i + 3), 8));20+ i += 2;21+ } else bytes.push(escapes[n] ?? n.charCodeAt(0));22+ }23+ return Buffer.from(bytes).toString();24+}25+26+function stripPrefix(p: string): string | null {27+ if (p === '/dev/null') return null;28+ return unquote(p).replace(/^[ab]\//, '');29+}30+31+export function parseDiff(text: string): DiffFile[] {32+ const files: DiffFile[] = [];33+ let f: DiffFile | null = null;34+ let h: Hunk | null = null;35+ let oldNo = 0;36+ let newNo = 0;37+ const lines = text.split('\n');38+ if (lines.at(-1) === '') lines.pop();39+ for (const line of lines) {40+ if (line.startsWith('diff --git ')) {41+ const rest = line.slice(11);42+ const half = (rest.length - 1) / 2;43+ const guess = Number.isInteger(half) && rest.slice(2, half) === rest.slice(half + 3) ? rest.slice(2, half) : null;44+ f = { oldPath: guess, newPath: guess, path: '', status: 'M', binary: false, hunks: [], add: 0, del: 0, truncated: false, nlines: 0, mode: null };45+ files.push(f);46+ h = null;47+ continue;48+ }49+ if (!f) continue;50+ if (!h) {51+ if (line.startsWith('new file mode')) {52+ f.status = 'A';53+ f.mode = line.slice(14);54+ } else if (line.startsWith('deleted file mode')) f.status = 'D';55+ else if (line.startsWith('rename from ')) {56+ f.status = 'R';57+ f.oldPath = unquote(line.slice(12));58+ } else if (line.startsWith('rename to ')) f.newPath = unquote(line.slice(10));59+ else if (line.startsWith('copy from ')) {60+ f.status = 'C';61+ f.oldPath = unquote(line.slice(10));62+ } else if (line.startsWith('copy to ')) f.newPath = unquote(line.slice(8));63+ else if (line.startsWith('Binary files ')) f.binary = true;64+ else if (line.startsWith('index ')) {65+ const m = /^index ([0-9a-f]+)\.\.([0-9a-f]+)/.exec(line);66+ if (m) f.oids = [m[1], m[2]];67+ }68+ else if (line.startsWith('--- ')) f.oldPath = stripPrefix(line.slice(4)) ?? f.oldPath;69+ else if (line.startsWith('+++ ')) {70+ const p = stripPrefix(line.slice(4));71+ if (p) f.newPath = p;72+ }73+ }74+ if (line.startsWith('@@')) {75+ const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);76+ oldNo = m ? Number(m[1]) : 0;77+ newNo = m ? Number(m[2]) : 0;78+ h = { header: line, lines: [] };79+ f.hunks.push(h);80+ continue;81+ }82+ if (!h) continue;83+ const t = line[0];84+ if (t === '+') f.add++;85+ else if (t === '-') f.del++;86+ if ((f.nlines ?? 0) >= MAX_LINES_PER_FILE) {87+ f.truncated = true;88+ continue;89+ }90+ f.nlines = (f.nlines ?? 0) + 1;91+ if (t === '+') h.lines.push({ t, text: line.slice(1), o: null, n: newNo++ });92+ else if (t === '-') h.lines.push({ t, text: line.slice(1), o: oldNo++, n: null });93+ else if (t === '\\') h.lines.push({ t, text: line, o: null, n: null });94+ else h.lines.push({ t: ' ', text: line.slice(1), o: oldNo++, n: newNo++ });95+ }96+ for (const x of files) x.path = (x.status === 'D' ? x.oldPath : x.newPath ?? x.oldPath) ?? '';97+ return files;98+}99+100+export function diffStats(files: DiffFile[]): Stats {101+ return {102+ files: files.length,103+ add: files.reduce((s, f) => s + f.add, 0),104+ del: files.reduce((s, f) => s + f.del, 0),105+ };106+}
A lib/discussion.ts +83 -0
@@ -0,0 +1,83 @@
1+// Comments and approvals on reviews, written by `forge comment` and2+// `forge approve` on the server (see sc_comment and sc_approve in bin/forge).3+//4+// A comment is a file, <repo>.git/forge/comments/<review>/<n>: header lines5+// ("time", "user", "sha", "rev", and for a line comment "path", "line",6+// "text"), a blank line, then the message. An approval is a line in the7+// review's log, "approve <ts> <user> <sha> <tree>", and like CI it is keyed by8+// tree, so rewording or reordering commits keeps it.9+import fs from 'node:fs';10+import path from 'node:path';11+import type { Approval, DiffFile, Review, ReviewComment } from './types.ts';12+13+export function readComments(gitDir: string, id: number): ReviewComment[] {14+ const dir = path.join(gitDir, 'forge', 'comments', String(id));15+ let names: string[];16+ try {17+ names = fs.readdirSync(dir).filter((f) => /^\d+$/.test(f));18+ } catch {19+ return [];20+ }21+ return names.map(Number).sort((a, b) => a - b).map((n): ReviewComment => {22+ const text = fs.readFileSync(path.join(dir, String(n)), 'utf8');23+ const blank = text.indexOf('\n\n');24+ const head: Record<string, string> = {};25+ for (const line of text.slice(0, blank < 0 ? text.length : blank).split('\n')) {26+ const sp = line.indexOf(' ');27+ if (sp > 0) head[line.slice(0, sp)] = line.slice(sp + 1);28+ }29+ return {30+ n, ts: Number(head.time), user: head.user ?? '', sha: head.sha ?? '', rev: head.rev ? Number(head.rev) : null,31+ path: head.path || null, line: head.line ? Number(head.line) : null, text: head.text ?? null,32+ body: blank < 0 ? '' : text.slice(blank + 2).replace(/\s+$/, ''), outdated: false, inDiff: false,33+ };34+ });35+}36+37+export function parseApproval(v: string): Approval {38+ const [ts, user, sha, tree] = v.split(' ');39+ return { ts: Number(ts), user, sha, tree, rev: null, current: false };40+}41+42+// Once the tip and revisions are known: the revision of each comment and43+// approval, which comments are outdated, and one approval per person, a44+// current one if they ever approved the tip's tree, else their latest.45+export function settle(r: Review, tipTree: string | null): void {46+ const revOf = (sha: string): number | null => r.revs.findLast((v) => v.sha === sha)?.n ?? null;47+ for (const c of r.comments) {48+ c.rev = revOf(c.sha) ?? c.rev;49+ c.outdated = c.sha !== r.tip;50+ }51+ const mine = new Map<string, Approval>();52+ for (const a of r.approvals) {53+ a.rev = revOf(a.sha);54+ a.current = !!tipTree && a.tree === tipTree;55+ if (a.user === r.author) continue;56+ const had = mine.get(a.user);57+ if (!had || a.current || !had.current) mine.set(a.user, a);58+ }59+ r.approvals = [...mine.values()];60+}61+62+// Line comments on the current revision whose line is in the combined diff.63+export function markInDiff(r: Review, files: DiffFile[]): void {64+ const shown = new Set<string>();65+ for (const f of files) for (const h of f.hunks) for (const l of h.lines) if (l.n != null) shown.add(`${f.path}:${l.n}`);66+ for (const c of r.comments) c.inDiff = !c.outdated && !!c.path && shown.has(`${c.path}:${c.line}`);67+}68+69+// The hook for diff(): current line comments, rendered by `html`, under70+// their line.71+export function inline(r: Review, html: (c: ReviewComment) => string): ((p: string, n: number) => string | null) | null {72+ const at = new Map<string, ReviewComment[]>();73+ for (const c of r.comments) {74+ if (c.outdated || !c.path || !c.line) continue;75+ const k = `${c.path}:${c.line}`;76+ at.set(k, [...(at.get(k) ?? []), c]);77+ }78+ if (!at.size) return null;79+ return (p, n) => {80+ const list = at.get(`${p}:${n}`);81+ return list ? `<div class="cmts">${list.map(html).join('')}</div>` : null;82+ };83+}
A lib/events-server.ts +107 -0
@@ -0,0 +1,107 @@
1+// Live pages: server-sent events for /events/<owner>/<repo>/ (one repo) and2+// /events/ (everything). Tails $FORGE_STATE/events.jsonl, which the hooks and3+// builds append to, and streams each new event to the pages watching it.4+//5+// Each event goes out twice: an unnamed message with a line of HTML for the6+// page's live status (ssexi swaps it in), and a named event (push, built) the7+// page's script acts on, e.g. refreshing its content when a build finishes.8+import http from 'node:http';9+import fs from 'node:fs';10+import path from 'node:path';11+import { fileURLToPath } from 'node:url';12+13+export interface ForgeEvent {14+ t: number;15+ repo: string;16+ type: 'push' | 'built' | 'comment' | 'approve';17+ user?: string;18+ refs?: string;19+ review?: number;20+ ms?: number | null;21+ rendered?: number;22+}23+24+interface Client {25+ repo: string | null;26+ res: http.ServerResponse;27+}28+29+const ENTITIES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };30+const esc = (s: unknown): string => String(s ?? '').replace(/[&<>"]/g, (c) => ENTITIES[c]);31+32+function status(ev: ForgeEvent): string {33+ if (ev.type === 'push') {34+ const what = (ev.refs ?? '').split(' ').filter(Boolean).map((r) => r.replace(/^refs\/(heads|tags)\//, '')).join(', ');35+ return `<span class="live-busy">${esc(ev.user || 'someone')} pushed ${esc(what || ev.repo)}, updating</span>`;36+ }37+ if (ev.type === 'built') return `<span class="live-on">updated ${esc(ev.repo)}</span>`;38+ if (ev.type === 'comment' || ev.type === 'approve') {39+ return `<span class="live-busy">${esc(ev.user || 'someone')} ${ev.type === 'comment' ? 'commented on' : 'approved'} #${esc(ev.review)}, updating</span>`;40+ }41+ return '';42+}43+44+function send(res: http.ServerResponse, ev: ForgeEvent): void {45+ const html = status(ev);46+ if (html) res.write(`data: ${html}\n\n`);47+ res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);48+}49+50+export function serveEvents(state: string, port: number): http.Server {51+ const file = path.join(state, 'events.jsonl');52+ const clients = new Set<Client>();53+ const broadcast = (ev: ForgeEvent): void => {54+ for (const c of clients) if (!c.repo || c.repo === ev.repo) send(c.res, ev);55+ };56+57+ // Follow the file: read whatever was appended since last time.58+ let offset = fs.existsSync(file) ? fs.statSync(file).size : 0;59+ let partial = '';60+ const poll = (): void => {61+ let size: number;62+ try {63+ size = fs.statSync(file).size;64+ } catch {65+ return;66+ }67+ if (size < offset) offset = 0; // rotated68+ if (size === offset) return;69+ const fd = fs.openSync(file, 'r');70+ const buf = Buffer.alloc(size - offset);71+ fs.readSync(fd, buf, 0, buf.length, offset);72+ fs.closeSync(fd);73+ offset = size;74+ const lines = (partial + buf.toString('utf8')).split('\n');75+ partial = lines.pop() ?? '';76+ for (const line of lines) {77+ try {78+ broadcast(JSON.parse(line) as ForgeEvent);79+ } catch {}80+ }81+ };82+ fs.watchFile(file, { interval: 300 }, poll);83+84+ return http.createServer((req, res) => {85+ const m = /^\/events\/(?:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/?)?$/.exec(new URL(req.url ?? '/', 'http://x').pathname);86+ if (!m || req.method !== 'GET') {87+ res.writeHead(404).end();88+ return;89+ }90+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });91+ res.write('retry: 5000\n');92+ // Connected and idle: nothing to show until someone pushes.93+ res.write('data: \n\n');94+ const client: Client = { repo: m[1] ? `${m[1]}/${m[2]}` : null, res };95+ clients.add(client);96+ const ping = setInterval(() => res.write(': ping\n\n'), 20000);97+ req.on('close', () => {98+ clearInterval(ping);99+ clients.delete(client);100+ });101+ }).listen(port, '127.0.0.1', () => console.log(`events on 127.0.0.1:${port}, following ${file}`));102+}103+104+// Run directly: node lib/events-server.ts105+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {106+ serveEvents(process.env.FORGE_STATE || path.resolve('demo/root/.forge'), Number(process.env.FORGE_EVENTS_PORT || 8091));107+}
A lib/forge.ts +511 -0
@@ -0,0 +1,511 @@
1+// Discover repos under FORGE_ROOT/<owner>/<repo>.git and flatten everything2+// into page lists for 11ty pagination. Heavy HTML (diffs, blobs) is rendered3+// lazily by filters so a build never holds every page's markup in memory.4+import fs from 'node:fs';5+import path from 'node:path';6+import { createHash } from 'node:crypto';7+import { summarize, loadRepo, IMAGE } from './repo.ts';8+9+const README = /^readme(\.(md|markdown|txt))?$/i;10+const MARKDOWN = /\.(md|markdown|mdown|mkd)$/i;11+import { Verifier } from './attest.ts';12+import { stackOf, nest } from './reviews.ts';13+import { inline, markInDiff } from './discussion.ts';14+import { reviewList } from './review-list.ts';15+import { isBinary } from './git.ts';16+import * as html from './render.ts';17+import { Spans, build, codeVersion } from './metrics.ts';18+import { RepoCache } from './cache.ts';19+import { repoIndex, reposIndex } from './search.ts';20+import { labelCommits, labelKey, releases, repoTopic } from './jev-commits.ts';21+import { annotateReviews, answersStamp, isMechanical, pairChanges } from './jev-reviews.ts';22+import { HighlightCache } from './highlight.ts';23+import { assets } from './assets.ts';24+import type { CiStatus, Commit, Config, DiffFile, Repo, RepoCounts, RepoSummary, RepoUrls, Review, ReviewComment, SiteLink } from './types.ts';25+26+export function config(env: NodeJS.ProcessEnv = process.env): Config {27+ const root = path.resolve(env.FORGE_ROOT || 'demo/root');28+ const state = path.join(root, '.forge');29+ return {30+ name: env.FORGE_NAME || 'forge',31+ root,32+ state,33+ allowedSigners: env.FORGE_ALLOWED_SIGNERS || path.join(state, 'allowed_signers'),34+ cacheDir: env.FORGE_CACHE || path.join(state, 'cache'),35+ baseUrl: (env.FORGE_BASE_URL || '').replace(/\/$/, ''),36+ ssh: env.FORGE_SSH || 'git@localhost',37+ sitesUrl: env.FORGE_SITES_URL || '/sites/{owner}/{repo}/{site}/',38+ previewsUrl: env.FORGE_PREVIEWS_URL || '/previews/{owner}/{repo}/{site}/{branch}/',39+ only: env.FORGE_ONLY || null,40+ maxCommits: Number(env.FORGE_MAX_COMMITS || 1000),41+ maxFiles: Number(env.FORGE_MAX_FILES || 5000),42+ maxBlob: Number(env.FORGE_MAX_BLOB || 1 << 20),43+ // Commits changing more lines than this show their file list, not the patch.44+ maxDiffLines: Number(env.FORGE_MAX_DIFF_LINES || 20000),45+ // Images (for READMEs and file pages) are copied as raw files up to this size.46+ maxRaw: Number(env.FORGE_MAX_RAW || 20 << 20),47+ };48+}49+50+// Repo and owner names sort ignoring case, so "Zed" doesn't come before "api".51+const caseless = (a: string, b: string): number => a.localeCompare(b, 'en', { sensitivity: 'base' }) || (a < b ? -1 : a > b ? 1 : 0);52+53+// Top-level names used by the forge itself; never owners.54+const RESERVED = new Set(['assets', 'sites', 'previews', 'hosts', 'index.html', 'git-shell-commands', 'lost+found']);55+56+export function discover(cfg: Config): { repos: RepoSummary[]; owners: string[] } {57+ if (!fs.existsSync(cfg.root)) return { repos: [], owners: [] };58+ const repos: RepoSummary[] = [];59+ const owners: string[] = [];60+ for (const owner of fs.readdirSync(cfg.root).sort(caseless)) {61+ if (owner.startsWith('.') || RESERVED.has(owner)) continue;62+ const odir = path.join(cfg.root, owner);63+ let entries: string[] | null;64+ // Skip anything that isn't a readable directory (e.g. lost+found on a fresh volume).65+ try {66+ entries = fs.statSync(odir).isDirectory() ? fs.readdirSync(odir) : null;67+ } catch {68+ entries = null;69+ }70+ if (!entries) continue;71+ owners.push(owner);72+ for (const d of entries.sort(caseless)) {73+ if (!d.endsWith('.git') || !fs.existsSync(path.join(odir, d, 'HEAD'))) continue;74+ repos.push(summarize(cfg, owner, d.slice(0, -4)));75+ }76+ }77+ return { repos, owners };78+}79+80+// Loaded repos by slug, for the lazy-rendering filters.81+const registry = new Map<string, Repo>();82+const highlights = new Map<string, HighlightCache>();83+84+// What every page of a repo shows in its header.85+export interface RepoHead {86+ owner: string;87+ name: string;88+ slug: string;89+ description: string;90+ defaultBranch: string;91+ urls: RepoUrls;92+ counts: RepoCounts;93+}94+95+// One generated file: a page for pagination, or raw bytes.96+export interface Page {97+ permalink: string;98+ title?: string;99+ nav?: string;100+ repo: RepoHead;101+ [key: string]: unknown;102+}103+104+export interface RawFile {105+ path: string;106+ oid?: string;107+ data?: Buffer;108+}109+110+export interface Forge {111+ config: Config;112+ repos: RepoSummary[];113+ owners: { owner: string; repos: RepoSummary[] }[];114+ pages: Record<string, Page[]>;115+ raw: RawFile[];116+ // Incremental builds: the live site, and the pages taken from it unchanged.117+ live: string | null;118+ code: string;119+ skipped: string[];120+ pageCaches: { cache: RepoCache; fps: Record<string, string> }[];121+}122+123+let last: Forge | null = null;124+export const current = (): Forge | null => last;125+126+// Path breadcrumbs: directories link to /tree/, a final file to /blob/.127+const crumbs = (urls: RepoUrls, branch: string, p: string, isFile = false): { name: string; href: string }[] => {128+ const parts = p ? p.split('/') : [];129+ return parts.map((name, i) => {130+ const sub = parts.slice(0, i + 1).join('/');131+ return { name, href: (isFile && i === parts.length - 1 ? urls.blob(branch, sub) : urls.tree(branch, sub)).href };132+ });133+};134+135+const fingerprint = (...parts: unknown[]): string =>136+ createHash('sha1').update(parts.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join('\0')).digest('hex').slice(0, 16);137+const ciKey = (ci: CiStatus | null): string => (ci ? `${ci.state}:${ci.latest.started}:${ci.latest.verification.signer}:${ci.latest.via ?? ''}` : '');138+139+export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {140+ build.dataStart = performance.now();141+ const cfg = config(env);142+ const { repos: summaries, owners: ownerNames } = discover(cfg);143+ const verifier = new Verifier({ allowedSigners: cfg.allowedSigners, cacheFile: path.join(cfg.cacheDir, 'verify.json') });144+ // Index and owner pages list every repo, but a single-repo build only loads145+ // one. Each repo's counts and sites are cached when it is built.146+ const summaryCache = path.join(cfg.cacheDir, 'summaries.json');147+ let cached: Record<string, { counts: RepoCounts; sites: SiteLink[]; topic?: string | null }> = {};148+ try {149+ cached = JSON.parse(fs.readFileSync(summaryCache, 'utf8'));150+ } catch {}151+ const pages: Record<string, Page[]> = { homes: [], logs: [], trees: [], blobs: [], commits: [], refs: [], reviewLists: [], reviews: [], reviewDiffs: [], revisions: [], sites: [], wiki: [], atom: [], branchMenus: [], releases: [] };152+ const raw: RawFile[] = [];153+ // Incremental builds: when building next to a live site, a page whose154+ // fingerprint matches the last build (and whose file still exists) isn't155+ // rendered; its file is hard-linked from the live site instead.156+ const live = env.FORGE_LIVE_WWW || null;157+ // Pages link assets by hashed name, so a new name (mermaid or Node updated,158+ // say) must re-render them too.159+ const code = fingerprint(codeVersion(), Object.values(assets()).map((a) => a.file));160+ const skipped: string[] = [];161+ const pageCaches: Forge['pageCaches'] = [];162+163+ for (const s of summaries) {164+ if (cfg.only && cfg.only !== s.slug) continue;165+ const spans = new Spans();166+ const before: Record<string, number> = Object.fromEntries(Object.entries(pages).map(([k, v]) => [k, v.length]));167+ const t0 = performance.now();168+ const cache = RepoCache.load(cfg, s.slug);169+ const repo = loadRepo(cfg, s, verifier, spans, cache);170+ registry.set(s.slug, repo);171+ // Jev's stored answers (lib/jev-commits.ts): commit labels, the repo's topic.172+ labelCommits(repo.dir, repo.commits.values(), cache.ranges);173+ const topic = repoTopic(repo);174+ const hl = new HighlightCache(path.join(cfg.cacheDir, 'highlight', s.slug));175+ hl.sweep(new Set([...repo.trees.values()].flatMap((t) => t.blobs.map((e) => e.oid))));176+ highlights.set(s.slug, hl);177+ const u = repo.urls;178+ const head: RepoHead = {179+ owner: repo.owner, name: repo.name, slug: repo.slug, description: repo.description, defaultBranch: repo.defaultBranch, urls: u,180+ counts: {181+ reviews: repo.reviews.filter((r) => r.status === 'open' || r.status === 'draft').length,182+ sites: repo.sites.length,183+ wiki: repo.wiki.length,184+ },185+ };186+ cached[s.slug] = { counts: head.counts, sites: repo.sites.map((x) => ({ name: x.name, url: x.url, date: x.history[0]?.date ?? null })), topic };187+ // Everything every page of this repo shows in its header.188+ const headKey = fingerprint(code, cfg.name, cfg.baseUrl, cfg.ssh, head.description, head.defaultBranch, (head.counts.wiki ?? 0) > 0, (head.counts.sites ?? 0) > 0);189+ const prev = cache.pages?.fps ?? {};190+ const next: Record<string, string> = {};191+ let skippedHere = 0;192+ const want = (out: string, key: string): boolean => {193+ const k = fingerprint(headKey, key);194+ next[out] = k;195+ if (live && prev[out] === k && fs.existsSync(path.join(live, out))) {196+ skipped.push(out);197+ skippedHere++;198+ return false;199+ }200+ return true;201+ };202+ const page = (perma: string, title: string, nav: string, extra: Record<string, unknown>): Page =>203+ ({ permalink: perma + (perma.endsWith('/') ? 'index.html' : ''), title, nav, repo: head, ...extra });204+ // Pages without a key are cheap and always rendered.205+ const add = (list: Page[], p: Page, key?: string): void => {206+ if (key == null || want(p.permalink, key)) list.push(p);207+ };208+209+ const defaultLog = (repo.logs.get(repo.defaultBranch) ?? []).map((sha) => repo.commits.get(sha)).filter((c): c is Commit => !!c);210+ const rootTree = repo.trees.get(repo.defaultBranch);211+ const entryLinks = (b: string, entries: { type: string; path: string }[]) => entries.map((e) => ({ ...e, href: e.type === 'commit' ? null : e.type === 'blob' ? u.blob(b, e.path).href : u.tree(b, e.path).href }));212+ add(pages.homes, page(u.home.path, `${repo.slug}`, 'home', {213+ readme: repo.readme, latest: defaultLog[0] ?? null, commitCount: defaultLog.length, branchCount: repo.branches.length,214+ entries: rootTree ? entryLinks(repo.defaultBranch, rootTree.dirs.get('') ?? []) : [],215+ filesHref: rootTree ? u.files(repo.defaultBranch).href : null,216+ reviews: firstStacks(nest(repo.reviews.filter((r) => r.status === 'open')), 5), topic,217+ }));218+ pages.atom.push({ permalink: u.atom.path, repo: head, commits: defaultLog.slice(0, 30) });219+ for (const kind of ['tree', 'log']) pages.branchMenus.push({ permalink: u.branchMenu(kind).path, repo: head, kind, branches: repo.branches });220+221+ const wantBlobs = new Set<string>();222+ for (const b of repo.branches) {223+ const commits = (repo.logs.get(b.name) ?? []).map((sha) => repo.commits.get(sha)).filter((c): c is Commit => !!c);224+ add(pages.logs, page(u.log(b.name).path, `Log - ${b.name} - ${repo.slug}`, 'log', { branch: b, commits }),225+ `log ${b.name} ${b.sha} ${fingerprint(commits.map((c) => c.sha + ciKey(c.ci) + labelKey(c)))}`);226+227+ const tree = repo.trees.get(b.name)!;228+ for (const [dirPath, entries] of tree.dirs) {229+ const readme = entries.find((e) => e.type === 'blob' && README.test(e.name) && (e.size ?? 0) <= cfg.maxBlob);230+ const p = page(u.tree(b.name, dirPath).path, `${dirPath || 'Files'} - ${b.name} - ${repo.slug}`, 'files', {231+ branch: b, path: dirPath, crumbs: crumbs(u, b.name, dirPath), entries: entryLinks(b.name, entries),232+ parentHref: dirPath ? u.tree(b.name, dirPath.includes('/') ? dirPath.slice(0, dirPath.lastIndexOf('/')) : '').href : null,233+ readme: readme ? { oid: readme.oid, path: readme.path, name: readme.name } : null,234+ filesHref: u.files(b.name).href,235+ });236+ if (want(p.permalink, `tree ${b.name} ${dirPath} ${tree.dirOids.get(dirPath) ?? ''}`)) {237+ pages.trees.push(p);238+ if (readme) wantBlobs.add(readme.oid);239+ }240+ }241+ for (const e of tree.blobs) {242+ const small = e.size != null && e.size <= cfg.maxBlob;243+ const image = IMAGE.test(e.path) && e.size != null && e.size <= cfg.maxRaw;244+ const markdown = MARKDOWN.test(e.path) && small;245+ const info = {246+ branch: b, path: e.path, crumbs: crumbs(u, b.name, e.path, true), oid: e.oid, size: e.size, mode: e.mode,247+ parentHref: u.tree(b.name, e.path.includes('/') ? e.path.slice(0, e.path.lastIndexOf('/')) : '').href,248+ image: image ? u.raw(b.name, e.path).href : null,249+ filesHref: u.files(b.name).href,250+ sourceHref: markdown ? u.blobSource(b.name, e.path).href : null,251+ renderedHref: markdown ? u.blob(b.name, e.path).href : null,252+ };253+ const key = `blob ${b.name} ${e.path} ${e.oid} ${e.mode}`;254+ // Markdown files render as documents; their source is a page of its own.255+ const p = page(u.blob(b.name, e.path).path, `${e.path} - ${b.name} - ${repo.slug}`, 'files', { ...info, markdown });256+ if (want(p.permalink, key)) {257+ pages.blobs.push(p);258+ if (small) wantBlobs.add(e.oid);259+ }260+ if (markdown) {261+ const src = page(u.blobSource(b.name, e.path).path, `${e.path} (source) - ${b.name} - ${repo.slug}`, 'files', { ...info, markdown: false, isSource: true });262+ if (want(src.permalink, `source ${key}`)) {263+ pages.blobs.push(src);264+ wantBlobs.add(e.oid);265+ }266+ }267+ if (image && want(u.raw(b.name, e.path).path, `raw ${e.oid}`)) {268+ raw.push({ path: u.raw(b.name, e.path).path, oid: e.oid });269+ wantBlobs.add(e.oid);270+ }271+ }272+ const list = tree.blobs.map((e) => e.path).join('\n') + '\n';273+ if (want(u.files(b.name).path, `files ${fingerprint(list)}`)) raw.push({ path: u.files(b.name).path, data: Buffer.from(list) });274+ }275+276+ const diffFor = new Set<Commit>();277+ for (const c of repo.commits.values()) {278+ const p = page(u.commit(c.sha).path, `${c.subject} - ${repo.slug}`, 'log', { commit: c });279+ if (want(p.permalink, `commit ${c.sha} ${ciKey(c.ci)} ${labelKey(c)} ${c.reviews.map((r) => `${r.id}:${r.title}`).join(',')}`)) {280+ pages.commits.push(p);281+ diffFor.add(c);282+ }283+ }284+ add(pages.refs, page(u.refs.path, `Refs - ${repo.slug}`, 'refs', { branches: repo.branches, tags: repo.tags }));285+ add(pages.releases, page(`${u.home.path}releases/`, `Releases - ${repo.slug}`, 'refs', { releases: releases(repo, cache.ranges) }));286+287+ const groups = (['open', 'draft', 'merged', 'closed'] as const).map((status) => ({ status, items: nest(repo.reviews.filter((r) => r.status === status)) })).filter((g) => g.items.length);288+ add(pages.reviewLists, page(u.reviews.path, `Reviews - ${repo.slug}`, 'reviews', { groups, total: repo.reviews.length }));289+ // Jev's stored answers on review pages (lib/jev-reviews.ts). Live reviews290+ // get answers after each push, so their pages also key on the store.291+ annotateReviews(repo.dir, repo.reviews);292+ const answers = answersStamp(repo.dir);293+ for (const r of repo.reviews) {294+ const jev = r.status === 'open' || r.status === 'draft' ? answers : '';295+ const stack = stackOf(r);296+ const key = fingerprint({297+ id: r.id, status: r.status, title: r.title, branch: r.branch, target: r.target, author: r.author, updated: r.updated,298+ revs: r.revisions.map((v) => [v.n, v.sha, v.ts, v.user, v.view]),299+ layer: r.layer.map((c) => c.sha + ciKey(c.ci)),300+ previews: r.previews.map((v) => [v.name, v.url, v.history[0]?.source]),301+ stack: stack.map((x) => [x.review.id, x.review.status, x.review.title, x.review.branch, x.review.layer.length, x.review.ci, x.depth]),302+ live: r.live,303+ discussion: [r.opts.description, r.comments.map((c) => [c.n, c.rev, c.outdated]), r.approvals.map((a) => [a.user, a.rev, a.current])],304+ jev,305+ });306+ const p = page(u.review(r.id).path, `#${r.id} ${r.title} - ${repo.slug}`, 'reviews', { review: r, stack, stackAt: stack.findIndex((x) => x.review === r) });307+ if (want(p.permalink, `review ${key}`)) {308+ pages.reviews.push(p);309+ for (const c of r.layer) diffFor.add(c);310+ // Line comments link to the combined diff when their line is in it.311+ if (r.comments.some((c) => c.path && !c.outdated)) markInDiff(r, (r.combined ??= repo.combinedDiff(r)));312+ }313+ const d = page(u.reviewDiff(r.id).path, `#${r.id} combined diff - ${repo.slug}`, 'reviews', { review: r });314+ const inlined = r.comments.filter((c) => c.path && !c.outdated).map((c) => c.n).join(',');315+ if (want(d.permalink, `combined ${r.combinedBase} ${r.tip} ${r.live ? r.branch : ''} ${inlined} ${jev}`)) {316+ r.combined ??= repo.combinedDiff(r);317+ pages.reviewDiffs.push(d);318+ }319+ for (const rev of r.revisions) {320+ const from = rev.prev ? r.revisions[rev.prev - 1] : null;321+ if (!rev.view || !from) continue;322+ const v = page(u.revision(r.id, rev.n).path, `#${r.id} r${rev.prev} vs r${rev.n} - ${repo.slug}`, 'reviews', { review: r, rev, from });323+ if (want(v.permalink, `revision ${r.id} ${rev.n} ${rev.sha} ${rev.view} ${fingerprint(rev.range ?? from.sha)} ${r.title} ${r.parent?.id} ${jev}`)) {324+ if (rev.view === 'diff') v.files = repo.diff(from.sha, rev.sha);325+ pages.revisions.push(v);326+ }327+ }328+ }329+ // For `forge reviews`, `checkout` and `land`, which read it over ssh.330+ const list = JSON.stringify(reviewList(repo.reviews, (id) => cfg.baseUrl + u.review(id).href), null, 1) + '\n';331+ if (want(`${u.home.path}reviews.json`, `reviews.json ${fingerprint(list)}`)) raw.push({ path: `${u.home.path}reviews.json`, data: Buffer.from(list) });332+ add(pages.sites, page(u.sites.path, `Sites - ${repo.slug}`, 'sites', { sites: repo.sites, previews: repo.previews }));333+ for (const w of repo.wiki) {334+ add(pages.wiki, page(u.wiki(w.slug).path, `${w.title} - wiki - ${repo.slug}`, 'wiki', { wiki: w, pages: repo.wiki.map((x) => ({ title: x.title, slug: x.slug, href: u.wiki(x.slug).href })) }));335+ }336+ const index = repoIndex(repo);337+ if (want(u.search.path, `search ${fingerprint(index)}`)) raw.push({ path: u.search.path, data: Buffer.from(index) });338+339+ // The heavy reads, for exactly the pages that will render.340+ repo.loadDiffs([...diffFor]);341+ repo.blobData = repo.loadBlobs([...wantBlobs]);342+ for (const r of raw) if (r.oid && !r.data) r.data = repo.blobData.get(r.oid)?.data;343+ cache.save();344+ if (live) pageCaches.push({ cache, fps: next });345+346+ const added: Record<string, number> = Object.fromEntries(Object.entries(pages).map(([k, v]): [string, number] => [k, v.length - before[k]]).filter(([, n]) => n));347+ const rendered = Object.values(added).reduce((a, b) => a + b, 0);348+ spans.count('rendered', rendered);349+ spans.count('skipped', skippedHere);350+ build.repos.push({ slug: s.slug, ms: { ...spans.ms, load: Math.round(performance.now() - t0) }, counts: { ...spans.counts, pages: rendered }, pages: added });351+ }352+ verifier.save();353+ build.dataEnd = performance.now();354+ for (const s of summaries) Object.assign(s, { counts: {}, sites: [] }, cached[s.slug]);355+ fs.mkdirSync(cfg.cacheDir, { recursive: true });356+ fs.writeFileSync(summaryCache, JSON.stringify(Object.fromEntries(summaries.map((s) => [s.slug, cached[s.slug] ?? { counts: {}, sites: [] }]))));357+358+ const owners = ownerNames.map((owner) => ({ owner, repos: summaries.filter((s) => s.owner === owner) }));359+ // Repo search on the index and owner pages.360+ raw.push({ path: '/repos.json', data: Buffer.from(reposIndex(summaries)) });361+ last = { config: cfg, repos: summaries, owners, pages, raw: raw.filter((r) => r.data), live, code, skipped, pageCaches };362+ return last;363+}364+365+interface BlobPage {366+ repo: RepoHead;367+ oid: string;368+ size: number | null;369+ path: string;370+ image: string | null;371+ isSource?: boolean;372+ branch?: { name: string };373+}374+375+// The first stacks of a nested list, whole, up to about `n` rows.376+function firstStacks<T extends { depth: number }>(list: T[], n: number): T[] {377+ const end = list.findIndex((e, i) => i >= n && e.depth === 0);378+ return end < 0 ? list : list.slice(0, end);379+}380+381+// A comment as it shows inline in the combined diff.382+function commentHtml(c: ReviewComment, repo: RepoHead, review: Review): string {383+ return `<div class="cmt" id="comment-${c.n}"><div class="cmt-head"><b>${html.esc(c.user)}</b> <a href="${repo.urls.review(review.id).href}#comment-${c.n}">${filters.ago(c.ts)}</a></div>` +384+ `<div class="markdown">${filters.reviewMarkdown(c.body, repo, review)}</div></div>`;385+}386+387+// Link a commit if it has a page, else show the short sha.388+function commitLink(sha: string, repo: RepoHead, label?: string): string {389+ const text = html.esc(label ?? String(sha).slice(0, 7));390+ return registry.get(repo.slug)?.commits.has(sha) ? `<a href="${repo.urls.commit(sha).href}">${text}</a>` : text;391+}392+393+// The entities rendered markdown uses (sanitize-html writes these).394+const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };395+const decode = (s: string): string =>396+ s.replace(/&(#x?)?(\w+);/gi, (m, num: string | undefined, e: string) => {397+ if (!num) return ENTITIES[e.toLowerCase()] ?? m;398+ const c = parseInt(e, num.length > 1 ? 16 : 10);399+ return c > 0 && c <= 0x10ffff ? String.fromCodePoint(c) : m;400+ });401+402+const MODES: Record<string, string> = { '040000': 'drwxr-xr-x', '100644': '-rw-r--r--', '100755': '-rwxr-xr-x', '120000': 'lrwxrwxrwx', '160000': 'm---------' };403+404+// Filters that return markup; setup() in render-page.ts marks their output safe.405+export const markupFilters = new Set(['ago', 'date']);406+407+// Lazy renderers, registered as Nunjucks filters (setup() in render-page.ts).408+export const filters = {409+ // `branch` links each changed file to its page on that branch, if present.410+ // With `review`, its current line comments show under their lines. With411+ // `mechanical` (review pages), files Jev judged mechanical are marked.412+ diffHtml(files: DiffFile[] | undefined, repo: RepoHead, prefix = '', branch: string | null = null, review: Review | null = null, mechanical = false): string {413+ const paths = branch ? new Set(registry.get(repo.slug)?.trees.get(branch)?.blobs.map((e) => e.path)) : null;414+ const blobHref = paths && branch ? (p: string) => (paths.has(p) ? repo.urls.blob(branch, p).href : null) : null;415+ const annotate = review ? inline(review, (c) => commentHtml(c, repo, review)) : null;416+ const dir = registry.get(repo.slug)?.dir;417+ return html.diff(files ?? [], { prefix, blobHref, annotate, mechanical: mechanical && dir ? isMechanical(dir) : null });418+ },419+ // Review descriptions (one line in the log, "\n" for line breaks) and420+ // comments are markdown, sanitized like READMEs; links resolve on the branch.421+ reviewMarkdown(text: string | null | undefined, repo: RepoHead, review: Review, escaped = false): string {422+ const r = registry.get(repo.slug);423+ if (!r || !text) return '';424+ return r.markdown(review.live ? review.branch : r.defaultBranch, '', escaped ? text.replaceAll('\\n', '\n') : text);425+ },426+ diffstatHtml(files: DiffFile[] | undefined, prefix = ''): string {427+ return html.diffstat(files ?? [], prefix);428+ },429+ blobHtml(p: BlobPage): string {430+ if (p.image && !p.isSource) return `<div class="image-view"><img src="${html.esc(p.image)}" alt="${html.esc(p.path)}"></div>`;431+ const data = registry.get(p.repo.slug)?.blobData.get(p.oid)?.data;432+ if (!data) return `<p class="empty">This file is too large to show here (${filters.size(p.size)}).</p>`;433+ if (isBinary(data)) return `<p class="empty">Binary file, ${filters.size(p.size)}.</p>`;434+ const hl = highlights.get(p.repo.slug);435+ return html.blob(data.toString('utf8'), p.path, hl && ((code, lang) => hl.highlight(p.oid, code, lang)));436+ },437+ // A markdown file rendered as a document.438+ markdownHtml(p: BlobPage): string {439+ const repo = registry.get(p.repo.slug);440+ const data = repo?.blobData.get(p.oid)?.data;441+ if (!repo || !data || !p.branch) return '';442+ return repo.markdown(p.branch.name, p.path, data.toString('utf8'));443+ },444+ // A directory's README, under its listing.445+ dirReadmeHtml(readme: { oid: string; path: string; name: string } | null, repo: RepoHead, branch: string): string {446+ const r = registry.get(repo.slug);447+ const data = readme && r?.blobData.get(readme.oid)?.data;448+ if (!r || !readme || !data || isBinary(data)) return '';449+ const text = data.toString('utf8');450+ return MARKDOWN.test(readme.name) ? r.markdown(branch, readme.path, text) : `<pre>${html.esc(text)}</pre>`;451+ },452+ // Rewritten pairs carry Jev's label for what the rewrite changed.453+ rangeDiffHtml(text: string, repo: RepoHead): string {454+ const r = registry.get(repo.slug);455+ const shas = [...(r?.commits.keys() ?? [])];456+ return html.rangeDiff(text, {457+ commitHref: (short) => {458+ const full = shas.find((s) => s.startsWith(short));459+ return full ? repo.urls.commit(full).href : null;460+ },461+ change: r ? pairChanges(r.dir, text) : null,462+ });463+ },464+ ciHtml(status: CiStatus | null, long = false): string {465+ return html.ci(status, { long });466+ },467+ date: html.date,468+ short: (sha: string | null | undefined): string => String(sha ?? '').slice(0, 7),469+ // Unix seconds (review logs) or an ISO date.470+ ago(ts: number | string | null): string {471+ if (!ts) return '';472+ return html.time(new Date(typeof ts === 'number' ? ts * 1000 : ts));473+ },474+ // A page's description, for link previews: one line of plain text, cut at475+ // a word near n characters. With html, the text of rendered markdown476+ // (markdown-it puts a newline between blocks, so tags can just go).477+ excerpt(s: string | null | undefined, html = false, n = 200): string {478+ let text = String(s ?? '');479+ if (html) text = decode(text.replace(/<[^>]*>/g, ''));480+ text = text.replace(/\s+/g, ' ').trim();481+ if (text.length <= n) return text;482+ const cut = text.lastIndexOf(' ', n);483+ return text.slice(0, cut > n / 2 ? cut : n) + '…';484+ },485+ plural: (n: number, word: string, many?: string): string => `${n} ${n === 1 ? word : many ?? word + 's'}`,486+ indent: (depth: number): string => '&nbsp;&nbsp;'.repeat(depth),487+ mode: (m: string): string => MODES[m] ?? m,488+ size(n: number | null): string {489+ if (n == null) return '';490+ if (n < 1024) return `${n}B`;491+ if (n < 1 << 20) return `${(n / 1024).toFixed(1)}K`;492+ return `${(n / (1 << 20)).toFixed(1)}M`;493+ },494+ // The header block of a commit page.495+ commitHeader(c: Commit, repo: RepoHead): string {496+ const e = html.esc;497+ const lines = [`<b>commit</b> ${commitLink(c.sha, repo, c.sha)}`];498+ for (const p of c.parents) lines.push(`<b>parent</b> ${commitLink(p, repo, p)}`);499+ lines.push(`<b>Author:</b> ${e(c.author.name)} &lt;<a href="mailto:${e(c.author.email)}">${e(c.author.email)}</a>&gt;`);500+ lines.push(`<b>Date:</b> ${e(c.author.date)}`);501+ if (c.ci) {502+ const via = c.ci.latest.via ? ` <span class="desc">(same tree as ${commitLink(c.ci.latest.via, repo)})</span>` : '';503+ lines.push(`<b>CI:</b> ${html.ci(c.ci, { long: true })}${via}`);504+ }505+ for (const r of c.reviews) lines.push(`<b>Review:</b> <a href="${repo.urls.review(r.id).href}">#${r.id} ${e(r.title)}</a>`);506+ lines.push('', e(c.subject));507+ if (c.body) lines.push('', e(c.body));508+ return `<pre class="meta">${lines.join('\n')}</pre>`;509+ },510+ commitLink,511+};
A lib/git.ts +68 -0

Not shown: this page already shows 6000 lines of diff.

A lib/highlight.ts +136 -0

Not shown: this page already shows 6000 lines of diff.

A lib/hosts.ts +186 -0

Not shown: this page already shows 6000 lines of diff.

A lib/jev-commits.ts +374 -0

Not shown: this page already shows 6000 lines of diff.

A lib/jev-fill.ts +59 -0

Not shown: this page already shows 6000 lines of diff.

A lib/jev-reviews.ts +418 -0

Not shown: this page already shows 6000 lines of diff.

A lib/jev-secrets.ts +373 -0

Not shown: this page already shows 6000 lines of diff.

A lib/jev.ts +190 -0

Not shown: this page already shows 6000 lines of diff.

A lib/markdown.ts +118 -0

Not shown: this page already shows 6000 lines of diff.

A lib/metrics-report.ts +56 -0

Not shown: this page already shows 6000 lines of diff.

A lib/metrics.ts +97 -0

Not shown: this page already shows 6000 lines of diff.

A lib/pages-server.ts +358 -0

Not shown: this page already shows 6000 lines of diff.

A lib/render-page.ts +186 -0

Not shown: this page already shows 6000 lines of diff.

A lib/render.ts +205 -0

Not shown: this page already shows 6000 lines of diff.

A lib/repo.ts +332 -0

Not shown: this page already shows 6000 lines of diff.

A lib/review-list.ts +92 -0

Not shown: this page already shows 6000 lines of diff.

A lib/reviews.ts +162 -0

Not shown: this page already shows 6000 lines of diff.

A lib/search.ts +33 -0

Not shown: this page already shows 6000 lines of diff.

A lib/sites.ts +62 -0

Not shown: this page already shows 6000 lines of diff.

A lib/types.ts +380 -0

Not shown: this page already shows 6000 lines of diff.

A lib/urls.ts +64 -0

Not shown: this page already shows 6000 lines of diff.

A package-lock.json +3296 -0

Not shown: this page already shows 6000 lines of diff.

A package.json +27 -0

Not shown: this page already shows 6000 lines of diff.

A server/Caddyfile.example +27 -0

Not shown: this page already shows 6000 lines of diff.

A server/container/forge-admin +9 -0

Not shown: this page already shows 6000 lines of diff.

A server/container/sshd.conf +11 -0

Not shown: this page already shows 6000 lines of diff.

A server/container/start.sh +148 -0

Not shown: this page already shows 6000 lines of diff.

A server/provision.sh +113 -0

Not shown: this page already shows 6000 lines of diff.

A site/_includes/base.njk +72 -0

Not shown: this page already shows 6000 lines of diff.

A site/_includes/discussion.njk +50 -0

Not shown: this page already shows 6000 lines of diff.

A site/_includes/help.njk +18 -0

Not shown: this page already shows 6000 lines of diff.

A site/_includes/logo.njk +1 -0

Not shown: this page already shows 6000 lines of diff.

A site/_includes/macros.njk +119 -0

Not shown: this page already shows 6000 lines of diff.

A site/_includes/search.njk +32 -0

Not shown: this page already shows 6000 lines of diff.

A site/assets/diffs.ts +174 -0

Not shown: this page already shows 6000 lines of diff.

A site/assets/fixi.js +91 -0

Not shown: this page already shows 6000 lines of diff.

A site/assets/keys.ts +179 -0

Not shown: this page already shows 6000 lines of diff.

A site/assets/live.ts +60 -0

Not shown: this page already shows 6000 lines of diff.

A site/assets/logo.svg +1 -0

Not shown: this page already shows 6000 lines of diff.

A site/assets/search.ts +240 -0

Not shown: this page already shows 6000 lines of diff.

A site/assets/ssexi.js +90 -0

Not shown: this page already shows 6000 lines of diff.

A site/assets/style.css +359 -0

Not shown: this page already shows 6000 lines of diff.

A site/assets/ui.ts +97 -0

Not shown: this page already shows 6000 lines of diff.

A site/index.njk +17 -0

Not shown: this page already shows 6000 lines of diff.

A site/owner.njk +23 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/atom.njk +22 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/blob.njk +21 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/branches.njk +8 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/commit.njk +42 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/home.njk +33 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/log.njk +10 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/refs.njk +32 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/releases.njk +30 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/repo.11tydata.json +4 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/review-diff.njk +33 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/review.njk +130 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/reviews.njk +43 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/revision.njk +46 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/sites.njk +41 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/tree.njk +16 -0

Not shown: this page already shows 6000 lines of diff.

A site/repo/wiki.njk +27 -0

Not shown: this page already shows 6000 lines of diff.

A site/site.11tydata.json +3 -0

Not shown: this page already shows 6000 lines of diff.

A tsconfig.json +20 -0

Not shown: this page already shows 6000 lines of diff.