Carla has already fixed task deletion thanks to bisect. But while she was in app.js she came across this:
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
- What
git blameis exactly - Reading the output
- Narrowing down:
-Lby lines and by function - Seeing beyond the noise:
-w,-Mand-C - Travelling in time:
<sha>^and--since git log -L: the complete evolution of a range of lines- Ignoring formatting commits:
--ignore-revand.git-blame-ignore-revs - Solving the
normaliseTextmystery - Blame in practice: attribution, not fault
- What
git blame is exactly
git blame is exactlygit 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,
blamepoints 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-Spickaxe from lesson 02-06. - It computes nothing new. All the information is already in the data model from lesson 01-04:
blamewalks the file's history comparing versions and attributes each line. It is a query, not stored metadata.
- Reading the output
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 annotateAnd the one really used day to day, because it combines just the right things:
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.
- Narrowing down:
-L by lines and by function
-L by lines and by functionYou almost never want the whole file. -L limits the analysis to a range:
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):
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-injavascriptpattern is activated by declaring it in.gitattributeswith*.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.
- Seeing beyond the noise:
-w, -M and -C
-w, -M and -CHere 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
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
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
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:
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:
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, ' ')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.
- Travelling in time:
<sha>^ and --since
<sha>^ and --sinceblame 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 commitFrom 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:
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:
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.
git log -L: the complete evolution of a range of lines
git log -L: the complete evolution of a range of linesblame 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.
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.jsVery 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 onceThe 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.
- Ignoring formatting commits:
--ignore-rev and .git-blame-ignore-revs
--ignore-rev and .git-blame-ignore-revsThis 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:
[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 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:
Now it is enabled in the configuration (lesson 01-05):
And from that moment on, every git blame in the repository applies it by itself:
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-revsin 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-revsand apply it in their blame view, so the file serves both locally and on the web.
- Solving the
normaliseText mystery
normaliseText mysteryRecapping 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.jsb7e2c4a9 (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) }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 ++# 4. Who else uses this function? (pickaxe, lesson 02-06)
git log -S "normaliseText" --oneline
grep -n "normaliseText" *.jsb7e2c4a9 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.
- 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:
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 blameis 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:
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:
- Run
git blame app.jsand check that each line is attributed to the right commit. - Narrow down with
-Lto a range of three lines. - Use
-L :functionName:app.jsto narrow down to a function. - Try
-s,-eand--date=shortand compare the outputs.
Exercise 2: the effect of a reformatting and how to ignore it
On the previous repository:
- Make a commit that reindents the whole file (from 2 to 4 spaces) without changing anything else.
- Run
git blameand check that every line now belongs to that commit. - Check that
-wrecovers the original attribution. - Make another commit that changes double quotes to single ones throughout the file, and check that
-wis no longer enough. - Create a
.git-blame-ignore-revswith that commit, configureblame.ignoreRevsFileand check that the attribution comes back.
Exercise 3: the complete history of a function
On the same repository:
- Use
git log -L :functionName:app.jsand describe the function's evolution. - Add a line to the function, commit it, and delete it in another commit.
- Check that
git blameshows no trace of that line, butgit log -Ldoes. - Locate that same deleted line using the
git log -Spickaxe 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>"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 emailSolution 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.js4a8c2e7 (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();
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.js1b6d9f3 (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.js9d3e7b4 (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:
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 -38e2c5f1 Revert the space collapsing: it broke tabs 3d9a7b4 Collapse multiple spaces when normalising 1b6d9f3 Migrate to single quotes throughout the JavaScript
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 and00000000the uncommitted changes. -Lnarrows down, and-L :function:fileis the most convenient way of doing it without counting lines.-w,-Mand-Care 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 -Lgives the complete film whereblamegives the snapshot: every commit that affected a range, with its diffs, including deleted lines.--ignore-revand.git-blame-ignore-revs(withblame.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?":
blame→log -L→show, andgrep/log -Sto 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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
