Las referencias: la capa mutable que apunta a un hash
Ramas, etiquetas y la cabeza son ficheros diminutos que contienen un identificador, y toda la parte difícil de Git vive en esa capa mutable y no en el almacén inmutable que hay debajo.
El almacén de las dos lecciones anteriores tiene una limitación que no es un defecto sino una definición: no puede expresar el presente. Un objeto nunca cambia, así que ninguna cantidad de objetos podrá decir jamás cuál es el estado actual del proyecto. Hace falta algo que sí cambie, y ese algo es la capa de referencias: un conjunto minúsculo de ficheros mutables, cada uno con un identificador dentro, que dicen dónde estamos. Es una capa tan pequeña que cabe entera en unos pocos kilobytes incluso en repositorios de veinte años, y sin embargo es donde vive absolutamente todo lo que resulta difícil, arriesgado o confuso en Git. Esta lección la desmonta, muestra que las operaciones temidas no destruyen datos sino que mueven punteros, y explica por qué esa asimetría entre una capa enorme e inofensiva y otra diminuta y peligrosa es la observación más aprovechable de todo el nivel.
- Ver una referencia como lo que es en el disco y entender por qué ocupa cuarenta y un bytes.
- Distinguir referencia directa de referencia simbólica y situar la cabeza en esa distinción.
- Comprender la actualización condicional de referencias y su papel como comparar e intercambiar.
- Reinterpretar reset, rebase y fusión como movimientos de puntero sobre objetos que ya existen.
- Identificar el registro de referencias como red de seguridad de la capa mutable.
Un fichero con un identificador dentro
Conviene empezar por la decepción, porque es instructiva. Una rama de Git no es una estructura de datos, ni un objeto, ni una lista de commits. Es un fichero de texto cuyo contenido son cuarenta caracteres hexadecimales y un salto de línea. Nada más. Se puede leer con las herramientas del sistema y se puede escribir con ellas, aunque no convenga.
cd /tmp/almacen
cat .git/refs/heads/main
# 97bf1bbf20ac502e1a6d57162e29342ab3e34130
# La forma correcta de listarlas todas, con formato estable
git show-ref
git for-each-ref --format='%(refname) %(objecttype) %(objectname)'
Cuando el número de referencias crece, mantener un fichero por cada una se vuelve ineficiente, y Git las consolida en un único fichero de texto. Esto no cambia la semántica en absoluto, solo el almacenamiento, y es la razón de que a veces el directorio parezca vacío.
git pack-refs --all
cat .git/packed-refs
ls .git/refs/heads/ # puede quedar vacio, y es normal
Que se pueda leer y escribir con las herramientas del sistema no es una invitación a hacerlo, y conviene decir por qué con precisión en lugar de por costumbre. Escribir el fichero a mano se salta el registro de referencias, que es la red de seguridad de esta capa; se salta el bloqueo, que es lo que garantiza que dos procesos no escriban a la vez; y no consulta el fichero consolidado, con lo que puede dejar dos valores distintos para la misma referencia. Los comandos existen precisamente para hacer esas tres cosas, y no cuestan más que escribir el fichero.
Que la capa mutable sea tan escuálida es exactamente lo que la hace tratable. Un repositorio del núcleo de Linux tiene millones de objetos y unos pocos miles de referencias, y esa desproporción de varios órdenes de magnitud no es casual: es el resultado buscado de haber empujado todo lo que se pueda hacia la capa inmutable.
Bajo refs/heads viven las ramas locales, bajo refs/tags las etiquetas y bajo refs/remotes la última posición conocida de las ramas de otros repositorios. La separación es pura convención de rutas, no hay tipos distintos: las tres cosas son ficheros con un identificador dentro. Esa uniformidad es la que permite que un mismo comando, git for-each-ref, las recorra todas, y la que hace que herramientas y sistemas de integración puedan crear jerarquías propias bajo refs/ sin pedir permiso a nadie.
La cabeza y la referencia simbólica
Falta una pieza para completar el cuadro y es la que responde a la pregunta de en qué rama estamos. La cabeza es una referencia especial que normalmente no contiene un identificador sino el nombre de otra referencia. A eso se le llama referencia simbólica y es una indirección de un solo nivel.
cat .git/HEAD
# ref: refs/heads/main
git symbolic-ref HEAD
# refs/heads/main
git rev-parse --symbolic-full-name HEAD # el nombre al que apunta
git rev-parse HEAD # el identificador final
Con esto, el famoso estado de cabeza desprendida deja de ser un misterio y pasa a ser una frase literal: la cabeza contiene un identificador en lugar de un nombre de rama. No hay nada roto ni anómalo, simplemente falta la indirección. Y de ahí se sigue por qué en ese estado los commits nuevos parecen desaparecer al cambiar de rama: cada commit mueve la cabeza, pero como ninguna rama apuntaba a ella, al mover la cabeza a otro sitio no queda ningún camino que llegue a esos objetos. Los objetos siguen en el almacén; lo que no queda es un nombre que los alcance.
flowchart LR H[HEAD referencia simbolica] --> R[refs slash heads slash main] R --> C3[commit c3] C3 --> C2[commit c2] C2 --> C1[commit c1] HD[HEAD desprendida] --> C2 T[refs slash tags slash v1] --> C1 style H fill:#f9e2af,color:#11111b style HD fill:#f38ba8,color:#11111b style R fill:#f9e2af,color:#11111b
Mover un puntero: la única operación que puede perder algo
Ahora se puede reinterpretar el catálogo entero de operaciones temidas, y la reinterpretación es la misma en todos los casos. Un reset duro no borra commits: escribe otro identificador en el fichero de la rama y sincroniza el árbol de trabajo. Un rebase no reescribe la historia: crea commits nuevos con contenido parecido y direcciones distintas, y luego mueve la rama a la punta de los nuevos. Una fusión no mezcla nada en el almacén: crea un commit con dos padres y mueve la rama. En los tres casos, la parte que añade objetos es inofensiva y la parte que puede doler es una sola escritura de cuarenta y un bytes.
Esta relectura tiene una utilidad práctica inmediata que va más allá de perder el miedo. Cuando algo sale mal, la pregunta correcta deja de ser qué comando deshace esto y pasa a ser cuál era el valor anterior de la referencia que se movió. Formulada así, la respuesta casi siempre está a un comando de distancia y no depende de recordar la semántica exacta de la operación que causó el problema. Da igual si fue un rebase, un reset, una fusión mal resuelta o un tirón que trajo cosas inesperadas: en todos los casos el arreglo consiste en devolver un puntero a un valor que sigue estando registrado.
Por eso Git protege esa escritura con dos mecanismos que conviene conocer explícitamente. El primero es la actualización condicional: se puede exigir que la referencia tenga un valor concreto antes de cambiarla, y si no lo tiene la operación falla en lugar de pisar el trabajo de otro. Es la primitiva de comparar e intercambiar de toda la vida, aplicada a un fichero.
VIEJO=$(git rev-parse main)
NUEVO=$(git rev-parse main~1)
# Solo actualiza si el valor actual es el que yo creo que es
git update-ref refs/heads/main $NUEVO $VIEJO
# Con un valor esperado incorrecto, la operacion falla en vez de pisar
git update-ref refs/heads/main $VIEJO 0000000000000000000000000000000000000000
# Varias referencias de forma atomica o ninguna
printf 'start\nupdate refs/heads/x %s\nprepare\ncommit\n' $NUEVO | git update-ref --stdin
El segundo mecanismo es el registro de referencias, que anota cada valor por el que ha pasado cada referencia local junto con el motivo del cambio. Es una bitácora local de la capa mutable y es la razón de que casi todo accidente sea reversible.
git reflog show main
# 97bf1bb main@{0}: commit: dos ficheros
git reset --hard main@{1} # volver al valor anterior de la rama
git fsck --unreachable # objetos que ya no alcanza ninguna referencia
Los objetos que dejan de estar alcanzados no desaparecen de inmediato, pero tampoco son eternos. Sobreviven mientras el registro de referencias los mencione, y ese registro caduca: hay un plazo por omisión de noventa días para lo alcanzable y de treinta para lo que no lo está. Cuando git gc se ejecuta, ya sea a mano o de forma automática, los objetos caducados se eliminan de verdad. La lectura práctica es que la ventana de recuperación es amplia y no infinita, y que en un incidente lo primero es mirar el registro, no reconstruir a mano.
Todo lo difícil vive arriba, y eso no es casualidad
Capa inmutable enorme
Millones de objetos donde escribir nunca destruye y donde dos réplicas se unen sin negociar nada.
Capa mutable diminuta
Unos pocos miles de punteros donde vive el estado actual y donde una escritura puede perder trabajo.
Bloqueo por fichero
Cada actualización crea un fichero con sufijo de bloqueo, escribe y renombra, que es la forma clásica de atomicidad.
Bitácora local de punteros
El registro de referencias existe porque solo la capa mutable necesita poder deshacerse.
Hay una asimetría más entre ambas capas que conviene nombrar porque suele pasar inadvertida y explica varias rarezas. El registro de referencias es estrictamente local y nunca se sincroniza: cada clon tiene el suyo y solo contiene los movimientos que ocurrieron en esa máquina. Esto tiene sentido porque describe decisiones de un usuario concreto y no hechos del proyecto, pero implica que la red de seguridad no viaja. Un objeto que solo estaba alcanzado por el registro de una máquina desaparece del mundo si esa máquina se pierde, aunque el objeto en sí fuera perfectamente válido.
Poner las cuatro tarjetas juntas revela el diseño. Todo lo que en un sistema distribuido resulta caro —el bloqueo, la atomicidad, la comparación antes de escribir, la bitácora para deshacer, el rechazo por conflicto— aparece únicamente en la capa de referencias. Y aparece ahí porque es la única capa donde dos actores pueden querer cosas incompatibles a la vez. En el almacén eso no puede ocurrir: si dos personas escriben, o escriben el mismo objeto, y entonces es literalmente el mismo, o escriben objetos distintos, y entonces conviven sin rozarse.
Merece la pena mirar de cerca el mecanismo de atomicidad porque es deliberadamente humilde y por eso funciona en todas partes. Actualizar una referencia consiste en crear un fichero con el mismo nombre y un sufijo de bloqueo, escribir en él el valor nuevo y renombrarlo encima del original. El renombrado dentro de un mismo sistema de ficheros es atómico, así que cualquier lector ve el valor viejo o el nuevo y nunca uno a medias, y si el proceso muere en mitad de la operación lo único que queda es un fichero de bloqueo huérfano que se puede borrar. No hay diario, ni transacciones del almacén, ni cooperación del sistema operativo más allá de una llamada que existe en todos: la robustez sale de haber reducido el problema a escribir cuarenta y un bytes en un sitio.
Este es también el punto donde aparece el único conflicto que Git no resuelve solo. Cuando una publicación es rechazada porque la referencia remota ha avanzado, no está fallando el almacén: los objetos viajaron perfectamente y están en el otro extremo. Lo que falla es que dos actores quieren que un mismo puntero apunte a sitios distintos, y eso es una decisión que ningún algoritmo puede tomar sin conocer la intención. La opción prudente para forzar ese cambio, git push --force-with-lease, no es más que la actualización condicional que ya vimos, aplicada a la referencia del otro extremo.
Esta es la conclusión que hay que llevarse y la que reordena todo lo que se ha visto en el resto del recorrido. El almacén de Git converge sin coordinación de una forma casi mágica: dos réplicas que han trabajado meses por separado se unen calculando la unión de sus conjuntos de objetos, sin resolver nada, sin preguntar quién llegó antes, sin relojes ni votaciones. Y sin embargo Git no es un sistema sin conflictos, porque la convergencia gratuita solo cubre la parte del sistema que no tiene noción de estado actual. En cuanto hace falta decir cuál es la versión buena, aparece un puntero, y un puntero es una variable compartida, y una variable compartida entre actores que no se hablan es el problema del acuerdo distribuido en su forma pura. Git no lo esquiva ni pretende haberlo resuelto: lo acorrala hasta reducirlo a su expresión mínima y luego, con notable honestidad, se lo devuelve a la persona en el único momento en que aparece. Dos consecuencias para quien diseña sistemas locales primero. La primera es de arquitectura y es casi una receta: mide tu diseño por el tamaño de su capa mutable, porque el esfuerzo de sincronizar, la superficie de conflicto y la probabilidad de perder datos crecen con esa capa y son casi indiferentes al tamaño de la inmutable; si tu capa mutable tiene un puntero por documento, tu problema es tratable, y si tiene uno por campo editable, has reproducido el problema que querías evitar. La segunda es de expectativas: los CRDT que se estudiaron en los niveles anteriores no derogan esta ley, la pagan por otra vía, definiendo de antemano una regla determinista que decide por ti cuando dos punteros discrepan. Un CRDT no elimina el conflicto, elimina la conversación sobre el conflicto, y a cambio te obliga a aceptar de antemano un resultado que quizá no habrías elegido. Git escogió lo contrario: convergencia total abajo, decisión humana arriba. Ninguna de las dos opciones es superior en abstracto, pero elegir sin saber que estás eligiendo entre esas dos es la forma más común de acabar con un sincronizador que pierde datos en silencio.
- Localiza tus ramas en el disco antes y después de ejecutar
git pack-refs --ally comprueba que la semántica no cambia. - Pon la cabeza en estado desprendido, haz un commit, cambia de rama y recupéralo usando solo
git reflog. - Provoca a propósito un fallo de actualización condicional pasando un valor antiguo incorrecto a
git update-ref. - Ejecuta una transacción de varias referencias con
git update-ref --stdiny comprueba que un error deja todo sin tocar. - Cuenta objetos y referencias de un repositorio grande y calcula la proporción entre ambas capas.
- Haz un reset duro, encuentra los objetos huérfanos con
git fsck --unreachabley devuélvelos a la vida creando una rama nueva.