GoSuda

Repensando el Git Monorepo: una arquitectura CAS-Native para humanos y coding agents

By Lemon Mint views ...

Toda organización de ingeniería en crecimiento acaba eligiendo entre muchos repositorios o uno solo. Al principio, con decenas de repositorios pequeños, la vida parece sencilla. Cada biblioteca tiene su propio gestor de incidencias, su propio pipeline de CI y un git clone que termina en segundos. Luego cambias una estructura en un paquete central de red 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 tradicional de Git, cambias la coordinación entre repositorios por el tamaño del repositorio. A medida que el historial de commits crece y los activos binarios se acumulan en revisiones pasadas, git fetch se vuelve lento. Los árboles de trabajo alcanzan los gigabytes. Muy 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 frente a los llamadores de otros proyectos y lo obligan a gestionar credenciales en múltiples 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 trabajan sobre la misma rama generan carreras de rebase interminables.

Inspirados en la arquitectura google3, construimos cambios atómicos sobre un único trunk, una incorporación dispersa (sparse) en menos de un segundo, una gobernanza jerárquica mediante OWNERS y réplicas de código abierto independientes, todo ello con la infraestructura estándar de Git y almacenamiento de objetos de bajo coste.

El diseño no necesita ni un demonio de sistema de archivos a medida ni un dispositivo de revisión de terceros, porque trata Git como un Content-Addressable Store (CAS) basado en árboles de Merkle.

El malentendido en el origen de los monorepos lentos en Git

Cuando los desarrolladores se quejan de que Git no escala a monorepos grandes, rara vez están alcanzando un límite en 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):

  1. blob: el contenido binario bruto de un único archivo.
  2. tree: un listado de directorio que asocia nombres de archivo y permisos con hashes de blob o de tree hijos.
  3. commit: una pequeña cabecera de texto (normalmente de 200 a 300 bytes) que apunta a un único hash de tree raíz, a cero o más hashes de commit padre y a metadatos del autor.

Si un monorepo contiene 50 000 archivos y editas una sola línea de un archivo, el nuevo commit reutiliza exactamente los hashes de blob y tree de los otros 49 999 archivos.

Un monorepo tradicional de Git 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 todos los proyectos.
  • 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 cada git clone y git fetch.

