Logo
Logo

Programming by Stealth

A blog and podcast series by Bart Busschots & Allison Sheridan.

PBS 174 of X: Working with Submodules (Git)

07 Dec 2024

This is the third of three instalments exploring Git’s support for nested repositories through so-called Git submodules. In the first instalment we focused purely on the theory and the motivations, then, in the second we got practical, learning how to use the git module command to nest one repository within another. We used a simplified but realistic scenario of a small software company to explore some common actions developers are likely to need to perform with Git submodules. Those actions were all realistic, but they were all very basic, and only involved passively consuming content from remote repositories, and no branching.

In this instalment we’re going to take things to the next logical level — contribute code back to a remote repository from within submodules, and introduce branches both within nested submodules and within the outer containing repositories.

Matching Podcast Episode

You can also Download the MP3

Read an unedited, auto-generated transcript with chapter marks: PBS_2024_12_07

Instalment Resources

A (Reminder) Note on Default Git Security Settings

In the previous instalment, we explained that for security reasons, Git prevents the use of file path URLs in Git commands triggered by other Git commands by default. Because our examples simulate multiple computers using separate local folders, we need to use file path URLs, so, some of our example commands need to use the -c option to make one-time exceptions to this security default. Specifically, every Git command we use that triggers other Git commands must include the following option:

-c protocol.file.allow=always

If we forget to do this we’ll get errors like:

fatal: transport 'file' not allowed

Again, just a reminder that in the real world, you’re most likely to be working with genuinely remote repositories over SSH or HTTPS, so you’re unlikely to encounter this issue.

Reminder: Our Overall Scenario — a Small Web App Business

We’re going to continue to illustrate the use of Git submodules with examples from our imaginary fledgling web app business that builds web apps —PBS Corp. When we left off we had an imagined Git server with three repositories:

  1. pbscorp-brand — the company’s unified style
  2. pbscorp-app1 — the company’s first web app which links the folder brand to pbscorps-brand using a Git submodule.
  3. pbscorp-app2 — the company’s second web app which also links a folder brand to pbscorps-brand using a Git submodule.

To play along, start by downloading the instalment ZIP file, extracting it, opening a terminal in the extracted folder, and executing the initialisation script initPBS174Demo.sh with the commands:

chmod +x *.sh; ./initPBS174Demo.sh

This script does the following:

  1. Creates parent folders to simulate Git repositories on different computers:
    1. remote-repos to represent the company’s GIT server
    2. pc-app1Dev to represent the PC of a developer working on app 1
    3. pc-app2Dev to represent the PC of a developer working on app 2
    4. pc-brandDesigner to represent the PC of a designer tasked with maintaining the brand
  2. Expands the bundled repos pbscorp-brand.bundle , pbscorp-app1.bundle & pbscorp-app2.bundle into the headless repositories pbscorp-brand.git , pbscorp-app1.git & pbscorp-app2.git in the remote-repos folder
  3. Clones the appropriate repos to each PC folder and initialises the submodules as appropriate

Scenario 4 — Extending the Brand from App 2 (Pushing Submodule Changes)

Up to this point, we’ve only used submodules to bring content and changes into our repos, but since submodules are just Git repos, they allow the usual push-and-pull two-way interaction.

To prove this point, let’s play the role of the developer of App 2.

If you’d like to play along, start by changing into the App 2 repo in the folder representing the App 2 developer’s PC (cd pc-app2Dev/pbscorp-app2).

In this repo, you’ll find a fully checked-out and initialised copy of App 2 just as we left it at the end of the previous instalment. We can prove this to ourselves by opening index.html in our favourite browser.

Jumping into our scenario — we’ve received some feedback from users that the display of the seconds to Christmas does not stand out enough.

Our first instinct is to just make the countdown bigger, so we edit index.html to add the Bootstrap 5 CSS class display-3 to the paragraph that contains the number. The updated paragraph looks like this:

<p class="numeric display-3" id="output">?</p>

You can editindex.html manually, or, replace it with an updated version of the complete file named pbscorp-app2-index-1.html from the root of the instalment ZIP with the command:

cp ../../pbscorp-app2-index-1.html index.html

If we refresh our browser we’ll see that things look a little better, but they’re not quite good enough yet. We really need a better font, one more suited for numeric data!

Note that the paragraph already has the CSS class numeric. We could add some custom CSS within just App 2 to style that class with a nice font. However, the need to display numeric data is likely to be useful in other PBS Corp apps, so the best thing to do would be to expand the corporate brand by adding some nice styling for numeric data. Let’s do that using our nested copy of the brand in the brand folder.

The first step is to change into the brand submodule folder and verify that we’re fully up to date with the master copy of the brand repo.

Start by changing into the brand folder:

cd brand

Then, fetch the latest data from the remote repo, and show our status with the commands:

git fetch && git status

You’ll notice that the output is not quite what we would like:

HEAD detached at d223950
nothing to commit, working tree clean

Our working tree is clean, good, but we have a detached head, bad!

This is completely normal because when you run the git submodule update command in the outer repo it checks out the appropriate commit in each nested submodule. Note I said commit, not a branch, a specific commit. That’s important, because that’s literally the definition of a detached head. From a practical point of view a detatched head means you can’t commit any changes because a commit has to get attached to the head of a current branch (for more see instalment 105).

When you’re only planning on pulling changes into a submodule this behaviour is absolutely no problem whatsoever, but if you want to commit any changes, you need to be on a specific branch.

Don’t worry, this is an easy problem to resolve — simply check out main:

git checkout main

Now when we run git status again we can see we’re in exactly the state we want to be:

On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean

We’re now ready to update the brand to meet our needs. After a lot of time wasting on Google Fonts, we decide to use the Rationale font in red for rendering numbers. To do this we need to make two updates to style.css:

  1. We need to update the line that imports the brand’s web fonts to add Rationale to the list — the new line is:
    @import url('https://fonts.googleapis.com/css2?family=Chewy&family=Comfortaa:wght@300..700&family=Rationale&display=swap');
    
  2. We need to add some style attributes for the CSS class .numeric so our new font gets applied in an appropriate shade of red by adding a new CSS directive:
    /* Customise the display of numeric data */
    .numeric{
        font-family: "Rationale", sans-serif;
        font-weight: 400;
        font-style: normal;
        color: #990000;
    }
    

You can make the changes to style.css manually, or replace the entire file with the updated version named pbscorp-brand-style-1.css from the root of the instalment ZIP with the command:

cp ../../../pbscorp-brand-style-1.css style.css

If you refresh the app in your browser you’ll see it now looks a lot better!

We’re working in the brand submodule repo, so let’s see how things stand here with a quick git status:

On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   style.css

no changes added to commit (use "git add" and/or "git commit -a")

Just as we expected, one modified file waiting to be committed and pushed:

git commit -am 'Feat: add support for displaying numeric data'
git push

Let’s move back to the outer repo to see how things stand there:

cd ..

Again, run git status:

On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   brand (new commits)
        modified:   index.html

no changes added to commit (use "git add" and/or "git commit -a")

As expected, we have two changes waiting to be committed — our update to index.html to add the display-3 class, and the new current commit in the brand submodule.

We can now finish our day’s work with an other commit and push:

git commit -am 'Feat: improve the display of the countdown number to address customer feedback'
git push

Scenario 5 — A Controlled Rollout of a New Brand (Branching with Submodules)

So far, we’ve worked on a single branch in all repositories, but in this final scenario, it’s time to change that. We’re going to use branches to make a controlled transition to a whole new brand — version 2.0!

Laying Some Ground Work

PBS Corp is a young company, and like many young companies, they didn’t think everything through on day one. This major rebrand is their first, and it’s become clear to the lead developer that the Git repositories need some restructuring to keep things organised going forward.

