PBS 174 of X: Working with Submodules (Git)
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
- The instalment ZIP file — pbs174.zip
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:
pbscorp-brand
— the company’s unified stylepbscorp-app1
— the company’s first web app which links the folderbrand
topbscorps-brand
using a Git submodule.pbscorp-app2
— the company’s second web app which also links a folderbrand
topbscorps-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:
- Creates parent folders to simulate Git repositories on different computers:
remote-repos
to represent the company’s GIT serverpc-app1Dev
to represent the PC of a developer working on app 1pc-app2Dev
to represent the PC of a developer working on app 2pc-brandDesigner
to represent the PC of a designer tasked with maintaining the brand
- Expands the bundled repos
pbscorp-brand.bundle
,pbscorp-app1.bundle
&pbscorp-app2.bundle
into the headless repositoriespbscorp-brand.git
,pbscorp-app1.git
&pbscorp-app2.git
in theremote-repos
folder - 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
:
- 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');
- 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:
- Only production-ready tested code should be committed to the
main
branch - 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:
- Only production-ready tested code should be committed to the
main
branch or any branch named with thestable
prefix - Repositories for projects that have not yet reached major version 2.0 will remain as they are with just one production-ready branch,
main
- Repositories for projects that have reached major version 2.0 will:
- Have separate production-ready branches for each major version named
stable-vX
, whereX
is the major version number - 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
- Have separate production-ready branches for each major version named
- 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. - 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:
- The sysadmins prepare the repositories for the change by:
- Adding the needed new branches to the brand repo
- Adding staging branches to the repos for App 1 & App 2
- The brand designer pushes the new brand to the new
stable-v2
branch in thepbscorps-brand
repo - The developer for App 1 tests the updated brand in staging
- 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