Skip to content
  • Home
  • How To
  • Module 0 - Foundations
  • M1
  • Agents
  • M2
  • M3
  • M4
  • M5
  • M6
  • Progress
  • Contact
  • Pricing
  • Sign in
  • Future
  1. Module 0M0
  2. ›
    S3Version Control with Git
  3. ›
    Lesson 3 of 4Branching, Merging, and Resolving Conflicts
  4. ›
    S3›Lesson 3 of 4
  1. Module 0
  2. ›
  3. Section 3
  4. ›
  5. Lesson 3

Branching, Merging, and Resolving Conflicts

FREE·~1.5 h·2 labs·4 questions·1 assignment


Context

A branch is an independent line of work inside the same project. You leave the version that works untouched, build somewhere else, and when the new work is worth keeping you fold the two lines back into one. Sometimes they fold cleanly, and sometimes both sides edited the same lines and the tool refuses to guess which one you meant.

This is how experimentation happens without risk. A new model architecture, a different feature set, or a rewritten preprocessing step gets its own line, and the baseline that currently produces your numbers stays exactly as it was. When two people edit the same training script at once, the same mechanism is what merges their work.

The conflict case is where beginners lose files. A conflicted file has markers written into it by the tool, and a script left in that state fails immediately, because those markers are not valid code. The previous lesson read history as a single straight line of snapshots, this lesson is where that line splits, runs in parallel, and comes back together.


Goal

  • Create a branch and make commits on it without touching the branch you started from.
  • Trace where HEAD points before and after switching branches, and read which commit it resolves to.
  • Merge one branch back into another and confirm the changes arrived.
  • Read a merge conflict's markers in a file and identify which side each block came from.
  • Resolve a conflict by editing the marked file, staging it, and committing the resolution.
  • Read your project history as a graph with parallel lines instead of a single straight timeline.

Prerequisites

Both are optional and the lesson assumes neither. They are here if you would rather see the picture before you build it.

  1. YouTube - "Complete Git and GitHub Tutorial" (Kunal Kushwaha), the branching and merging chapters only. Watch a branch split off and merge back as a picture before you do it by hand, so the graph is a shape you already know when git log --graph draws one.
  2. Pro Git book (free, openly licensed) - Chapter 3 "Git Branching", "Branches in a Nutshell" and "Basic Branching and Merging". Read those two sections only. The clearest written explanation of what a branch and HEAD actually are.

Table of Contents

  • Branch
  • HEAD
  • Merge
  • Merge Conflict

Theory

SETUP - DO THIS FIRST (everything below depends on it):

cd ~
mkdir branch-lab && cd branch-lab && git init -q
echo "learning_rate = 0.01" > config.txt  # the file the branches will fight over
git add config.txt
git commit -m "Initial config"

That first commit is not optional. A branch is a pointer to a commit, so in a repository that has none, git branch feature/new-optimizer refuses with fatal: not a valid object name: 'main'.


Branch

What. A branch is an independent line of development inside one repository. Concretely it is a lightweight, movable pointer to a commit. Creating a branch does not copy your files, it just makes a second name pointing at a commit, and commits you make while on that branch move that pointer forward, leaving every other branch where it was. The default branch is usually called main.

Why. A branch lets you work on something without endangering known-good code. You can start a feature/new-optimizer branch, make ten messy commits trying ideas, and main stays exactly as it was the whole time, still runnable, still shippable. If the experiment works you merge it in, if it fails you simply abandon the branch. This is the everyday rhythm of real ML work: main holds the version that trains correctly, and every change, a new feature, a config sweep, a bug fix, happens on its own branch first. Because a branch is only a pointer (not a copy of your files), creating one is instant and nearly free, so you make them freely.

When. Make a branch whenever you start a distinct piece of work: a feature, an experiment, a fix. The convention is one branch per task, named for the task. You return to main (or merge into it) only when the work on the branch is done and verified.

# 'git branch' with no arguments LISTS branches, '*' marks the current one.
git branch                        # * main  -> you are on 'main', the only branch

# Create a new branch. This makes a new pointer at the CURRENT commit,
# it does NOT move you onto it yet and does NOT copy any files.
git branch feature/new-optimizer  # new pointer 'feature/new-optimizer'

# 'git switch' moves you ONTO a branch (your working files now reflect it).
git switch feature/new-optimizer  # now commits land on this branch

