Git for Humans: Merge, Diff, and the Git Master Workflow

Git for Humans: Merge, Diff, and the Git Master Workflow

Learn proper git merge strategies, how to read changes with diff and log, and how a git master works — in a fun, human way with memes, gifs, and real examples.

By Omar Flores

It’s 6:47 PM on a Friday. You’ve been working on a feature for two weeks. Your branch has 34 commits, three config files touched, and one refactor nobody asked for but absolutely had to happen. Meanwhile, main moved. A lot.

The cursor is hovering over the merge button.

Your hand won’t move.

A developer sweating intensely in front of a screen deciding whether to press the button

Everyone has been there. The difference between the developer who presses that button with confidence and the one who presses it with their eyes closed and a prayer isn’t luck. It’s understanding what Git is actually doing when you run that command, and having the workflow to verify it first.

This is that guide. Real knowledge, stripped of documentation filters, with the honesty of someone who has watched merges fail in production. And with some memes, because if we can’t laugh at merge conflicts, what can we laugh at.


What Actually Happens When You Run git merge

Before touching the command, you need to understand what Git does under the hood. Not in terms of DAG objects — in terms you can use in the moment.

When you run git merge feature from main, Git finds three points in the history:

  1. The last commit on main (the tip of your current branch)
  2. The last commit on feature (the tip of the branch you’re integrating)
  3. The common ancestor — the commit where the two branches diverged

With those three points, Git runs a three-way merge. It compares the changes that main made since the ancestor, and the changes that feature made since the same ancestor. If the changes are in different areas of the code, Git combines them automatically. If both branches touched the same line differently, there’s a conflict and Git reports it.

Think of it as two people who both edited the same Google Doc while the last saved version was an older draft. If one person changed paragraph 3 and the other changed paragraph 7, the system can auto-merge. If both modified paragraph 3 in different ways, someone has to decide which version stays.

That is literally what Git does. The difference is that Git leaves both versions in the file for you to decide, rather than just saving the most recent change.

    Common ancestor
         |
    D---E (main diverged here)
         \
          F---G---H (feature, 3 new commits)
               \
         main: I---J (2 new commits on main while you worked)

The result of the merge is a new commit K that has both J and H as parents. The history is recorded faithfully: these two lines of work existed in parallel, and at this point they were integrated.


Read First, Merge Later

The most expensive mistake I’ve seen in teams: merging without knowing what’s going to happen. The command takes two words. The review takes three minutes. The three minutes save hours.

The reading flow before any merge:

# Step 1: Bring the current state from the remote
git fetch origin

# Step 2: See the full tree — where each branch stands
git log --oneline --graph --all --decorate

# Step 3: See what your branch has that main doesn't
git log main..feature --oneline

# Step 4: See exactly what changes feature brings
git diff main...feature

Those four commands give you a complete picture before running the merge. How many commits the branch brings. Which files. Which lines changed.

The difference between main..feature (two dots) and main...feature (three dots) matters here. Two dots shows commits that are in feature but not in main. Three dots in the context of git diff shows the changes from the common ancestor — what feature did differently since it branched off. That last one is what you want when reviewing before a merge.

After reading, and only after reading, you decide the type of merge. Not all merges are the same.


The Three Types of Merge and When to Use Each

Git has three merge strategies worth knowing. Every team and project has its own rules, but here’s the framework for deciding.

Fast-forward is the simplest merge. It happens when main didn’t advance while you were working on feature. Git simply moves the main pointer to the last commit of feature. No merge commit is created. The history stays linear.

# Explicit: if fast-forward isn't possible, fail rather than create a merge commit
git merge --ff-only feature

Use it when: your branch is short, personal, and main didn’t move. A quick fix, a typo correction, single-developer changes.

Merge commit (--no-ff) creates a merge commit even when fast-forward is possible. It preserves the context that there was a separate branch. The history clearly shows “this was an independent feature that got integrated here.”

# Always create a merge commit, even if fast-forward is possible
git merge --no-ff feature -m "feat: integrate Stripe payments system"

Use it when: the feature has value as an identifiable unit of work. You want the history to reflect parallel work. This is the correct mode for feature branches in Gitflow.

Squash merge flattens all the feature commits into one before integrating them. It doesn’t create a merge commit. The main history stays clean, as if the entire feature was a single commit.

git merge --squash feature
# Squash doesn't auto-commit, you do it
git commit -m "feat(payments): implement complete Stripe checkout flow"

Use it when: the branch has “work in progress” commits not worth preserving. The main history is sacred and must be readable. GitHub calls this “Squash and merge” in its PR interface.

The most common mistake I see: using fast-forward for everything on a team of 8 developers. The history becomes completely linear and you lose the context of what was worked on in parallel with what. When something fails in production two weeks later, running git bisect over a linear history of 200 commits is a nightmare.


How to Read History Like a Git Master

A dog programmer saying "I have no idea what I'm doing"

That GIF is every developer looking at git log for the first time without extra flags. A wall of hashes and messages with no visible structure.

The antidote is this command. Memorize it:

git log --oneline --graph --all --decorate

