Skip to content

Fix: Git submodule update failed / fatal: not a git repository

FixDevs · (Updated: )

Part of:  Docker, DevOps & Infrastructure

Quick Answer

Resolve Git submodule update and init failures including 'fatal: not a git repository', path conflicts, URL mismatches, shallow clone issues, and CI/CD checkout problems.

The Submodule State Disagreement

Personally, I think Git submodules are the single most failure-prone subsystem in modern Git workflows. The reason is that the state lives in four separate places (.gitmodules, .git/config, the parent’s tree, and .git/modules/), and almost every “submodule update failed” report is a disagreement between two of them. I learned long ago to read all four before touching any of them. You run git submodule update, git submodule init, or clone a repository that contains submodules and get one of these errors:

fatal: not a git repository: '../.git/modules/libs/some-library'
fatal: No url found for submodule path 'libs/some-library' in .gitmodules
Clone of 'git@github.com:org/some-library.git' into submodule path '/project/libs/some-library' failed
Failed to clone 'libs/some-library'. Retry scheduled

You may also see variations like:

fatal: repository 'https://github.com/org/some-library.git' not found
fatal: clone of 'https://github.com/org/some-library.git' into submodule path '/project/libs/some-library' failed

Or during a recursive update:

Submodule path 'libs/some-library': checked out 'abc1234def5678...'
fatal: git upload-pack: not our ref abc1234def5678
fatal: Fetched in submodule path 'libs/some-library', but it did not contain abc1234def5678. Direct fetching of that commit failed.

These errors all point to a broken, misconfigured, or inaccessible Git submodule. The root cause varies, but the fixes below cover every common scenario.

Quick Reference Before You Dive In