git branch alone lists what exists and stars where you are. git branch <name> creates a second pointer at your current commit, nothing is copied, no files change. git switch <name> is what actually moves you onto the branch, so that the next commit advances that branch and not the one you left. (git checkout <name> is the older command for the same switch, git switch is the modern, clearer one.)

# Shortcut: create AND switch in one step with '-c' (create).
git switch -c experiment/lr-schedule        # same as 'git branch X' then 'git switch X'

# Make a commit here, it advances ONLY this branch's pointer.
echo "lr = 0.1" >> config.txt               # change a file on this branch
git add config.txt                          # stage the change (from Lesson 0.3.1)
git commit -m "Try a higher learning rate"  # 'experiment/lr-schedule' moves forward

git switch -c <name> is the common shortcut: create the branch and step onto it in one command. The commit that follows advances only experiment/lr-schedule, main is untouched and still points at the older commit. That isolation is the entire point of a branch: work piles up on the side path while the main path stays frozen.

DON'T:

  • DON'T assume git branch <name> moves you onto the new branch. It only creates the pointer, you stay where you are until you git switch (or use git switch -c to do both).
  • DON'T picture a branch as a copy of your files or a separate folder. It is a single pointer to a commit, switching branches updates the files in your one working folder in place.

HEAD

What. HEAD is a pointer to the commit (or, normally, the branch) you currently have checked out, it answers "where am I right now?" Almost always HEAD points at a branch name (e.g. HEAD -> main), and that branch in turn points at a commit. When you commit, HEAD's branch moves forward, when you git switch, HEAD moves to a different branch.

Why. Git needs to know which branch your next commit should advance, and HEAD is that knowledge. Every commit you make is recorded as a child of whatever commit HEAD currently resolves to, and the branch HEAD points at is the one that moves. Understanding HEAD removes the mystery from switching: when you git switch other-branch, HEAD now points at other-branch, your working files are rewritten to match its commit, and your next commit advances it. HEAD is the single "you are here" marker that makes branches navigable.

When. You reason about HEAD any time you switch branches or are unsure which branch a commit will land on. You also see it constantly in git log and git status output, where Git prints HEAD -> branchname to tell you where you stand.

git switch main  # back to 'main' from 'experiment/lr-schedule'

# '.git/HEAD' literally stores where HEAD points, usually a branch ref.
cat .git/HEAD    # ref: refs/heads/main  -> HEAD points at branch 'main'

# 'git status' reports the current branch (i.e. what HEAD points to).
git status       # On branch main -> HEAD -> main

cat .git/HEAD shows the raw truth: HEAD is a reference to a branch (refs/heads/main is just the full name of main). git status reports the same fact in friendlier words, "On branch main." Both answer the one question HEAD exists to answer: which branch are you on.

# Switch branches and watch HEAD follow. '--oneline' from the log lesson.
git switch experiment/lr-schedule  # HEAD now points at this branch
git log --oneline -1               # shows the commit HEAD currently resolves to
# bf155d0 (HEAD -> experiment/lr-schedule) Try a higher learning rate

git switch main                    # HEAD moves back to 'main'
git log --oneline -1               # now resolves to a DIFFERENT commit
# 048cbf5 (HEAD -> main, feature/new-optimizer) Initial config

After git switch experiment/lr-schedule, git log prints HEAD -> experiment/lr-schedule and resolves to that branch's latest commit. Switch back to main and HEAD moves with you, now resolving to main's commit instead. HEAD is not a commit you choose directly, it follows you as you switch, always marking the branch your next commit will advance.

DON'T:

  • DON'T confuse HEAD (where you are) with a branch (a line of work). HEAD usually points at a branch, they are different things, HEAD moves between branches, a branch moves forward as you commit.
  • DON'T worry yet about "detached HEAD" warnings from checking out a raw commit. In this lesson HEAD always points at a branch, just know the phrase exists and means HEAD is pointing straight at a commit instead of a branch.

Merge

What. A merge combines the changes of two branches into one. You stand on the branch you want to merge into (the destination, often main), and git merge <other> brings the other branch's commits into it. When the two branches changed different things, Git combines them automatically and records a single new merge commit that has two parents, one from each line.

