the static git browser that builds this site
git clone https://git.lucas.co/gitsite.git
Version git-bare-sync.sh here, and fix the loop that skipped every repo
The script that creates the ~/git bare repos and pushes work trees into
them lived only in ~/.local/bin — unversioned, absent from every repo, one
rm from gone, and with no way to recreate it on a new machine. It belongs
next to repos.conf, which it reads and which already points at it.
~/.local/bin/git-bare-sync.sh is now a symlink to this copy, so PATH and
the docs naming that path keep working, with one file to edit.
It was also broken. The loop took repos.conf field 2 and handed it to
sync_one as a work tree, but field 2 is the BARE path — that is what
gitsite reads HEAD from. `[ -d "$path/.git" ]` was therefore false for
every entry, each hit the "not a git work tree" skip, and the run still
exited 0 printing "done". Nothing had been pushed in weeks: on 2026-09-18,
21 repos held unpushed commits (cce-designer 25, cce-compositor 19,
cce-ui 12) while the workspace docs still said committing was publishing.
Now it iterates NAMES and resolves each work tree under $WORK_ROOTS
(~/projects/cce, ~/projects, ~/Dropbox/src; GIT_WORK_ROOTS overrides),
which covers the three that are not under projects/cce. Two things that
used to pass silently now fail the run:
- a name with no work tree under those roots — the silent skip is exactly
what hid this, so nothing is skipped quietly any more;
- a repos.conf entry whose bare path is not the one this script maintains,
which would mean pushing into a repo the site never reads — the same
non-publishing failure from the other end.
Verified against a synthetic repos.conf: both refused with exit 1, a
healthy repo alongside them still synced. A real dry run now resolves all
34 repos with no skips.
EXTRA_REPOS is empty: hou-control and gitsite are both in repos.conf now,
and the hou-control path here had gone stale (it lives in ~/projects).
The mechanism stays for repos that really are unlisted.
readme.txt gains a Publishing model section and loses the claim that the
timer "makes committing locally IS publishing true" — it stopped being
true when the bare layer landed.
Co-Authored-By: Claude Opus 5 <[email protected]>
git-bare-sync.sh | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
readme.txt | 45 +++++++++++--
2 files changed, 241 insertions(+), 4 deletions(-)
diff --git a/git-bare-sync.sh b/git-bare-sync.sh
new file mode 100755
index 0000000..97578e3
--- /dev/null
+++ b/git-bare-sync.sh
@@ -0,0 +1,200 @@
+#!/bin/sh
+# Create and maintain the local bare-repo layer under ~/git.
+#
+# ~/git/<name>.git bare, pushable -- the canonical copy
+# ^ git push
+# working tree gitsite mirrors from the bare repo -> git.lucas.co
+#
+# Why this exists: nothing in the setup could be pushed to. The cce repos'
+# origin was https://git.lucas.co/<name>.git, which is static dumb-http -- you
+# can clone from it, you cannot push to it, because there is no receive-pack
+# behind static hosting. So "published" meant "a timer happened to run", with
+# no signal either way. gitsite's own readme names the hazard: if the timer
+# stops, local commits look published but aren't.
+#
+# With a bare remote, `git push` succeeds or fails now, and the bare repo is a
+# second real copy on disk -- independent of the working tree, so an rm -rf no
+# longer costs everything since the last nightly restic run.
+#
+# Safe to re-run. Existing bare repos are fetched into, not recreated, and an
+# existing origin is preserved under a descriptive name rather than dropped.
+#
+# Which repos: every line of repos.conf, by NAME (field 1). Field 2 is the BARE
+# path -- what gitsite reads HEAD from -- not a work tree, so work trees are
+# resolved by name under $WORK_ROOTS. A name that resolves to nothing is
+# reported and fails the run; it is never silently skipped.
+#
+# Usage: git-bare-sync.sh [--dry-run]
+# env: GIT_BARE_ROOT (default ~/git), GIT_WORK_ROOTS, REPOS_CONF
+
+set -eu
+
+BARE_ROOT="${GIT_BARE_ROOT:-$HOME/git}"
+REPOS_CONF="${REPOS_CONF:-$HOME/Dropbox/src/gitsite/repos.conf}"
+DRY=""
+[ "${1:-}" = "--dry-run" ] && DRY=1
+
+# Where work trees live. repos.conf does NOT record them -- its field 2 is the
+# BARE path, because that is what gitsite reads HEAD from -- so a repo's work
+# tree is found by NAME under these roots, first match winning.
+WORK_ROOTS="${GIT_WORK_ROOTS:-$HOME/projects/cce $HOME/projects $HOME/Dropbox/src}"
+
+# Repos that are not published through gitsite and so are absent from
+# repos.conf. Add a line here (a work-tree path), or add them to repos.conf to
+# publish them too. Empty now that hou-control and gitsite are both listed
+# there -- they were carried here with a path that had since gone stale.
+EXTRA_REPOS=""
+
+# The work tree for a repo name, or nothing if it is not under WORK_ROOTS.
+find_worktree() {
+ _name=$1
+ for _root in $WORK_ROOTS; do
+ if [ -d "$_root/$_name/.git" ]; then
+ printf '%s\n' "$_root/$_name"
+ return 0
+ fi
+ done
+ return 1
+}
+
+say() { printf '%s\n' "$*"; }
+run() {
+ if [ -n "$DRY" ]; then
+ printf ' would: %s\n' "$*"
+ else
+ "$@"
+ fi
+}
+
+# A name for an existing origin, so replacing it loses no information.
+preserved_name() {
+ case "$1" in
+ *git.lucas.co*) echo "published" ;; # static mirror: fetch-only
+ *codeberg.org*) echo "codeberg" ;; # no longer used
+ *github.com*) echo "github" ;;
+ *) echo "previous" ;;
+ esac
+}
+
+# Runs in a subshell so a failure inside it is returnable rather than fatal
+# under `set -e`. fail() records the first error and keeps going, so a repo
+# that cannot be cloned still gets reported rather than silently skipped.
+sync_one() (
+ set +e
+ rc=0
+ fail() { rc=1; }
+ path=$1
+ [ -d "$path/.git" ] || { say " skip $path (not a git work tree)"; exit 0; }
+ name=$(basename "$path")
+ bare="$BARE_ROOT/$name.git"
+
+ if [ -d "$bare" ]; then
+ say " $name: bare exists"
+ else
+ say " $name: creating $bare"
+ run git clone --bare --quiet "$path" "$bare" || { fail; exit $rc; }
+ # clone --bare leaves a back-reference to the work tree; the bare repo
+ # is the upstream, so it should not point anywhere.
+ run git -C "$bare" remote remove origin 2>/dev/null || true
+ run git -C "$bare" config gc.auto 6700
+ fi
+
+ current=$(git -C "$path" remote get-url origin 2>/dev/null || true)
+ if [ -n "$current" ]; then
+ case "$current" in
+ "$bare") : ;; # already pointed at the bare repo
+ *)
+ keep=$(preserved_name "$current")
+ if git -C "$path" remote get-url "$keep" >/dev/null 2>&1; then
+ run git -C "$path" remote remove origin
+ else
+ say " keeping old origin as '$keep' ($current)"
+ run git -C "$path" remote rename origin "$keep"
+ fi
+ run git -C "$path" remote add origin "$bare"
+ ;;
+ esac
+ else
+ run git -C "$path" remote add origin "$bare"
+ fi
+
+ # A repo with no commits yet has nothing to push and is not a failure.
+ if ! git -C "$path" rev-parse --quiet --verify HEAD >/dev/null 2>&1; then
+ say " no commits yet -- bare repo created, nothing to push"
+ exit 0
+ fi
+
+ # Push every branch and tag. --all covers main/master without caring which.
+ run git -C "$path" push --quiet origin --all || fail
+ run git -C "$path" push --quiet origin --tags || fail
+
+ branch=$(git -C "$path" symbolic-ref --short HEAD 2>/dev/null || true)
+ if [ -n "$branch" ]; then
+ run git -C "$path" branch --quiet --set-upstream-to="origin/$branch" "$branch" 2>/dev/null || true
+ # Point the bare repo's HEAD at the same branch, so cloning it checks
+ # out the right thing and gitsite shows the right default.
+ run git -C "$bare" symbolic-ref HEAD "refs/heads/$branch" || fail
+ fi
+ exit $rc
+)
+
+[ -n "$DRY" ] && say "DRY RUN -- nothing will be written"
+run mkdir -p "$BARE_ROOT"
+
+FAILED=""
+
+# One damaged repo must not abort the other thirty-three. A repo whose refs
+# Dropbox has mangled makes git exit non-zero here; record it and carry on.
+attempt() {
+ if sync_one "$1"; then
+ :
+ else
+ say " !! $(basename "$1") FAILED -- see above"
+ FAILED="$FAILED $(basename "$1")"
+ fi
+}
+
+say "from $REPOS_CONF:"
+# Read the whole list first: the loop body runs in this shell, not a subshell,
+# so a failure is visible to the caller rather than swallowed by a pipeline.
+# Fields 1|2 are name|bare-path (no spaces around the separators, per the file's
+# own header). This used to iterate field 2 and hand it to sync_one as though it
+# were a work tree; every repo then reported "not a git work tree" and was
+# skipped, so nothing was pushed for weeks while commits piled up looking
+# published. Hence: resolve by NAME, and treat an unresolvable one as a failure
+# rather than a skip.
+list=$(grep -v '^#' "$REPOS_CONF" | grep -v '^[[:space:]]*$' | cut -d'|' -f1,2)
+for entry in $list; do
+ name=${entry%%|*}
+ conf_bare=${entry#*|}
+ if ! path=$(find_worktree "$name"); then
+ say " !! $name: no work tree under $WORK_ROOTS -- NOT synced"
+ FAILED="$FAILED $name"
+ continue
+ fi
+ # gitsite publishes whatever field 2 points at, while this script maintains
+ # $BARE_ROOT/<name>.git. If the two ever diverge, pushes land in a repo the
+ # site never reads -- silent non-publishing again, from the other end.
+ if [ "$BARE_ROOT" = "$HOME/git" ] && [ "$conf_bare" != "$BARE_ROOT/$name.git" ]; then
+ say " !! $name: repos.conf publishes $conf_bare, but this syncs"
+ say " $BARE_ROOT/$name.git -- pushes would not reach the site"
+ FAILED="$FAILED $name"
+ continue
+ fi
+ attempt "$path"
+done
+
+if [ -n "$EXTRA_REPOS" ]; then
+ say "extras:"
+ for path in $EXTRA_REPOS; do
+ attempt "$path"
+ done
+fi
+
+say ""
+if [ -n "$FAILED" ]; then
+ say "done, with failures:$FAILED"
+ say "bare repos in $BARE_ROOT"
+ exit 1
+fi
+say "done. bare repos in $BARE_ROOT"
diff --git a/readme.txt b/readme.txt
index 6976eb9..0409a0b 100644
--- a/readme.txt
+++ b/readme.txt
@@ -9,7 +9,12 @@ repos.conf, plus dumb-http clone support so
Files
-----
repos.conf which repos to publish: name|path|description|mode
+ path is the BARE repo in ~/git -- what this reads HEAD from,
+ not a work tree
mode "clone" = browse + clonable, "browse" = browse only
+git-bare-sync.sh
+ creates/maintains the ~/git bare repos and pushes every work
+ tree into them -- see "Publishing model" below
generate.py the HTML generator
build.sh syncs mirrors, runs generate.py, adds clone files
deploy.sh pushes the built site to Cloudflare Pages
@@ -32,10 +37,9 @@ Automatic publishing
--------------------
gitsite.timer runs autodeploy.sh hourly (randomized up to 5m; Persistent
so a run missed while the machine was off catches up), and autodeploy.sh
-rebuilds + deploys only when a repo in repos.conf has a new HEAD. That
-timer is what makes "committing locally IS publishing" true — the repos
-it mirrors have no push remotes, so if it stops running, nothing reaches
-git.lucas.co and the local commits look published but aren't.
+rebuilds + deploys only when a repo in repos.conf has a new HEAD. If it
+stops running, nothing reaches git.lucas.co and pushed commits look
+published but aren't.
The units are NOT installed by any script here. On a new machine:
@@ -46,6 +50,39 @@ The units are NOT installed by any script here. On a new machine:
Check: systemctl --user list-timers gitsite.timer
Log: ~/.cache/gitsite/autodeploy.log
+Publishing model
+----------------
+Committing is NOT publishing; pushing is. Each repo's origin is a local
+bare repo under ~/git (real, pushable, and a second copy on disk); this
+site mirrors from those, and repos.conf lists them. So:
+
+ git commit ... # local only
+ git push origin <branch> # the publishing step
+ # gitsite.timer then deploys it
+
+That replaced an older arrangement where origin was this site itself --
+fetch-only, static, no receive-pack -- and "published" meant "a timer
+happened to run", with no signal either way.
+
+git-bare-sync.sh creates any missing bare repos and pushes every repo in
+repos.conf into its own, so it is the bulk version of that push step:
+
+ git-bare-sync.sh --dry-run # show what would be pushed
+ git-bare-sync.sh # do it
+
+It finds each repo's work tree by NAME under ~/projects/cce, ~/projects
+and ~/Dropbox/src (override with GIT_WORK_ROOTS), because repos.conf
+records only the bare path. A name it cannot resolve fails the run rather
+than being skipped: it previously read repos.conf's bare path AS a work
+tree, found no .git, and silently skipped all 34 repos -- which left 21
+of them holding unpushed commits (2026-09-18) while the docs still said a
+commit was enough.
+
+It lives here rather than loose in ~/.local/bin, where it was unversioned
+and one rm from gone; ~/.local/bin/git-bare-sync.sh is a symlink to this
+copy, so PATH and the docs that name that path keep working and there is
+only one file to edit.
+
One-time Cloudflare setup
-------------------------
1. npx wrangler login