While we haven’t explicitly referenced it in our scenarios, PBS Corps have had the following simple branching policy for their repositories:

  1. Only production-ready tested code should be committed to the main branch
  2. Any experimentation should be done on branches with names prefixed with wip-

Version 2 of the brand is the first time PBS Corps have updated any of their projects from one major version to another, i.e. from a 1.* to a 2.* version. This change illustrated the need to move from having a single production-ready (main) to supporting multiple simultaneous production branches. Why? Because the transition from the original brand to the new brand will be rolled out in phases, one app at a time. It will be necessary to continue to apply bug fixes to a production-ready branch for version 1.* while simultaneously fixing bugs and adding new features to a production-ready branch for version 2.*.

PBS Corp need a new branching policy that allows multiple production-ready branches to be maintained simultaneously, one for each major version. The decision is made to designate branches named stable-vX (where X is a major version number) as production-ready branches.

The question then became, what to do with main? Technically it could be deleted, but by convention, main is the default branch in Git repositories, so removing it is likely to cause confusion. The decision was made to keep main, but only use it as an alias for one of the stable branches. Which one? The one developers starting a new project should default to at that time. Effectively, main will point to the head of the primary production-ready branch at all times.

Another side-effect of this major rebrand is that a budget was made available to a dedicated staging environment where changes can be tested before being merged into the appropriate stable branch.

Putting it all together, the chief software engineer announces an updated branching policy:

  1. Only production-ready tested code should be committed to the main branch or any branch named with the stable prefix
  2. Repositories for projects that have not yet reached major version 2.0 will remain as they are with just one production-ready branch, main
  3. Repositories for projects that have reached major version 2.0 will:
    1. Have separate production-ready branches for each major version named stable-vX, where X is the major version number
    2. Retain their main branch, but keep it aligned with the head of the primary stable branch at all times, i.e. with the stable branch that should be used by developers starting new projects
  4. Repositories for apps that have been configured on the staging infrastructure will have a branch named staging which will be connected to the staging server via a CI/CD pipeline (Reminder: CI/CD is automation triggered by Git actions, and stands for Continuous Integration/Continuous Deployment). Every commit to this branch will be automatically published on the staging web server in a folder with the same name as the app’s repo.
  5. Any experimentation should be done on branches with names prefixed with wip-

With those foundations laid, PBS Corp is ready to plan its rebranding. The project manager has broken the project down into the following phases:

  1. The sysadmins prepare the repositories for the change by:
    1. Adding the needed new branches to the brand repo
    2. Adding staging branches to the repos for App 1 & App 2
  2. The brand designer pushes the new brand to the new stable-v2 branch in the pbscorps-brand repo
  3. The developer for App 1 tests the updated brand in staging
  4. The developer for App 1 pushes the updated brand to production

Scenario 5a — Sysadmin Prepares Repos

Everything the sysadmin needs to do has been covered many times before in this Git series, so rather than manually stepping through the process, I’ve captured all the work in a script. Like with the init script used at the start of this and the previous instalments, the script will echo the commands it executes as well as their outputs, so you can see exactly what the sysadmins had to do.

To run the script, change to the base folder for the Installment ZIP (cd ../../) and run the following command:

./initPBS174Demo-scenario5.sh

Before we continue, let’s take stock of all the branches that now exist:

Repo Branch Description
pbscorp-brand main The default branch, for now, kept aligned with the latest release on stable-v1 (the major version that should be used for new projects at present).
  stable-v1 The current release of the original brand. Since the focus is now shifting to the new brand, no new features will be added here going forward, just bug fixes.
  stable-v2 The branch where version 2 of the brand will be deployed. Currently still aligned with stable-v1 & main because the work on version 2 has not started yet (that’s scenario 5b).
pbscorp-app1 main The production version of the app.
  staging The branch connected to the staging server via a CI/CD pipeline — used for testing changes before merging them into main.
