Chapter 13

Git and GitHub

Commits, branches, merging, rebasing, pull requests and collaboration.

# 13. Git and GitHub

Git is the most important tool in modern software delivery because it turns a project into a system of reproducible snapshots, reviewable changes, and safe collaboration. GitHub adds a shared platform for hosting repositories, discussing changes, and enforcing quality checks. If you want to be effective in a production engineering environment, you need to understand what Git is actually doing beneath the commit command, how to structure branches, and how to recover from mistakes without damaging team trust.

This chapter explains Git's internals, workflow choices, collaboration patterns, and recovery tools. By the end, you will understand how Git stores content, why commits have hashes and parent relationships, how to structure a branch strategy, and how to recover from common mistakes without rewriting the team’s shared history.

Overview and Learning Objectives

By the end of this chapter, you will be able to:

  1. Explain Git objects: blobs, trees, commits, and refs.
  2. Write atomic commits and follow Conventional Commits conventions.
  3. Compare Git Flow and GitHub Flow and choose a practical workflow.
  4. Use merge, rebase, squash, and interactive rebase safely.
  5. Resolve conflicts while preserving intent and minimizing risk.
  6. Review pull requests and configure branch protection for production teams.
  7. Recover from mistakes using reflog, reset, revert, and cherry-pick.

Git as a Content-Addressable Database

At a high level, Git is not just a file history tool. It is a content-addressable database. Every file version, directory snapshot, and commit is stored as an object keyed by a hash. This is why Git can be fast, safe, and resilient: the same content produces the same object ID, and object IDs are derived from content.

The central idea is simple:

  • A blob stores file content.
  • A tree stores directory entries and references to blobs or subtrees.
  • A commit stores metadata and a pointer to a tree, plus parent commit IDs.
  • A ref stores a name that points to a commit, such as main or HEAD.

This model explains why Git feels so different from systems that store file deltas or change lists. Git stores snapshots, not only patches. The repository’s history is a chain of states that can be examined, compared, and reconstructed.

Blobs: file content

A blob is the object behind a file’s raw content. It does not contain file names or timestamps. A file named src/server.js and a file named README.md can each become a blob, but the blob is keyed by content, not by path.

bash
git hash-object README.md

This command gives the hash of the file content as Git would store it. If you change the file slightly, the hash changes. This is one reason Git is efficient for large repositories: identical content can be reused across commits and branches.

Trees: directory snapshots

A tree object stores a list of entries: file names, modes, types, and references to blobs or subtrees. It acts like a directory listing. A commit points to a tree, and that tree points to the files that were in the repository at that time.

You can inspect a commit graph with:

bash
git cat-file -p HEAD

That reveals the commit object: author, committer, commit message, tree hash, and parent commit hash. If you inspect the tree hash, you can see the files represented at that snapshot.

Commits: snapshots with metadata

A commit is more than a snapshot. It is a node in a graph. Every commit has a hash derived from:

  • the tree snapshot
  • the parent commit hash
  • author and committer metadata
  • the commit message
  • timestamps and other metadata

This gives Git the ability to reconstruct history and reason about merges, rebases, and branches. Each commit is immutable in the sense that changing content changes the hash. Git history is append-only in practice, with new commits created on top of existing ones, and older commits can be replaced only by rewriting history locally.

Refs: branch names and tags

A ref is a pointer to a commit. Branch names like main, feature/login, and release/1.2 are refs. Tags are also refs, usually immutable labels for versions or releases. HEAD is a symbolic reference that points to the current branch or a detached commit.

bash
git show-ref

This lists all refs and their commit IDs. When you run git checkout main, Git updates HEAD to point to the main ref. When you create a new commit, Git updates the current branch ref to the new commit.

Why this matters in production

Understanding objects matters because it explains why Git is reliable in real development culture:

  • content deduplication prevents repeated storage of identical files
  • branch and tag names are lightweight pointers, not copies of files
  • commit hashes are deterministic given the same content and metadata
  • history can be traversed, inspected, and recovered even after partial failures

