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.
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.
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.
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.
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.
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.
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.
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.
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.
# 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.