Git Submodules vs. Alternatives for Multi-Repo Setups
AI generated
git
HEAD
Git · Submodules · Multi-Repo · Composer
Git Submodules vs. Alternatives for Multi-Repo Setups
When vendoring via submodule is actually worth it

Git submodules promise to cleanly stitch multiple repositories together, but in practice forgotten updates, a confusing detached HEAD state, and diverging commit pins across the team cause frustration on a regular basis. This article explains how submodules technically work, compares them with git subtree, Composer based dependencies, and monorepos, and shows which tool actually fits typical Magento multi repo setups.

13 min. read Submodules · Subtree · Composer Magento 2 · Multi-Repo · CI/CD

1. How Git submodules technically work

A Git submodule is, at its core, nothing more than a fully independent repository with its own history, its own branches, and its own remote, mounted at a specific path inside the working tree of a parent repository. The parent repository stores neither the content nor the commits of the submodule itself. Instead, Git creates a special tree entry of mode 160000 in the parent repo's tree, known as a gitlink. This entry doesn't point to a blob or a tree, it points directly to a single, specific commit SHA inside the submodule repository.

This is complemented by the .gitmodules file in the parent repo's root directory, an ordinary, tracked text file in INI format that records the remote URL and optionally a branch for every submodule path. The separation of concerns matters: .gitmodules tells Git where a submodule comes from, while the gitlink entry in the tree tells Git which exact commit should currently be checked out there. This combination of an external repository plus a fixed commit pointer makes submodules the native Git tool for vendoring, but it's also the root of nearly every pitfall described below.

Running git ls-tree HEAD in a repository with submodules shows, alongside normal blob and tree entries, a line like 160000 commit a1b2c3d... vendor/module-x. Mode 160000 only exists for submodule entries and explicitly marks them as "commit", not as a file or directory. That's exactly why git diff shows only a single line with the old and new commit hash for a submodule change, never the actual file contents, because Git treats the entire submodule as an atomic, opaque pointer.

The .gitmodules file adds the origin information on top of that structure. It's tracked and committed like any normal file, but strictly separate from the actual version pin. A common misunderstanding: a git pull in the parent repository updates neither the gitlink nor the content of the submodule directory to the latest state of the submodule's remote automatically. It only fetches the parent repo's new history, including a possibly changed gitlink pointer, which only takes effect in the working tree after an explicit submodule update command.


# .gitmodules: tracked in the parent repo, maps path to remote URL
[submodule "vendor/module-x"]
	path = vendor/module-x
	url = git@github.com:example-vendor/module-x.git
	branch = main

[submodule "deploy-config"]
	path = config/deploy-config
	url = git@gitlab.mironsoft.internal:ops/deploy-config.git
	# Shallow clone keeps checkout fast for a config-only repo
	shallow = true

3. Adding, cloning, and updating submodules

A new submodule is added with git submodule add <url> <path>. The command clones the external repository into the given path, writes the corresponding entry into .gitmodules, creates the gitlink tree entry, and stages both for the next commit in the parent repository. Up to this point, nothing has actually been shared except the reference. Other team members who clone the parent repo initially get only an empty folder at the submodule path, because a plain git clone does not initialize submodules by default.

This is by far the most common practical stumbling block: without git clone --recurse-submodules during the initial clone, or git submodule update --init --recursive afterwards, the submodule directory stays empty while build scripts or the Composer autoloader reference files that simply don't exist. The same problem resurfaces after every git pull once the gitlink pointer in the parent repo has changed: the new commit hash is known locally, but the submodule's working directory still points to the old state until it is updated again.


# Clone a parent repo including all submodules in one step
$ git clone --recurse-submodules git@github.com:mironsoft/magento-shop.git

# Forgot --recurse-submodules? Initialize and fetch afterwards
$ git submodule update --init --recursive

# Pull parent changes AND move submodules to their newly pinned commits
$ git pull --recurse-submodules

# Make git pull always recurse into submodules for this repo
$ git config submodule.recurse true

# Check status of all submodules including nested ones
$ git submodule status --recursive
 a1b2c3d vendor/module-x (heads/main)
+e4f5a6b config/deploy-config (heads/main)
# leading "+" means the checked-out commit differs from the pinned SHA

4. The detached HEAD problem with submodules

After every git submodule update, the submodule by default is not on a branch but in a detached HEAD state, pointing directly at the pinned commit. This is technically correct and intentional, since Git has to check out exactly the referenced commit regardless of which branch currently points to it. For developers used to every checkout automatically landing on a branch, this feels like a bug though, especially when they accidentally start working directly inside the submodule directory.

The real risk appears when a commit is made while in the detached HEAD state: the new commits do exist in the submodule's local object store, but they're not attached to any branch. If a different branch is subsequently checked out inside the submodule, or git submodule update runs again in the parent repo, those commits become effectively unreachable for Git and risk being removed by the next garbage collection. The rule of thumb, therefore: before making any change inside a submodule, explicitly run git checkout -b feature/x or at least switch to a known branch, never keep working directly in a detached HEAD state.