This mental model also helps when debugging “why is my repo in a weird state?” The answer often lies in seeing whether a branch pointer, a detached HEAD, or a corrupted object store is involved.

Atomic Commits and Conventional Commits

A good commit is small, coherent, and understandable. An atomic commit contains a single change with a clear reason. It should not mix unrelated refactors, formatting changes, and feature work in one patch. A team can review the commit history more confidently when each commit expresses one decision.

Atomicity is not about being tiny for its own sake. It is about making the repository easy to reason about. A bug fix should ideally appear in a commit that says which issue it addresses and why. A refactor should be separate if it changes structure but not behavior. A formatting sweep should usually be isolated from logic changes to reduce confusion during review or revert.

Commit messages that help the next engineer

Good commit messages answer two questions:

  1. What changed?
  2. Why did it change?

A message like “Fix login redirect” is weaker than “Fix redirect loop after successful OAuth callback.” The second tells you the bug, the scenario, and the technical context. Commit messages are not just for history; they are a communication tool for the next developer, a reviewer, or the incident responder during an outage.

Conventional Commits

Conventional Commits is a popular convention for writing structured messages. It provides a standard format:

text
<type>(<scope>): <subject>

<body>

<footer>

Common types include:

  • feat: new user-facing or product functionality
  • fix: bug fix
  • docs: documentation only
  • refactor: code restructuring without behavior change
  • test: tests added or corrected
  • chore: tooling or maintenance work
  • perf: performance improvements
  • ci: CI pipeline changes

Examples:

text
feat(auth): add MFA challenge before account recovery
fix(api): prevent duplicate invoice creation on retry
refactor(ui): simplify filter state reducer
docs(readme): add release checklist for maintainers

This matters in production because many teams use it for release notes, automated changelog generation, and semantic versioning. A repository with consistent commit conventions can generate a readable changelog without manual interpretation.

Writing atomic commits in practice

Before committing, review the diff:

bash
git diff --staged
git diff --cached

A commit should usually be staged intentionally. Use partial staging to isolate work into logical units:

bash
git add -p

This is especially useful when you made a debugging change while exploring a bug and also cleaned up a comment or rearranged code. You can stage only the relevant fix and leave exploratory experiments for later discard or separate commits.

A classic example is a feature branch that includes both a database migration and frontend validation. If the migration introduces a schema issue, reviewers can understand the fix more easily if the migration and UI logic are separate commits. This makes revert decisions safer.

Atomic commits and deployment risk

Atomic commits don’t only improve readability. They reduce deployment risk. Suppose a deploy accidentally includes a commit that touches user permissions, adds telemetry, and renames a variable. Reverting that commit may undo more than intended. If the work was atomic and grouped sensibly, a team can selectively revert or cherry-pick a single fix.

In some codebases, a single PR may include many small commits. That is acceptable if the branch is intentionally stacked, but maintainers often prefer one well-structured PR with a coherent set of changes. The crucial principle is not “one commit per PR” but “one decision per commit.”

Branching Strategies: Git Flow vs GitHub Flow

Teams need a workflow that matches release velocity, risk tolerance, and operational complexity. Two common strategies are Git Flow and GitHub Flow. Both are valid, but they optimize for different environments.

Git Flow

