Emmanuel Akinfulubi

Git, properly all 3 written

Not "add, commit, push" by memory. What a commit actually is, and how branching and review work.

8 What a commit actually is

A commit is not a diff, and a branch is not a copy of your files. Both are much simpler than that, and the two ideas explain nearly every "why did that happen" moment Git ever produces. This lesson proves it by looking directly at what Git stored, using the same commands Git uses internally.

A commit is a snapshot, addressed by the hash of its own content

Every object Git stores -- a file's content, a directory listing, a commit -- is identified by the SHA-1 hash of that content. Not a filename, not a sequential number: the actual bytes, hashed. Two files with identical content get the same hash, even in different commits, which is the whole reason Git can store history efficiently.

Content-addressed storage, proven with real hashes

ran at build time
work=$(mktemp -d)
cd "$work"
git init -q
git config user.name "Course"
git config user.email "course@example.com"

echo "version one" > notes.txt
git add notes.txt
git commit -q -m "first commit"

first_commit=$(git rev-parse HEAD)
blob_hash=$(git hash-object notes.txt)
echo "commit hash:  $first_commit"
echo "file's blob hash: $blob_hash"

# The hash is computed from CONTENT, not identity. Prove it: put the exact same
# content in a differently-named file, and git gives it the identical hash.
echo "version one" > notes-copy.txt
copy_hash=$(git hash-object notes-copy.txt)
echo "a different file, same content, hash: $copy_hash"

# Change one character and the hash changes completely -- not incrementally.
echo "version two" > notes.txt
changed_hash=$(git hash-object notes.txt)
echo "same file, one word changed, hash: $changed_hash"

check "identical content always hashes the same, regardless of filename" "$copy_hash" "$blob_hash"
check "different content always hashes differently" "$([ "$changed_hash" != "$blob_hash" ] && echo yes)" "yes"
check "a commit hash is a real 40-character SHA-1" "$(echo "$first_commit" | grep -Ec '^[0-9a-f]{40}$')" "1"

What it printed, when the build ran it

commit hash:  b0d7e41a48ed1fe498ebcf1b3cf8c6fdd563e5c9
file's blob hash: 9bc69cf340b5d476e7e3758dbdd4918e654dc8be
a different file, same content, hash: 9bc69cf340b5d476e7e3758dbdd4918e654dc8be
same file, one word changed, hash: 6702fe2da3140f13942b3ce09819f786acf558c1
ok   identical content always hashes the same, regardless of filename
ok   different content always hashes differently
ok   a commit hash is a real 40-character SHA-1

Looking inside a real commit with git's own plumbing

git commit is a convenience wrapper. Underneath, a commit object is just text: a pointer to a tree (the snapshot of the whole directory), a pointer to the previous commit (except the first one), an author, and a message. cat-file shows you that text directly, with nothing hidden.

cat-file: reading a commit's real, plain-text content

ran at build time
work=$(mktemp -d)
cd "$work"
git init -q
git config user.name "Course"
git config user.email "course@example.com"

echo "line one" > app.txt
git add app.txt
git commit -q -m "add app.txt"

echo "line two" >> app.txt
git add app.txt
git commit -q -m "append a second line"

second_commit=$(git rev-parse HEAD)
echo "the second commit's raw content:"
git cat-file -p "$second_commit"

first_parent=$(git cat-file -p "$second_commit" | grep '^parent' | awk '{print $2}')
first_commit=$(git rev-parse HEAD~1)
echo
echo "does the commit's own recorded parent match the actual first commit?"
echo "  recorded parent: $first_parent"
echo "  actual first commit: $first_commit"

check "the commit object records its parent explicitly" "$first_parent" "$first_commit"
check "the commit object contains the message we gave it" \
  "$(git cat-file -p "$second_commit" | grep -c 'append a second line')" "1"

What it printed, when the build ran it

the second commit's raw content:
tree abd193168f596fbe79f1afb2a6b16b354c4f1088
parent 2c134c708890928a25f6f7bcefffadb7fb7b3000
author Course <course@example.com> 1789503430 +0100
committer Course <course@example.com> 1789503430 +0100

append a second line

