Submodules are simple at the object-model level and surprisingly sharp at the workflow level. The parent repository records one commit ID; the nested repository owns the history that produced it. Most failures happen when someone updates one side and forgets to publish or commit the other.

What the superproject actually stores

submodule-object-model.txttext
superproject commit
├── .gitmodules
│   └── path + clone URL (+ optional branch hint)
└── vendor/codec  [gitlink mode 160000]
    └── exact submodule commit: abc123...
 
submodule repository
└── commits, branches, tags, remotes and working tree

The superproject stores metadata and a pointer—not a copy of the nested repository history.

What follows from this model

  • The gitlink has tree mode 160000 and names a commit object in another repository.

  • .gitmodules is versioned configuration that maps a submodule name/path to a URL; local initialization copies relevant settings into .git/config.

  • The branch containing that submodule commit is not recorded by the gitlink. A branch setting is only an update hint for --remote.

  • A normal clone can contain an empty submodule directory until initialization/update occurs.

When a submodule is the right trade-off

  • Use one when the component needs independent history, permissions, releases, and reuse while the parent must pin an exact revision.

  • Consider a package manager when the dependency is released as a package with a lockfile and standard registry lifecycle.

  • Consider a subtree/vendor import when consumers need one repository checkout and independent upstream contribution is secondary.

  • Avoid submodules merely to organize one codebase; the extra authentication, recursive tooling, branch state, and two-repository review are real operational costs.

Add a submodule at an explicit path

superprojectbash
git submodule add https://github.com/example/codec.git vendor/codec
git status --short
git diff --cached --submodule

Risk level: caution. Review the command before running it.

Review both staged changes

  • git submodule add REPOSITORY PATH clones/initializes the nested repository and stages .gitmodules plus the gitlink.

  • An explicit stable path makes layouts and later path-specific commands unambiguous.

  • git diff --cached --submodule shows the staged submodule pointer change rather than pretending it is an ordinary directory.

  • Before committing, verify repository ownership, URL protocol, license, source integrity, expected commit/tag, and whether every environment can authenticate.

superprojectbash
git ls-files --stage vendor/codec
git config -f .gitmodules --get-regexp '^submodule\..*\.\(path\|url\|branch\)$'
git -C vendor/codec rev-parse HEAD

Three views of the same relationship

  • git ls-files --stage should show mode 160000 and the staged commit ID for vendor/codec.

  • Reading .gitmodules through git config -f validates its Git-config syntax and exposes versioned URLs/paths.

  • git -C vendor/codec rev-parse HEAD prints the nested checkout commit; it should match the intended staged gitlink.

  • Do not put credentials or tokens in .gitmodules: it is committed, cloned, cached, and displayed by hosting services.

Commit the new relationship

superprojectbash
git add .gitmodules vendor/codec
git commit -m "Add codec as a pinned submodule"
git show --stat --submodule=short HEAD

Risk level: caution. Review the command before running it.

Why targeted staging is clearer

  • Staging the two intended paths avoids sweeping unrelated working-tree changes into the commit.

  • The parent commit records the submodule pointer and metadata, not the submodule repository’s file contents.

  • A Signed-off-by trailer is required only when the project’s contribution policy demands it; git commit -s is not a generic submodule step.

  • git show --submodule=short provides review evidence of the recorded commit ID.

Clone a superproject and populate all modules

Terminalbash
git clone --recurse-submodules https://github.com/example/superproject.git
cd superproject
git submodule status --recursive

What recursive clone guarantees

  • --recurse-submodules initializes and checks out registered modules while cloning the parent.

  • git submodule status --recursive includes nested modules and reports the commits currently checked out.

  • Authentication must succeed independently for every submodule URL; access to the parent does not grant access to private children.

  • Review .gitmodules before recursively initializing an untrusted repository because the operation fetches code from configured URLs.

Initialize an existing non-recursive clone

superprojectbash
git submodule sync --recursive
git submodule update --init --recursive --jobs 4
git submodule status --recursive

Why sync comes first

  • sync copies changed .gitmodules URLs into local submodule configuration, including nested modules.

  • update --init initializes missing modules, fetches the commit recorded by the parent, and checks it out.

  • --recursive applies the operation to nested submodules; --jobs 4 bounds parallel cloning/fetching. Tune it for network/server policy.

  • The default checkout commonly leaves submodule HEAD detached at the pinned commit. That is expected for consumption.

Understand detached HEAD before editing

A submodule checkout is usually detached because the parent asked for a commit, not a branch. You can build and inspect it safely, but a new commit made while detached is easy to abandon when the next update moves HEAD. Create/switch to an intentional branch before development.

superproject/vendor/codecbash
git status --short --branch
git switch main
git pull --ff-only
# edit, test, stage and commit inside this repository
git push origin main

Risk level: caution. Review the command before running it.