5. Diverging commit pins across the team and CI complexity

Because every gitlink pins an exact commit, submodule states drift apart across a team quickly unless every developer consistently updates after every pull. One developer works against commit A, another still against commit B, a third has unknowingly checked out an even newer commit C locally without committing it in the parent repo. Tests then appear to pass or fail seemingly at random, even though the code in the parent repository looks identical to everyone, because the actual cause is invisible inside the submodule pointer and only surfaces as a vague "modified content" line in git status on the parent repo.

In CI pipelines, the problem compounds: a runner that freshly clones the parent repository needs an explicit submodule initialization step, or the build or tests fail with cryptic "file not found" errors that have nothing to do with the actual code. Private submodule repositories additionally require the CI runner to hold its own credentials or SSH deploy keys, separate from those of the parent repo, which noticeably complicates secret management and permission models in multi-stage pipelines.


# .gitlab-ci.yml: explicit submodule handling for a Magento CI pipeline
variables:
  GIT_SUBMODULE_STRATEGY: recursive
  # Separate deploy token, submodule repo is private and not covered
  # by the parent repo's default CI credentials
  GIT_SUBMODULE_FORCE_HTTPS: "true"

build:
  stage: build
  before_script:
    - git submodule sync --recursive
    - git submodule update --init --recursive
  script:
    - bin/composer install --no-dev --optimize-autoloader
    - bin/magento setup:di:compile

6. git subtree: merging history instead of referencing it

git subtree solves the same underlying problem, integrating foreign code into a repository, with a fundamentally different approach: instead of storing a reference to an external repository, subtree copies and merges the entire history of the foreign project directly into a subdirectory of the parent repo. After a git subtree add, the code is simply there, as if it had been part of the repository from the start. There's no .gitmodules, no gitlink, no separate update command, no detached HEAD state, a plain git clone is entirely sufficient.

The price for that is a noticeably messier commit history: every subtree merge potentially adds hundreds of foreign commits into the parent repo's git log, which complicates git blame and bisect sessions. Updates happen via git subtree pull, which merges in the full current state of the foreign repo again, and local changes can be pushed back to the original repository with git subtree push, though only if its history stays compatible. For teams unwilling to tolerate an extra init command after cloning, subtree is often the more pragmatic choice over submodules.


# Add a third-party repo as a subtree, squashing its history into one commit
$ git subtree add --prefix=vendor/module-x \
    git@github.com:example-vendor/module-x.git main --squash

# Pull upstream changes into the subtree later on
$ git subtree pull --prefix=vendor/module-x \
    git@github.com:example-vendor/module-x.git main --squash

# Push local changes made inside vendor/module-x back upstream
$ git subtree push --prefix=vendor/module-x \
    git@github.com:example-vendor/module-x.git contribution-branch

# No .gitmodules, no init step: a plain clone already has everything
$ git clone git@github.com:mironsoft/magento-shop.git

7. Composer dependencies: private Packagist, Satis, and VCS repositories

For PHP and Magento projects, the most natural alternative to submodules is often not a Git technique at all, but the package manager already in use anyway. Instead of pinning a raw commit hash via a gitlink, Composer references versioned releases through semantic versioning constraints like ^2.3. A private Magento module can be loaded directly from the Git remote via a vcs repository, published as its own package archive through a self-hosted Satis instance, or managed via private Packagist with full release management and team access rights.

The decisive advantage over submodules: composer install is already part of every Magento deployment and every CI pipeline, so there's no extra init step and no risk of a forgotten update. Versions are explicitly pinned in composer.lock, which guarantees reproducible builds just as reliably as a gitlink, but with readable version numbers instead of cryptic hashes, and with the familiar tools composer update, composer why, and composer outdated for dependency analysis. The downside: every change to the private module requires an actual release tag, which noticeably slows the iteration cycle during active parallel development.


{
  "repositories": [
    {
      "type": "vcs",
      "url": "git@github.com:mironsoft/module-seosuite.git"
    },
    {
      "type": "composer",
      "url": "https://satis.mironsoft.internal"
    }
  ],
  "require": {
    "mironsoft/module-seosuite": "^2.3",
    "mironsoft/module-core": "^1.8"
  },
  "config": {
    "allow-plugins": {
      "magento/*": true
    }
  }
}

8. Monorepo: one repository for everything

The most radical alternative gives up on multiple repositories entirely: in the monorepo approach, the store, shared modules, and deployment configuration all live inside exactly one Git repository, typically in separate directories. This eliminates submodules, gitlinks, and version pinning as a concept altogether, because there is simply just one single, shared history. A single commit can contain a change to a module and the matching adjustment in the store code at the same time, atomically and without coordinating across multiple repository boundaries. This exact advantage makes monorepos particularly attractive for tightly coupled, jointly developed code.

The price is structural: as the number of projects grows, so does repo size, clone times increase, and without additional tooling like sparse checkout or partial clone, developers often download more code than they actually need for their task. CI pipelines also have to learn to selectively build and test only the actually affected areas, otherwise pipeline runtime grows linearly with the total size of the repository, even when only a single line in a small module has changed.

