Chapter 42: Git: Branching, Merging, and Handling Conflicts
Chapter Objectives
By the end of this chapter, you will be able to:
- Understand the fundamental concepts behind Git’s branching and merging model.
- Implement common branching workflows, such as feature branching, for systematic development.
- Perform various types of merges, including fast-forward and three-way merges.
- Diagnose and resolve merge conflicts effectively.
- Utilize rebasing to maintain a clean and linear project history.
- Apply these version control skills to manage a sample embedded project on the Raspberry Pi 5.
Introduction
Complexity is the norm in embedded Linux development. A single project can involve a custom kernel, bootloader modifications, device drivers, multiple user-space applications, and extensive configuration files. Managing changes across this intricate ecosystem without a robust system is not just difficult; it’s practically impossible. This is where a Distributed Version Control System (DVCS) like Git becomes one of the most critical tools in a developer’s arsenal. Unlike centralized systems of the past, Git gives every developer a full, independent copy of the repository, empowering them to work offline and experiment freely.
This chapter delves into the heart of Git’s power: its branching and merging capabilities. Branching allows you to diverge from the main line of development to work on a new feature, a bug fix, or an experimental idea in complete isolation. This ensures that the primary, stable codebase—often called main
or master
—remains pristine and deployable. Once your work is complete and tested, you can merge it back, integrating your changes into the main project. This workflow is the bedrock of modern collaborative software development, enabling teams to work in parallel without treading on each other’s toes. We will explore how to create and manage branches, the mechanics of merging, and the inevitable challenge of resolving conflicts. By the end, you will not only understand the theory but will have practiced these essential skills, preparing you to contribute confidently to any embedded Linux project.
Technical Background
To truly master Git’s branching and merging, we must first look beneath the surface at how Git thinks about its data. Unlike many other version control systems that store information as a list of file-based changes (deltas or diffs), Git’s core data model is based on snapshots. When you make a commit, Git essentially takes a picture of what all your files look like at that moment and stores a reference to that snapshot. For efficiency, if files have not changed, Git doesn’t store the file again, just a link to the previous identical file it has already stored. This snapshot-based architecture is the key to Git’s speed and its powerful branching model.
A branch in Git is not a cumbersome copy of the entire codebase. Instead, it is simply a lightweight, movable pointer to one of these snapshots, or commits. The default branch name in a new repository is typically main
. When you start making commits, this main
branch pointer moves forward automatically to point to the last commit you made. When you create a new branch, all Git does is create a new pointer to the same commit you are currently on. This operation is incredibly fast and cheap, encouraging developers to use branches frequently.
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#f8fafc', 'edgeLabelBackground':'#f8fafc', 'clusterBkg': '#f8fafc'}}}%% graph TD subgraph Git Repository History C1(fa:fa-code-commit Commit 1<br><i>Initial Commit</i>) C2(fa:fa-code-commit Commit 2<br><i>Add main.c</i>) C3(fa:fa-code-commit Commit 3<br><i>Refactor code</i>) C1 --> C2 --> C3 end subgraph Branch Pointers B1["<b>main</b><br>(Branch Pointer)"] B2["<b>feature-x</b><br>(New Branch Pointer)"] end B1 -- points to --> C3 B2 -- also points to --> C3 style C1 fill:#8b5cf6,stroke:#8b5cf6,stroke-width:2px,color:#ffffff style C2 fill:#8b5cf6,stroke:#8b5cf6,stroke-width:2px,color:#ffffff style C3 fill:#8b5cf6,stroke:#8b5cf6,stroke-width:2px,color:#ffffff style B1 fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff style B2 fill:#0d9488,stroke:#0d9488,stroke-width:2px,color:#ffffff
As you continue working on your new branch, making new commits, the new branch pointer moves forward, while the main
branch pointer remains where it was. You have now created a divergence in your development history. This isolation is crucial; it allows you to build and test a new feature without any risk to the stable code on the main
branch. You could be developing a new I2C driver on a feature/i2c-driver
branch while a colleague is fixing a critical bug on a hotfix/memory-leak
branch. Both lines of work proceed in parallel, completely independent of one another.
Branching Strategies
While you can create branches freely, professional development teams typically adopt a structured branching strategy or workflow to keep projects organized. One of the most well-known is Git Flow. It prescribes a complex system of branches, including a master
branch for official releases, a develop
branch for integrating features, and supporting branches for features, releases, and hotfixes. While comprehensive, its complexity can be overkill for many embedded projects.
A more streamlined and popular alternative is the GitHub Flow, which is simpler and often a better fit. In this model, the main
branch is always considered stable and deployable. To do any work, you create a descriptive feature branch directly from main
(e.g., add-pwm-support
). All work and commits happen on this branch. When the feature is complete, it is merged back into main
, and the feature branch is deleted. This keeps the history clean and ensures main
is always a source of truth. For embedded systems, where you might have a “bleeding-edge” development line and a stable production line, a simple strategy like GitHub Flow, perhaps with an additional long-lived production
branch for releases, works exceptionally well.
The Art of Merging
Once the work on your feature branch is complete, you need to integrate it back into your main line of development. This is done with the git merge
command. The way the merge is performed depends on the relationship between the two branches.
The simplest scenario is a fast-forward merge. This occurs when the main
branch has not had any new commits since you created your feature branch. In this case, Git sees that your feature branch’s history is a direct continuation of the main
branch’s history. To perform the merge, Git simply moves the main
branch pointer forward to point to the same commit as your feature branch. No new commits are created; it’s a clean and linear progression.
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#f8fafc', 'edgeLabelBackground':'#f8fafc', 'clusterBkg': '#f8fafc'}}}%% graph TD subgraph "Scenario 1: Fast-Forward Merge" direction LR A1(C1) --> A2(C2); A2 -- "main<br>(no new commits)" --- A3( ); A2 -- "feature-x<br>(new work)" --> A4(C3) --> A5(C4); A3[ ] -- "git merge feature-x" --> A5; subgraph Legend1 [ ] direction LR L1(main) L2(feature-x) L3(Result: main pointer moves to C4) style L1 fill:#1e3a8a,stroke:#1e3a8a,color:#ffffff style L2 fill:#0d9488,stroke:#0d9488,color:#ffffff style L3 fill:#10b981,stroke:#10b981,color:#ffffff end style A3 fill:#f8fafc, stroke:#f8fafc end style A1 fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff style A2 fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff style A4 fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff style A5 fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff
However, in a collaborative or long-running project, it’s very likely that the main
branch will have received new commits while you were working on your feature. The histories have truly diverged. In this situation, Git cannot simply move the pointer. Instead, it performs a three-way merge. Git looks at three commits to make its decision: the tip of the main
branch, the tip of your feature branch, and their common ancestor (the commit where the two branches diverged). Git then creates a new special commit, called a merge commit, that combines the changes from both branches. This merge commit is unique in that it has two parent commits. This preserves the historical context of both lines of development, showing explicitly where a feature was integrated.
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#f8fafc', 'edgeLabelBackground':'#f8fafc', 'clusterBkg': '#f8fafc'}}}%% graph TD subgraph "Scenario 2: Three-Way Merge" direction TB B1(C1) --> B2(Common Ancestor<br>C2); B2 --> B3("main<br>C3"); B2 --> B4("feature-x<br>C4"); B3 -- "git merge feature-x" --> B5("Merge Commit<br>C5"); B4 -- " " --> B5; subgraph Legend2 [ ] direction LR L4(main) L5(feature-x) L6(Result: New merge commit C5) style L4 fill:#1e3a8a,stroke:#1e3a8a,color:#ffffff style L5 fill:#0d9488,stroke:#0d9488,color:#ffffff style L6 fill:#f59e0b,stroke:#f59e0b,color:#ffffff end end style B1 fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff style B2 fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff style B3 fill:#1e3a8a,stroke:#1e3a8a,color:#ffffff style B4 fill:#0d9488,stroke:#0d9488,color:#ffffff style B5 fill:#f59e0b,stroke:#f59e0b,color:#ffffff
Handling Merge Conflicts
The three-way merge process is often automatic. Git is intelligent enough to combine changes as long as they don’t affect the same lines of code in the same file. But what happens when you and another developer both modify the exact same part of a file? This is a merge conflict, and Git cannot resolve it for you because it doesn’t know which change is correct.
When a merge conflict occurs, Git will pause the merge process and report the conflict. It will then modify the problematic file, inserting conflict markers to show you exactly where the clash occurred. These markers look like this:
<<<<<<< HEAD
This is the version of the code from your current branch (e.g., main).
=======
This is the version of the code from the branch you are trying to merge in.
>>>>>>> feature-x
Your job as the developer is to open the file, examine the conflicting sections, and edit the file to be what it should be. This might mean keeping your changes, keeping the other branch’s changes, or writing something entirely new that combines both. Once you have manually edited the file to remove the conflict markers and are satisfied with the result, you must tell Git that you have resolved the conflict. This is done by staging the modified file with git add
, and then completing the merge by running git commit
.
An Alternative: Rebasing
Another powerful tool for integrating changes is git rebase
. While git merge
combines two histories with a merge commit, git rebase
works differently. It essentially replays your work from one branch on top of another.
Imagine your feature branch diverged from main
three commits ago, and main
has since moved forward. If you run git rebase main
while on your feature branch, Git will:
- Find the common ancestor of your feature branch and
main
. - Temporarily save the changes you made on your feature branch as patches.
- Reset your feature branch to be identical to the current
main
branch. - Apply your saved patches, one by one, creating new commits on top of
main
.
The end result is that your feature branch now appears as if it had been created from the very latest version of main
. This creates a perfectly linear history, which many developers find easier to read and navigate. After rebasing, you can then merge your feature branch into main
, and it will be a clean fast-forward merge.
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#f8fafc', 'edgeLabelBackground':'#f8fafc', 'clusterBkg': '#f8fafc'}}}%% graph TD subgraph "Legend" direction LR L1(main branch) L2(feature branch) L3(Merge Commit) L4(Rebased Commit) style L1 fill:#1e3a8a,stroke:#1e3a8a,color:#ffffff style L2 fill:#0d9488,stroke:#0d9488,color:#ffffff style L3 fill:#f59e0b,stroke:#f59e0b,color:#ffffff style L4 fill:#0d9488,stroke:#0d9488,color:#ffffff end subgraph "Option 1: Merge" E(C1) --> F(C2) F --> G("main: C3") F --> H("feature: C4") G --> I("Merge Commit: C5") H --> I style G fill:#1e3a8a,stroke:#1e3a8a,color:#ffffff style H fill:#0d9488,stroke:#0d9488,color:#ffffff style I fill:#f59e0b,stroke:#f59e0b,color:#ffffff end subgraph "Option 2: Rebase" J(C1) --> K(C2) --> L("main: C3") L --> M("feature: C4'") M -- "Clean, linear history" --> N(( )) style L fill:#1e3a8a,stroke:#1e3a8a,color:#ffffff style M fill:#0d9488,stroke:#0d9488,color:#ffffff style N fill:#f8fafc, stroke:#f8fafc end subgraph "Before: Diverged History" A(C1) --> B(C2) B --> C("main: C3") B --> D("feature: C4") style C fill:#1e3a8a,stroke:#1e3a8a,color:#ffffff style D fill:#0d9488,stroke:#0d9488,color:#ffffff end style A fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff style B fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff style E fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff style F fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff style J fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff style K fill:#8b5cf6,stroke:#8b5cf6,color:#ffffff
Warning: Rebasing rewrites history. The new commits it creates have different IDs than the original ones. This is perfectly fine for your own local branches, but you should never rebase a branch that has been pushed and is being used by other developers. Doing so will create divergent histories for everyone else, leading to a very confusing and difficult-to-fix repository state. The golden rule is: rebase your own private work, but merge public/shared work.
Practical Examples
Let’s put this theory into practice using a hypothetical project on our Raspberry Pi 5. We’ll simulate the development of a simple C application that controls an LED.
Setup: Creating the Project
First, let’s create a project directory and initialize a Git repository. We’ll also create a simple C file and make our first commit.
# On your Raspberry Pi 5
mkdir led-control
cd led-control
git init
Now, create a file named led.c
with some initial content.
// led.c
#include <stdio.h>
int main() {
printf("Embedded LED Control Program\n");
printf("Status: System Ready\n");
return 0;
}
Add this file to the repository and commit it.
git add led.c
git commit -m "Initial commit: Add basic program structure"
Example 1: Feature Branch and Fast-Forward Merge
We need to add a function to turn the LED on. We’ll do this on a feature branch.
# Create and switch to a new branch called 'feature/led-on'
git checkout -b feature/led-on
# You can also use the newer 'git switch' command:
# git switch -c feature/led-on
Your active branch is now feature/led-on
. Let’s modify led.c
to add the new function.
// led.c
#include <stdio.h>
void turn_on_led() {
printf("LED -> ON\n");
}
int main() {
printf("Embedded LED Control Program\n");
printf("Status: System Ready\n");
turn_on_led(); // Call the new function
return 0;
}
Now, commit this change to the feature branch.
git add led.c
git commit -m "feat: Implement turn_on_led function"
Our feature is complete. Let’s merge it back into main
. First, we switch back to the main
branch.
git checkout main
Now, we execute the merge.
git merge feature/led-on
You will see output similar to this:
Updating a1b2c3d..e4f5g6h
Fast-forward
led.c | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
This was a fast-forward merge because main
hadn’t changed. The history is still linear. You can now safely delete the feature branch.
git branch -d feature/led-on
Example 2: Three-Way Merge
Let’s simulate a more complex scenario. We’ll create a branch to add a “turn off” function. But while we’re working on it, a critical “hotfix” needs to be applied to main
.
First, create the new feature branch from main
.
git checkout -b feature/led-off
Modify led.c
on this branch.
// led.c
#include <stdio.h>
void turn_on_led() {
printf("LED -> ON\n");
}
void turn_off_led() {
printf("LED -> OFF\n");
}
int main() {
printf("Embedded LED Control Program\n");
printf("Status: System Ready\n");
turn_on_led();
turn_off_led(); // Call the new function
return 0;
}
Commit the change on the feature/led-off
branch.
git add led.c
git commit -m "feat: Implement turn_off_led function"
Now, imagine an urgent bug is found. We need to switch back to main
and fix it immediately.
git checkout main
Let’s modify led.c
on main
to add a version number. This is our “hotfix”.
// led.c
#include <stdio.h>
// Version 1.1
void turn_on_led() {
printf("LED -> ON\n");
}
int main() {
printf("Embedded LED Control Program v1.1\n"); // Updated line
printf("Status: System Ready\n");
turn_on_led();
return 0;
}
Commit the hotfix on main
.
git add led.c
git commit -m "fix: Add version number and update title"
Now the histories have diverged. main
has a commit that feature/led-off
does not, and vice-versa. Let’s merge the feature branch into main
.
git merge feature/led-off
Because Git can’t do a fast-forward, it will perform a three-way merge. It will likely open your default text editor to ask for a merge commit message. The default message is usually sufficient. Just save and close the editor.
The result is a new merge commit on main
that combines both lines of work. If you inspect led.c
, you’ll see it contains both the version update and the turn_off_led
function.
Example 3: Creating and Resolving a Merge Conflict
Now for the classic problem. Let’s create two branches from main
and modify the same line on both.
# From main, create branch A
git checkout -b feature/status-a
# Modify the status line in led.c
# Change "Status: System Ready" to "Status: System A Ready"
# Then commit
git add led.c
git commit -m "feat: Update status for system A"
# Go back to main and create branch B
git checkout main
git checkout -b feature/status-b
# Modify the SAME status line in led.c
# Change "Status: System Ready" to "Status: System B Online"
# Then commit
git add led.c
git commit -m "feat: Update status for system B"
We now have two branches with conflicting changes. Let’s try to merge feature/status-a
into main
. This will be a fast-forward.
git checkout main
git merge feature/status-a
So far, so good. But now, let’s try to merge feature/status-b
into main
.
git merge feature/status-b
Git will fail with a message:
Auto-merging led.c
CONFLICT (content): Merge conflict in led.c
Automatic merge failed; fix conflicts and then commit the result.
If you open led.c
, you will see the conflict markers:
// led.c
#include <stdio.h>
// ... other code ...
int main() {
printf("Embedded LED Control Program v1.1\n");
<<<<<<< HEAD
printf("Status: System A Ready\n");
=======
printf("Status: System B Online\n");
>>>>>>> feature/status-b
turn_on_led();
return 0;
}
```HEAD` refers to your current branch (`main`), which has the "System A" change. The incoming branch `feature/status-b` has the "System B" change. To resolve this, we must edit the file. Let's say the correct status should be a combination of both. We edit the file to look like this, removing the markers:
```c
// led.c
#include <stdio.h>
// ... other code ...
int main() {
printf("Embedded LED Control Program v1.1\n");
printf("Status: System A Ready, System B Online\n"); // Manually resolved
turn_on_led();
return 0;
}
Now, we tell Git the conflict is resolved by staging the file and committing.
git add led.c
git commit
# A pre-populated commit message will appear. Just save and close.
The conflict is now resolved, and the merge is complete.
Common Mistakes & Troubleshooting
Even with a solid understanding, pitfalls are common. Here are some frequent mistakes and how to handle them.
Exercises
- Basic Branch and Merge:
- Objective: Practice the fundamental feature branch workflow.
- Steps:
- In your
led-control
project, ensure you are on themain
branch. - Create a new branch named
feature/add-delay
. - On this new branch, add a
sleep(1);
call from<unistd.h>
between turning the LED on and off inled.c
. - Commit this change with a descriptive message.
- Switch back to the
main
branch. - Merge the
feature/add-delay
branch.
- In your
- Verification: Confirm the merge was a fast-forward and that
led.c
onmain
now contains thesleep(1);
call.
- Simple Conflict Resolution:
- Objective: Learn to resolve a straightforward merge conflict.
- Steps:
- From
main
, create a branch calledrefactor/on-message
. - On this branch, change the
printf
message inturn_on_led()
to “GPIO Pin 17: HIGH”. Commit the change. - Switch back to
main
. Create another branch calledrefactor/on-message-verbose
. - On this second branch, change the same
printf
message to “Setting LED output to ON state”. Commit the change. - Merge
refactor/on-message
intomain
. - Now, attempt to merge
refactor/on-message-verbose
intomain
. - Resolve the resulting conflict by choosing one message or creating a new one.
- Complete the merge.
- From
- Verification: The
main
branch should have a new merge commit, andled.c
should contain your resolved message.
- Rebasing for a Clean History:
- Objective: Use
git rebase
to create a linear history. - Steps:
- Switch back to the
main
branch and create a new branchfeature/blinking
. - On this branch, make two separate commits: first, add a
for
loop around the on/off functions to make the LED blink; second, add a comment explaining the blink functionality. - While that was happening, switch back to
main
and add a new comment at the top of the file, like a copyright notice, and commit it. - Now switch back to the
feature/blinking
branch. - Instead of merging, rebase your work on top of
main
usinggit rebase main
. - Switch to
main
and mergefeature/blinking
.
- Switch back to the
- Verification: The merge into
main
should be a fast-forward. Usegit log --oneline --graph
to see the clean, linear history.
- Objective: Use
- Using
git stash
for a Hotfix:- Objective: Practice using
git stash
to manage work-in-progress during an interruption. - Steps:
- Create a new branch
feature/brightness-control
. - Start modifying
led.c
to add a placeholder functionset_brightness(int level)
, but do not commit it. Your work is in progress. - Suddenly, you need to fix a typo on
main
. Usegit stash
to save your uncommitted changes. - Switch to
main
, fix a typo somewhere in a comment, and commit the fix. - Switch back to
feature/brightness-control
. - Use
git stash pop
to re-apply your saved changes and continue your work.
- Create a new branch
- Verification: Your uncommitted changes to
led.c
should reappear, and you can continue where you left off.
- Objective: Practice using
- Collaborative Conflict Simulation:
- Objective: Simulate a conflict scenario that occurs in a team environment.
- Steps:
- Create a second clone of your local repository in a different directory:
git clone ./led-control ../led-control-dev2
. - In the original
led-control
repo, create a branchdev1-work
and add a functionvoid cleanup_gpio()
at the end ofled.c
. Commit the change. - In the
led-control-dev2
repo, create a branchdev2-work
and add a different functionvoid shutdown_system()
at the end ofled.c
. Commit the change. - In the original repo, merge
dev1-work
intomain
. - Now, simulate the second developer pushing their work. In the
led-control-dev2
repo, you can’t push to the other local repo directly without setting up a remote, so we’ll simulate it by creating a patch:git format-patch main
. This creates a.patch
file. - Copy that patch file over to the original
led-control
directory. - In the original repo, on
main
, try to apply the patch:git am < patch-file-name.patch
. - This should cause a conflict. Resolve it by ensuring both new functions are present in the final file. Use
git am --continue
to finish.
- Create a second clone of your local repository in a different directory:
- Verification: The
main
branch in the original repository should now contain both thecleanup_gpio
andshutdown_system
functions.
Summary
- Git Branches are Pointers: A branch is a lightweight, movable pointer to a commit, making them fast and easy to use.
- Branching Isolates Work: Creating feature branches allows you to develop new code, fix bugs, or experiment without destabilizing the main codebase.
- Merging Integrates Work:
git merge
is used to combine the history of two branches. This can be a simple fast-forward or a three-way merge that creates a new merge commit. - Conflicts are Normal: Merge conflicts happen when two branches have competing changes to the same part of a file. They are resolved by manually editing the file, staging the change with
git add
, and committing. - Rebasing Creates Linear History:
git rebase
re-applies commits from one branch on top of another, which is useful for cleaning up local history before merging. However, it rewrites history and must not be used on shared branches. - Workflows Provide Structure: Adopting a branching strategy like GitHub Flow brings order and predictability to collaborative projects.
By mastering these workflows, you have gained a foundational skill for any modern software development, especially in the complex and collaborative field of embedded Linux.
Further Reading
- Pro Git Book: Scott Chacon and Ben Straub. The definitive, free online book on Git. Chapters 3 (“Git Branching”) and 6 (“Git Tools”) are particularly relevant. https://git-scm.com/book/en/v2
- Git Documentation: The official documentation for the
git merge
,git rebase
, andgit branch
commands. Always an authoritative source. https://git-scm.com/docs - Atlassian’s Git Tutorials: An excellent collection of tutorials covering everything from basic concepts to advanced workflows. Their explanation of comparing workflows is very insightful. https://www.atlassian.com/git/tutorials/comparing-workflows
- A successful Git branching model by Vincent Driessen: The original, classic blog post that proposed the “Git Flow” model. While not always the best fit, understanding it provides deep insight into branching strategies. https://nvie.com/posts/a-successful-git-branching-model/
- GitHub Flow: The official guide explaining the simpler GitHub Flow model. https://docs.github.com/en/get-started/quickstart/github-flow
- The Git Parable by Tom Preston-Werner: An easy-to-read story that explains the core concepts behind Git’s design, helping to build a better mental model of how it works. https://tom.preston-werner.com/2009/05/19/the-git-parable.html