DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    Git & GitHubLesson 9 of 13
    Your progress · 0%
    On this page
    1. HEAD and detached HEAD
    2. Searching history with git log
    3. Blame that sees past reformats and moves
    4. Pickaxe search with git log
    5. Diff: working folder, staging and commits
    6. Diff between branches
    7. Bisect: find the commit that broke it
    ↖ Course roadmap
    Lesson 9 · History & diffs

    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.

    35 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 6 complete

    HEAD 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:

    NameMeans
    HEADthe commit you're on
    HEAD~1, HEAD~3one, three commits back (first parents)
    HEAD^2a merge commit's second parent (the branch that was merged in)
    main@{yesterday}where main was yesterday, from the reflog
    9ef9b93any 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~3
    • gitversion control
    git status
    • statuswhat 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" -2
    • gitversion control
    • loghistory

    The real thing, in colour: branch names in brackets, HEAD showing where you are, and the lines showing where work split and where the merge joined it back.© Screenshot: DevOps Academy

    The real thing, in colour: branch names in brackets, HEAD showing where you are, and the lines showing where work split and where the merge joined it back.© Screenshot: DevOps Academy

    -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.py
    • gitversion 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 --oneline
    • gitversion control
    • loghistory

    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.py
    • gitversion control
    OptionWhat it ignores
    -wwhitespace-only changes, so the reformat is skipped
    -Clines 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,bonly 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.py
    • gitversion 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-revs
    • catprint a file
    git config blame.ignoreRevsFile .git-blame-ignore-revs
    • gitversion control
    • configread 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_OVER
    • gitversion control
    • loghistory

    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 control
    • loghistory
    • |pipe: send output to the next command
    • grepfind 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_OVER
    • gitversion control
    • loghistory

    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

    Name the two things you're comparing, and the right diff follows.© Diagram: DevOps Academy

    Name the two things you're comparing, and the right diff follows.© Diagram: DevOps Academy
    $ 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 --stat
    • gitversion 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..feature
    • gitversion control
    • loghistory
    FormWith git logWith git diff
    main..featurecommits in feature, not in maintip of main vs tip of feature
    main...featurecommits in either, not in bothcommon 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.

    Good, bad, test the middle, repeat. Each answer throws away half the suspects.© Animation: DevOps Academy

    Good, bad, test the middle, repeat. Each answer throws away half the suspects.© Animation: DevOps Academy
    $ 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 start
    • gitversion 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~8
    • gitversion control
    Test only what's in the commit

    Bisect checks out old commits into the same folder, so leftover build output can lie to you. Here Python's cached bytecode in __pycache__ made an early run blame the wrong commit, which is why the command uses python3 -B (don't use cached files). Clean build folders in your test script, or run it with git clean -fdx first if nothing untracked matters.

    ⌘
    Mini mission

    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.

    Input: Any empty folder with Python 3Output: The ID and message of the first bad commit
    Reveal 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 calc
    • gitversion control
    • initcreate a repository here
    • &&run the next command only if this one worked
    • cdchange 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 folder
    • commitsave 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"; done
    • forloop over a list
    • dostart of what the loop runs each time
    • echoprint text
    • >>append output to a file
    • doneend 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 done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 7

    git status says "HEAD detached at 9ef9b93". What does that mean?

    Go deeper

    Official reading for this lesson

    • GuidePro Git: viewing the commit historyPro Git ↗
    • GuidePro Git: revision selectionPro Git ↗
    • GuidePro Git: debugging with Git (blame and bisect)Pro Git ↗
    • Docsgit-bisect documentationGit ↗
    • Docsgit-diff documentationGit ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousUndoing changes: revert, reset, stash and reflogNext lesson →Rewriting history: amend, interactive rebase and safe force-pushes