Ha pasado lo que era previsible. Ana lleva meses extrayendo piezas reutilizables de gestor-tareas: el botón con estado de carga, el diálogo de confirmación, el campo de texto con validación. Al principio vivían en app.js; después en un componentes.js aparte. Y ahora hay dos proyectos más en la empresa que quieren usarlos.
El equipo ha hecho lo correcto: ha creado un repositorio propio, componentes-ui, en git.ejemplo.es/equipo/componentes-ui.git, con su propio historial, sus propias versiones etiquetadas y sus propios responsables.
Y ahora surge la pregunta incómoda: ¿cómo usa gestor-tareas esa biblioteca?
Copiar los ficheros y pegarlos funciona hasta el primer arreglo, momento en el que hay tres copias divergentes. Y no basta con "tener la biblioteca en algún sitio": hace falta que quede registrado qué versión exacta de componentes-ui usa cada commit de gestor-tareas, para que al hacer git checkout v1.0.0 dentro de seis meses se pueda reconstruir exactamente aquella aplicación.
La respuesta nativa de Git a ese problema son los submódulos. Son potentes, son la solución correcta para ciertos casos, y tienen fama —merecida— de morder a quien no entiende cómo funcionan. Esta lección va de entenderlos bien.
Contenido
- Qué es exactamente un submódulo
git submodule add: añadircomponentes-ui- Qué se confirma exactamente: el objeto de tipo commit
- Clonar un proyecto con submódulos
- Actualizar: el submódulo frente al puntero
- Trabajar dentro de un submódulo y el detached HEAD
- Inspección:
status,foreachydiff --submodule submodule.recursey otras opciones que quitan dolor- Los problemas reales
- Alternativas: submódulos, subtree, paquetes y monorepo
- Qué es exactamente un submódulo
La definición completa, y todo lo demás se deduce de ella:
Un submódulo es una entrada en el árbol del repositorio padre que apunta a un commit concreto de otro repositorio, más una línea en el fichero
.gitmodulesque dice de dónde clonar ese otro repositorio.
Dos piezas, ninguna más:
- El puntero: una entrada en el árbol (lección 01-04) cuyo tipo no es
blobnitree, sinocommit. Guarda un hash de 40 caracteres y nada más. .gitmodules: un fichero de texto versionado, en la raíz del padre, que asocia cada ruta con su URL.
flowchart TD
subgraph P["Repositorio gestor-tareas"]
C1["commit c8f2a1e"]
T1["tree raíz"]
B1["blob app.js"]
B2["blob index.html"]
B3["blob .gitmodules"]
SM["commit 7f3a9d2<br/>(entrada 'componentes-ui')"]
end
subgraph S["Repositorio componentes-ui"]
X1["commit 4b8e1c5"]
X2["commit 7f3a9d2"]
X3["commit 9d2f6a8"]
end
C1 --> T1
T1 --> B1
T1 --> B2
T1 --> B3
T1 --> SM
SM -.->|"apunta a"| X2
Lo crucial: el repositorio padre no contiene el código de la biblioteca. Contiene un post-it que dice "aquí va componentes-ui, exactamente en el commit 7f3a9d2". El código vive en el otro repositorio, con su propio .git, su propio historial y sus propias ramas.
De ahí salen las tres propiedades que definen la experiencia de trabajar con submódulos:
- Reproducibilidad exacta. Cada commit de
gestor-tareasfija una versión concreta de la biblioteca.git checkout v1.0.0+ actualizar submódulos reconstruye la aplicación tal cual era. - Historiales independientes. Un commit en la biblioteca no aparece en el historial de la aplicación. Son dos repositorios distintos con dos
git logdistintos. - Actualización explícita. El puntero no se mueve solo. Si la biblioteca avanza, el padre sigue apuntando al mismo commit hasta que alguien decida moverlo y lo confirme.
Esa última propiedad es simultáneamente la mayor virtud y la mayor fuente de quejas.
git submodule add: añadir componentes-ui
git submodule add: añadir componentes-uiAna lo hace desde la raíz de gestor-tareas:
cd ~/proyectos/gestor-tareas
git submodule add [email protected]:equipo/componentes-ui.git vendor/componentes-uiCloning into '/home/ana/proyectos/gestor-tareas/vendor/componentes-ui'... remote: Enumerating objects: 214, done. remote: Total 214 (delta 89), reused 214 (delta 89) Receiving objects: 100% (214/214), 48.32 KiB | 4.83 MiB/s, done. Resolving deltas: 100% (89/89), done.
Sintaxis:
- Si omites la ruta, se usa el nombre del repositorio (
componentes-ui/). -b <rama>registra una rama de seguimiento, útil para--remote(apartado 5).
Lo que ha pasado, exactamente:
On branch main Changes to be committed: (use "git restore --staged <file>..." to unstage) new file: .gitmodules new file: vendor/componentes-ui
Dos ficheros nuevos, no doscientos. El directorio entero de la biblioteca aparece como una sola entrada. Ese es el primer indicio visible de que no se está guardando el contenido.
El .gitmodules generado:
[submodule "vendor/componentes-ui"]
path = vendor/componentes-ui
url = [email protected]:equipo/componentes-ui.gitY el commit:
git commit -m "Añadir componentes-ui como submódulo
La biblioteca de componentes vive ahora en su propio repositorio.
Se fija en la versión v2.1.0 para que el build sea reproducible."[main 3d8f1a6] Añadir componentes-ui como submódulo 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 vendor/componentes-ui
Fíjate en la última línea: create mode 160000. Los modos que conocíamos de la lección 01-04 eran 100644 (fichero normal), 100755 (ejecutable) y 040000 (directorio). 160000 es el modo especial de un submódulo, y ese número es literalmente cómo Git marca "aquí hay un puntero a un commit externo".
Ana ya puede usarlo desde index.html:
// app.js
const dialogo = ComponentesUI.crearDialogo({
titulo: 'Confirmar borrado',
mensaje: '¿Seguro que quieres borrar esta tarea?',
});Y envía:
- Qué se confirma exactamente: el objeto de tipo commit
Vale la pena verlo por dentro, porque entenderlo aquí evita toda la confusión posterior. Retomamos las herramientas de la lección 01-04:
100644 blob 8f2a1c9e... .gitmodules 100644 blob 4b7d9c3e... README.md 100644 blob 2e5f8a1c... app.js 100644 blob 9c4e7b2f... estilos.css 100644 blob 1d6a8f3c... index.html 040000 tree 7b3d5c9a... vendor
Ahí está. El árbol vendor contiene una entrada de tipo commit con modo 160000. No es un tree, no es un blob: es una referencia a un objeto commit que ni siquiera está en la base de datos de objetos de gestor-tareas.
Efectivamente: ese objeto vive en el repositorio de componentes-ui, no aquí. El padre solo guarda su nombre.
Y el tamaño total que ocupa esa referencia en el repositorio padre:
Un árbol con una entrada: unas decenas de bytes. El repositorio de gestor-tareas no ha crecido nada por incorporar una biblioteca de 200 commits.
De aquí se deduce por qué el diff de un cambio de submódulo tiene ese aspecto tan raro:
diff --git a/vendor/componentes-ui b/vendor/componentes-ui
index 7f3a9d2..9d2f6a8 160000
--- a/vendor/componentes-ui
+++ b/vendor/componentes-ui
@@ -1 +1 @@
-Subproject commit 7f3a9d2c4e8b1f6a3d5c9e2b7f4a8d1c6e3b5a9f
+Subproject commit 9d2f6a8b3e7c1d5f9a2b6c4e8d1f3a7c5b9e2d6fUna línea que cambia. Todo el contenido de la biblioteca puede haber cambiado, pero para el padre esto es un puntero que se mueve.
- Clonar un proyecto con submódulos
Bruno clona el proyecto por primera vez:
git clone [email protected]:equipo/gestor-tareas.git
cd gestor-tareas
ls vendor/componentes-ui/El directorio existe pero está vacío. Este es el primer tropiezo clásico, y le pasa a todo el mundo: git clone no descarga los submódulos por defecto.
Hay dos formas de arreglarlo.
La correcta: clonar con --recurse-submodules
git clone --recurse-submodules [email protected]:equipo/gestor-tareas.gitCloning into 'gestor-tareas'... ... Submodule 'vendor/componentes-ui' ([email protected]:equipo/componentes-ui.git) registered for path 'vendor/componentes-ui' Cloning into '/home/bruno/gestor-tareas/vendor/componentes-ui'... Submodule path 'vendor/componentes-ui': checked out '7f3a9d2c4e8b1f6a3d5c9e2b7f4a8d1c6e3b5a9f'
Clona el padre y, acto seguido, inicializa y clona cada submódulo en el commit fijado.
Si ya has clonado sin ella
git submodule init # lee .gitmodules y registra los submódulos en .git/config
git submodule update # clona y hace checkout del commit fijadoO en un solo comando:
git submodule update --init
git submodule update --init --recursive # si los submódulos tienen submódulosLa separación en dos pasos tiene sentido cuando la entiendes:
| Comando | Qué hace | Dónde escribe |
|---|---|---|
git submodule init |
Copia la configuración de .gitmodules a .git/config |
.git/config local |
git submodule update |
Clona (si hace falta) y hace checkout del commit fijado |
El directorio del submódulo |
El paso de init existe porque permite cambiar la URL localmente antes de clonar (por ejemplo, para usar un espejo interno o HTTPS en vez de SSH) sin tocar el .gitmodules que comparte todo el equipo:
git submodule init
git config submodule.vendor/componentes-ui.url https://git.ejemplo.es/equipo/componentes-ui.git
git submodule updateTambién se puede inicializar solo alguno, en proyectos con muchos:
- Actualizar: el submódulo frente al puntero
Aquí está la distinción que más confusión genera, y merece un encabezado propio.
Hay dos actualizaciones distintas y hacen cosas opuestas:
| Comando | Qué hace | Dirección |
|---|---|---|
git submodule update |
Pone el submódulo en el commit que dice el padre | El padre manda |
git submodule update --remote |
Trae lo último del remoto del submódulo y mueve el puntero | El submódulo manda |
git submodule update: obedecer al padre
Es el caso normal, y el que se ejecuta después de un git pull:
Ana ha subido un commit que mueve el puntero de la biblioteca. Bruno hace git pull del padre, y submodule update deja su copia de componentes-ui exactamente en el commit que Ana fijó. Sincronización, no actualización.
git submodule update --remote: traer lo nuevo
Git ha hecho fetch en el submódulo y ha hecho checkout de la punta de su rama de seguimiento. ¿Qué rama? Por este orden:
- La indicada en
.gitmodulesconbranch = <rama>(ogit submodule add -b). - Si no hay ninguna,
HEADdel remoto, que suele sermain.
[submodule "vendor/componentes-ui"]
path = vendor/componentes-ui
url = [email protected]:equipo/componentes-ui.git
branch = estableY ahora lo importante: eso todavía no ha cambiado nada en el historial del padre. Es un cambio sin confirmar:
(new commits) significa: "el submódulo está en un commit distinto del que yo tengo registrado". Para que la actualización sea real y le llegue al resto del equipo, hay que confirmarla en el padre:
git add vendor/componentes-ui
git commit -m "Actualizar componentes-ui a v2.2.0
Incluye la corrección del foco en el diálogo de confirmación
(componentes-ui#48) que necesitamos para el borrado múltiple."
git pushsequenceDiagram
participant B as componentes-ui (remoto)
participant L as vendor/componentes-ui (local)
participant P as gestor-tareas (padre)
L->>B: git submodule update --remote → fetch + checkout
Note over L: el submódulo avanza a 9d2f6a8
Note over P: git status → "modified: (new commits)"
P->>P: git add vendor/componentes-ui + commit
Note over P: ahora el puntero registrado es 9d2f6a8
La regla que resume todo el apartado: actualizar el submódulo es un cambio en tu disco; actualizar el puntero es un commit en el padre. Si no confirmas, nadie más ve la actualización, y el siguiente git submodule update te la deshace.
- Trabajar dentro de un submódulo y el detached HEAD
Carla necesita arreglar el diálogo de confirmación, que está en la biblioteca. Entra al directorio del submódulo:
Detached HEAD, el estado de la lección 03-02. Y tiene toda la lógica del mundo: el padre no dice "usa la rama main de la biblioteca", dice "usa el commit 7f3a9d2". Git hace exactamente eso, y un checkout de un commit suelto deja HEAD desacoplado.
Consecuencia peligrosa: si Carla edita y confirma aquí sin más, crea un commit que no pertenece a ninguna rama. En cuanto alguien haga git submodule update, ese commit queda huérfano y solo se recupera por el reflog (lección 09-04).
El procedimiento correcto tiene cuatro pasos:
# 1. Ponerse en una rama de verdad, dentro del submódulo
cd vendor/componentes-ui
git switch main
git pull
# 2. Trabajar como en cualquier repositorio
git switch -c correccion/foco-dialogo
# ... editar dialogo.js ...
git commit -am "Devolver el foco al disparador al cerrar el diálogo"
# 3. PUBLICAR el cambio en el remoto DEL SUBMÓDULO
git push -u origin correccion/foco-dialogo
# (tras revisarlo e integrarlo en main del submódulo)
# 4. Volver al padre y confirmar el puntero nuevo
cd ../..
git add vendor/componentes-ui
git commit -m "Actualizar componentes-ui: corrección del foco en el diálogo"
git pushEl paso 3 es el que se olvida, y su omisión es el fallo más grave de todos los submódulos. Si Carla confirma el puntero en el padre sin haber publicado el commit del submódulo, el resultado es:
fatal: remote error: upload-pack: not our ref 5c9e2b7f4a8d1c6e3b5a9f7f3a9d2c4e8b1f6a3d Fetched in submodule path 'vendor/componentes-ui', but it did not contain 5c9e2b7f4a8d1c6e3b5a9f7f3a9d2c4e8b1f6a3d. Direct fetching of that commit failed.
El padre apunta a un commit que no existe en ningún sitio salvo en el disco de Carla. El proyecto está roto para todo el equipo hasta que ella publique.
Git tiene una red de seguridad para esto, y hay que activarla:
git push --recurse-submodules=check # aborta el push si hay commits sin publicar
git push --recurse-submodules=on-demand # los publica automáticamente antescheck es la opción recomendada: te avisa y te obliga a decidir, en lugar de publicar cosas por su cuenta.
- Inspección:
status, foreach y diff --submodule
status, foreach y diff --submodulegit submodule status
El primer carácter es un indicador de estado, y hay que saber leerlo:
| Prefijo | Significado |
|---|---|
| (espacio) | El submódulo está en el commit correcto |
- |
No inicializado: falta git submodule update --init |
+ |
Está en otro commit distinto del registrado |
U |
Tiene conflictos de fusión sin resolver |
Ejemplos de las situaciones problemáticas:
→ Bruno acaba de clonar sin --recurse-submodules.
→ Alguien ha movido el submódulo y no lo ha confirmado en el padre.
Con --recursive baja por los submódulos anidados.
git submodule foreach
Ejecuta un comando en cada submódulo:
git submodule foreach 'git status --short'
git submodule foreach 'git fetch'
git submodule foreach --recursive 'git switch main && git pull'Dentro del comando, Git define variables útiles:
| Variable | Contenido |
|---|---|
$name |
El nombre del submódulo en .gitmodules |
$path |
La ruta relativa desde el padre |
$sha1 |
El commit que el padre tiene registrado |
$toplevel |
La ruta absoluta del repositorio padre |
git diff --submodule
Por defecto, un cambio de submódulo se ve como aquella línea de Subproject commit. --submodule=log muestra los commits que hay de diferencia, que es infinitamente más informativo:
Submodule vendor/componentes-ui 7f3a9d2..9d2f6a8: > Devolver el foco al disparador al cerrar el diálogo > Añadir la variante compacta del botón > Corregir el contraste del texto deshabilitado
Tres commits de diferencia, con sus asuntos. Los modos disponibles:
| Modo | Salida |
|---|---|
--submodule=short |
La línea Subproject commit (por defecto) |
--submodule=log |
La lista de commits entre ambos puntos |
--submodule=diff |
El diff completo de los cambios dentro del submódulo |
Y como es lo que se quiere casi siempre, conviene fijarlo:
Con status.submoduleSummary, git status también resume los commits pendientes en lugar de decir solo (new commits).
submodule.recurse y otras opciones que quitan dolor
submodule.recurse y otras opciones que quitan dolorLa mayor parte del sufrimiento con submódulos viene de olvidar el --recurse-submodules en algún comando. Esta opción lo activa por defecto para casi todos:
A partir de ahí, git pull, git switch, git checkout, git reset y otros actualizan los submódulos automáticamente. Es la primera configuración que debería poner cualquiera que trabaje con submódulos.
Nota:
submodule.recurseno afecta agit clone, que sigue necesitando su--recurse-submodulesexplícito.
El juego completo de configuración recomendada:
# Recorrer submódulos automáticamente en pull, switch, checkout, reset...
git config --global submodule.recurse true
# Ver commits en lugar de hashes al comparar
git config --global diff.submodule log
# Resumen de submódulos en git status
git config --global status.submoduleSummary true
# Avisar si voy a enviar un puntero a commits sin publicar
git config --global push.recurseSubmodules check
# Acelerar el fetch de varios submódulos en paralelo
git config --global submodule.fetchJobs 4Y un puñado de comandos que resuelven situaciones concretas:
# Cambió la URL en .gitmodules y hay que propagarla a .git/config
git submodule sync --recursive
# Descartar TODOS los cambios locales de los submódulos y volver al puntero
git submodule update --init --recursive --force
# Eliminar un submódulo por completo
git submodule deinit -f vendor/componentes-ui
git rm vendor/componentes-ui
rm -rf .git/modules/vendor/componentes-ui
git commit -m "Eliminar el submódulo componentes-ui"Ese último merece explicación: deinit lo desregistra, git rm quita la entrada del árbol y la línea de .gitmodules, y el rm -rf de .git/modules/ borra el repositorio interno que Git conserva (desde la versión 1.7.8, el .git del submódulo no está dentro de su directorio, sino en .git/modules/<nombre> del padre, y en el directorio del submódulo queda un fichero .git que apunta allí — la misma técnica que veremos con worktree en la lección 06-06).
- Los problemas reales
Todo lo anterior funciona. Estos son los tropiezos que se dan en la práctica y su solución.
Problema 1: "el directorio está vacío"
Síntoma. Alguien clona y vendor/componentes-ui/ no tiene nada. La aplicación no arranca.
Causa. git clone sin --recurse-submodules.
Solución. git submodule update --init --recursive. Y prevención: documentarlo en el README.md, y —mejor todavía— un hook post-checkout de la lección 06-01 que avise:
#!/usr/bin/env bash
# .git/hooks/post-checkout
if [ -f .gitmodules ] && git submodule status | grep -q '^-'; then
echo "⚠ Hay submódulos sin inicializar. Ejecuta:"
echo " git submodule update --init --recursive"
fiProblema 2: el puntero apunta a un commit que no existe
Ya visto en el apartado 6: alguien confirmó el puntero sin publicar el commit del submódulo.
Solución. Que esa persona haga git push en el submódulo. Prevención: push.recurseSubmodules check.
Problema 3: cambios sin confirmar dentro del submódulo
Síntoma. git status en el padre dice modified: vendor/componentes-ui (modified content) y no hay forma de que desaparezca.
Causa. Alguien ha editado ficheros dentro del submódulo sin confirmarlos allí.
Solución, según lo que quieras:
# A) Los cambios son buenos: confírmalos DENTRO del submódulo y publica
cd vendor/componentes-ui && git switch main && git commit -am "..." && git push
# B) Los cambios sobran: descártalos
git submodule update --force
# C) Solo quiero que git status del padre los ignore (¡con cuidado!)
git config submodule.vendor/componentes-ui.ignore dirtyLa opción ignore acepta none (por defecto), untracked (ignora ficheros sin seguimiento), dirty (ignora también las modificaciones) y all (ignora incluso un puntero distinto). all es peligroso: oculta exactamente la información que necesitas ver.
Problema 4: cambiar de rama en el padre
Síntoma. Carla pasa de una rama que tiene el submódulo a otra que no lo tiene (o que lo tiene en otra versión), y aparecen ficheros extraños o el directorio se queda con contenido antiguo.
Causa. El checkout del padre mueve el puntero, pero el contenido del directorio del submódulo no se actualiza solo salvo que lo pidas.
Solución. submodule.recurse true, o acordarse de git checkout --recurse-submodules <rama>.
Y un caso especialmente molesto: al volver a una rama anterior a la incorporación del submódulo, el directorio queda ahí con contenido y sin seguimiento. No es un fallo: es que Git no borra directorios que contienen un repositorio para no destruir trabajo.
Problema 5: conflictos de fusión en el puntero
Dos ramas actualizan el submódulo a commits distintos:
No hay marcadores de conflicto que editar: el conflicto es qué hash debe ganar. La resolución consiste en decidir el commit correcto:
# Ver los dos candidatos
git diff --submodule=log
# Elegir uno de los lados
cd vendor/componentes-ui
git log --oneline --all -10
git checkout <el-commit-correcto> # normalmente el que incluye ambos cambios
cd ../..
git add vendor/componentes-ui
git commitA menudo la respuesta correcta no es ninguno de los dos, sino un commit posterior del submódulo que contiene los cambios de ambas ramas. Fusiónalos primero dentro del submódulo, y después apunta ahí.
Problema 6: la fricción cotidiana
Y el problema del que menos se habla: los submódulos añaden un paso a todo. Cada pull puede necesitar un submodule update; cada cambio en la biblioteca son dos commits y dos push; cada persona nueva se equivoca al clonar la primera vez. Nada de eso es grave por separado, pero se acumula.
Por eso la pregunta importante no es "¿cómo se usan los submódulos?", sino "¿es esta la herramienta adecuada para mi caso?". Vamos con ello.
- Alternativas: submódulos, subtree, paquetes y monorepo
Hay cuatro formas razonables de componer un proyecto a partir de varias piezas.
| Submódulos | git subtree |
Gestor de paquetes | Monorepo | |
|---|---|---|---|---|
| Qué guarda el padre | Un puntero a un commit | El código real, fusionado en su historial | Una versión declarada (package.json) |
Todo, es un solo repositorio |
| Tamaño del repositorio padre | Mínimo | Crece con la biblioteca | Mínimo | Grande |
| ¿El que clona necesita pasos extra? | Sí (--recurse-submodules) |
No | Sí (npm install) |
No |
| Historial de la biblioteca | Separado e íntegro | Mezclado (o aplastado) | Invisible | Unificado |
| Actualizar | submodule update --remote + commit |
git subtree pull |
Cambiar la versión + instalar | No aplica: es el mismo commit |
| Contribuir a la biblioteca | Natural: es un repositorio normal | Torpe: git subtree push |
Requiere clonar aparte | Trivial |
| Reproducibilidad exacta | Total (un hash) | Total | Buena (con fichero de bloqueo) | Total |
| Curva de aprendizaje | Alta | Media | Baja | Baja |
| Cambios atómicos aplicación+biblioteca | No (dos commits) | Sí | No | Sí |
| Herramientas externas | Ninguna | Ninguna | Sí (npm, pip, Maven...) | Suele hacer falta |
Un párrafo por cada una:
git subtree hace lo contrario que los submódulos: copia el contenido de la biblioteca dentro del padre y lo mantiene sincronizado con fusiones. Quien clona no tiene que hacer nada especial, porque el código está ahí. A cambio, el repositorio crece, el historial se mezcla y contribuir de vuelta a la biblioteca es incómodo. Es una buena opción cuando consumes una dependencia y rara vez la modificas, y cuando la fricción del --recurse-submodules es inaceptable (por ejemplo, si el proyecto lo clona gente ajena al equipo). Los comandos básicos son git subtree add, pull y push; no lo desarrollamos aquí porque merecería su propia lección.
Un gestor de paquetes (npm, pip, Maven, Cargo...) es, en la mayoría de los proyectos modernos, la respuesta correcta. Si componentes-ui se puede publicar como paquete —aunque sea en un registro privado de la empresa—, el equipo declara "@equipo/componentes-ui": "^2.1.0" en su package.json, el fichero de bloqueo garantiza la reproducibilidad, y toda la maquinaria de versiones semánticas (lección 05-05) trabaja a su favor. Que Git pueda hacer esto no significa que deba. Antes de elegir submódulos, pregúntate siempre si un paquete resolvería el problema.
El monorepo —un único repositorio con la aplicación y la biblioteca dentro— elimina el problema de raíz: un commit puede cambiar ambas a la vez, y siempre son coherentes. Es lo que hacen muchas empresas grandes. El precio es un repositorio que crece mucho y necesita herramientas propias para permisos, compilaciones parciales y rendimiento. Ese escenario, con sus técnicas —clones parciales, sparse-checkout, clones superficiales—, es el tema de la lección 10-04: Escalando Git para Proyectos Grandes.
Los submódulos ganan cuando se cumplen a la vez varias de estas condiciones:
- La dependencia es código fuente que compilas junto al tuyo, no un artefacto publicable.
- Necesitas fijar un commit exacto, no un rango de versiones.
- Modificas la biblioteca con cierta frecuencia y quieres que sea un repositorio de primera clase.
- No hay (o no quieres montar) un registro de paquetes interno.
- El equipo es pequeño y se puede formar en la mecánica.
Casos reales típicos: temas y plugins de un CMS, bibliotecas C/C++ compiladas desde fuente, configuraciones compartidas entre proyectos, firmware con componentes de terceros.
Y una mención que hay que hacer aquí porque a veces se confunde: si tu problema no es código compartido sino ficheros grandes y binarios —imágenes de alta resolución, vídeos, modelos, ejecutables— la respuesta no es ninguna de las cuatro, sino Git LFS, que sustituye esos ficheros por punteros de texto y guarda el contenido en un almacén aparte. Es el tema de la lección 10-03: Git LFS para Ficheros Grandes.
Para gestor-tareas, el equipo acaba tomando esta decisión: submódulo por ahora, porque componentes-ui está en pleno desarrollo, se modifica cada semana y no compensa publicar un paquete a cada cambio. Cuando la biblioteca se estabilice y otros equipos empiecen a consumirla, pasarán a publicarla en el registro npm interno. Es una decisión sensata: la forma de componer un proyecto puede cambiar con su madurez.
Errores Comunes y Consejos
Error 1: clonar sin --recurse-submodules. El error número uno. Directorio vacío y aplicación rota. Solución: git submodule update --init --recursive.
Error 2: confirmar el puntero sin publicar el commit del submódulo. Rompe el proyecto para todos los demás. Prevención: git config push.recurseSubmodules check.
Error 3: trabajar en el submódulo sin salir del detached HEAD. Los commits quedan huérfanos. Haz git switch <rama> antes de tocar nada.
Error 4: confundir submodule update con submodule update --remote. El primero obedece al padre; el segundo trae lo nuevo del remoto. Son opuestos.
Error 5: olvidar confirmar en el padre tras actualizar. Si no hay commit en el padre, la actualización solo existe en tu disco.
Error 6: usar submodule.<n>.ignore = all. Oculta precisamente la información que necesitas ver. Como mucho, dirty.
Error 7: intentar borrar un submódulo con rm -rf. Deja restos en .git/config, .gitmodules y .git/modules/. Usa la secuencia deinit + git rm.
Error 8: elegir submódulos por defecto. Casi siempre hay una alternativa más simple. Justifica la elección.
Consejo 1: git config --global submodule.recurse true, siempre. Es la configuración que más dolor evita.
Consejo 2: diff.submodule log y status.submoduleSummary true. Convierten hashes ilegibles en listas de commits.
Consejo 3: apunta a commits estables, preferiblemente etiquetados. Un submódulo apuntando a la punta de main de otro equipo es una fuente inagotable de sorpresas. git describe --tags dentro del submódulo te dice dónde estás.
Consejo 4: documenta la mecánica en el README.md. Cinco líneas con el clone --recurse-submodules y el submodule update --init ahorran una hora a cada persona nueva.
Consejo 5: un hook post-checkout que avise de submódulos sin inicializar. Es el uso perfecto de lo aprendido en la lección 06-01.
Consejo 6: al actualizar el puntero, explica por qué en el mensaje. "Actualizar componentes-ui" no dice nada; "Actualizar componentes-ui a v2.2.0 por la corrección del foco" convierte ese commit en información útil.
Ejercicios
Ejercicio 1: crear y explorar un submódulo
Trabajando en local (sin servidor remoto):
- Crea un repositorio
bibliotecacon tres commits y una etiquetav1.0. - Crea un repositorio
aplicacioncon dos commits. - Añade
bibliotecacomo submódulo deaplicacionenvendor/bibliotecay confírmalo. - Inspecciona el árbol con
git cat-file -p HEAD^{tree}y localiza la entrada de modo160000. - Comprueba que el objeto commit al que apunta no existe en la base de objetos del padre.
Ejercicio 2: el ciclo completo de actualización
Sobre el ejercicio anterior:
- Añade dos commits nuevos a
biblioteca. - En
aplicacion, comprueba quegit statusno dice nada (el puntero sigue igual). - Ejecuta
git submodule update --remotey observa qué cambia engit status. - Confirma el nuevo puntero con un mensaje que explique el porqué.
- Comprueba el diff del puntero con
--submodule=short,--submodule=logy--submodule=diff.
Ejercicio 3: simular los problemas y resolverlos
- Clona
aplicacionsin--recurse-submodulesy comprueba el directorio vacío y la salida degit submodule status. - Arréglalo con
git submodule update --init. - En el clon, entra al submódulo y comprueba que estás en detached HEAD. Haz un commit ahí sin cambiar de rama y observa el aviso de Git.
- Recupera ese commit huérfano poniéndolo en una rama.
- Elimina el submódulo del padre por completo (
deinit,git rm,.git/modules/) y verifica que no quedan restos.
Soluciones
Solución 1:
mkdir -p /tmp/practica-sub && cd /tmp/practica-sub
# La biblioteca
git init -q -b main biblioteca
cd biblioteca
echo "export function boton() {}" > boton.js
git add . && git commit -q -m "Añadir el componente boton"
echo "export function dialogo() {}" > dialogo.js
git add . && git commit -q -m "Añadir el componente dialogo"
echo "export function campo() {}" > campo.js
git add . && git commit -q -m "Añadir el componente campo de texto"
git tag v1.0
cd ..
# La aplicación
git init -q -b main aplicacion
cd aplicacion
echo "<html><body></body></html>" > index.html
git add . && git commit -q -m "Añadir el esqueleto HTML"
echo "console.info('arranque');" > app.js
git add . && git commit -q -m "Añadir el arranque de la aplicacion"La opción
-c protocol.file.allow=alwayshace falta desde Git 2.38 para usar submódulos con rutas locales; con URLshttps://ogit@no es necesaria.
100644 blob 3f8a1c9e... .gitmodules 100644 blob 7d2e5b4c... app.js 100644 blob 9a4f1c6b... index.html 040000 tree 2c8d5f9a... vendor
Ahí está el modo 160000 y el tipo commit.
Confirmado: el objeto no está en la base de datos del padre. Solo se guarda su nombre.
Solución 2:
cd /tmp/practica-sub/biblioteca
echo "export function tabla() {}" > tabla.js
git add . && git commit -q -m "Añadir el componente tabla"
echo "// corregido el foco" >> dialogo.js
git commit -qam "Devolver el foco al disparador al cerrar el dialogo"
git tag v1.1
cd /tmp/practica-sub/aplicacion
git status --shortEl padre sigue apuntando al commit fijado: los commits nuevos de la biblioteca le son indiferentes. Esa es la propiedad, no un fallo.
diff --git a/vendor/biblioteca b/vendor/biblioteca index 8b3d6f2..d5a9c2f 160000 --- a/vendor/biblioteca +++ b/vendor/biblioteca @@ -1 +1 @@ -Subproject commit 8b3d6f2a9c4e7b1d5f8a2c6e9b4d7f1a3c5e8b2d +Subproject commit d5a9c2f7b1e4a8d3c6f9b2e5a8d1c4f7b3e6a9d2
Submodule vendor/biblioteca 8b3d6f2..d5a9c2f: > Devolver el foco al disparador al cerrar el dialogo > Añadir el componente tabla
Mucho más útil: dos commits, con sus asuntos.
Muestra el contenido real de los cambios dentro del submódulo.
git add vendor/biblioteca
git commit -q -m "Actualizar biblioteca a v1.1
Incluye la correccion del foco en el dialogo, necesaria para el
flujo de borrado con confirmacion."
git submodule statusPrefijo espacio: todo en orden.
Solución 3:
cd /tmp/practica-sub
git -c protocol.file.allow=always clone -q aplicacion clon-app
cd clon-app
ls vendor/biblioteca/El - inicial es el diagnóstico: no inicializado.
git -c protocol.file.allow=always submodule update --init
ls vendor/biblioteca/
git submodule statusecho "export function menu() {}" > menu.js
git add . && git commit -q -m "Añadir el componente menu"
git log --oneline -1
cd ../..
git submodule statusEl + avisa: el submódulo está en un commit distinto del registrado. Y ese commit no está en ninguna rama:
Ya está a salvo. Sin ese paso, un git submodule update lo habría dejado huérfano.
# 5. Eliminar el submódulo por completo
cd /tmp/practica-sub/clon-app
git submodule deinit -f vendor/biblioteca
git rm -q vendor/biblioteca
rm -rf .git/modules/vendor/biblioteca
git commit -q -m "Eliminar el submodulo biblioteca"
cat .gitmodules 2>/dev/null || echo "(.gitmodules ya no existe)"
git config --get-regexp '^submodule\.' || echo "(sin configuracion de submodulos)"
ls .git/modules 2>/dev/null || echo "(sin repositorios internos)"Los tres sitios donde vive un submódulo, limpios. Un rm -rf a secas habría dejado restos en los tres.
Conclusión
Los submódulos son la respuesta nativa de Git a componer un proyecto con varios repositorios, y su comportamiento entero se deduce de una sola idea. Lo esencial:
- Un submódulo es un puntero a un commit concreto de otro repositorio, guardado como una entrada de árbol de modo
160000y tipocommit, más una línea en.gitmodules. El padre no contiene el código de la biblioteca. - Eso da reproducibilidad exacta (cada commit del padre fija una versión de la dependencia), historiales independientes y actualización explícita: el puntero no se mueve solo.
git submodule add <url> <ruta>lo incorpora; el commit resultante toca dos ficheros, no doscientos.- Clonar requiere
--recurse-submodules, ogit submodule update --init --recursivedespués. Es el error más frecuente. - Hay dos actualizaciones opuestas:
submodule updatepone el submódulo donde dice el padre;submodule update --remotetrae lo nuevo del remoto y hay que confirmar el puntero en el padre para que exista de verdad. - Dentro del submódulo se está en detached HEAD: cambia a una rama antes de trabajar, y publica el commit del submódulo antes de confirmar el puntero (
push.recurseSubmodules check). git submodule statuscon sus prefijos (,-,+,U),foreachydiff --submodule=logson las herramientas de inspección.submodule.recurse true,diff.submodule logystatus.submoduleSummary trueeliminan la mayor parte de la fricción.- Los problemas reales son siempre los mismos: directorios vacíos, punteros a commits sin publicar, cambios sin confirmar dentro del submódulo, cambios de rama en el padre y conflictos de puntero. Todos tienen solución conocida y todos tienen prevención.
- Y sobre todo: no son la opción por defecto. Compáralos con
git subtree, con un gestor de paquetes (a menudo la mejor respuesta) y con el monorepo (lección 10-04), y elige con criterio. Para binarios grandes, la herramienta es Git LFS (lección 10-03).
gestor-tareas ya se compone de dos repositorios, y el equipo tiene la trazabilidad que necesitaba. Pero queda un problema pendiente desde el final del módulo 5, y es el de Carla.
Carla trabaja en funcionalidad/borrado-multiple, una rama larga y a medias. Cada vez que llega un aviso urgente —un fallo en producción, una revisión de código que atender, una duda sobre otra rama— tiene que hacer git stash, cambiar de rama, resolver, volver y git stash pop. Diez veces al día. Y con el submódulo recién añadido la cosa empeora, porque cada cambio de rama arrastra también su actualización.
Su primera idea es clonar el repositorio dos veces. Funcionaría, pero duplica el espacio, duplica los fetch y deja dos repositorios con historiales que hay que mantener sincronizados a mano. Hay una solución mucho mejor, integrada en Git y sorprendentemente poco conocida: varios directorios de trabajo compartiendo una única base de datos de objetos. Es la lección 06-06: Múltiples Copias de Trabajo con git worktree.
Dominando Git: De Principiante a Avanzado
Módulo 1: Introducción a Git
- ¿Qué es Git?
- Instalando Git
- Terminología Básica de Git
- El Modelo de Datos de Git
- Configurando Git
- Configuración Inicial
Módulo 2: Operaciones Básicas de Git
- Creando un Repositorio
- Clonando un Repositorio
- Flujo de Trabajo Básico de Git
- Preparando y Confirmando Cambios
- Inspeccionando Cambios con git diff
- Visualizando el Historial de Confirmaciones
Módulo 3: Ramas y Fusión
- Entendiendo las Ramas
- Creando y Cambiando Ramas
- Fusionando Ramas
- Estrategias de Fusión
- Resolviendo Conflictos de Fusión
- Gestión de Ramas
Módulo 4: Trabajando con Repositorios Remotos
- Entendiendo los Repositorios Remotos
- Agregando un Repositorio Remoto
- Autenticación con Repositorios Remotos
- Obteniendo y Extrayendo Cambios
- Enviando Cambios
- Rastreando Ramas
Módulo 5: Operaciones Avanzadas de Git
- Rebase
- Rebase Interactivo
- Cherry-Picking de Confirmaciones
- Guardando Cambios Temporales
- Etiquetando Confirmaciones
- Revirtiendo Confirmaciones
Módulo 6: Herramientas y Técnicas de Git
- Usando Git Hooks
- Git Bisect
- Git Blame
- Git Log y Alias
- Submódulos de Git
- Múltiples Copias de Trabajo con git worktree
Módulo 7: Estrategias de Colaboración y Flujo de Trabajo
- Forks y Pull Requests
- Revisiones de Código con Git
- Flujo de Trabajo Git Flow
- GitHub Flow
- Trunk Based Development
- Integración Continua con Git
Módulo 8: Mejores Prácticas y Consejos de Git
- Escribiendo Buenos Mensajes de Confirmación
- Manteniendo un Historial Limpio
- Ignorando Archivos con .gitignore
- Atributos de Fichero con .gitattributes
- Mejores Prácticas de Seguridad
- Consejos de Rendimiento
Módulo 9: Solución de Problemas y Depuración
- Problemas Comunes de Git
- Deshaciendo Cambios
- Resolviendo Divergencias con el Remoto
- Recuperando Confirmaciones Perdidas
- Tratando con Repositorios Corruptos
- Técnicas Avanzadas de Depuración
