Carla has already fixed task deletion thanks to bisect. But while she was in app.js she came across this:

function normaliseText(text) {
  return text
    .replace(/ /g, ' ')
    .replace(/[​-‍]/g, '')
    .trim();
}

Nobody on the team remembers writing that function. It is not documented, it has no comments and it looks like a defensive patch against something very specific. The temptation to delete it is enormous: it is five lines that "do nothing".

That temptation is exactly the scenario described by Chesterton's fence: do not take down a fence in the middle of a field until you know why it was put there. And in a Git repository you can always find out, because every line of every file comes from a specific commit, with its author, its date and its message.

The tool that makes that query is git blame, and in this lesson we are going to squeeze it dry: not just to see who touched the line last, but to see through the noise — reformatting, moved code, spacing changes — and get to the commit that really explains the why.

Contents

  1. What git blame is exactly
  2. Reading the output
  3. Narrowing down: -L by lines and by function
  4. Seeing beyond the noise: -w, -M and -C
  5. Travelling in time: <sha>^ and --since
  6. git log -L: the complete evolution of a range of lines
  7. Ignoring formatting commits: --ignore-rev and .git-blame-ignore-revs
  8. Solving the normaliseText mystery
  9. Blame in practice: attribution, not fault

  1. What git blame is exactly

git blame <file> answers a very specific question:

For each line of the file as it stands now, what was the last commit that modified it?

Three things in that definition are worth underlining, because the three most common misunderstandings come from there:

  • It is "the last one that touched it", not "the one that wrote it". If Ana wrote the line in January and Bruno changed a comma in June, blame points at Bruno. Sections 4 and 7 are precisely the tools for seeing through that noise.
  • It works on the file's current state. Deleted lines do not appear: if you are interested in those, the tool is git log -L (section 6) or the -S pickaxe from lesson 02-06.
  • It computes nothing new. All the information is already in the data model from lesson 01-04: blame walks the file's history comparing versions and attributes each line. It is a query, not stored metadata.

  1. Reading the output

cd ~/projects/task-manager
git blame app.js
9f3c7a1e (Ana Ferrer       2026-04-12 09:31:44 +0200   1) const list = document.querySelector('#task-list');
9f3c7a1e (Ana Ferrer       2026-04-12 09:31:44 +0200   2) const form = document.querySelector('#task-form');
4e8b2c9d (Bruno Salas      2026-05-03 17:22:08 +0200   3) const field = document.querySelector('#new-task');
^7d2a1f8 (Ana Ferrer       2026-03-28 11:05:12 +0200   4)
2c6e9a4f (Carla Vidal      2026-05-19 14:47:31 +0200   5) let tasks = loadTasks();
00000000 (Not Committed Yet 2026-08-01 10:12:03 +0200  6) let activeFilter = 'all';

The five columns, from left to right:

Column Content Detail
1 Abbreviated hash of the commit It is the commit that touched that line last
2 Author The author, not the committer (the distinction from lesson 01-06)
3 Date and time Authorship date, with time zone
4 Line number In the current file
5 Content The line as it is

And two special markers that you need to know how to read:

  • ^7d2a1f8, with a caret: the line comes from the oldest commit in the analysed range. It is usually the initial commit (boundary commit). It means "this is as old as the history I am looking at".
  • 00000000 / Not Committed Yet: the line is modified in the working tree and has not been committed yet. It is yours, right now.

Formatting options that save a lot of time:

git blame -s app.js            # hash and line only: compact, no author or date
git blame -e app.js            # shows the email instead of the name
git blame --date=short app.js  # dates as 2026-04-12, with no time or zone
git blame -l app.js            # full 40-character hashes
git blame -c app.js            # format compatible with git annotate

And the one really used day to day, because it combines just the right things:

git blame --date=short -w app.js

One practical detail: blame's output is long and usually goes through the pager. To jump straight to the area you care about, in less all you need is /normaliseText + Enter. But it is far better to narrow down from the start, which is what comes next.

  1. Narrowing down: -L by lines and by function

