|$ curl https://forge-ai.dev/api/markdown?path=docs/git/submodules
$cat docs/git-submodules.md
updated Recently·25 min read·published
Git Submodules
Introduction
Git submodules allow you to embed one Git repository inside another. The parent repo tracks a specific commit of the submodule, not a branch — ensuring reproducible builds across environments. Submodules are useful for shared libraries, vendor code, and monorepo-like structures without the overhead.
Adding & Cloning Submodules
submodules.sh
Bash
| 1 | # Add a submodule |
| 2 | git submodule add https://github.com/org/shared-lib.git libs/shared-lib |
| 3 | |
| 4 | # This creates: |
| 5 | # .gitmodules — submodule configuration |
| 6 | # libs/shared-lib/ — the submodule directory |
| 7 | |
| 8 | # Add with a specific branch |
| 9 | git submodule add -b main https://github.com/org/shared-lib.git libs/shared-lib |
| 10 | |
| 11 | # Commit the submodule reference |
| 12 | git add .gitmodules libs/shared-lib |
| 13 | git commit -m "Add shared-lib submodule" |
| 14 | |
| 15 | # Clone a repo with submodules |
| 16 | git clone https://github.com/org/main-project.git |
| 17 | cd main-project |
| 18 | git submodule init |
| 19 | git submodule update |
| 20 | |
| 21 | # One-liner: clone with submodules |
| 22 | git clone --recurse-submodules https://github.com/org/main-project.git |
| 23 | |
| 24 | # Update to latest commits |
| 25 | cd libs/shared-lib |
| 26 | git pull origin main |
| 27 | cd ../.. |
| 28 | git add libs/shared-lib |
| 29 | git commit -m "Update shared-lib to latest" |
ℹ
info
Always use --recurse-submodules when cloning. Without it, the submodule directories will be empty — one of the most common surprises for new Git users.
Managing Submodules
managing-submodules.sh
Bash
| 1 | # List all submodules |
| 2 | git submodule status |
| 3 | # abc1234 libs/shared-lib (heads/main) |
| 4 | |
| 5 | # Update all submodules to their tracked commits |
| 6 | git submodule update --init --recursive |
| 7 | |
| 8 | # Update to the latest commit on the tracked branch |
| 9 | git submodule update --remote --merge |
| 10 | |
| 11 | # Update a specific submodule |
| 12 | git submodule update --remote libs/shared-lib |
| 13 | |
| 14 | # See what changed in a submodule |
| 15 | cd libs/shared-lib |
| 16 | git log --oneline HEAD..origin/main |
| 17 | cd ../.. |
| 18 | |
| 19 | # Remove a submodule |
| 20 | git submodule deinit -f libs/shared-lib |
| 21 | git rm -f libs/shared-lib |
| 22 | rm -rf .git/modules/libs/shared-lib |
| 23 | git add .gitmodules |
.gitmodules
Bash
| 1 | # .gitmodules file (auto-generated) |
| 2 | [submodule "libs/shared-lib"] |
| 3 | path = libs/shared-lib |
| 4 | url = https://github.com/org/shared-lib.git |
| 5 | branch = main |
| 6 | |
| 7 | # CI/CD: always initialize submodules |
| 8 | # GitHub Actions: |
| 9 | # - uses: actions/checkout@v4 |
| 10 | # with: |
| 11 | # submodules: recursive |
| 12 | |
| 13 | # Docker: copy .gitmodules for multi-stage builds |
| 14 | # COPY .gitmodules . |
| 15 | # RUN git submodule update --init --recursive |
Submodules vs Subtrees
| Aspect | Submodules | Subtrees |
|---|---|---|
| Setup | Simple | Moderate |
| Tracking | Pinned to a commit | Full history merge |
| Cloning | Requires init/update | Automatic (inlined) |
| Contributing back | Separate workflow | Push to subtree remote |
| Repo size | Stays small | Grows with history |
subtree.sh
Bash
| 1 | # Add a subtree |
| 2 | git subtree add --prefix=libs/shared-lib https://github.com/org/shared-lib.git main --squash |
| 3 | |
| 4 | # Update subtree |
| 5 | git subtree pull --prefix=libs/shared-lib https://github.com/org/shared-lib.git main --squash |
| 6 | |
| 7 | # Push changes back to the subtree repo |
| 8 | git subtree push --prefix=libs/shared-lib https://github.com/org/shared-lib.git feature/improvement |
✓
best practice
Use submodules when you need precise version pinning and the submodule has its own development lifecycle. Use subtreeswhen you want simpler cloning and don't mind the larger repo size.
Advanced Patterns
Extra depth for production teams — conflict strategies, automation, and recovery.
Automation-friendly flags
automation.sh
Bash
| 1 | git status --porcelain=v1 |
| 2 | git diff --name-only --diff-filter=ACMR |
| 3 | git log -1 --pretty=format:%H |
| 4 | git merge-base HEAD origin/main |
| 5 | git rev-list --count origin/main..HEAD |
Recovery drill
recovery.sh
Bash
| 1 | git reflog | head -20 |
| 2 | git fsck --lost-found | head |
| 3 | git branch rescue HEAD@{1} |
| 4 | git log --oneline rescue -5 |
🔥
pro tip
Practice recovery in /tmp labs before you need it on a deadline.
Production Checklist
- No secrets in history for this change set
- CI green on the PR
- Rebased or merged with latest main
- Rollback plan: revert SHA known
- Tags/releases updated if needed
prod-check.sh
Bash
| 1 | git status -sb |
| 2 | git log --oneline origin/main..HEAD |
| 3 | git diff --check |
| 4 | git rev-parse HEAD |
Additional Examples
more-a.sh
Bash
| 1 | # Cherry-pick a range onto a release branch |
| 2 | git switch release/1.2 |
| 3 | git cherry-pick abc123^..def456 |
| 4 | # conflict? |
| 5 | git status |
| 6 | # fix, then: |
| 7 | git add -A && git cherry-pick --continue |
| 8 | # or abort: |
| 9 | # git cherry-pick --abort |
more-b.sh
Bash
| 1 | # Bisect with a script |
| 2 | git bisect start |
| 3 | git bisect bad HEAD |
| 4 | git bisect good v1.0.0 |
| 5 | git bisect run ./scripts/test-bug.sh |
| 6 | git bisect reset |
more-c.sh
Bash
| 1 | # Submodule bump |
| 2 | git submodule update --remote --merge libs/shared |
| 3 | git add libs/shared |
| 4 | git commit -m "chore(deps): bump shared submodule" |
| 5 | git submodule status |
more-d.sh
Bash
| 1 | # Workflow: trunk-based short PR |
| 2 | git fetch origin |
| 3 | git switch -c fix/timeout origin/main |
| 4 | # change + test |
| 5 | git commit -am "fix: request timeout" |
| 6 | git push -u origin HEAD |
| 7 | gh pr create --fill |
| 8 | gh pr checks |
| 9 | gh pr merge --squash --delete-branch |
Submodule Lifecycle Deep Dive
Submodules pin another repository at an exact commit. The parent stores the gitlink mode 160000 and the SHA — not the submodule files themselves.
Add & clone
sm-add.sh
Bash
| 1 | git submodule add git@github.com:org/lib.git libs/lib |
| 2 | git commit -m "chore: add lib submodule" |
| 3 | # Fresh clone: |
| 4 | git clone --recurse-submodules git@github.com:org/app.git |
| 5 | # Or after clone: |
| 6 | git submodule update --init --recursive |
Update & bump
sm-bump.sh
Bash
| 1 | cd libs/lib |
| 2 | git fetch |
| 3 | git switch main |
| 4 | git pull --ff-only |
| 5 | cd ../.. |
| 6 | git add libs/lib |
| 7 | git commit -m "chore(deps): bump lib to $(git -C libs/lib rev-parse --short HEAD)" |
| 8 | # Or: |
| 9 | git submodule update --remote --merge libs/lib |
| 10 | git add libs/lib && git commit -m "chore(deps): bump lib" |
Remove
sm-remove.sh
Bash
| 1 | git submodule deinit -f libs/lib |
| 2 | git rm -f libs/lib |
| 3 | rm -rf .git/modules/libs/lib |
| 4 | git commit -m "chore: remove lib submodule" |
Pitfalls
- Forgetting --recurse-submodules on clone leaves empty dirs
- Detached HEAD inside submodule is normal — checkout a branch before committing there
- Prefer packages (npm/pip) when you do not need exact git history coupling
⚠
warning
Submodules increase onboarding cost. Prefer them for true multi-repo version pins (firmware, docs themes), not for every shared library.
sm-lab.sh
Bash
| 1 | rm -rf /tmp/sm && mkdir -p /tmp/sm/{lib,app} |
| 2 | cd /tmp/sm/lib && git init -b main && echo lib > f && git add f && git commit -m lib |
| 3 | cd /tmp/sm/app && git init -b main && echo app > f && git add f && git commit -m app |
| 4 | git submodule add /tmp/sm/lib libs/lib |
| 5 | git commit -m "add submodule" |
| 6 | git submodule status |
Submodules vs Subtree vs Packages
- Submodule: pin external repo SHA; separate history
- Subtree: vendor history into monorepo (heavier)
- Package registry: usually best for libraries
$Blueprint — Engineering Documentation·Section ID: GIT-SM-01·Revision: 1.0
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.