Fastest fix: run git submodule sync --recursive then git submodule update --init --recursive from the parent repo root. That re-copies the URLs from .gitmodules into .git/config and checks out the exact pinned commit, which clears most “stale submodule” cases. If you actually want the submodule’s newest upstream commit (not the pinned one), that is a different command — git submodule update --remote — and the distinction is the single most common reason people think submodules are “broken.”
You edited .gitmodules to point vendor/ui-kit at a new URL, ran git submodule update --init --recursive, and the submodule directory still contains the old commit, or it is empty. Or a teammate committed a submodule bump on their machine, but when you pull and run git submodule update, you get a SHA that does not exist in the submodule’s history. Submodules are confusing because three separate things must stay in sync:
- the
.gitmodulesfile (tracked text, holds the URL and optional branch), - the
[submodule]section in.git/config(your local, untracked copy of that URL), and - the SHA pointer stored as a “gitlink” tree entry in the parent repo (
git ls-tree HEAD <path>).
A mismatch between any two of these produces an update that silently does nothing or errors out. Behaviour and flags below are current as of June 2026 (Git 2.4x).
Which bucket are you in?
Run these three commands first, then match the result to a cause:
git submodule status --recursive
git config --file .gitmodules submodule.vendor/ui-kit.url
git config submodule.vendor/ui-kit.url
| Symptom | Most likely cause | Jump to |
|---|---|---|
git submodule status line starts with - | Never initialized (no --recurse-submodules on clone) | Cause 1 / Step 2 |
Line starts with + | Checked-out SHA differs from the parent’s pinned SHA | Cause 5 / Step 3 |
The two git config ... .url values differ | .gitmodules edited but .git/config not synced | Cause 1 / Step 1 |
fatal: reference is not a tree | Pinned SHA not on the submodule remote | Cause 2 / Step 2 |
| URL inside submodule is the old host | Cloned before the host moved, never synced | Cause 3 / Step 1 |
.git/modules/<path> missing | Folder is a plain directory, not a nested repo | Cause 4 / Step 4 |
| Only one commit of submodule history | Shallow clone cut off the pinned SHA | Cause 6 / Step 5 |
Status prefixes, per the Git docs: a leading space means in sync, - means uninitialized, + means the checked-out commit differs from the parent’s recorded SHA, and U means a merge conflict.
Common causes
Ordered by hit rate, highest first.
1. .gitmodules was changed but .git/config was not synced
Editing .gitmodules by hand (instead of using git submodule set-url) updates the tracked text file but leaves the [submodule] section in .git/config pointing at the old URL. git submodule sync exists specifically to reconcile them; it only touches submodules that already have an entry in .git/config.
How to spot it: compare git config --file .gitmodules submodule.vendor/ui-kit.url with git config submodule.vendor/ui-kit.url. If they differ, a sync is needed.
2. The parent’s gitlink points to a SHA not yet on the submodule remote
A teammate pushed a submodule bump commit in the parent repo before pushing the corresponding commits to the submodule’s own remote. The parent now references a SHA that nobody else can fetch.
How to spot it: cd vendor/ui-kit && git fetch && git cat-file -t <SHA>. If it returns fatal: Not a valid object name, the SHA is not on the remote yet, and git submodule update will fail with fatal: reference is not a tree: <SHA>.
3. Submodule was initialized from the wrong URL
The team moved hosting (for example GitHub to GitLab), updated .gitmodules, but developers who cloned before the change never ran git submodule sync. Their .git/config still has the old URL, so fetches hit the dead host.
How to spot it: git -C vendor/ui-kit remote get-url origin returns the old URL, or git submodule foreach 'git remote -v' shows the stale host across submodules.
4. Submodule directory exists as a regular directory (not a git repo)
The submodule was accidentally deleted and re-created as a plain folder, or the gitfile linking it to .git/modules/... was removed. Git cannot update a plain directory as a submodule.
How to spot it: ls .git/modules/vendor/ui-kit does not exist, or there is no .git file inside vendor/ui-kit.
5. git submodule update was run instead of --remote
git submodule update checks out the exact SHA pinned in the parent commit. It is doing its job — that pinned SHA just is not the submodule’s newest commit. To advance to the latest upstream commit you need --remote.
How to spot it: git submodule status shows the SHA equal to git ls-tree HEAD vendor/ui-kit (so you are correctly on the pinned commit), yet you expected something newer.
6. Shallow clone cut off the submodule SHA
CI cloned the parent with --depth 1 and the submodule with --depth 1, but the submodule’s pinned SHA is older than that shallow horizon.
How to spot it: git -C vendor/ui-kit log --oneline | wc -l returns 1, and the required SHA is not in that single commit. This is the classic root of fatal: reference is not a tree in CI.
Shortest path to fix
Step 1: Sync .gitmodules into .git/config
# Run from the root of the parent repo
git submodule sync --recursive
This copies the current URLs from .gitmodules into .git/config for every submodule. Going forward, prefer changing URLs with git submodule set-url <path> <newurl> (it edits both files in one step) rather than hand-editing .gitmodules.
Step 2: Initialize and update all submodules
git submodule update --init --recursive
If a submodule SHA is missing from its remote you will see fatal: reference is not a tree: <SHA>. That is a missing-push problem, not a local one — ask whoever bumped the pointer to push the submodule’s commits first, then retry. (Confirm with git -C vendor/ui-kit fetch && git cat-file -t <SHA>.)
Step 3: Advance the submodule to its branch tip (only if you want the newest commit)
If you want the submodule to follow a branch instead of staying on the pinned SHA:
git submodule update --remote --merge vendor/ui-kit
--remote fetches the latest commit from the submodule’s configured branch (branch = in .gitmodules, defaulting to its remote HEAD), and --merge keeps the submodule off a detached HEAD. Then record the new pointer in the parent:
git add vendor/ui-kit
git commit -m "chore: bump ui-kit submodule to latest main"
Until you commit that gitlink, no teammate sees the bump — git submodule update --remote alone updates only your working tree.
Step 4: Recover a missing or broken submodule directory
# Cleanly deinit, purge the nested repo, then re-add
git submodule deinit -f vendor/ui-kit
rm -rf .git/modules/vendor/ui-kit
git submodule update --init vendor/ui-kit
If the path was never properly registered, re-add it: git submodule add <url> vendor/ui-kit, then git submodule update --init vendor/ui-kit.
Step 5: Fix shallow-clone failures in CI
# Unshallow the submodule so the pinned SHA is reachable
git -C vendor/ui-kit fetch --unshallow
git submodule update --init --recursive
Better, fix it at the checkout step. As of June 2026, actions/checkout@v4 (v5 is current) handles this with:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0 # full history; avoids "reference is not a tree" on old pins
Outside GitHub Actions, use git clone --recurse-submodules <url> without --depth, or pass a generous --depth such as --shallow-submodules --depth 50.
How to confirm it’s fixed
git submodule status --recursive
Every line should begin with a single space (no -, +, or U). Then spot-check one submodule:
git -C vendor/ui-kit rev-parse HEAD # should equal the pinned SHA below
git ls-tree HEAD vendor/ui-kit # the SHA the parent expects
If those two SHAs match and the status line is clean, the submodule is correctly checked out.
Prevention
- Clone with
git clone --recurse-submodules <url>so new clones initialize submodules automatically. - Set
git config --global submodule.recurse true(Git 2.14+) sogit pull,git checkout, andgit switchmove submodule pointers for you. - When bumping a submodule, push the submodule’s commits to its remote first, then commit the pointer change in the parent. Reversing this order is what produces
fatal: reference is not a treefor everyone else. - Pin a branch in
.gitmodules(branch = main) for any submodule you intend to track with--remote. - In GitHub Actions, set
submodules: recursiveonactions/checkout; addfetch-depth: 0if pins can be old. - Add a pre-push hook that blocks uncommitted pointer drift:
git submodule status | grep '^+'returning output means a bump was not committed. - Document the workflow in
CONTRIBUTING.md. Many submodule bugs come from developers runninggit pullwithout knowing they also needgit submodule update. - For third-party dependencies you only consume (never edit), consider a real package manager (npm, pip, Cargo) or
git subtreeinstead — submodules shine for first-party code you develop in parallel.
FAQ
Q: After git submodule update, the submodule is in detached HEAD. Is that a bug?
A: No, it is by design. git submodule update checks out a specific SHA, not a branch, so detached HEAD is expected. To make commits inside the submodule, run git switch -c my-branch there first, or use --merge / --rebase with --remote to stay on a branch.
Q: What is the difference between git submodule update and git submodule update --remote?
A: Plain update checks out the exact SHA the parent repo has pinned. --remote ignores the pinned SHA and instead fetches the latest commit from the submodule’s configured branch. If you “can’t get the latest,” you almost always wanted --remote.
Q: How do I remove a submodule entirely?
A: git submodule deinit -f vendor/ui-kit, then git rm -f vendor/ui-kit, then rm -rf .git/modules/vendor/ui-kit. Removing the [submodule "vendor/ui-kit"] block from .gitmodules happens automatically with git rm; commit the result.
Q: Can I use --depth to speed up submodule updates in CI?
A: Yes — git submodule update --init --recursive --depth 1. But if a pinned SHA is older than that cutoff the fetch fails with fatal: reference is not a tree. Use --depth 50 (or fetch-depth: 0 in Actions) as a safer minimum.
Q: We changed the submodule URL across the whole team. What is the fastest migration?
A: One person runs git submodule set-url vendor/ui-kit <newurl>, commits .gitmodules, and pushes. Everyone else runs, in order: git pull && git submodule sync --recursive && git submodule update --init --recursive.