Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-11-12 23:41:27 +0300
committerLeitwolf112026-11-12 23:41:27 +0300
commit83e0ad4586d90bf02c3a5bfeb1ab92fd90e51703 (patch)
tree41c12c9a54223df2d681554679408307282319fd
parent80d46cbffc87da9bf2665935e563ab556c1a267d (diff)
downloadprogmaweb-ops/git-mirrors.tar.gz
Add read-only Git mirrors on git.progmasoft.comops/git-mirrors
git.progmasoft.com shows the public repositories under development (tazel, tazelish, tazel.net, catch3, progmasoft-cpp, progmaweb, website) as read-only mirrors with cgit, and serves clones over smart HTTP, such as git clone https://git.progmasoft.com/tazel. GitHub stays where development happens; private and archived repositories are not mirrored. ops/git holds what runs on the server: - cgitrc, the header, footer and stylesheet: the look of progmasoft.com (its tokens, the four-tile brand with "Git" beside it, the dark footer), light like its default, no scripts, no rendered READMEs. - git-mirror.sh with git-mirror.service/.timer: every ten minutes, as gitmirror, branches and tags only from GitHub, pruned, the default branch followed, repacked with a bitmap when something changed. - fcgiwrap@cgit drop-ins: cgit and git-http-backend run as cgit with systemd sandboxing (no network, read-only system, exposure 0.7), on a socket only nginx can open. - selinux/progmasoft_cgit.te: fcgiwrap runs in httpd_t and the two CGIs move into git_script_t; the module allows only that move under the NoNewPrivileges that the sandboxing sets. - nginx/git.conf: HSTS, a content security policy without scripts, per-address request and connection limits, 403 for receive-pack and the dumb protocol, buffered clones, and the inline handlers of cgit's menus removed. ops/README.md describes the files, the labels, and why the name is not behind the Cloudflare proxy: through it a clone of catch3 never reached the client. Deployed and checked live: every page answers 200, unknown ones 404, full clones of tazel, progmaweb and catch3 work, a push answers 403, no SELinux denials, no console errors, no horizontal scroll at 390 px.
-rw-r--r--ops/README.md46
-rw-r--r--ops/git/cgitrc77
-rw-r--r--ops/git/git-mirror.sh54
-rw-r--r--ops/git/include/footer.html6
-rw-r--r--ops/git/include/head.html1
-rw-r--r--ops/git/include/header.html19
-rw-r--r--ops/git/nginx/git.conf113
-rw-r--r--ops/git/repositories9
-rw-r--r--ops/git/selinux/progmasoft_cgit.te18
-rw-r--r--ops/git/static/icon.svg7
-rw-r--r--ops/git/static/progmasoft-git.css716
-rw-r--r--ops/git/systemd/fcgiwrap@cgit.service.d/hardening.conf37
-rw-r--r--ops/git/systemd/fcgiwrap@cgit.service.d/processes.conf8
-rw-r--r--ops/git/systemd/fcgiwrap@cgit.socket.d/nginx.conf7
-rw-r--r--ops/git/systemd/git-mirror.service44
-rw-r--r--ops/git/systemd/git-mirror.timer13
16 files changed, 1175 insertions, 0 deletions
diff --git a/ops/README.md b/ops/README.md
index 0457499..9022227 100644
--- a/ops/README.md
+++ b/ops/README.md
@@ -296,3 +296,49 @@ ssh root@91.151.88.130 'chown -R root:root /srv/maven && restorecon -R /srv/mave
```
Published versions are never changed. The certificate of `progmasoft.com` must name `maven.progmasoft.com`.
+
+## Git mirrors
+
+`git.progmasoft.com` shows read-only mirrors of the public repositories under development, such as
+`https://git.progmasoft.com/tazel`. GitHub stays where development happens: issues, pull requests and CI, and the source
+links of the sites. Private and archived repositories are not mirrored. The files are in `ops/git`:
+
+| File | Installed as |
+| --- | --- |
+| `cgitrc` | `/etc/cgitrc`: the repositories, their sections and descriptions, and the look |
+| `include/head.html`, `header.html`, `footer.html` | `/etc/cgit/progmasoft/`: the header and footer of progmasoft.com |
+| `static/progmasoft-git.css`, `static/icon.svg` | `/srv/git/www/static/`: the tokens and brand of `apps/web/app/globals.css` |
+| `repositories` | `/etc/git-mirror/repositories`: what is mirrored, one name per line |
+| `git-mirror.sh` | `/usr/local/libexec/git-mirror`, labelled `bin_t` |
+| `systemd/git-mirror.service`, `.timer` | `/etc/systemd/system/`: every ten minutes, as `gitmirror` |
+| `systemd/fcgiwrap@cgit.*.d/` | `/etc/systemd/system/`: cgit and git-http-backend as `cgit`, socket for `nginx` only |
+| `selinux/progmasoft_cgit.te` | the module `progmasoft_cgit` |
+| `nginx/git.conf` | `/etc/nginx/conf.d/git.conf` |
+
+Packages: `git-core`, and `cgit` and `fcgiwrap` from EPEL; `checkpolicy` builds the module.
+
+- **Mirrors.** `git-mirror` fetches only branches and tags from `https://github.com/Progmasoft/<name>.git` (never the
+ pull-request refs), prunes what GitHub removed, follows its default branch, and repacks into one pack with a bitmap
+ when something changed, so a clone starts sending at once. It needs no credentials. A new repository needs a line in
+ `repositories` and a `repo.url` block in `cgitrc`.
+- **Read only.** Nginx answers 403 to `git-receive-pack` and to the dumb protocol; git-http-backend gets no user; the
+ mirrors belong to `gitmirror` and are read as `cgit`, trusted through `safe.directory` for that backend only.
+- **SELinux.** `/srv/git/mirrors` is `git_sys_content_t` and `/var/cache/cgit` `git_rw_content_t` (package default).
+ `/usr/bin/fcgiwrap` is `httpd_exec_t`, so fcgiwrap runs in `httpd_t` and Nginx may connect to it, and
+ `git-http-backend` is `git_script_exec_t` like cgit, so both move into `git_script_t`. systemd's sandboxing sets
+ NoNewPrivileges; the module `progmasoft_cgit` allows exactly that one move under it.
+
+ ```sh
+ semanage fcontext -a -t git_sys_content_t '/srv/git/mirrors(/.*)?'
+ semanage fcontext -a -t bin_t /usr/local/libexec/git-mirror
+ semanage fcontext -a -t httpd_exec_t /usr/bin/fcgiwrap
+ semanage fcontext -a -t git_script_exec_t /usr/libexec/git-core/git-http-backend
+ restorecon -RF /srv/git /usr/local/libexec/git-mirror /usr/sbin/fcgiwrap /usr/libexec/git-core/git-http-backend
+ ```
+
+- **No Cloudflare proxy.** `git.progmasoft.com` is a DNS-only A record: through the proxy, a clone of a large repository
+ (catch3, about 25 MB) never reached the client, while the origin served it in under a minute. The address of the server
+ is public through `mail.progmasoft.com` anyway; Nginx limits requests and connections per address itself.
+- **Look.** No README is rendered (cgit would include its HTML unfiltered) and no script runs: the content security
+ policy allows none, and Nginx removes the inline `onchange` handlers of cgit's menus, whose buttons do the same.
+- **Certificate.** The certificate of `progmasoft.com` names `git.progmasoft.com` (webroot, `--expand`).
diff --git a/ops/git/cgitrc b/ops/git/cgitrc
new file mode 100644
index 0000000..5da9211
--- /dev/null
+++ b/ops/git/cgitrc
@@ -0,0 +1,77 @@
+# SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+# cgit of https://git.progmasoft.com: read-only mirrors of the public repositories of Progmasoft
+# that are under development. Development happens on GitHub; git-mirror.timer fetches from there.
+# Installed as /etc/cgitrc.
+
+root-title=Progmasoft Git
+root-desc=Read-only mirrors of the repositories of Progmasoft. Issues and pull requests are on GitHub.
+virtual-root=/
+remove-suffix=1
+
+# The look of progmasoft.com: its stylesheet, header and footer.
+css=/static/progmasoft-git.css
+favicon=/static/icon.svg
+logo=
+head-include=/etc/cgit/progmasoft/head.html
+header=/etc/cgit/progmasoft/header.html
+footer=/etc/cgit/progmasoft/footer.html
+
+# Clones go to git-http-backend (smart HTTP, upload-pack only); cgit's own dumb HTTP stays off.
+enable-http-clone=0
+clone-url=https://git.progmasoft.com/$CGIT_REPO_URL
+
+# No README is rendered: without a filter cgit would include its HTML as it is.
+enable-index-owner=0
+enable-index-links=0
+enable-blame=0
+enable-commit-graph=0
+enable-log-filecount=1
+enable-log-linecount=0
+noplainemail=1
+max-stats=
+snapshots=tar.gz
+side-by-side-diffs=0
+robots=index, nofollow
+
+cache-root=/var/cache/cgit
+cache-size=1000
+
+section=Tazel
+repo.url=tazel
+repo.path=/srv/git/mirrors/tazel.git
+repo.desc=A build system and package manager for C, C++, Objective-C, Objective-C++ and Zig, with Kotlin project files.
+repo.homepage=https://github.com/Progmasoft/tazel
+
+repo.url=tazelish
+repo.path=/srv/git/mirrors/tazelish.git
+repo.desc=Installs Tazel and chooses which version of it runs.
+repo.homepage=https://github.com/Progmasoft/tazelish
+
+repo.url=tazel.net
+repo.path=/srv/git/mirrors/tazel.net.git
+repo.desc=The site of Tazel, tazel.net.
+repo.homepage=https://github.com/Progmasoft/tazel.net
+
+section=C++
+repo.url=catch3
+repo.path=/srv/git/mirrors/catch3.git
+repo.desc=Progmasoft Catch3: an independently maintained C++17 test framework with a parallel runner, property checks, snapshots and structured results.
+repo.homepage=https://github.com/Progmasoft/catch3
+
+repo.url=progmasoft-cpp
+repo.path=/srv/git/mirrors/progmasoft-cpp.git
+repo.desc=The C++ style guide of Progmasoft: naming, formatting, language rules, errors, ownership, ABI, testing and tooling.
+repo.homepage=https://github.com/Progmasoft/progmasoft-cpp
+
+section=Sites
+repo.url=progmaweb
+repo.path=/srv/git/mirrors/progmaweb.git
+repo.desc=The site and account service of Progmasoft: a Next.js frontend and an ASP.NET Core API.
+repo.homepage=https://github.com/Progmasoft/progmaweb
+
+repo.url=website
+repo.path=/srv/git/mirrors/website.git
+repo.desc=The site of tala.run: a Vue frontend and an ASP.NET Core API.
+repo.homepage=https://github.com/Progmasoft/website
diff --git a/ops/git/git-mirror.sh b/ops/git/git-mirror.sh
new file mode 100644
index 0000000..25bed02
--- /dev/null
+++ b/ops/git/git-mirror.sh
@@ -0,0 +1,54 @@
+#!/bin/sh
+# SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+# Mirrors the repositories of /etc/git-mirror/repositories from https://github.com/Progmasoft into
+# /srv/git/mirrors/<name>.git: branches and tags only, never the pull-request refs of GitHub, with
+# what GitHub removed removed here too. Runs as gitmirror from git-mirror.service; installed as
+# /usr/local/libexec/git-mirror.
+set -eu
+
+list=${GIT_MIRROR_LIST:-/etc/git-mirror/repositories}
+root=${GIT_MIRROR_ROOT:-/srv/git/mirrors}
+status=0
+
+# A for loop, not a pipe: a pipe would run the loop in a subshell and lose the status.
+names=$(grep -Ev '^[[:space:]]*(#|$)' "$list")
+for name in $names; do
+ case "$name" in
+ *[!A-Za-z0-9._-]* | .* ) echo "git-mirror: skipping the name '$name'" >&2; status=1; continue ;;
+ esac
+ repository="$root/$name.git"
+ if [ ! -d "$repository" ]; then
+ git init --quiet --bare "$repository.new"
+ git -C "$repository.new" config remote.origin.url "https://github.com/Progmasoft/$name.git"
+ git -C "$repository.new" config remote.origin.fetch '+refs/heads/*:refs/heads/*'
+ git -C "$repository.new" config --add remote.origin.fetch '+refs/tags/*:refs/tags/*'
+ git -C "$repository.new" config remote.origin.tagOpt --no-tags
+ git -C "$repository.new" config http.receivepack false
+ mv "$repository.new" "$repository"
+ fi
+ # A clone is served from one pack with a bitmap, so it starts sending at once.
+ git -C "$repository" config repack.writeBitmaps true
+ git -C "$repository" config pack.writeBitmapHashCache true
+ git -C "$repository" config gc.auto 0
+ mkdir -p "$repository/info/web"
+ before=$(git -C "$repository" for-each-ref --format="%(objectname) %(refname)" | sha256sum)
+ if git -C "$repository" fetch --quiet --prune origin; then
+ after=$(git -C "$repository" for-each-ref --format="%(objectname) %(refname)" | sha256sum)
+ if [ "$before" != "$after" ] || [ ! -f "$repository/info/web/repacked" ]; then
+ git -C "$repository" repack -a -d -b -q
+ touch "$repository/info/web/repacked"
+ fi
+ # HEAD follows the default branch of GitHub.
+ head=$(git -C "$repository" ls-remote --symref origin HEAD | sed -n 's|^ref: refs/heads/\([^[:space:]]*\)[[:space:]]*HEAD$|\1|p')
+ [ -n "$head" ] && git -C "$repository" symbolic-ref HEAD "refs/heads/$head"
+ # cgit shows the age of the last change of a repository from this file.
+ git -C "$repository" for-each-ref --sort=-committerdate --count=1 \
+ --format='%(committerdate:iso8601)' refs/heads > "$repository/info/web/last-modified"
+ else
+ echo "git-mirror: fetching $name failed" >&2
+ status=1
+ fi
+done
+exit "$status"
diff --git a/ops/git/include/footer.html b/ops/git/include/footer.html
new file mode 100644
index 0000000..3e4f5f1
--- /dev/null
+++ b/ops/git/include/footer.html
@@ -0,0 +1,6 @@
+<footer class="site-footer">
+ <div class="shell footer-bottom">
+ <span>© 2026 Progmasoft</span>
+ <span>Mirrored from GitHub every ten minutes.</span>
+ </div>
+</footer>
diff --git a/ops/git/include/head.html b/ops/git/include/head.html
new file mode 100644
index 0000000..2376219
--- /dev/null
+++ b/ops/git/include/head.html
@@ -0,0 +1 @@
+<meta name="theme-color" content="#0066b8">
diff --git a/ops/git/include/header.html b/ops/git/include/header.html
new file mode 100644
index 0000000..8b14244
--- /dev/null
+++ b/ops/git/include/header.html
@@ -0,0 +1,19 @@
+<header class="site-header">
+ <div class="shell header-inner">
+ <a class="brand" href="https://git.progmasoft.com/">
+ <span class="brand-mark" aria-hidden="true">
+ <span class="brand-tile brand-tile-slate"></span>
+ <span class="brand-tile brand-tile-teal"></span>
+ <span class="brand-tile brand-tile-violet"></span>
+ <span class="brand-tile brand-tile-orange"></span>
+ </span>
+ <strong class="brand-name">Progmasoft</strong>
+ <span class="brand-product">Git</span>
+ </a>
+ <nav class="primary-nav" aria-label="Primary">
+ <a href="https://progmasoft.com/">Progmasoft</a>
+ <a class="button button-small" href="https://github.com/Progmasoft">GitHub</a>
+ </nav>
+ </div>
+</header>
+<p class="shell mirror-note">Read-only mirrors. Development, issues and pull requests are on GitHub.</p>
diff --git a/ops/git/nginx/git.conf b/ops/git/nginx/git.conf
new file mode 100644
index 0000000..c11dce4
--- /dev/null
+++ b/ops/git/nginx/git.conf
@@ -0,0 +1,113 @@
+# SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+# https://git.progmasoft.com: cgit over read-only mirrors, and clones over smart HTTP with
+# git-http-backend (upload-pack only). Installed as /etc/nginx/conf.d/git.conf.
+
+limit_req_zone $binary_remote_addr zone=git_pages:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=git_clones:10m rate=1r/s;
+limit_conn_zone $binary_remote_addr zone=git_connections:10m;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name git.progmasoft.com;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/lib/letsencrypt;
+ default_type text/plain;
+ }
+
+ location / {
+ return 308 https://git.progmasoft.com$request_uri;
+ }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name git.progmasoft.com;
+
+ ssl_certificate /etc/letsencrypt/live/progmasoft.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/progmasoft.com/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+
+ # Nothing is uploaded here: a clone sends only the list of what it wants.
+ client_max_body_size 1m;
+ limit_conn git_connections 8;
+
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "DENY" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+ # cgit writes no scripts; its diffstat bars set their width in style attributes.
+ add_header Content-Security-Policy "default-src 'none'; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" always;
+
+ location ^~ /static/ {
+ alias /srv/git/www/static/;
+ expires 1d;
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Content-Security-Policy "default-src 'none'; style-src 'self'" always;
+ }
+
+ location = /robots.txt {
+ default_type text/plain;
+ return 200 "User-agent: *\nDisallow: /*/snapshot/\nDisallow: /*/log/\nDisallow: /*/diff/\nDisallow: /*/patch/\n";
+ }
+
+ # Pushing is never possible: no receive-pack, and the dumb protocol is off.
+ location ~ ^/[A-Za-z0-9._-]+/git-receive-pack$ {
+ return 403;
+ }
+
+ location ~ ^/(?<git_repo>[A-Za-z0-9._-]+?)(?:\.git)?/(?<git_path>info/refs|git-upload-pack)$ {
+ set $git_service "";
+ if ($git_path = "info/refs") {
+ set $git_service $arg_service;
+ }
+ if ($git_path = "git-upload-pack") {
+ set $git_service "git-upload-pack";
+ }
+ if ($git_service != "git-upload-pack") {
+ return 403;
+ }
+ limit_req zone=git_clones burst=10 nodelay;
+
+ fastcgi_pass unix:/run/fcgiwrap/fcgiwrap-cgit.sock;
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /usr/libexec/git-core/git-http-backend;
+ fastcgi_param GIT_PROJECT_ROOT /srv/git/mirrors;
+ fastcgi_param GIT_HTTP_EXPORT_ALL "";
+ fastcgi_param PATH_INFO /$git_repo.git/$git_path;
+ fastcgi_param REMOTE_USER "";
+ # The mirrors belong to gitmirror and are read as cgit; only this backend trusts them.
+ fastcgi_param GIT_CONFIG_COUNT 1;
+ fastcgi_param GIT_CONFIG_KEY_0 safe.directory;
+ fastcgi_param GIT_CONFIG_VALUE_0 /srv/git/mirrors/*;
+ fastcgi_read_timeout 300s;
+ # Nginx buffers the pack (to a file when it is large), so a slow reader never stalls
+ # git-http-backend behind fcgiwrap.
+ fastcgi_buffering on;
+ fastcgi_max_temp_file_size 2048m;
+ }
+
+ location / {
+ limit_req zone=git_pages burst=40 nodelay;
+ # The menus of cgit (branch, diff options) submit themselves from inline handlers, which the
+ # policy above blocks; their buttons ("switch", "reload") do the same without a script.
+ sub_filter "" "";
+ sub_filter_once off;
+
+ fastcgi_pass unix:/run/fcgiwrap/fcgiwrap-cgit.sock;
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /var/www/cgi-bin/cgit;
+ fastcgi_param CGIT_CONFIG /etc/cgitrc;
+ fastcgi_param PATH_INFO $uri;
+ fastcgi_param QUERY_STRING $args;
+ fastcgi_param HTTP_HOST $server_name;
+ fastcgi_read_timeout 60s;
+ }
+}
diff --git a/ops/git/repositories b/ops/git/repositories
new file mode 100644
index 0000000..bdd0a89
--- /dev/null
+++ b/ops/git/repositories
@@ -0,0 +1,9 @@
+# The repositories git-mirror.service mirrors from https://github.com/Progmasoft, one per line.
+# Each must also have a repo.url entry in cgitrc. Installed as /etc/git-mirror/repositories.
+tazel
+tazelish
+tazel.net
+catch3
+progmasoft-cpp
+progmaweb
+website
diff --git a/ops/git/selinux/progmasoft_cgit.te b/ops/git/selinux/progmasoft_cgit.te
new file mode 100644
index 0000000..d4d5d75
--- /dev/null
+++ b/ops/git/selinux/progmasoft_cgit.te
@@ -0,0 +1,18 @@
+# SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+# fcgiwrap@cgit runs in httpd_t under NoNewPrivileges, which systemd sets for its sandboxing.
+# This lets cgit and git-http-backend move from there into git_script_t, the narrower domain the
+# policy gives them; nothing else is allowed. Built and loaded with:
+# checkmodule -M -m -o progmasoft_cgit.mod progmasoft_cgit.te
+# semodule_package -o progmasoft_cgit.pp -m progmasoft_cgit.mod
+# semodule -i progmasoft_cgit.pp
+module progmasoft_cgit 1.0;
+
+require {
+ type httpd_t;
+ type git_script_t;
+ class process2 nnp_transition;
+}
+
+allow httpd_t git_script_t:process2 nnp_transition;
diff --git a/ops/git/static/icon.svg b/ops/git/static/icon.svg
new file mode 100644
index 0000000..c7c9ead
--- /dev/null
+++ b/ops/git/static/icon.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="100 100 312 312">
+ <title>Progmasoft</title>
+ <rect x="112" y="112" width="132" height="132" fill="#3b4658"/>
+ <rect x="268" y="112" width="132" height="132" fill="#1e7188"/>
+ <rect x="112" y="268" width="132" height="132" fill="#7655fa"/>
+ <rect x="268" y="268" width="132" height="132" fill="#dc7644"/>
+</svg>
diff --git a/ops/git/static/progmasoft-git.css b/ops/git/static/progmasoft-git.css
new file mode 100644
index 0000000..11f765c
--- /dev/null
+++ b/ops/git/static/progmasoft-git.css
@@ -0,0 +1,716 @@
+/* SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> */
+/* SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 */
+
+/* The look of progmasoft.com for cgit: the same tokens, brand, header and footer as
+ apps/web/app/globals.css, applied to the markup cgit writes. Light, like the default of
+ progmasoft.com; this site keeps no preferences. */
+
+:root {
+ --blue-700: #004e8c;
+ --blue-600: #0066b8;
+ --blue-500: #0078d4;
+ --blue-100: #dff0ff;
+ --green-600: #16825d;
+ --ink-950: #121417;
+ --ink-800: #2a2f35;
+ --ink-650: #4c545d;
+ --ink-500: #68717c;
+ --line: #d9dee4;
+ --line-strong: #b9c1ca;
+ --surface: #ffffff;
+ --surface-muted: #f5f7f9;
+ --surface-blue: #f1f8ff;
+ --header-surface: rgb(255 255 255 / 94%);
+ --danger: #b42318;
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --shell: 1180px;
+ --mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
+ color-scheme: light;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--surface);
+ color: var(--ink-950);
+ font-family:
+ "Segoe UI Variable",
+ "Segoe UI",
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ sans-serif;
+ font-size: 15px;
+ line-height: 1.5;
+ text-rendering: optimizeLegibility;
+ /* The header and footer reach the edges of the window from inside div#cgit. */
+ overflow-x: hidden;
+}
+
+a {
+ color: var(--blue-600);
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+:focus-visible {
+ outline: 3px solid #ffbf47;
+ outline-offset: 3px;
+}
+
+.shell {
+ width: min(calc(100% - 40px), var(--shell));
+ margin-inline: auto;
+}
+
+/* ----- Header and footer, as on progmasoft.com ----- */
+
+.site-header {
+ min-height: 68px;
+ border-bottom: 1px solid var(--line);
+ background: var(--header-surface);
+}
+
+.header-inner {
+ min-height: 68px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 11px;
+ color: var(--ink-950);
+}
+
+.brand:hover {
+ text-decoration: none;
+}
+
+.brand-mark {
+ width: 24px;
+ height: 24px;
+ display: grid;
+ flex: 0 0 auto;
+ grid-template-columns: repeat(2, 1fr);
+ grid-template-rows: repeat(2, 1fr);
+ gap: 2px;
+}
+
+.brand-tile {
+ display: block;
+}
+
+.brand-tile-slate {
+ background: #3b4658;
+}
+
+.brand-tile-teal {
+ background: #1e7188;
+}
+
+.brand-tile-violet {
+ background: #7655fa;
+}
+
+.brand-tile-orange {
+ background: #dc7644;
+}
+
+.brand-name {
+ color: #8e0000;
+ font-size: 20px;
+ font-weight: 650;
+ letter-spacing: -0.02em;
+ line-height: 1;
+}
+
+.brand-product {
+ border-left: 2px solid var(--ink-800);
+ margin-left: 5px;
+ padding-left: 16px;
+ color: var(--ink-800);
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.primary-nav {
+ display: flex;
+ align-items: center;
+ gap: 28px;
+}
+
+.primary-nav a {
+ color: var(--ink-800);
+ font-weight: 600;
+}
+
+.primary-nav a:hover {
+ color: var(--blue-600);
+ text-decoration: none;
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ border-radius: var(--radius-sm);
+ background: var(--blue-600);
+ color: #fff;
+ font-weight: 700;
+}
+
+.primary-nav a.button,
+.primary-nav a.button:hover {
+ color: #fff;
+}
+
+.button:hover {
+ background: var(--blue-700);
+ text-decoration: none;
+}
+
+.button-small {
+ min-height: 36px;
+ padding: 7px 14px;
+ font-size: 14px;
+}
+
+.mirror-note {
+ margin-block: 18px 0;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ padding: 10px 14px;
+ background: var(--surface-blue);
+ color: var(--ink-650);
+ font-size: 14px;
+}
+
+.site-footer {
+ margin-top: 56px;
+ border-top: 1px solid #343a40;
+ background: #20252a;
+ color: #d8dde2;
+}
+
+.footer-bottom {
+ min-height: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ font-size: 14px;
+}
+
+/* ----- The page cgit writes ----- */
+
+div#cgit {
+ width: min(calc(100% - 40px), var(--shell));
+ margin-inline: auto;
+}
+
+/* cgit writes the header before div#cgit and the footer inside it; the footer still reaches the
+ edges of the window. */
+div#cgit > .site-footer {
+ margin-inline: calc(50% - 50vw);
+ padding-inline: calc(50vw - 50%);
+}
+
+table#header {
+ width: 100%;
+ margin-top: 28px;
+ border-collapse: collapse;
+}
+
+table#header td.logo {
+ display: none;
+}
+
+table#header td.main {
+ color: var(--ink-950);
+ font-size: 30px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+
+table#header td.main a {
+ color: var(--ink-950);
+}
+
+table#header td.sub {
+ padding-top: 4px;
+ color: var(--ink-650);
+ font-size: 16px;
+}
+
+table#header td.form {
+ text-align: right;
+ vertical-align: middle;
+}
+
+table#header td.form form {
+ display: inline-flex;
+ gap: 6px;
+ align-items: center;
+}
+
+table#header td.form select {
+ max-width: 260px;
+}
+
+select,
+input[type="search"],
+input[type="text"],
+input[type="submit"] {
+ min-height: 34px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 5px 9px;
+ background: var(--surface);
+ color: var(--ink-800);
+ font: inherit;
+ font-size: 14px;
+}
+
+input[type="submit"] {
+ border-color: var(--blue-600);
+ background: var(--blue-600);
+ color: #fff;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+input[type="submit"]:hover {
+ background: var(--blue-700);
+}
+
+table.tabs {
+ width: 100%;
+ margin-top: 22px;
+ border-collapse: collapse;
+ border-bottom: 1px solid var(--line);
+}
+
+table.tabs td {
+ padding: 0;
+ vertical-align: bottom;
+}
+
+table.tabs td a {
+ display: inline-block;
+ margin-right: 22px;
+ border-bottom: 3px solid transparent;
+ padding: 10px 0 9px;
+ color: var(--ink-650);
+ font-weight: 600;
+}
+
+table.tabs td a.active,
+table.tabs td a:hover {
+ border-bottom-color: var(--blue-600);
+ color: var(--ink-950);
+ text-decoration: none;
+}
+
+table.tabs td.form {
+ padding-bottom: 8px;
+ text-align: right;
+}
+
+table.tabs td.form form {
+ display: inline-flex;
+ gap: 6px;
+}
+
+div.path {
+ margin-top: 14px;
+ color: var(--ink-650);
+ font-family: var(--mono);
+ font-size: 13px;
+}
+
+div.content {
+ margin-top: 20px;
+ overflow-x: auto;
+}
+
+div.cgit-panel {
+ float: right;
+ margin: 0 0 14px 18px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ padding: 10px 12px;
+ background: var(--surface-muted);
+ font-size: 13px;
+}
+
+div.cgit-panel b {
+ display: block;
+ margin-bottom: 6px;
+}
+
+/* Lists: the repositories, refs, log and tree. */
+table.list {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ border-collapse: separate;
+ border-spacing: 0;
+ overflow: hidden;
+}
+
+table.list th {
+ border-bottom: 1px solid var(--line);
+ padding: 10px 12px;
+ background: var(--surface-muted);
+ color: var(--ink-650);
+ font-size: 13px;
+ font-weight: 700;
+ text-align: left;
+}
+
+table.list td {
+ border-bottom: 1px solid var(--line);
+ padding: 9px 12px;
+ vertical-align: top;
+}
+
+table.list tr:last-child td {
+ border-bottom: 0;
+}
+
+table.list tr:hover td {
+ background: var(--surface-blue);
+}
+
+table.list tr.nohover:hover td,
+table.list tr.nohover-highlight:hover td {
+ background: transparent;
+}
+
+/* The empty rows cgit puts between the parts of a summary. */
+table.list tr.nohover > td[colspan] {
+ height: 14px;
+ border-bottom: 0;
+ padding: 0;
+ font-size: 0;
+}
+
+table.list tr.nohover > th {
+ border-top: 1px solid var(--line);
+}
+
+table.list th a {
+ color: inherit;
+}
+
+table.list td.reposection {
+ padding-top: 20px;
+ background: var(--surface);
+ color: var(--ink-950);
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+table.list td.toplevel-repo a,
+table.list td.sublevel-repo a {
+ font-weight: 650;
+}
+
+table.list td.commitgraph,
+table.list td.ls-mode,
+table.list td.ls-size,
+table.list td.sha1,
+td.ls-mode,
+td.sha1 {
+ color: var(--ink-500);
+ font-family: var(--mono);
+ font-size: 13px;
+ white-space: nowrap;
+}
+
+table.list td.ls-size {
+ text-align: right;
+}
+
+a.ls-dir {
+ font-weight: 650;
+}
+
+a.ls-blob,
+a.ls-dir,
+a.ls-mod {
+ font-family: var(--mono);
+ font-size: 14px;
+}
+
+span.age-mins,
+span.age-hours {
+ color: var(--green-600);
+ font-weight: 600;
+}
+
+span.age-days,
+span.age-weeks,
+span.age-months,
+span.age-years {
+ color: var(--ink-500);
+}
+
+a.branch-deco,
+a.tag-deco,
+a.tag-annotated-deco,
+a.remote-deco,
+a.deco {
+ margin-left: 6px;
+ border: 1px solid var(--line-strong);
+ border-radius: 999px;
+ padding: 1px 8px;
+ background: var(--surface-muted);
+ color: var(--ink-800);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+a.branch-deco {
+ border-color: #a7d3b9;
+ background: #eaf7ef;
+ color: var(--green-600);
+}
+
+a.tag-deco,
+a.tag-annotated-deco {
+ border-color: #bcd8f2;
+ background: var(--surface-blue);
+ color: var(--blue-700);
+}
+
+ul.pager {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+ margin: 18px 0 0;
+ padding: 0;
+ list-style: none;