9. Submodules, subtree, Composer, and monorepo compared

None of the four options is universally the "right" solution, each solves a different underlying problem to a different degree. The table below compares them across the most practically relevant criteria.

Criterion Submodules Subtree Composer Monorepo
Setup complexity Init step required after every clone No init step, a plain clone is enough composer install, already standard No extra step
History clarity Cleanly separated, own history Foreign commits mixed in with your own Cleanly separated via packages One history, topics intermixed
Team learning curve High, detached HEAD confuses newcomers Medium, a few extra commands Low, standard workflow for any PHP dev Low, but requires tooling maturity
CI overhead Extra init and auth configuration No extra configuration needed Already included in the standard build Requires a selective build strategy
Good fit for Pinned vendor forks, config repos One-off vendoring situations Versioned, reusable modules Tightly coupled teams, one product

For most Magento multi-repo setups, a simple rule of thumb applies: as soon as a piece of code is meant to be a standalone, reusable module with clear version boundaries, for example an SEO extension used across multiple client projects, Composer via a private VCS repository or Satis is almost always the better choice. Submodules remain useful for well-scoped special cases, such as a pinned fork of a third-party module that should deliberately not be updated through Composer, or a central, git-tracked deployment config repository that multiple projects should reference but never update automatically. Monorepos, in turn, pay off mainly when a store and its modules are developed exclusively by a single, tightly coordinated team anyway.

Mironsoft

Repo strategies, Composer migrations, and CI/CD pipelines for PHP and Magento teams

Ready to structure your multi-repo setup properly?

We analyze your current submodule or multi-repo setup, point out concrete alternatives, and guide the migration to Composer, subtree, or a monorepo, without interrupting your build and deployment processes.

Repo strategy audit

Analyzing and evaluating existing submodule and multi-repo setups

Composer migration

Setting up private Packagist, Satis, or VCS repositories for Magento modules

CI/CD for multi-repo

Reliably setting up pipelines for submodule, Composer, or monorepo workflows

10. Summary

Git submodules pin an external repository to an exact commit through a gitlink tree entry and the .gitmodules file. That's technically clean, but it comes with practical pitfalls: forgotten git submodule update --init --recursive calls, a confusing detached HEAD state after every update, diverging commit pins across the team, and extra configuration effort in every CI pipeline. git subtree avoids the init step and the detached HEAD state entirely, at the cost of a messier, intermixed commit history.

For most PHP and Magento projects, Composer via private Packagist, Satis, or VCS repositories is the more pragmatic choice, because version management and installation are already part of the standard workflow. A monorepo eliminates the problem structurally by making multiple repositories unnecessary altogether, but demands growing investment in tooling and selective CI strategies in return. Submodules remain the right choice for clearly scoped special cases like pinned vendor forks or shared deployment config repositories that are deliberately not meant to update automatically.

Git Submodules vs. Alternatives: The Essentials at a Glance

Submodules

Gitlink + .gitmodules pin an exact commit. The init step and detached HEAD are the biggest pitfalls.

Subtree

Merges foreign history directly into the repo. No init step, but a messier git log.

Composer

Versioned releases via VCS repository, Satis, or private Packagist instead of a raw commit pin.

Monorepo

One repository, atomic commits across module boundaries, at the cost of growing repo size and CI complexity.

11. FAQ: Git Submodules vs. Alternatives

1What is a Git submodule, technically speaking?
An independent repository pinned to an exact commit via a gitlink tree entry (mode 160000) and .gitmodules. The parent repo stores neither content nor history itself.
2Why is my submodule folder empty after cloning?
git clone does not initialize submodules by default. Clone with --recurse-submodules or run git submodule update --init --recursive afterwards.
3What does detached HEAD mean for submodules and is it dangerous?
HEAD points directly at the pinned commit instead of a branch. Only dangerous when committing there: those commits are attached to no branch and can be lost on the next update.
4What is the difference between git submodule and git subtree?
Submodules only reference a pinned commit in the external repo. Subtree merges the entire foreign history into the parent repo, no init step, but a messier log.
5When should I use Composer instead of submodules for a private module?
When the module is a standalone, reusable package with clear version boundaries. Composer brings standard tooling with no extra init commands.
6What is the difference between Satis and private Packagist?
Satis is a self-hosted, static package list with no release management. Private Packagist is a full, hosted package server with team access rights.
7How do I update all submodules to their latest remote state?
Run git submodule update --remote --merge, then commit the new gitlink state in the parent repository so it becomes visible to the team.
8Why do submodule states diverge across a team so easily?
Because git pull does not automatically update the submodule's working directory. Without consistent submodule update, team members unknowingly work against different commit states.
9What are the downsides of a monorepo?
Growing repo size and clone time, plus the need for a selective CI build strategy so pipeline runtime doesn't grow with the total repo size.
10When are submodules still the right choice?
For pinned vendor forks with local patches, or central, git-tracked deployment config repositories that multiple projects should reference without versioning through Composer.