Dividir un repositorio no es una cuestión de herramienta. La herramienta se entiende en una tarde. Lo difícil es la decisión previa y el trabajo de limpieza posterior, y las dos cosas se saltan con regularidad, porque el corte en sí va muy rápido.
Este artículo es un tutorial completo y no da por supuesto nada más que Git en la línea de comandos. Toma una tienda online de herramienta que ha crecido dentro de un único repositorio, provoca el momento en el que la carpeta común deja de sostener, hace el split paso a paso conservando la historia y luego enseña lo que aun así sale mal el día del split. Al final llega la contrapropuesta honesta: cuándo es mejor no dividir nada.
Contenido
- La escena: una tienda, un repositorio
- Por qué durante mucho tiempo fue la decisión correcta
- El detonante: alguien de fuera tiene que tocar los precios
- El primer intento, el que parece inofensivo
- Tres semanas después falta la respuesta
- Las cuatro señales que justifican el split
- Los tres motivos que no lo son
- Qué necesitas
- Paso 1: determinar el límite
- Paso 2: crear el clon fresco
- Paso 3: filtrar la historia
- Paso 4: llevarse las rutas antiguas
- Paso 5: aclarar los tags
- Paso 6: llenar el nuevo repositorio
- Paso 7: clausurar el directorio antiguo
- El punto de inflexión: lo que aun así falla el día del split
- Lo que el split cuesta de forma duradera
- Las herramientas comparadas
- La dirección contraria: mantener el monorepo sostenible
- Cuándo compensa el split y cuándo no
- Preguntas frecuentes
- Fuentes
- Conclusión
La escena: una tienda, un repositorio
Una tienda online vende herramienta: taladros, sierras de calar, atornilladores a batería, además de accesorios y repuestos. Empezó como una sola aplicación y ha crecido hasta cinco servicios. Todo sigue hoy en un único repositorio.
werkzeugshop/
├── storefront/ interfaz web
├── catalog-service/ artículos, categorías, búsqueda
├── checkout-service/ carrito, pedido, pago
├── pricing-service/ precios de tarifa, promociones, escalas por cantidad
├── shared-contracts/ DTOs comunes y ficheros OpenAPI
└── infra/ Terraform, Helm charts, pipelinesUn clon, un checkout, una pipeline. Quien cambia una interfaz cambia en el mismo commit también a todos los que la llaman. El build está verde o rojo, y las dos cosas valen para toda la tienda.
Por qué durante mucho tiempo fue la decisión correcta
En este montaje no hay ninguna matriz de versiones, porque no hay nada que versionar. Lo que está en la rama principal encaja entre sí. Esa es la mayor ventaja de un repositorio común, y normalmente solo se echa de menos cuando ya no está.
Un ejemplo de la tienda. El motor de precios entrega hasta ahora los precios como número entero en céntimos. Para repuestos con precios mínimos eso tiene que pasar a ser un valor decimal. En el repositorio común es un solo cambio:
// shared-contracts/src/main/java/shop/contracts/PriceDto.java
public record PriceDto(String sku, BigDecimal amount, String currency) { }El mismo commit arrastra pricing-service y checkout-service. Ninguna release de la biblioteca de contratos, ninguna coordinación, ninguna fase de transición con dos formatos válidos. Una revisión, un build verde, listo.
Esa propiedad no desaparece cuando el repositorio crece. Solo la adelantan en algún momento otros costes.
El detonante: alguien de fuera tiene que tocar los precios
La tienda quiere introducir escalas por cantidad para repuestos. A partir de doce unidades un precio, a partir de cuarenta y ocho otro, y además reglas especiales para clientes distribuidores. La lógica de negocio es más amplia de lo previsto, y en el equipo no hay nadie libre. Así que entra un proveedor externo que hace exactamente esa reforma.
Con eso una pregunta deja de golpe de ser teórica: necesita acceso de escritura a pricing-service. Pero no debe ver el checkout-service, donde está la conexión con el pago junto con la gestión de claves, y menos todavía su historia completa.
Git no puede resolver eso. Los permisos actúan a nivel de repositorio, no a nivel de directorio. No hay ningún ajuste que libere un subdirectorio a una persona y esconda el resto. Con eso se acaba la discusión de fondo, y no por un argumento de arquitectura, sino por una propiedad de la herramienta.
Ese es el punto más importante aquí: el split no llega porque a alguien la estructura le parezca más bonita. Llega porque existe un requisito que de otra forma no se puede cumplir.
El primer intento, el que parece inofensivo
El camino obvio dura dos minutos. Crear un repositorio nuevo, copiar dentro la carpeta, un commit.
mkdir ../pricing-service
cp -r werkzeugshop/pricing-service/* ../pricing-service/
cd ../pricing-service
git init
git add .
git commit -m "initial import"El resultado funciona. El servicio compila, los tests pasan, el proveedor recibe acceso y nadie ve más de lo que debe ver. De momento la tarea está hecha.
Lo que se pierde por el camino se nota más tarde, y por eso este camino resulta tan tentador.
Tres semanas después falta la respuesta
Un cliente pide catorce hojas de sierra y recibe el precio escalado. Con once hojas no lo recibe. El límite está en doce, y alguien pregunta por qué doce y no diez.
En el repositorio antiguo la respuesta estaría a un comando de distancia:
git log -L :calculateTieredPrice:src/main/java/shop/pricing/TierCalculator.javaEn el repositorio nuevo hay exactamente un commit, y se llama «initial import». La discusión en la que nacieron las doce unidades, la referencia al ticket, el comentario del compañero que en su día señaló la unidad de embalaje: todo fuera. No borrado, porque en el repositorio antiguo sigue estando. Pero está donde ya no busca nadie, y cuelga de rutas que en el repositorio nuevo ya no existen.
De eso va exactamente el split con historia. No de la exhaustividad como fin en sí misma, sino de la capacidad de responder una pregunta dentro de un año.
Las cuatro señales que justifican el split
Antes de la parte técnica, la decisión. Cuatro observaciones sostienen el split, y en la práctica aparecen casi siempre juntas.
Ritmos de release distintos. La storefront sale a producción varias veces al día. El checkout-service agrupa los cambios en aprobaciones, porque ahí cuelga la gestión de pagos. En el repositorio común el ritmo lento impone su frecuencia al rápido, o el equipo monta construcciones de ramas para desacoplar los dos. Cuando esas construcciones empiezan a necesitar reglas propias, esa es la señal.
Responsabilidad separada. Mientras un equipo responde de todo, cada revisión es una pregunta de contenido. En cuanto son dos equipos los responsables, se convierte en una pregunta de competencias. Las reglas sobre propietarios de código reflejan eso, pero no lo imponen.
Acceso que tiene que ser distinto. El criterio de nuestra escena. Es el único de los cuatro sobre el que no se puede negociar.
Tiempos de respuesta que ya nadie aguanta. Si cada cambio en la storefront compila también el checkout, la espera crece con toda la tienda en lugar de con la parte cambiada. Antes de dividir por eso, sin embargo, toca poner la pipeline a prueba. De todos modos debería compilar solo lo que ha cambiado.
Los tres motivos que no lo son
Igual de importante es qué no justifica el split.
Que el repositorio se ha vuelto demasiado grande. El tamaño es un problema de herramienta, y Git tiene respuestas para eso, están más abajo. Quien divide por la duración del clon cambia una molestia por una tarea de coordinación permanente.
Que la carpeta se ve poco clara. La claridad nace del corte en módulos. Con el número de remotes no tiene nada que ver. Una tienda mal cortada se corrige peor repartida en cinco repositorios, porque entonces el límite queda fijado en la infraestructura.
Que los microservicios necesitarían un repositorio cada uno. Eso es convención. Ser desplegable de forma independiente no tiene nada que ver con dónde se guarda el código fuente, y la tienda lo demuestra desde hace años.
Qué necesitas
Para los pasos siguientes bastan Git y una herramienta adicional.
git filter-repo no viene incluido en Git. Es un único script de Python que se deja en la ruta de búsqueda. Según la documentación del proyecto, se requiere Git a partir de la versión 2.36.0 y Python 3 a partir de la versión 3.6.
python3 --version
git --version
git filter-repo --versionSi el último comando responde con «not a git command», todavía falta el script. Está disponible en los gestores de paquetes habituales y, como alternativa, se puede dejar como fichero y darle permisos de ejecución.
Una palabra sobre git filter-branch, que muchas guías antiguas recomiendan: la documentación de Git desaconseja su uso de forma expresa. Da dos motivos. El comando daña la historia de maneras que no se notan al momento, y es tan lento que reproducir un daño así se convierte en una prueba de paciencia. Para nuestro caso queda descartado.
Paso 1: determinar el límite
Antes del primer comando está la pregunta de qué sale exactamente. En la tienda de herramienta la respuesta no es simplemente pricing-service/, porque el servicio usa contratos comunes.
grep -rl "shop.contracts" werkzeugshop/pricing-service/src | headHay tres posibilidades, y la decisión va antes del split, no después:
- Los contratos se quedan en el monorepo y en adelante se publican como biblioteca. Entonces el servicio extraído necesita una dependencia a una versión, y con eso empieza la matriz de versiones.
- Los contratos se van con él. Entonces existen dos veces, y las dos copias derivan.
- Los contratos reciben un repositorio propio. Limpio, pero es un segundo split.
Para la tienda la elección cae en la primera variante, porque checkout-service y storefront necesitan los mismos contratos. El servicio extraído los trae en adelante como artefacto versionado.
Deja esa decisión por escrito antes de seguir. Es la parte que dentro de seis meses ya no se puede reconstruir.
Paso 2: crear el clon fresco
git filter-repo reescribe historia. Eso pasa en una copia que existe justo para eso. El directorio de trabajo en el que alguien está desarrollando ahora mismo queda intacto.
git clone https://github.com/werkzeugshop/werkzeugshop.git pricing-split
cd pricing-splitLa herramienta trae para eso una medida de precaución: si no corre en un clon fresco, aborta, salvo que se lo sobrescribas de forma expresa. Esa actitud compensa para toda la migración. Un clon que de todas formas solo existe para el intento puede salir mal. Lo borras y vuelves a empezar, y ahí nadie pierde nada.
En un repositorio grande vale la pena mirar aquí el reloj. Cuánto dura la pasada del filtro lo mides mejor en esa copia que averiguándolo el día de la migración.
Paso 3: filtrar la historia
Ahora el corte propiamente dicho. El comando conserva un solo directorio y lo sube a la raíz del repositorio.
git filter-repo --subdirectory-filter pricing-serviceDespués, el contenido del servicio está en el directorio raíz, y la historia solo contiene commits que han tocado ese directorio. Comprobarlo se puede al momento:
ls
git log --oneline | wc -l
git log --oneline | tail -5La salida enseña la diferencia con la carpeta copiada de antes:
src pom.xml README.md Dockerfile
487
a3f19c2 Motor de precios extraído del monolito
7d4e881 Precios promocionales con periodo de validez
1c9a03e Redondeo a la unidad de embalaje
0b2e447 Escala por cantidad a partir de doce unidades, ticket SHOP-412
9f1d330 Primera versión de los precios de tarifaLa respuesta a la pregunta por las doce unidades vuelve a estar ahí, con número de ticket incluido. La herramienta limpia después por su cuenta y reempaqueta el repositorio, para eso no hay nada más que hacer.
Paso 4: llevarse las rutas antiguas
Aquí acecha el error que con más frecuencia pasa desapercibido. Los filtros de ruta trabajan sobre las rutas tal y como están en la historia, no sobre el nombre de hoy.
El servicio de la tienda de herramienta no se llamó siempre pricing-service. Los dos primeros años estuvo bajo preise/, después bajo preis-service/. Los dos son nombres alemanes, porque así arrancó la tienda, y solo desde el cambio a identificadores en inglés se llama como hoy. El comando del paso 3 conoce únicamente el nombre actual. Todo lo que pasó antes del último renombrado se cae en silencio. No hay ningún aviso, el resultado tiene una pinta plausible, y la historia termina en una fecha que a nadie le llama la atención.
Por eso, antes de la pasada del filtro toca mirar atrás:
git log --follow --name-only --format="%h" -- pricing-service/pom.xml | grep -v "^$" | tail -20Si ahí aparece una ruta anterior, te llevas todas las variantes y las renombras de una tacada:
git filter-repo
--path preise/
--path preis-service/
--path pricing-service/
--path-rename preise/:pricing-service/
--path-rename preis-service/:pricing-service/
--path-rename pricing-service/:Las tres primeras indicaciones determinan qué se conserva. Los tres renombrados juntan las rutas históricas y suben el resultado a la raíz. Después la historia es continua, y git log --follow encuentra también commits de la época en la que el servicio se llamaba de otra forma.
Paso 5: aclarar los tags
La tienda de herramienta ha etiquetado sus releases sobre el proyecto entero: v3.4.0, v3.5.0 y así. Esos tags se van con el filtrado, y en el repositorio nuevo llevan a confusión, porque v3.4.0 nunca fue una versión del motor de precios, sino una de la tienda.
Dos caminos son defendibles. O los renombras al filtrar, para que su procedencia siga siendo visible. Eso lo añades a la misma y única pasada del paso 4:
git filter-repo --path preise/ --path preis-service/ --path pricing-service/ --path-rename preise/:pricing-service/ --path-rename preis-service/:pricing-service/ --path-rename pricing-service/: --tag-rename "":"shop-"Con eso v3.4.0 pasa a ser shop-v3.4.0, y nadie lo confunde luego con una versión del servicio. O los tiras después y empiezas con una numeración propia:
git tag -l | while read -r t; do git tag -d "$t"; done
git tag v1.0.0Para la tienda la decisión cae en el renombrado, porque en las notas de release del manual de operación se hace referencia a los números antiguos. Lo importante es solo que sea una decisión consciente. Dejarlos sin renombrar es la única variante que da problemas más adelante.
Paso 6: llenar el nuevo repositorio
De la referencia al monorepo no tienes que ocuparte, la herramienta ya la ha quitado. Esa es su segunda medida de seguridad incorporada, y es intencionada: sin origin nadie puede empujar sin querer la historia reescrito al repositorio de origen. Quien de verdad quiera hacerlo, vuelve a añadir el remoto a mano y entonces sabe lo que hace.
git remote -v
# salida vacía, filter-repo ha quitado origin
git remote add origin https://github.com/werkzeugshop/pricing-service.git
git push -u origin main
git push origin --tagsSolo después entra el proveedor externo, y únicamente en este repositorio. Ese era todo el objetivo del ejercicio, y en este punto está cumplido.
Paso 7: clausurar el directorio antiguo
El paso que más veces se cae, porque el trabajo de verdad parece hecho. En el monorepo pricing-service/ sigue ahí. Todavía compila, los tests todavía pasan, y dentro de medio año alguien que no se enteró de la migración cambia ahí una línea.
cd ../werkzeugshop
git rm -r pricing-serviceEn ese sitio va un aviso que responde la pregunta antes de que se haga:
mkdir pricing-service
cat > pricing-service/README.md <<'AVISO'
Este servicio se ha mudado.
Nuevo repositorio: https://github.com/werkzeugshop/pricing-service
La historia completa se conserva ahí, también la de la época de
preise/ y preis-service/.
Aquí ya no se desarrolla.
AVISO
git add pricing-service/README.md
git commit -m "Motor de precios externalizado, referencia al nuevo repositorio"A eso se suma adaptar la pipeline del monorepo para que ya no compile el servicio desaparecido, y publicar los contratos como biblioteca, según lo decidido en el paso 1.
El punto de inflexión: lo que aun así falla el día del split
El código fuente es la parte fácil. Lo que en la tienda de herramienta llamó la atención los días siguientes ya no tenía nada que ver con Git.
Las reglas de protección de rama no existían en el repositorio nuevo. En el monorepo cada cambio en la rama principal necesitaba dos aprobaciones. El repositorio nuevo estaba recién creado, así que ahí no valía nada, y el primer commit del proveedor externo aterrizó directo en main. Nadie había hecho nada mal, la regla simplemente no se había trasladado nunca.
Faltaban los secrets. La pipeline del servicio necesitaba credenciales para el registro de artefactos, y estaban en el monorepo. El primer build en el repositorio nuevo falló, y como el mensaje de error solo hablaba de permisos que faltaban, la búsqueda duró más que todo el split.
La actualización de dependencias se quedó en el aire. El bot que en el monorepo propone saltos de versión no conocía el repositorio nuevo. Durante tres meses no llegó ni una sola propuesta, y eso se notó al llegar un aviso de seguridad.
Las referencias apuntaban a la nada. En tickets, en la documentación y en mensajes de commit había enlaces a ficheros del monorepo. Esas rutas ahí ya no existen.
Por eso en la lista del día del split va todo menos el código fuente: reglas de protección, aprobaciones obligatorias, secrets, automatización, referencias. Quien solo se lleva la historia ha trasladado la mitad.
Lo que el split cuesta de forma duradera
Los costes de verdad no caen el día de la migración. Caen cada mes posterior, y por eso se subestiman.
El cambio atómico se acabó. Acuérdate del paso de los precios a BigDecimal de más arriba, un commit en el repositorio común. Después del split eso se convierte en una secuencia: primero publicar la biblioteca de contratos en una versión nueva, después subir el motor de precios a esa versión, después arrastrar checkout y storefront. Entretanto existen dos formatos válidos, y alguien tiene que decidir durante cuánto tiempo.
Con eso cada interfaz compartida necesita una versión, un compromiso de compatibilidad y una forma de anunciar rupturas. Ese es el precio real del split, y es permanente.
La verdad sobre el estado global desaparece. Antes, la rama principal respondía a la pregunta de qué estados encajan entre sí. Después hace falta una fuente propia para eso, en la tienda de herramienta una descripción de entorno en el repositorio de GitOps que registra qué versión corre dónde.
La pipeline se multiplica. Cada repositorio trae su propia configuración y sus propios secrets. Lo que se mantenía una vez se mantiene ahora varias veces, y las configuraciones divergen desde el primer día si nadie las mantiene activamente juntas.
Las herramientas comparadas
| Herramienta | Instalación | Velocidad | Puede con ficheros sueltos | Recomendación |
|---|---|---|---|---|
git filter-repo |
script de Python aparte | rápido | sí | el camino para este caso |
git subtree split |
incluido en Git | bastante más lento | no, solo directorios | para un corte único sobre un límite limpio |
git filter-branch |
incluido en Git | muy lento | sí | desaconsejado de forma expresa por la documentación de Git |
git subtree split es el camino pragmático cuando no se puede instalar ninguna herramienta adicional y el límite ya cae limpio sobre un directorio:
git subtree split --prefix=pricing-service -b pricing-onlyEso genera una rama con la historia filtrada, que se empuja a un repositorio nuevo. Lo que no puede: extraer ficheros sueltos y juntar rutas históricas. Para la tienda de herramienta, con sus tres nombres de directorio, no basta.
La dirección contraria: mantener el monorepo sostenible
Si el único dolor es el tamaño, hay un camino más barato que una migración. Git trae dos mecanismos para eso, y se pueden combinar.
Un clon parcial trae el contenido de los ficheros solo cuando hace falta, en lugar de transferir por adelantado toda la historia de cada fichero:
git clone --filter=blob:none https://github.com/werkzeugshop/werkzeugshop.gitComo complemento, git sparse-checkout limita el árbol de trabajo a los directorios en los que alguien trabaja de verdad:
cd werkzeugshop
git sparse-checkout set pricing-service shared-contracts
lsDespués solo quedan en el árbol de trabajo esos dos directorios más los ficheros del nivel superior. El modo cone preestablecido acepta a propósito solo directorios y no patrones arbitrarios, lo que mantiene rápida la evaluación.
Van dos limitaciones con el paquete. La documentación de Git sigue listando git sparse-checkout como experimental, y el índice reducido asociado es una funcionalidad propia, también experimental, que puede desconcertar a herramientas externas. Para máquinas de desarrollo esa suele ser aun así la respuesta más económica que una migración cuyos costes derivados son permanentes.
Lo que estos mecanismos no resuelven: el problema de acceso de nuestra escena. Un clon parcial no esconde nada, solo carga más tarde. Quien no puede ver el código del checkout no puede clonar el repositorio.
Cuándo compensa el split y cuándo no
Compensa cuando los permisos de acceso tienen que ser distintos, porque para eso no hay alternativa. Compensa cuando dos partes tienen de forma duradera ritmos de release distintos y las construcciones que reflejan eso en el repositorio común se vuelven ellas mismas una carga. Y compensa cuando la responsabilidad está repartida entre equipos separados y cada revisión se convierte en una cuestión de competencias.
No compensa como acción de limpieza, ni por la duración del clon, ni porque una convención de arquitectura lo sugiera. En esos casos pagas costes de coordinación por un problema que se habría resuelto más barato.
Para la tienda de herramienta la respuesta fue clara, pero valía solo para uno de los cinco servicios. Storefront, catálogo y checkout siguen hoy juntos, y no hay motivo para cambiar eso.
Preguntas frecuentes
¿Pierdo la historia si copio una carpeta a un repositorio nuevo?
Sí, por completo. El repositorio nuevo empieza con un único commit. La historia antigua sigue existiendo en el repositorio de origen, pero ahí cuelga de rutas que en el nuevo no existen, y en la práctica ya no se encuentra.
¿Por qué no debo usar git filter-branch?
Porque la documentación de Git lo desaconseja de forma expresa. Da dos motivos: el comando daña la historia de maneras que no se ven al momento, y es tan lento que buscar el fallo después resulta desproporcionado.
¿Qué pasa si el directorio antes se llamaba de otra forma?
El filtro de ruta conoce solo las rutas que le indicas. Todo lo anterior al renombrado se cae, sin aviso. Tienes que indicar todas las rutas históricas y juntarlas mediante renombrado.
¿Tengo que llevarme los tags?
No, pero tienes que decidir. Los tags del proyecto entero llevan a confusión en el repositorio extraído, porque designan una versión que ahí nunca existió. O renombrar o borrar.
¿Puedo deshacer el split?
La pasada del filtro en sí sí, tirando el clon y empezando de nuevo. Justo para eso está el clon fresco. Lo que no se puede revertir son las costumbres posteriores: versiones publicadas, referencias y todo lo que otros ya han apuntado al repositorio nuevo.
¿Necesito repositorios separados para microservicios?
No. Ser desplegable de forma independiente es una propiedad de la cadena de build y despliegue, no del sitio donde se guarda el código fuente. Un monorepo puede entregar cinco servicios de forma independiente.
¿Cuánto dura la pasada del filtro?
Depende del número de commits y de ficheros. En lugar de estimarlo, lo mides en el clon fresco que vas a crear de todos modos. Ese es el único caso en el que la migración permite un ensayo gratis.
Fuentes
- Documentación de Git sobre
git filter-branch, incluidas la advertencia y la referencia a la alternativa - Documentación de Git sobre
git sparse-checkout, modo cone y estado del índice reducido - Documentación del proyecto
git filter-repopara los requisitos y el comportamiento con el clon fresco
Todos los comandos y ejemplos de este artículo son propios y están reproducidos sobre el montaje descrito.
Conclusión
El split no es una acción de limpieza. Reconoce un límite que en lo funcional llevaba tiempo ahí, y en la tienda de herramienta fue el acceso lo que lo hizo visible.
Si lo recorres, llévate la historia. Te cuesta un paso de trabajo adicional y dentro de un año te responde a la pregunta de por qué la escala por cantidad empieza en doce unidades. Y planifica el día siguiente: reglas de protección, secrets, automatización y referencias no se mudan solas.
El siguiente paso concreto es pequeño. Coge un directorio del que creas que tendría que ser independiente, clona el repositorio de cero y deja correr la pasada del filtro una vez. Después ves en minutos si la historia es continua o si en algún sitio espera un renombrado del que ya nadie se acordaba.