Why. Branching is only half the workflow, merging is how the side path's work rejoins the main path. After your feature/new-optimizer branch is finished and verified, you merge it into main so the feature becomes part of the real version. The merge is also what turns the history into a true graph: two lines that split earlier now join, and the merge commit with its two parents is the join. Reading that graph tells the story, where work diverged and where it came back together. This extends the linear history from your last lesson: the same commits, now with parallel lines that fork and rejoin.

When. You merge when work on a branch is complete and you want it in another branch, typically merging a finished feature or fix back into main. You run git merge from the destination branch (switch to it first), naming the branch whose changes you want to pull in.

# Step 1: stand on the branch you want to merge INTO (the destination).
git switch main                             # bring changes INTO 'main'

# 'main' has to have moved too, or there is nothing to reconcile: Git just
# slides the pointer forward, prints Fast-forward, and records NO merge commit.
echo "seed = 42" > seed.txt                 # a change that exists only on 'main'
git add seed.txt && git commit -m "Pin the random seed"

# Step 2: merge the other branch's work into it.
# '--no-edit' accepts Git's own merge message instead of opening an editor.
git merge --no-edit experiment/lr-schedule  # pull that branch's commits into 'main'
# Merge made by the 'ort' strategy.  <- both sides moved, so a merge commit
#  config.txt | 1 +
#  1 file changed, 1 insertion(+)

You always merge into the branch you are on, so step one is to git switch to the destination. git merge experiment/lr-schedule then brings that branch's commits into main. If main had no new commits of its own, Git just slides main's pointer forward to match, a "fast-forward," no new commit needed. If both branches advanced, Git creates a merge commit joining them.

# See the result as a GRAPH: '--graph' draws the lines, '--all' shows
# every branch, '--oneline' keeps each commit to one line.
git log --oneline --graph --all
# *   8cc0f26 (HEAD -> main) Merge branch 'experiment/lr-schedule'
# |\
# | * bf155d0 (experiment/lr-schedule) Try a higher learning rate   <- the merged-in branch
# * | e73f2d3 Pin the random seed                                   <- what 'main' did meanwhile
# |/
# * 048cbf5 (feature/new-optimizer) Initial config                  <- where they split

git log --oneline --graph --all is how you read the graph your branches and merges created. The * marks are commits, the | and \ and / lines draw how they connect. Here you can see the split (one line became two) and the merge commit at the top joining them back, the two-parent shape that a merge produces. This is the linear history from before, now branching and rejoining.

DON'T:

  • DON'T run git merge from the wrong branch. git merge X brings X into your current branch, if you are on X itself, or on some unrelated branch, you will merge into the wrong place. Switch to the destination first.
  • DON'T expect a merge to delete the other branch. After merging experiment/lr-schedule into main, the branch pointer still exists. The merge made its commits part of main's history without copying them, and it does not remove the branch (you delete it separately if you want).

Merge Conflict

What. A merge conflict happens when both branches changed the same lines of the same file in different ways, so Git cannot decide which version is correct. Instead of guessing, Git pauses the merge, writes both versions into the file surrounded by conflict markers (<<<<<<<, =======, >>>>>>>), and hands you the decision. You resolve it by editing the file to the version you want, removing the markers, staging the file, and committing.

Why. Automatic merging works only when the two branches touched different things, different files, or different lines of the same file. The moment both edited the same line (say, main set learning_rate = 0.001 and your branch set learning_rate = 0.1), there is no automatic right answer: keeping both would be nonsense, and picking one silently could discard someone's work. So Git refuses to guess and marks the spot, because a human knows which value is actually intended. This is the conflict read as competing diffs, each branch's change to that line is a diff, and they overlap, so you reconcile them by hand.