does the commit's own recorded parent match the actual first commit?
  recorded parent: 2c134c708890928a25f6f7bcefffadb7fb7b3000
  actual first commit: 2c134c708890928a25f6f7bcefffadb7fb7b3000
ok   the commit object records its parent explicitly
ok   the commit object contains the message we gave it

This is why "history" is a chain, not a list. Each commit stores one parent pointer (or two, for a merge). Walking backwards from any commit by following parent is literally what git log does -- there is no separate "history" data structure, just commits pointing at the commit before them.

A branch is a movable label, not a copy

This is the single most common misconception, and the cause of most "I thought my other branch was safe" moments. A branch is a 41-byte text file containing one commit hash. Nothing is copied when you create one; a label just starts pointing somewhere.

A branch is a 41-byte file, not a copy

ran at build time
work=$(mktemp -d)
cd "$work"
git init -q
git config user.name "Course"
git config user.email "course@example.com"

echo "main content" > file.txt
git add file.txt
git commit -q -m "initial commit"
main_commit=$(git rev-parse HEAD)

# What "creating a branch" actually is: a new small file under .git/refs/heads,
# containing exactly one line -- the commit hash it points at.
git branch feature
branch_file="$work/.git/refs/heads/feature"
echo "the ENTIRE content of the 'feature' branch's ref file:"
cat "$branch_file"

branch_points_at=$(cat "$branch_file")
echo
echo "does that match the current commit? $branch_points_at vs $main_commit"

check "the branch file is tiny -- just a hash, nothing copied" \
  "$(wc -c < "$branch_file" | tr -d ' ')" "41"
check "a new branch points at the exact commit it was created from" "$branch_points_at" "$main_commit"

# Now prove branches are independent labels: commit on main, and confirm the
# feature branch's file has not moved.
echo "changed on main" > file.txt
git add file.txt
git commit -q -m "a commit only main should see"

branch_points_at_after=$(cat "$branch_file")
echo "after a NEW commit on main, feature's ref file still says: $branch_points_at_after"

check "creating a commit on one branch never moves another branch's pointer" \
  "$branch_points_at_after" "$main_commit"

What it printed, when the build ran it

the ENTIRE content of the 'feature' branch's ref file:
c389568689f5b115808605872770ff1bd14ae7b0

does that match the current commit? c389568689f5b115808605872770ff1bd14ae7b0 vs c389568689f5b115808605872770ff1bd14ae7b0
ok   the branch file is tiny -- just a hash, nothing copied
ok   a new branch points at the exact commit it was created from
after a NEW commit on main, feature's ref file still says: c389568689f5b115808605872770ff1bd14ae7b0
ok   creating a commit on one branch never moves another branch's pointer

What this explains. "Switching branches" (git switch or git checkout) is Git rewriting your working files to match whatever commit the label currently points at -- fast, because nothing was duplicated to get there in the first place. And a branch with no commits ahead of another is not "empty," it is the same commit, under a different name.

HEAD: where you are, not what you are on

HEAD is one more small file, and it usually contains not a hash but a pointer to a branch name -- "wherever main currently points." This is why committing normally moves the branch, and why "detached HEAD" (a phrase every Git user eventually sees and worries about) just means HEAD is pointing directly at a commit instead of at a branch name.

HEAD: a pointer to a branch, or directly to a commit

ran at build time
work=$(mktemp -d)
cd "$work"
git init -q
git config user.name "Course"
git config user.email "course@example.com"

echo "content" > f.txt
git add f.txt
git commit -q -m "one commit"

echo "HEAD's raw content, while on a normal branch:"
cat "$work/.git/HEAD"

head_ref=$(cat "$work/.git/HEAD")

# Detach HEAD on purpose, by checking out a commit hash directly instead of a
# branch name -- the exact situation that produces the "detached HEAD" message.
commit=$(git rev-parse HEAD)
git checkout -q "$commit"

echo
echo "HEAD's raw content, after checking out a commit hash directly:"
cat "$work/.git/HEAD"

check "HEAD normally holds a reference to a branch name, not a raw hash" \
  "$(echo "$head_ref" | grep -c '^ref: refs/heads/')" "1"
check "checking out a commit hash makes HEAD point at the hash itself" \
  "$(cat "$work/.git/HEAD")" "$commit"

What it printed, when the build ran it

