History and diffs: find what changed, who changed it, and when it broke
Read history like an investigator: HEAD and detached HEAD, git log filters, every kind of diff, blame for a line, and git bisect to find the exact commit that introduced a bug.
By the end of this lesson you'll be able to
0 of 6 completeHEAD and detached HEAD
HEAD means "where you are". Normally it points at a branch, and the branch points at a commit. You can
name commits relative to it:
| Name | Means |
|---|---|
HEAD | the commit you're on |
HEAD~1, HEAD~3 | one, three commits back (first parents) |
HEAD^2 | a merge commit's second parent (the branch that was merged in) |
main@{yesterday} | where main was yesterday, from the reflog |
9ef9b93 | any commit by its ID (at least 4 characters, if unique) |
Check out a commit instead of a branch and you get a detached HEAD: HEAD points straight at a commit.
$ git switch --detach HEAD~3 HEAD is now at 9ef9b93 Tidy calc and add note 5 $ git status HEAD detached at 9ef9b93
git switch --detach HEAD~3gitversion control
git statusstatuswhat changed
That's perfect for looking at old code or running an old version's tests. If you make commits here, they belong to no branch, and Git warns you when you leave:
Warning: you are leaving 1 commit behind, not connected to any of your branches: 5b76f4b experiment
Keep them by creating a branch before switching away: git switch -c rescue. If you already left,
git branch rescue 5b76f4b (the ID from the warning, or from git reflog) brings them back.
Searching history with git log
$ git log --oneline --author="Rajeev" -2 # by author $ git log --oneline --since="2 weeks ago" # by date (--until too) $ git log --oneline --grep="Tidy" # by message 9ef9b93 Tidy calc and add note 5 $ git log --oneline -S "a - b" # by content: commits that added or removed this text 9ef9b93 Tidy calc and add note 5 $ git log --oneline -- calc.py # commits that touched a file $ git log -L1,2:calc.py # the history of lines 1-2 of calc.py, with diffs $ git log --format='%h %an %ar %s' -2 # your own format 384c76f Rajeev Kumar 0 seconds ago Add note 8 6597d96 Rajeev Kumar 0 seconds ago Add note 7 $ git log --oneline --graph --all --decorate # the whole picture
git log --oneline --author="Rajeev" -2gitversion controlloghistory
-S (the "pickaxe") is the one people don't know and then use every week: "when did this config value
appear?", "which commit deleted this function?". -G does the same with a regular expression.
Who changed this line?
$ git blame calc.py ^8716d2f (Rajeev Kumar 2026-10-14 04:32:00 +0000 1) def add(a, b): 9ef9b936 (Rajeev Kumar 2026-10-14 04:32:00 +0000 2) return a - b $ git show 9ef9b936 # the commit: its message says why $ git blame -w -C -L 2,2 calc.py # ignore whitespace, follow code moved between files
git blame calc.pygitversion control
Each line shows the last commit that changed it (^ marks the first commit). Blame is for finding the
why, by reading that commit's message and pull request, not for blaming people. On GitHub, the Blame
button on any file does the same, with links to each pull request.
Blame that sees past reformats and moves
Plain blame has one weakness: it names the last commit that touched a line, and the last commit is often boring. A reformat touches every line. Moving code to a new file touches every line. After both, blame credits the person who ran the formatter, and the real history disappears.
Here is a small shop repository where Asha added free shipping, I reformatted the file with 2-space
indents, then moved the pricing code from cart.py into a new pricing.py:
$ git log --oneline ba6b843 Lower tax rate to 12% for books a87c23c Move pricing into pricing.py ca7c63d Reformat with 2-space indents d8f2a2b Add free shipping over 999 d5b6404 Add cart total with GST $ git blame pricing.py ba6b8430 (Rajeev Kumar 2026-10-16 09:00:00 +0530 1) TAX_RATE = 0.12 a87c23ce (Rajeev Kumar 2026-10-15 09:00:00 +0530 2) FREE_SHIPPING_OVER = 999 a87c23ce (Rajeev Kumar 2026-10-15 09:00:00 +0530 3) a87c23ce (Rajeev Kumar 2026-10-15 09:00:00 +0530 4) def total(items): a87c23ce (Rajeev Kumar 2026-10-15 09:00:00 +0530 5) subtotal = sum(i["price"] * i["qty"] for i in items) a87c23ce (Rajeev Kumar 2026-10-15 09:00:00 +0530 6) shipping = 0 if subtotal > FREE_SHIPPING_OVER else 49 a87c23ce (Rajeev Kumar 2026-10-15 09:00:00 +0530 7) return subtotal * (1 + TAX_RATE) + shipping
git log --onelinegitversion controlloghistory
Six lines blamed on "Move pricing into pricing.py". True, and useless. Two options fix it:
$ git blame -w -C -C pricing.py ba6b8430 pricing.py (Rajeev Kumar 2026-10-16 09:00:00 +0530 1) TAX_RATE = 0.12 d8f2a2bd cart.py (Asha Verma 2026-10-13 11:00:00 +0530 2) FREE_SHIPPING_OVER = 999 ^d5b6404 cart.py (Rajeev Kumar 2026-10-12 10:00:00 +0530 3) ^d5b6404 cart.py (Rajeev Kumar 2026-10-12 10:00:00 +0530 4) def total(items): ^d5b6404 cart.py (Rajeev Kumar 2026-10-12 10:00:00 +0530 5) subtotal = sum(i["price"] * i["qty"] for i in items) d8f2a2bd cart.py (Asha Verma 2026-10-13 11:00:00 +0530 6) shipping = 0 if subtotal > FREE_SHIPPING_OVER else 49 d8f2a2bd cart.py (Asha Verma 2026-10-13 11:00:00 +0530 7) return subtotal * (1 + TAX_RATE) + shipping
git blame -w -C -C pricing.pygitversion control
| Option | What it ignores |
|---|---|
-w | whitespace-only changes, so the reformat is skipped |
-C | lines moved or copied from other files changed in the same commit (-C -C: from any file in the commit that created the file; -C -C -C: from any commit, slower) |
-L a,b | only lines a to b; -L :total blames just the function total |
Now the file column says where each line was born, and the shipping lines belong to Asha. Without -w,
-C alone stops at the reformat:
$ git blame -C -L 6,7 pricing.py ca7c63d6 cart.py (Rajeev Kumar 2026-10-14 09:00:00 +0530 6) shipping = 0 if subtotal > FREE_SHIPPING_OVER else 49 ca7c63d6 cart.py (Rajeev Kumar 2026-10-14 09:00:00 +0530 7) return subtotal * (1 + TAX_RATE) + shipping
git blame -C -L 6,7 pricing.pygitversion control
Ignore formatting commits for everyone
-w only helps with whitespace. A formatter that also changes quotes or wraps lines needs a stronger tool:
list those commits in a .git-blame-ignore-revs file, commit it, and blame skips them:
$ cat .git-blame-ignore-revs # Reformat with 2-space indents ca7c63d67b183e18917c221be5cbdb9d86bf5f40 $ git config blame.ignoreRevsFile .git-blame-ignore-revs $ git blame -C -L 6,7 pricing.py d8f2a2bd cart.py (Asha Verma 2026-10-13 11:00:00 +0530 6) shipping = 0 if subtotal > FREE_SHIPPING_OVER else 49 d8f2a2bd cart.py (Asha Verma 2026-10-13 11:00:00 +0530 7) return subtotal * (1 + TAX_RATE) + shipping
cat .git-blame-ignore-revscatprint a file
git config blame.ignoreRevsFile .git-blame-ignore-revsgitversion controlconfigread or set settings
Use the full commit ID: a short one stops working when the repository grows. GitHub's Blame view reads
.git-blame-ignore-revs from the repository root automatically; locally, each person sets
blame.ignoreRevsFile once (or passes --ignore-revs-file). Make "add the reformat commit to the file"
part of the pull request that does the reformat.
Pickaxe search with git log
Blame answers "who wrote this line that exists now?". The pickaxe answers the opposite question: "when did this text appear, change or disappear?", even if it's long gone from the code.
-S text lists commits that changed how many times the text appears: it was added, removed, or
moved between files.
$ git log --oneline -S FREE_SHIPPING_OVER a87c23c Move pricing into pricing.py d8f2a2b Add free shipping over 999 $ git log --oneline -S "0.18" ba6b843 Lower tax rate to 12% for books a87c23c Move pricing into pricing.py d5b6404 Add cart total with GST
git log --oneline -S FREE_SHIPPING_OVERgitversion controlloghistory
Add -p to see the change itself, and a path to narrow it down:
$ git log -S "0.18" --oneline -p -- pricing.py | grep -E '^[0-9a-f]{7}|^[-+]TAX' ba6b843 Lower tax rate to 12% for books -TAX_RATE = 0.18 +TAX_RATE = 0.12 a87c23c Move pricing into pricing.py +TAX_RATE = 0.18
git log -S "0.18" --oneline -p -- pricing.py | grep -E '^[0-9a-f]{7}|^[-+]TAX'gitversion controlloghistory|pipe: send output to the next commandgrepfind lines that match a pattern
-G regex lists commits whose diff has an added or removed line matching the regular expression.
The difference shows up with the reformat commit:
$ git log --oneline -G FREE_SHIPPING_OVER a87c23c Move pricing into pricing.py ca7c63d Reformat with 2-space indents d8f2a2b Add free shipping over 999
git log --oneline -G FREE_SHIPPING_OVERgitversion controlloghistory
The reformat rewrote the lines that mention FREE_SHIPPING_OVER, so -G finds it. It didn't change how
many times the name appears, so -S skips it. Use -S for "when was this added or deleted?", the usual
question, and -G (or -S ... --pickaxe-regex) when you need a pattern, such as -G 'TAX_RATE = 0\.[0-9]+'
to find every change to the tax value. --grep is different again: it searches commit messages.
Diff: working folder, staging and commits
$ git diff --stat # unstaged changes calc.py | 1 + $ git diff --staged --stat # staged changes: the next commit notes.md | 1 + $ git diff HEAD --stat # both: everything since the last commit calc.py | 1 + notes.md | 1 + 2 files changed, 2 insertions(+) $ git diff HEAD~3 HEAD -- calc.py # between two commits, one file $ git diff --word-diff # changed words, not whole lines: great for prose and config $ git show HEAD --stat # one commit's changes
git diff --statgitversion control
Diff between branches
$ git log --oneline main..feature # commits on feature that main doesn't have $ git diff main...feature --name-only # what feature changed since it left main $ git diff main..feature --stat # the two tips compared directly
git log --oneline main..featuregitversion controlloghistory
| Form | With git log | With git diff |
|---|---|---|
main..feature | commits in feature, not in main | tip of main vs tip of feature |
main...feature | commits in either, not in both | common ancestor vs tip of feature |
For "what will my pull request change?" use three dots with diff: it ignores whatever landed on main after you branched, exactly like GitHub's Files changed tab.
Bisect: find the commit that broke it
Something worked last week and doesn't now, with 200 commits in between. Reading them all takes a day.
git bisect binary-searches instead: you mark one good commit and one bad one, Git checks out the commit
in the middle, you test it, and every answer halves the range. About 8 steps for 200 commits.
$ git bisect start $ git bisect bad # the current commit is broken $ git bisect good v1.4.0 # this tag (or commit) was fine Bisecting: 3 revisions left to test after this (roughly 2 steps) $ git bisect good # after testing this commit: good, or git bisect bad $ git bisect reset # finished: back to where you started
git bisect startgitversion control
If a test script can tell good from bad (exit code 0 = good, 1–127 = bad, 125 = can't test this one), Git does the whole search for you:
$ git bisect start HEAD HEAD~8 # bad first, then good $ git bisect run python3 -B test.py running 'python3' '-B' 'test.py' 9ef9b936b45e6747c47f65a02d4fac4d096fbe4f is the first bad commit $ git bisect reset
git bisect start HEAD HEAD~8gitversion control
Find the commit that broke a test
A calculator worked when it was first committed, and eight commits later its test fails. Without reading any diffs, let git bisect run find the first bad commit, then use git show and git blame to explain what changed.
The ID and message of the first bad commitReveal one safe solution
$ git init calc && cd calc $ printf 'def add(a, b):\n return a + b\n' > calc.py $ printf 'from calc import add\nassert add(2, 3) == 5\nprint("ok")\n' > test.py $ git add . && git commit -m "Add calc with a test" $ for i in 1 2 3 4 5 6 7 8; do echo "# note $i" >> notes.md; git add notes.md; [ $i = 5 ] && sed -i 's/a + b/a - b/' calc.py && git add calc.py; git commit -qm "Add note $i"; done $ python3 -B test.py # fails now: AssertionError $ git bisect start HEAD HEAD~8 $ git bisect run python3 -B test.py $ git bisect reset $ git show --stat <the ID bisect printed> $ git blame -L 2,2 calc.py
git init calc && cd calcgitversion controlinitcreate a repository here&&run the next command only if this one workedcdchange directory
printf 'def add(a, b):\n return a + b\n' > calc.py>write output to a file, replacing it
git add . && git commit -m "Add calc with a test"addstage changes.this foldercommitsave staged changes
for i in 1 2 3 4 5 6 7 8; do echo "# note $i" >> notes.md; git add notes.md; [ $i = 5 ] && sed -i 's/a + b/a - b/' calc.py && git add calc.py; git commit -qm "Add note $i"; doneforloop over a listdostart of what the loop runs each timeechoprint text>>append output to a filedoneend of the loop
Bisect names "Add note 5", and git show reveals it changed calc.py as well as the notes: the bug hid
in a commit whose message didn't mention it. That's the case for one logical change per commit, and why
bisect beats reading commit messages.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
git status says "HEAD detached at 9ef9b93". What does that mean?
Official reading for this lesson
Or get every quiz answer right and it completes itself.