#!/usr/bin/env bash # Create emergency Git bundle backups from Gitea over a non-interactive Git remote. # This is a repository-history fallback only. It does not replace `gitea dump`, # does not back up Gitea DB rows, settings, issues, packages, secrets, or LFS. set -euo pipefail GITEA_BASE_URL="${GITEA_BASE_URL:-http://192.168.0.110:3001}" GIT_REMOTE_BASE="${GIT_REMOTE_BASE:-}" LOCAL_GITEA_CONTAINER="${LOCAL_GITEA_CONTAINER:-}" LOCAL_REPO_ROOT="${LOCAL_REPO_ROOT:-/data/git/repositories}" OUTPUT_ROOT="${OUTPUT_ROOT:-/home/ollama/backup/110/gitea/git-bundles}" TIMEOUT_LS_REMOTE_SECONDS="${TIMEOUT_LS_REMOTE_SECONDS:-60}" TIMEOUT_CLONE_SECONDS="${TIMEOUT_CLONE_SECONDS:-180}" TIMEOUT_BUNDLE_SECONDS="${TIMEOUT_BUNDLE_SECONDS:-180}" TIMEOUT_VERIFY_SECONDS="${TIMEOUT_VERIFY_SECONDS:-60}" STAMP="${STAMP:-$(date +%Y%m%d-%H%M%S)}" DRY_RUN=0 REPOS=() usage() { cat <<'USAGE' Usage: scripts/backup/gitea-repo-bundle-backup.sh [options] Options: --repo OWNER/NAME Repository to bundle. May be passed more than once. --output-root PATH Bundle root. Default: /home/ollama/backup/110/gitea/git-bundles --gitea-base URL Gitea base URL. Default: http://192.168.0.110:3001 --git-remote-base URL Git remote base, such as ssh://git@192.168.0.110:2222. When set, it is used instead of --gitea-base. --local-container NAME Read bare repos directly from a local Gitea container. This avoids tokens and SSH keys on the Gitea host. --local-repo-root PATH Repo root inside the local container. --stamp STAMP Output directory stamp. Default: current local timestamp. --dry-run Print target URLs without cloning or writing bundles. -h, --help Show this help. This script uses GIT_TERMINAL_PROMPT=0 and never asks for, reads, or prints tokens or passwords. Private repositories without an existing non-interactive credential or SSH key fail closed in the manifest. USAGE } while [ "$#" -gt 0 ]; do case "$1" in --repo) shift REPOS+=("${1:?--repo requires OWNER/NAME}") ;; --output-root) shift OUTPUT_ROOT="${1:?--output-root requires PATH}" ;; --gitea-base) shift GITEA_BASE_URL="${1:?--gitea-base requires URL}" ;; --git-remote-base) shift GIT_REMOTE_BASE="${1:?--git-remote-base requires URL}" ;; --local-container) shift LOCAL_GITEA_CONTAINER="${1:?--local-container requires NAME}" ;; --local-repo-root) shift LOCAL_REPO_ROOT="${1:?--local-repo-root requires PATH}" ;; --stamp) shift STAMP="${1:?--stamp requires value}" ;; --dry-run) DRY_RUN=1 ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 64 ;; esac shift done if [ "${#REPOS[@]}" -eq 0 ]; then REPOS=( "wooo/awoooi" "wooo/ewoooc" "wooo/2026FIFAWorldCup" "wooo/agent-bounty-protocol" "wooo/AwoooGo" "wooo/stockplatform-v2" "wooo/vibework" "wooo/momo-pro-system" "wooo/tsenyang-website" "wooo/clawbot-v5" "wooo/bitan-pharmacy" "wooo/vtuber" ) fi repo_remote_url() { repo="$1" if [ -n "$GIT_REMOTE_BASE" ]; then printf '%s/%s.git\n' "${GIT_REMOTE_BASE%/}" "$repo" return fi printf '%s/%s.git\n' "${GITEA_BASE_URL%/}" "$repo" } if [ "$DRY_RUN" -eq 1 ]; then for repo in "${REPOS[@]}"; do if [ -n "$LOCAL_GITEA_CONTAINER" ]; then printf 'DRY_RUN repo=%s transport=container container=%s\n' "$repo" "$LOCAL_GITEA_CONTAINER" else printf 'DRY_RUN repo=%s url=%s\n' "$repo" "$(repo_remote_url "$repo")" fi done exit 0 fi backup_root="${OUTPUT_ROOT%/}/${STAMP}" tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/gitea-bundle.XXXXXX")" manifest="${backup_root}/manifest.tsv" mkdir -p "$backup_root" trap 'rm -rf "$tmp_root"' EXIT printf 'repo\tstatus\thead_count\tbundle\tchecksum\n' > "$manifest" failed_count=0 success_count=0 for repo in "${REPOS[@]}"; do slug="${repo//\//__}" refs_file="${backup_root}/${slug}.refs" err_file="${backup_root}/${slug}.err" bundle_file="${backup_root}/${slug}.bundle" checksum_file="${bundle_file}.sha256" repo_tmp="${tmp_root}/${slug}.git" if [ -n "$LOCAL_GITEA_CONTAINER" ]; then container_repo="${LOCAL_REPO_ROOT%/}/${repo}.git" container_bundle="/tmp/awoooi-${slug}-${STAMP}.bundle" if ! docker exec -u git "$LOCAL_GITEA_CONTAINER" test -d "$container_repo" >/dev/null 2>&1; then owner="${repo%%/*}" name="${repo#*/}" resolved_paths="$(docker exec -u git "$LOCAL_GITEA_CONTAINER" sh -c \ 'find "$1/$2" -mindepth 1 -maxdepth 1 -type d -iname "$3.git" -print' \ sh "${LOCAL_REPO_ROOT%/}" "$owner" "$name" 2>>"$err_file" || true)" resolved_count="$(printf '%s\n' "$resolved_paths" | awk 'NF {count += 1} END {print count + 0}')" if [ "$resolved_count" -ne 1 ]; then printf '%s\tlocal_repo_read_failed\t0\t\t\n' "$repo" >> "$manifest" failed_count=$((failed_count + 1)) continue fi container_repo="$resolved_paths" fi if ! timeout "$TIMEOUT_LS_REMOTE_SECONDS" docker exec -u git "$LOCAL_GITEA_CONTAINER" \ git -C "$container_repo" for-each-ref --format='%(objectname)%09%(refname)' \ >"$refs_file" 2>"$err_file"; then printf '%s\tlocal_repo_read_failed\t0\t\t\n' "$repo" >> "$manifest" failed_count=$((failed_count + 1)) continue fi head_count="$(wc -l < "$refs_file" | tr -d ' ')" if ! timeout "$TIMEOUT_BUNDLE_SECONDS" docker exec -u git "$LOCAL_GITEA_CONTAINER" \ git -C "$container_repo" bundle create "$container_bundle" --all \ >/dev/null 2>>"$err_file"; then docker exec -u git "$LOCAL_GITEA_CONTAINER" rm -f "$container_bundle" >/dev/null 2>&1 || true printf '%s\tbundle_failed\t%s\t\t\n' "$repo" "$head_count" >> "$manifest" failed_count=$((failed_count + 1)) continue fi if ! timeout "$TIMEOUT_VERIFY_SECONDS" docker exec -u git "$LOCAL_GITEA_CONTAINER" \ git -C "$container_repo" bundle verify "$container_bundle" \ >/dev/null 2>>"$err_file"; then docker exec -u git "$LOCAL_GITEA_CONTAINER" rm -f "$container_bundle" >/dev/null 2>&1 || true printf '%s\tverify_failed\t%s\t\t\n' "$repo" "$head_count" >> "$manifest" failed_count=$((failed_count + 1)) continue fi if ! docker cp "${LOCAL_GITEA_CONTAINER}:${container_bundle}" "$bundle_file" 2>>"$err_file"; then docker exec -u git "$LOCAL_GITEA_CONTAINER" rm -f "$container_bundle" >/dev/null 2>&1 || true printf '%s\tcopy_failed\t%s\t\t\n' "$repo" "$head_count" >> "$manifest" failed_count=$((failed_count + 1)) continue fi docker exec -u git "$LOCAL_GITEA_CONTAINER" rm -f "$container_bundle" >/dev/null 2>&1 || true (sha256sum "$bundle_file" 2>/dev/null || shasum -a 256 "$bundle_file") > "$checksum_file" printf '%s\tok\t%s\t%s\t%s\n' "$repo" "$head_count" "$bundle_file" "$checksum_file" >> "$manifest" success_count=$((success_count + 1)) continue fi url="$(repo_remote_url "$repo")" if ! GIT_TERMINAL_PROMPT=0 timeout "$TIMEOUT_LS_REMOTE_SECONDS" \ git ls-remote --heads "$url" >"$refs_file" 2>"$err_file"; then printf '%s\tls_remote_failed\t0\t\t\n' "$repo" >> "$manifest" failed_count=$((failed_count + 1)) continue fi head_count="$(wc -l < "$refs_file" | tr -d ' ')" rm -rf "$repo_tmp" if ! GIT_TERMINAL_PROMPT=0 timeout "$TIMEOUT_CLONE_SECONDS" \ git clone --mirror --quiet "$url" "$repo_tmp" 2>>"$err_file"; then printf '%s\tclone_failed\t%s\t\t\n' "$repo" "$head_count" >> "$manifest" failed_count=$((failed_count + 1)) continue fi if ! timeout "$TIMEOUT_BUNDLE_SECONDS" \ git -C "$repo_tmp" bundle create "$bundle_file" --all >/dev/null 2>>"$err_file"; then printf '%s\tbundle_failed\t%s\t\t\n' "$repo" "$head_count" >> "$manifest" failed_count=$((failed_count + 1)) continue fi if ! timeout "$TIMEOUT_VERIFY_SECONDS" \ git -C "$repo_tmp" bundle verify "$bundle_file" >/dev/null 2>>"$err_file"; then printf '%s\tverify_failed\t%s\t%s\t\n' "$repo" "$head_count" "$bundle_file" >> "$manifest" failed_count=$((failed_count + 1)) continue fi (sha256sum "$bundle_file" 2>/dev/null || shasum -a 256 "$bundle_file") > "$checksum_file" rm -rf "$repo_tmp" printf '%s\tok\t%s\t%s\t%s\n' "$repo" "$head_count" "$bundle_file" "$checksum_file" >> "$manifest" success_count=$((success_count + 1)) done expected_count="${#REPOS[@]}" cat "$manifest" if [ "$failed_count" -ne 0 ] || [ "$success_count" -ne "$expected_count" ]; then printf 'ERROR bundle backup incomplete success=%s expected=%s failed=%s; complete links unchanged\n' \ "$success_count" "$expected_count" "$failed_count" >&2 exit 1 fi promotion_path_ready() { link="$1" if [ ! -e "$link" ] || [ -L "$link" ]; then return 0 fi printf 'ERROR promotion path exists and is not a symlink: %s\n' "$link" >&2 return 1 } latest_link="${OUTPUT_ROOT%/}/latest" complete_link="${OUTPUT_ROOT%/}/latest-private-complete" promotion_path_ready "$latest_link" promotion_path_ready "$complete_link" ln -sfn "$backup_root" "$latest_link" ln -sfn "$backup_root" "$complete_link" printf 'BUNDLE_BACKUP_COMPLETE=1 success=%s expected=%s root=%s\n' "$success_count" "$expected_count" "$backup_root"