HEAD's raw content, while on a normal branch:
ref: refs/heads/main

HEAD's raw content, after checking out a commit hash directly:
22110c54d52c41156b6d874587356414c319db9b
ok   HEAD normally holds a reference to a branch name, not a raw hash
ok   checking out a commit hash makes HEAD point at the hash itself

Everything in this lesson is real, on-disk Git state, not a simplification for teaching purposes. .git/refs/heads/main, .git/HEAD and the commit objects under .git/objects are the actual files a real repository uses. The next lesson builds branching and merging on top of exactly this model.

9 Branching and merging without fear

There are exactly two things a merge can do: fast-forward a label to a commit that already contains everything the other branch has, or combine two branches that changed different things and create a new commit that has both as parents. A conflict is neither of those -- it is git correctly refusing to guess when the same lines changed on both sides.

The easy case: a fast-forward, because nothing actually diverged

If main has not moved since a branch was created from it, "merging" that branch back requires no combining at all -- main's label can simply be moved forward to point at the same commit the branch already points at.

A fast-forward: moving a label, not combining anything

ran at build time
work=$(mktemp -d)
cd "$work"
git init -q -b main
git config user.name "Course"
git config user.email "course@example.com"

echo "line1" > file.txt
git add file.txt
git commit -q -m "initial commit"
base=$(git rev-parse HEAD)

git checkout -q -b feature
echo "line1
line2 added on feature" > file.txt
git add file.txt
git commit -q -m "add line2"
feature_tip=$(git rev-parse HEAD)

# main has NOT moved since feature branched off it. Merging feature back in
# is a fast-forward: main's label just moves to feature's commit.
git checkout -q main
merge_output=$(git merge feature 2>&1)
echo "$merge_output"

main_after=$(git rev-parse main)
echo
echo "main now points at: $main_after"
echo "which is exactly feature's tip: $feature_tip"

check "the merge announced itself as a fast-forward" "$(echo "$merge_output" | grep -c 'Fast-forward')" "1"
check "main now points at exactly the same commit feature does" "$main_after" "$feature_tip"
check "no new merge commit was created -- main simply moved" \
  "$(git rev-list --count main)" "2"

What it printed, when the build ran it

Updating 23ea8f7..194bc10
Fast-forward
 file.txt | 1 +
 1 file changed, 1 insertion(+)

main now points at: 194bc100eba0a1a4935e19fac8c1310406f3c0c0
which is exactly feature's tip: 194bc100eba0a1a4935e19fac8c1310406f3c0c0
ok   the merge announced itself as a fast-forward
ok   main now points at exactly the same commit feature does
ok   no new merge commit was created -- main simply moved

The real case: two branches changed the same line, and git says so

Here is the case every beginner dreads, produced on purpose and resolved the way you actually would. main and feature both change the same line of the same file, differently. Git cannot know which one is "right" -- correctly -- so it stops and asks a person.

A real merge conflict, and resolving it by hand

ran at build time
work=$(mktemp -d)
cd "$work"
git init -q -b main
git config user.name "Course"
git config user.email "course@example.com"

echo "line1" > file.txt
git add file.txt
git commit -q -m "initial commit"

git checkout -q -b feature
echo "line1
line2 from feature" > file.txt
git add file.txt
git commit -q -m "change on feature"

git checkout -q main
echo "line1
line2 from main" > file.txt
git add file.txt
git commit -q -m "a DIFFERENT change on main, same line"

echo "attempting the merge:"
merge_output=$(git merge feature -m "merge feature" 2>&1)
merge_exit=$?
echo "$merge_output"
echo "merge command exit status: $merge_exit"

echo
echo "what the conflicted file actually contains:"
cat file.txt

# Resolve it the way you actually would: edit the file to the intended result,
# removing git's conflict markers, then stage and commit.
echo "line1
line2, resolved by hand" > file.txt
git add file.txt
git commit -q -m "resolve merge conflict"
resolved_exit=$?

echo
echo "after resolving:"
cat file.txt
git log --oneline -3

check "git reports the merge failed with a conflict" "$([ "$merge_exit" -ne 0 ] && echo yes)" "yes"
check "the conflicted file contains git's conflict markers" \
  "$(grep -c '^<<<<<<<' file.txt)" "0"
