Lesson 5 - Guided Project: Two Features, One Conflict
On this page
- Welcome to the Guided Project
- Stage 1: Set Up the Base and Build the First Feature
- Stage 2: Build the Second Feature From the Same Base
- Stage 3: Merge the First Feature (Clean Fast-Forward)
- Stage 4: Merge the Second Feature (Conflict, Then Resolve)
- Stage 5: Review the Merged History
- Extend the Project
- Summary
- Next Steps
- Continue Building Your Skills
Welcome to the Guided Project
You’ve learned each piece of branching and merging on its own: a branch is a movable pointer, git merge reunites diverged work, and a merge conflict appears when two branches edit the same lines. This guided project is where those pieces meet a realistic situation. You’ll play the role of a SkyLog developer building two features at the same time — each on its own branch, each starting from the same main.
The first feature adds a timestamp to every logged observation. The second adds a hashtag tag so you can group entries. Because both branches start from the same base and one of them lands first, merging the second is going to collide — both features rewrite the very same add function. That collision isn’t a mistake; it’s the everyday reality of parallel work, and you’ll resolve it the way real developers do: by reading both sides and combining them into one function that has both timestamps and tags.
We’ll work entirely inside the SkyLog repository, building it up from a clean base so every command and every byte of output you see is reproducible on your own machine.
By the end of this project, you will be able to:
- Develop two independent features on separate branches from the same starting point
- Merge one branch as a clean fast-forward and recognize it from the output
- Resolve a real merge conflict by combining changes from both branches
- Read a finished merge in
git log --oneline --graphand confirm a clean working tree
Let’s build it.
Stage 1: Set Up the Base and Build the First Feature
Start in an empty folder and create the SkyLog repository with a single committed script. The base skylog.py defines an add function that appends an observation to a notes file:
import sys
def add(observation):
line = f"- {observation}\n"
with open("observations.md", "a") as f:
f.write(line)
print(f"Logged: {observation}")Save that as skylog.py, then commit it on main:
$ git add skylog.py
$ git commit -m "Add SkyLog script"This single commit is the common ancestor both feature branches will start from. Now create the first feature branch and switch to it:
$ git switch -c feature/timestamps
Switched to a new branch 'feature/timestamps'On this branch, the goal is to stamp each entry with the time it was logged. Rewrite skylog.py to import datetime and prepend a [HH:MM] timestamp to the line:
import sys
from datetime import datetime
def add(observation):
line = f"- [{datetime.now():%H:%M}] {observation}\n"
with open("observations.md", "a") as f:
f.write(line)
print(f"Logged: {observation}")Commit it:
$ git add skylog.py
$ git commit -m "Add timestamps to entries"The feature/timestamps branch now points one commit ahead of main. The add function here has been rewritten — note especially that the line = ... assignment is now different from the base. Remember that line; it’s exactly where the trouble will start.
Stage 2: Build the Second Feature From the Same Base
The second feature is independent of the first, so it must start from main — not from feature/timestamps. Switch back to main first, then branch off it:
$ git switch main
Switched to branch 'main'
$ git switch -c feature/tags
Switched to a new branch 'feature/tags'By switching to main before creating feature/tags, you guarantee both branches share the same starting commit. This is what makes them truly parallel: neither knows about the other’s work.
On feature/tags, the goal is to attach a hashtag to each observation. Give add a tag parameter (defaulting to "sky") and append #tag to the line:
import sys
def add(observation, tag="sky"):
line = f"- {observation} #{tag}\n"
with open("observations.md", "a") as f:
f.write(line)
print(f"Logged: {observation}")Commit it:
$ git add skylog.py
$ git commit -m "Add hashtag to entries"Now you have two diverged branches. Both started from Add SkyLog script, and both rewrote the def add(...) signature and the line = ... assignment — but in different ways. main itself still sits at the original commit, untouched.
Stage 3: Merge the First Feature (Clean Fast-Forward)
Switch to main — the branch that will receive the work — and merge feature/timestamps into it:
$ git switch main
$ git merge feature/timestamps
Updating 371a294..fd58589
Fast-forward
skylog.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)This is a fast-forward, and the output tells you so directly: Updating <old>..<new> followed by Fast-forward. Because main hadn’t moved since feature/timestamps branched off, Git had nothing to reconcile — it simply slid the main pointer forward to the feature’s tip. No merge commit was created; the history is still a straight line.
main now contains the timestamp feature. One down, one to go — and the second one won’t be this quiet.
Stage 4: Merge the Second Feature (Conflict, Then Resolve)
Now merge the tags feature. main has moved since feature/tags branched off (it gained the timestamp commit), and both branches changed the same two lines of skylog.py. Git can’t choose for you:
$ git merge feature/tags
Auto-merging skylog.py
CONFLICT (content): Merge conflict in skylog.py
Automatic merge failed; fix conflicts and then commit the result.The merge paused. CONFLICT (content): Merge conflict in skylog.py names the exact file, and Automatic merge failed tells you nothing has been committed yet — Git is waiting for you. Open skylog.py and you’ll see Git has marked the disputed region with conflict markers:
import sys
from datetime import datetime
<<<<<<< HEAD
def add(observation):
line = f"- [{datetime.now():%H:%M}] {observation}\n"
=======
def add(observation, tag="sky"):
line = f"- {observation} #{tag}\n"
>>>>>>> feature/tags
with open("observations.md", "a") as f:
f.write(line)
print(f"Logged: {observation}")Read the markers carefully. Everything between <<<<<<< HEAD and ======= is what’s currently on main (the timestamp version). Everything between ======= and >>>>>>> feature/tags is the incoming change (the tag version). Notice that the lines outside the markers — the import, the with open(...), the print(...) — merged cleanly; only the two conflicting lines are in dispute.
Here’s the key decision: you don’t want to throw either feature away. You want both — entries that carry a timestamp and a tag. So delete all three marker lines and write a single add function that merges the two ideas: keep the tag="sky" parameter from one side and the [HH:MM] timestamp from the other, in one line assignment:
import sys
from datetime import datetime
def add(observation, tag="sky"):
line = f"- [{datetime.now():%H:%M}] {observation} #{tag}\n"
with open("observations.md", "a") as f:
f.write(line)
print(f"Logged: {observation}")This is a deliberate, human decision — Git couldn’t have made it because it doesn’t know the two features were meant to work together. With the conflict markers gone and the function combining both changes, stage the resolved file and complete the merge:
$ git add skylog.py
$ git commit --no-editStaging the file is how you tell Git “this conflict is resolved.” The --no-edit flag accepts Git’s prepared merge message (Merge branch 'feature/tags') without opening an editor. The merge is now committed, and both features live in main.
Combining both sides is a normal resolution
A merge conflict isn’t a forced choice between “mine” or “theirs.” Very often the right answer is to keep both changes woven together — exactly what you did here, ending up with timestamps and tags in one function. Git surfaces the conflict precisely because that combining decision needs a human. Note too that this conflict was small and easy to resolve because each branch was a tight, focused change to one function. The more often you merge — and the smaller and more focused you keep your branches — the rarer and gentler conflicts become.
Stage 5: Review the Merged History
The project is finished. Look at the shape of what you built:
$ git log --oneline --graph
* 42b0929 Merge branch 'feature/tags'
|\
| * ba0c302 Add hashtag to entries
* | fd58589 Add timestamps to entries
|/
* 371a294 Add SkyLog script(Your commit hashes will differ — they’re computed from your content and timestamps.) Read it bottom-up. The single commit Add SkyLog script is the common ancestor. Above it the history forks: Add timestamps to entries on the * | line came in via the fast-forward, and Add hashtag to entries on the | * line is the tags branch. At the top, the merge commit Merge branch 'feature/tags' ties them back together — the |\ below it shows its two parents. That fork-and-rejoin diamond is the visual record of two features developed in parallel and reunited.
Finally, confirm the working tree is clean:
$ git status --shortThe command prints nothing, which means there’s nothing to stage and nothing to commit — every change is safely recorded. An empty git status --short is the sign of a finished, tidy piece of work.
Extend the Project
Exercise 1: A third feature, another fast-forward
From the current main, branch feature/usage and add a one-line docstring to the add function (for example """Append a tagged, timestamped observation."""). Commit it, switch back to main, and merge. Will this be a fast-forward or a three-way merge, and how will the output tell you?
Hint
Because main hasn’t moved since you created feature/usage, it will be a fast-forward — the output will say Updating <old>..<new> and Fast-forward, with no Merge made by the 'ort' strategy line. The docstring is a new line that doesn’t touch any line the other branch changed, so even if it weren’t a fast-forward there’d be no conflict.
Exercise 2: Force a conflict on purpose
Create two branches off main, feature/a and feature/b, and on each one change the print(f"Logged: {observation}") line to a different message. Merge feature/a (clean), then merge feature/b and resolve the conflict. This time, pick just one side instead of combining. How do you signal to Git that you’ve resolved it?
Hint
Edit skylog.py so only the message you want remains and all conflict markers (<<<<<<<, =======, >>>>>>>) are deleted, then run git add skylog.py followed by git commit --no-edit. Staging the file is what tells Git the conflict is resolved — there’s no separate “resolve” command.
Exercise 3: Abort instead of resolving
Set up a conflicting merge as in Exercise 2, but before resolving it, decide you’d rather back out entirely. Which command returns you to the state just before the merge, as if it never started?
Hint
Run git merge --abort. It discards the in-progress merge and restores your branch to exactly where it was before you ran git merge, conflict markers and all removed. It’s the safety net whenever a merge turns out messier than expected.
Summary
In this project you developed two SkyLog features in parallel and brought them both home. You created feature/timestamps and feature/tags from the same main commit, so they were truly independent. Merging the first was a fast-forward — main hadn’t moved, so Git just slid the pointer forward, shown by Updating <old>..<new> and Fast-forward. Merging the second produced a real merge conflict, because both branches had rewritten the same add function. You read the conflict markers to tell HEAD (the timestamp version) from the incoming feature/tags (the tag version), then combined both into a single function with timestamps and tags, staged it with git add, and finished the merge with git commit --no-edit. The final git log --oneline --graph showed the fork-and-rejoin diamond of two reunited features, and git status --short printed nothing — a clean, finished repository.
Key Concepts
- Parallel feature branches — branch each feature off the same
mainso they develop independently. - Fast-forward merge — when the receiver hasn’t moved, Git slides the pointer forward with no merge commit (
Fast-forwardin the output). - Merge conflict — Git stops when both branches edited the same lines;
CONFLICT (content)names the file and nothing is committed yet. - Conflict markers —
<<<<<<< HEAD/=======/>>>>>>>bracket the two competing versions; resolving means editing to the version you want and deleting all markers. - Combining as a resolution — keeping both sides woven together is a common, valid way to resolve a conflict.
- Finishing a conflicted merge —
git add <file>marks it resolved;git commit --no-editaccepts the prepared merge message.
Why This Matters
This is what real Git work looks like: several changes in flight at once, most merges clean, an occasional conflict that needs a moment of judgment. Knowing that a fast-forward and a conflict are both normal outcomes — and that resolving a conflict often means combining rather than choosing — is the difference between treating Git as a source of fear and treating it as a dependable collaborator. The exact flow you practiced here (branch, commit, merge, resolve, verify) is the spine of every team workflow you’ll meet next, where these same branches and merges happen through GitHub and pull requests.
Next Steps
Continue to Module 4 - GitHub and Remotes
Put your repo online and sync with GitHub
Back to Module Overview
Return to the Branching and Merging module overview
Continue Building Your Skills
You’ve now run a complete branching-and-merging cycle end to end: two features built in parallel, one merged as a clean fast-forward, one resolved through a real conflict by combining both changes, ending in a tidy merged history. Everything you’ve done so far has lived on your own machine. In the next module you’ll push SkyLog up to GitHub, learn how local and remote branches stay in sync, and start collaborating through the same merges and conflicts — just shared with the world.