Cada vez que un desarrollador o un runner de CI obtiene refs/heads/main, el cliente y el servidor negocian la alcanzabilidad en el grafo de commits a través de miles de revisiones históricas. Si alguien subió por accidente un archivo comprimido de 100 MB hace tres años y lo borró 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

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/*, viven en el mismo almacén de objetos del servidor, se benefician de la misma compresión delta de packfiles y pueden obtenerse o enviarse explícitamente, pero no imponen ninguna sobrecarga en los clones por defecto.

Repartir estas funciones entre referencias separadas dentro de un único repositorio elimina el cuello de botella:

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 workspace aisladas (listas de cambios) por desarrollador o agente
5refs/ws/cl/<change-id>/<tip>  Patchsets inmutables: cada punta de un cambio enviada a revisión
6refs/heads/land/<id>          Disparador efímero de la cola de fusión, borrado inmediatamente tras el landing

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 trunk sin padres (refs/trunk/main)

refs/trunk/main apunta al árbol fuente completo y unificado de toda la organización (//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), pero su objeto commit se crea sin el flag -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 borrados el 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 aprueba un cambio, el script de fusión crea dos objetos commit que apuntan al mismo hash SHA-1 exacto del árbol raíz $NEW_TREE:

 1# Commit de instantánea sin padres para checkouts de workspace inmediatos de profundidad 1
 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 no duplica la huella de almacenamiento. Como ambos commits referencian el mismo $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. Workspaces 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 contaminar los desplegables de ramas ni ralentizar git fetch para los ingenieros humanos.

Incorporación en cuatro pasos: 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 ninguna CLI a medida ni ningún driver FUSE. La incorporación consta de cuatro pasos con Git estándar y los alias del repositorio:

 1# 1. Clonar el plano de control de 20 KB con filtrado de blobs y modo disperso activado
 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 basado en la instantánea de trunk más reciente
13git ws-new alice/optimize-framing

Cada paso obtiene solo lo que necesita:

  1. git clone --filter=blob:none --sparse descarga el plano de control de 20 KB de refs/heads/main.
  2. git trunk-init configura +refs/trunk/*:refs/remotes/origin/trunk/* y ejecuta una obtención sin blobs. Git descarga los metadatos tree de refs/trunk/main (normalmente unas decenas de kilobytes) y cero blobs de archivos.
  3. git sparse-checkout set net/relay ipc/codec indica al filtro de índice en modo cono de Git que materialice solo el directorio raíz y esos dos subárboles.
  4. git ws-new cambia a una nueva rama basada en origin/trunk/main. Después, Git solicita bajo demanda únicamente los objetos blob dentro de net/relay e ipc/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 en un Content-Addressable Store, 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:

  1. Montaje instantáneo del árbol completo: un demonio ligero de montaje local lee el objeto tree raíz de refs/trunk/main y proyecta toda la jerarquía de directorios del monorepo en el directorio de trabajo en milisegundos, eliminando la necesidad de ejecutar git sparse-checkout set.
  2. Carga de blobs bajo demanda: el contenido de los archivos permanece virtual hasta que un compilador, un IDE o un agente invoca la llamada al sistema open(2) o read(2). Entonces, el demonio obtiene el blob SHA-1 correspondiente desde el endpoint del CAS y lo almacena en caché en el almacenamiento NVMe local.
  3. Superposición copy-on-write para workspaces: las ediciones locales se escriben en una capa superior ligera de superposición. Ejecutar git ws-save calcula el hash únicamente de las rutas modificadas en esa superposición, escribe los objetos blob y tree actualizados y envía el commit resultante a refs/ws/<actor>/<id> sin analizar los directorios no modificados.

El modelo de listas de cambios sobre Git puro: landing en memoria en el servidor

En un flujo de trabajo tradicional de Git, fusionar una rama de funcionalidad crea un commit de fusión con dos padres, lo que hace que el historial no sea lineal, o obliga a los desarrolladores a hacer rebase manualmente y competir con los pushes concurrentes sobre main.

Inspirado en el modelo de Changelist (CL) de google3, cada workspace de nuestra arquitectura representa un cambio atómico único sobre la instantánea de trunk. 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 aterrizar, el servidor colapsa el delta del workspace en una única revisión lineal de trunk sin llegar a hacer checkout de un directorio de trabajo en el runner de CI.

Así se ejecuta en memoria el script de landing (land.sh) usando la capa de plumbing de Git 2.40+:

 1# 1. Identificar el commit de instantánea raíz desde el que se bifurcó 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 "Merge conflict detected; run git ws-sync to rebase." >&2
15  exit 1
16fi

En el paso 2:

  • Independientemente del número de commits locales realizados en la rama del workspace, el script compara solo tres hashes de árbol: la instantánea base original ($BASE_TREE), la instantánea actual de trunk ($MAIN_TREE) y el árbol final del workspace ($WORK_TREE).
  • Si refs/trunk/main avanzó en //web/blog mientras Alice editaba //net/relay, git merge-tree --write-tree calcula el árbol raíz combinado $NEW_TREE directamente dentro del almacén de objetos de Git en milisegundos, sin escribir archivos en el sistema de archivos del runner.
  • Una vez superadas las comprobaciones de política descritas a continuación, un único git push --atomic con --force-with-lease actualiza refs/trunk/main y añade un commit lineal a refs/trunk/history, y el disparador de landing refs/heads/land/<id> se elimina.

Aplicar los límites de arquitectura: visibilidad en cuatro niveles y OWNERS jerárquicos

Consolidar código en un único repositorio solo funciona si los límites entre dominios se aplican de forma mecánica antes de que un commit pueda aterrizar. Justo después de que git merge-tree --write-tree produzca $NEW_TREE (y antes de que git push --atomic actualice el trunk), 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 se divide en cuatro niveles de visibilidad:

Prefijo de directorioNivelImportaciones permitidasImportaciones prohibidasControl OWNERS
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*Producción públicaProducción pública, //third_party/*//corp/*, //experimental/*Aplicado
//third_party/*Proveedor externoAutocontenido o //third_party/*Todos los dominios que no sean third_partyAplicado
//corp/*Privado internoProducción pública, //corp/*, //third_party/*//experimental/*Aplicado
//experimental/<actor>/*SandboxCualquier 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 job de landing se aborta antes de tocar refs/trunk/main.

2. Verificación jerárquica de OWNERS sin servidor Gerrit

El verificador aplica archivos OWNERS a nivel de directorio con reglas de herencia:

  • Para cada ruta modificada entre $MAIN_TREE y $NEW_TREE, el verificador recorre hacia arriba desde el directorio del archivo hasta //OWNERS.
  • Cualquier archivo OWNERS puede declarar set noparent para bloquear las aprobaciones heredadas de los directorios padre, o per-file *.sql=@dba-team para acotar las aprobaciones mediante un glob de nombre de archivo.
  • Invariante anti-manipulación: los archivos OWNERS existentes se leen siempre desde $MAIN_TREE (el árbol actual de trunk), nunca desde $NEW_TREE. Un autor no autorizado no puede concederse permisos editando OWNERS en el mismo commit del workspace.
  • Exención de sandbox: cualquier ruta bajo //experimental/<actor>/* omite las comprobaciones de OWNERS, de modo que los ingenieros y agentes puedan prototipar libremente con git ws-exp <actor> <project> y, más tarde, graduar el paquete a un dominio de producción con git ws-graduate.

Revisión de código sin fricción: pull requests efímeras 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 desde una rama de workspace una pull request estándar de GitHub hacia main intentaría comparar historiales no relacionados. Lo resolvimos con tres flujos de revisión adaptados a distintos tipos de cambios:

  1. Diffs de workspace de dos puntos instantáneos (git ws-save + Edge Diff Router) Enviar un workspace con git ws-save expone refs/ws/<actor>/<id> a través de un worker ligero en el edge. Este worker resuelve refs/trunk/main y la referencia del workspace a sus SHAs 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.
  2. Pull requests efímeras de GitHub (git ws-pr) Para revisión línea por línea en GitHub, git ws-pr envía el commit de instantánea raíz ($(git rev-list --max-parents=0 HEAD)) a refs/heads/base/<id> y la punta del workspace a refs/heads/review/<id>. Después abre una pull request de GitHub que compara review/<id> con base/<id>. Como la rama base es la instantánea desde la que se bifurcó el workspace, la pestaña Files changed de GitHub muestra solo el delta del workspace. Cuando el revisor hace clic en Merge pull request, un webhook promueve el commit de cabeza a refs/heads/land/<id>, ejecuta el pipeline de landing en memoria y elimina automáticamente refs/heads/base/<id> y refs/heads/review/<id>.
  3. 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 réplicas independientes como de costumbre.

Cambios, patchsets y arrastre de aprobaciones

Un nombre de rama no puede identificar un cambio. Un agente puede renombrar su rama, enmendar la punta o hacer rebase sobre un trunk más reciente varias veces antes de aterrizar. Como en Gerrit, el flujo de trabajo identifica un cambio mediante un trailer Change-Id: I<40 hex> en el mensaje del commit de la punta. git ws-save, git ws-pr y git ws-land añaden el trailer cuando la punta no lo tiene y lo conservan cuando ya existe, de modo que el identificador sobrevive a enmiendas, rebases y renombrados. El commit de trunk aterrizado conserva el trailer y registra el número de su pull request de revisión, por lo que git trunk-cl <number|Change-Id> lo encuentra en refs/trunk/history.

Cada punta enviada a revisión se convierte en un patchset. git ws-pr la envía a la referencia inmutable refs/ws/cl/<change-id>/<tip> junto a las ramas de revisión. Ejecutar git ws-pr de nuevo tras una enmienda o un git ws-sync añade un patchset a la misma pull request. git ws-ps lista los patchsets del cambio actual, y git ws-psdiff <a> <b> imprime el interdiff entre dos de ellos, de modo que el revisor lea solo lo que cambió desde la última ronda.

GitHub vincula una aprobación a un único commit, por lo que cada rebase requeriría normalmente una nueva revisión. El pipeline de landing también acepta una aprobación concedida a otro patchset del mismo cambio cuando ambos patchsets aplican el mismo parche sobre su propia instantánea base. Para ello compara git patch-id --verbatim sobre el diff binario completo. Un rebase limpio sobre un trunk más reciente aterriza sin otra revisión, mientras que cualquier edición de contenido, incluido un cambio de espacios en blanco o de modo de archivo, requiere una nueva aprobación.

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 hacer 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 puertas de liberación, 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 tiempo O(delta), proporcional a los archivos modificados en un commit y no al tamaño del monorepo:

  • Empalme de índice sin blobs: al insertar o extraer el subárbol de un repositorio réplica en el árbol raíz del monorepo, el motor establece GIT_NO_LAZY_FETCH=1 y ejecuta git update-index --index-info seguido de git write-tree --missing-ok. Los subárboles y blobs no modificados permanecen referenciados por SHA-1 y nunca se descargan al runner de CI.
  • Prefetch 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 mediante git cat-file --batch. Así Git puede calcular un packfile delta ligero y enviarlo al repositorio réplica en segundos.
  • Preservación de flujos de trabajo y ramas de release: 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 como main, lo que preserva las puertas de liberación de los repositorios que despliegan desde una rama production separada. Los landings del monorepo se sincronizan con la rama main de la réplica, y los commits automáticos de metadatos o traducciones en main se sincronizan de vuelta a refs/trunk/main mediante webhook. Promover main a production sigue siendo un paso de liberación explícito.

Reflexiones finales

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, identifica cada cambio mediante un Change-Id con patchsets inmutables y fusiona árboles en memoria con git merge-tree --write-tree. El coste de incorporación crece con los directorios que necesita una tarea, y el coste del landing y de la sincronización con las réplicas crece con el tamaño de un cambio, no con el tamaño del repositorio.