If you arrived here from Google with a fresh failure, the five facts that resolve roughly 90 percent of cases:

  1. Submodule state lives in FOUR places that can disagree. .gitmodules (committed config), .git/config (local override), the parent’s tree (pinned SHA), .git/modules/<name> (cached clone). Most failures are mismatches between two of them. The git submodule documentation and the gitsubmodules overview are the canonical references.
  2. After git pull that changed .gitmodules, ALWAYS run git submodule sync. The local .git/config keeps its old URL otherwise; update will keep failing on the stale entry.
  3. In CI, actions/checkout@v4 does NOT include submodules by default. Pass submodules: recursive and (for private repos) a PAT with the right scopes.
  4. Shallow clones (--depth 1) and submodules conflict. If the parent pins an old SHA, that SHA may not be in the shallow history. Either deepen the clone or use --shallow-submodules.
  5. The nuclear reset is git submodule deinit --all -f && rm -rf .git/modules/* && git submodule update --init --recursive. It works on every state-corruption case but wipes 10-20 minutes of re-cloning; try git submodule sync first.

The rest of this article walks through each cause in detail, plus the failure modes most other guides skip.

Why the Four Sources of Truth Disagree

Git submodules are repositories nested inside a parent repository. The parent repo tracks three things for each submodule:

  1. The URL of the submodule’s remote repository (stored in .gitmodules).
  2. The path where the submodule lives in the working tree (also in .gitmodules).
  3. The exact commit SHA the submodule should be checked out at (stored in the parent’s tree object).

When any of these three pieces breaks, the submodule update fails. Common causes include:

  • Submodules were never initialized after a fresh clone.
  • The URL in .gitmodules is wrong (SSH vs. HTTPS mismatch, renamed repo, moved org).
  • The submodule is in a detached HEAD state and the expected commit no longer exists on the remote.
  • Path conflicts where the submodule directory already contains files or another git repo.
  • The URL was changed in .gitmodules but the local .git/config still has the old URL.
  • Shallow clones that don’t have enough history to check out the pinned submodule commit.
  • CI/CD pipelines that don’t enable submodule checkout by default.

Each fix below targets a specific root cause. Start with Fix 1 if you just cloned the repo, or jump to the fix that matches your situation.

Diagnostic Timeline

The first instinct is to retype git submodule update --init --recursive and hope it sticks. That works perhaps a third of the time. The rest of the time you need to read three sources of truth before touching anything.

Minute 0: Read all three sources of truth at once. Run these in order without acting on the output yet:

cat .gitmodules
git config --local --get-regexp '^submodule\.'
git submodule status
ls .git/modules/ 2>/dev/null || echo "no cached modules"

.gitmodules is what the parent repo committed. .git/config is what your local clone uses. git submodule status shows the current SHA versus the pinned SHA. .git/modules/ is the cache. A submodule failure is almost always a disagreement between two of those four.

Minute 1: Classify the symptom. A leading - in git submodule status means the submodule was never initialized (Fix 1). A leading + means the working tree drifted off the pinned commit (Fix 3). A leading U means a merge conflict on the gitlink. No prefix but a clone failure means URL or auth (Fix 2 or Fix 8).

Minute 2: Diff the URLs explicitly. A surprising number of failures come from a URL that was edited in .gitmodules but never propagated:

diff <(git config --file .gitmodules --get-regexp '\.url$' | sort) \
     <(git config --local --get-regexp '^submodule\..*\.url$' | sort)

If those two lines differ, you need git submodule sync before any update will work (Fix 5).

Minute 3: Probe the cache. If .git/modules/<name> exists but the working tree is empty, Git will refuse to re-clone into the existing cache. This is the classic fatal: not a git repository: '../.git/modules/...' trigger. Skip ahead to Fix 4.

Minute 4: Check the pinned commit lives on the remote. Before you blame your network, prove the SHA exists upstream:

SUB_URL=$(git config --file .gitmodules submodule.libs/some-library.url)
SUB_SHA=$(git ls-tree HEAD libs/some-library | awk '{print $3}')
git ls-remote "$SUB_URL" | grep "$SUB_SHA" || echo "pinned SHA missing on remote"

If the SHA is missing, somebody force-pushed over it or deleted the branch. No amount of --init --recursive will save you; you need to repoint the parent at a SHA that still exists.

Minute 5: Only now reach for the nuclear option. If the timeline above is inconclusive, Fix 7 (deinit + cache wipe + re-add) resolves every remaining state-corruption case. But running it first wastes 20 minutes of re-cloning when a one-line git submodule sync would have fixed it.

When to Use Which Fix

The next eight sections cover the fixes in detail. The table below maps your symptom to the recommended fix.

Your symptomRecommended fixWhy
Just cloned, submodule dirs are emptyFix 1: git submodule update --init --recursiveSubmodules not initialized
Clone fails with auth errorsFix 2: switch SSH / HTTPS, configure key, use insteadOfWrong protocol or missing creds
+ prefix in git submodule statusFix 3: git submodule update --recursive to reset to pinned SHAWorking tree drifted off pin
Submodule directory has leftover filesFix 4: rm -rf the dir, retry updatePath conflict prevents clone
URL changed in .gitmodules but update still failsFix 5: git submodule sync --recursive first.git/config has stale URL
reference is not a tree errorsFix 6: git fetch --unshallow or use --shallow-submodulesShallow clone history too short
State is too broken to repair incrementallyFix 7: deinit + cache wipe + re-addLast resort, always works
Failing in GitHub Actions / GitLab CIFix 8: enable submodule checkout, configure CI authDefault CI clones skip submodules

If multiple rows apply, pick the topmost one.

Fix 1: Initialize Submodules

The most common cause is simply that submodules were never initialized after cloning. A regular git clone downloads the parent repo but leaves submodule directories empty.

Run:

git submodule update --init --recursive

This does three things:

  1. --init registers each submodule listed in .gitmodules into your local .git/config.
  2. update clones each submodule and checks out the commit pinned by the parent repo.
  3. --recursive handles nested submodules (submodules that themselves contain submodules).

If you are cloning the repository for the first time, you can skip this step entirely by cloning with submodules included:

git clone --recurse-submodules https://github.com/org/project.git

To make this the default behavior for all future clones, set a global config:

git config --global submodule.recurse true

With this setting, git clone, git pull, and git checkout will automatically handle submodules.

If git submodule update --init itself fails, the problem is deeper. Move on to the next fixes.

Fix 2: Fix the .gitmodules URL (HTTPS vs. SSH)

Open .gitmodules in the root of your repository:

cat .gitmodules

You will see something like:

[submodule "libs/some-library"]
    path = libs/some-library
    url = git@github.com:org/some-library.git

The url field is where most problems hide. Common issues:

SSH URL but no SSH key configured. If the URL starts with git@github.com:, Git will try to authenticate via SSH. If you don’t have an SSH key set up, it fails. Switch to HTTPS:

git config submodule.libs/some-library.url https://github.com/org/some-library.git

Or edit .gitmodules directly and change the URL:

[submodule "libs/some-library"]
    path = libs/some-library
    url = https://github.com/org/some-library.git

Then sync the change:

git submodule sync
git submodule update --init --recursive

HTTPS URL but you need SSH. The reverse problem. If your environment uses SSH keys (common in CI/CD), change the URL to the SSH form git@github.com:org/some-library.git.

The repository was renamed or moved. If the upstream repo changed its URL (org rename, repo transfer), update .gitmodules with the new URL and run git submodule sync.

For SSH authentication issues specifically, see the detailed guide at Fix: Git Permission Denied (publickey) which covers key generation, ssh-agent setup, and deploy keys.

A pattern I rely on in mixed dev / CI environments: Git’s insteadOf rewriting. Add it to ~/.gitconfig and SSH URLs in .gitmodules quietly become HTTPS at clone time:

git config --global url."https://github.com/".insteadOf "git@github.com:"

This means CI can use HTTPS tokens while devs continue to use SSH locally, with no changes to the committed .gitmodules. The same trick works for self-hosted GitLab and Bitbucket. I add it reflexively on every new machine.

Fix 3: Fix Detached HEAD in Submodules

Submodules are always checked out in a detached HEAD state by default. Git pins them to a specific commit, not a branch. This is by design, but it causes confusion and errors.

Check the submodule status:

git submodule status

Output looks like:

+abc1234 libs/some-library (v2.1.0)

The + prefix means the submodule is checked out at a different commit than what the parent expects. This often happens after someone runs commands inside the submodule directory without updating the parent reference.

To reset the submodule to the commit the parent expects:

git submodule update --recursive

If you want the submodule to track a branch instead of a fixed commit, configure it in .gitmodules:

[submodule "libs/some-library"]
    path = libs/some-library
    url = https://github.com/org/some-library.git
    branch = main

Then update with remote tracking:

git submodule update --remote --merge

This fetches the latest commit on the configured branch and merges it into the submodule. You can then commit the updated submodule reference in the parent repo.

If the pinned commit no longer exists on the remote (force-pushed away or the branch was deleted), you will see:

fatal: Fetched in submodule path 'libs/some-library', but it did not contain abc1234

In this case, you need to update the parent to point to a valid commit. Enter the submodule, check out a valid branch, then update the parent:

cd libs/some-library
git fetch origin
git checkout main
cd ../..
git add libs/some-library
git commit -m "Update some-library submodule to latest main"

For a deeper explanation of how detached HEAD works and how to recover from it, see Fix: Git Detached HEAD.

Fix 4: Fix Submodule Path Conflicts

If the submodule directory already exists and contains files (but isn’t a proper submodule checkout), Git will refuse to overwrite it.

Check if the directory exists and what’s in it:

ls -la libs/some-library

If it contains leftover files, remove the directory and try again:

rm -rf libs/some-library
git submodule update --init libs/some-library

Another path conflict happens when the submodule path in .gitmodules doesn’t match what Git expects. Verify the path:

git config --file .gitmodules --get-regexp path

If the path is wrong, edit .gitmodules to fix it and re-sync:

git submodule sync
git submodule update --init

A subtler conflict occurs when the .git/modules/<submodule> directory exists from a previous (failed) clone but the working tree directory was removed. Git sees the cached module data and gets confused. Clear it:

rm -rf .git/modules/libs/some-library
rm -rf libs/some-library
git submodule update --init libs/some-library

This forces a clean re-clone of the submodule.

If you are seeing fatal: not a git repository errors pointing to a .git/modules/ path, this cached module cleanup is almost always the fix. For more background on this family of errors, see Fix: fatal: not a git repository.

Fix 5: Re-register Submodules After a URL Change (git submodule sync)

When someone updates the URL in .gitmodules and pushes it, your local .git/config still has the old URL. The git submodule update command reads from .git/config, not .gitmodules, so it tries the stale URL and fails.

The fix is git submodule sync, which copies URLs from .gitmodules into .git/config:

git submodule sync --recursive
git submodule update --init --recursive

You can verify the URLs are correct:

git submodule foreach --recursive 'git remote get-url origin'

This prints the remote URL for each submodule. Compare them against the entries in .gitmodules.

If you changed the URL yourself and want to propagate it, the full sequence is:

# 1. Edit .gitmodules with the new URL
# 2. Sync local config
git submodule sync --recursive
# 3. Update submodules
git submodule update --init --recursive
# 4. Commit the .gitmodules change
git add .gitmodules
git commit -m "Update submodule URL"

A surprisingly common workflow bug: running git submodule update immediately after pulling a .gitmodules URL change, without git submodule sync in between. The update reads from .git/config, which still holds the old URL, so it fails on a stale entry that you cannot see by looking at .gitmodules. Sync first, update second; that ordering has spared me hours of “but the URL is correct in the committed file!”

Fix 6: Fix Shallow Clone Submodule Issues

Shallow clones (git clone --depth 1) save time and bandwidth, but they cause problems with submodules. A shallow clone only fetches the latest commits, so if the parent repo pins a submodule to an older commit, that commit might not exist in the shallow history.

The error typically looks like:

fatal: reference is not a tree: abc1234def5678
Unable to checkout 'abc1234def5678' in submodule path 'libs/some-library'

Fix the parent repo shallow clone:

git fetch --unshallow
git submodule update --init --recursive

Fix a shallow submodule clone:

If the submodule itself was cloned shallowly, deepen it:

cd libs/some-library
git fetch --unshallow
cd ../..
git submodule update --recursive

Prevent the issue in future clones. If you need shallow clones for speed but also need submodules, use the --shallow-submodules flag to get shallow clones of the submodules themselves:

git clone --recurse-submodules --shallow-submodules https://github.com/org/project.git

This clones both the parent and submodules shallowly. Each submodule gets the exact commit it needs.

For large repositories on CI servers, combine shallow cloning with specific fetch depth:

git clone --depth 50 --recurse-submodules https://github.com/org/project.git

A depth of 50 is usually enough to include the pinned submodule commits while keeping clone times reasonable.

Fix 7: Remove and Re-add Broken Submodules

When nothing else works, the nuclear option is to fully remove the submodule and re-add it from scratch. This is the most thorough fix because it wipes all local submodule state.

Step 1: Deinit the submodule:

git submodule deinit -f libs/some-library

This removes the submodule’s entry from .git/config and cleans its working tree.

Step 2: Remove the submodule from the Git index:

git rm -f libs/some-library

Step 3: Remove the cached module data:

rm -rf .git/modules/libs/some-library

Step 4: Commit the removal:

git commit -m "Remove broken submodule libs/some-library"

Step 5: Re-add the submodule:

git submodule add https://github.com/org/some-library.git libs/some-library
git commit -m "Re-add libs/some-library submodule"

Step 6: Verify it works:

git submodule update --init --recursive
git submodule status

The output should show a clean status with no + or - prefixes.

If you have multiple broken submodules, you can deinit all of them at once:

git submodule deinit --all -f
rm -rf .git/modules/*

Then re-initialize everything:

git submodule update --init --recursive

This is the equivalent of “turn it off and on again” for Git submodules. It resolves stale caches, URL mismatches, and corrupted module directories in one pass.

If you encounter push errors after re-adding submodules, the parent will reject the push until the gitlink and .gitmodules are both staged together; push them in the same commit.

Fix 8: Fix CI/CD Submodule Checkout

CI/CD pipelines are the most common place submodule failures occur because pipelines don’t check out submodules by default, and authentication is handled differently than on a developer’s machine.

GitHub Actions

By default, the actions/checkout action does not include submodules. Add the submodules parameter:

- uses: actions/checkout@v4
  with:
    submodules: recursive
    token: ${{ secrets.GITHUB_TOKEN }}

If your submodules are in private repositories, the default GITHUB_TOKEN may not have access. Use a Personal Access Token (PAT) or a GitHub App token instead:

- uses: actions/checkout@v4
  with:
    submodules: recursive
    token: ${{ secrets.PAT_TOKEN }}

The PAT needs repo scope for private submodule repos.

Alternatively, configure URL rewriting so Git uses the token for HTTPS authentication:

- name: Configure Git for submodules
  run: |
    git config --global url."https://x-access-token:${{ secrets.PAT_TOKEN }}@github.com/".insteadOf "git@github.com:"
    git config --global url."https://x-access-token:${{ secrets.PAT_TOKEN }}@github.com/".insteadOf "https://github.com/"
- uses: actions/checkout@v4
  with:
    submodules: recursive

GitLab CI

GitLab CI uses a different mechanism. Add the GIT_SUBMODULE_STRATEGY variable:

variables:
  GIT_SUBMODULE_STRATEGY: recursive
  GIT_SUBMODULE_DEPTH: 1

build:
  script:
    - echo "Submodules are checked out"

For private submodules on GitLab, you need to configure the CI job to use the CI job token. Add this to your .gitmodules:

[submodule "libs/some-library"]
    path = libs/some-library
    url = ../../org/some-library.git

Using relative URLs (../../org/some-library.git) instead of absolute URLs lets GitLab resolve them against the project’s own origin, which automatically applies the CI token.

If relative URLs aren’t possible, use a before_script block:

before_script:
  - git config --global url."https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/".insteadOf "https://gitlab.com/"
  - git submodule sync --recursive
  - git submodule update --init --recursive

Bitbucket Pipelines

clone:
  depth: full
  submodules: true

pipelines:
  default:
    - step:
        script:
          - echo "Submodules checked out"

Common CI/CD Pitfalls

  • Shallow clones: Most CI systems use shallow clones by default. If submodule commits aren’t included, increase the fetch depth or use fetch-depth: 0 in GitHub Actions.
  • SSH vs. HTTPS: CI environments usually prefer HTTPS with tokens over SSH. Make sure .gitmodules URLs match what the CI can authenticate against.
  • Caching: If your CI caches the .git directory, stale submodule data may persist between runs. Clear the cache or add git submodule sync before git submodule update.

For general CI/CD debugging, the Fix: GitHub Actions process completed with exit code 1 guide covers workflow failures in more detail.

Stranger Causes I Have Tracked Down

If none of the fixes above resolved your issue, try these less common solutions:

Check file permissions. On Linux and macOS, the .git/modules/ directory and its contents need to be readable by your user. Fix permissions:

chmod -R u+rwX .git/modules/

Check disk space. Submodule cloning fails silently when the disk is full. Verify with df -h.

Check your Git version. Submodule behavior has changed significantly across Git versions. Some flags like --recurse-submodules don’t exist in older versions. Check yours:

git --version

Git 2.13+ is recommended for reliable submodule support. Git 2.34+ adds improvements for partial clone submodule support.

Check for nested .git files. Each submodule has a .git file (not directory) in its root that points to .git/modules/<name>. If this file is missing or has wrong content, recreate it:

echo "gitdir: ../../.git/modules/libs/some-library" > libs/some-library/.git

Look at Git LFS interactions. If your submodules use Git LFS, the LFS smudge filter can fail independently of the submodule checkout. Run GIT_LFS_SKIP_SMUDGE=1 git submodule update --init to confirm whether LFS is the actual blocker before debugging further.

Watch for case-only path renames on macOS. Rename Libs/some-library to libs/some-library on a case-insensitive filesystem and Git records the rename but the directory never moves. git submodule status shows the new path, the filesystem still has the old one, and every update fails. Do a two-step rename through a temporary name (libs_tmp) and re-init.

Check for safe.directory rejection in containers. Mounting your repo into a container under a different UID triggers fatal: detected dubious ownership in repository at '/workspace/.git/modules/...'. Allow it explicitly:

git config --global --add safe.directory '*'

This is common in Docker-based CI where the runner user differs from the host user.

Inspect .git/modules/<name>/config for a wrong worktree path. When you move a working tree to a new absolute path, the stored worktree line in the cached module config still points at the old path and every update fails with fatal: not a git repository. Open the file, fix the worktree line to a relative path (../../../libs/some-library), and retry.

Check if the remote still exists. Verify that the submodule’s remote URL is reachable:

git ls-remote --exit-code $(git config --file .gitmodules submodule.libs/some-library.url)

If this fails, the remote repo has been deleted, made private, or moved. Update the URL in .gitmodules accordingly.

Try a completely fresh clone. When all else fails, clone the repository from scratch with submodules:

git clone --recurse-submodules https://github.com/org/project.git fresh-project

If the fresh clone works but your existing checkout doesn’t, the issue is corrupted local state. Copy your local changes over to the fresh clone and continue working there.

What Other Tutorials Get Wrong About Submodule Failures

Most Git tutorials list the same fixes but frame them in ways that produce subtle bugs.

They jump straight to git submodule deinit && rm -rf .git/modules. That works but discards 10-20 minutes of caching. Try git submodule sync first; it is a one-line fix for the most common URL-mismatch case.

They omit the .gitmodules vs .git/config distinction. git submodule update reads .git/config, not .gitmodules. Tutorials that show editing .gitmodules without git submodule sync leave readers chasing a fix that does not propagate.

They miss the shallow-clone trap. git clone --depth 1 is recommended widely for fast CI checkouts, but it conflicts with submodules pinned to older commits. The error reference is not a tree is a shallow-history symptom, not a corruption symptom; tutorials that treat it as the latter send readers deinitting cleanly cached repos.

They omit submodule.recurse=true as a global setting. Once set, git clone, git pull, and git checkout all handle submodules automatically. Most “I forgot to update submodules” reports vanish under this one config line. Tutorials that show manual git submodule update commands at every step train fragile habits.

They miss CI / private-repo auth. GitHub Actions’ default GITHUB_TOKEN cannot access submodules in OTHER private repos. Articles that show submodules: recursive without explaining the PAT requirement send readers chasing “auth fails” on green-looking workflows.

They treat git submodule status as decorative. The + / - / U prefix is the single most useful diagnostic in this entire problem space. Tutorials that focus on commands without explaining how to read status leave the actual diagnostic information on the floor.

Frequently Asked Questions

What is the difference between .gitmodules and .git/config?

.gitmodules is a committed file at the root of the parent repo; it is the canonical source of truth for which submodules exist, their paths, and their default URLs. .git/config is per-clone local configuration; it can override URLs (for local mirrors, auth tokens, alternate hosts) without changing what is committed. git submodule update reads .git/config. After any .gitmodules change, run git submodule sync to copy the new URL into .git/config.

When should I use git submodule sync?

After any git pull that includes a .gitmodules change. The pull updates the committed file, but .git/config keeps its old URL until you sync. If submodule updates start failing right after a pull, git submodule sync --recursive is usually the fix.

Why is my submodule always in detached HEAD?

By design. Submodules pin to a specific SHA, not a branch. Detached HEAD reflects that the working tree is at the committed-pinned SHA. To check out a branch inside the submodule, cd in and run git checkout main (or whatever branch). Commit work, return to the parent, and git add submodule_path to bump the parent’s pinned SHA.

Should I use git submodule update --remote?

Use it sparingly. --remote fetches the latest commit on the configured branch and overwrites the pinned SHA. This breaks reproducibility: the parent commit no longer specifies the exact submodule SHA, only “whatever was latest when someone ran the command.” For projects that want this behavior (vendoring docs, themes), it is fine. For libraries with reproducible-build requirements, stick to explicit SHA pins.

Why does actions/checkout@v4 not include submodules by default?

The default is to clone only the parent for speed. Add submodules: recursive to the action’s with block to enable submodule checkout. For private submodules in other repos, you also need a PAT or GitHub App token; the default GITHUB_TOKEN is scoped to the parent repo only.

Is there a way to make submodules work like normal directories?

Not really. If you want a vendored copy that lives in version control without the indirection layer, use git subtree instead of git submodule. Subtree merges the entire history of the dependency into the parent repo, which means no auth juggling, no --init step, and no detached HEAD. The trade-off is repo size and history complexity.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles