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:
- Explain Git objects: blobs, trees, commits, and refs.
- Write atomic commits and follow Conventional Commits conventions.
- Compare Git Flow and GitHub Flow and choose a practical workflow.
- Use merge, rebase, squash, and interactive rebase safely.
- Resolve conflicts while preserving intent and minimizing risk.
- Review pull requests and configure branch protection for production teams.
- 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
mainorHEAD.
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.
git hash-object README.mdThis 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:
git cat-file -p HEADThat 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.
git show-refThis 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:
- What changed?
- 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:
<type>(<scope>): <subject>
<body>
<footer>Common types include:
feat: new user-facing or product functionalityfix: bug fixdocs: documentation onlyrefactor: code restructuring without behavior changetest: tests added or correctedchore: tooling or maintenance workperf: performance improvementsci: CI pipeline changes
Examples:
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 maintainersThis 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:
git diff --staged
git diff --cachedA commit should usually be staged intentionally. Use partial staging to isolate work into logical units:
git add -pThis 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:
mainfor productiondevelopfor integrationfeature/*branches for featuresrelease/*for release prephotfix/*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:
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 mainThis 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.
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 checksThis 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.
git checkout main
git merge feature/login-formThis 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.
git checkout feature/login-form
git rebase mainThis 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
git rebase -i HEAD~5In the editor, you may see lines like:
pick a1b2c3d Add validation
pick f4e5d6a Fix typo
pick 9f8e7d6 Add loading stateYou 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:
git statusYou will see files marked as unmerged. Open them and inspect conflict markers:
<<<<<<< HEAD
const interval = 60;
=======
const interval = 30;
>>>>>>> feature/timeoutThe 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
- Same line changed in two branches.
- often straightforward; decide which logic is correct or combine them.
- Same function renamed differently.
- inspect both versions and maintain consistent naming.
- One branch deleted a file while another edited it.
- decide whether the file should persist, be recreated, or be removed after update.
- 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.
git reflogThis 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:
git checkout <old-sha>or:
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.
git reset --hard HEAD~1This 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.
git revert HEADThis 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.
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:
git status
git log --oneline --graph --decorate --all
git diff origin/main...HEAD
git fetch --all --prune
git branch -vvA 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.
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-rulesYou 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:
base_price = 30
plan_price = 35or, 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:
base_price = 30
plan_price = 35Then:
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.
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 reflogUse 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:
git reset --hard <sha-from-reflog>Then rewrite the feature branch into a clean history:
git rebase -i HEAD~3Change the second and third commits to squash and keep a commit message like:
feat(checkout): add checkout validation flowThe 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.