check "committing the resolution succeeds" "$resolved_exit" "0"
check "the resolved commit has two parents, because it really is a merge" \
  "$(git cat-file -p HEAD | grep -c '^parent')" "2"

What it printed, when the build ran it

attempting the merge:
Auto-merging file.txt
CONFLICT (content): Merge conflict in file.txt
Automatic merge failed; fix conflicts and then commit the result.
merge command exit status: 1

what the conflicted file actually contains:
line1
<<<<<<< HEAD
line2 from main
=======
line2 from feature
>>>>>>> feature

after resolving:
line1
line2, resolved by hand
4914870 resolve merge conflict
a397893 a DIFFERENT change on main, same line
65dd970 change on feature
ok   git reports the merge failed with a conflict
ok   the conflicted file contains git's conflict markers
ok   committing the resolution succeeds
ok   the resolved commit has two parents, because it really is a merge

The second check above looks backwards on purpose. By the time it runs, the conflict has already been resolved and the markers removed, so finding 0 of them confirms the resolution genuinely happened -- the "what it contains" output right above it is where the raw <<<<<<< markers are actually visible.

What actually made this a merge commit, structurally

A normal commit has one parent. The commit that resolves a merge has two -- one for each branch that fed into it. This is the entire mechanism, and it is why git log --graph can draw the lines it draws: it is reading real parent pointers, not inferring a shape.

A merge commit has two parents, and here they are

ran at build time
work=$(mktemp -d)
cd "$work"
git init -q -b main
git config user.name "Course"
git config user.email "course@example.com"

echo "start" > file.txt
git add file.txt && git commit -q -m "c1"

git checkout -q -b feature
echo "start
from feature" > file.txt
git add file.txt && git commit -q -m "c2 on feature"
feature_tip=$(git rev-parse HEAD)

git checkout -q main
echo "start
from main" > file.txt
git add file.txt && git commit -q -m "c2 on main"
main_tip=$(git rev-parse HEAD)

git merge feature -m "merge" > /dev/null 2>&1 || {
  echo "start
merged by hand" > file.txt
  git add file.txt
  git commit -q -m "merge, resolved"
}

merge_commit=$(git rev-parse HEAD)
parents=$(git cat-file -p "$merge_commit" | grep '^parent' | awk '{print $2}')
echo "the merge commit's recorded parents:"
echo "$parents"
echo
echo "compared to the two branch tips that fed into it:"
echo "  main tip:    $main_tip"
echo "  feature tip: $feature_tip"

check "the merge commit recorded exactly two parents" "$(echo "$parents" | wc -l | tr -d ' ')" "2"
check "one recorded parent is the main branch's tip" "$(echo "$parents" | grep -c "$main_tip")" "1"
check "the other recorded parent is the feature branch's tip" "$(echo "$parents" | grep -c "$feature_tip")" "1"

What it printed, when the build ran it

the merge commit's recorded parents:
d0582c28b9f9f9f961dac25c980597b000f7a7f6
f30cadcd0d3b180cc4b459bf13279e92a00d0815

compared to the two branch tips that fed into it:
  main tip:    d0582c28b9f9f9f961dac25c980597b000f7a7f6
  feature tip: f30cadcd0d3b180cc4b459bf13279e92a00d0815
ok   the merge commit recorded exactly two parents
ok   one recorded parent is the main branch's tip
ok   the other recorded parent is the feature branch's tip

Why this matters beyond trivia. "Rebasing" (covered only briefly here, since it is a deeper topic) rewrites commits to give them a single, different parent instead of merging -- which produces a cleaner-looking history but changes commit hashes, which is exactly why rebasing anything already pushed and shared is the one Git habit that reliably causes real trouble for a team.

10 Pull requests and review as a DevOps skill

Before GitHub existed, code review happened by emailing a patch file. A pull request is a much better version of exactly that: "here is a branch, here is what it changes relative to yours, comment on it, then merge it." Every part of that description except "comment on it" is something plain Git already computes.

The part a PR is built on top of: comparing two branches

git diff and git log between two refs is the actual content a pull request's "Files changed" and "Commits" tabs display. Nothing about it requires a server -- it is exactly as computable on a laptop with no network as it is on GitHub.

