El modelo de objetos de Git
La mayoría de las confusiones de Git —"¿por qué la diferencia de mi notebook es un muro de ruido?", "¿por qué desapareció mi rama?", "¿por qué rebase es peligroso pero merge no?"— provienen de imaginar Git como una pila de diferencias aplicadas en orden, como un historial de deshacer muy literal. Eso no es lo que es.
Git es una base de datos direccionable por contenido de un puñado de tipos de objetos, enlazados en un grafo mediante hashes. Cada comando que ejecutas —commit, branch, merge, rebase, tag— es una operación específica y bastante mecánica sobre ese grafo. Comandos Esenciales de Git cubre el conjunto de comandos diarios en torno a los cuales se construye esta sección; esta página es el modelo subyacente: la razón por la que .gitignore excluye .venv/, por qué las diferencias de los notebooks se ven como se ven, y por qué una rama de hotfix se corta de una etiqueta en lugar de main.
Resumen
- Git almacena cuatro tipos de objetos —blobs, trees, commits y tags— cada uno direccionado por el hash de su propio contenido, enlazados en un grafo acíclico dirigido (DAG); las ramas y las etiquetas son solo punteros con nombre en ese grafo.
- Por Qué Importa: Casi todas las operaciones "seguras vs. peligrosas" de Git son seguras o peligrosas específicamente por lo que hacen a este grafo, y varios puntos débiles específicos de Python (diferencias de notebooks, hinchazón de venv, cambios constantes en archivos de bloqueo) se remontan directamente a lo que realmente se almacena como una instantánea.
- Conceptos Clave: blob, tree, commit, ref, DAG (grafo acíclico dirigido), almacenamiento direccionable por contenido.
- Cuándo Usar Este Modelo: Decidir entre merge y rebase, entender por qué un directorio
.venv/nunca debe ser confirmado, razonar sobre por qué un notebook de Jupyter difiere mal por defecto, o explicar por qué una rama de hotfix se basa en una etiqueta de lanzamiento en lugar demain. - Limitaciones / Compensaciones: El modelo explica qué hacen las operaciones de Git en el historial, no reemplaza las convenciones de flujo de trabajo reales de un equipo, que aún deben acordarse y aplicarse (ver Mejores Prácticas de Git).
- Temas Relacionados: herramientas de diff de notebooks, estrategia de ramas, cambios constantes en archivos de bloqueo, protección de ramas.
Fundamentos
El almacenamiento de Git está construido a partir de cuatro tipos de objetos, y cada uno de ellos se identifica por el hash de su propio contenido, no por un nombre de archivo, no por un número de secuencia, sino por el contenido mismo.
Un blob almacena el contenido crudo de un archivo, solo bytes, sin nombre de archivo adjunto. Un tree almacena un listado de directorio: nombres, modos y el hash del blob o sub-tree al que apunta cada nombre. Un commit almacena un puntero a un tree (el estado de todo el repositorio en ese momento), un puntero a su commit padre (o dos, para un merge), un autor y un mensaje. Una tag (la anotada) es su propio objeto pequeño que apunta a un commit, típicamente con un mensaje y una firma.
La consecuencia que sorprende a las personas que vienen de un modelo mental de "secuencia de diferencias": un commit es una instantánea completa, no una diferencia. Cuando ejecutas git commit, Git escribe un objeto tree que representa el proyecto completo en ese instante. Lo hace de manera eficiente, porque cualquier archivo o sub-árbol byte a byte idéntico a un commit anterior ya tiene un objeto con ese hash exacto, por lo que Git lo reutiliza en lugar de almacenarlo dos veces.
La diferencia que ves en git show o git log -p se calcula bajo demanda comparando dos instantáneas, no es lo que se almacena realmente.
Una analogía simple: imagina un archivador donde cada carpeta y cada documento está etiquetado no por nombre sino por una suma de verificación de su contenido exacto, y documentos idénticos en cualquier lugar del archivador comparten una copia física sin importar cuántas carpetas lo referencien. Un commit es una tarjeta que dice "el archivador se veía exactamente así, ver etiqueta X para la carpeta de nivel superior, y la tarjeta anterior era Y". Ese es todo el modelo.
echo "myproject/.venv/" >> .gitignore
echo "**/__pycache__/" >> .gitignore
git add .gitignore pyproject.toml uv.lock src/
git commit -m "chore: exclude local venv and bytecode caches"Mecánicas e Interacciones
Debido a que los objetos se enlazan entre sí por hash, el historial completo de un repositorio forma un grafo acíclico dirigido (DAG): los commits apuntan hacia atrás a sus padres, nunca hacia adelante, y nunca en un ciclo.
v1.4.0 (tag) v1.5.0 (tag)
│ │
...──A────B───────C────D────E─────────F──... main
\ /
C1───C2───C3───────────┘ feature/tax-report (merged)
hotfix/1.4.1 branches from tag v1.4.0, not from main's current tip:
C (v1.4.0)
\
H1───H2 hotfix/1.4.1
Una rama (main, feature/tax-report) no es un contenedor que contiene un conjunto de commits, es un único puntero móvil (una "ref") a un commit, actualizado automáticamente para apuntar a cada nuevo commit a medida que lo creas. Crear una rama es casi gratuito precisamente por esto: git branch feature/x escribe un archivo pequeño que contiene un hash de commit, nada más. Cada commit en el grafo pertenece al grafo mismo, alcanzable desde donde sea que sea alcanzable, no "poseído" por la rama que actualmente apunte a él.
Un commit de merge es el único lugar donde la forma del DAG se vuelve genuinamente importante: es un objeto commit con dos punteros padre en lugar de uno, que es lo que hace que el grafo sea un DAG en lugar de una simple cadena.
Rebase y merge resuelven el mismo problema, trayendo el trabajo de otra rama a la tuya, a través de operaciones de grafo genuinamente diferentes, no solo comandos diferentes para el mismo resultado. Un merge añade un nuevo commit de dos padres encima de ambas historias, dejando cada commit original intacto. Un rebase reproduce los commits de tu rama uno por uno sobre una nueva base, calculando la diferencia de cada commit, y luego creando un objeto commit completamente nuevo con esa diferencia aplicada encima de la nueva base, mismo contenido, padre diferente, hash diferente. Los commits originales todavía existen en el grafo hasta que se recolectan; la rama rebasada ahora simplemente apunta a las nuevas copias.
Consideraciones Avanzadas y Aplicaciones
Los equipos de Python se enfrentan a este modelo de instantáneas de frente con dos puntos débiles muy específicos que no tienen nada que ver con que Git sea poco fiable y todo que ver con lo que se trata como un blob.
Un notebook de Jupyter (.ipynb) es un único archivo JSON que agrupa celdas de código y sus salidas renderizadas, incluyendo imágenes codificadas en base64, en un solo documento. Dado que un commit almacena todo el árbol, editar una línea de código pero volver a ejecutar una celda cambia el JSON de salida también, por lo que una "diferencia" de una línea en el código puede aparecer como un muro de JSON cambiado en el blob confirmado. Herramientas como nbstripout (un hook de pre-commit que elimina las celdas de salida antes de preparar) o nbdime (una herramienta de diff y merge consciente de notebooks) existen específicamente porque el diff de texto plano de Git no tiene conciencia especial de la estructura del notebook, difiere el JSON como cualquier otro archivo de texto; ver Git para Datos/Notebooks para la configuración concreta.
Un entorno virtual (.venv/) o un directorio __pycache__/ confirmado por accidente hincha cada clon y checkout futuro, porque esos archivos se convierten en parte de cada instantánea a partir de ese commit en adelante, incluso después de su eliminación posterior, los blobs históricos permanecen en el almacén de objetos a menos que el historial en sí sea reescrito. Esta es precisamente la razón por la que un .gitignore de Python que excluye .venv/, __pycache__/, *.pyc, y .mypy_cache/ desde el commit uno importa más de lo que podría parecer a primera vista.
Ese comportamiento de rebase, nuevos commits, no originales editados, es exactamente por qué "nunca hacer rebase (o force-push) de una rama compartida" es una regla real y no solo una precaución por sí misma. Si dos personas tienen una rama extraída y una hace force-push de una versión rebasada, el ref ahora apunta a un conjunto diferente de objetos commit con hashes diferentes a los que el historial local de la otra persona todavía apunta, Git no tiene forma de reconciliar "el mismo trabajo, hash diferente" automáticamente.
Eliminar una rama solo elimina el puntero, no los commits en sí, se vuelven inalcanzables (ningún ref apunta a ellos) pero permanecen en el almacén de objetos del repositorio hasta que la recolección de basura eventualmente los elimine, que es el mecanismo real detrás de git gc.
| Estrategia | Qué hace al grafo | Fortaleza | Mejor Ajuste |
|---|---|---|---|
| Commit de Merge | Añade un commit de dos padres; originales intactos | Preserva el historial exacto, seguro en ramas compartidas | Ramas de larga duración, ramas de lanzamiento de vuelta a main |
| Rebase | Reproduce commits como objetos nuevos sobre una base nueva | Historial lineal y legible | Ramas de características locales antes de ser compartidas/enviadas |
| Squash merge | Colapsa una rama entera en un nuevo commit | Fácil de seleccionar, historial limpio de main | Ramas de características que se fusionan en main, especialmente notebooks |
| Cherry-pick | Copia la diferencia de un commit existente a otra rama como un nuevo commit | Mueve exactamente un cambio, independiente del resto de su rama | Backporting de un hotfix o un solo commit a una rama de lanzamiento |
Conceptos Erróneos Comunes
- "Un commit almacena una diferencia del commit anterior." Almacena una instantánea completa de todo el árbol del proyecto en ese momento; la diferencia que se muestra se calcula comparando dos instantáneas sobre la marcha, no se lee del almacenamiento.
- "Una rama es un contenedor que contiene un conjunto de commits." Una rama es un único puntero a un commit, los commits en sí pertenecen al grafo y simplemente son alcanzables desde ese puntero, no son poseídos por él.
- "Los diffs de notebooks son ruidosos porque Git es malo manejando archivos grandes." Son ruidosos porque las salidas de un notebook se almacenan en línea en el mismo blob JSON que su código fuente, y Git difiere todo ese blob como texto plano sin ninguna conciencia específica de notebooks.
- "Eliminar
.venv/de un commit posterior lo elimina del repositorio." Lo elimina de ese punto en adelante; los blobs históricos de commits anteriores permanecen en el almacén de objetos y el tamaño del clon incluso después de la eliminación posterior. El tamaño del historial solo se reduce si ese historial se reescribe realmente, lo cual es disruptivo en un repositorio compartido. - "Rebase edita los commits originales para moverlos." Crea objetos commit completamente nuevos con hashes nuevos que contienen los mismos cambios, los originales permanecen en el grafo hasta que nada los apunte y eventualmente sean recolectados.
- "Force-pushing es solo una versión más fuerte de un push normal." Un push normal solo tiene éxito si es un fast-forward; force-push anula esa verificación y puede mover una ref compartida lejos de commits de los que otros todavía dependen.
Preguntas Frecuentes
¿Qué es el modelo de objetos de Git, en una frase?
Un almacén direccionable por contenido de cuatro tipos de objetos —blobs, trees, commits, tags— cada uno identificado por el hash de su propio contenido y enlazado en un grafo acíclico dirigido en el que las ramas y las etiquetas simplemente apuntan.
¿Es un commit de Git una instantánea o una diferencia?
Una instantánea completa de todo el árbol del proyecto en ese momento, haciendo referencia a un commit padre, no una diferencia. Git lo almacena eficientemente reutilizando cualquier archivo o sub-árbol que sea byte a byte idéntico a uno ya almacenado.
¿Qué es realmente una rama, mecánicamente?
Un archivo pequeño que contiene un hash de commit, nada más. Hacer un commit en una rama solo actualiza ese puntero al hash del nuevo commit.
¿Por qué los diffs de los notebooks de Jupyter parecen mucho más ruidosos que el diff de un archivo `.py` normal?
Porque las celdas de salida de un notebook, incluidas las imágenes incrustadas, residen en el mismo blob JSON que su código fuente, por lo que volver a ejecutar una celda cambia la instantánea rastreada incluso si la línea de código no cambió significativamente. Git difiere ese JSON como texto plano sin conocimiento de la estructura del notebook.
¿Por qué confirmar `.venv/` hincha un repositorio incluso después de que se elimine más tarde?
Porque cada commit es una instantánea completa, los blobs históricos de esos archivos permanecen como parte de commits anteriores en el almacén de objetos independientemente de la eliminación posterior. El tamaño del clon y el tamaño del historial solo se reducen si ese historial se reescribe realmente, lo cual es disruptivo en un repositorio compartido.
¿Cuál es la diferencia mecánica real entre merge y rebase?
Un merge añade un nuevo commit con dos padres encima de ambas historias existentes, dejando intacto cada commit original. Un rebase reproduce las diferencias de tus commits sobre una nueva base, produciendo objetos commit completamente nuevos con hashes nuevos.
¿Por qué hacer rebase de una rama compartida causa problemas a los colaboradores?
Porque rebase produce nuevos objetos commit con hashes diferentes a los originales, un force-push de un rebase mueve la ref de la rama para que apunte a commits que el historial local de un colaborador no reconoce como el mismo trabajo.
Si elimino una rama por error, ¿el trabajo se pierde realmente?
Generalmente no de inmediato, eliminar una rama solo quita el puntero, y los commits a los que apuntaba permanecen en el almacén de objetos como objetos inalcanzables hasta que la recolección de basura eventualmente los elimine. git reflog a menudo encuentra el último hash de commit para que la rama pueda ser recreada.
¿Por qué una rama de hotfix a menudo comienza desde una etiqueta de lanzamiento en lugar de `main`?
Una etiqueta es un punto fijo en el grafo que representa exactamente lo que se lanzó como esa versión, mientras que main puede contener ya trabajo no lanzado para cuando se necesite un hotfix. Basar la rama desde la etiqueta garantiza que el hotfix comience desde precisamente lo que se está ejecutando en producción.
¿Qué hace realmente un squash-merge al grafo de commits?
Calcula una diferencia combinada a través de cada commit en la rama de características y la escribe como un único commit nuevo con un padre en la rama de destino, en lugar de preservar cada commit intermedio.
¿Por qué hacer force-push de `main` es específicamente peligroso, mecánicamente?
Force-push anula la verificación normal de solo fast-forward de Git y puede mover la ref de main para que apunte a un commit diferente al que los colaboradores esperan en sus historiales locales, dejando huérfanos el trabajo construido sobre los commits que fueron movidos.
Relacionados
- Comandos Esenciales de Git - el conjunto de comandos diarios que este modelo explica
- Git para Datos/Notebooks -
nbstripoutynbdimeen la práctica - Branching & Merging - estrategias de ramas construidas sobre este modelo de grafo
- Git Worktrees - un segundo directorio de trabajo extraído del mismo grafo
- Mejores Prácticas de Git - reglas condensadas que se derivan de este modelo
Versiones de Stack: Esta página es conceptual y no está ligada a una versión específica de stack.