wandres.dev
GIT COMO CAS · el ejemplo que ya usas

La revelación: el almacén está debajo, no dentro

Git no es un control de versiones con un almacén dentro sino un almacén direccionado por contenido con una interfaz de versiones encima, y sus propios comandos de bajo nivel lo demuestran sin ambigüedad.

⏱ 22 min

Casi todo el mundo aprende Git por su superficie: se hace un commit, se cambia de rama, se fusiona, y cuando algo sale mal se busca en la red el conjuro que lo arregla. Bajo esa superficie hay una arquitectura que rara vez se enuncia con precisión, y enunciarla bien cambia por completo lo que uno entiende del sistema. La formulación habitual dice que Git es un control de versiones que guarda las cosas en un almacén interno. La formulación correcta invierte los términos: Git es un almacén direccionado por contenido, y el control de versiones es una interfaz construida sobre él. La diferencia no es retórica ni pedagógica. Determina qué operaciones son baratas, cuáles son peligrosas, qué se puede replicar sin coordinación y qué exige acuerdo. Esta lección no pide creer en esa inversión: la demuestra con los comandos que el propio Git expone para manipular el almacén sin pasar por la interfaz, y termina construyendo un commit válido sin usar nunca el comando de hacer commits.

🎯 Al terminar esta lección sabrás
  • Distinguir la capa de fontanería de la capa de interfaz y saber por qué Git documenta ambas por separado.
  • Comprobar con las manos que el identificador de un objeto es una función pura de su contenido.
  • Reproducir a mano el cálculo del identificador para dejar de tratarlo como una caja negra.
  • Construir un commit alcanzable desde una rama usando solo comandos de bajo nivel.
  • Reconocer qué propiedades del sistema vienen del almacén y cuáles vienen de la interfaz.

Fontanería e interfaz: dos vocabularios en el mismo programa

Git divide sus comandos en dos familias y lo hace de forma explícita en su propia documentación. La familia de interfaz, la que se usa a diario, está pensada para personas: acepta abreviaturas, adivina intenciones, imprime mensajes con consejos y cambia su salida entre versiones porque su contrato es con el usuario. La familia de fontanería está pensada para programas: su salida es estable, su entrada es estricta y sus operaciones son mínimas. Es esta segunda familia la que deja ver la arquitectura, porque cada uno de sus comandos hace exactamente una cosa del almacén y ninguna de la interfaz.

El punto de partida es el directorio .git/objects. Ahí no hay ficheros con nombres reconocibles, ni versiones numeradas, ni diferencias entre revisiones. Hay directorios de dos caracteres hexadecimales, y dentro de cada uno, ficheros cuyo nombre son los treinta y ocho caracteres restantes de un identificador. Ese identificador no lo asigna nadie: se calcula a partir del contenido que se va a guardar. Un almacén así se llama direccionado por contenido, y la propiedad que lo define es que la dirección de un dato no es un lugar donde alguien decidió ponerlo, sino una consecuencia de lo que el dato es.

mkdir /tmp/almacen && cd /tmp/almacen
git init -q -b main
git config user.email "tu@ejemplo.org"
git config user.name "Tu Nombre"

# Guardar contenido en el almacen sin tocar el arbol de trabajo
echo "hola" | git hash-object -w --stdin
# 5c1b14949828006ed75a3e8858957f86a2f7e2eb

ls .git/objects/5c/
# 1b14949828006ed75a3e8858957f86a2f7e2eb

La ruta partida en dos caracteres no es una excentricidad estética sino una concesión al sistema de ficheros. Muchos sistemas degradan notablemente cuando un directorio contiene cientos de miles de entradas, y repartir los objetos en doscientos cincuenta y seis subdirectorios mantiene cada uno en un tamaño manejable. Es la primera aparición de un patrón que recorre todo el nivel: el modelo lógico es limpio y el almacenamiento hace concesiones al mundo real, pero las concesiones nunca se filtran hacia arriba porque nada del modelo depende de dónde acabó cada byte.

Merece la pena detenerse en lo que acaba de ocurrir, porque es más raro de lo que parece. No se ha creado un fichero en el proyecto, no se ha registrado un cambio, no se ha declarado una intención. Se ha metido una secuencia de bytes en un almacén y el almacén ha devuelto la única dirección posible para esa secuencia. Si se repite el comando con el mismo contenido, el resultado es idéntico y no se escribe nada nuevo, porque el objeto ya está. La escritura es idempotente por construcción, no por cuidado del programador.

ℹ️
La fontanería no es una API interna, es una interfaz pública y estable

Es tentador leer estos comandos como detalles de implementación que uno no debería usar. No lo son. Git los documenta, mantiene su formato de salida y espera que los guiones y las herramientas los usen. git hash-object, git cat-file, git write-tree, git commit-tree, git update-ref y git for-each-ref tienen páginas de manual propias y contratos explícitos. Cuando alguien construye una interfaz gráfica de Git seria, habla con esta capa, no con la de las abreviaturas.

