home · contact · privacy
Split (and extend) setup_git_mirror.sh into adopt_repo.sh, setup_git_server.sh. master
authorPlom Heller <plom@plomlompom.com>
Wed, 23 Sep 2026 01:16:31 +0000 (03:16 +0200)
committerPlom Heller <plom@plomlompom.com>
Wed, 23 Sep 2026 01:16:31 +0000 (03:16 +0200)
CLAUDE.md
scripts/adopt_repo.sh [new file with mode: 0755]
scripts/install_server.sh
scripts/lib/OPTS_SSH_NEW_HOST.sh [new file with mode: 0644]
scripts/lib/PATH_GIT_BASE.sh [new file with mode: 0644]
scripts/setup_git_mirror.sh [deleted file]
scripts/setup_git_server.sh [new file with mode: 0755]

index 192a9b0959c030c38efe1b22270f97c731e40f45..6c186320e30ba06492e05aef27cdc30fcd4f3a0e 100644 (file)
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -40,7 +40,9 @@ Both tracks converge on the same first-login family — `start_root_t490s.sh`
   - admin workstation: `install_server.sh`
   - inside a target system: `start_root_t490s.sh`, `start_root_server.sh`,
     `start_user.sh`, `update_efi.sh` (local systems only, see below)
-  - optional, by hand on an already-set-up target: `setup_git_mirror.sh`
+  - optional, by hand on an already-set-up target: `setup_git_server.sh`
+  - optional, by hand from any machine holding a checkout, targeting a
+    repos server over ssh: `adopt_repo.sh`
 - `to_install/` — per-target package lists (`t490s`, `server`), read in place
   by `start_root` from `${PATH_REPO}/to_install/${TARGET}`.
 - `to_copy/` — plain files installed verbatim by a target-side script.
@@ -48,7 +50,7 @@ Both tracks converge on the same first-login family — `start_root_t490s.sh`
 - `templates/` — files rendered by `render_template` with `${VAR}`/`$(cmd)`
   expansion, instead of inline heredocs: `fstab`, `wifi.nmconnection`
   (rendered by `install_debian.sh`), `git-daemon.service`
-  (`setup_git_mirror.sh`), `update-efi-hook` (`start_root_t490s.sh`).
+  (`setup_git_server.sh`), `update-efi-hook` (`start_root_t490s.sh`).
 - `home/` — dotfile skeletons `home/any/`, `home/root/`, `home/user/`,
   symlinked file-by-file into an account's home by `link_home` (see below).
   `link_home <target>` always links `home/any/` (what every account wants)