When. Conflicts arise during a git merge (and later when pulling others' work) whenever overlapping edits exist. You handle one whenever Git reports "CONFLICT" and git status shows files as "both modified." Until you resolve and commit, the merge is in progress and your repository is in a special mid-merge state.

# Set up the collision: 'main' and a branch each change the SAME line.
git switch -c experiment/fast-lr           # new branch off 'main'
echo "learning_rate = 0.1" > config.txt    # this branch's value
git add config.txt && git commit -m "Set lr to 0.1"

git switch main                            # back to 'main'
echo "learning_rate = 0.001" > config.txt  # 'main' changes the SAME line
git add config.txt && git commit -m "Set lr to 0.001"

git merge experiment/fast-lr               # both touched the same line -> CONFLICT
# ...
# CONFLICT (content): Merge conflict in config.txt
# Automatic merge failed; fix conflicts and then commit the result.

Both branches rewrote the same lines of config.txt, so the merge cannot proceed automatically. Git reports CONFLICT and stops, leaving the merge half-finished. Nothing is broken, Git is simply waiting for you to decide. git status at this point will list config.txt as "both modified."

# Open the file: Git wrote BOTH versions in, fenced by conflict markers.
cat config.txt
# <<<<<<< HEAD                 <- start of YOUR side (current branch, 'main')
# learning_rate = 0.001        <- 'main' version
# =======                      <- divider between the two sides
# learning_rate = 0.1          <- the OTHER branch's version
# >>>>>>> experiment/fast-lr   <- end, labeled with the incoming branch

The markers tell you exactly which side is which: everything between <<<<<<< HEAD and ======= is your current branch's version (HEAD = main here), and everything between ======= and >>>>>>> experiment/fast-lr is the incoming branch's version. The markers are not code, they are Git's annotations showing the two competing diffs side by side so you can choose.

# RESOLVE: edit the file so it contains ONLY the version you want, and
# DELETE all three marker lines. Here we decide 0.1 is correct.
nano config.txt     # edit until the file is just: 'learning_rate = 0.1'

# Tell Git the conflict is resolved by STAGING the fixed file.
git add config.txt  # staging a conflicted file marks it resolved

# Commit to COMPLETE the merge, '-m' supplies its message.
git commit -m "Merge experiment/fast-lr: keep lr 0.1"

Resolution is manual editing: open the file, delete the <<<<<<<, =======, and >>>>>>> lines, and leave only the content you actually want (you may keep one side, the other, or a hand-written combination). git add on the once-conflicted file is how you signal "resolved." The final git commit records the merge commit and ends the mid-merge state. The merge is now complete with the value you chose.

DON'T:

  • DON'T commit a file that still contains <<<<<<<, =======, or >>>>>>> lines. Those markers are not valid content, leaving them in means your config or code now has literal conflict markers in it, which will break the file. Remove all three before staging.
  • DON'T think Git picks a side for you or that a conflict is an error you did wrong. A conflict is the expected outcome of overlapping edits, Git stopping is correct behavior, and the only fix is a human choosing the intended content.

Additional - escaping a merge you don't want to finish

If you start a merge, hit conflicts, and decide you are not ready to resolve them, you do not have to push through. git merge --abort throws the half-done merge away and returns the repository to exactly how it was before you ran git merge. It is the clean "undo" for a merge in progress, useful when a conflict surprises you and you want to step back, look at both branches with git log --graph, and try again deliberately.


Lab Exercises

Lab Exercise 1: Branch Without Disturbing main

In a fresh repository with at least one commit on main, list your branches, create a new branch named feature/data-loader, switch onto it, make a commit that adds or changes a file, then switch back to main. In comments, confirm (using a log command) that main does not contain the commit you made on the branch, proving the branch isolated your work.

Lab Exercise 2: Follow HEAD, Then Merge Two Lines of Work

Part A: With at least two branches pointing at different commits, print where HEAD points using both the raw .git/HEAD file and git status. Switch between the two branches and, after each switch, show the single commit HEAD resolves to. In comments, state what HEAD pointed at before and after, and why the resolved commit changed.

Part B: With main checked out, merge the other branch in and read the resulting graph. Then create a genuine disagreement: branch again, change one specific line of a file, commit, return to main, change that same line differently, commit, and merge. Record the exit status, the two-letter code status reports for that file, and the marker lines git writes into it. Resolve it by editing the file to the content you actually want, then stage and commit the merge. In comments, say why the first merge needed no decision from you and the second one did.


Solution 1: Branch Without Disturbing main
# --- Setup: a fresh repository with one commit on 'main' ---
cd ~ && mkdir branch-lab-2 && cd branch-lab-2 && git init -q
echo "rows = 0" > loader.txt && git add loader.txt && git commit -qm "Start"

git branch                            # list branches: * main

# Create AND switch in one step.
git switch -c feature/data-loader     # now HEAD -> feature/data-loader

# Make a commit that lands only on this branch.
echo "rows = 1000" > loader.txt       # a change on the branch
git add loader.txt                    # stage it (Lesson 0.3.1)
git commit -m "Add data loader stub"  # advances ONLY this branch

git switch main                       # back to 'main'

# PROOF: 'main' does NOT have that commit. '--oneline' lists commits compactly.
git log --oneline                     # 'main' has no entry for the Add data loader stub commit

git switch -c feature/data-loader creates the branch and moves onto it. The commit advances only that branch's pointer. After switching back to main, git log --oneline on main does not list "Add data loader stub", the commit exists only on feature/data-loader, which is exactly the isolation a branch gives you: work on the side path never touches the path you left.

DON'T:

  • DON'T expect loader.txt to still be in your folder after switching to main. Switching rewrites your working files to match main's commit, so a file created only on the branch disappears from view until you switch back (it is not lost, it lives on the branch).
  • DON'T create the branch with git branch and then forget to git switch, or your commit will land on main instead of the new branch.
Solution 2: Follow HEAD, Then Merge Two Lines of Work
# Where does HEAD point right now? Two ways to ask.
cat .git/HEAD                   # ref: refs/heads/main  -> HEAD points at branch 'main'
git status                      # On branch main  -> same fact, friendlier wording

# Switch and resolve HEAD to its commit after EACH move.
git switch feature/data-loader  # HEAD now points at this branch
git log --oneline -1            # a1b2c3d (HEAD -> feature/data-loader) Add data loader stub

git switch main                 # HEAD moves back to 'main'
git log --oneline -1            # e4f5a6b (HEAD -> main) ...  -> DIFFERENT commit

cat .git/HEAD and git status both report the same thing in different forms: HEAD points at the branch main. After git switch feature/data-loader, git log -1 shows HEAD -> feature/data-loader and resolves to that branch's commit, after switching back, HEAD resolves to main's commit instead. The resolved commit changed because HEAD followed you onto a different branch, and each branch points at its own latest commit.

# PART B - a merge that needs no decision from you.
git switch main
git merge feature/data-loader             # main had not moved: fast-forward, no merge commit
git log --oneline --graph -3              # a straight line, no fork

# The file must already exist on main, so both sides MODIFY a shared ancestor.
echo "learning_rate = 0.03" > config.txt
git add config.txt && git commit -m "Add config"

# Now engineer a real disagreement on ONE line.
git switch -c feature/tune-lr
echo "learning_rate = 0.01" > config.txt
git add config.txt && git commit -m "Set lr to 0.01"

git switch main
echo "learning_rate = 0.05" > config.txt  # SAME line, different value
git add config.txt && git commit -m "Set lr to 0.05"

git merge feature/tune-lr                 # CONFLICT: git stops and refuses to choose
echo $?                                   # 1 -> the merge did not complete
git status --short                        # UU config.txt  -> both sides modified it
cat config.txt                            # <<<<<<< HEAD / ======= / >>>>>>> feature/tune-lr

# Resolve by writing what you actually want, then finish the merge.
echo "learning_rate = 0.02" > config.txt  # neither side verbatim: your decision
git add config.txt                        # staging IS how you declare it resolved
git commit -m "Merge feature/tune-lr, settle lr at 0.02"
git log --oneline --graph -4              # the fork and the rejoin are both visible

Part B contains both merges the lesson is built on, and the difference between them is the whole point. The first needed no decision because main had not moved since the branch was created, so git simply slid main forward: a fast-forward is not really a merge, it is a relabeling. The second had two commits changing one line in incompatible ways, and git stopped rather than guessing, exiting 1, marking the file UU and writing both versions into it so you can see exactly what it could not decide.

UU is worth reading precisely: it means both sides modified a file they shared. That is why the sequence commits config.txt on main first. Branch from a point where the file does not exist yet, have both sides create it, and you get AA instead, both added, which is a different situation git reports differently.

Notice what resolving actually is: you edit the file to the content you want, and then git add is what declares it settled. Git does not check whether your resolution is sensible, only that you claimed it.

DON'T:

  • DON'T read HEAD as a fixed commit. It is a "you are here" pointer that moves whenever you switch, and the commit it resolves to depends on which branch it currently points at.
  • DON'T edit .git/HEAD by hand to switch branches. Read it to understand HEAD, but always move with git switch.
  • DON'T resolve a conflict by deleting the markers and keeping both versions. The result is a file that is syntactically intact and semantically wrong, which is a silent failure hiding inside a loud one.
  • DON'T expect every merge to produce a merge commit. A fast-forward produces none, and that is correct rather than a missing step.

Theory Assessment

Conceptual reinforcement, not a coding section. All four questions with their hints first, then all four answers.

Q1: What a branch is, and the difference one flag makes

State what a branch is under the hood, not what it does, and explain why creating one is nearly instant and copies no files. Then explain the difference between git branch feature/x and git switch feature/x, and what git switch -c feature/x does.

Hint: Ask how much data git actually has to write to create one. For the second half, one of the two commands moves you and one does not.

Q2: You are here

Describe what HEAD normally points to, and how git status and git log each reveal it. What happens to HEAD when you git switch to another branch?

Hint: HEAD usually points at something that itself points at a commit, so there are two hops, not one.

Q3: Which way a merge runs, and when it makes a commit

To merge feature/x into main, which branch must you be standing on when you run git merge, and why does the direction matter? Then, reading a --graph where a line splits and rejoins at a commit with two parents: identify what that two-parent commit is and what the split point represents. Finally, explain when a merge produces a new two-parent commit and when it is instead a fast-forward with no new commit at all.

Hint: git merge X modifies the branch you are on, so merging is not symmetric. For the last part, ask whether the destination branch moved at all since the other one was created.

Q4: When git refuses to choose

During a merge git prints CONFLICT (content): Merge conflict in config.txt and stops. Explain in plain terms why it refused to finish automatically, and what state the repository is now in. Then decode the file: given <<<<<<< HEAD, a block, =======, another block, >>>>>>> feature/x, say which block belongs to which branch and list the exact steps to resolve it. Name the command that instead cancels the whole merge. Finally, explain why deleting a merged branch with git branch -d loses no work.

Hint: Ask what git would have to invent in order to finish on its own. For the last part, remember what a branch actually is, and ask which branch still reaches its commits once the merge is done.


Answer 1:

A branch is a lightweight, movable pointer to a commit, and nothing more. Not a folder, not a copy of your files, just a name stored as a tiny reference. Creating one is nearly instant precisely because all git writes is that small pointer. When you commit while on a branch, that branch's pointer moves forward to the new commit, leaving every other branch where it was.

git branch feature/x creates the pointer at your current commit but does not move you. git switch feature/x moves you onto an existing branch, so your working files and your next commit's destination become that branch. git switch -c feature/x does both at once.

DON'T picture a branch as a separate directory. DON'T assume git branch <name> puts you on it, committing right after without switching lands the commit on your old branch.

Answer 2:

HEAD normally points at a branch, shown as HEAD -> main, and that branch points at a commit. So HEAD answers "which branch am I on, and therefore which commit am I at", in two hops rather than one. git status reports it as "On branch main", and git log prints HEAD -> main beside the current commit.

When you git switch other-branch, HEAD moves to point at that branch, your working files are rewritten to match its commit, and your next commit advances that branch.

DON'T treat HEAD as a fixed commit you set directly, it is a "you are here" marker that follows you.

Answer 3:

You must be standing on main, the branch you are merging into. git merge X brings X's changes into the current branch, so merging is not symmetric: it modifies the branch you are on. Stand on the destination, name the source.

In the graph, the commit with two parents is the merge commit, the point where two lines were rejoined. The split point is the commit where history diverged, where one line became two as commits were made independently on each side.

A merge produces a new two-parent commit only when both branches have moved since they diverged, so there are genuinely two lines to reconcile. If the destination branch has not moved, git simply slides its pointer forward to the other branch's commit, which is a fast-forward and creates no commit at all. That is not a lesser merge, it is a relabeling, and it is the correct outcome.

DON'T run git merge main while standing on feature/x if your goal was to update main. DON'T read every top commit as a merge, only a two-parent one is.

Answer 4:

Git stopped because both branches changed the same lines in different ways, and there is no automatic right answer. Keeping both would be nonsense and silently picking one could destroy work, so git refuses to guess, writes both versions into the file with markers, and pauses the merge mid-way. The repository is now in an in-progress merge state, git status shows the file as both modified, and the merge will not complete until you resolve and commit.

The block between <<<<<<< HEAD and ======= is your current branch. The block between ======= and >>>>>>> feature/x is the incoming branch. To resolve: edit the file to contain only what you want, one side or the other or a hand-written combination, delete all three marker lines, git add <file> to declare it resolved, then git commit to finish the merge. To back out entirely instead, git merge --abort returns you to the pre-merge state.

git branch -d loses no work because a branch is only a pointer. Merging made its commits part of the destination's history without copying them, so deleting the name removes a label while every commit it referred to remains reachable from the branch you merged into. The -d form refuses outright if the commits are not merged, which is the safety net.

Notice that git stopping is the friendly behavior here. It is loud, it names the file, and it hands you both versions. Compare that to a tool that picks a side quietly and leaves you a file that is syntactically fine and semantically wrong.

DON'T read a conflict as an error you caused wrongly, it is the expected result of overlapping edits. DON'T forget to remove the marker lines before staging, committing them writes those literal lines into your file.


Assignment

Assignment: Engineer and Resolve a Real Conflict

Create a conflict on purpose: from main, branch into feature/tune-lr, change a specific line of config.txt, and commit, back on main, change the same line differently and commit. Merge the branch, resolve the resulting conflict by writing a hand-combined final line (not simply one side), and delete the merged branch. Then, in a second demonstration on a fresh branch, use the --no-ff flag to force a merge commit where the merge would otherwise fast-forward, and delete that branch once it is merged. In comments, explain the difference between resolving a conflict and what --no-ff controls.

Hints:

  • Resolve the conflict by editing config.txt down to one line you write yourself (combining both intents), deleting all three conflict markers, then git add and git commit.
  • --no-ff is unrelated to conflicts: git merge --no-ff <branch> tells Git to record a merge commit even when it could have fast-forwarded, so the branch's existence stays visible in the graph.

Solution:
# --- Setup: a fresh repository with one commit on 'main' ---
cd ~ && mkdir conflict-lab && cd conflict-lab && git init -q
echo "learning_rate = 0.05" > config.txt   # the line both branches will change
git add config.txt && git commit -m "Initial config"

# --- Part 1: a real conflict, resolved with a hand-combined line ---
git switch -c feature/tune-lr              # branch off 'main'
echo "learning_rate = 0.1" > config.txt
git add config.txt && git commit -m "Tune lr to 0.1"

git switch main
echo "learning_rate = 0.001" > config.txt  # SAME line, different value
git add config.txt && git commit -m "Set lr to 0.001 on main"

git merge feature/tune-lr                  # CONFLICT on the shared line
# Auto-merging config.txt
# CONFLICT (content): Merge conflict in config.txt
# Automatic merge failed; fix conflicts and then commit the result.

# Resolve by WRITING A COMBINED line (neither original verbatim), then
# deleting all three markers. Final file becomes a single intended line.
nano config.txt                            # result: 'learning_rate = 0.01  # compromise between 0.1 and 0.001'
git add config.txt                         # mark resolved
git commit -m "Merge feature/tune-lr: compromise lr 0.01"

git branch -d feature/tune-lr              # delete the merged branch pointer

# --- Part 2: force a merge commit with --no-ff on a fresh branch ---
git switch -c feature/log-format           # branch off 'main'
echo "log = json" > logging.txt
git add logging.txt && git commit -m "Add JSON logging"

git switch main                            # 'main' did NOT advance, so a normal merge would fast-forward
# force a merge commit
git merge --no-ff feature/log-format -m "Merge log format (no-ff)"
git branch -d feature/log-format           # clean up

git log --oneline --graph --all            # shows the forced two-parent merge commit

Part 1 is conflict resolution: both branches set learning_rate on the same line, so the merge conflicts. Instead of picking one side, you edit config.txt to a hand-written combined line (0.01), delete all three markers, then git add and git commit to finish the merge, resolution is about deciding the file's final content. Part 2 shows --no-ff, which is unrelated to conflicts: here main had not advanced, so an ordinary merge would have fast-forwarded (just moved the pointer, leaving no trace the branch existed). git merge --no-ff forces Git to record a real merge commit anyway, keeping the branch's split-and-rejoin visible in the graph. git branch -d removes each branch pointer after its work is safely merged.

DON'T confuse the two mechanisms: resolving a conflict is about which content ends up in a file when edits overlap, while --no-ff is about whether a merge commit is created when no conflict exists at all. They solve different problems and can appear independently.


Additional Resources

Optional, and nothing later depends on it. Worth a look only if this lesson left you curious.

  1. Atlassian - "How to Resolve Merge Conflicts in Git?" (Atlassian Git Tutorial, "Using Branches" section). A clear, free command-line walkthrough of conflict markers being edited and resolved, reinforcing the conflict resolution you just practiced.

Summary

For every lesson before this one your history was a rope: one commit after another, a single line you could trace from start to end without ever choosing a direction. This lesson cut the rope into strands you can lay side by side. A branch is the small, almost weightless act of saying "let this line go its own way for a while", a pointer that moves forward as you commit, while everything you branched from stays exactly as it was. HEAD is the quiet marker that keeps you oriented through all of it, the "you are here" that follows you from strand to strand so you always know which line your next commit will extend. Together they turn the rope into a graph, and git log --graph is where that graph becomes something you can actually read: the points where work split off and the points where it came home.

Merging is the coming-home, and most of the time it is uneventful, Git looks at two lines that touched different things and quietly weaves them into one. The merge conflict is the exception that teaches the rule. When two lines reach for the same words at the same time, Git does the one honest thing it can: it stops, lays both versions in front of you between its markers, and refuses to pretend it knows which you meant. That refusal is not a failure, it is the tool recognizing the limit of what a machine should decide on its own. The whole of conflict resolution is you stepping into that gap, reading the competing diffs, choosing or composing the line that is actually right, clearing the markers, and committing the decision.

What you have practiced here is the real shape of collaborative and experimental work, even though you did it alone on one machine. Every serious project lives on branches: main stays trustworthy while features and experiments rise and fall beside it, and the discipline of merging, and of calmly resolving the conflicts that merging sometimes produces, is what keeps that trustworthy line trustworthy. You can now split your work, carry it forward without fear of breaking what already runs, bring it back together, and handle the moment when two changes collide. The timeline is a graph now, and you know how to read it and how to mend it.


Cheat Sheet

Part A - Reference Table

Concept Means Do it with Notes
Branch independent line of development git branch <name> a movable pointer to a commit, no file copy
Create + switch new branch, step onto it git switch -c <name> git branch alone does not move you
Switch move onto an existing branch git switch <name> older form: git checkout <name>
HEAD where you are (which branch) git status, cat .git/HEAD usually HEAD -> branch, follows you
Merge combine two branches git merge <other> run from the destination branch
Fast-forward merge with no new commit (automatic) when destination did not advance
Merge commit two-parent join commit (automatic) when both branches advanced
Conflict both changed same lines resolve by hand markers <<<<<<< ======= >>>>>>>
Resolve finish a conflicted merge edit → git add → git commit delete all 3 markers first
Graph history as branching lines git log --oneline --graph --all reads splits and rejoins

Part B - Compact Code Block

git switch -c feature/x          # create + switch onto a new branch
# commit on branch
echo "change" > file.txt; git add file.txt; git commit -m "work"
git switch main                  # HEAD follows back to 'main'
git merge feature/x              # bring the branch INTO 'main'
git log --oneline --graph --all  # read the history as a graph
# Conflict on the same line of a file:
git merge feature/x               # CONFLICT (content): Merge conflict in config.txt
cat config.txt                    # <<<<<<< HEAD (current) ======= (incoming) >>>>>>> feature/x
nano config.txt                   # edit to ONE version, delete all 3 marker lines
git add config.txt                # stage = mark resolved
git commit -m "Resolve conflict"  # completes the merge
git merge --abort                 # (alternative) cancel an in-progress merge
git branch -d feature/x           # delete a branch after it is merged
←Previous: Lesson 2
Next: Lesson 4→

Questions about this lesson: #module-0 on Discord

Found an error in this lesson? Report it

Your progress
Current streak
🔥 0
Lessons marked today
0 / 3
Longest streak
0

Sign in

The bootcamp

  • Curriculum
  • How To
  • Pricing
  • Changelog
  • Future

The community

  • Discord
  • Reviews
  • Contact

Follow

  • Instagram
  • X
  • TikTok
  • Reddit

Legal

  • Terms
  • Privacy
  • Refunds

Coming soon on November 16, 2026

Theme