Los cuatro objetos: blob, árbol, commit y etiqueta
El almacén de Git contiene exactamente cuatro tipos de objeto, cada uno con un formato mínimo y un identificador derivado de su contenido, y al enlazarse forman literalmente el Merkle DAG del nivel anterior.
Un almacén direccionado por contenido podría guardar bytes sin más y dejar toda la estructura para las capas de arriba. Git no hace eso: define cuatro tipos de objeto, y solo cuatro, con formatos tan escuetos que caben en una página. Esa parquedad es deliberada y es la razón de que el formato lleve dos décadas siendo compatible consigo mismo. Esta lección abre los cuatro, muestra sus bytes con los comandos que ya conoces, calcula sus identificadores y demuestra que al enlazarse no producen algo parecido a un Merkle DAG sino exactamente uno, con la propiedad que lo define: el identificador de un nodo depende del identificador de todos sus descendientes, y por tanto una sola dirección de treinta y tantos caracteres resume sin ambigüedad un estado completo y toda su historia.
- Conocer el contenido exacto de los cuatro tipos de objeto y qué información deliberadamente no guarda cada uno.
- Entender por qué un blob no tiene nombre y qué consecuencia práctica tiene eso.
- Leer un árbol crudo con sus modos y comprender el papel de los modos en la identidad.
- Verificar que el identificador de un commit cubre transitivamente todo su contenido y su pasado.
- Situar la etiqueta anotada como el único objeto pensado para firmar y no para estructurar.
El blob y el árbol: contenido sin nombre y nombres sin contenido
El primer objeto es el más simple y el más contraintuitivo. Un blob contiene los bytes de un fichero y absolutamente nada más: ni el nombre del fichero, ni su ruta, ni permisos, ni fechas, ni de qué proyecto vino. Dos ficheros idénticos en repositorios distintos, en rutas distintas y con nombres distintos, son el mismo blob. Esa amnesia es la que hace que renombrar un fichero de veinte megas no cueste nada, y también la razón de que Git no guarde renombrados: los detecta después, comparando, porque en el almacén no hay nada que registrar.
El segundo objeto pone los nombres que el primero no tiene. Un árbol es una lista ordenada de entradas, y cada entrada tiene un modo, un nombre y la dirección de otro objeto, que puede ser un blob o a su vez otro árbol. Esa recursión es la que representa directorios anidados. Los modos son un conjunto cerrado y pequeño, y conviene conocerlos porque forman parte de lo que se resume: 100644 para un fichero normal, 100755 para uno ejecutable, 120000 para un enlace simbólico, 40000 para un subárbol y 160000 para la referencia a otro repositorio.
cd /tmp/almacen
echo "hola" > saludo.txt
mkdir -p doc && echo "notas" > doc/leeme.md
git add . && git commit -q -m "dos ficheros"
# El arbol de la revision actual, con su entrada de subarbol
git cat-file -p 'HEAD^{tree}'
# Todo el arbol de forma recursiva, incluyendo los subarboles
git ls-tree -r -t HEAD
Conviene fijarse en el reparto de responsabilidades entre ambos objetos, porque es una separación de diseño muy limpia y no una casualidad. El blob responde a la pregunta de qué hay; el árbol responde a la pregunta de cómo se llama y dónde está. Al no mezclarlas, mover un fichero de sitio no toca el contenido, cambiar el contenido no toca la estructura de nombres, y dos proyectos distintos que contienen el mismo fichero de licencia comparten un único objeto sin haberse coordinado jamás. La misma separación aplicada a datos de aplicación es igual de útil: guardar el valor por un lado y el nombre por otro es lo que permite renombrar sin reescribir y deduplicar sin comparar.
Que el modo forme parte del contenido del árbol tiene una consecuencia que sorprende la primera vez: dar permiso de ejecución a un guion cambia el árbol y por tanto cambia el commit, aunque el contenido del fichero sea byte a byte el mismo. El blob no se toca, porque el blob no sabe nada de permisos. Lo que cambia es la entrada que lo nombra.
El comando imprime los árboles de forma legible, con el tipo escrito y el identificador en hexadecimal, y eso induce a pensar que así están guardados. No lo están. En el objeto real cada entrada es el modo en octal como texto, un espacio, el nombre, un byte cero y el identificador en binario, sin separadores adicionales. Si intentas recalcular el resumen de un árbol a partir de la salida legible nunca te saldrá, y no será culpa del algoritmo. El orden de las entradas tampoco es libre: está fijado, y por eso dos árboles con el mismo contenido son el mismo objeto en cualquier máquina.
El commit y la etiqueta: el tiempo y el sello
El tercer objeto es el que la gente confunde con Git entero. Un commit es texto plano con una estructura fija: una línea que apunta al árbol raíz, cero o más líneas que apuntan a commits padres, la identidad y fecha de quien lo redactó, la identidad y fecha de quien lo aplicó, opcionalmente una firma, una línea en blanco y el mensaje. Nada más. En particular, un commit no contiene una diferencia respecto a nada. Contiene un árbol completo, es decir, una instantánea del proyecto entero.
git cat-file -p HEAD
# tree 40ce32801aa3d5001925dc4dc342210ec3b57b2e
# author Tu Nombre 1785938457 -0500
# committer Tu Nombre 1785938457 -0500
#
# dos ficheros
git cat-file -t HEAD # commit
git cat-file -s HEAD # tamano en bytes del objeto
Que un commit no contenga diferencias merece un párrafo aparte porque desmonta la explicación más repetida sobre Git. La diferencia entre dos revisiones no está guardada en ninguna parte: se calcula en el momento en que alguien la pide, comparando dos árboles. Esa es la razón de que se pueda pedir la diferencia entre dos puntos cualesquiera de la historia con el mismo coste, aunque estén separados por diez años y no exista ningún camino directo entre ellos, y también la razón de que el mismo par de revisiones pueda mostrarse con opciones distintas —ignorando espacios, detectando renombrados, con más o menos contexto— sin que nada de eso afecte a lo almacenado.
El número de líneas de padre es lo único variable y define la topología: cero padres en la raíz de la historia, uno en el caso corriente, dos o más en una fusión. Ese detalle es el que convierte la lista de commits en un grafo dirigido acíclico, y es también el que hace que el orden de los padres importe, porque la primera posición tiene significado para muchas herramientas.
El cuarto objeto es el que menos se usa y el único que existe casi por una razón ajena a la estructura. Una etiqueta anotada es un objeto que apunta a otro objeto, declara de qué tipo es, lleva un nombre, la identidad de quien la creó y un mensaje, y admite una firma criptográfica. Sirve para sellar un punto de la historia con una afirmación humana verificable.
git tag -a v1 -m "primera version publicada"
# La etiqueta ligera solo crea una referencia y no crea objeto
git tag v1-ligera
git cat-file -t $(git rev-parse v1) # tag
git cat-file -p $(git rev-parse v1)
git for-each-ref --format='%(refname) %(objecttype) %(objectname)'
El detalle que hace a la etiqueta interesante para este recorrido no es su uso habitual sino su forma. Es el único objeto cuyo propósito principal es afirmar algo sobre otro objeto, y por eso es el punto natural donde se ancla una firma. Firmar una etiqueta que apunta a un commit equivale a firmar el estado completo del proyecto en ese punto y toda su historia anterior, porque la dirección del commit ya resume ambas cosas. Una sola firma sobre cuarenta caracteres cubre millones de líneas, y esa economía es una consecuencia directa del Merkle DAG que se describe en la sección siguiente.
Ese último comando enseña algo que confunde a mucha gente: la etiqueta anotada aparece con tipo tag porque la referencia apunta a un objeto de etiqueta que a su vez apunta al commit, mientras que la ligera aparece con tipo commit porque apunta directamente. Es la diferencia entre poner un sello y poner un adhesivo.
Con git cat-file --batch-all-objects --batch-check='%(objecttype)' y una tubería a sort y uniq -c obtienes en segundos la composición real de cualquier repositorio. En proyectos con muchos ficheros pequeños los árboles suelen superar en número a los blobs, y ese dato cambia por completo qué optimización merece la pena. Medir antes de opinar es especialmente barato aquí.
El enlace: por qué esto es exactamente un Merkle DAG
Reunidos los cuatro, la estructura aparece sola. Un commit contiene la dirección de un árbol; el árbol contiene direcciones de blobs y de otros árboles; el commit contiene además direcciones de otros commits. Y como la dirección de cada objeto es el resumen de su contenido, y su contenido incluye las direcciones de sus hijos, el resumen de un objeto depende transitivamente del contenido completo de todo lo que cuelga de él. Esa es la definición de árbol de Merkle, generalizada a un grafo porque los commits tienen varios padres y varios hijos.
flowchart TB T1[etiqueta v1] --> C2[commit segundo] C2 --> C1[commit primero] C2 --> A2[arbol raiz] A2 --> B1[blob saludo] A2 --> A3[arbol doc] A3 --> B2[blob leeme] C1 --> A1[arbol antiguo] A1 --> B1 style B1 fill:#a6e3a1,color:#11111b style C2 fill:#89b4fa,color:#11111b
Conviene señalar dónde está exactamente el acíclico de esa sigla, porque no es una restricción impuesta desde fuera sino una imposibilidad aritmética. Para que un commit apuntara a un descendiente suyo, su contenido tendría que incluir la dirección de ese descendiente; pero la dirección del descendiente se calcula a partir de un contenido que ya incluye la del ancestro. Cerrar el ciclo exigiría encontrar un contenido cuyo resumen aparezca dentro de sí mismo, que es justamente lo que una función de resumen criptográfica hace inviable. El grafo no es acíclico porque alguien lo compruebe, sino porque no hay forma de construir un ciclo.
El diagrama tiene un detalle que no es decorativo: el blob de saludo cuelga de dos árboles distintos. No hay dos copias, hay un objeto compartido, y eso ocurre sin que nadie lo programe porque los dos árboles calcularon la misma dirección para el mismo contenido. La compartición estructural, que en otros sistemas es una optimización explícita, aquí es una consecuencia inevitable de direccionar por contenido.
De esa forma se derivan las propiedades operativas que uno usa a diario sin nombrarlas.
Un identificador resume un mundo
La dirección de un commit determina sin ambigüedad el contenido de todos los ficheros y toda la historia anterior.
Comparar es comparar direcciones
Si dos árboles tienen la misma dirección, sus subárboles son idénticos y no hay nada que recorrer.
Compartición sin esfuerzo
Lo que no cambia entre dos revisiones no se duplica, porque produce los mismos objetos.
Falsificar exige romper el resumen
Alterar cualquier byte del pasado cambia todas las direcciones que descienden de él y rompe la cadena entera.
La cuarta tarjeta se puede verificar en un minuto y merece la pena hacerlo, porque ver la propagación con los propios ojos fija la idea mejor que cualquier explicación. Basta con reescribir un commit antiguo y comprobar que todos los identificadores posteriores cambian, incluidos los de las revisiones que no se tocaron.
# Tocar el pasado obliga a recrear todos sus descendientes
git log --format='%h %s'
git rebase --force-rebase --root
git log --format='%h %s' # ninguna direccion coincide con la anterior
La segunda tarjeta es la que sostiene el rendimiento de casi todo. Cuando Git compara dos revisiones y encuentra un subárbol con la misma dirección en ambas, no lo abre. No compara los ficheros, no lee los blobs, no hace nada: sabe que son idénticos porque el resumen lo garantiza. En un repositorio con cien mil ficheros donde cambiaron tres, el trabajo real es proporcional a los tres y no a los cien mil, y esa poda no la implementó nadie en el comparador: la regala la estructura.
Aquí está la idea que más resistencia genera y la que más rinde una vez aceptada. Todo el mundo asume que un sistema de versiones debe guardar diferencias, porque guardar el proyecto entero en cada revisión suena a despilfarro insostenible. Git guarda instantáneas completas, y no a pesar del coste sino porque el coste no existe: como cada objeto se nombra por su contenido, la instantánea de mil ficheros de los que cambió uno no contiene mil objetos nuevos sino uno, más la cadena de árboles que va desde la raíz hasta él, que en un proyecto normal son tres o cuatro. El resto de las entradas apuntan a los objetos que ya estaban. La instantánea es lógicamente completa y físicamente incremental a la vez, y esa combinación es lo que la hace superior a guardar diferencias en todos los aspectos que importan. Guardar diferencias obliga a reconstruir para leer, hace que el coste de acceder a una revisión dependa de cuántas revisiones haya delante, convierte cualquier corrupción en una pérdida de todo lo posterior y hace que comparar dos puntos lejanos sea caro. Guardar instantáneas con compartición estructural da acceso en tiempo constante a cualquier revisión, comparación en tiempo proporcional a lo que cambió y aislamiento total de los daños. La diferencia deja de ser una forma de almacenar y pasa a ser una forma de mostrar, que es donde debía haber estado siempre. Y la lección transferible al diseño local-first es directa y se aplica muchísimo más allá de Git: cuando dudes entre guardar estados o guardar cambios, comprueba primero si puedes hacer que los estados compartan estructura por identidad de contenido, porque si puedes, la disyuntiva desaparece y te quedas con la semántica simple del estado y el coste del cambio. Casi todos los sistemas que eligieron guardar diferencias lo hicieron antes de que esa opción existiera, y arrastran la decisión por inercia.
- Crea un repositorio con un subdirectorio y recorre su árbol completo con
git ls-tree -r -t HEADidentificando cada modo. - Cambia el permiso de ejecución de un fichero y comprueba que el blob no cambia pero el árbol y el commit sí.
- Copia un fichero idéntico a otra ruta y verifica con
git cat-file --batch-all-objects --batch-checkque no aparece ningún blob nuevo. - Crea una etiqueta anotada y otra ligera y compara ambas con
git for-each-refexplicando la diferencia de tipo. - Recalcula a mano el identificador de un blob y razona por qué el mismo método aplicado a un árbol requiere leer el objeto crudo.
- Toma un repositorio grande que uses y obtén el reparto por tipos de objeto para ver si dominan los blobs o los árboles.