pbscorp-app2 main The production version of the app.
  staging The branch connected to the staging server via a CI/CD pipeline — used for testing changes before merging them into main.

Scenario 5b — Brand Designer Pushes Version 2 of the Brand

Before the designer can start their work on the new brand they need to check out the new stable-v2 branch.

So, let’s play that role now. Start by changing your terminal into the folder representing the brand repo on the designer’s PC (cd pc-brandDesigner/pbscorp-brand).

Before we can check out the new branch we need to update our local cache of the remote repo from the company’s Git server:

git fetch

The output shows the new branch being fetched:

From /Users/bart/Documents/Temp/From GitHub/programming-by-stealth/instalmentResources/pbs174/pc-brandDesigner/../remote-repos/pbscorp-brand
 * [new branch]      stable-v1  -> origin/stable-v1
 * [new branch]      stable-v2  -> origin/stable-v2

Let’s check out the branch we need to do our work on:

git checkout stable-v2

Running git status shows we are now ready to begin our work:

On branch stable-v2
Your branch is up to date with 'origin/stable-v2'.

nothing to commit, working tree clean

The updated style sheet can be found in the file pbscorp-brand-style-2.css in the root of the instalment zip, copy it into place using the command:

cp ../../pbscorp-brand-style-2.css style.css

We are now ready to commit and push our new brand!

git commit -am 'Feat: initial release of V2 of the PBS Corp brand' && git push

Scenario 5c — Test the New Brand on App 1

Let’s change hats now to the developer of App 2 by changing into the App 1 repo in the folder presenting that developer’s PC ( cd ../../pc-app1Dev/pbscorp-app1).

The first thing we need to do is to update our local cache of both the outside repo’s remote, and the inner repo’s remote. We can do that in one command by running:

git fetch --recurse-submodules

If you watch the output you’ll see both the new staging branch on the outter pbscorp-app1 repo, and the two new stable-v1 & stable-v2 branches on the inner pbscorp-brand repo get cached:

From /Users/bart/Documents/Temp/From GitHub/programming-by-stealth/instalmentResources/pbs174/pc-app1Dev/../remote-repos/pbscorp-app1
 * [new branch]      staging    -> origin/staging
Fetching submodule brand
From /Users/bart/Documents/Temp/From GitHub/programming-by-stealth/instalmentResources/pbs174/pc-app1Dev/../remote-repos/pbscorp-brand
   d223950..2c74c10  main       -> origin/main
 * [new branch]      stable-v1  -> origin/stable-v1
 * [new branch]      stable-v2  -> origin/stable-v2

Our task is to do a test migration to the new brand, so the first thing we want to do is change to the new staging branch on the outer repo with the command:

git checkout staging

A quick git status shows we’re ready to start our work:

On branch staging
Your branch is up to date with 'origin/staging'.

nothing to commit, working tree clean

To move the brand forward we need to change branch on the submodule, so let’s change to that folder:

cd brand

If we do a git status we’ll see we’re currently on a detached head in the inner pbscorp-brand repo:

HEAD detached at d223950
nothing to commit, working tree clean

Since we need to change branch anyway, that’s not a problem, we simply change to the stable-v2 branch with the command:

git checkout stable-v2

That’s all we need to do within the inner repo, so we can change back to the outter repo:

cd ..

Before we go any further, we need other make a small tweak to index.html. App 1 has not been updated since we added support for nicely formatting numeric data in scenario 4. To get the most out of either version of the brand we need to add the CSS class numeric to the tag containing the random numbers the app generates. This is just a simple one-line change to update the markup for the output to:

<p class="text-center h1"><code id="output" class="numeric"></code></p>

You’ll find a full version of the updated file in the root of the instalment ZIP as pbscorp-app1-index-1.html. Copy it over the current index.html with the command:

cp ../../pbscorp-app1-index-1.html index.html

