Chapter 13Lesson 02~150 minutes

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.

Submodule labRecursive clonePointer updategit subtree

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.

Local file-transport note: modern Git restricts 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

  1. A fresh clone has the gitlink but an empty dependency directory. Which command materializes the recorded commit?
  2. The nested repository has a new commit, but the parent still pins the old one. Which repository needs the next commit?
  3. A project's .gitmodules URL changed after you initialized. Which command updates local submodule URL configuration?
  4. You need source files to appear in an ordinary clone with no nested initialization step. Submodule or subtree?
  5. You need a dependency commit-by-commit history visible in the parent subtree graph. Squash or unsquashed import?

15. Cleanup

Verify your current directory first. The command below recursively removes only this disposable lab.
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?

Question 4. What does git submodule sync change?

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.

Next

Turn repository composition into policy

Lesson 3 defines submodule URL/branch/update/recurse configuration, version-pinning rules, nested build trust boundaries, ownership, and when a package/artifact dependency is operationally cleaner than source nesting.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.