El identificador no es un número mágico

Conviene desactivar la mitología del hash cuanto antes, porque mientras el identificador parezca magia el resto del sistema seguirá pareciendo magia. Git guarda un objeto formando primero una cabecera con el tipo, un espacio, el tamaño en bytes y un byte cero, y a continuación el contenido íntegro. El identificador es el resumen criptográfico de esa concatenación. Nada más. Se puede calcular con herramientas ajenas a Git y comprobar que coinciden.

# El calculo que hace Git, hecho a mano
printf 'blob 5\0hola\n' | sha1sum
# 5c1b14949828006ed75a3e8858957f86a2f7e2eb  -

# El mismo calculo, hecho por Git
echo "hola" | git hash-object -t blob --stdin
# 5c1b14949828006ed75a3e8858957f86a2f7e2eb

Que el algoritmo sea sustituible refuerza el argumento en lugar de debilitarlo: un repositorio puede crearse con resumen de 256 bits con git init --object-format=sha256, y entonces los identificadores son otros pero la arquitectura es exactamente la misma. Lo que importa no es qué función se use sino que la dirección se derive del contenido. De ahí salen tres consecuencias que ninguna capa superior tiene que implementar: la deduplicación es automática, porque dos contenidos iguales ocupan una sola entrada; la verificación es local, porque cualquiera puede recalcular la dirección y comprobar que el dato no se corrompió; y la copia entre máquinas no necesita renombrar nada, porque el nombre viaja con el dato.

La verificación local merece un momento más de atención porque es una propiedad rara y valiosísima. En casi cualquier otro almacén, comprobar que un dato no se ha corrompido exige compararlo con otra copia o confiar en una suma de control que alguien guardó aparte y que a su vez podría estar corrompida. Aquí la comprobación es intrínseca: el nombre del objeto es su propia suma de control, y no hay forma de tener el objeto sin tener el medio de verificarlo. Un disco que empieza a fallar se detecta sin comparar con nadie y sin conexión, que es exactamente la situación en la que se encuentra un dispositivo local-first.

# Inspeccionar cualquier objeto sin saber nada de ramas ni commits
git cat-file -t 5c1b14949828006ed75a3e8858957f86a2f7e2eb   # blob
git cat-file -s 5c1b14949828006ed75a3e8858957f86a2f7e2eb   # 5
git cat-file -p 5c1b14949828006ed75a3e8858957f86a2f7e2eb   # hola
💡
Un comando para ver el almacén entero de golpe

Cuando quieras auditar un repositorio real sin filtros, git cat-file --batch-all-objects --batch-check recorre todos los objetos del almacén y admite un formato configurable. Con --batch-check='%(objectname) %(objecttype) %(objectsize)' obtienes un censo completo en una sola pasada, y es la forma honesta de responder a preguntas como cuánto pesa de verdad la historia o cuántos objetos hay que no alcanza ninguna rama.

Un commit sin usar el comando de hacer commits

La demostración definitiva de que la interfaz está encima y no dentro consiste en prescindir de ella por completo. Vamos a producir un commit alcanzable desde una rama, indistinguible de cualquier otro, sin escribir jamás git commit. Cada paso toca una sola pieza: el contenido, el listado de nombres, el registro histórico y el puntero.

cd /tmp/almacen

# 1. El contenido entra en el almacen y devuelve su direccion
BLOB=$(echo "hola" | git hash-object -w --stdin)

# 2. Un listado que asocia un nombre a esa direccion
git update-index --add --cacheinfo 100644,$BLOB,saludo.txt
ARBOL=$(git write-tree)

# 3. Un registro historico que apunta a ese listado
COMMIT=$(git commit-tree $ARBOL -m "primero")

# 4. Un puntero mutable que apunta a ese registro
git update-ref refs/heads/main $COMMIT

git log --oneline
git cat-file -p HEAD

Lo que aparece al final es un repositorio normal, con su rama, su historia y su contenido, y sin embargo no se ha ejecutado ni un solo comando de la capa de interfaz. Cada uno de los cuatro pasos ha hecho algo distinto y la diferencia entre ellos es el núcleo de este nivel: los tres primeros añaden objetos inmutables a un almacén y son intrínsecamente seguros, porque añadir un objeto nunca destruye otro; el cuarto modifica un puntero, y ahí sí se puede perder algo, porque el valor anterior deja de estar apuntado por nadie.

flowchart TB
A[interfaz para personas commit merge rebase] --> B[capa de referencias mutables]
B --> C[almacen direccionado por contenido]
C --> D[objetos inmutables cuya direccion es su resumen]
A -.-> E[cambia entre versiones]
C -.-> F[formato estable y verificable]
style C fill:#a6e3a1,color:#11111b
style B fill:#f9e2af,color:#11111b

