#!/bin/bash
host-repo.sh — publish a git checkout as a bare repo on upspin, ready to be
browsed, cloned, and pushed through gitweb (https://git.cosmocr.at).
Usage:
scripts/host-repo.sh [--public | --access FILE] [--config FILE] \
[--owner-config FILE]
an existing git working copy (committed history is
published; the working tree and remotes are not)
destination, e.g. rpg137@gmail.com/myrepo.git
--public grant read,list to all (anonymous browse + clone);
default is private: only the owner and the service
--access FILE use FILE as the repo's Access file instead of the
generated one (mutually exclusive with --public)
--config FILE upspin config to use (default $HOME/upspin/config)
--owner-config FILE
config of the destination tree's OWNER, used only to put
the Access file. Needed when hosting into another user's
tree: upspin lets only the owner write Access files, while
the content upload runs as the (granted) --config user.
What it does, and why:
- Makes a fresh git clone --bare of the checkout (single packfile +
packed-refs — the fewest upspin files), drops hooks/description and the
origin remote (which would leak the local path), and sanity-checks HEAD.
- Puts the Access file FIRST, then uploads the repo content. With ee
packing, each file's decryption key is wrapped for the readers granted
by the governing Access file at write time — so the grants (the
service, and all for --public) must be in place before the upload, or
every file would need an upspin share -fix afterwards to be readable.
- Uploads HEAD last: gitweb treats a directory as a repo once HEAD and
objects/ both exist, so holding HEAD back keeps the repo from being
served half-copied.
- The Access file grants * (all rights) to the tree owner; to
git@cosmocr.at unless it is itself the owner (the service reads the repo
for web/git clients and git push write-back runs as it); to the Admins
group; and, with --owner-config, to the uploading user, who is not the
owner yet still writes the content. Who may push is whoever holds the
upspin write right here (scripts/update-access.sh edits and re-shares).
Requires: git, upspin (with the destination tree owned by the configured
upspin user — only the owner may put Access files).
set -euo pipefail
die() { echo "host-repo.sh: $*" >&2; exit 1; }
PUBLIC=0 CONFIG="${HOME}/upspin/config" OWNERCONFIG="" ACCESSFILE="" ARGS=() while [ $# -gt 0 ]; do case "$1" in --public) PUBLIC=1 ;; --access) shift; ACCESSFILE="${1:?--access needs a file}" ;; --config) shift; CONFIG="${1:?--config needs a file}" ;; --owner-config) shift; OWNERCONFIG="${1:?--owner-config needs a file}" ;; -h|--help) awk 'NR>1 && /^#/ { sub(/^# ?/, ""); print; next } NR>1 { exit }' "$0"; exit 0 ;; -*) die "unknown flag $1" ;; *) ARGS+=("$1") ;; esac shift done [ "${#ARGS[@]}" -eq 2 ] || die "usage: host-repo.sh [--public | --access FILE] [--config FILE] [--owner-config FILE] " CHECKOUT="${ARGS[0]}" DEST="${ARGS[1]%/}" [ -z "$ACCESSFILE" ] || [ "$PUBLIC" = 0 ] || die "--access and --public are mutually exclusive (put read/list: all in the file)" [ -z "$ACCESSFILE" ] || [ -s "$ACCESSFILE" ] || die "--access file missing or empty: $ACCESSFILE"
command -v git >/dev/null || die "git not found" command -v upspin >/dev/null || die "upspin not found" [ -f "$CONFIG" ] || die "upspin config not found: $CONFIG"
--- validate the source checkout ---
git -C "$CHECKOUT" rev-parse --git-dir >/dev/null 2>&1 || die "$CHECKOUT is not a git checkout" git -C "$CHECKOUT" rev-parse -q --verify HEAD >/dev/null || die "$CHECKOUT has no commits"
--- validate the destination path ---
OWNER="${DEST%%/}" case "$OWNER" in @.) ;; *) die "destination must start with an upspin user (user@domain/...): $DEST" ;; esac [ "$DEST" != "$OWNER" ] || die "destination must include a repo path under the user root" case "$DEST" in *.git) ;; *) echo "note: destination does not end in .git — works, but the convention helps clients" >&2 ;; esac
ME=$(sed -n 's/^username: *//p' "$CONFIG" | head -1) if [ -n "$OWNERCONFIG" ]; then [ -f "$OWNERCONFIG" ] || die "owner config not found: $OWNERCONFIG" OC_USER=$(sed -n 's/^username: *//p' "$OWNERCONFIG" | head -1) [ "$OC_USER" = "$OWNER" ] || die "--owner-config user is $OC_USER but the destination tree is owned by $OWNER" elif [ "$ME" != "$OWNER" ]; then echo "warning: destination tree is owned by $OWNER but the config user is $ME;" >&2 echo " the Access put will fail unless run as the owner (see --owner-config)" >&2 fi
if upspin -config="$CONFIG" ls "$DEST" >/dev/null 2>&1; then die "$DEST already exists — refusing to overwrite" fi
--- stage a clean bare repo ---
TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT BASE=$(basename "$DEST") STAGE="$TMP/$BASE" # cp -R below copies the dir itself, so its name is final
git clone --quiet --bare "$CHECKOUT" "$STAGE" git -C "$STAGE" config --remove-section remote.origin 2>/dev/null || true git -C "$STAGE" pack-refs --all rm -rf "$STAGE/hooks" "$STAGE/description"
HEAD must be a symref to a branch that exists, or clients see an empty repo.
HEADREF=$(git -C "$STAGE" symbolic-ref -q HEAD || true) if [ -z "$HEADREF" ] || ! git -C "$STAGE" show-ref -q --verify "$HEADREF"; then FIRST=$(git -C "$STAGE" for-each-ref --format='%(refname)' refs/heads | head -1) [ -n "$FIRST" ] || die "staged repo has no branches" git -C "$STAGE" symbolic-ref HEAD "$FIRST" echo "note: HEAD pointed at a missing branch; reset to $FIRST" >&2 fi
--- upspin with config ---
UP() { upspin -config="$CONFIG" "$@"; }
--- upload: parents, Access first, content, HEAD last ---
if [ "$(dirname "$DEST")" != "$OWNER" ]; then
Create intermediate directories one level at a time (no mkdir -p).
REL=$(dirname "${DEST#"$OWNER"/}") P="$OWNER" IFS='/' read -ra SEGS <<< "$REL" for seg in "${SEGS[@]}"; do P="$P/$seg" UP mkdir "$P" 2>/dev/null || true done fi UP mkdir "$DEST"
Access before content, so ee-packed uploads wrap keys for these readers.
Only the tree owner may put Access files, hence the --owner-config escape.
ACCESS="$TMP/Access" if [ -n "$ACCESSFILE" ]; then cp "$ACCESSFILE" "$ACCESS" else
The "*" list, with nobody named twice: the owner; the service, unless it is
the owner; the Admins group; and, when --owner-config is in play, the
uploading user $ME, who is not the owner but still writes the content.
STAR="$OWNER" [ "$OWNER" = "git@cosmocr.at" ] || STAR="$STAR, git@cosmocr.at" STAR="$STAR, upspin@cosmocr.at/Group/Admins" if [ -n "$OWNERCONFIG" ] && [ "$ME" != "$OWNER" ] && [ "$ME" != "git@cosmocr.at" ]; then STAR="$STAR, $ME" fi { echo "*: $STAR" if [ "$PUBLIC" = 1 ]; then echo "read: all" echo "list: all" echo "write: upspin@cosmocr.at/Group/Writers" fi } > "$ACCESS" fi if [ -n "$OWNERCONFIG" ]; then upspin -config="$OWNERCONFIG" cp "$ACCESS" "$DEST/Access" else UP cp "$ACCESS" "$DEST/Access" fi
echo "==> uploading $(du -sh "$STAGE" | cut -f1) to $DEST"
Everything except HEAD, then HEAD: the repo only becomes servable once
HEAD lands. upspin cp -R dir dst/ copies dir into dst by basename.
for entry in "$STAGE"/*; do name=$(basename "$entry") [ "$name" = "HEAD" ] && continue if [ -d "$entry" ]; then UP cp -R "$entry" "$DEST/" else UP cp "$entry" "$DEST/" fi done UP cp "$STAGE/HEAD" "$DEST/"
--- verify ---
UP ls "$DEST/HEAD" >/dev/null || die "verify failed: $DEST/HEAD not readable" URL="https://git.cosmocr.at/$DEST" echo "==> hosted at $DEST" if grep -qE '^\sread\s[:,].*\ball\b' "$ACCESS"; then echo " browse/clone (anonymous): $URL" else echo " private: browse/clone/push need credentials (vault SSO via" echo " git-credential-cosmocr, or a PAT in $OWNER/gitweb/tokens)" echo " clone: git clone $URL" fi echo " push access: users with 'write' in $DEST/Access"