Demystifying Git and Merge Conflicts 

Git can be an incredibly effective coding tool, but it can also be an incredibly frustrating one. It has a steep learning curve, but you’ll be a lot better off understanding how it works rather than copying and pasting commands from Stack Overflow or ChatGPT. I’ve been there, and things can go very, very wrong.

What is Git?

Git is a version control system which tracks changes in files within a repository. It lets you maintain different versions of that codebase. Not to be confused with GitHub, which is a Git server, or a remote location which serves as the host to codebases in Git. GitHub provides a user-friendly front-end for managing changes and issue tracking. There are plenty of other Git servers, such as Bitbucket and GitLab.

Tips for using Git

Git is massive, and there’s plenty of tutorials and guides out there to help you learn it. This is far from a comprehensive guide, but these are the commands I use on a regular basis. I’ll go over some of the basics, and then some of the niche ones that I’ve found particularly useful. 

We’ll walk through a simple Git workflow assuming an we have already initialised a Git repository using git init, and that we have a handful of files that we’re working on. We’ll start handling changes on a single branch and expand to multiple branches. These changes should each be contained in a single commit. There’s no set rule for how many lines it should be, but a good rule of thumb is that a commit addresses a single logical unit of work.

If you’re looking for a more comprehensive guide, check out the one provided by GitHub.

Git Workflow

Let’s say we’ve made some changes to our main.py and utils.py files. Assuming those are already being tracked, we can add them to staging, commit those changes, and push them to the remote repository, as shown below. I’ve included some checks like git status to ensure that the staging is in order before making the commit.

# Add specific files to staging
$ git add main.py utils.py

# Or update all tracked files (but not untracked ones)
$ git add -u

# Check the current status of your working directory
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   main.py
    modified:   utils.py

# Commit your staged changes with a message
$ git commit -m "Refactor main and utility functions"
[main abc1234] Refactor main and utility functions
 2 files changed, 14 insertions(+), 6 deletions(-)

# Push commits to the remote repository
$ git push origin main
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 8 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 567 bytes | 567.00 KiB/s, done.
To github.com:user/repo.git
   a1b2c3d..abc1234  main -> main

Fixup Commits

Now imagine we’ve forgotten a small change that relates to the last commit. Instead of making a whole new commit, you can create a fixup commit. git commit --fixup <commit> creates a special commit with the fixup! prefix that tells Git to squash it automatically later during a rebase. In simpler terms, this means that it creates a commit with a specific message, such that when you run some form of squash, Git knows to attach that fixup commit with the commit it refers to (HEAD). An easy way to squash your commits is with --autosquash, which used with git rebase -i to automatically reorder and mark the fixup commits for squashing.

# Want to amend or fix the most recent commit?
$ git commit --fixup HEAD
[main def5678] fixup! Refactor main and utility functions
 1 file changed, 3 insertions(+)

# Once you're ready to squash the fixup commit into the original, use an interactive rebase:
$ git rebase -i --autosquash HEAD~2
# This opens a text editor with something like:
# pick abc1234 Refactor main and utility functions
# fixup def5678 fixup! Refactor main and utility functions
# Save and close to squash them into a single clean commit.

This is assuming that you wanted the add-on change to attach to the last commit. If you wanted to attach it to the second-to-last commit you would use git commit --fixup HEAD~1 and if you wanted to attach it to the third-to-last commit you would use git commit --fixup HEAD~2 and so on. Or you can use the commit ID as seen below.

Comparing Commits

If we want to take a look back at what was actually on our commits, we can check the log and compare the commits in question with git diff. We can also view and copy the commit ID using the log. In the interest of space, I’ve used git log --oneline but you can use git log for a more comprehensive log.

# View a concise list of the 3 most recent commits
$ git log --oneline -3
e3a1c9b (HEAD -> main) Add debug print statement to process_data
c9f4a72 Clean up variable naming in main.py
abc1234 Refactor main and utility functions

# Show the difference between c9f4a72 and e3a1c9b
$ git diff c9f4a72 e3a1c9b
diff --git a/main.py b/main.py
index 7fd6c99..f3b1d55 100644
--- a/main.py
+++ b/main.py
@@ def process_data(data):
-    result = clean(data)
+    result = clean(data)
+    print("Processing complete:", result)

These commit IDs can be used for much more than git diff. They will be used below with git cherry-pick and they can be used above with the fixup commits. For example, you can replace HEAD~n where n is the number of commits back you want to go, with a specific commit ID such as git commit --fixup c9f4a72.