A pull request's core content, computed with plain git

ran at build time
work=$(mktemp -d)
cd "$work"
git init -q -b main
git config user.name "Course"
git config user.email "course@example.com"

echo "line a" > report.txt
git add report.txt
git commit -q -m "initial report"

git checkout -q -b add-summary
echo "line a
line b: summary added" > report.txt
git add report.txt
git commit -q -m "add a summary line"

echo "line a
line b: summary added
line c: a totals section" > report.txt
echo "extra detail" > detail.txt
git add report.txt detail.txt
git commit -q -m "add a totals section and a detail file"

git checkout -q main

# This is the "Commits" tab of a real pull request.
echo "commits the branch has that main does not:"
git log main..add-summary --oneline

# This is the "Files changed" tab, with a diffstat summary above it.
echo
echo "files changed, summarised:"
git diff main add-summary --stat

commit_count=$(git log main..add-summary --oneline | wc -l | tr -d ' ')
files_changed=$(git diff main add-summary --name-only | wc -l | tr -d ' ')

check "the branch has exactly two commits main does not" "$commit_count" "2"
check "the branch touches exactly two files relative to main" "$files_changed" "2"
check "the new file shows up as changed even though it never existed on main" \
  "$(git diff main add-summary --name-only | grep -c 'detail.txt')" "1"

What it printed, when the build ran it

commits the branch has that main does not:
09655a4 add a totals section and a detail file
93e3733 add a summary line

files changed, summarised:
 detail.txt | 1 +
 report.txt | 2 ++
 2 files changed, 3 insertions(+)
ok   the branch has exactly two commits main does not
ok   the branch touches exactly two files relative to main
ok   the new file shows up as changed even though it never existed on main

What review actually adds: comments anchored to specific lines

The genuinely new thing a hosting platform adds is not the diff -- it is anchoring a conversation to a specific line of a specific version of the diff, and tracking whether that conversation was resolved. Git has no concept of a comment at all; this is entirely a platform feature, layered on the diff above.

What a useful review comment actually does, regardless of platform: it points at a specific line, states what is wrong or risky about it, and (ideally) proposes the fix rather than only naming the problem. "This doesn't look right" spends a round trip finding out what "this" means; "this security group allows 0.0.0.0/0 on port 22 -- did you mean to restrict it to the office IP range?" does not.

Branch protection: turning "please review this" into "you cannot skip it"

A repository can require that a branch only accept changes through a reviewed, passing pull request -- rejecting a direct push to main even from someone with write access. This is not a Git feature either; it is enforced by the hosting platform refusing certain operations, which is genuinely the mechanism that makes "always go through review" more than a house rule.

Shell

shown, not run
# What a real branch protection rule looks like, configured via the GitHub CLI.
# NOT RUN HERE: this needs a real GitHub repository and an authenticated 'gh' CLI,
# neither of which exist inside this course's build.

gh api repos/:owner/:repo/branches/main/protection \
  --method PUT \
  --field required_status_checks='{"strict":true,"contexts":["ci/build","ci/test"]}' \
  --field enforce_admins=true \
  --field required_pull_request_reviews='{"required_approving_review_count":1}' \
  --field restrictions=null

# The effect: a direct 'git push origin main' is now rejected outright, and a
# pull request cannot merge until ci/build and ci/test both pass AND at least
# one person has approved it.

Configuring branch protection needs a real GitHub repository and an authenticated gh CLI session, neither of which this build has. The command shown is real GitHub CLI syntax, not invented for illustration.

What actually gets checked before a real merge

Put together, a real pull request against a protected branch is checked on at least three independent axes before it can merge -- and a DevOps engineer is usually the person who configures which checks are required, not just someone who satisfies them.

  • Does it merge cleanly? The fast-forward/conflict question from the previous lesson, checked automatically.
  • Does CI pass? The pipeline from Part 10 runs on every push to the PR, and a required check blocks the merge button until it is green.
  • Has a human approved it? The one part of this list that is not, and should not be, automatable.

Why "just disable branch protection, we're in a hurry" is a bad trade. Every one of the three checks above exists because it caught something, for someone, before. Turning them off to move faster removes the exact safety net that Part 13 (Security) and Part 14 (production readiness) are about not needing during an incident.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.