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): el clon de la granja podía quedarse atrás para siempre, y eso ya pasó
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/cierres.json
59 docs/state/kernel-contract.txt
60 docs/state/static-audit.txt
61 docs/state/keystones.json
62 docs/state/duplicados.json
63)
64
65while [ $# -gt 0 ]; do
66 case "$1" in
67 --message) MENSAJE="${2:-}"; shift 2 ;;
68 --remote) REMOTE="${2:-origin}"; shift 2 ;;
69 --dry-run) DRY=1; shift ;;
70 --) shift; FICHEROS=("$@"); break ;;
71 *) echo "publicar-estado: opción desconocida '$1'" >&2; exit 2 ;;
72 esac
73done
74[ -n "$MENSAJE" ] || { echo "publicar-estado: falta --message" >&2; exit 2; }
75
76# ── El empujón, en plumbing ───────────────────────────────────────────────────────────────────
77# Se reintenta porque el único modo de falla esperable es que OTRO empuje entre el `fetch` y el
78# `push`. Eso no es un error: es la carrera normal de un repo compartido, y la respuesta correcta
79# es reconstruir el commit sobre la cabeza nueva, no forzar. `--force` nunca aparece acá: pisaría
80# el trabajo del otro, que es justo lo que el latido no debe poder hacer ni por accidente.
81# ¿El árbol de trabajo ya tiene, byte a byte, lo que el commit destino pone en cada ruta que el
82# fast-forward tocaría? Se compara `git hash-object` del fichero EN DISCO contra el blob del commit,
83# que funciona igual para rutas seguidas y sin seguir — y una ruta que el commit tiene y el disco no
84# cuenta como diferencia, que es lo correcto: ahí el ff sí traería algo nuevo.
85_arbol_coincide_con() {
86 local destino="$1" ruta blob disco
87 for ruta in $(git diff --name-only HEAD "$destino" 2>/dev/null); do
88 blob="$(git rev-parse "$destino:$ruta" 2>/dev/null)" || return 1
89 [ -f "$ruta" ] || return 1
90 disco="$(git hash-object -- "$ruta" 2>/dev/null)" || return 1
91 [ "$blob" = "$disco" ] || return 1
92 done
93 return 0
94}
95
96intento=0
97while :; do
98 intento=$((intento + 1))
99
100 # El refspec explícito `+main:refs/remotes/<remoto>/main` no es adorno: con `git fetch <remoto>
101 # main` a secas el ref de SEGUIMIENTO puede quedar rancio, y entonces el `git log origin/main..HEAD`
102 # que este guion le sugiere a un humano miente justo cuando se va a usar para diagnosticar. El `+`
103 # fuerza el ref de seguimiento, que no es trabajo local: se puede pisar sin perder nada.
104 git fetch -q "$REMOTE" "+main:refs/remotes/$REMOTE/main" \
105 || { echo "!! no pude traer $REMOTE/main"; exit 1; }
106 base="$(git rev-parse "refs/remotes/$REMOTE/main")"
107
108 # Índice temporal sembrado con el árbol de origin/main. `GIT_INDEX_FILE` lo aísla del índice
109 # compartido del repo, que es el que arrastra el trabajo a medias de otro agente.
110 idx="$(mktemp "$ROOT/work/.publicar-estado.idx.XXXXXX")"
111 trap 'rm -f "$idx"' EXIT
112 GIT_INDEX_FILE="$idx" git read-tree "$base" || { echo "!! read-tree falló"; exit 1; }
113
114 puestos=0
115 for f in "${FICHEROS[@]}"; do
116 # Un fichero AUSENTE se salta en silencio, pero un fichero VACÍO también: regla 3 de CLAUDE.md
117 # —un vacío llega hasta el final diciendo que todo fue bien—. Si un vigía murió a mitad y dejó
118 # 0 bytes, publicar eso borraría el veredicto anterior y el khipu diría «verde» por un hueco.
119 [ -s "$f" ] || { [ -e "$f" ] && echo " ⚠ $f existe pero está VACÍO — no lo publico"; continue; }
120 modo=100644
121 [ -x "$f" ] && modo=100755
122 blob="$(git hash-object -w -- "$f")" || { echo "!! hash-object falló en $f"; exit 1; }
123 GIT_INDEX_FILE="$idx" git update-index --add --cacheinfo "$modo,$blob,$f" \
124 || { echo "!! update-index falló en $f"; exit 1; }
125 puestos=$((puestos + 1))
126 done
127 [ "$puestos" -gt 0 ] || { echo "==> no hay ni un fichero de estado con contenido — nada que publicar"; exit 0; }
128
129 tree="$(GIT_INDEX_FILE="$idx" git write-tree)" || { echo "!! write-tree falló"; exit 1; }
130 rm -f "$idx"; trap - EXIT
131
132 if [ "$tree" = "$(git rev-parse "$base^{tree}")" ]; then
133 echo "==> sin novedad: el estado del disco es idéntico a $REMOTE/main"
134 exit 0
135 fi
136
137 commit="$(git commit-tree "$tree" -p "$base" -m "$MENSAJE")" \
138 || { echo "!! commit-tree falló"; exit 1; }
139
140 if [ "$DRY" = 1 ]; then
141 echo "==> [dry-run] publicaría ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))"
142 git diff --stat "$base" "$commit" | sed 's/^/ /'
143 exit 0
144 fi
145
146 # `<sha>:refs/heads/main` con el padre siendo la cabeza recién traída ⇒ es un fast-forward para
147 # el remoto. Si el remoto se movió, lo RECHAZA (no hay lease que valga: el rechazo ES la señal).
148 #
149 # Se empuja a la URL CANÓNICA explícitamente, no a `$REMOTE` a secas, y los espejos van aparte en
150 # un segundo paso. `git push <remoto>` con varias `pushurl` las empuja TODAS y devuelve != 0 si
151 # cualquiera falla: con eso un espejo caído se ve igual que una publicación fallida, el reintento
152 # vuelve a traer (ya con el canónico al día), calcula «sin novedad» y SALE 0. O sea que el fallo
153 # del espejo quedaría tapado por un éxito. Separarlos es lo que permite decir qué lado quedó atrás
154 # — que es la regla de este repo: comprobar los dos lados, no la salida del push.
155 CANON="$(git config --get remote.$REMOTE.url)"
156 if git push -q "$CANON" "$commit:refs/heads/main" 2>/dev/null; then
157 echo "==> estado publicado: ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))"
158 # El ref de seguimiento al día CON LO QUE ACABAMOS DE EMPUJAR. Sin esto queda un ciclo atrás por
159 # construcción —se trae antes de empujar— y el `git log origin/main..HEAD` del final mentiría
160 # siempre. Es un ref de seguimiento, no trabajo local: se escribe sin riesgo.
161 git update-ref "refs/remotes/$REMOTE/main" "$commit" 2>/dev/null || true
162 # ── ESPEJOS ───────────────────────────────────────────────────────────────────────────────
163 # Un espejo que falla NO invalida lo publicado, así que no cambia el código de salida; pero se
164 # grita, porque el modo de falla conocido es que el espejo DERIVE EN SILENCIO durante días
165 # (CLAUDE.md regla 2). Si no hay ninguno configurado se dice también: un espejo ausente y un
166 # espejo roto se ven igual desde el log, y no son lo mismo.
167 espejos=0
168 for url in $(git remote get-url --push --all "$REMOTE" 2>/dev/null); do
169 [ "$url" = "$CANON" ] && continue
170 espejos=$((espejos + 1))
171 git push -q "$url" "$commit:refs/heads/main" 2>/dev/null \
172 && echo " espejo al día: $url" \
173 || echo " ⚠ EL ESPEJO QUEDÓ ATRÁS: $url — lo publicado está en el canónico, el espejo no"
174 done
175 [ "$espejos" -gt 0 ] || echo " (este clon no tiene espejo configurado — ver scripts/espejo-setup.sh)"
176 # ── ADELANTAR LA RAMA LOCAL ───────────────────────────────────────────────────────────────
177 # Lo publicado NO depende de esto: si no se puede, no es un fallo. Pero tampoco se avisa a lo
178 # bruto, porque este guion corre 48 veces al día y un ⚠ en cada ciclo deja de leerse — la misma
179 # lección que `espejo-setup.sh` acababa de aprender gritando «gh NO autenticado» con el push
180 # funcionando.
181 #
182 # ⚠ EL CASO NORMAL ES QUE EL `merge --ff-only` FALLE, Y NO INDICA NADA MALO. El generador acaba
183 # de reescribir `docs/state/` en el árbol y eso es justo lo que se publicó, así que el árbol ya
184 # tiene EL MISMO CONTENIDO que el commit nuevo. git igual se niega («local changes would be
185 # overwritten») porque su comprobación mira ÍNDICE contra HEAD, no árbol contra destino.
186 if ! git merge-base --is-ancestor HEAD "$commit" 2>/dev/null; then
187 echo " ⚠ la rama local está DIVERGIDA de $REMOTE/main — publicado igual; mirá: git log $REMOTE/main..HEAD"
188 elif git merge --ff-only -q "$commit" 2>/dev/null; then
189 echo " rama local al día"
190 # Si el árbol YA coincide con el destino en todo lo que el ff tocaría, el adelanto es un no-op
191 # en disco y se puede hacer moviendo la rama. Se hace con `reset --mixed`, que deja el árbol
192 # intacto y reescribe el índice —y por eso va GUARDADO por «el índice no tiene nada»: el índice
193 # es estado COMPARTIDO (regla 2) y pisar lo que otro agente dejó en `git add` es exactamente el
194 # daño que este repo ya documentó. Si hay algo stageado, no se toca y se dice.
195 # ⚠ La comprobación «el árbol ya coincide con el destino» se hace COMPARANDO EL CONTENIDO DEL
196 # DISCO, no con `git diff <commit> -- <ruta>`. Y es un bloqueo circular medido (2026-10-10, al
197 # añadir `cierres.json`): `git diff` **ignora las rutas sin seguir**, así que un fichero de
198 # estado NUEVO aparece eternamente como «difiere» —el commit lo tiene, el índice no— y la rama
199 # no adelanta nunca; pero volverlo seguido es justamente lo que el adelanto haría. El síntoma
200 # era «la rama local queda detrás» en cada ciclo, para siempre, por un fichero que en el disco
201 # era byte a byte el publicado.
202 elif git diff --cached --quiet HEAD 2>/dev/null \
203 && _arbol_coincide_con "$commit"; then
204 git reset --mixed -q "$commit" \
205 && echo " rama local al día (el árbol ya tenía el contenido publicado)" \
206 || echo " la rama local queda detrás — lo publicado NO depende de eso"
207 # ── ÚLTIMO RECURSO: SOLTAR NUESTRA PROPIA SUCIEDAD Y VOLVER A INTENTAR EL FF ────────────────
208 # Los dos caminos de arriba se bloquean juntos en cuanto OTRO agente empuja código: el `ff` no
209 # puede porque `docs/state/` está modificado, y el `reset --mixed` tampoco porque exige que el
210 # árbol ya tenga TODO lo que el ff tocaría —y el código del otro no lo tiene—. Resultado medido
211 # el 2026-10-15: el clon que siembra al worker se quedó **106 commits y dos días** atrás, y la
212 # granja construyó recetas que no estaban en ningún commit. Esto es el eslabón que faltaba.
213 #
214 # `docs/state/` es salida NUESTRA y acabamos de publicarla: en este punto el commit remoto
215 # contiene exactamente lo que hay en el disco, así que descartar esas rutas es un no-op de
216 # contenido —vuelven en el mismo ff, idénticas— y desbloquea el adelanto. Se descarta SÓLO
217 # `docs/state/`, nunca recetas ni código: lo de otro agente no se toca ni para desatascarse.
218 # Guardado por el índice vacío por lo mismo que el `reset --mixed`: es estado compartido.
219 elif git diff --cached --quiet HEAD 2>/dev/null \
220 && [ -n "$(git diff --name-only HEAD -- docs/state 2>/dev/null)" ]; then
221 git checkout -- docs/state 2>/dev/null
222 if git merge --ff-only -q "$commit" 2>/dev/null; then
223 echo " rama local al día (soltando docs/state, que es salida propia ya publicada)"
224 else
225 echo " la rama local queda detrás — lo publicado NO depende de eso, pero el ÁRBOL QUE SE"
226 echo " SIEMBRA sí: mirá \`git status\` en $ROOT antes de que la granja muela algo viejo"
227 fi
228 else
229 echo " la rama local queda detrás (hay cambios propios en el árbol) — lo publicado NO depende de eso"
230 fi
231 exit 0
232 fi
233
234 if [ "$intento" -ge "$INTENTOS" ]; then
235 echo "!! $INTENTOS intentos y el push sigue rechazado — alguien empuja más rápido de lo que reconstruyo"
236 exit 1
237 fi
238 echo " otro empujó primero (intento $intento/$INTENTOS) — reconstruyo sobre la cabeza nueva"
239 sleep $((intento * 2))
240done