Vale la pena insistir en el orden de los pasos porque no es arbitrario y se repite en todo el sistema. Nunca se apunta a algo que no exista todavía: el blob existe antes de que el árbol lo nombre, el árbol existe antes de que el commit lo referencie y el commit existe antes de que la rama apunte a él. Esa disciplina de construir de abajo hacia arriba y publicar al final es la que garantiza que en ningún instante intermedio haya una referencia colgando, y es la misma razón por la que una sincronización interrumpida a media transferencia deja el repositorio del receptor exactamente como estaba: los objetos llegaron, pero como la referencia no se movió, no forman parte de nada.

Qué gana y qué pierde el sistema con esta forma

🧱

Añadir nunca destruye

Escribir un objeto no puede pisar otro, porque dos contenidos distintos tienen direcciones distintas y dos iguales son el mismo objeto.

🔍

Integridad comprobable en local

Cualquier réplica puede recalcular las direcciones y detectar corrupción sin preguntar a nadie, que es lo que hace git fsck.

📦

Replicación sin negociar nombres

Copiar objetos entre máquinas no requiere traducir identificadores ni resolver colisiones de nomenclatura.

⚠️

El riesgo se concentra arriba

Todo lo que puede perder trabajo vive en la capa de punteros, y por eso Git le pone una red de seguridad aparte.

La tercera tarjeta es la que más pesa cuando el sistema deja de ser un experimento. Que copiar objetos no requiera negociar nombres significa que la fusión de dos almacenes es la operación de conjuntos más aburrida que existe: se vuelcan los objetos de uno en el otro y ya está, sin comparar, sin resolver, sin preguntar quién llegó antes y sin posibilidad de que algo quede a medias. En un sistema con identificadores asignados por alguien, ese mismo paso exige una tabla de traducción, una política para los choques y una forma de propagar las traducciones a todo lo que apuntaba a los identificadores antiguos. Es la diferencia entre una tarde y un trimestre.

Esta última tarjeta anticipa la tercera lección y conviene dejarla planteada ya. Cuando alguien dice que un comando de Git le borró el trabajo, casi nunca es cierto en el sentido literal: los objetos siguen en el almacén y suelen ser recuperables durante semanas. Lo que ocurrió es que un puntero se movió y dejó de haber un camino que llegara hasta ellos. Perder el camino y perder el dato son cosas distintas, y confundirlas es lo que hace que Git parezca impredecible.

Direccionar por contenido es renunciar a decidir dónde van las cosas, y esa renuncia es lo que compra la distribución

Si de este nivel hubiera que salvar una sola idea sería esta, porque explica a la vez el diseño de Git y el de casi todo sistema distribuido que funciona bien. En un almacén convencional, alguien decide la dirección de cada dato: un identificador autoincremental, una ruta, una clave elegida por la aplicación. Esa decisión parece inocua y es exactamente el punto donde nace la necesidad de coordinación, porque dos nodos que deciden por separado pueden decidir lo mismo para cosas distintas, y entonces hay que ponerse de acuerdo antes de escribir. Direccionar por contenido elimina la decisión: nadie elige la dirección, la dirección se calcula, y dos nodos que guardan el mismo contenido llegan al mismo nombre sin haber hablado nunca. Lo que se obtiene a cambio es una lista de propiedades que normalmente hay que construir con esfuerzo y aquí salen gratis: la escritura es idempotente, la unión de dos almacenes es su unión de conjuntos y no necesita resolver nada, la caché nunca se invalida porque un nombre siempre significa lo mismo, y la verificación de integridad no necesita una autoridad. Todo eso a cambio de una renuncia concreta y asumible: el almacén no puede expresar el paso del tiempo, porque un objeto nunca cambia, y por tanto hace falta algo más para decir cuál es el estado actual. Ese algo más son las referencias, y aquí está el reparto que hay que interiorizar antes de diseñar cualquier sistema local-first: se separa lo que puede replicarse libremente de lo que exige acuerdo, y se hace lo segundo tan pequeño como sea posible. Git no evita el problema del acuerdo distribuido, lo encierra en un puñado de ficheros de cuarenta y un bytes y deja que todo lo demás, que es el noventa y nueve por ciento del sistema, viva en un régimen donde el acuerdo sencillamente no hace falta.

⚔️ Desmonta un repositorio real hasta el fondo
  1. Crea un repositorio nuevo y guarda un contenido con git hash-object -w --stdin sin crear ningún fichero.
  2. Reproduce el identificador con printf y sha1sum incluyendo la cabecera y el byte cero.
  3. Comprueba que guardar dos veces el mismo contenido devuelve la misma dirección y no añade objetos, midiéndolo con git count-objects -v.
  4. Construye un commit completo usando solo git update-index, git write-tree, git commit-tree y git update-ref.
  5. Repite el ejercicio en un repositorio creado con git init --object-format=sha256 y compara qué cambia y qué no.
  6. Lanza git cat-file --batch-all-objects --batch-check sobre un repositorio grande que uses y observa la proporción entre tipos de objeto.