Branches

Branches let you work on different versions of your code without touching the main one. You can test new features, work on bug fixes, or anything in your imagination with the original codebase and optionally bring these changes into the main codebase.

# Switch to an existing branch
$ git checkout feature/add-login
Switched to branch 'feature/add-login'

When we’re done on our branch, we can bring those changes back into main. Two ways to do that: merge and rebase. Use git merge if you want a record of the branch; it creates a new merge commit that ties the histories of both branches together. In doing so, it preserves the branching structure (parallel history + merge commit).

# Merge combines changes and keeps the full branch history
$ git checkout main
$ git merge feature/add-login

Alternatively, we can use git rebase to rebase your branch’s changes onto main, making it look like the feature branch was developed on top of main all along (linear history, but rewritten).

# Rebase replays your changes on top of the latest main
$ git checkout feature/add-login
$ git rebase main

Stash

Now imagine we’re halfway through writing some code and suddenly realise we’re working on the wrong branch. Or maybe we’re not ready to commit yet but need to switch branches. If these new, uncommitted changes would conflict with files in the other branch, Git won’t let you switch. This is where git stash saves us. It temporarily hides your uncommitted changes so we can come back to them later.

# Stash your changes (saves everything that's not committed)
$ git stash
Saved working directory and index state WIP on main: abc1234 Refactor data pipeline

# Switch branches safely
$ git checkout feature/add-login

# List all your stashed changes
$ git stash list
stash@{0}: WIP on main: abc1234 Refactor data pipeline

# Bring the changes back (and remove them from stash)
$ git stash pop
Auto-merging main.py
# Or if you just want to delete a stash without applying it
$ git stash drop stash@{0}
Dropped stash@{0} (WIP on main: abc1234 Refactor data pipeline

Use this when you’ve made quick edits in the wrong place and don’t want to commit yet. Just remember that stashing is temporary, don’t forget to come back for it! And if you lose track of your entries, you can always check with git stash list. I’ve seen people use commands from external sources which stash their changes, and then they freak out because they think they lost them. I promise they are right there, you just have to check the list!

Cherry Pick

Now, we need just a single commit from another branch, but not the whole branch. We can use git cherry-pick along with the commit ID to bring over just that commit.

# First, find the commit you want
$ git log feature/fix-typo --oneline
d3f4e1a Fix typo in error message

# Switch to the branch you want to apply it to
$ git checkout main

# Apply that one commit onto your current branch
$ git cherry-pick d3f4e1a
[main abc5678] Fix typo in error message

Merge Conflicts

Merge conflicts used to send a shiver down my spine. A merge conflict happens when Git can’t decide which version of a file to keep. This usually happens when you’re trying to merge two branches that have changes on the same lines of code. You have to step in and manually tell Git which are the changes you want to keep between the two lines. It’ll look something like this:

Auto-merging main.py
CONFLICT (content): Merge conflict in main.py
Automatic merge failed; fix conflicts and then commit the result.

Inside the file, you’ll see markers like this:

def process_data(data):
<<<<<<< HEAD
    print("Processing:", data)
=======
    print("Processing complete:", data)
>>>>>>> feature/improve-logs

Everything between <<<<<<< and ======= is your current branch. Everything below ======= is from the branch you’re merging in. To solve it, we have to delete the markers and choose what to keep before continuing on with the merge. Alternatively, we can tools to help us manage these commits, as they are often a lot more complex than the example shown above.

Sublime Merge

Sublime Merge is a Git Client which provides a frontend for managing commits, branches, and conflicts. It creates and runs Git commands behind-the-scenes. It clearly shows you what’s staged, where branches diverge, what’s contained in individual commits, and so much more. It was recommended to me specifically for its capabilities with merge conflicts, and after I tried it once I never looked back. I’m just going to go over its capabilities with merge conflicts, but check out their website for more.

When we have a merge conflict and view our repository, it will flag the conflict and let us go into the file to resolve it.

After you select Resolve, it will take you into the code directly. Rather than manually change everything, you can check the two versions of the code, and select which you want to go in the contested lines:

Once you’ve selected the one you want, you can click Save and Stage. It will then take you back to the main screen where you can commit the merge and carry on coding:

Hopefully this clears some stuff up and gives you some tools to make your programming life a bit easier!

Author