Submodules, Subtrees, Nested Dependencies, and Multi-Repository Tradeoffs: Guided Hands-On Workflow and Core Operations
Operate nested local repositories safely: add/status/update/sync/deinit submodules, clone recursively, advance and commit a gitlink pin, handle detached HEAD, and exercise subtree add/pull/push with both history strategies.
Learning objectives
- Create a nested submodule topology using only disposable local repositories.
- Compare normal clone with recursive submodule initialization and update.
- Create a dependency commit from a named branch and commit the corresponding superproject gitlink update.
- Synchronize changed submodule URL configuration and deinitialize/reinitialize safely.
- Import, update, and export a tiny subtree and compare squashed with unsquashed ancestry.
1. Build four local repositories: tiny, dependency, parent, and vendor
This entire lesson is disposable. Local bare repositories act as remotes so the Git mechanics remain visible without accounts or credentials.
file:///local submodule transport in some contexts for
security. The lab enables file transport only per command with
-c protocol.file.allow=always or the temporary
GIT_ALLOW_PROTOCOL=file environment variable. Do not
set this globally merely to silence a security boundary.
Git Bash, Bash, or zsh
mkdir git-composition-lab
cd git-composition-lab
# Level 2 nested dependency
git init --bare tiny.git
git clone tiny.git tiny-work
cd tiny-work
git switch -c trunk
git config user.name "Dependency Learner"
git config user.email "dependency@example.invalid"
printf "tiny=v1\n" > tiny.conf
git add tiny.conf
git commit -m "Create tiny dependency"
git push -u origin trunk
cd ..
git --git-dir=tiny.git symbolic-ref HEAD refs/heads/trunk
# Main dependency repository
git init --bare dependency.git
git clone dependency.git dependency-work
cd dependency-work
git switch -c trunk
git config user.name "Dependency Learner"
git config user.email "dependency@example.invalid"
printf "library=v1\n" > library.conf
git add library.conf
git commit -m "Create library dependency"
git push -u origin trunk
git -c protocol.file.allow=always submodule add ../tiny.git nested/tiny
git add .gitmodules nested/tiny
git commit -m "Add nested tiny dependency"
git push
cd ..
git --git-dir=dependency.git symbolic-ref HEAD refs/heads/trunk
# Parent/superproject repository
git init --bare parent.git
git clone parent.git parent-work
cd parent-work
git switch -c trunk
git config user.name "Parent Learner"
git config user.email "parent@example.invalid"
printf "# Parent Service\n" > README.md
git add README.md
git commit -m "Create parent baseline"
git push -u origin trunk
cd ..
git --git-dir=parent.git symbolic-ref HEAD refs/heads/trunk
2. Add the dependency as a submodule and inspect the data model
The core porcelain operation is git submodule add.
Because this lab deliberately uses local file-transport
repositories, the command-scoped
-c protocol.file.allow=always override is added only
for this disposable exercise; do not enable that policy globally
merely to suppress a transport safety boundary.
cd parent-work
git -c protocol.file.allow=always submodule add ../dependency.git components/library
git status --short
git diff --cached -- .gitmodules components/library
git ls-files --stage components/library
cat .gitmodules
git submodule status --recursive
Expected: .gitmodules is staged as a
normal file. components/library appears as mode
160000 with the dependency commit OID. The nested
nested/tiny repository may not yet be populated until
recursive update is requested.
git add .gitmodules components/library
git commit -m "Pin library as submodule"
git push
3. Initialize nested submodules recursively
GIT_ALLOW_PROTOCOL=file git submodule update --init --recursive
git submodule status --recursive
git -C components/library rev-parse HEAD
git -C components/library/nested/tiny rev-parse HEAD
The outer gitlink pins the library commit. That library commit
contains its own gitlink to tiny. Recursive update
walks this dependency chain and populates each recorded commit.
4. Compare a normal fresh clone with a recursive fresh clone
cd ..
git clone parent.git fresh-plain
cd fresh-plain
git submodule status --recursive
git ls-files --stage components/library
ls components/library
cd ..
The superproject gitlink exists in the fresh clone, but the nested
repository is uninitialized; submodule status typically
prefixes the commit with -. The directory is empty or
otherwise not a populated dependency checkout.
GIT_ALLOW_PROTOCOL=file git clone --recurse-submodules parent.git fresh-recursive
cd fresh-recursive
git submodule status --recursive
git -C components/library status --short --branch
git -C components/library/nested/tiny status --short --branch
cd ..
The recursive clone performs the equivalent of recursive initialize/update after cloning the superproject.
5. Prove detached HEAD in a fresh recursive clone, then create a safe dependency-development branch
The earlier submodule add working copy may still have
its cloned branch checked out if no later update needed to move it.
A fresh recursive clone gives us a deterministic consumer checkout
of the recorded gitlink commit:
cd fresh-recursive
git -C components/library branch --show-current
git -C components/library symbolic-ref -q HEAD || echo "detached HEAD"
git -C components/library rev-parse HEAD
git rev-parse HEAD:components/library
cd ../parent-work
In the fresh clone, the two OIDs should match and
branch --show-current should be empty because default
checkout places the submodule at the exact recorded commit. That
detached state is appropriate for a consumer. To make a real
dependency commit in the original development copy, create a named
branch explicitly from its remote branch:
git -C components/library switch -c dependency-update origin/trunk
git -C components/library config user.name "Dependency Learner"
git -C components/library config user.email "dependency-update@example.invalid"
printf "library=v2\n" > components/library/library.conf
git -C components/library add library.conf
git -C components/library commit -m "Advance library dependency"
DEPENDENCY_V2=$(git -C components/library rev-parse HEAD)
git -C components/library push origin HEAD:trunk
The dependency repository now has a new commit. The parent repository has not recorded that new pin yet.
6. Observe and commit the gitlink update separately
git status --short
git diff --submodule=log -- components/library
git ls-files --stage components/library
git -C components/library rev-parse HEAD
git add components/library
git diff --cached --submodule=log -- components/library
git commit -m "Update library dependency pin"
git push
git rev-parse HEAD:components/library
test "$(git rev-parse HEAD:components/library)" = "$DEPENDENCY_V2"
This two-repository sequence is fundamental: first create/publish the dependency commit, then commit the new gitlink in the superproject. Forgetting the second commit is one of the most common submodule pointer-drift failures.
7. Synchronize a changed URL without changing the gitlink
In production, a dependency repository might move from one host/path
to another while the pinned commit remains the same.
.gitmodules is versioned, but an initialized clone also
has local submodule.<name>.url configuration.
git config -f .gitmodules --get submodule.components/library.url
git config --local --get submodule.components/library.url
# Teaching-only: make the tracked URL spelling explicit and recommit if changed.
git config -f .gitmodules submodule.components/library.url ../dependency.git
git submodule sync --recursive
git config --local --get submodule.components/library.url
sync copies the current URL from
.gitmodules into already initialized local submodule
configuration. It does not advance the dependency commit.
8. Deinitialize and reinitialize without deleting the gitlink from history
git submodule status
git submodule deinit components/library
git submodule status
ls components/library
git -c protocol.file.allow=always submodule update --init components/library
git submodule status
deinit removes the submodule's local registration and
working tree. The superproject still tracks
.gitmodules and the 160000 gitlink. This
is different from removing a submodule from project history.
9. Confirm whether git subtree is available
git subtree 2>&1 | head
git subtree is maintained in Git's
contrib/subtree area and is installed by many
mainstream distributions, but packaging can vary. If the command is
unavailable, keep the conceptual sections and skip the executable
subtree portion rather than downloading an unverified script.
10. Create a tiny independent vendor source
cd ..
git init --bare vendor.git
git clone vendor.git vendor-work
cd vendor-work
git switch -c trunk
git config user.name "Vendor Learner"
git config user.email "vendor@example.invalid"
printf "vendor=v1\n" > vendor.conf
git add vendor.conf
git commit -m "Create vendor source"
git push -u origin trunk
VENDOR_V1=$(git rev-parse HEAD)
cd ..
git --git-dir=vendor.git symbolic-ref HEAD refs/heads/trunk
11. Import an unsquashed subtree, then pull an upstream change
git init -b trunk subtree-unsquashed
cd subtree-unsquashed
git config user.name "Subtree Learner"
git config user.email "subtree@example.invalid"
printf "# Parent with vendored source\n" > README.md
git add README.md
git commit -m "Create subtree parent"
git subtree add --prefix=vendor/library ../vendor.git trunk
git log --graph --decorate --oneline --all --max-count=12
git merge-base --is-ancestor "$VENDOR_V1" HEAD
cd ../vendor-work
printf "vendor=v2\n" > vendor.conf
git add vendor.conf
git commit -m "Advance vendor source"
VENDOR_V2=$(git rev-parse HEAD)
git push
cd ../subtree-unsquashed
git subtree pull --prefix=vendor/library ../vendor.git trunk
git merge-base --is-ancestor "$VENDOR_V2" HEAD
cat vendor/library/vendor.conf
Without --squash, the imported vendor commits
participate in the combined repository history, so ancestry checks
for those source commits succeed.
12. Export subtree history to a separate repository
cd ..
git init --bare vendor-export.git
cd subtree-unsquashed
git subtree push --prefix=vendor/library ../vendor-export.git trunk
git --git-dir=../vendor-export.git rev-parse refs/heads/trunk
git --git-dir=../vendor-export.git show refs/heads/trunk:vendor.conf
subtree push splits the selected prefix into a history
suitable for another repository and pushes the requested ref. In
production, treat this as an explicit publishing operation with
normal review/authentication controls.
13. Compare a squashed import
cd ..
git init -b trunk subtree-squashed
cd subtree-squashed
git config user.name "Subtree Learner"
git config user.email "subtree@example.invalid"
printf "# Squashed subtree parent\n" > README.md
git add README.md
git commit -m "Create squashed subtree parent"
git subtree add --prefix=vendor/library --squash ../vendor.git trunk
git log --graph --decorate --oneline --all --max-count=12
git merge-base --is-ancestor "$VENDOR_V2" HEAD
echo $?
The final ancestry check should be non-zero: the source commit itself is not joined into the parent ancestry in the same way as the unsquashed workflow. The vendored tree content is present, but the parent graph records a squashed integration representation.
14. Challenge — choose the command from the data model
- A fresh clone has the gitlink but an empty dependency directory. Which command materializes the recorded commit?
- The nested repository has a new commit, but the parent still pins the old one. Which repository needs the next commit?
-
A project's
.gitmodulesURL changed after you initialized. Which command updates local submodule URL configuration? - You need source files to appear in an ordinary clone with no nested initialization step. Submodule or subtree?
- You need a dependency commit-by-commit history visible in the parent subtree graph. Squash or unsquashed import?
15. Cleanup
cd ../..
pwd
rm -rf git-composition-lab
PowerShell
Set-Location ../..
Get-Location
Remove-Item -Recurse -Force git-composition-lab
16. Knowledge check
Question 1. Why did the plain clone not populate
components/library?
Question 2. Why was the dependency development branch created explicitly?
Question 3. What does the superproject commit after dependency v2 actually record?
160000 gitlink object ID for the submodule
path, pointing to the dependency v2 commit.
Question 4. What does
git submodule sync change?
.gitmodules; it does
not move the gitlink pin.
Question 5. Why can subtree files be built after an ordinary clone?
17. Summary
You built nested submodules locally, inspected the gitlink, compared normal and recursive clones, created a dependency commit from a safe branch, committed the parent pointer update, synchronized URL configuration, deinitialized/reinitialized, and exercised subtree add/pull/push with both history styles.
Authoritative references
git-submodule
git-clone
git-diff
git-subtree source documentation
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.