Let’s see where we stand now with a quick git status:

On branch staging
Your branch is up to date with 'origin/staging'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   brand (new commits)
        modified:   index.html

no changes added to commit (use "git add" and/or "git commit -a")

We can see that our brand submodule has moved to a new commit, and that we have modified index.html, let’s commit and push those changes:

git commit -am 'FEAT: migrate to Brand V2' && git push

If this were the real world, the act of pushing to the staging branch would trigger the organisation’s CI/CD pipeline to publish the updated app to the test web server, and we would test the app there. We’re just pretending, so we’ll simply test by opening index.html in our favourite browser.

Hooray — the app works, our tests pass 🎉

Scenario 5d — Push the New Brand to Production for App 1

The final step now is to take our tested update and merge it into our production branch, i.e. main.

As we learned back in instalment 107, to merge a branch into main we need to start by checking out the main branch.

Let’s try doing that exacly like we learned before:

git checkout main

You might expect that if you refreshed the web app in your browser now you’d see the brand change, but you won’t. OK, that’s strange, what’s going on? Let’s have a look with git status:

On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   brand (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

Oh, we moved the outer repo to the main branch, but we left the inner submodule unchanged. That means it’s now seen as modified relative to what the main branch expects. This is factually correct. The submodule is not at the commit the main branch expects it to be at, but it’s not what we want.

If you paid very close attention to the output from the git checkout command you’d have seen an indication that something odd happened:

M       brand
Switched to branch 'main'
Your branch is up to date with 'origin/main'.

Notice that the first line starts with M which tells you brand is in a modified state from main’s POV!

OK, let’s go back to the staging branch and try that again, but this time in a submodule-aware way:

git checkout staging
git checkout --recurse-submodules main

This time the output is what we’d normally expect to see:

Switched to branch 'main'
Your branch is up to date with 'origin/main'.

Refreshing our web app in the browser now shows it reverting to the old brand, and git status shows that we are exactly where we should be on the main branch with nothing shown as modified:

On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean

OK, so now let’s merge our staging branch into this branch:

git merge staging

This appears to work, the output looks as expected:

Updating 44e216d..dcaf289
Fast-forward
 brand      | 2 +-
 index.html | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

But, if we refresh our browser, we’ll see that again, the brand has not changed. A git status shows us why, our submodule is not yet at the commit main now specifies:

On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   brand (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

If we look at the output from git submodule status we can also see that we are on the wrong commit at the moment because the ID is pre-fixed with a +:

+d2239509d6b621ea10a708899180661af9ea2428 brand (heads/main)

How do we get the submodule into sync? Simple, we tell tell Git to update it to the desired commit with the command:

git submodule update

The output shows the submodule moving to the new commit:

Submodule path 'brand': checked out 'c17aeca849c005581badb9a882df8ab37e2443d5'

Now when we refresh the browser we see our new brand here on the main branch 🎉

This is confirmed by git status:

On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

If we refresh our browser, the new branding has been applied to App 1. As we can see, we have a commit we’ve not yet pushed, so let’s finish our submodule adventure by pushing our last change:

git push

And with that, our exploration of submodules is complete!

Final Thoughts

Over the past three instalments we’ve explored both why it’s often useful to nest Git repositories, and, how to do it with Git submodules. While our exploration was not exhaustive, the senarios should give you the knowledge you need to get started with submodules.

In summary, the important submodule-specific commands are:

# initialise the submodules specified in the .gitmodules file in the local Git database
git submodule init

# ensure each submodule has the commit checked out specified for it on the current branch
git submodule update

# switch branches and synchronise all submodules as you do
git checkout --recurse-submodules BRANCH_NAME

# fetch remote changed for the outter modules and all submodules
git fetch --recurse-submodules

# pull remote changes into all submodules at once (outside-in)
git submodule update --remote

Join the Community

Find us in the PBS channel on the Podfeet Slack.

Podfeet Slack