#!/usr/bin/env bash # publicar-estado.sh — publica `docs/state/` en origin/main SIN depender del árbol ni de la rama local. # # ══ POR QUÉ EXISTE ════════════════════════════════════════════════════════════════════════════ # El latido commiteaba en la rama local y empujaba. Eso ata la publicación a DOS estados que el # latido no controla y que en una caja compartida se ensucian solos: # # 1. **el árbol de trabajo** — `git pull --rebase` (el plan B de `git-sincronizar.sh`) se NIEGA # con un fichero modificado, aunque no tenga nada que ver con `docs/state/`. # 2. **la rama local** — una vez divergida, `git pull --ff-only` no puede volver nunca, y el # `--ff-only` es a propósito (regla 2 ter: acá un `pull --rebase` se llevó un commit). # # MEDIDO el 2026-10-07, que es lo que lo motiva: `/opt/takana` llevaba **580 commits locales y # 12 días sin publicar**. Causa: un `scripts/servidor/claude-caja.sh` modificado —y que resultó # ser BYTE A BYTE lo que ya estaba en origin, o sea trabajo duplicado de otro clon— más la rama # divergida. El latido latía cada 30 min, decía «NO pude publicar … queda local», y nadie lo leía. # Los 580 commits contenían, en total, **cuatro líneas de timestamp**. # # ⇒ Este guion no commitea en la rama local y no lee el árbol a través de git: toma los ficheros # del disco, los mete en un índice TEMPORAL sobre el `origin/main` recién traído, y empuja el # commit resultante directo a `refs/heads/main`. No hay árbol que ensuciar ni rama que divergir. # El índice compartido tampoco se toca (regla 2: `git commit` commitea el índice ENTERO). # # ⚠ **No borra la huella del timestamp, y es deliberado.** Los ficheros de vigía llevan # `medido ` en la primera línea para que cada ciclo deje rastro en el `git log`: con el frente # en verde el texto es constante, y un fichero que nadie refresca envejece hacia el optimismo. Esa # decisión está razonada en `cosecha-cron.sh` y acá se respeta — lo que se arregla es el mecanismo # de publicar, no la huella. # # Uso: scripts/farm/publicar-estado.sh --message "" [--dry-run] [--remote origin] [-- ] # Sin `-- ` usa la lista por defecto de más abajo. # La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano. set -uo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" REMOTE="origin" MENSAJE="" DRY=0 INTENTOS="${INTENTOS:-5}" # Lista por defecto: los ficheros de estado que el latido regenera cada ciclo. Vive acá y no en el # llamador para que publicar a mano empuje EXACTAMENTE lo mismo que el cron. FICHEROS=( docs/state/subcomandos.txt docs/state/raices.txt docs/state/sonames.txt docs/state/servicios.txt docs/state/build-state.json docs/state/build-state-kde.json docs/state/build-state-gnome.json docs/state/build-state-cosmic.json docs/state/build-state-wlr.json docs/state/drenaje.json docs/state/fuentes-vigia.json docs/state/upstream.json docs/state/cierres.json docs/state/kernel-contract.txt docs/state/static-audit.txt docs/state/keystones.json docs/state/duplicados.json ) while [ $# -gt 0 ]; do case "$1" in --message) MENSAJE="${2:-}"; shift 2 ;; --remote) REMOTE="${2:-origin}"; shift 2 ;; --dry-run) DRY=1; shift ;; --) shift; FICHEROS=("$@"); break ;; *) echo "publicar-estado: opción desconocida '$1'" >&2; exit 2 ;; esac done [ -n "$MENSAJE" ] || { echo "publicar-estado: falta --message" >&2; exit 2; } # ── El empujón, en plumbing ─────────────────────────────────────────────────────────────────── # Se reintenta porque el único modo de falla esperable es que OTRO empuje entre el `fetch` y el # `push`. Eso no es un error: es la carrera normal de un repo compartido, y la respuesta correcta # es reconstruir el commit sobre la cabeza nueva, no forzar. `--force` nunca aparece acá: pisaría # el trabajo del otro, que es justo lo que el latido no debe poder hacer ni por accidente. # ¿El árbol de trabajo ya tiene, byte a byte, lo que el commit destino pone en cada ruta que el # fast-forward tocaría? Se compara `git hash-object` del fichero EN DISCO contra el blob del commit, # que funciona igual para rutas seguidas y sin seguir — y una ruta que el commit tiene y el disco no # cuenta como diferencia, que es lo correcto: ahí el ff sí traería algo nuevo. _arbol_coincide_con() { local destino="$1" ruta blob disco for ruta in $(git diff --name-only HEAD "$destino" 2>/dev/null); do blob="$(git rev-parse "$destino:$ruta" 2>/dev/null)" || return 1 [ -f "$ruta" ] || return 1 disco="$(git hash-object -- "$ruta" 2>/dev/null)" || return 1 [ "$blob" = "$disco" ] || return 1 done return 0 } intento=0 while :; do intento=$((intento + 1)) # El refspec explícito `+main:refs/remotes//main` no es adorno: con `git fetch # main` a secas el ref de SEGUIMIENTO puede quedar rancio, y entonces el `git log origin/main..HEAD` # que este guion le sugiere a un humano miente justo cuando se va a usar para diagnosticar. El `+` # fuerza el ref de seguimiento, que no es trabajo local: se puede pisar sin perder nada. git fetch -q "$REMOTE" "+main:refs/remotes/$REMOTE/main" \ || { echo "!! no pude traer $REMOTE/main"; exit 1; } base="$(git rev-parse "refs/remotes/$REMOTE/main")" # Índice temporal sembrado con el árbol de origin/main. `GIT_INDEX_FILE` lo aísla del índice # compartido del repo, que es el que arrastra el trabajo a medias de otro agente. idx="$(mktemp "$ROOT/work/.publicar-estado.idx.XXXXXX")" trap 'rm -f "$idx"' EXIT GIT_INDEX_FILE="$idx" git read-tree "$base" || { echo "!! read-tree falló"; exit 1; } puestos=0 for f in "${FICHEROS[@]}"; do # Un fichero AUSENTE se salta en silencio, pero un fichero VACÍO también: regla 3 de CLAUDE.md # —un vacío llega hasta el final diciendo que todo fue bien—. Si un vigía murió a mitad y dejó # 0 bytes, publicar eso borraría el veredicto anterior y el khipu diría «verde» por un hueco. [ -s "$f" ] || { [ -e "$f" ] && echo " ⚠ $f existe pero está VACÍO — no lo publico"; continue; } modo=100644 [ -x "$f" ] && modo=100755 blob="$(git hash-object -w -- "$f")" || { echo "!! hash-object falló en $f"; exit 1; } GIT_INDEX_FILE="$idx" git update-index --add --cacheinfo "$modo,$blob,$f" \ || { echo "!! update-index falló en $f"; exit 1; } puestos=$((puestos + 1)) done [ "$puestos" -gt 0 ] || { echo "==> no hay ni un fichero de estado con contenido — nada que publicar"; exit 0; } tree="$(GIT_INDEX_FILE="$idx" git write-tree)" || { echo "!! write-tree falló"; exit 1; } rm -f "$idx"; trap - EXIT if [ "$tree" = "$(git rev-parse "$base^{tree}")" ]; then echo "==> sin novedad: el estado del disco es idéntico a $REMOTE/main" exit 0 fi commit="$(git commit-tree "$tree" -p "$base" -m "$MENSAJE")" \ || { echo "!! commit-tree falló"; exit 1; } if [ "$DRY" = 1 ]; then echo "==> [dry-run] publicaría ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))" git diff --stat "$base" "$commit" | sed 's/^/ /' exit 0 fi # `:refs/heads/main` con el padre siendo la cabeza recién traída ⇒ es un fast-forward para # el remoto. Si el remoto se movió, lo RECHAZA (no hay lease que valga: el rechazo ES la señal). # # Se empuja a la URL CANÓNICA explícitamente, no a `$REMOTE` a secas, y los espejos van aparte en # un segundo paso. `git push ` con varias `pushurl` las empuja TODAS y devuelve != 0 si # cualquiera falla: con eso un espejo caído se ve igual que una publicación fallida, el reintento # vuelve a traer (ya con el canónico al día), calcula «sin novedad» y SALE 0. O sea que el fallo # del espejo quedaría tapado por un éxito. Separarlos es lo que permite decir qué lado quedó atrás # — que es la regla de este repo: comprobar los dos lados, no la salida del push. CANON="$(git config --get remote.$REMOTE.url)" if git push -q "$CANON" "$commit:refs/heads/main" 2>/dev/null; then echo "==> estado publicado: ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))" # El ref de seguimiento al día CON LO QUE ACABAMOS DE EMPUJAR. Sin esto queda un ciclo atrás por # construcción —se trae antes de empujar— y el `git log origin/main..HEAD` del final mentiría # siempre. Es un ref de seguimiento, no trabajo local: se escribe sin riesgo. git update-ref "refs/remotes/$REMOTE/main" "$commit" 2>/dev/null || true # ── ESPEJOS ─────────────────────────────────────────────────────────────────────────────── # Un espejo que falla NO invalida lo publicado, así que no cambia el código de salida; pero se # grita, porque el modo de falla conocido es que el espejo DERIVE EN SILENCIO durante días # (CLAUDE.md regla 2). Si no hay ninguno configurado se dice también: un espejo ausente y un # espejo roto se ven igual desde el log, y no son lo mismo. espejos=0 for url in $(git remote get-url --push --all "$REMOTE" 2>/dev/null); do [ "$url" = "$CANON" ] && continue espejos=$((espejos + 1)) git push -q "$url" "$commit:refs/heads/main" 2>/dev/null \ && echo " espejo al día: $url" \ || echo " ⚠ EL ESPEJO QUEDÓ ATRÁS: $url — lo publicado está en el canónico, el espejo no" done [ "$espejos" -gt 0 ] || echo " (este clon no tiene espejo configurado — ver scripts/espejo-setup.sh)" # ── ADELANTAR LA RAMA LOCAL ─────────────────────────────────────────────────────────────── # Lo publicado NO depende de esto: si no se puede, no es un fallo. Pero tampoco se avisa a lo # bruto, porque este guion corre 48 veces al día y un ⚠ en cada ciclo deja de leerse — la misma # lección que `espejo-setup.sh` acababa de aprender gritando «gh NO autenticado» con el push # funcionando. # # ⚠ EL CASO NORMAL ES QUE EL `merge --ff-only` FALLE, Y NO INDICA NADA MALO. El generador acaba # de reescribir `docs/state/` en el árbol y eso es justo lo que se publicó, así que el árbol ya # tiene EL MISMO CONTENIDO que el commit nuevo. git igual se niega («local changes would be # overwritten») porque su comprobación mira ÍNDICE contra HEAD, no árbol contra destino. if ! git merge-base --is-ancestor HEAD "$commit" 2>/dev/null; then echo " ⚠ la rama local está DIVERGIDA de $REMOTE/main — publicado igual; mirá: git log $REMOTE/main..HEAD" elif git merge --ff-only -q "$commit" 2>/dev/null; then echo " rama local al día" # Si el árbol YA coincide con el destino en todo lo que el ff tocaría, el adelanto es un no-op # en disco y se puede hacer moviendo la rama. Se hace con `reset --mixed`, que deja el árbol # intacto y reescribe el índice —y por eso va GUARDADO por «el índice no tiene nada»: el índice # es estado COMPARTIDO (regla 2) y pisar lo que otro agente dejó en `git add` es exactamente el # daño que este repo ya documentó. Si hay algo stageado, no se toca y se dice. # ⚠ La comprobación «el árbol ya coincide con el destino» se hace COMPARANDO EL CONTENIDO DEL # DISCO, no con `git diff -- `. Y es un bloqueo circular medido (2026-10-10, al # añadir `cierres.json`): `git diff` **ignora las rutas sin seguir**, así que un fichero de # estado NUEVO aparece eternamente como «difiere» —el commit lo tiene, el índice no— y la rama # no adelanta nunca; pero volverlo seguido es justamente lo que el adelanto haría. El síntoma # era «la rama local queda detrás» en cada ciclo, para siempre, por un fichero que en el disco # era byte a byte el publicado. elif git diff --cached --quiet HEAD 2>/dev/null \ && _arbol_coincide_con "$commit"; then git reset --mixed -q "$commit" \ && echo " rama local al día (el árbol ya tenía el contenido publicado)" \ || echo " la rama local queda detrás — lo publicado NO depende de eso" # ── ÚLTIMO RECURSO: SOLTAR NUESTRA PROPIA SUCIEDAD Y VOLVER A INTENTAR EL FF ──────────────── # Los dos caminos de arriba se bloquean juntos en cuanto OTRO agente empuja código: el `ff` no # puede porque `docs/state/` está modificado, y el `reset --mixed` tampoco porque exige que el # árbol ya tenga TODO lo que el ff tocaría —y el código del otro no lo tiene—. Resultado medido # el 2026-10-15: el clon que siembra al worker se quedó **106 commits y dos días** atrás, y la # granja construyó recetas que no estaban en ningún commit. Esto es el eslabón que faltaba. # # `docs/state/` es salida NUESTRA y acabamos de publicarla: en este punto el commit remoto # contiene exactamente lo que hay en el disco, así que descartar esas rutas es un no-op de # contenido —vuelven en el mismo ff, idénticas— y desbloquea el adelanto. Se descarta SÓLO # `docs/state/`, nunca recetas ni código: lo de otro agente no se toca ni para desatascarse. # Guardado por el índice vacío por lo mismo que el `reset --mixed`: es estado compartido. elif git diff --cached --quiet HEAD 2>/dev/null \ && [ -n "$(git diff --name-only HEAD -- docs/state 2>/dev/null)" ]; then git checkout -- docs/state 2>/dev/null if git merge --ff-only -q "$commit" 2>/dev/null; then echo " rama local al día (soltando docs/state, que es salida propia ya publicada)" else echo " la rama local queda detrás — lo publicado NO depende de eso, pero el ÁRBOL QUE SE" echo " SIEMBRA sí: mirá \`git status\` en $ROOT antes de que la granja muela algo viejo" fi else echo " la rama local queda detrás (hay cambios propios en el árbol) — lo publicado NO depende de eso" fi exit 0 fi if [ "$intento" -ge "$INTENTOS" ]; then echo "!! $INTENTOS intentos y el push sigue rechazado — alguien empuja más rápido de lo que reconstruyo" exit 1 fi echo " otro empujó primero (intento $intento/$INTENTOS) — reconstruyo sobre la cabeza nueva" sleep $((intento * 2)) done