@@ -79,15 +81,15 @@ root, so all of it is present on every target.
   `start_root_t490s.sh` already does it): `sh scripts/update_efi.sh`
 - Push repo to a remote server and start its first-login setup (from the
   admin's workstation): `sh scripts/install_server.sh <server>`
+- Make an already-set-up server a git host: `sh scripts/setup_git_server.sh`
+- Make it the authoritative home of a repo (from any machine holding a
+  checkout of that repo): `sh scripts/adopt_repo.sh <path-repo> <server>`
 - First login on a new local system: nothing to type — root's first console
   login runs `start_root_t490s.sh` via a hook left by `install_debian.sh`.
 - First login on a new remote server: triggered by `install_server.sh`.
 - Both first-login scripts can be rerun by hand later (e.g.
   `/data/confplom/scripts/start_root_t490s.sh`, or
   `~/confplom/scripts/start_root_server.sh`); so can `start_user.sh`.
-- Make an already-set-up target a distribution point for fresh systems:
-  `sh scripts/setup_git_mirror.sh`; a fresh system then runs
-  `git clone git://<that-server>/<repo-dirname>.git`.
 
 There is no test runner. For logic changes prefer a dry read-through plus
 `sh -n`/`shellcheck -s dash`; never execute the scripts against real block
@@ -126,7 +128,7 @@ devices or real remote servers to "test" them.
   parsed at runtime out of the rescue environment's
   `/etc/network/interfaces` and re-emitted into a NetworkManager keyfile in
   the target. The repo therefore carries nothing secret, which is what makes
-  serving it anonymously via `setup_git_mirror.sh` acceptable.
+  serving it anonymously via `setup_git_server.sh` acceptable.
 
 ## Shared library
 
@@ -183,8 +185,11 @@ another script; core utilities like `mkdir`/`sed`/`mktemp` aren't listed).
   script**, because each script's `$HOME` differs: `install_server.sh` reads
   the *admin's* `~/.ssh/known_hosts`; `start_root_server.sh` reads *root's*
   `~/.ssh` on the freshly provisioned target.
-- `FNAME_INITRD`/`FNAME_VMLINUZ`, `FNAME_PROFILE`, `DIRNAME_SSH`, `TO_RBIND`:
-  the remaining constants shared by two or more scripts.
+- `FNAME_INITRD`/`FNAME_VMLINUZ`, `FNAME_PROFILE`, `DIRNAME_SSH`, `TO_RBIND`,
+  `PATH_GIT_BASE` (`/srv/git`), `OPTS_SSH_NEW_HOST` (the `ssh`/`scp` option
+  accepting an unseen host key on first contact, so a first-ever connection
+  doesn't block on a prompt): the remaining constants shared by two or more
+  scripts.
 
 Anything derived from values only known after argument parsing is *not* a
 shared constant: build it from `path_luks_mapper`/`path_vg` (each taking the
@@ -273,11 +278,11 @@ time — e.g. no `chrooted_command.sh` while `install_debian.sh` is mid-
 debootstrap; the second's `close_luksvg` would tear the VG down under the
 first. Run them serially.
 
-Exempt: `start_root_*.sh`, `start_user.sh`, `setup_git_mirror.sh` (already
-booted into a target, never touch the LUKS+VG), `install_server.sh` (only
-talks to a remote server over SSH; can run concurrently with the others or
-with itself against another server), and `update_efi.sh` (only mounts the
-plain EFI partition).
+Exempt: `start_root_*.sh`, `start_user.sh`, `setup_git_server.sh` (already
+booted into a target, never touch the LUKS+VG), `install_server.sh` and
+`adopt_repo.sh` (only talk to a remote server over SSH; either can run
+concurrently with the others or with itself against another server), and
+`update_efi.sh` (only mounts the plain EFI partition).
 
 ## `setup_luksvg.sh`
 
@@ -439,8 +444,8 @@ tar`. Constants: `FNAME_REPO_TAR=repo.tar`;
 4. `ssh` in to `tar xf --no-same-owner` (lands at `~/<repo-dirname>`). The
    flag matters: GNU `tar` as root defaults to `--same-owner`, which would
    restore the *workstation's* numeric UID onto files under `/root`; that
-   silent mismatch later makes root-run `git` (e.g. `setup_git_mirror.sh`'s
-   `git clone --bare`) refuse the tree as "dubious ownership". Then `ssh -t`
+   silent mismatch would otherwise make any later root-run `git` operation
+   against that tree refuse it as "dubious ownership". Then `ssh -t`
    (pseudo-tty, since the script ends prompting for a password) to run
    `./${RELPATH_SETUP_SCRIPT}`.
 
@@ -536,34 +541,83 @@ No arguments; just `include link_home; link_home user`. Safe to run again by
 hand as any account: as root it additionally picks up `home/user/` into
 root's home; as the user it picks up entries added after provisioning.
 
-## `setup_git_mirror.sh`
+## `setup_git_server.sh`
 
-Optional, run by hand on an already-set-up target to make it a distribution
-point. Motivation: a fresh system with no SSH keys exchanged can't use
-`install_server.sh`; it can instead anonymously
-`git clone git://<server>/<repo-dirname>.git`. No arguments; refuses to run
-if `${PATH_GIT_MIRROR}` (`/srv/git/${DIRNAME_REPO}.git`) exists — one-time
-setup, not a sync step.
+Optional, run by hand on an already-set-up target to turn it into a git
+host — infrastructure only, no specific repo. Motivation: a fresh system
+with no SSH keys exchanged can't use `install_server.sh`; a git host lets it
+instead anonymously `git clone git://<server>/<repo>.git`. Repos themselves
+are added afterwards, one at a time, by `adopt_repo.sh` (below). No
+arguments; safe to rerun — every step here is idempotent, since the one
+thing worth protecting against a rerun (an already-adopted repo) is state
+`adopt_repo.sh` owns, not this script.
 
 1. `apt-get -y update` + install `git` (a bare target may lack it).
-2. `git clone --bare "${PATH_REPO}"` into the mirror path — a snapshot, not
-   a live link.
-3. `touch git-daemon-export-ok` in it, rather than `--export-all`, so other
-   repos later placed under `/srv/git` aren't served automatically.
-4. `chown -R "${USERNAME}:${USERNAME}"`: `git://` is read-only, so keeping
-   the mirror current means the admin `git push`ing over `ssh` as
-   `${USERNAME}` (root SSH is already disabled), so that account must own it.
-   Consequence, not a bug: root running `git` against the mirror trips
-   "dubious ownership"; inspect via
-   `sudo -u "${USERNAME}" git -C "${PATH_GIT_MIRROR}" log`, or for a
-   one-off `git config --global --add safe.directory "${PATH_GIT_MIRROR}"`.
-5. Render `templates/git-daemon.service` (`User=${USERNAME}`; never run a
+2. `mkdir -p "${PATH_GIT_BASE}"` (`/srv/git`) and `chown` it (not `-R`) to
+   `${USERNAME}`, so that account can later `git init --bare` repos under
+   it (over `ssh`, via `adopt_repo.sh`) without sudo — root SSH is already
+   disabled by `start_root_server.sh`, and `git://` itself is read-only, so
+   this is the only way a repo ever gets created or updated there.
+   Deliberately not recursive: repos under `${PATH_GIT_BASE}` are
+   `adopt_repo.sh`'s to own, not this script's to reassert on a rerun — e.g.
+   a future repo owned by some other account shouldn't get silently
+   reclaimed by a rerun of this one.
+3. Render `templates/git-daemon.service` (`User=${USERNAME}`; never run a
    network-facing daemon as root needlessly) running
-   `git daemon --reuseaddr --base-path=/srv/git /srv/git`;
+   `git daemon --reuseaddr --base-path=/srv/git /srv/git` — one daemon,
+   started once, ends up serving every repo `adopt_repo.sh` later adds
+   under `${PATH_GIT_BASE}`, so it's never touched again per repo;
    `systemctl daemon-reload` + `enable --now git-daemon` (systemd is already
    present; no inetd needed).
-6. Print the anonymous clone URL, the `ssh` push URL, and a reminder that
-   git daemon listens on 9418/tcp in case a firewall is added later.
+4. Print the clone/push URL patterns and a reminder that git daemon listens
+   on 9418/tcp in case a firewall is added later.
+
+## `adopt_repo.sh`
+
+Optional, run by hand from any machine holding a checkout of the repo to
+adopt — this repo's own or another project's; only this repo's own
+`scripts/lib/` is needed, so per "Hardcoded machine assumptions" the running
+machine just needs *some* checkout of this repo present, same as any other
+script here — against a `setup_git_server.sh`-provisioned server. Two
+arguments: the local repo's path and the server. Makes that server the
+repo's authoritative, single copy of history, so it also repoints the local
+checkout's own `origin` there. Unlike every other server-facing script here
+it never runs *on* the server via a console/first-login hook — it drives the
+server purely over `ssh`, closer to `install_server.sh` than to a
+target-side script.
+
+1. Sanity checks: `${PATH_SRC_REPO}` looks like a git checkout; its current
+   branch (`NAME_BRANCH`, via `symbolic-ref`) resolves — refuses a detached
+   `HEAD`; the target `${PATH_GIT_BASE}/<repo>.git` doesn't already exist on
+   the server (checked over `ssh`) — one-time adoption per repo, not a sync
+   step.
+2. Over `ssh`, as `${USERNAME}` (root SSH is disabled by
+   `start_root_server.sh`): `git init --bare
+   --initial-branch="${NAME_BRANCH}"` — genuinely empty, not a clone, since
+   there's nothing local to the server to clone from; content arrives in
+   the next step. `--initial-branch` matters because an empty bare repo has
+   nothing to derive a default branch from otherwise, and its `HEAD`
+   (pointing at a ref that doesn't exist yet) only resolves once that
+   same-named branch is pushed. Then `touch git-daemon-export-ok` (rather
+   than `--export-all`, so other repos later adopted onto the same server
+   aren't served automatically) and sets `receive.denyNonFastForwards` +
+   `receive.denyDeletes`: once this is the repo's only copy, a force-push or
+   branch deletion has nothing to recover from, so pushes may only add
+   commits. Deliberate friction; lift either by hand on the server for a
+   rare, genuine history rewrite.
+3. `git push --mirror` from the local checkout carries over every branch
+   and tag in one shot — the actual content transfer, analogous to a
+   same-host `git clone --bare`.
+4. Only once that push has succeeded: point the local checkout's own
+   `origin` at the new `ssh://` URL (`remote set-url`, or `remote add` if it
+   had none). `set-url` alone never touches a separately configured
+   `remote.origin.pushurl` — which git prefers over `url` for pushes — so if
+   one was already set (and now differs), print how to also repoint it by
+   hand rather than silently dropping what may be a deliberate split.
+5. Print the new anonymous clone URL.
+
+Both scripts share `PATH_GIT_BASE` and (with `install_server.sh`)
+`OPTS_SSH_NEW_HOST` — see "Shared library".
 
 ## `home/` skeletons
 
diff --git a/scripts/adopt_repo.sh b/scripts/adopt_repo.sh
new file mode 100755 (executable)
index 0000000..c432f0a
--- /dev/null
@@ -0,0 +1,64 @@
+#!/bin/sh
+. "$(dirname "$0")/_lib.sh"
+include OPTS_SSH_NEW_HOST
+include PATH_GIT_BASE
+include USERNAME
+include check_tools
+include error
+include msg
+include try_quiet
+include usage
+
+# inputs to confirm
+usage $# "path-repo" "repos-server"
+PATH_SRC_REPO=$1
+SERVER=$2
+LOGIN="${USERNAME}@${SERVER}"
+
+# early sanity check, with further-down constant declarations relying on this …
+[ -d "${PATH_SRC_REPO}/.git" ]\
+    || error "${PATH_SRC_REPO}: not a git checkout"
+
+# constants derived from changeables
+NAME_REPO=$(basename "$(cd "${PATH_SRC_REPO}" && pwd)")
+PATH_REMOTE_REPO="${PATH_GIT_BASE}/${NAME_REPO}.git"
+URL_REMOTE_REPO="ssh://${LOGIN}${PATH_REMOTE_REPO}"
+
+# sanity checks and determination of current active repo branch as NAME_BRANCH
+# (needed for git init's --initial-branch, so the history-free remote repo's
+# HEAD resolves once our later git push --mirror creates that branch)
+check_tools git ssh
+NAME_BRANCH=$(git -C "${PATH_SRC_REPO}" symbolic-ref --quiet --short HEAD)\
+    || error "${PATH_SRC_REPO}: not on a branch (detached HEAD?)"
+msg 'Checking %s does not already host %s …' "${SERVER}" "${NAME_REPO}"
+ssh ${OPTS_SSH_NEW_HOST} "${LOGIN}" "[ ! -e '${PATH_REMOTE_REPO}' ]"\
+    || error "${PATH_REMOTE_REPO} already exists on ${SERVER}"
+
+msg 'Creating bare repo on %s, hardened against history loss …' "${SERVER}"
+ssh ${OPTS_SSH_NEW_HOST} "${LOGIN}" "git init --quiet --bare \
+    --initial-branch='${NAME_BRANCH}' '${PATH_REMOTE_REPO}' \
+    && touch '${PATH_REMOTE_REPO}/git-daemon-export-ok' \
+    && git -C '${PATH_REMOTE_REPO}' config receive.denyNonFastForwards true \
+    && git -C '${PATH_REMOTE_REPO}' config receive.denyDeletes true"
+
+msg 'Pushing all branches and tags to %s …' "${SERVER}"
+GIT_SSH_COMMAND="ssh ${OPTS_SSH_NEW_HOST}" \
+    git -C "${PATH_SRC_REPO}" push --quiet --mirror "${URL_REMOTE_REPO}"
+
+msg "Pointing this checkout's origin at %s …" "${SERVER}"
+if try_quiet git -C "${PATH_SRC_REPO}" remote get-url origin; then
+    PUSHURL=$(git -C "${PATH_SRC_REPO}" config --get remote.origin.pushurl)\
+        || PUSHURL=""
+    git -C "${PATH_SRC_REPO}" remote set-url origin "${URL_REMOTE_REPO}"
+    if [ -n "${PUSHURL}" ] && [ "${PUSHURL}" != "${URL_REMOTE_REPO}" ]; then
+        msg 'Note: origin also has a separate pushurl (%s)' "${PUSHURL}"
+        msg 'left untouched; a bare git push will still use it. To also'
+        msg 'repoint pushes here:'
+        msg '    git remote set-url --push origin %s' "${URL_REMOTE_REPO}"
+    fi
+else
+    git -C "${PATH_SRC_REPO}" remote add origin "${URL_REMOTE_REPO}"
+fi
+
+msg '%s is now authoritative for %s.' "${SERVER}" "${NAME_REPO}"
+msg 'Anonymous clone URL: git://%s/%s.git' "${SERVER}" "${NAME_REPO}"
index 94e16c8f5339757814296bb71e4141405d069304..ac52aa757ef7dde7b65d67b69a4da7bf5410d158 100755 (executable)
@@ -2,6 +2,7 @@
 . "$(dirname "$0")/_lib.sh"
 include DIRNAME_REPO
 include DIRNAME_SCRIPTS
+include OPTS_SSH_NEW_HOST
 include PATH_MY_SSH
 include PATH_REPO
 include check_tools
@@ -10,7 +11,6 @@ include usage
 
 # constants we might want to change at some point
 FNAME_REPO_TAR=repo.tar
-OPTS_SSH_NEW_HOST="-o StrictHostKeyChecking=accept-new"
 
 # constants derived from changeables
 PATH_REPO_PARENT=$(dirname "${PATH_REPO}")
@@ -37,7 +37,7 @@ scp ${OPTS_SSH_NEW_HOST} "${PATH_REPO_TAR}" "${LOGIN}:~"
 rm -rf "${PATH_TEMP}"
 
 msg 'Unpacking repo and running setup script …'
-# --no-same-owner owns the files to root, prevening "dubious ownership" when
-# running setup_git_mirror.sh on it later!
+# --no-same-owner owns the files to root, preventing "dubious ownership" for
+# any later git operation against this tree (by hand, or a future script)!
 ssh ${OPTS_SSH_NEW_HOST} "${LOGIN}" tar xf "${FNAME_REPO_TAR}" --no-same-owner
 ssh -t ${OPTS_SSH_NEW_HOST} "${LOGIN}" "./${RELPATH_SETUP_SCRIPT}"
diff --git a/scripts/lib/OPTS_SSH_NEW_HOST.sh b/scripts/lib/OPTS_SSH_NEW_HOST.sh
new file mode 100644 (file)
index 0000000..ae71709
--- /dev/null
@@ -0,0 +1,2 @@
+# accept-new avoids blocking on a host-key prompt on a first-ever connection
+OPTS_SSH_NEW_HOST="-o StrictHostKeyChecking=accept-new"
diff --git a/scripts/lib/PATH_GIT_BASE.sh b/scripts/lib/PATH_GIT_BASE.sh
new file mode 100644 (file)
index 0000000..8dcceda
--- /dev/null
@@ -0,0 +1 @@
+PATH_GIT_BASE=/srv/git
diff --git a/scripts/setup_git_mirror.sh b/scripts/setup_git_mirror.sh
deleted file mode 100755 (executable)
index 250e608..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/bin/sh
-. "$(dirname "$0")/_lib.sh"
-include DIRNAME_REPO
-include PATH_REPO
-include USERNAME
-include error
-include msg
-include render_template
-
-# constants we might want to change at some point
-PATH_GIT_BASE=/srv/git
-
-# constants derived from changeables
-FNAME_GIT_DAEMON_UNIT=git-daemon.service
-PATH_GIT_DAEMON_UNIT="/etc/systemd/system/${FNAME_GIT_DAEMON_UNIT}"
-PATH_GIT_MIRROR="${PATH_GIT_BASE}/${DIRNAME_REPO}.git"
-PATH_GIT_MIRROR_EXPORT_OK="${PATH_GIT_MIRROR}/git-daemon-export-ok"
-
-[ -e "${PATH_GIT_MIRROR}" ]\
-    && error "${PATH_GIT_MIRROR} already exists"
-
-msg 'Installing git …'
-apt-get -y update
-apt-get -y install git
-
-msg 'Bare-cloning repo to %s for anonymous serving …' "${PATH_GIT_MIRROR}"
-mkdir -p "${PATH_GIT_BASE}"
-git clone --quiet --bare "${PATH_REPO}" "${PATH_GIT_MIRROR}"
-touch "${PATH_GIT_MIRROR_EXPORT_OK}"
-
-msg 'Own repo to %s so they can update it via ssh …' "${USERNAME}"
-chown -R "${USERNAME}:${USERNAME}" "${PATH_GIT_MIRROR}"
-
-msg 'Writing and enabling git-daemon systemd unit …'
-render_template "${FNAME_GIT_DAEMON_UNIT}" >| "${PATH_GIT_DAEMON_UNIT}"
-systemctl daemon-reload
-systemctl enable --now git-daemon
-
-msg 'Anonymous clone URL: git://<this-server>/%s.git' "${DIRNAME_REPO}"
-msg 'Push updates later with: git push ssh://%s@<this-server>%s' \
-    "${USERNAME}" "${PATH_GIT_MIRROR}"
-msg 'Firewall note: git daemon listens on port 9418/tcp.'
diff --git a/scripts/setup_git_server.sh b/scripts/setup_git_server.sh
new file mode 100755 (executable)
index 0000000..768c3e3
--- /dev/null
@@ -0,0 +1,33 @@
+#!/bin/sh
+. "$(dirname "$0")/_lib.sh"
+include PATH_GIT_BASE
+include USERNAME
+include msg
+include render_template
+
+# constants derived from changeables
+FNAME_GIT_DAEMON_UNIT=git-daemon.service
+PATH_GIT_DAEMON_UNIT="/etc/systemd/system/${FNAME_GIT_DAEMON_UNIT}"
+
+msg 'Ensuring installation of git …'
+apt-get -y update
+apt-get -y install git
+
+msg 'Creating %s to hold hosted repos, owned by %s …' \
+    "${PATH_GIT_BASE}" "${USERNAME}"
+mkdir -p "${PATH_GIT_BASE}"
+chown "${USERNAME}:${USERNAME}" "${PATH_GIT_BASE}"
+
+msg 'Writing and enabling git-daemon systemd unit …'
+render_template "${FNAME_GIT_DAEMON_UNIT}" >| "${PATH_GIT_DAEMON_UNIT}"
+systemctl daemon-reload
+systemctl enable --now git-daemon
+
+msg 'Ready. Adopt a repo onto this server from wherever it lives with:'
+msg '    sh scripts/adopt_repo.sh <path-repo> <this-server>'
+msg 'Anonymous clone URL pattern (read-only): git://<this-server>/<repo>.git'
+msg 'Push URL pattern (read-write): ssh://%s@<this-server>%s/<repo>.git' \
+    "${USERNAME}" "${PATH_GIT_BASE}"
+msg 'Clone via the git:// form only for a checkout that never needs to'
+msg 'push; it has no push access at all, by design.'
+msg 'Firewall note: git daemon listens on port 9418/tcp.'