Git Flow uses long-lived branches such as:

  • main for production
  • develop for integration
  • feature/* branches for features
  • release/* for release prep
  • hotfix/* for emergency production fixes

It is useful in projects with scheduled releases, multiple deployment environments, and a more rigid process. In a large enterprise team, release branches can help isolate a version before production cutover. The tradeoff is complexity: you need more branch hygiene, more merge coordination, and more release-management overhead.

A typical Git Flow path:

bash
git checkout develop
git checkout -b feature/payment-tokens
# work, commit, push
# open PR to develop
# after QA, merge to develop
# create release branch
# finalize release and merge to main

This approach is quite effective if there are formal release windows and multiple stages like staging, QA, and production.

GitHub Flow

GitHub Flow is simpler. The primary branch is usually main, and every change starts from main in a short-lived feature branch. You open a pull request to merge back to main. Deployments can happen continuously or with a release gate, depending on the team’s process.

bash
git checkout main
git pull --ff-only origin main
git checkout -b feature/checkout-validation
# work, commit, push
# open PR
# merge to main after reviews and checks

This model works well for product teams shipping frequently. Many modern SaaS teams prefer GitHub Flow because it reduces ceremony and aligns with continuous delivery. The branch never lives long, and the core branch stays deployable.

When to choose which

Use Git Flow when:

  • you release on a schedule
  • multiple versions are supported concurrently
  • multiple environments require careful cutover
  • release coordination is a real operational concern

Use GitHub Flow when:

  • shipping continuously is the norm
  • small PRs are expected
  • automated checks are reliable and fast
  • the team needs a lighter-weight workflow

Merge, Rebase, and Interactive Rebase

Git offers multiple ways to integrate branches. Merge and rebase both produce a correct final history, but they create different narratives.

Merge commits

A merge commit preserves the branch structure and the fact that two histories were combined. This is often easier for teams who want context about where a feature branch was incorporated.

bash
git checkout main
git merge feature/login-form

This creates a merge commit if the histories diverged. This history is explicit and easy to inspect later, especially when debugging a regression. The downside is that a repository can become visually noisy, particularly if many feature branches are merged in a row.

Rebase

Rebase rewrites a branch onto another base commit by replaying commits. It creates a linear history, which many teams find easier to read.

bash
git checkout feature/login-form
git rebase main

This is useful when you want your feature branch to include the latest upstream changes without an explicit merge commit. However, rebasing rewrites commit hashes. It is safe only when the branch is not shared publicly or when all collaborators understand that the branch will be force-pushed. Rebase is especially useful before opening a PR for a clean commit history.

Interactive rebase and squash

Interactive rebase lets you reorganize commits before merging. You can:

  • reorder commits
  • edit a commit message
  • split a commit
  • squash multiple commits into one
  • drop debugging commits
bash
git rebase -i HEAD~5

In the editor, you may see lines like:

text
pick a1b2c3d Add validation
pick f4e5d6a Fix typo
pick 9f8e7d6 Add loading state

You can change pick to squash or fixup to combine commits into a cleaner narrative. Squashing is common when a feature PR contains several small commits that are not useful to preserve individually.

This is helpful in production because maintainers prefer a clean, reviewable final history. A PR with 12 tiny “WIP” commits can be hard to review, while a clean set of 3 carefully named commits communicates the actual story of the change.

Rebase caution and safety

Rebase is powerful but can be dangerous if misunderstood. You should not rebase a branch that another developer has already pulled and built on. The key rule is:

  • rebase private branches before sharing
  • rebase shared branches only with coordination
  • avoid force-pushing a branch that others expect to remain stable

If a branch has already been pushed to a repository used by others, a rebase followed by a force push can cause duplicated work or confusion. Use git fetch, inspect the status, and coordinate before rewriting a shared branch.

Conflict Resolution and Merge Discipline

Conflicts are a normal part of collaborative Git. A conflict is not a failure; it is Git telling you that two changes touched the same location and a human must decide which semantics to keep.

Practical conflict flow

When Git stops with a conflict:

bash
git status

You will see files marked as unmerged. Open them and inspect conflict markers:

text
<<<<<<< HEAD
const interval = 60;
=======
const interval = 30;
>>>>>>> feature/timeout

The goal is not to “accept one side blindly”. The goal is to integrate the correct combined outcome. In a real production conflict, decide:

  • Which change is still correct?
  • Are both changes necessary?
  • Should this be refactored to preserve both behaviors?
  • Does the new code need tests added to cover the merged outcome?

Common conflict patterns

  1. Same line changed in two branches.
    • often straightforward; decide which logic is correct or combine them.
  2. Same function renamed differently.
    • inspect both versions and maintain consistent naming.
  3. One branch deleted a file while another edited it.
    • decide whether the file should persist, be recreated, or be removed after update.
  4. Dependency or config drift.
    • merge carefully and verify the build or runtime configuration.

Pull Requests, GitHub Review, and Branch Protection

GitHub pull requests are not just “a place to merge code.” They are a workflow for review, automation, and safe integration. A PR creates a focused discussion around the change before it enters the main branch.

Why review matters

A good review asks questions such as:

  • Does the code match the stated intent?
  • Are edge cases covered?
  • Is the change consistent with the surrounding architecture?
  • Are tests adequate?
  • Will this be maintainable six months from now?

Review is especially critical in production because code doesn’t just execute once; it becomes a dependency for future work. Most defects are not introduced by blatant mistakes alone but by unreviewed assumptions, missing error handling, and mismatched understanding between contributors.

Best practices for PR review

  • Keep PRs narrow and coherent.
  • Include a clear summary and rationale.
  • Ask for tests or explicit validation performed.
  • Review behavior, not just style.
  • Prefer discussion in the PR over an ad-hoc Slack thread that people will forget.

GitHub’s review tools support comments, approval requests, requested changes, and conversations attached to a specific line. This makes it easier to review the exact logic involved rather than broad file-level statements.

Branch protection

Branch protection rules protect the main branch from accidental or careless merges. Common settings include:

  • require pull request reviews before merging
  • require status checks to pass
  • dismiss stale reviews when new commits are pushed
  • require branches to be up to date before merging
  • restrict who can push directly to the protected branch
  • require linear history or enforced merge strategy

This does more than enforce process; it reduces operational disruption. If a deployment pipeline or test suite is required before merge, the team can avoid shipping broken changes to the trunk. Branch protection is particularly important in production repositories where the main branch is often a deployment target.

A protected branch is not a bureaucratic obstacle. It is a safety barrier that aligns the repository with the team’s real delivery requirements.

Recovery: Reflog, Reset, Revert, and Cherry-Pick

Even experienced developers make mistakes. Git provides several recovery mechanisms, each suited to a different problem.

Reflog: the safety net

The reflog records branch and HEAD movements. It is one of the most valuable recovery tools because it shows recent actions such as commit, merge, rebase, and checkout events.

bash
git reflog

This is often the first place to look after an accidental reset or a mistaken checkout. Suppose you reset a branch and want to recover a lost commit. The reflog shows the previous HEAD location. You can reference the old commit by its hash:

bash
git checkout <old-sha>

or:

bash
git branch recovery/<feature-name> <old-sha>

This is how many lost commits are recovered in real teams.

Reset vs revert

git reset moves the current branch pointer. It can be used to move a branch back to an earlier commit. This is rewriting history and is appropriate only for local, unpublished changes or a well-coordinated branch rewrite.

bash
git reset --hard HEAD~1

This discards the most recent commit from the current branch.

git revert creates a new commit that undoes the changes introduced by a previous commit. It preserves history and is the safe choice for shared public branches.

bash
git revert HEAD

This is often used in production when a release commit needs to be undone without rewriting the branch history. If a team has already merged a breaking commit into main, revert is the safer path than reset because it preserves the integrity of shared history.

Cherry-pick

Cherry-pick takes a commit from one branch and applies it to another. It is useful when you want a specific fix from one branch without merging the entire workstream.

bash
git checkout main
git cherry-pick <commit-sha>

This is common when a hotfix is developed on a release branch and then ported back to main or to another maintenance branch. It is also useful when a fix needs to be applied to multiple long-lived release lines.

Be careful: cherry-pick creates a new commit with a different hash, even if the patch is the same. That is intentional. It avoids accidental sharing of the exact same commit identity across independent histories.

Recovery mindset

The best recovery strategy is to avoid panic. Use git status, git log --oneline --graph --decorate --all, and git reflog before making destructive commands. A rushed reset or a force-push to a shared branch can create a lot of expensive cleanup work. When a branch is shared, prefer revert and communication over destructive rewriting.

Production teams often create runbooks around this exact issue: “What do we do when a deploy includes a bad commit?” The answer is usually not “rewrite history”; it is “identify the commit, revert it intentionally, verify the fix, and document the incident.”

Debugging Git Workflows

Git issues usually fall into a few recurring categories:

  • accidental commit to the wrong branch
  • divergence after rebasing without coordination
  • merge conflicts after long-lived branches drift apart
  • hidden state from a detached HEAD or stale local branch
  • failure to push because the remote branch has moved ahead

Useful commands include:

bash
git status
git log --oneline --graph --decorate --all
git diff origin/main...HEAD
git fetch --all --prune
git branch -vv

A common production problem is a branch that has not been rebased onto the latest main before merge. This leads to difficult conflict resolution and stale CI results. The fix is often simple: update the base branch, rebase locally, and then push. But do this only with branch coordination rules in place.

Another common review failure is a developer who pushes a “cleanup” commit mixed into a feature commit. The fix is usually to rebase interactively and split or squash changes, then force-push the branch only if it is not shared. This is a discipline issue, not a Git bug.

Hands-On Exercises

Exercise 1: Build a branch and fix a real conflict

Setup a small repo with two branches, main and feature/price-rules, then edit the same file in both branches and merge.

bash
git init demo-git
cd demo-git
git checkout -b main
printf 'price = 25\n' > pricing.txt
git add pricing.txt
git commit -m "feat: add base price"

git checkout -b feature/price-rules
printf 'price = 35\n' > pricing.txt
git commit -am "feat: set pricing for new plan"

git checkout main
printf 'price = 30\n' > pricing.txt
git commit -am "feat: adjust baseline pricing"

git merge feature/price-rules

You will get a conflict. Resolve it intentionally by deciding the final price rule, stage the result, and commit the merge.

Solution

Open the file and look at the conflict markers. Suppose the business requirement is that the baseline price should be 30, but the feature branch’s price needs to stay on 35 for the new plan. A correct resolution may be to introduce a clear rule, like:

text
base_price = 30
plan_price = 35

or, if the feature branch is meant to override the default, the final file could preserve the feature branch value while showing the main branch as a fallback. In a production scenario, the correct resolution depends on the product rule.

A realistic resolved file might be:

text
base_price = 30
plan_price = 35

Then:

bash
git add pricing.txt
git commit -m "Merge branch 'feature/price-rules'"

The important lesson is that conflict resolution is a semantic decision, not a mechanical one.

Exercise 2: Recover a lost commit and structure a clean PR

Create a small feature branch, make several commits, then accidentally reset the branch and recover using git reflog. After recovery, use interactive rebase to squash the branch into a clean history.

bash
git checkout -b feature/checkout-flow
git commit --allow-empty -m "feat: add checkout scaffold"
git commit --allow-empty -m "fix: validate address"
git commit --allow-empty -m "fix: validate payment"
git log --oneline --decorate --max-count=5

git reset --hard HEAD~2
git reflog

Use the reflog to recover the lost commit and then squash the branch into a single clean commit with a Conventional Commit message.

Solution

The reflog shows the branch movement. Recover the commit by pointing the branch back to the earlier SHA:

bash
git reset --hard <sha-from-reflog>

Then rewrite the feature branch into a clean history:

bash
git rebase -i HEAD~3

Change the second and third commits to squash and keep a commit message like:

text
feat(checkout): add checkout validation flow

The final log should show a single clean feature commit, which is much easier to review and merge. This demonstrates the difference between a destructive local reset and a safe, coordinated rewrite when the branch is still private.

Summary

Git is not only a version-tracking tool; it is a structured system for safe collaboration. Understanding blobs, trees, commits, and refs makes the repository model tangible. Writing atomic commits and using Conventional Commits makes history readable and reviewable. Choosing a workflow such as Git Flow or GitHub Flow should reflect the real shipping cadence and deployment requirements of the team. Merge, rebase, and cherry-pick are each useful; the key is using them with the right safety boundaries.

Review and branch protection are not administrative overhead. They are quality controls that reduce risk in production. Recovery tools like reflog, revert, and reset each solve specific problems, and the right choice depends on whether the history is local, shared, or already in production. A team that understands Git deeply is more resilient because it can move faster without losing clarity or control.