git.lucas.co / gitsite
the static git browser that builds this site
git clone https://git.lucas.co/gitsite.git

commita886c896a584fc05545e2f6279bfbea3891ff009
parent102bfd7108
authorLucas Galante <[email protected]>
date2026-09-18 23:15
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