Monorepos, LFS, submodules
Advanced
Why this matters
These three arrive without warning, usually as something that looks broken: a folder that is empty after a clone, an image that opens as a page of text, a repository with fourteen products in it. None of them is broken, and each has one thing you need to know.
Monorepos
A monorepo is one repository holding many projects: several applications, their shared libraries, the infrastructure and the documentation, all versioned together.
| Monorepo | Many repositories | |
|---|---|---|
| A change spanning two projects | One merge request | Two, coordinated |
| Finding anything | One clone, one search | Know which repository first |
| Clone size and speed | Large, sometimes very | Small |
| CI | Must work out what changed and run only that | Straightforward |
| Access control | Coarse: access is to the whole thing | Per repository |
What changes for you, in practice:
- Your path matters more than your repository. Issues,
CODEOWNERSand CI rules are all scoped by directory, so "the docs team owns/docs" replaces "the docs team owns the docs repository". - The pipeline may not run everything. A change under
docs/may trigger only the documentation jobs, which is why a green pipeline can look suspiciously fast. git logneeds a path.git log --oneline -- docs/is the only way to see your area's history; without the path you get everyone's.- Cloning can be slow.
git clone --filter=blob:nonefetches file contents on demand and makes a large clone much faster, and everything works normally afterwards.
Git LFS
Git stores every version of every file forever, which is fine for text and painful for a 40 MB video. Git LFS (Large File Storage) keeps large files on a separate server and puts a small pointer file in the repository instead.
You can tell a project uses it from .gitattributes:
*.psd filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -textThe symptom to recognize: you open an image or a video and get a few lines of text like this:
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a2140c1f...
size 41306624That is the pointer, and it means LFS is not installed or the files were not fetched. The fix is to install Git LFS, run git lfs install once per machine, and then git lfs pull in the repository.
Two things worth knowing beyond that. LFS storage and bandwidth are quota'd and billed on both platforms, which is why teams are careful about what goes into it. And a file must be tracked by LFS before it is committed: adding a rule later does not move existing files out of history, which is a separate and unpleasant operation.
Submodules
A submodule is another Git repository nested inside this one, pinned to a specific commit. .gitmodules records where it comes from:
[submodule "vendor/trailguide-data"]
path = vendor/trailguide-data
url = ../sub-childThe surprise: a plain clone leaves the folder empty.
$ git clone <url> trailguide
$ ls vendor/trailguide-data-d3b92ec8073de8841dbb87d25de61309478b897b vendor/trailguide-dataNothing. And git submodule status shows a leading -, meaning "not initialised":
The fix, and the one command to remember from this lesson:
$ git submodule update --init --recursiveSubmodule 'vendor/trailguide-data' registered for path 'vendor/trailguide-data'
Cloning into '…/vendor/trailguide-data'...
Submodule path 'vendor/trailguide-data': checked out 'd3b92ec…'Now the folder has content, and the status line loses its -. Cloning with git clone --recurse-submodules does both steps at once.
What a submodule change looks like
The parent repository does not record the submodule's files, only which commit it points at. Commit something inside the submodule and the parent shows:
$ git status --short M vendor/trailguide-data$ git diff --submodule=short-Subproject commit d3b92ec8073de8841dbb87d25de61309478b897b
+Subproject commit fe8516bd408a62cad581c86d33843db1fba54e5b
One line changed: the pinned commit. That is the whole mental model. To update a submodule, you commit inside it and push there, then commit the new pointer in the parent. Two repositories, two pushes; forgetting the second is the most common submodule mistake, and it leaves colleagues pointing at a commit that only exists on your machine.
How to do it
The commands worth knowing, by symptom:
$ git submodule status # is it initialised?
$ git submodule update --init --recursive # fill an empty submodule folder
$ git clone --recurse-submodules <url> # do it at clone time
$ git lfs install && git lfs pull # a "file" that is three lines of text
$ git clone --filter=blob:none <url> # a very large repository
$ git log --oneline -- docs/ # a monorepo: your area onlyVS Code shows submodules as separate entries in the Source Control view once initialised, and the built-in terminal handles the rest. The Git LFS support depends on LFS being installed on the machine, not on the editor.
IntelliJ handles submodules better than most: it detects them, offers to initialise them after a clone, and shows each as its own root in the Git tool window with separate branch controls.
A submodule appears in the file list as a folder with a commit hash rather than an expandable directory. LFS is enabled per project in Settings → General → Visibility, project features, permissions, with storage counting against the namespace's quota.
The same: submodules show as a pinned commit in the file list and link to the other repository. LFS is enabled per repository, with storage and bandwidth billed to the account.
Common mistakes
- Assuming an empty submodule folder means a broken clone. It means
--initwas not run. - Committing inside a submodule and pushing only the parent, leaving colleagues pointing at a commit that does not exist for them.
- Committing a large binary directly in a project that uses LFS for that type. Check
.gitattributesfirst. - Adding an LFS rule after the file is already in history, which does not move it.
- Running
git logwithout a path in a monorepo and drowning. - Treating a monorepo's fast pipeline as suspicious. Path-based rules are normal.
Try it yourself
Goal: meet a submodule in controlled conditions.
- Create two small repositories locally,
parentandchild. - In
parent, rungit submodule add ../child vendor/child, then commit. - Clone
parentto a new folder and runls vendor/child. It is empty. - Run
git submodule statusand note the leading-, thengit submodule update --init --recursive. - Commit something inside the submodule, then run
git statusandgit diff --submodule=shortin the parent and read what changed.
Expected result: an empty folder that fills with one command, and a parent whose diff is a single "Subproject commit" line.
Show solution
Step 5 is the mental model. The parent repository never contains the submodule's files, only a pointer to one of its commits. Once that is clear, every confusing submodule situation becomes readable: the question is always "which commit is the parent pointing at, and does it exist where everyone can reach it?".