Repensando el Git Monorepo: una arquitectura CAS-Native para humanos y Coding Agents

Toda organización de ingeniería en crecimiento acaba encontrando la misma bifurcación en el camino. Al principio, con decenas de repositorios pequeños, todo parece sencillo. Cada biblioteca tiene su propio gestor de incidencias, su propio pipeline de CI y un git clone que termina antes de dar un sorbo al café. Después llega el coste. Cambias una estructura en un paquete de red central y pasas el resto de la tarde abriendo pull requests de actualización de versión en seis repositorios dependientes, esperando a que las colas de CI se vacíen en orden topológico.
Si trasladas todo a un monorepo Git tradicional, cambias el dolor de coordinación por un dolor físico. A medida que el historial de commits se profundiza y los activos binarios se acumulan en revisiones pasadas, git fetch se ralentiza. Los árboles de trabajo crecen hasta alcanzar gigabytes. Pronto alguien propone desplegar un demonio de sistema de archivos virtual a medida, un servidor dedicado de revisión de código o una flota de workers de compilación remotos solo para que el desarrollo diario siga siendo soportable.
Cuando los agentes de programación autónomos se incorporan al equipo, ambos modos de fallo empeoran. Los polyrepos ciegan al agente ante los llamadores entre proyectos y lo obligan a gestionar credenciales de varios repositorios. Los monorepos tradicionales saturan el disco del contenedor del agente y el ancho de banda de red en cada inicialización de tarea, mientras que los agentes concurrentes que modifican la misma rama generan carreras interminables de rebase.
Queríamos una tercera opción. Inspirados por la arquitectura google3, nos planteamos una pregunta sencilla: ¿podemos obtener cambios atómicos sobre un único tronco, incorporación dispersa en menos de un segundo, gobernanza jerárquica mediante OWNERS y réplicas abiertas independientes utilizando únicamente la infraestructura estándar de Git y almacenamiento de objetos de uso común?
La respuesta fue afirmativa, y no requirió un demonio de sistema de archivos a medida ni un dispositivo de revisión de terceros. Solo requirió usar Git para lo que realmente es por dentro: un Content-Addressable Store (CAS) basado en árboles de Merkle.
El error conceptual en el núcleo de los monorepos Git lentos
Cuando los desarrolladores se quejan de que Git no escala en monorepos grandes, rara vez están chocando con un límite de la base de datos de objetos de Git. Dentro de .git/objects, Git almacena tres primitivas inmutables indexadas por su hash de contenido SHA-1 (o SHA-256):
blob: el contenido binario bruto de un único archivo.tree: un listado de directorio que asocia nombres de archivo y permisos con hashes deblobo detreehijo.commit: una pequeña cabecera de texto (normalmente de 200 a 300 bytes) que apunta a un único hash detreeraíz, a cero o más hashes decommitpadre y a metadatos de autor.
Como almacén direccionable por contenido, este diseño es notablemente eficiente. Si un monorepo contiene 50 000 archivos y editas una sola línea de un archivo, el nuevo commit reutiliza exactamente los mismos hashes de blob y de tree para los otros 49 999 archivos.
Entonces, ¿por qué un monorepo Git tradicional resulta pesado? Porque la convención sobrecarga una única referencia, refs/heads/main, con tres funciones en conflicto:
- Contiene el árbol de directorios unificado completo de cada proyecto.
- Arrastra consigo la cadena completa de commits históricos enlazados por padres hasta el primer día.
- Reside dentro de
refs/heads/*, el espacio de nombres que los servidores Git anuncian y negocian por defecto en cadagit cloneygit fetch.
Cada vez que un desarrollador o un runner de CI obtiene refs/heads/main, el cliente y el servidor negocian la alcanzabilidad dentro del grafo de commits a lo largo de miles de revisiones históricas. Si alguien subió accidentalmente un archivo de 100 MB hace tres años y lo eliminó al día siguiente, su blob sigue colgando de un tree histórico alcanzable desde refs/heads/main.
Separar el espacio de nombres de referencias en cuatro capas
El protocolo de red de Git solo anuncia refs/heads/* y refs/tags/* durante un clone estándar. Las referencias bajo espacios de nombres personalizados, como refs/trunk/* o refs/ws/*, residen en el mismo almacén de objetos del servidor, se benefician de la misma compresión delta de packfiles y pueden obtenerse o enviarse de forma explícita, aunque no añaden ninguna sobrecarga a los clones por defecto.
Al dividir las responsabilidades en cuatro espacios de nombres de referencias dentro de un único repositorio, el cuello de botella desaparece:
1refs/heads/main Solo plano de control (~20 KB: reglas, .gitconfig, configuración de sincronización, scripts)
2refs/trunk/main Commit de instantánea sin padres que apunta al árbol raíz unificado más reciente
3refs/trunk/history Cadena lineal de commits enlazados por padres que comparte el SHA-1 exacto del árbol raíz
4refs/ws/<actor>/<id> Instantáneas de espacios de trabajo aislados (changelists) por desarrollador o agente
5refs/heads/land/<id> Disparador efímero de la cola de fusión, eliminado inmediatamente tras la integración
1. El plano de control (refs/heads/main)
La rama por defecto no contiene código fuente de la aplicación. Solo registra las reglas operativas del repositorio (AGENTS.md, README.md), un .gitconfig compartido con alias de flujo de trabajo, la configuración de sincronización en Starlark (sync.star) y los scripts de fusión del lado del servidor. Clonar la rama por defecto transfiere unos 20 KB y termina en milisegundos.
2. La instantánea de tronco sin padres (refs/trunk/main)
refs/trunk/main apunta al árbol fuente completo y unificado de toda la organización (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), pero su objeto commit se crea sin el indicador -p de padre.
Como refs/trunk/main no tiene padres (parents = []), obtenerlo no requiere ninguna negociación del grafo de commits. El servidor Git no recorre el historial; envía el único commit de cabeza y sus objetos tree de directorio actuales. Los binarios históricos o los archivos eliminados del mes pasado no son alcanzables desde refs/trunk/main y nunca se transfieren.
3. El historial lineal de auditoría (refs/trunk/history)
Los desarrolladores siguen necesitando git blame y git log al investigar regresiones. Cada vez que se integra un cambio, el script de fusión crea dos objetos commit que apuntan al mismo hash SHA-1 $NEW_TREE del árbol raíz:
1# Commit de instantánea sin padres para checkouts de workspace de profundidad 1 instantáneos
2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
3
4# Commit enlazado con su padre para git log y git blame lineales
5HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")
6
7git push --atomic origin \
8 --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
9 --force-with-lease="refs/trunk/history:${OLD_HIST}" \
10 "${SNAP}:refs/trunk/main" \
11 "${HIST}:refs/trunk/history"
¿Mantener tanto refs/trunk/main como refs/trunk/history duplica la huella de almacenamiento del repositorio? En absoluto. Como ambos commits referencian el mismo hash $NEW_TREE, el almacén direccionable por contenido de Git deduplica el 100 % de los objetos tree y blob. El coste adicional de la instantánea sin padres es una única cabecera de commit de 250 bytes.
4. Espacios de trabajo aislados (refs/ws/<actor>/<id>)
Las ramas de trabajo en curso se envían a refs/ws/<actor>/<id> en lugar de a refs/heads/*. Cien agentes de programación pueden guardar puntos de control cada minuto sin ensuciar los desplegables de ramas ni ralentizar git fetch para los ingenieros humanos.
Incorporación en cinco comandos: cómo funciona el checkout disperso y sin blobs
Con esta disposición de referencias, un nuevo desarrollador o un contenedor efímero de agente de programación no instala una CLI personalizada ni un driver FUSE. La incorporación requiere cinco comandos estándar de Git:
1# 1. Clonar el plano de control de 20 KB con filtrado de blobs y modo disperso habilitados
2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
3cd monorepo
4
5# 2. Cargar los alias del repositorio y adjuntar refs/trunk/*
6git config include.path ../.gitconfig
7git trunk-init
8
9# 3. Declarar los directorios de dominio necesarios para esta tarea
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Crear un workspace aislado anclado a la instantánea de tronco más reciente
13git ws-new alice/optimize-framing
Internamente, cada paso realiza solo el trabajo mínimo necesario:
git clone --filter=blob:none --sparsedescarga el plano de control de 20 KB enrefs/heads/main.git trunk-initconfigura+refs/trunk/*:refs/remotes/origin/trunk/*y ejecuta un fetch sin blobs. Git descarga los metadatostreederefs/trunk/main(normalmente unas decenas de kilobytes) y cero blobs de archivos.git sparse-checkout set net/relay ipc/codecindica al filtro de índice en modo cono de Git que materialice únicamente el directorio raíz y esos dos subárboles.git ws-newcambia a una nueva rama anclada enorigin/trunk/main. En ese preciso momento, Git solicita bajo demanda únicamente los objetosblobdentro denet/relayeipc/codec. El 95 % restante del monorepo nunca se descarga al disco.
Horizonte futuro: montar refs/trunk/main como sistema de archivos virtual (VFS)
El checkout disperso con clones sin blobs cubre los flujos de trabajo diarios sin herramientas adicionales en el cliente. Como refs/trunk/main ya es un árbol de Merkle puro dentro de un almacén direccionable por contenido, la arquitectura también proporciona una base directa para un sistema de archivos virtual (VFS) opcional en espacio de usuario mediante FUSE en Linux, FileProvider en macOS o ProjFS en Windows:
- Montaje instantáneo del árbol completo: un demonio de montaje local ligero lee el objeto
treeraíz derefs/trunk/mainy proyecta toda la jerarquía de directorios del monorepo en el directorio de trabajo en milisegundos, eliminando la necesidad de ejecutargit sparse-checkout set. - Carga de blobs bajo demanda: el contenido de los archivos permanece virtual hasta que un compilador, un IDE o un agente emite una llamada al sistema
open(2)oread(2). Entonces el demonio obtiene elblobSHA-1 correspondiente del endpoint del CAS y lo almacena en caché en el almacenamiento NVMe local. - Superposición copy-on-write para los workspaces: las ediciones locales se escriben en una capa superior ligera de superposición. Ejecutar
git ws-savecalcula el hash únicamente de las rutas modificadas en esa superposición, escribe los objetosblobytreeactualizados y envía el commit resultante arefs/ws/<actor>/<id>sin recorrer los directorios no modificados.
El modelo de changelist sobre Git puro: integración en el servidor en memoria
En un flujo de trabajo Git tradicional, fusionar una rama de funcionalidad crea un commit de fusión con dos padres (convirtiendo el historial en un patio de maniobras enmarañado) o exige que los desarrolladores hagan rebase manualmente y compitan con otros ingenieros que envían cambios a main.
Inspirada en el modelo de Changelist (CL) de google3, cada workspace de nuestra arquitectura representa un cambio atómico único frente a la instantánea del tronco. Mientras trabaja localmente en ws/alice/optimize-framing, un desarrollador o agente puede hacer diez commits incrementales (wip, fix tests, lint). Cuando el cambio está listo para integrarse, el servidor colapsa el delta del workspace en una única revisión lineal del tronco sin llegar a hacer checkout de un directorio de trabajo en el runner de CI.
Así se ejecuta el script de integración (land.sh) en memoria usando la capa de fontanería de Git 2.40+:
1# 1. Identificar el commit de instantánea raíz desde el que se ramificó el workspace
2BASE=$(git rev-list --max-parents=0 "$WORK_SHA")
3
4BASE_TREE=$(git rev-parse "${BASE}^{tree}")
5MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
6WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")
7
8# 2. Calcular el árbol raíz fusionado en memoria sin checkout del árbol de trabajo
9if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
10 NEW_TREE="$WORK_TREE"
11elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
12 NEW_TREE=$(head -n1 <<<"$OUT")
13else
14 echo "Conflicto de fusión detectado; ejecute git ws-sync para hacer rebase." >&2
15 exit 1
16fi
Observa con atención el paso 2:
- Independientemente de cuántos commits locales se hayan hecho en la rama del workspace, el script compara solo tres hashes de árbol: la instantánea base original (
$BASE_TREE), la instantánea actual del tronco ($MAIN_TREE) y el árbol final del workspace ($WORK_TREE). - Si
refs/trunk/mainavanzó en//web/blogmientras Alice editaba//net/relay,git merge-tree --write-treecalcula el árbol raíz combinado$NEW_TREEdirectamente dentro del almacén de objetos de Git en milisegundos. No se escribe ningún archivo en el sistema de archivos del runner. - Finalmente,
git push --atomiccon--force-with-leaseactualizarefs/trunk/main, añade un commit lineal arefs/trunk/historyy elimina el disparador de integraciónrefs/heads/land/<id>en una sola transacción.
Aplicar los límites de arquitectura: visibilidad en cuatro niveles y OWNERS jerárquicos
Consolidar el código en un único repositorio solo funciona si los límites entre dominios se aplican mecánicamente antes de que un commit pueda integrarse. Justo después de que git merge-tree --write-tree produce $NEW_TREE (y antes de que git push --atomic actualice el tronco), nuestro motor de políticas ejecuta dos comprobaciones de coste O(delta) sobre git diff-tree "$MAIN_TREE" "$NEW_TREE".
1. Visibilidad de dominios en cuatro niveles
El árbol raíz está dividido en cuatro niveles de visibilidad:
| Prefijo de directorio | Nivel | Importaciones permitidas | Importaciones prohibidas | Control de OWNERS |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Producción pública | Producción pública, //third_party/* | //corp/*, //experimental/* | Aplicado |
//third_party/* | Proveedor externo | Autocontenidos o //third_party/* | Todos los dominios no third_party | Aplicado |
//corp/* | Privado interno | Producción pública, //corp/*, //third_party/* | //experimental/* | Aplicado |
//experimental/<actor>/* | Sandbox | Cualquier paquete que no sea corp | //corp/* | Exento (aprobación automática) |
Si un paquete público de //web/* intenta importar un paquete interno de //corp/* o un prototipo no graduado de //experimental/*, el trabajo de integración se aborta antes de tocar refs/trunk/main.
2. Verificación jerárquica de OWNERS sin un servidor Gerrit
No necesitas una instancia dedicada de Gerrit para aplicar archivos OWNERS a nivel de directorio con reglas de herencia:
- Para cada ruta modificada entre
$MAIN_TREEy$NEW_TREE, el verificador asciende desde el directorio del archivo hasta//OWNERS. - Cualquier archivo
OWNERSpuede declararset noparentpara bloquear las aprobaciones heredadas de los directorios padre, oper-file *.sql=@dba-teampara acotar las aprobaciones por patrón glob del nombre de archivo. - Invariante anti-manipulación: los archivos
OWNERSexistentes se leen siempre desde$MAIN_TREE(el árbol actual del tronco), nunca desde$NEW_TREE. Un autor no autorizado no puede concederse permisos editandoOWNERSen el mismo commit del workspace. - Exención para sandbox: cualquier ruta bajo
//experimental/<actor>/*omite las comprobaciones deOWNERSpara que ingenieros y agentes puedan prototipar libremente congit ws-exp <actor> <project>y, más tarde, graduar el paquete a un dominio de producción congit ws-graduate.
Revisión de código sin fricción: pull requests efímeros y diffs de instantánea
Como refs/heads/main solo contiene el plano de control y refs/trunk/main es un commit sin padres, abrir directamente un Pull Request estándar de GitHub desde una rama del workspace hacia main intentaría comparar historiales no relacionados. Lo resolvimos con tres flujos de revisión adaptados a distintos tipos de cambios:
- Diffs instantáneos de dos puntos del workspace (
git ws-save+ Edge Diff Router) Enviar un workspace congit ws-saveexponerefs/ws/<actor>/<id>a través de un worker ligero en el borde de la red. Este worker resuelverefs/trunk/mainy la referencia del workspace a sus SHA de commit y redirige a la vista de comparación de dos puntos de GitHub (/compare/<trunk-sha>..<ws-sha>). Para parches pequeños o cambios generados por agentes, los revisores pueden inspeccionar el diff y hacer clic en Land directamente en la interfaz web. - Pull requests efímeros de GitHub (
git ws-pr) Para la revisión línea a línea en GitHub,git ws-prenvía el commit de instantánea raíz ($(git rev-list --max-parents=0 HEAD)) arefs/heads/base/<id>y la punta del workspace arefs/heads/review/<id>. Después abre un Pull Request de GitHub que comparareview/<id>conbase/<id>. Como la rama base es exactamente la instantánea desde la que se ramificó el workspace, la pestaña Files changed de GitHub muestra únicamente las líneas exactas modificadas en el workspace. Cuando el revisor hace clic en Merge pull request, un webhook promueve el commit de cabeza arefs/heads/land/<id>, ejecuta el pipeline de integración en memoria y elimina automáticamenterefs/heads/base/<id>yrefs/heads/review/<id>. - Pull requests en réplicas externas Los colaboradores de código abierto nunca tienen que aprender el flujo de trabajo del monorepo. Hacen fork y abren pull requests contra repositorios réplica independientes como de costumbre.
Sincronización bidireccional de coste O(delta) y ramas de producción separadas
Muchas organizaciones publican paquetes individuales como repositorios independientes de GitHub para que los usuarios externos puedan ejecutar go get, cargo install o hacer fork de un único proyecto sin tocar el monorepo. Algunos proyectos, como blogs estáticos o aplicaciones web con liberaciones controladas, también mantienen una rama production separada junto a main.
Nuestro motor de sincronización en Starlark gestiona tanto las exportaciones salientes como las contribuciones entrantes en un tiempo O(delta), proporcional a los archivos modificados en un commit y no al tamaño del monorepo:
- Inserción de índice sin blobs: al insertar el subárbol de un repositorio réplica en el árbol raíz del monorepo o extraerlo de él, el motor establece
GIT_NO_LAZY_FETCH=1y ejecutagit update-index --index-infoseguido degit write-tree --missing-ok. Los subárboles y blobs no modificados se referencian únicamente por sus hashes SHA-1, sin descargarse nunca al runner de CI. - Precarga de blobs solo del delta: antes de enviar un commit exportado a una réplica independiente, el script ejecuta
git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"y precarga solo los blobs antiguos y nuevos de los archivos modificados mediantegit cat-file --batch. Así, Git puede calcular un packfile delta compacto y enviarlo al repositorio réplica en segundos. - Conservación de flujos de trabajo y ramas de liberación: durante la exportación saliente, el sincronizador conserva el directorio
.github/workflows/propio de cada repositorio independiente, de modo que los runners públicos de CI ejecuten las pruebas del proyecto en la réplica. Cada workflow de la configuración de Starlark apunta a una rama explícita comomain, lo que preserva los controles de liberación de los repositorios que despliegan desde una ramaproductionseparada. Las integraciones en el monorepo se sincronizan con la ramamainde la réplica, y los commits automáticos de metadatos o traducciones enmainse sincronizan de vuelta conrefs/trunk/mainmediante webhook. Promovermainaproductionsigue siendo un paso de liberación explícito.
Reflexiones finales
Construir un monorepo de alta velocidad no exige abandonar Git estándar ni operar una compleja flota de servidores de infraestructura a medida. Esta arquitectura trata el almacén de objetos de Git como un Content-Addressable Store, separa refs/heads/main de refs/trunk/main y refs/trunk/history, y fusiona árboles en memoria con git merge-tree --write-tree. Como resultado, tanto los ingenieros humanos como los agentes de programación autónomos pueden clonar un workspace en menos de un segundo y escalar de forma limpia a lo largo de cientos de proyectos.