git subtree and git submodule both allow you to include external code in your Git repository, but they do it in fundamentally different ways, and understanding those differences is key to choosing the right one.

Let’s see git subtree in action. Imagine you have a main project, my-main-project, and you want to include a shared library, shared-utils.

First, add the shared-utils repository as a remote:

cd my-main-project
git remote add shared-utils ../shared-utils

Now, pull the shared-utils code into a subdirectory within my-main-project:

git subtree add --prefix=libs/shared-utils shared-utils main --squash

This command takes the entire history of the main branch from the shared-utils remote, "splats" it into the libs/shared-utils directory in your my-main-project repository, and creates a single commit representing this addition. The --squash option is common because it avoids pulling in the entire, potentially massive, history of the external project as separate commits in your main project.

Here’s what the commit looks like:

commit a1b2c3d4e5f678901234567890abcdef12345678
Author: Your Name <you@example.com>
Date:   Mon Oct 26 10:30:00 2023 -0700

    Add 'shared-utils' from remote 'shared-utils'

    (subtree split)
    commit abcdef1234567890abcdef1234567890abcdef12
    Author: Shared Utils Author <author@shared.com>
    Date:   Fri Oct 23 09:00:00 2023 -0700

        Initial commit for shared-utils

Notice how the commit message often includes a (subtree split) marker, and the commit itself represents the state of the external code at that point. The external code is now just files in your repository, like any other code.

Now, let’s contrast this with git submodule.

First, add the shared-utils repository as a submodule:

cd my-main-project
git submodule add ../shared-utils libs/shared-utils

This creates two things:

  1. A new file named .gitmodules in your repository’s root:

    [submodule "libs/shared-utils"]
        path = libs/shared-utils
        url = ../shared-utils
    
  2. A special commit in your my-main-project repository that records the exact commit hash of the shared-utils repository. It doesn’t copy the files themselves into your working directory.

Here’s what that commit looks like:

commit fedcba9876543210fedcba9876543210fedcba98
Author: Your Name <you@example.com>
Date:   Mon Oct 26 10:35:00 2023 -0700

    Submodule 'libs/shared-utils' (../shared-utils)

When you clone my-main-project with submodules, the libs/shared-utils directory will be empty. You need to initialize and update the submodules:

git clone my-main-project
cd my-main-project
git submodule init
git submodule update

Or, more commonly, clone and initialize/update in one go:

git clone --recurse-submodules ../my-main-project

The core difference is this: git subtree copies the external code into your repository, making it part of your project’s history. git submodule creates a link to another repository, pointing to a specific commit within that external repository. Your main project doesn’t contain the history of the submodule; it just knows which commit of the submodule to check out.

When you update the external code with git subtree, you typically pull changes from the remote into your subtree directory and then commit them. For example, to update libs/shared-utils with the latest from shared-utils’s main branch:

git subtree pull --prefix=libs/shared-utils shared-utils main --squash

This fetches the latest changes from the shared-utils remote, applies them to your libs/shared-utils directory, and creates a new commit in my-main-project representing the updated state.

For git submodule, updating involves fetching changes in the submodule’s repository and then committing the new submodule commit hash in your main project.

cd my-main-project/libs/shared-utils
git checkout main
git pull origin main
cd ../..
git add libs/shared-utils # This stages the new commit hash for the submodule
git commit -m "Update shared-utils submodule to latest"

The mental model for subtree is that the external code is now your code, just organized into a subdirectory. For submodule, the external code remains its own independent project, and your project simply pins a specific version of it.

The one thing most people don’t realize is that when you use git subtree, you can push changes back to the external repository. After making changes in my-main-project/libs/shared-utils, you can push them to the shared-utils remote. This is done using git subtree push.

git subtree push --prefix=libs/shared-utils shared-utils main

This command takes the commit history from your libs/shared-utils directory, splits it out, and pushes it to the main branch of the shared-utils remote. This is a powerful feature that makes subtree feel more like merging external code directly into your project.

The next conceptual hurdle you’ll likely encounter is managing complex dependency graphs and ensuring build consistency across multiple external code components.

Want structured learning?

Take the full Git course →