#!/usr/bin/env bash # # forgejo-sync.sh — bring git.levineuwirth.org up to date with the local # repositories, over HTTPS. # # Context: Forgejo is advertised as the canonical home (it is cited by URL # in the CV PDF) but is three to four months behind on eight of nine repos, # because the only configured Forgejo remote used ssh://…:2222, which is # filtered on most public networks. HTTPS works everywhere. # # SAFETY PROPERTIES, all deliberate: # # * Dry run by default. Nothing is created, pushed, or modified unless # --execute is passed. Without it the script only reads. # * Never force-pushes. Plain `git push`. If Forgejo holds commits this # machine does not have, the push is REFUSED and reported rather than # resolved — losing someone else's commits is not this script's call. # * Never touches `origin`. It adds a separate `forgejo` remote, so the # existing GitHub workflow keeps working and every change here is # undone by `git remote remove forgejo`. # * New repositories are created PRIVATE. Some of these (oneiros-config, # levshell) are not public on GitHub, and a wrong default here would # publish them. Flip individual repos to public in the UI afterwards. # * No mirrors are configured. A Forgejo push-mirror force-pushes to its # target, so enabling one while Forgejo is behind would overwrite # GitHub with stale history. Mirrors come after this has run clean. # # Usage: # export FORGEJO_TOKEN=… # Settings → Applications, scope write:repository # bash tools/forgejo-sync.sh # survey only # bash tools/forgejo-sync.sh --execute # do it # bash tools/forgejo-sync.sh --execute --create-missing # # The token is required even for the survey: it lists your private repos, # and a tokenless listing would report them as missing and propose creating # duplicates. set -uo pipefail FORGEJO_URL="https://git.levineuwirth.org" FORGEJO_USER="neuwirth" REPO_ROOT="${REPO_ROOT:-$HOME/Repos/personal}" # Repositories to leave alone entirely. They are still listed in the output, # marked HELD, so that skipping one stays a visible decision rather than a # repo that quietly disappears from the report. # # oneiros-config — personal configuration, not ready to leave this machine. SKIP_REPOS=( oneiros-config ) EXECUTE=false CREATE_MISSING=false for arg in "$@"; do case "$arg" in --execute) EXECUTE=true ;; --create-missing) CREATE_MISSING=true ;; -h|--help) sed -n '2,40p' "$0"; exit 0 ;; *) echo "unknown argument: $arg" >&2; exit 2 ;; esac done if [ -z "${FORGEJO_TOKEN:-}" ]; then echo "FORGEJO_TOKEN is not set. Generate one at:" >&2 echo " $FORGEJO_URL/user/settings/applications (scope: write:repository)" >&2 echo "Then: export FORGEJO_TOKEN=…" >&2 exit 2 fi $EXECUTE || echo "### DRY RUN — nothing will be changed. Pass --execute to act. ###" echo api() { curl -sS -H "Authorization: token $FORGEJO_TOKEN" \ -H "Content-Type: application/json" "$@" } # Git needs the token too, for private repositories. It is handed over via # GIT_ASKPASS rather than embedded in the URL or passed on a command line, # so it stays out of `ps` output, out of the remote's stored config, and out # of any error message git prints on failure. ASKPASS=$(mktemp) chmod 700 "$ASKPASS" printf '#!/bin/sh\nprintf "%%s" "$FORGEJO_TOKEN"\n' > "$ASKPASS" export GIT_ASKPASS="$ASKPASS" export GIT_TERMINAL_PROMPT=0 trap 'rm -f "$ASKPASS"' EXIT # --------------------------------------------------------------------------- # What already exists on Forgejo (includes private repos, hence the token). # --------------------------------------------------------------------------- existing=$(api "$FORGEJO_URL/api/v1/user/repos?limit=100" \ | python3 -c 'import json,sys try: print("\n".join(r["name"].lower() for r in json.load(sys.stdin))) except Exception as e: print("APIERROR", e, file=sys.stderr)') if [ -z "$existing" ]; then echo "Could not list repositories — is the token valid?" >&2 exit 1 fi echo "Forgejo currently holds: $(echo "$existing" | tr '\n' ' ')" echo created=0; pushed=0; skipped=0; blocked=0 for dir in "$REPO_ROOT"/*/; do [ -d "$dir/.git" ] || continue name=$(basename "$dir") cd "$dir" || continue for held in "${SKIP_REPOS[@]}"; do if [ "$name" = "$held" ]; then echo "=== $name" echo " HELD — in SKIP_REPOS, not created, not pushed, no remote added" echo continue 2 fi done branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) nbranches=$(git for-each-ref --format='%(refname)' refs/heads | wc -l) ntags=$(git tag | wc -l) dirty=$(git status --porcelain | wc -l) echo "=== $name ($branch, $nbranches branches, $ntags tags)" [ "$dirty" -gt 0 ] && echo " note: $dirty uncommitted change(s) — not pushed either way" # Forgejo repo names are lowercased; the local directory may not be # (LeVCS on disk is levcs there). remote_name=$(echo "$name" | tr '[:upper:]' '[:lower:]') if ! echo "$existing" | grep -qx "$remote_name"; then if ! $CREATE_MISSING; then echo " ABSENT on Forgejo — rerun with --create-missing to create it (private)" skipped=$((skipped+1)); echo; continue fi if $EXECUTE; then code=$(api -o /dev/null -w '%{http_code}' -X POST \ "$FORGEJO_URL/api/v1/user/repos" \ -d "{\"name\":\"$remote_name\",\"private\":true,\"auto_init\":false}") if [ "$code" != "201" ]; then echo " create FAILED (HTTP $code) — skipping" blocked=$((blocked+1)); echo; continue fi echo " created $remote_name (private)" created=$((created+1)) else echo " would create $remote_name (private)" created=$((created+1)) fi fi # Username in the URL, token supplied by GIT_ASKPASS. url="https://$FORGEJO_USER@${FORGEJO_URL#https://}/$FORGEJO_USER/$remote_name.git" if $EXECUTE; then git remote remove forgejo 2>/dev/null || true git remote add forgejo "$url" fi # --- divergence check ------------------------------------------------- # Read the remote's refs without fetching. If any remote commit is not # an object we already hold, the remote is ahead or has diverged, and a # plain push would either fail or (with --force) destroy it. Report and # move on; a human decides. # A failed listing must NOT read as "nothing unknown on the remote". # Unauthenticated ls-remote against a private repo returns empty and # exits non-zero, which would otherwise look identical to a clean # fast-forward and wave through a push that could be refused — or, with # any future --force, be destructive. if ! remote_refs=$(timeout 45 git ls-remote "$url" 'refs/heads/*' 2>/dev/null); then echo " BLOCKED — cannot read refs from Forgejo (auth, network, or" echo " repository missing). Not pushing blind." blocked=$((blocked+1)); echo; continue fi ahead_of_us=0 while read -r sha ref; do [ -n "${sha:-}" ] || continue if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then echo " REMOTE HAS UNKNOWN COMMIT on ${ref#refs/heads/} ($(echo "$sha" | cut -c1-8))" ahead_of_us=1 fi done <<< "$remote_refs" if [ "$ahead_of_us" = 1 ]; then echo " BLOCKED — Forgejo holds commits this machine does not." echo " Resolve by hand (fetch and inspect) before syncing this one." blocked=$((blocked+1)); echo; continue fi # --- push ------------------------------------------------------------- if $EXECUTE; then if git push --all "$url" >/dev/null 2>&1 && git push --tags "$url" >/dev/null 2>&1; then echo " pushed all branches + tags" pushed=$((pushed+1)) else echo " PUSH FAILED — rerun by hand to see git's reason:" echo " git -C '$dir' push --all forgejo" blocked=$((blocked+1)) fi else out=$(git push --all --dry-run "$url" 2>&1 | grep -vE '^To |^$' | head -5) if [ -z "$out" ]; then echo " already up to date" else echo " would push:" echo "$out" | sed 's/^/ /' fi pushed=$((pushed+1)) fi echo done echo "----------------------------------------------------------------" printf 'created/creatable: %d pushed/pushable: %d skipped: %d blocked: %d\n' \ "$created" "$pushed" "$skipped" "$blocked" $EXECUTE || echo "(dry run — nothing above actually happened)" echo echo "Next, only once this runs clean:" echo " 1. Check the new repos' visibility; all were created private." echo " 2. Configure push mirrors Forgejo → GitHub, per repo, in" echo " Settings → Repository → Mirror Settings. A mirror force-pushes," echo " so it is only safe now that Forgejo is not behind."