The submodule is a complete repository

  • git status --branch reveals detached HEAD and local modifications before switching.

  • Replace main with the project’s actual development branch; do not assume master or remote HEAD policy.

  • pull --ff-only refuses an implicit merge. Review fetched changes and run the component’s own tests.

  • Push the new submodule commit before publishing a superproject pointer to it, or teammates can receive “not our ref”/missing-commit failures.

Record an updated submodule pointer in the parent

superprojectbash
git -C vendor/codec status --short --branch
git diff --submodule=log
git add vendor/codec
git commit -m "Update codec submodule"

Risk level: caution. Review the command before running it.

A parent commit is a dependency upgrade

  • The first command verifies the child is clean and on the intended published commit.

  • git diff --submodule=log shows commits between the old and new gitlinks, giving reviewers meaningful upgrade context.

  • Staging vendor/codec updates only the gitlink in the superproject index.

  • Run integration tests at the parent level; a component can pass its own suite yet break the superproject.

Prevent a parent push from referencing unpublished work

superprojectbash
git push --recurse-submodules=check origin main

Risk level: caution. Review the command before running it.

The push guard catches the expensive mistake

  • --recurse-submodules=check aborts if referenced submodule commits cannot be found on a configured remote.

  • on-demand can push required submodule commits automatically, but explicit child-first review/push is easier to reason about in many teams.

  • The check depends on correct remote configuration and reachability; it does not replace access-control or CI clone testing.

  • Do not use git push --all as a routine substitute—it pushes all local branches in one repository and does not express the two-repository publication invariant.

Track a branch only when automation needs it

superprojectbash
git submodule set-branch --branch main -- vendor/codec
git submodule update --remote --merge -- vendor/codec
git diff --submodule=log

Risk level: caution. Review the command before running it.

--remote discovers; the parent still pins

  • set-branch records the branch hint in .gitmodules; commit that metadata change.

  • update --remote fetches the configured remote-tracking branch rather than stopping at the existing parent gitlink.

  • --merge tries to integrate the fetched commit into the current submodule branch and can conflict. Resolve/test inside the child.

  • Nothing is reproducible until the resulting child commit is published and the new gitlink is reviewed and committed in the parent. CI should normally consume the pinned pointer, not chase a moving branch.

Change a submodule URL safely

superprojectbash
git submodule set-url -- vendor/codec https://github.com/new-owner/codec.git
git submodule sync --recursive -- vendor/codec
git diff -- .gitmodules

Risk level: caution. Review the command before running it.

Public metadata and local configuration differ

  • set-url updates the versioned .gitmodules entry using Git’s supported command.

  • sync updates local configuration so subsequent fetches use the new URL. Other clones must sync or reinitialize after pulling the commit.

  • Test the new URL and permissions from a clean environment before merging.

  • Relative URLs can support forks/mirrors when repository layout is controlled, but their resolution rules must be tested across every hosting remote.

Remove a submodule from the repository

superprojectbash
git -C vendor/codec status --short
git rm vendor/codec
git diff --cached --submodule
git commit -m "Remove codec submodule"

Risk level: destructive. Review the command before running it.

Protect nested work before removal

  • The first command must be clean; otherwise commit/push, archive, or deliberately discard child work before proceeding.

  • Modern Git documents git rm PATH as the repository-level removal path; it stages removal of the gitlink and relevant .gitmodules entry. Review the staged diff.

  • Removal from the superproject does not delete the upstream repository or commits already published there.

  • Local administrative data can remain under .git/modules; do not recursively delete it until recovery needs and other worktrees are understood. git submodule deinit PATH is for unregistering a local checkout without necessarily removing it from history.

CI and supply-chain checklist

  • Use recursive checkout only after reviewing submodule URLs/protocols and pinning the superproject commit.

  • Give CI least-privilege credentials for every private child; avoid rewriting URLs with tokens that leak into logs/config/cache.

  • Cache repositories carefully: stale local submodule configuration can ignore changed .gitmodules URLs. Sync and verify status.

  • Run git submodule status --recursive and fail when expected modules are uninitialized, modified, or at the wrong commit.

  • Scan/license-test submodule content as part of the delivered product even though its history lives elsewhere.

  • Make release artifacts depend on immutable parent and child commits; a branch hint is not a dependency lock.

Troubleshooting

  • Directory is empty: run git submodule update --init --recursive and inspect authentication errors.

  • Leading `-`, `+`, or `U` in status: - means uninitialized, + means a different commit than recorded, and U indicates merge conflicts.

  • Changes vanished after update: inspect child reflogs for detached-HEAD commits, create a branch, and publish/recover before another cleanup.

  • “not our ref” / commit not found: the parent references a child commit missing from the reachable remote; publish it or update the parent to a reachable commit.

  • URL changed but fetch uses old location: run git submodule sync --recursive, then inspect .git/config and nested settings.

  • File transport not allowed: Git restricts risky protocols in submodule operations. Use a trusted hosted URL; do not weaken protocol policy globally to accommodate an unreviewed repository.

  • Parent status says modified: run git -C PATH status plus git diff --submodule to distinguish dirty child files from a moved child HEAD.

Primary references