Git & Collaboration Workflows

Branching & Merging Deeply

18 min Lesson 2 of 28

Branching & Merging Deeply

In the previous lesson you learned how Git stores data as a directed acyclic graph of objects. Now we exploit that structure to understand what a branch really is, why Git can create one in microseconds, and what happens under the hood during a merge or a conflict. At Google and Meta, dozens of engineers push to the same repository every minute — every one of them depends on the mechanics covered here.

Branches Are Cheap Pointers

A branch is nothing but a file in .git/refs/heads/ containing a 40-character SHA-1 (now increasingly SHA-256) that points to a commit object. Creating a branch does not copy files; it writes 41 bytes to disk.

# Show what a branch actually is on disk cat .git/refs/heads/main # → e4a3f2c1d9b8a7c6e5f4d3c2b1a09876543210ab # Create a branch — instantaneous, no copy of files git branch feature/auth-service # HEAD points to the current branch pointer cat .git/HEAD # → ref: refs/heads/main # Switch (HEAD now points to the new branch) git switch feature/auth-service cat .git/HEAD # → ref: refs/heads/feature/auth-service

Every time you make a commit, Git advances the current branch pointer to the new commit. HEAD follows along, always pointing to whatever the current branch pointer points to — unless you are in a detached HEAD state (HEAD points directly to a commit, not to a branch file).

Branches as pointers to commits C1 Initial commit C2 Add router C3 Auth service C4 Fix logging feature/auth-service main HEAD
Branches are 41-byte pointer files. main and feature/auth-service each point to their latest commit; HEAD points to the active branch.

Fast-Forward Merge

When the target branch (e.g., main) has not diverged from the branch being merged in — meaning every commit on main is already an ancestor of the feature branch — Git simply advances the pointer. No merge commit is created. This is a fast-forward.

# Scenario: main is at C2, feature/auth-service is at C3 # C3's parent is C2 — main can fast-forward git switch main git merge feature/auth-service # Output: Fast-forward # auth.go | 42 ++++++++++ # 1 file changed, 42 insertions(+) # Force a real merge commit even when FF is possible: git merge --no-ff feature/auth-service -m "Merge feature/auth-service into main"
When to use --no-ff: Many teams (and most GitHub/GitLab merge-button defaults) enforce --no-ff or "merge commits" so that every feature integration is visible in git log --graph as a discrete event. This is invaluable in incident retrospectives: "When did auth-service land on main?" has a clear, searchable answer.

Three-Way Merge & Merge Commits

When both branches have diverged, Git cannot fast-forward. Instead it finds the merge base — the most recent common ancestor of both tips — and applies a three-way merge algorithm comparing: merge base, our branch tip, their branch tip. If both sides changed the same lines, you get a conflict.

Three-way merge creating a merge commit C2 (base) Merge base C4 (main) Fix logging C3 (feature) Auth service C5 (merge) Two parents Three-way merge compares all three main
Three-way merge: Git finds the common ancestor (C2), compares changes from both branches, and creates a new merge commit (C5) with two parents.

Resolving Conflicts

A conflict means Git cannot automatically reconcile two sets of changes to the same region of the same file. Git writes conflict markers into the file and halts:

<<<<<<< HEAD return authenticate(user, password, mfa_required=True) ======= return authenticate(user, password, timeout=30) >>>>>>> feature/auth-service

HEAD is your current branch (what you have). The section after ======= is what is coming in. Your job is to produce the correct merged result — often combining both changes:

# 1. Open the conflicted file and edit it to the correct state, e.g.: # return authenticate(user, password, mfa_required=True, timeout=30) # 2. Stage the resolved file git add src/auth.py # 3. Complete the merge git commit # Git pre-fills the commit message; accept or annotate it. # To abort a merge mid-way (restores pre-merge state): git merge --abort # Use a three-pane merge tool (configured below): git mergetool
Configure a merge tool once, use it forever. At Google, engineers typically configure vimdiff, meld, or an IDE integration so that git mergetool opens an intuitive three-pane view (LOCAL / BASE / REMOTE → MERGED). Set yours with git config --global merge.tool vimdiff and git config --global mergetool.keepBackup false.

Common Failure Modes in Production

  • Long-lived branches accumulate conflicts. A feature branch open for two weeks against a busy main can accumulate hundreds of conflicting hunks. The fix is frequent integration — merge or rebase from main every day, not before the PR is due.
  • Octopus merges gone wrong. git merge branchA branchB branchC performs an octopus merge (one commit, multiple parents). Git refuses if there are conflicts; use sequential merges instead.
  • Binary file conflicts. Git cannot three-way-merge a PNG or a compiled binary. Adopt Git LFS and set merge strategies (*.png merge=ours in .gitattributes) to always keep one side.
  • Forgetting to delete merged branches. Repositories with thousands of stale branches become slow. Enforce automated branch deletion: GitHub has "Delete branch on merge"; GitLab has the same toggle. Add a cron that prunes remote tracking refs: git fetch --prune.
Never force-push a shared branch after a merge. If you merged a colleague's branch into yours, then someone force-pushes, the merge commit and its parents get rewritten. Teammates who already pulled will diverge. Treat any branch that two people have cloned as immutable — use git revert to undo, not git push --force.

Merge Strategies at Scale

Git supports pluggable merge strategies passed with -s. The default is ort (Ostensibly Recursive's Twin — introduced in Git 2.34, now the default, faster and more correct than the old recursive). For binary conflicts or generated files, -s ours keeps your version wholesale. The -X flag passes strategy options: -X ours (with the default strategy) auto-resolves conflicts by preferring your side, useful when merging a release branch back into a fast-moving main.

# Prefer "ours" side whenever there is a conflict (still does a real merge) git merge -X ours release/v2.1 # Keep the entire current branch wholesale — the other branch history is recorded # but none of its file changes are applied: git merge -s ours hotfix/legacy # Squash all commits from feature into a single un-committed change, then commit manually git merge --squash feature/data-pipeline git commit -m "feat: add data pipeline (squashed)"

Squash merges produce a linear history on main without the overhead of rebase, at the cost of losing granular per-commit authorship. Many companies (Shopify, Stripe) default to squash merges on their trunk precisely for bisectability — every commit on main is a complete, deployable unit.