sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
fix(granja): un espejo caído se veía igual que una publicación fallida
1#!/usr/bin/env bash
2# publicar-estado.sh — publica `docs/state/` en origin/main SIN depender del árbol ni de la rama local.
3#
4# ══ POR QUÉ EXISTE ════════════════════════════════════════════════════════════════════════════
5# El latido commiteaba en la rama local y empujaba. Eso ata la publicación a DOS estados que el
6# latido no controla y que en una caja compartida se ensucian solos:
7#
8# 1. **el árbol de trabajo** — `git pull --rebase` (el plan B de `git-sincronizar.sh`) se NIEGA
9# con un fichero modificado, aunque no tenga nada que ver con `docs/state/`.
10# 2. **la rama local** — una vez divergida, `git pull --ff-only` no puede volver nunca, y el
11# `--ff-only` es a propósito (regla 2 ter: acá un `pull --rebase` se llevó un commit).
12#
13# MEDIDO el 2026-10-07, que es lo que lo motiva: `/opt/takana` llevaba **580 commits locales y
14# 12 días sin publicar**. Causa: un `scripts/servidor/claude-caja.sh` modificado —y que resultó
15# ser BYTE A BYTE lo que ya estaba en origin, o sea trabajo duplicado de otro clon— más la rama
16# divergida. El latido latía cada 30 min, decía «NO pude publicar … queda local», y nadie lo leía.
17# Los 580 commits contenían, en total, **cuatro líneas de timestamp**.
18#
19# ⇒ Este guion no commitea en la rama local y no lee el árbol a través de git: toma los ficheros
20# del disco, los mete en un índice TEMPORAL sobre el `origin/main` recién traído, y empuja el
21# commit resultante directo a `refs/heads/main`. No hay árbol que ensuciar ni rama que divergir.
22# El índice compartido tampoco se toca (regla 2: `git commit` commitea el índice ENTERO).
23#
24# ⚠ **No borra la huella del timestamp, y es deliberado.** Los ficheros de vigía llevan
25# `medido <ISO>` en la primera línea para que cada ciclo deje rastro en el `git log`: con el frente
26# en verde el texto es constante, y un fichero que nadie refresca envejece hacia el optimismo. Esa
27# decisión está razonada en `cosecha-cron.sh` y acá se respeta — lo que se arregla es el mecanismo
28# de publicar, no la huella.
29#
30# Uso: scripts/farm/publicar-estado.sh --message "<texto>" [--dry-run] [--remote origin] [-- <ficheros>]
31# Sin `-- <ficheros>` usa la lista por defecto de más abajo.
32# La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano.
33set -uo pipefail
34
35ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
36cd "$ROOT"
37
38REMOTE="origin"
39MENSAJE=""
40DRY=0
41INTENTOS="${INTENTOS:-5}"
42
43# Lista por defecto: los ficheros de estado que el latido regenera cada ciclo. Vive acá y no en el
44# llamador para que publicar a mano empuje EXACTAMENTE lo mismo que el cron.
45FICHEROS=(
46 docs/state/subcomandos.txt
47 docs/state/raices.txt
48 docs/state/sonames.txt
49 docs/state/servicios.txt
50 docs/state/build-state.json
51 docs/state/build-state-kde.json
52 docs/state/build-state-gnome.json
53 docs/state/build-state-cosmic.json
54 docs/state/build-state-wlr.json
55 docs/state/drenaje.json
56 docs/state/fuentes-vigia.json
57 docs/state/upstream.json
58 docs/state/kernel-contract.txt
59 docs/state/static-audit.txt
60 docs/state/keystones.json
61 docs/state/duplicados.json
62)
63
64while [ $# -gt 0 ]; do
65 case "$1" in
66 --message) MENSAJE="${2:-}"; shift 2 ;;
67 --remote) REMOTE="${2:-origin}"; shift 2 ;;
68 --dry-run) DRY=1; shift ;;
69 --) shift; FICHEROS=("$@"); break ;;
70 *) echo "publicar-estado: opción desconocida '$1'" >&2; exit 2 ;;
71 esac
72done
73[ -n "$MENSAJE" ] || { echo "publicar-estado: falta --message" >&2; exit 2; }
74
75# ── El empujón, en plumbing ───────────────────────────────────────────────────────────────────
76# Se reintenta porque el único modo de falla esperable es que OTRO empuje entre el `fetch` y el
77# `push`. Eso no es un error: es la carrera normal de un repo compartido, y la respuesta correcta
78# es reconstruir el commit sobre la cabeza nueva, no forzar. `--force` nunca aparece acá: pisaría
79# el trabajo del otro, que es justo lo que el latido no debe poder hacer ni por accidente.
80intento=0
81while :; do
82 intento=$((intento + 1))
83
84 git fetch -q "$REMOTE" main || { echo "!! no pude traer $REMOTE/main"; exit 1; }
85 base="$(git rev-parse FETCH_HEAD)"
86
87 # Índice temporal sembrado con el árbol de origin/main. `GIT_INDEX_FILE` lo aísla del índice
88 # compartido del repo, que es el que arrastra el trabajo a medias de otro agente.
89 idx="$(mktemp "$ROOT/work/.publicar-estado.idx.XXXXXX")"
90 trap 'rm -f "$idx"' EXIT
91 GIT_INDEX_FILE="$idx" git read-tree "$base" || { echo "!! read-tree falló"; exit 1; }
92
93 puestos=0
94 for f in "${FICHEROS[@]}"; do
95 # Un fichero AUSENTE se salta en silencio, pero un fichero VACÍO también: regla 3 de CLAUDE.md
96 # —un vacío llega hasta el final diciendo que todo fue bien—. Si un vigía murió a mitad y dejó
97 # 0 bytes, publicar eso borraría el veredicto anterior y el khipu diría «verde» por un hueco.
98 [ -s "$f" ] || { [ -e "$f" ] && echo " ⚠ $f existe pero está VACÍO — no lo publico"; continue; }
99 modo=100644
100 [ -x "$f" ] && modo=100755
101 blob="$(git hash-object -w -- "$f")" || { echo "!! hash-object falló en $f"; exit 1; }
102 GIT_INDEX_FILE="$idx" git update-index --add --cacheinfo "$modo,$blob,$f" \
103 || { echo "!! update-index falló en $f"; exit 1; }
104 puestos=$((puestos + 1))
105 done
106 [ "$puestos" -gt 0 ] || { echo "==> no hay ni un fichero de estado con contenido — nada que publicar"; exit 0; }
107
108 tree="$(GIT_INDEX_FILE="$idx" git write-tree)" || { echo "!! write-tree falló"; exit 1; }
109 rm -f "$idx"; trap - EXIT
110
111 if [ "$tree" = "$(git rev-parse "$base^{tree}")" ]; then
112 echo "==> sin novedad: el estado del disco es idéntico a $REMOTE/main"
113 exit 0
114 fi
115
116 commit="$(git commit-tree "$tree" -p "$base" -m "$MENSAJE")" \
117 || { echo "!! commit-tree falló"; exit 1; }
118
119 if [ "$DRY" = 1 ]; then
120 echo "==> [dry-run] publicaría ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))"
121 git diff --stat "$base" "$commit" | sed 's/^/ /'
122 exit 0
123 fi
124
125 # `<sha>:refs/heads/main` con el padre siendo la cabeza recién traída ⇒ es un fast-forward para
126 # el remoto. Si el remoto se movió, lo RECHAZA (no hay lease que valga: el rechazo ES la señal).
127 #
128 # Se empuja a la URL CANÓNICA explícitamente, no a `$REMOTE` a secas, y los espejos van aparte en
129 # un segundo paso. `git push <remoto>` con varias `pushurl` las empuja TODAS y devuelve != 0 si
130 # cualquiera falla: con eso un espejo caído se ve igual que una publicación fallida, el reintento
131 # vuelve a traer (ya con el canónico al día), calcula «sin novedad» y SALE 0. O sea que el fallo
132 # del espejo quedaría tapado por un éxito. Separarlos es lo que permite decir qué lado quedó atrás
133 # — que es la regla de este repo: comprobar los dos lados, no la salida del push.
134 CANON="$(git config --get remote.$REMOTE.url)"
135 if git push -q "$CANON" "$commit:refs/heads/main" 2>/dev/null; then
136 echo "==> estado publicado: ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))"
137 # ── ESPEJOS ───────────────────────────────────────────────────────────────────────────────
138 # Un espejo que falla NO invalida lo publicado, así que no cambia el código de salida; pero se
139 # grita, porque el modo de falla conocido es que el espejo DERIVE EN SILENCIO durante días
140 # (CLAUDE.md regla 2). Si no hay ninguno configurado se dice también: un espejo ausente y un
141 # espejo roto se ven igual desde el log, y no son lo mismo.
142 espejos=0
143 for url in $(git remote get-url --push --all "$REMOTE" 2>/dev/null); do
144 [ "$url" = "$CANON" ] && continue
145 espejos=$((espejos + 1))
146 git push -q "$url" "$commit:refs/heads/main" 2>/dev/null \
147 && echo " espejo al día: $url" \
148 || echo " ⚠ EL ESPEJO QUEDÓ ATRÁS: $url — lo publicado está en el canónico, el espejo no"
149 done
150 [ "$espejos" -gt 0 ] || echo " (este clon no tiene espejo configurado — ver scripts/espejo-setup.sh)"
151 # Adelantar la rama local sólo si es un fast-forward limpio y el árbol no estorba. Si no se
152 # puede, NO es un fallo: lo publicado ya está, que es lo que importa. Se dice y se sigue.
153 if git merge-base --is-ancestor HEAD "$commit" 2>/dev/null; then
154 git merge --ff-only -q "$commit" 2>/dev/null \
155 && echo " rama local al día" \
156 || echo " ⚠ la rama local no pudo adelantar (árbol con cambios) — lo publicado NO depende de eso"
157 else
158 echo " ⚠ la rama local está DIVERGIDA de $REMOTE/main — publicado igual; mirá: git log $REMOTE/main..HEAD"
159 fi
160 exit 0
161 fi
162
163 if [ "$intento" -ge "$INTENTOS" ]; then
164 echo "!! $INTENTOS intentos y el push sigue rechazado — alguien empuja más rápido de lo que reconstruyo"
165 exit 1
166 fi
167 echo " otro empujó primero (intento $intento/$INTENTOS) — reconstruyo sobre la cabeza nueva"
168 sleep $((intento * 2))
169done