What each part does:

  • --oneline: one line per commit, abbreviated hash + message
  • --graph: draws the branch tree with ASCII art on the left
  • --all: shows all branches, not just the current one
  • --decorate: shows branch and tag names on each commit

The output looks like this:

* a3f2c1d (HEAD -> main, origin/main) feat: add payments endpoint
*   b7e91a2 Merge branch 'feature/auth'
|\
| * c4d83b1 feat(auth): implement refresh tokens
| * 2a91f8e feat(auth): add JWT validation middleware
|/
* e6c74d3 fix: correct user search query
* 1b892c0 feat: add pagination in product listing

Now you can see the real tree. Which branch comes from where. Where merges happened. What’s on origin/main vs your local main.

To go deeper, git log has additional flags that insiders use:

# See every commit's changes (full diff)
git log -p

# See only which files changed and how many lines
git log --stat

# See the commit history of a specific file
git log --follow -p src/handlers/payments.go

# See who modified which line of a file and in which commit
git blame -L 45,60 src/handlers/payments.go

# See commits between two points
git log v1.2.0..v1.3.0 --oneline

git blame has a bad reputation but it’s a tool of understanding, not blame. When you find a piece of code you don’t understand, git blame tells you which commit introduced it. That commit has a message. That message (if the team writes good commits) tells you the why.


The Art of Reading Diffs

The diff is the heart of everything in Git. Before merges, after merges, during code review. The developer who reads diffs well reads code well.

The three most common scenarios:

# What did I change since the last commit?
git diff HEAD

# What's staged and ready to commit?
git diff --staged

# What's different between two branches?
git diff main..feature

# What changed in a specific commit?
git show a3f2c1d

# Which files changed between two commits? (without the content)
git diff --name-only HEAD~5 HEAD

# How many lines changed in each file?
git diff --stat main..feature

The git diff output format looks intimidating at first but becomes natural fast:

diff --git a/src/handlers/payments.go b/src/handlers/payments.go
index 3b4a91f..7c2d8e1 100644
--- a/src/handlers/payments.go
+++ b/src/handlers/payments.go
@@ -45,8 +45,12 @@ func ProcessPayment(w http.ResponseWriter, r *http.Request) {
     if err := validateAmount(req.Amount); err != nil {
         http.Error(w, err.Error(), http.StatusBadRequest)
         return
     }
+
+    if req.Currency == "" {
+        req.Currency = "USD"
+    }
+
     result, err := stripe.Charge(req)

Lines with + are what was added. Lines with - are what was removed. The @@ shows which line in the file you’re at. Lines without a prefix are unchanged context.

With git show you can inspect any commit in detail, including its full diff:

# Inspect a specific commit
git show a3f2c1d

# Show only the message without the diff
git show a3f2c1d --stat

# See the state of a file at a specific commit
git show main:src/handlers/payments.go

That last command is one of the most underrated in Git. When you need to compare your current version to how a file looked three commits ago without doing a checkout, git show is the answer.


Merge Conflicts: Not the End of the World

The "This is Fine" dog sitting in a room on fire

Merge conflicts trigger this reaction in most developers. But a conflict isn’t a Git error. It’s Git saying: “both branches touched the same place in different ways, and I’m not the one who should decide which version is correct. That’s your call.”

When you have a conflict, the file looks like this:

<<<<<<< HEAD
    return c.ValidateCard(card, user.Region)
=======
    return c.ValidateCard(card, user.Country)
>>>>>>> feature/payment-refactor

Everything between <<<<<<< HEAD and ======= is what your current branch has. Everything between ======= and >>>>>>> feature/payment-refactor is what the branch you’re integrating brings. You decide what stays, edit the file, and delete the markers.

The resolution can be keeping one of the two, combining them, or writing something entirely new that accounts for both changes:

// Option 1: keep HEAD
return c.ValidateCard(card, user.Region)

// Option 2: keep the feature branch
return c.ValidateCard(card, user.Country)

// Option 3: combine them with new logic
field := user.Country
if field == "" {
    field = user.Region
}
return c.ValidateCard(card, field)

To make this more manageable, configure the diff3 conflict style that also shows the common ancestor:

git config --global merge.conflictStyle diff3

With diff3, the conflict shows three versions: your version, the original before either side touched the code, and the other branch’s version. Having the original visible makes it much easier to understand what each side changed.

The resolution flow:

# See all files with conflicts
git status

# After manually resolving each file:
git add src/handlers/payments.go

# When all conflicts are resolved:
git merge --continue
# or simply:
git commit

If at any point you decide the merge went wrong and you need to go back:

# Abort the merge and return to the state before it started
git merge --abort

git merge --abort is your safety net. There is no conflict situation you can’t exit with this command. Use it without guilt when you need to think more carefully.


Rebase: The Double-Edged Weapon

Two Spider-Men pointing at each other

The eternal debate in any software team: merge or rebase? Both produce the same final code result, but with completely different histories. And in Git, the history is the product.

git rebase main takes the commits on your branch and replays them on top of the latest commit on main. Instead of a merge commit with two parents, the history looks as if you’d always been working on the most recent version of main.

Before rebase:
main:    D---E---F---G
              \
feature:       A---B---C

After git rebase main from feature:
main:    D---E---F---G
                      \
feature:               A'--B'--C'

The commits A, B, C became A', B', C' — same changes, different hashes, new position in the tree. The history stays linear and clean.

The golden rule of rebase, without exceptions: never rebase branches that other developers have on their machines.

When you rebase, the existing commits disappear and new ones are created with different hashes. If your teammate has feature with the original commits A, B, C, and you rebased producing A', B', C', when they push or pull, Git will see two divergent histories of the same work. The result is chaos.

Rebase is safe in exactly one scenario: on your own local branch, before pushing for the first time or before opening a PR.

To clean up your branch history before a PR, interactive rebase is the right tool:

# Interactive rebase on the last 4 commits
git rebase -i HEAD~4

This opens an editor with the commit list:

pick a1b2c3 wip: starting validation
pick d4e5f6 fix: typo fix
pick g7h8i9 wip: continuing validation
pick j0k1l2 feat(payments): complete card validation

# Commands:
# pick = use the commit
# squash = combine with previous commit
# reword = change the message
# drop = remove the commit

You change pick to squash on the “wip” commits, save, and Git combines them into one. The result is a clean history before anyone else sees it.


The Git Master’s Daily Workflow

A git master doesn’t have secret commands. They have habits. Here’s the real workflow:

Starting the day:

# Bring current state without modifying your branch
git fetch origin

# See what moved overnight
git log --oneline --graph origin/main..origin/HEAD

# If your main branch is behind:
git pull --rebase origin main

git pull --rebase instead of git pull avoids the automatic merge commits that Git creates when your local and remote branches diverged. The history stays cleaner.

Before opening a PR:

# Update your branch with the latest from main
git fetch origin
git rebase origin/main

# Review exactly what you're about to send
git diff origin/main..HEAD

# Verify your commit history is clean and descriptive
git log origin/main..HEAD --oneline

# If you need to clean up intermediate commits:
git rebase -i origin/main

Life-changing aliases. Add these to your ~/.gitconfig:

[alias]
    lg = log --oneline --graph --all --decorate
    st = status --short
    co = checkout
    br = branch -vv
    df = diff
    ds = diff --staged
    last = log -1 HEAD --stat
    undo = reset HEAD~1 --mixed

With these aliases, git lg gives you the full tree, git st gives a compact status, and git undo undoes the last commit without losing the changes (it returns them to the working directory).


The Commands Nobody Taught You

These four commands separate those who survive Git from those who master it.

git reflog — the command that saves careers. Every time you move HEAD in Git, the reflog records it. Deleting a branch, doing a reset, losing commits to a bad rebase — all recoverable with reflog.

# See everything Git remembered you did with HEAD
git reflog

# Recover commits "lost" by a bad reset
git reflog
# Find the hash of the commit you wanted
git checkout -b rescue a3f2c1d

git bisect — binary search through history. When you have a bug that you know didn’t exist three weeks ago but you don’t know which of the 200 commits in between introduced it, bisect does binary search through the history.

git bisect start
git bisect bad                  # current commit has the bug
git bisect good v2.3.0          # this version didn't have the bug

# Git checks out the middle commit
# You test whether the bug exists
git bisect bad  # or git bisect good

# Git checks out the next middle commit
# Repeat until bisect finds the exact commit that introduced the bug
git bisect reset  # when done

Bisect can find the guilty commit in 7 steps over a history of 128 commits. Without it, that’s 128 manual inspections.

git stash — the “I’ll finish this later” drawer. When you need to switch branches urgently but have work in progress:

# Save work in progress
git stash push -m "wip: card validation unfinished"

# Switch branches, handle the urgent thing, come back
git checkout main
# ... urgent work ...
git checkout feature/payments

# Recover your work
git stash pop
# or if you have multiple stashes:
git stash list
git stash apply stash@{2}

git cherry-pick — bring a specific commit from another branch without merging the whole branch. Useful when a security fix you made in develop needs to go to production before the full feature is ready.

# Bring only the security fix commit to the current branch
git cherry-pick a3f2c1d

# Bring multiple commits
git cherry-pick a3f2c1d b7e91a2 c4d83b1

# Bring a range of commits
git cherry-pick v2.3.0..v2.3.2

Use cherry-pick sparingly. It duplicates commits in history. If you’re using cherry-pick frequently, it’s a signal that your branching strategy needs a review.


The Alias That Closes the Loop

Before you go, one last alias I use on every project. Add it to yours:

[alias]
    ready = !git fetch origin && git log --oneline --graph origin/main..HEAD && git diff --stat origin/main

git ready before any merge or PR: it fetches the current state, shows you which commits you have that main doesn’t, and shows you a summary of changed files. Three seconds of context that prevent ten minutes of surprises.

A senior developer doesn’t press the merge button with more courage. They press it with more information.


Git doesn’t save code. It saves decisions. Every commit is a documented decision, and every merge is the moment where two lines of decisions become one.