You almost never want the whole file. -L limits the analysis to a range:

git blame -L 78,92 app.js
8d4f2a7c (Bruno Salas  2026-06-22 11:14:38 +0200  78)   list.addEventListener('click', (e) => {
8d4f2a7c (Bruno Salas  2026-06-22 11:14:38 +0200  79)     if (e.target.closest('.delete')) {
8d4f2a7c (Bruno Salas  2026-06-22 11:14:38 +0200  80)       deleteTask(e.target.closest('.delete').dataset.id);

Ways of expressing the range, all valid:

Syntax Meaning
-L 78,92 From line 78 to line 92
-L 78,+15 Fifteen lines starting at 78
-L 78,-5 From 73 to 78
-L 78 From 78 to the end of the file
-L ,20 From the beginning to 20
-L :normaliseText:app.js That function's block, without counting lines
-L '/^function deleteTask/',+30 From the line matching the regular expression

The -L :<name>:<file> form is the most convenient and the least known. Git locates the function's block using the same funcname rules that git diff uses for hunk headers (lesson 02-05):

git blame -L :normaliseText:app.js
b7e2c4a9 (Carla Vidal  2026-02-14 16:03:55 +0100  43) function normaliseText(text) {
b7e2c4a9 (Carla Vidal  2026-02-14 16:03:55 +0100  44)   return text
3f1a8d6c (Ana Ferrer   2026-05-30 10:18:22 +0200  45)     .replace(/ /g, ' ')
3f1a8d6c (Ana Ferrer   2026-05-30 10:18:22 +0200  46)     .replace(/[​-‍]/g, '')
b7e2c4a9 (Carla Vidal  2026-02-14 16:03:55 +0100  47)     .trim();
b7e2c4a9 (Carla Vidal  2026-02-14 16:03:55 +0100  48) }

First clue in the mystery: the function was created by Carla in February, and Ana added the two replace calls in May. Two commits, two possibly different reasons.

Function recognition depends on the language. Git ships built-in patterns for many of them (c, java, python, php, rust…). For JavaScript, the built-in javascript pattern is activated by declaring it in .gitattributes with *.js diff=javascript. That file, and everything it lets you configure, is the subject of lesson 08-04; here it is enough to know that if -L :function: misses, that is the reason.

  1. Seeing beyond the noise: -w, -M and -C

Here is blame's real value, and what separates naive use from expert use. "Raw" attribution lies constantly, because:

  • Somebody reindented the file → every line is theirs.
  • Somebody moved a function elsewhere within the same file → every line is theirs.
  • Somebody extracted a module into a new file → every line is theirs.

In all three cases, the content did not change, and blame is pointing at the messenger. These three options correct it:

-w: ignore whitespace changes

git blame -w app.js

It ignores differences that are only whitespace when comparing versions. If Bruno ran the file through the formatter and changed the indentation from 4 spaces to 2, -w looks through that commit and carries on attributing the line to whoever wrote the actual code.

It is practically free and almost never gets in the way. Always use it.

-M: follow code moved within the same file

git blame -M app.js

It detects blocks of lines that were moved or copied within the same file and attributes the line to its origin, not to the commit that moved it. This is the "I have reordered the functions to group the storage ones together" case.

-C: follow code moved between files

git blame -C app.js

It detects lines that come from another file modified in the same commit. This is the case of extracting code into a new module.

-C has levels, according to how many times it is repeated, and this is the table to keep to hand:

Option What it looks for Cost
-M Moves and copies within the same file Low
-C Additionally, code coming from other files modified in the same commit Medium
-C -C Additionally, code coming from any file in the commit that created the file High
-C -C -C Additionally, code coming from any file in any commit Very high

And the detection threshold can be tuned with a number: -M20 requires at least 20 alphanumeric characters before considering that a block was moved (the default for -M is 40).

In practice, the invocation that solves 95% of serious investigations is:

git blame -w -C -C -L :normaliseText:app.js app.js

Note that everything can be combined: ignore whitespace, follow moves between files at a medium-high level, and narrow down to a function. It is slower, but in a file of a few hundred lines you do not even notice.

A practical comparison of the effect, on the same lines:

git blame -L 43,48 app.js
c9a2e7f4 (Bruno Salas  2026-07-11 09:02:17 +0200  43) function normaliseText(text) {
c9a2e7f4 (Bruno Salas  2026-07-11 09:02:17 +0200  44)   return text
c9a2e7f4 (Bruno Salas  2026-07-11 09:02:17 +0200  45)     .replace(/ /g, ' ')
git blame -w -C -L 43,48 app.js
b7e2c4a9 (Carla Vidal  2026-02-14 16:03:55 +0100  43) function normaliseText(text) {
b7e2c4a9 (Carla Vidal  2026-02-14 16:03:55 +0100  44)   return text
3f1a8d6c (Ana Ferrer   2026-05-30 10:18:22 +0200  45)     .replace(/ /g, ' ')

The first result points at Bruno and at a July commit: that is the commit in which Bruno moved the function from index.html to app.js. Informationally, it is useless. The second points at the commits that wrote the content, which is what we were after.

  1. Travelling in time: <sha>^ and --since

blame accepts a starting point other than HEAD:

git blame v1.0.0 -- app.js         # the file as it stood at the tag
git blame 8d4f2a7 -- app.js        # as it stood at that commit

From that comes the most useful technique in the whole section: jumping over a commit to see what was there before.

When blame tells you that line 45 was touched by 3f1a8d6 and that commit turns out to be a reformatting, you want to know who touched it before. The answer is to blame that commit's parent:

git blame 3f1a8d6^ -- app.js

3f1a8d6^ is "the parent of 3f1a8d6" (lesson 02-06). By blaming the file at that point, commit 3f1a8d6 ceases to exist as far as the analysis is concerned and the previous one shows up. Repeating the process you can go back layer by layer:

git blame 3f1a8d6^ -- app.js | sed -n '45p'
# → 1c8f4b2 (Carla Vidal 2026-03-02 ...)
git blame 1c8f4b2^ -- app.js | sed -n '45p'
# → b7e2c4a9 (Carla Vidal 2026-02-14 ...)

It is tedious by hand, and that is why git log -L exists (section 6), which does that walk in one go. But the technique is worth knowing, because it always works and works on any version of Git.

Other ways of narrowing the time range:

git blame --since=3.months app.js
git blame v1.0.0..HEAD -- app.js

With --since, lines whose last change predates the limit appear marked with ^ as boundary lines: "this was already here before the period you care about". It is useful for answering "what has been touched in this file this quarter?" without three-year-old noise.

  1. git log -L: the complete evolution of a range of lines

blame gives a snapshot: the current state and who touched each line last. git log -L gives the film: every commit that has affected a range of lines, with its diff.

git log -L :normaliseText:app.js
commit 3f1a8d6c9e2b4a7f1d5c8e3b6a9f2d4c7e1b5a8f
Author: Ana Ferrer <ana.ferrer@example.com>
Date:   Fri May 30 10:18:22 2026 +0200

    Remove invisible characters when normalising task text

    When pasting text from the mail client, tasks were arriving with
    non-breaking spaces (U+00A0) and zero-width marks. The result was
    that two visually identical tasks were not detected as duplicates
    and that the text filter found nothing.

diff --git a/app.js b/app.js
--- a/app.js
+++ b/app.js
@@ -43,5 +43,7 @@
 function normaliseText(text) {
   return text
+    .replace(/ /g, ' ')
+    .replace(/[​-‍]/g, '')
     .trim();
 }

commit b7e2c4a9f1d6c3e8b5a2f7d9c4e1b8a6f3d5c2e9
Author: Carla Vidal <carla.vidal@example.com>
Date:   Sat Feb 14 16:03:55 2026 +0100

    Normalise task text before saving it

    Leftover spaces at the beginning and the end were causing apparent
    duplicate tasks.

diff --git a/app.js b/app.js
--- a/app.js
+++ b/app.js
@@ -41,0 +43,5 @@
+function normaliseText(text) {
+  return text
+    .trim();
+}

Mystery solved in a single command. The two replace calls are not arbitrary: they protect against the invisible characters that arrive when pasting text from email. Deleting that function would have reintroduced a real and hard-to-reproduce fault.

The forms of -L are the same as in blame:

git log -L 43,48:app.js                    # range of lines
git log -L :normaliseText:app.js           # one function
git log -L '/^function deleteTask/',+20:app.js

Very useful combinations:

git log -L :normaliseText:app.js --oneline    # just the list of commits, no diffs
git log -L :normaliseText:app.js -n 3         # the three most recent
git log -L :saveTasks:app.js -L :loadTasks:app.js   # two ranges at once

The comparison between the two tools, which is the one to be clear about:

git blame git log -L
What it shows Current state: who touched each line last Full history: every change in the range
Deleted lines Do not appear They do appear, in the diffs
Output One line per line of code One commit with a diff per change
Question it answers "Who wrote this?" "How did we get to this?"
Typical starting point You are reading the file and something jars You already know which area interests you
Cost Fast Slower (it walks the file's history)

The natural flow is to chain them: blame to locate the area and the commit, log -L to understand the evolution, and git show <sha> to read the complete commit with its message.

  1. Ignoring formatting commits: --ignore-rev and .git-blame-ignore-revs

This section solves the problem that poisons blame most in real projects.

The task-manager team decides to adopt Prettier. Ana runs it over the whole project and commits:

npx prettier --write .
git commit -am "Apply Prettier formatting to the whole project"
[main a3f9c2e] Apply Prettier formatting to the whole project
 3 files changed, 412 insertions(+), 398 deletions(-)

A single commit, no behaviour change… and from now on git blame app.js attributes almost every line of the file to Ana. All the historical information has been buried under a cosmetic commit.

-w helps if the change was only whitespace, but Prettier also reorders, breaks long lines and changes quotes: -w is not enough.

--ignore-rev

git blame --ignore-rev a3f9c2e app.js

Git analyses the file as if that commit did not exist as far as attribution is concerned. The lines it changed are attributed to the previous commit that genuinely touched them. If a line cannot be reassigned cleanly, Git marks it with an asterisk * to warn that the attribution is approximate.

.git-blame-ignore-revs

Repeating --ignore-rev by hand does not scale. The solution is a version-controlled file with the list of commits to ignore:

# .git-blame-ignore-revs
#
# Purely cosmetic commits that should not show up in git blame.
# Requires: git config blame.ignoreRevsFile .git-blame-ignore-revs
#
# Apply Prettier formatting to the whole project (2026-07-25)
a3f9c2e7b1d4c8f2a6e9b3d5c7f1a4e8b2d6c9f3

# Migrate to single quotes throughout the JavaScript (2026-06-02)
7d1c4a8f3e6b9d2c5a8f1e4b7d3c6a9f2e5b8d1c

# Reindent styles.css to 2 spaces (2026-05-14)
2e9b5d8c1f4a7e3b6d9c2f5a8e1b4d7c3f6a9e2b

Two formatting rules that have to be respected:

  • Full 40-character hashes. Abbreviated ones will not do; Git rejects them with an error.
  • Comments with #. Document what each commit was: in a year's time nobody will remember.

To get the full hash:

git rev-parse a3f9c2e

Now it is enabled in the configuration (lesson 01-05):

git config blame.ignoreRevsFile .git-blame-ignore-revs

And from that moment on, every git blame in the repository applies it by itself:

git blame app.js
b7e2c4a9 (Carla Vidal  2026-02-14 16:03:55 +0100  43) function normaliseText(text) {
3f1a8d6c (Ana Ferrer   2026-05-30 10:18:22 +0200  45)     .replace(/ /g, ' ')

The real history is back. And since the file is version-controlled, the whole team benefits as soon as they run that git config once — the same "version-controlled file + one local git config" pattern we saw with core.hooksPath in lesson 06-01.

Other related options:

git blame --ignore-revs-file other-list.txt app.js   # use another file on a one-off basis
git blame --ignore-rev HEAD app.js                   # ignore the last commit
git config blame.markIgnoredLines true               # mark the affected lines with '?'
git config blame.markUnblamableLines true            # mark the non-reassignable ones with '*'

How to do it right from the start: when you are about to apply a mass reformatting, do it in its own isolated commit that touches nothing else, and add it to .git-blame-ignore-revs in the following commit. A reformatting mixed in with functional changes is impossible to ignore cleanly and contaminates the history for ever. That discipline is part of what we shall see in lesson 08-02.

A practical note: the hosting platforms (GitHub, GitLab) also read .git-blame-ignore-revs and apply it in their blame view, so the file serves both locally and on the web.

  1. Solving the normaliseText mystery

Recapping Carla's complete investigation, which is the standard procedure faced with any "what is this here for?":

# 1. Who touched these lines last? Ignoring noise.
git blame -w -C --date=short -L :normaliseText:app.js app.js
b7e2c4a9 (Carla Vidal 2026-02-14 43) function normaliseText(text) {
b7e2c4a9 (Carla Vidal 2026-02-14 44)   return text
3f1a8d6c (Ana Ferrer  2026-05-30 45)     .replace(/ /g, ' ')
3f1a8d6c (Ana Ferrer  2026-05-30 46)     .replace(/[​-‍]/g, '')
b7e2c4a9 (Carla Vidal 2026-02-14 47)     .trim();
b7e2c4a9 (Carla Vidal 2026-02-14 48) }
# 2. What did the commit that added the odd part say?
git show 3f1a8d6 --stat
commit 3f1a8d6c9e2b4a7f1d5c8e3b6a9f2d4c7e1b5a8f
Author: Ana Ferrer <ana.ferrer@example.com>
Date:   Fri May 30 10:18:22 2026 +0200

    Remove invisible characters when normalising task text

    When pasting text from the mail client, tasks were arriving with
    non-breaking spaces (U+00A0) and zero-width marks. [...]

 app.js | 2 ++
# 3. What has the function's complete evolution been?
git log -L :normaliseText:app.js --oneline
# 4. Who else uses this function? (pickaxe, lesson 02-06)
git log -S "normaliseText" --oneline
grep -n "normaliseText" *.js
b7e2c4a9 Normalise task text before saving it
3f1a8d6c Remove invisible characters when normalising task text
c9a2e7f4 Move the helper functions from index.html to app.js

Four commands, two minutes, and a definitive answer: the function is not to be touched, and now Carla also knows that it ought to carry an explanatory comment. She adds it:

/**
 * Normalises a task's text before saving it.
 *
 * Removes non-breaking spaces (U+00A0) and zero-width marks (U+200B-U+200D,
 * U+FEFF), which arrive when pasting text from the mail client and cause
 * two visually identical tasks to be treated as different.
 * See commit 3f1a8d6.
 */
function normaliseText(text) {
  return text
    .replace(/ /g, ' ')
    .replace(/[​-‍]/g, '')
    .trim();
}

Note the detail: the comment references the commit. It is the way to connect the code with the full explanation without repeating it, and it saves the next person this whole investigation.

  1. Blame in practice: attribution, not fault

The command's name is an unfortunate historical accident. "To blame" means to hold responsible for something bad, and that has created an unpleasant association: the idea that git blame is for pointing at whoever made a mess.

That is not what it is for. Git even has an alias with a better name, inherited from other systems:

git annotate app.js      # the same thing, with a slightly different format

And "to annotate" describes much better what it does: enriching each line with its provenance.

The legitimate uses, all of them constructive:

  • Understanding the why. This lesson's case. The commit message contains the context the code cannot express.
  • Finding out who to ask. Not to reproach, but because whoever wrote something three months ago has context you do not have.
  • Dating a change. "Is this line before or after the migration?"
  • Assessing the risk of touching something. Three-year-old code nobody has touched and nobody remembers: proceed with care.
  • Documenting backwards. As Carla has just done.

And this leads to the lesson's underlying conclusion, which is worth stating without hedging:

git blame is worth exactly what your commit messages are worth.

If the commit that comes up says "Remove invisible characters when normalising task text" with three lines explaining why, blame has solved your problem. If it says "fix", "changes" or "asdf", blame has told you who and when, but not why, which was the only thing you needed.

That is the direct connection with lesson 08-01: Writing Good Commit Messages. There we shall see how to write a message that explains the intent and not the mechanics. And the definitive argument for doing it properly is this: you do not write the message for today, you write it for the git blame somebody will run two years from now — and that somebody will probably be you.

In the same way, atomic commits (lesson 08-02) make blame point at a comprehensible change rather than at a 900-line commit entitled "Friday's work".

Common Mistakes and Tips

Mistake 1: believing blame says who wrote the line. It says who touched it last. Without -w, -M, -C and --ignore-rev, the answer is usually "the last person to run the formatter".

Mistake 2: blaming the whole file. Hundreds of lines of output to investigate five. Always use -L, and above all -L :function:file.

Mistake 3: stopping at the first commit that shows up. If it is a move or a reformatting, keep pulling the thread with git blame <sha>^ -- <file> or go straight to git log -L.

Mistake 4: using blame to look for a line that no longer exists. blame only sees the current state. For deleted code, git log -L, git log -S "text" (pickaxe) or git log -p -- <file>.

Mistake 5: abbreviated hashes in .git-blame-ignore-revs. Git demands all 40 characters and fails with an unclear error if you leave them out. Get them with git rev-parse.

Mistake 6: mixing reformatting and functional changes in one commit. That commit can no longer be ignored cleanly and it contaminates the file's blame for ever. Reformatting always goes in its own commit.

Mistake 7: using blame to apportion responsibility. As well as being toxic, it is technically unreliable: the attribution depends on the options you use.

Tip 1: create an alias. You will see them in lesson 06-04, but bring it forward now:

git config --global alias.praise "blame -w -C --date=short"

Tip 2: chain the three tools. blame to locate, log -L for the evolution, show for the complete commit. It is the standard procedure and it does not fail.

Tip 3: when you solve a mystery, document it in the code. A comment with the reference to the commit saves the next people the investigation.

Tip 4: blame in the editor. Practically every modern editor has an integration (GitLens in VS Code, :Git blame in Vim with fugitive, IntelliJ's built-in annotation). They show the commit for the line under the cursor without leaving the file. Configure them with -w if they let you.

Tip 5: combine it with bisect. When bisect (lesson 06-02) gives you the guilty commit, blame on the lines it changed tells you what was there before and why it was like that.

Exercises

Exercise 1: basic and narrowed-down blame

Create a repository with an app.js file that evolves over five commits from three different authors (you can change author with git commit --author="Name <email>"). Then:

  1. Run git blame app.js and check that each line is attributed to the right commit.
  2. Narrow down with -L to a range of three lines.
  3. Use -L :functionName:app.js to narrow down to a function.
  4. Try -s, -e and --date=short and compare the outputs.

Exercise 2: the effect of a reformatting and how to ignore it

On the previous repository:

  1. Make a commit that reindents the whole file (from 2 to 4 spaces) without changing anything else.
  2. Run git blame and check that every line now belongs to that commit.
  3. Check that -w recovers the original attribution.
  4. Make another commit that changes double quotes to single ones throughout the file, and check that -w is no longer enough.
  5. Create a .git-blame-ignore-revs with that commit, configure blame.ignoreRevsFile and check that the attribution comes back.

Exercise 3: the complete history of a function

On the same repository:

  1. Use git log -L :functionName:app.js and describe the function's evolution.
  2. Add a line to the function, commit it, and delete it in another commit.
  3. Check that git blame shows no trace of that line, but git log -L does.
  4. Locate that same deleted line using the git log -S pickaxe from lesson 02-06.

Solutions

Solution 1:

mkdir /tmp/practice-blame && cd /tmp/practice-blame
git init -b main

cat > app.js <<'END'
function normaliseText(text) {
  return text.trim();
}
END
git add . && git commit -q -m "Add the basic text normalisation" \
  --author="Carla Vidal <carla.vidal@example.com>"

cat > app.js <<'END'
function normaliseText(text) {
  return text.trim();
}

function saveTasks(tasks) {
  localStorage.setItem("tasks", JSON.stringify(tasks));
}
END
git commit -qam "Add saving tasks to localStorage" \
  --author="Ana Ferrer <ana.ferrer@example.com>"

cat > app.js <<'END'
function normaliseText(text) {
  return text
    .replace(/ /g, " ")
    .trim();
}

function saveTasks(tasks) {
  localStorage.setItem("tasks", JSON.stringify(tasks));
}
END
git commit -qam "Convert non-breaking spaces when normalising" \
  --author="Bruno Salas <bruno.salas@example.com>"
git blame --date=short app.js
5f2a8c1 (Carla Vidal 2026-08-01 1) function normaliseText(text) {
9d3e7b4 (Bruno Salas 2026-08-01 2)   return text
9d3e7b4 (Bruno Salas 2026-08-01 3)     .replace(/ /g, " ")
9d3e7b4 (Bruno Salas 2026-08-01 4)     .trim();
5f2a8c1 (Carla Vidal 2026-08-01 5) }
5f2a8c1 (Carla Vidal 2026-08-01 6)
2c7f4a9 (Ana Ferrer  2026-08-01 7) function saveTasks(tasks) {

Note an instructive detail: line 2 is attributed to Bruno even though return text was conceptually written by Carla. That is because Bruno modified that line when splitting the expression over several lines. blame operates on lines, not on intentions.

git blame -L 1,3 app.js
git blame -L :normaliseText:app.js
git blame -s app.js          # hash and line only
git blame -e app.js          # with the email

Solution 2:

# 1. Indentation reformatting
sed -i 's/^  /    /' app.js
sed -i 's/^    \./        ./' app.js
git commit -qam "Reindent app.js to 4 spaces" \
  --author="Ana Ferrer <ana.ferrer@example.com>"

# 2. Everything belongs to Ana now
git blame --date=short app.js
4a8c2e7 (Ana Ferrer 2026-08-01 2)     return text
4a8c2e7 (Ana Ferrer 2026-08-01 3)         .replace(/ /g, " ")
4a8c2e7 (Ana Ferrer 2026-08-01 4)         .trim();
# 3. With -w the real attribution comes back
git blame -w --date=short app.js
9d3e7b4 (Bruno Salas 2026-08-01 2)     return text
9d3e7b4 (Bruno Salas 2026-08-01 3)         .replace(/ /g, " ")
# 4. Quote change: -w is no longer enough
sed -i 's/"/'"'"'/g' app.js
git commit -qam "Migrate to single quotes throughout the JavaScript" \
  --author="Ana Ferrer <ana.ferrer@example.com>"

git blame -w --date=short app.js
1b6d9f3 (Ana Ferrer 2026-08-01 3)         .replace(/ /g, ' ')
1b6d9f3 (Ana Ferrer 2026-08-01 8)     localStorage.setItem('tasks', ...

The change is not a whitespace one, so -w does not see through it.

# 5. The file of ignored commits
git rev-parse HEAD > /tmp/hash.txt
{
  echo "# Cosmetic commits ignored in git blame"
  echo "#"
  echo "# Migrate to single quotes throughout the JavaScript"
  cat /tmp/hash.txt
} > .git-blame-ignore-revs

git add .git-blame-ignore-revs
git commit -qm "Add the list of commits ignored in blame"
git config blame.ignoreRevsFile .git-blame-ignore-revs

git blame -w --date=short app.js
9d3e7b4 (Bruno Salas 2026-08-01 3)         .replace(/ /g, ' ')
2c7f4a9 (Ana Ferrer  2026-08-01 8)     localStorage.setItem('tasks', ...

The real attribution is back, and now it is automatic for the whole repository.

Solution 3:

git log -L :normaliseText:app.js --oneline
1b6d9f3 Migrate to single quotes throughout the JavaScript
4a8c2e7 Reindent app.js to 4 spaces
9d3e7b4 Convert non-breaking spaces when normalising
5f2a8c1 Add the basic text normalisation

Four commits: two cosmetic and two of real content. The complete film, including what blame with --ignore-revs deliberately hides.

# 2. Add and delete a line
sed -i "s|        .trim();|        .replace(/\\\\s+/g, ' ')\n        .trim();|" app.js
git commit -qam "Collapse multiple spaces when normalising"

sed -i "/replace(\/\\\\s+\/g/d" app.js
git commit -qam "Revert the space collapsing: it broke tabs"
# 3. blame does not see it; log -L does
git blame app.js | grep "s+" || echo "(blame does not show the deleted line)"
git log -L :normaliseText:app.js --oneline | head -3
(blame does not show the deleted line)
8e2c5f1 Revert the space collapsing: it broke tabs
3d9a7b4 Collapse multiple spaces when normalising
1b6d9f3 Migrate to single quotes throughout the JavaScript
# 4. The pickaxe finds it all the same
git log -S "\\s+" --oneline -- app.js
8e2c5f1 Revert the space collapsing: it broke tabs
3d9a7b4 Collapse multiple spaces when normalising

-S counts occurrences of the string and shows the commits where that number changes: the one that introduced it and the one that removed it. It is the tool for what is no longer there.

Conclusion

git blame turns the history into a line-by-line annotation of the code in front of you. The essentials:

  • It answers "which commit touched this line last?", on the file's current state. It does not say who wrote it originally, and it does not see deleted lines.
  • The output has five columns — hash, author, date, line number and content; ^ marks the boundary commit and 00000000 the uncommitted changes.
  • -L narrows down, and -L :function:file is the most convenient way of doing it without counting lines.
  • -w, -M and -C are what separates a useful attribution from a useless one: they ignore whitespace and follow moved or copied code, within and between files, at increasing levels (-C, -C -C, -C -C -C).
  • git blame <sha>^ -- <file> jumps over a commit to see what was there before: the manual technique for pulling the thread.
  • git log -L gives the complete film where blame gives the snapshot: every commit that affected a range, with its diffs, including deleted lines.
  • --ignore-rev and .git-blame-ignore-revs (with blame.ignoreRevsFile) neutralise mass reformattings and give the real history back. For it to work, the reformatting has to be in its own isolated commit.
  • The standard procedure faced with any "what is this here for?": blamelog -Lshow, and grep/log -S to see who else depends on it.
  • And most importantly: it is attribution, not fault, and it is worth whatever your commit messages are worth (lesson 08-01).

Carla and the team now have a considerable querying arsenal: log with its filters and its pickaxe, bisect, blame, log -L, show. The problem is that the useful invocations are getting longer and longer — git blame -w -C --date=short -L :function:app.js app.js is not something you type by hand twice — and that there are views of the history we still do not know how to ask for: the complete branch graph, merges only, who has contributed how much, or a one-line format with colours that reads at a glance.

It is time to level up with git log and, above all, to stop typing all this by hand. That is lesson 06-04: Git Log and Aliases.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved