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

  1. Qué es exactamente un submódulo
  2. git submodule add: añadir componentes-ui
  3. Qué se confirma exactamente: el objeto de tipo commit
  4. Clonar un proyecto con submódulos
  5. Actualizar: el submódulo frente al puntero
  6. Trabajar dentro de un submódulo y el detached HEAD
  7. Inspección: status, foreach y diff --submodule
  8. submodule.recurse y otras opciones que quitan dolor
  9. Los problemas reales
  10. Alternativas: submódulos, subtree, paquetes y monorepo

  1. 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 .gitmodules que dice de dónde clonar ese otro repositorio.

Dos piezas, ninguna más:

  1. El puntero: una entrada en el árbol (lección 01-04) cuyo tipo no es blob ni tree, sino commit. Guarda un hash de 40 caracteres y nada más.
  2. .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-tareas fija 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 log distintos.
  • 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.

  1. git submodule add: añadir componentes-ui

Ana lo hace desde la raíz de gestor-tareas:

cd ~/proyectos/gestor-tareas
git submodule add [email protected]:equipo/componentes-ui.git vendor/componentes-ui
Cloning 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:

git submodule add [-b <rama>] <url> [<ruta>]
  • 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:

git status
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.git

Y 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:

<script src="vendor/componentes-ui/dist/componentes.js"></script>
// app.js
const dialogo = ComponentesUI.crearDialogo({
  titulo: 'Confirmar borrado',
  mensaje: '¿Seguro que quieres borrar esta tarea?',
});

Y envía:

git push

  1. 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:

git cat-file -p HEAD^{tree}
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
git cat-file -p HEAD:vendor
160000 commit 7f3a9d2c4e8b1f6a3d5c9e2b7f4a8d1c6e3b5a9f	componentes-ui

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.

git cat-file -t 7f3a9d2
fatal: git cat-file: could not get object info

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:

git cat-file -s $(git rev-parse HEAD:vendor)

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 9d2f6a8b3e7c1d5f9a2b6c4e8d1f3a7c5b9e2d6f

Una línea que cambia. Todo el contenido de la biblioteca puede haber cambiado, pero para el padre esto es un puntero que se mueve.

  1. 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/
(vacío)

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.git
Cloning 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 fijado

O en un solo comando:

git submodule update --init
git submodule update --init --recursive     # si los submódulos tienen submódulos

La 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 update

También se puede inicializar solo alguno, en proyectos con muchos:

git submodule update --init vendor/componentes-ui

  1. 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:

git pull
git submodule update --init --recursive

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 submodule update --remote vendor/componentes-ui
Submodule path 'vendor/componentes-ui': checked out '9d2f6a8b3e7c1d5f9a2b6c4e8d1f3a7c5b9e2d6f'

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:

  1. La indicada en .gitmodules con branch = <rama> (o git submodule add -b).
  2. Si no hay ninguna, HEAD del remoto, que suele ser main.
[submodule "vendor/componentes-ui"]
	path = vendor/componentes-ui
	url = [email protected]:equipo/componentes-ui.git
	branch = estable

Y ahora lo importante: eso todavía no ha cambiado nada en el historial del padre. Es un cambio sin confirmar:

git status
On branch main
Changes not staged for commit:
	modified:   vendor/componentes-ui (new commits)

(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 push
sequenceDiagram
    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.

  1. 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:

cd vendor/componentes-ui
git status
HEAD detached at 7f3a9d2
nothing to commit, working tree clean

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 push

El 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:

# Bruno, en su máquina
git pull
git submodule update
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 antes
# Que sea el comportamiento por defecto del repositorio
git config push.recurseSubmodules check

check es la opción recomendada: te avisa y te obliga a decidir, en lugar de publicar cosas por su cuenta.

  1. Inspección: status, foreach y diff --submodule

git submodule status

git submodule status
 7f3a9d2c4e8b1f6a3d5c9e2b7f4a8d1c6e3b5a9f vendor/componentes-ui (v2.1.0)

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:

-7f3a9d2c... vendor/componentes-ui

→ Bruno acaba de clonar sin --recurse-submodules.

+9d2f6a8b... vendor/componentes-ui (v2.2.0)

→ 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 submodule foreach 'echo "$name está en $(git describe --tags --always)"'
Entering 'vendor/componentes-ui'
vendor/componentes-ui está en v2.1.0

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:

git diff --submodule=log
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:

git config --global diff.submodule log
git config --global status.submoduleSummary true

Con status.submoduleSummary, git status también resume los commits pendientes en lugar de decir solo (new commits).

  1. submodule.recurse y otras opciones que quitan dolor

La 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:

git config --global submodule.recurse true

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.recurse no afecta a git clone, que sigue necesitando su --recurse-submodules explí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 4

Y 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).

  1. 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"
fi

Problema 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í.

git submodule status
+7f3a9d2c... vendor/componentes-ui (v2.1.0-3-g5c9e2b7)

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 dirty

La 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:

CONFLICT (submodule): Merge conflict in vendor/componentes-ui

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 commit

A 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.

  1. 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? (--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) No
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):

  1. Crea un repositorio biblioteca con tres commits y una etiqueta v1.0.
  2. Crea un repositorio aplicacion con dos commits.
  3. Añade biblioteca como submódulo de aplicacion en vendor/biblioteca y confírmalo.
  4. Inspecciona el árbol con git cat-file -p HEAD^{tree} y localiza la entrada de modo 160000.
  5. 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:

  1. Añade dos commits nuevos a biblioteca.
  2. En aplicacion, comprueba que git status no dice nada (el puntero sigue igual).
  3. Ejecuta git submodule update --remote y observa qué cambia en git status.
  4. Confirma el nuevo puntero con un mensaje que explique el porqué.
  5. Comprueba el diff del puntero con --submodule=short, --submodule=log y --submodule=diff.

Ejercicio 3: simular los problemas y resolverlos

  1. Clona aplicacion sin --recurse-submodules y comprueba el directorio vacío y la salida de git submodule status.
  2. Arréglalo con git submodule update --init.
  3. 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.
  4. Recupera ese commit huérfano poniéndolo en una rama.
  5. 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"
git -c protocol.file.allow=always submodule add ../biblioteca vendor/biblioteca
git status --short
A  .gitmodules
A  vendor/biblioteca

La opción -c protocol.file.allow=always hace falta desde Git 2.38 para usar submódulos con rutas locales; con URLs https:// o git@ no es necesaria.

git commit -q -m "Añadir biblioteca como submodulo en v1.0"
git cat-file -p HEAD^{tree}
100644 blob 3f8a1c9e...	.gitmodules
100644 blob 7d2e5b4c...	app.js
100644 blob 9a4f1c6b...	index.html
040000 tree 2c8d5f9a...	vendor
git cat-file -p HEAD:vendor
160000 commit 8b3d6f2a9c4e7b1d5f8a2c6e9b4d7f1a3c5e8b2d	biblioteca

Ahí está el modo 160000 y el tipo commit.

git cat-file -t 8b3d6f2 2>&1 | head -1
fatal: git cat-file: could not get object info

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 --short
(sin salida)

El padre sigue apuntando al commit fijado: los commits nuevos de la biblioteca le son indiferentes. Esa es la propiedad, no un fallo.

git submodule update --remote
git status
Submodule path 'vendor/biblioteca': checked out 'd5a9c2f...'
On branch main
Changes not staged for commit:
	modified:   vendor/biblioteca (new commits)
git diff --submodule=short
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
git diff --submodule=log
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.

git diff --submodule=diff | head -20

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 status
 d5a9c2f7b1e4a8d3c6f9b2e5a8d1c4f7b3e6a9d2 vendor/biblioteca (v1.1)

Prefijo 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/
(vacío)
git submodule status
-d5a9c2f7b1e4a8d3c6f9b2e5a8d1c4f7b3e6a9d2 vendor/biblioteca

El - inicial es el diagnóstico: no inicializado.

git -c protocol.file.allow=always submodule update --init
ls vendor/biblioteca/
git submodule status
boton.js  campo.js  dialogo.js  tabla.js
 d5a9c2f7b1e4a8d3c6f9b2e5a8d1c4f7b3e6a9d2 vendor/biblioteca (v1.1)
# 3. El detached HEAD
cd vendor/biblioteca
git status | head -2
HEAD detached at d5a9c2f
echo "export function menu() {}" > menu.js
git add . && git commit -q -m "Añadir el componente menu"
git log --oneline -1
cd ../..
git submodule status
a7c3e9f Añadir el componente menu
+a7c3e9f1d5b8c2e6a9f4d7b1c3e8a5f2d6b9c4e7 vendor/biblioteca (v1.1-1-ga7c3e9f)

El + avisa: el submódulo está en un commit distinto del registrado. Y ese commit no está en ninguna rama:

cd vendor/biblioteca
git branch --contains HEAD
(sin salida: no pertenece a ninguna rama)
# 4. Rescatarlo
git switch -c correccion/menu
git branch --contains HEAD
* correccion/menu

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)"
(.gitmodules ya no existe)
(sin configuracion de submodulos)
(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 160000 y tipo commit, 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, o git submodule update --init --recursive después. Es el error más frecuente.
  • Hay dos actualizaciones opuestas: submodule update pone el submódulo donde dice el padre; submodule update --remote trae 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 status con sus prefijos ( , -, +, U), foreach y diff --submodule=log son las herramientas de inspección. submodule.recurse true, diff.submodule log y status.submoduleSummary true eliminan 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

Módulo 2: Operaciones Básicas de Git

Módulo 3: Ramas y Fusión

Módulo 4: Trabajando con Repositorios Remotos

Módulo 5: Operaciones Avanzadas de Git

Módulo 6: Herramientas y Técnicas de Git

Módulo 7: Estrategias de Colaboración y Flujo de Trabajo

Módulo 8: Mejores Prácticas y Consejos de Git

Módulo 9: Solución de Problemas y Depuración

Módulo 10: Git en el Mundo Real

© Copyright 2026. Todos los derechos reservados