Corrida real contra gioser: los 21 repos fallaron al crearse y el guión dijo «no se pudo crear» A SECAS, porque mandaba el stderr de `gh` a /dev/null. La causa era el LÍMITE DE LA API DE GITHUB agotado —que se resuelve esperando— y sin el mensaje eso parecía un problema de permisos, de nombre o del propio guión: hubo que reproducirlo a mano para enterarse de algo que la herramienta ya sabía. Dos arreglos: · El error de `gh` se muestra, recortado a su primera línea. · Ante «rate limit» se CORTA en el primero: los 20 restantes van a fallar igual y cada intento gasta cuota. Dice a qué hora se repone (leído de `gh api rate_limit`) y recuerda que reintentar es seguro, porque el guión es idempotente. No quedó estado parcial de la corrida fallida: el `pushurl` se agrega DESPUÉS de crear el repo, así que los 21 clones siguen con su remoto original intacto. Verificado en tres de ellos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
201 lines
10 KiB
Bash
Executable File
201 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# espejar-repos.sh — le da una copia FUERA de esta máquina a cada repo que sólo existe acá (SDD 29).
|
|
#
|
|
# ── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────────
|
|
# Medido en gioser el 2026-09-12: **28 repositorios con remoto en la propia máquina, 26 sin ninguna
|
|
# copia fuera**. Entre ellos `tawasuyu`, que produce 10 de los 13 binarios que nadie más provee.
|
|
# Apagar el origen no borra unos servicios: borra EL CÓDIGO CON EL QUE SE VOLVERÍAN A CONSTRUIR, y
|
|
# los clones de trabajo están en el mismo disco que también muere.
|
|
#
|
|
# Hace lo mismo que `scripts/espejo-setup.sh` hace por takana —`pushurl` doble sobre `origin`, de
|
|
# modo que cualquier `git push origin` que ya exista espeje solo— pero sobre un árbol entero.
|
|
#
|
|
# ── LAS CUATRO DECISIONES ───────────────────────────────────────────────────────────────────────
|
|
# 1. **Por defecto NO hace nada.** Imprime lo que haría. Hay que pedirlo con `--apply`. Crear repos
|
|
# y empujar código a un servicio externo no es algo que deba pasar por correr un script sin leer.
|
|
# 2. **Varios clones del MISMO origen van a UN repo remoto.** En gioser hay cinco clones de
|
|
# `tawasuyu/tawasuyu` (`tawasuyu`, `-b`, `-j1`, `-arbol`, `tw-deploy`). Crear cinco repos
|
|
# distintos multiplica la confusión; empujarlos todos al mismo `main` los haría chocar. El
|
|
# primario empuja normal y los demás van a `refs/heads/clon/<nombre>/*`: **nada se pierde y nada
|
|
# colisiona**.
|
|
# 3. **Sólo agrega; nunca toca el `fetch` de `origin`.** El canónico sigue siendo el que estaba.
|
|
# 4. **Lo sucio NO viaja, y se dice.** Un espejo guarda historia commiteada, no el árbol de trabajo:
|
|
# `tawasuyu` tiene 472 ficheros `.rs` modificados sin commitear. Un repo sucio se reporta con su
|
|
# cuenta, porque creer que está a salvo cuando la mitad del trabajo no está commiteado es
|
|
# exactamente el fallo que este guión existe para evitar.
|
|
#
|
|
# Uso:
|
|
# scripts/mudanza/espejar-repos.sh # ensayo: dice qué haría
|
|
# scripts/mudanza/espejar-repos.sh --apply # crea los repos privados y empuja
|
|
# scripts/mudanza/espejar-repos.sh --root /mnt/vvv --owner usuario --apply
|
|
set -uo pipefail
|
|
|
|
OWNER=""; APPLY=0; ROOTS=(); DEPTH=4
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--apply) APPLY=1;;
|
|
--owner) OWNER="$2"; shift;;
|
|
--root) ROOTS+=("$2"); shift;;
|
|
--depth) DEPTH="$2"; shift;;
|
|
-h|--help) sed -n '2,30p' "$0"; exit 0;;
|
|
*) echo "opción desconocida: $1" >&2; exit 2;;
|
|
esac; shift
|
|
done
|
|
[ ${#ROOTS[@]} -eq 0 ] && ROOTS=(/mnt/vvv /home /opt /srv /var/www)
|
|
|
|
command -v gh >/dev/null || { echo "✗ falta \`gh\`" >&2; exit 2; }
|
|
if ! gh auth status >/dev/null 2>&1; then
|
|
echo "✗ \`gh\` no está autenticado. Sin token no se puede crear ni empujar." >&2; exit 2
|
|
fi
|
|
[ -z "$OWNER" ] && OWNER="$(gh api user -q .login 2>/dev/null)"
|
|
[ -z "$OWNER" ] && { echo "✗ no se pudo averiguar la cuenta; pasá --owner" >&2; exit 2; }
|
|
|
|
# Los nombres/IPs de ESTA máquina, para saber qué remoto es «de acá». No se cablean: se preguntan.
|
|
MIS_IPS="$(ip -4 -o addr show scope global 2>/dev/null | awk '{split($4,a,"/"); print a[1]}')"
|
|
es_local() { # $1 = url
|
|
local h="${1#*://}"; h="${h%%/*}"; h="${h##*@}"; h="${h%%:*}"
|
|
[ -z "$h" ] && return 1
|
|
case "$h" in localhost|127.0.0.1) return 0;; esac
|
|
local ip; ip="$(getent ahostsv4 "$h" 2>/dev/null | awk 'NR==1{print $1}')"
|
|
[ -z "$ip" ] && return 1
|
|
echo "$MIS_IPS" | grep -qx "$ip"
|
|
}
|
|
|
|
echo "══ repos sin copia fuera de esta máquina ══"
|
|
[ "$APPLY" = 1 ] || echo " (ENSAYO — nada se crea ni se empuja; agregá --apply)"
|
|
echo " cuenta destino: $OWNER · raíces: ${ROOTS[*]}"
|
|
echo
|
|
|
|
declare -A GRUPO_PRIM GRUPO_N
|
|
CANDIDATOS=()
|
|
while IFS= read -r g; do
|
|
r="${g%/.git}"
|
|
urls="$(git -C "$r" config --get-regexp 'remote\..*\.(url|pushurl)' 2>/dev/null | awk '{print $2}')"
|
|
[ -z "$urls" ] && continue
|
|
dentro=""; fuera=""
|
|
while IFS= read -r u; do
|
|
[ -z "$u" ] && continue
|
|
if es_local "$u"; then dentro="$u"; else fuera="$u"; fi
|
|
done <<< "$urls"
|
|
[ -z "$dentro" ] && continue # no depende de esta máquina
|
|
[ -n "$fuera" ] && continue # ya tiene copia afuera: no se toca
|
|
# clave del grupo: la RUTA del remoto, no su host ni su puerto — cinco clones del mismo upstream
|
|
# llegan por URLs distintas y son el mismo proyecto.
|
|
ruta="${dentro#*://}"; ruta="${ruta##*@}"; ruta="${ruta#*[:/]}"; ruta="${ruta%.git}"
|
|
clave="$(basename "$(dirname "$ruta")")/$(basename "$ruta")"
|
|
CANDIDATOS+=("$clave|$r|$dentro")
|
|
GRUPO_N["$clave"]=$(( ${GRUPO_N["$clave"]:-0} + 1 ))
|
|
done < <(find "${ROOTS[@]}" -maxdepth "$DEPTH" -name .git -prune 2>/dev/null)
|
|
|
|
[ ${#CANDIDATOS[@]} -eq 0 ] && { echo " nada que espejar: todos tienen copia fuera."; exit 0; }
|
|
|
|
# El primario de cada grupo: el clon cuyo HEAD es MÁS NUEVO.
|
|
#
|
|
# ⚠ El primer criterio que escribí era «el que más commits alcanzables tiene», y contra los cinco
|
|
# clones de tawasuyu dio un empate exacto en 9343: `rev-list --all` cuenta las refs de `origin`, que
|
|
# los cinco comparten, así que la métrica estaba dominada por lo que tienen en común y no medía nada.
|
|
# El desempate quedaba en manos del orden del `find` y eligió `tw-deploy` (HEAD del 6/9) sobre
|
|
# `tawasuyu` (del 12/9), que es el que se está trabajando. La fecha del HEAD sí distingue, y es
|
|
# determinista: se compara en ISO, que ordena como texto.
|
|
for c in "${CANDIDATOS[@]}"; do
|
|
clave="${c%%|*}"; resto="${c#*|}"; r="${resto%%|*}"
|
|
f="$(git -C "$r" log -1 --format=%cI 2>/dev/null || echo 0)"
|
|
prev="${GRUPO_PRIM[$clave]:-}"
|
|
if [ -z "$prev" ] || [[ "$f" > "${prev##*|}" ]]; then GRUPO_PRIM["$clave"]="$r|$f"; fi
|
|
done
|
|
|
|
# ¿Un clon secundario aporta algo? Se contesta preguntándole al PRIMARIO si ya tiene esos objetos.
|
|
# Medido: los cinco clones de tawasuyu tienen CERO commits que no estén en su remoto, así que
|
|
# empujar cuatro copias bajo `refs/heads/clon/...` sería ruido puro. Namespacear sólo cuando hay
|
|
# historia propia de verdad.
|
|
aporta_algo() { # $1 = clon secundario $2 = primario
|
|
local sha
|
|
while IFS= read -r sha; do
|
|
[ -z "$sha" ] && continue
|
|
git -C "$2" cat-file -e "$sha" 2>/dev/null || return 0
|
|
done < <(git -C "$1" for-each-ref --format='%(objectname)' refs/heads refs/tags 2>/dev/null)
|
|
return 1
|
|
}
|
|
|
|
fallos=0; hechos=0; a_espejar=0; saltados=0; sucios=()
|
|
for c in "${CANDIDATOS[@]}"; do
|
|
clave="${c%%|*}"; resto="${c#*|}"; r="${resto%%|*}"; dentro="${resto#*|}"
|
|
nombre="$(basename "$clave")"
|
|
primario="${GRUPO_PRIM[$clave]%%|*}"
|
|
es_prim=0; [ "$r" = "$primario" ] && es_prim=1
|
|
destino="https://github.com/$OWNER/$nombre.git"
|
|
sucio="$(git -C "$r" status --porcelain 2>/dev/null | wc -l)"
|
|
[ "$sucio" -gt 0 ] && sucios+=("$(basename "$r"): $sucio fichero(s) sin commitear")
|
|
|
|
if [ "$es_prim" = 1 ]; then
|
|
etq="→ $OWNER/$nombre"
|
|
refspec="--all"
|
|
elif aporta_algo "$r" "$primario"; then
|
|
etq="→ $OWNER/$nombre bajo refs/heads/clon/$(basename "$r")/* (tiene historia propia)"
|
|
refspec="refs/heads/*:refs/heads/clon/$(basename "$r")/*"
|
|
else
|
|
printf " %-30s · nada propio que salvar: todo lo suyo ya está en %s\n" \
|
|
"$(basename "$r")" "$(basename "$primario")"
|
|
saltados=$((saltados+1)); continue
|
|
fi
|
|
a_espejar=$((a_espejar+1))
|
|
printf " %-30s %s\n" "$(basename "$r")" "$etq"
|
|
[ "${GRUPO_N[$clave]}" -gt 1 ] && [ "$es_prim" = 1 ] &&
|
|
printf " %-30s (primario del grupo «%s», %s clones)\n" "" "$clave" "${GRUPO_N[$clave]}"
|
|
|
|
[ "$APPLY" = 1 ] || continue
|
|
|
|
if ! gh repo view "$OWNER/$nombre" >/dev/null 2>&1; then
|
|
# ⚠ El error de `gh` se MUESTRA. La primera versión lo mandaba a /dev/null y decía «no se pudo
|
|
# crear» a secas: los 21 fallaron y el guión no daba ni una pista de por qué. Era el límite de
|
|
# la API de GitHub agotado —cosa que se resuelve esperando— y sin el mensaje eso parecía un
|
|
# problema de permisos, de nombre o del propio guión. Un fallo sin su motivo obliga a
|
|
# reproducirlo a mano para enterarse de algo que la herramienta ya sabía.
|
|
err="$(gh repo create "$OWNER/$nombre" --private 2>&1)"
|
|
if [ $? -ne 0 ] || [ -n "${err##*}" ] && ! gh repo view "$OWNER/$nombre" >/dev/null 2>&1; then
|
|
echo " ✗ no se pudo crear $OWNER/$nombre: ${err%%$'\n'*}"
|
|
fallos=$((fallos+1))
|
|
# Contra el límite de la API no sirve seguir intentando: los 20 restantes van a fallar igual y
|
|
# cada intento gasta cuota. Se corta y se dice cuándo se puede reintentar.
|
|
case "$err" in *"rate limit"*|*"RATE_LIMITED"*)
|
|
reset="$(gh api rate_limit --jq '.resources.graphql.reset // .resources.core.reset' 2>/dev/null)"
|
|
echo
|
|
echo " ✗ LÍMITE DE LA API DE GITHUB AGOTADO — no se intenta con los demás."
|
|
[ -n "$reset" ] && echo " se repone a las $(date -d "@$reset" +%H:%M 2>/dev/null)."
|
|
echo " Reintentá con el mismo comando: es idempotente, no repite lo ya hecho."
|
|
exit 1;;
|
|
esac
|
|
continue
|
|
fi
|
|
echo " · repo privado creado"
|
|
fi
|
|
# `--add --push` sobre `origin`: el fetch sigue siendo el canónico y todo `git push origin` que ya
|
|
# exista empieza a espejar sin tocar ningún script.
|
|
git -C "$r" remote get-url --push --all origin 2>/dev/null | grep -qxF "$destino" ||
|
|
git -C "$r" remote set-url --add --push origin "$destino" 2>/dev/null
|
|
git -C "$r" remote get-url --push --all origin 2>/dev/null | grep -qxF "$dentro" ||
|
|
git -C "$r" remote set-url --add --push origin "$dentro" 2>/dev/null
|
|
if git -C "$r" push "$destino" $refspec >/dev/null 2>&1 &&
|
|
git -C "$r" push "$destino" --tags >/dev/null 2>&1; then
|
|
echo " ✓ empujado"; hechos=$((hechos+1))
|
|
else
|
|
echo " ✗ el push falló"; fallos=$((fallos+1))
|
|
fi
|
|
done
|
|
|
|
echo
|
|
if [ ${#sucios[@]} -gt 0 ]; then
|
|
echo " ⚠ ${#sucios[@]} repo(s) con trabajo SIN COMMITEAR — el espejo NO lo lleva:"
|
|
printf ' %s\n' "${sucios[@]}"
|
|
echo " Un espejo guarda historia commiteada, no el árbol de trabajo. Commitealo antes de"
|
|
echo " apagar el origen, o ese trabajo se va con la máquina."
|
|
fi
|
|
if [ "$APPLY" = 1 ]; then
|
|
echo " ${hechos} espejado(s) · ${fallos} fallo(s)"
|
|
[ "$fallos" -gt 0 ] && exit 1
|
|
else
|
|
echo " ${#CANDIDATOS[@]} repo(s) sin copia fuera: ${a_espejar} a espejar, ${saltados} sin historia propia."
|
|
echo " Volvé a correrlo con --apply para crearlos (privados) y empujar."
|
|
fi
|
|
exit 0
|