The previous five lessons solved problems with names: I have lost commits, my branch has diverged, the repository is corrupt. This one deals with the category that has no name and that is, in practice, the most frequent:

Git is doing something I do not understand, and I do not even know where to start looking.

A file that shows up as modified when nobody has touched it. A .gitignore that ignores the wrong things. A push that takes forty seconds in a small project. A hook that does not run. A configuration that says one thing and a behaviour that says another. A git log that hides commits that exist.

They are not breakdowns: they are mismatches between what you think is there and what actually is. And for that Git has a toolbox we have barely touched on: trace variables that show what is happening underneath, commands that say where each setting comes from, and the plumbing layer that queries the database without interpretation.

More important than the tools is the method, because without it the tools produce noise. We will close with a four-step diagnostic method and with a real investigation into task-manager that combines bisect and blame to go from the symptom to the commit, and from the commit to the line and its reason.

Contents

  1. The diagnostic method, in four steps
  2. Trace variables: seeing what Git does underneath
  3. Debugging the effective configuration
  4. Debugging paths and patterns: check-ignore, check-attr, ls-files
  5. Plumbing for inspecting the graph
  6. Narrowing down with git diff --stat and git log
  7. A real investigation: from bisect to blame
  8. A catalogue of mysteries and their commands

  1. The diagnostic method, in four steps

Before the tools, the procedure. It is what separates a twenty-minute investigation from a wasted afternoon.

flowchart TD
    A["1. REPRODUCE<br/>The minimal command that causes the symptom"]
    B["2. ISOLATE<br/>Remove variables: clean repo, no config,<br/>no hooks, another machine"]
    C["3. QUERY THE REAL STATE<br/>Not the remembered one. Plumbing and traces"]
    D["4. TEST THE HYPOTHESIS<br/>A concrete, falsifiable prediction"]
    A --> B --> C --> D
    D -->|"It does not hold"| C
    D -->|"It holds"| E["Fix the cause,<br/>not the symptom"]

Step 1: reproduce

Reduce the symptom to the minimal command that causes it.

# Bad: "the push fails"
# Good:
git push origin GT-241

If you cannot reproduce it at will, you cannot verify that you have fixed it. And very often reducing the case already reveals the cause: "it only fails with this branch", "only with this file", "only from Carla's laptop".

Step 2: isolate

Remove variables one at a time:

# Without the global or system configuration
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git status

# Without hooks (lesson 06-01)
git -c core.hooksPath=/dev/null commit -m "test"
git commit --no-verify -m "test"

# Without aliases or repository configuration
git -c include.path= <command>

# In a freshly cloned repository, in /tmp
git clone <url> /tmp/clean-test && cd /tmp/clean-test

If the problem goes away without the global configuration, you already know where to look. If it persists in a clean clone, the problem is in the repository or on the server, not on your machine.

Step 3: query the real state, not the remembered one

This is the step that is skipped most often and the one that resolves matters most often.

Do not ask... Ask...
"I think that file is ignored" git check-ignore -v <file>
"I have user.email set correctly" git config --list --show-origin --show-scope
"That file is in the index" git ls-files --stage <file>
"That branch points at that commit" git rev-parse <branch>
"The file is normal text" git ls-files --eol <file>
"The remote is on that commit" git ls-remote origin <branch>
"That hook is running" GIT_TRACE=1 git commit ...

Memory and documentation lie; commands do not.

Step 4: test the hypothesis

Formulate a concrete, falsifiable prediction before touching anything:

"If the cause is that .gitattributes marks *.css as text eol=crlf, then git check-attr eol styles.css must say crlf, and git ls-files --eol styles.css must show w/crlf."

Run it, check it, and only then fix it. If the prediction fails, the hypothesis was a bad one: go back to step 3. Changing things at random until "it works" leaves the problem latent and prevents you knowing what caused it.

  1. Trace variables: seeing what Git does underneath

Git has a tracing system that is switched on with environment variables. They are the most direct way of seeing what is really going on.

Variable What it shows When to use it
GIT_TRACE=1 Every subcommand Git runs, with its arguments Odd aliases, hooks, commands that call others
GIT_TRACE_SETUP=1 How Git locates the repository: .git, root, prefix "Not a repository", worktrees, submodules
GIT_TRACE_PERFORMANCE=1 Timings per stage Slow commands (08-06)
GIT_TRACE_PACKET=1 Every packet of the protocol with the server fetch/push that fail or run slowly
GIT_TRACE_PACK_ACCESS=1 Accesses to packfiles Object database performance
GIT_CURL_VERBOSE=1 The complete HTTP dialogue Problems with HTTPS remotes
GIT_SSH_COMMAND="ssh -v" The complete SSH dialogue Permission denied (publickey)
GIT_TRACE2_PERF=1 Modern, structured traces Fine-grained analysis
GIT_TRACE_SHALLOW=1 Shallow clone logic --depth (10-04)

All of them accept an absolute path instead of 1, to write to a file:

GIT_TRACE=/tmp/trace.log git status

GIT_TRACE: what Git is actually running

GIT_TRACE=1 git status
10:14:22.104 git.c:463               trace: built-in: git status
10:14:22.108 run-command.c:657       trace: run_command: 'gpg' '--status-fd=2' '-bsau' 'ana.ferrer@example.com'

There you can see, for instance, whether Git is calling gpg (commit signing, lesson 08-05), a hook, or a credential helper.

It is especially useful with aliases that do odd things (lesson 06-04):

GIT_TRACE=1 git lg
10:15:03.221 git.c:750               trace: alias expansion: lg => 'log' '--graph' '--abbrev-commit' '--date=relative'
10:15:03.222 git.c:463               trace: built-in: git log --graph --abbrev-commit --date=relative

And with hooks that do not run:

GIT_TRACE=1 git commit -m "test" 2>&1 | grep -i hook

If nothing appears, the hook is not being launched. Usual causes: it is not executable (chmod +x), core.hooksPath points somewhere else, or the file name has an extension.

ls -l .git/hooks/pre-commit
git config --get core.hooksPath

GIT_TRACE_SETUP: where Git thinks it is

GIT_TRACE_SETUP=1 git status
10:16:41.003 trace.c:318  setup: git_dir: /home/ana/projects/task-manager/.git
10:16:41.003 trace.c:319  setup: git_common_dir: /home/ana/projects/task-manager/.git
10:16:41.003 trace.c:320  setup: worktree: /home/ana/projects/task-manager
10:16:41.003 trace.c:321  setup: cwd: /home/ana/projects/task-manager/src
10:16:41.003 trace.c:322  setup: prefix: src/

It resolves an entire family of mysteries:

  • "Not a git repository" while inside one: it tells you which .git it finds (or that it finds none).
  • You are working in a repository that is not the one you think: typical with submodules (lesson 06-05) and worktrees (06-06), where git_dir and git_common_dir differ.
  • The .gitignore patterns do not work as you expect: the prefix explains where paths are interpreted from.

GIT_SSH_COMMAND and GIT_CURL_VERBOSE: network problems

Picking up section 5.8 of lesson 09-01, here is the complete version.

GIT_SSH_COMMAND="ssh -v" git fetch 2>&1 | head -40
debug1: Reading configuration data /home/ana/.ssh/config
debug1: /home/ana/.ssh/config line 4: Applying options for git.example.com
debug1: Connecting to git.example.com [192.0.2.10] port 22.
debug1: Offering public key: /home/ana/.ssh/id_rsa RSA SHA256:xxxx agent
debug1: Authentications that can continue: publickey
debug1: Offering public key: /home/ana/.ssh/id_ed25519 ED25519 SHA256:yyyy agent
debug1: Server accepts key: /home/ana/.ssh/id_ed25519 ED25519 SHA256:yyyy agent
debug1: Authentication succeeded (publickey).

Every line is a diagnosis:

Line What it tells you
Reading configuration data ~/.ssh/config Which configuration file applies
Applying options for <host> Which Host block matched
Connecting to <ip> port 22 Where it is actually going (the right host?)
Offering public key: <path> Which keys it offers and in what order
Server accepts key Which one it accepted
Authentications that can continue: publickey It rejected the previous one and is still trying

The classic case: you have several keys, SSH offers the wrong one first and the server cuts you off after N attempts. The solution is a block in ~/.ssh/config:

Host git.example.com
    User git
    IdentityFile ~/.ssh/id_ed25519_example
    IdentitiesOnly yes

IdentitiesOnly yes is the key part: it forces it to offer only that one.

For HTTPS:

GIT_CURL_VERBOSE=1 git fetch 2>&1 | head -40
* Connected to git.example.com (192.0.2.10) port 443
> GET /team/task-manager.git/info/refs?service=git-upload-pack HTTP/2
> User-Agent: git/2.45.0
< HTTP/2 401
< www-authenticate: Basic realm="Git"
* Issue another request to this URL
> Authorization: Basic YW5hOnh4eA==
< HTTP/2 200

It diagnoses corporate proxies, certificates, redirects and credentials. A persistent 401 points at the credential helper (lesson 04-03):

git config --get credential.helper
git credential-cache exit          # empty the cached credentials
printf 'protocol=https\nhost=git.example.com\n\n' | git credential fill

Careful: GIT_CURL_VERBOSE can display Authorization headers. Do not paste its output into a public ticket without reviewing it (lesson 08-05).

GIT_TRACE_PACKET: the protocol, packet by packet

The deepest level. It shows the exact dialogue with the server.

GIT_TRACE_PACKET=1 git fetch 2>&1 | head -30
packet:        git< version 2
packet:        git< agent=git/2.45.0
packet:        git< ls-refs=unborn
packet:        git< fetch=shallow wait-for-done
packet:        git< object-format=sha1
packet:        git> command=ls-refs
packet:        git> peel
packet:        git> ref-prefix refs/heads/
packet:        git< b52c9d1e4a7f3c8b6d2e9a5f1c7b3d8e4a6f2c9b refs/heads/main
packet:        git< 7d3a8f4a2c6e9b1d5f3a8c6e2b9d4f7a1c5e8b3d refs/heads/GT-241

What it is really for:

  • Seeing which references the server announces and which protocol version is negotiated.
  • Diagnosing a slow fetch: if the server announces 40,000 references, there is the problem (lesson 08-06, reference hygiene).
  • Understanding a push rejected by a server hook (lesson 07-06): the hook's message travels in these packets.
  • Checking whether protocol v2 is in use, which is far more efficient:
git config --get protocol.version      # should be 2 in a modern Git
git -c protocol.version=2 fetch

Traces for all commands, temporarily

# In the current session
export GIT_TRACE=1
export GIT_TRACE_SETUP=1
# ...reproduce the problem...
unset GIT_TRACE GIT_TRACE_SETUP

And a script to capture everything at once when a report has to be sent:

#!/usr/bin/env bash
# capture-trace.sh <git command...>
LOG=/tmp/git-trace-$(date +%s).log
GIT_TRACE="$LOG" \
GIT_TRACE_SETUP="$LOG" \
GIT_TRACE_PERFORMANCE="$LOG" \
GIT_TRACE_PACKET="$LOG" \
  "$@"
echo "Trace in: $LOG"
echo "REVIEW the file before sharing it: it may contain credentials."

  1. Debugging the effective configuration

Picking up from lesson 01-05. Git reads the configuration from several places and the last one wins. When something behaves unexpectedly, the configuration is suspect number one.

git config --list --show-origin --show-scope
system  /etc/gitconfig          core.autocrlf=false
global  /home/ana/.gitconfig    user.name=Ana Ferrer
global  /home/ana/.gitconfig    user.email=ana.ferrer@example.com
global  /home/ana/.gitconfig    pull.rebase=true
local   .git/config             remote.origin.url=git.example.com:team/task-manager.git
local   .git/config             user.email=ana@personal.example
local   .git/config             core.autocrlf=input

Two new columns compared with the usual --list:

  • --show-scope: which level it is at (system, global, local, worktree, command).
  • --show-origin: the exact file, including those brought in by include.path.

In the example, user.email appears twice: the local one wins. There is the explanation for "I have set the email correctly and the commits come out with a different one" (lesson 09-01, section 5.3).

The effective value of a setting

# Which value wins
git config --get user.email

# ALL the defined values, in order of precedence (the last one wins)
git config --get-all user.email

# With their origin
git config --get-all --show-origin user.email

# Everything beginning with a prefix
git config --get-regexp '^remote\.'
git config --get-regexp '^alias\.'
git config --get-regexp '^advice\.'

That last one deserves attention: if somebody copied a configuration that silences the advice.* settings, you are missing the suggestions that solve half the problems in this module (lesson 09-01, section 6).

Precedence and overrides

Level File Priority
system /etc/gitconfig Lowest
global ~/.gitconfig or ~/.config/git/config Medium
local .git/config High
worktree .git/config.worktree Highest (if extensions.worktreeConfig)
-c on the command line Maximum
GIT_* variables Environment Depends on the setting
# Try a value without changing anything, just for this command
git -c core.autocrlf=false status
git -c diff.noprefix=false diff

# See what happens WITHOUT the user configuration
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git status

The last one is the definitive test for "is it something in my configuration?".

Conditional configuration, the source of surprises

# ~/.gitconfig
[includeIf "gitdir:~/projects/work/"]
    path = ~/.gitconfig-work

[includeIf "gitdir:~/projects/personal/"]
    path = ~/.gitconfig-personal

It is an excellent feature for separating identities, but it produces the bafflement of "it works here and not there". --show-origin untangles it in a second, because it names the included file.

And watch out for the trailing slash: gitdir:~/projects/work/ (with /) matches the directory and its descendants; without it, the behaviour changes.

  1. Debugging paths and patterns: check-ignore, check-attr, ls-files

This is where the most frequent day-to-day mysteries live.

git check-ignore -v: why a file is (or is not) ignored

Picking up from lesson 08-03:

git check-ignore -v data/dump.sql
.gitignore:12:*.sql	data/dump.sql

Reading: rules file : line : pattern that decides, and the file affected. There is no possible ambiguity.

# Several files at once
git check-ignore -v app.js data/dump.sql node_modules/x/y.js

# Everything being ignored in the project
git status --ignored --short

# And the baffling case: why is it NOT ignored?
git check-ignore -v --no-index local-config.json
echo $?

If check-ignore returns nothing and the exit code is 1, no rule is ignoring it. And if the file nonetheless appears in git status despite being ignored, the cause is almost always the same:

git ls-files --error-unmatch local-config.json
local-config.json

It is in the index. .gitignore only affects untracked files; one that is already tracked carries on being watched even if the pattern matches. The solution is the one from lesson 08-03:

git rm --cached local-config.json
git commit -m "chore: stop version-controlling the local configuration"

Other less obvious causes, which check-ignore -v reveals by naming the rules file:

File that may be deciding Where it lives
The repository's .gitignore In any directory of the project
.git/info/exclude Local, not version-controlled
core.excludesFile Global, typically ~/.gitignore_global
A .gitignore in a parent directory A more specific one wins
A negation rule !pattern Reactivates something ignored earlier

And the classic trap with directories:

git check-ignore -v screenshots/screen.png
.gitignore:8:screenshots/	screenshots/screen.png

If a directory is ignored, Git does not even go into it, so a negation rule for a file inside it does not work:

screenshots/
!screenshots/important.png     # DOES NOT work: Git does not go into screenshots/
screenshots/*
!screenshots/important.png     # DOES work

git check-attr: which attributes apply

Picking up from lesson 08-04:

git check-attr -a styles.css
styles.css: text: set
styles.css: eol: lf
styles.css: diff: css
# One particular attribute
git check-attr eol text diff -- styles.css index.html app.js

# Where each rule comes from
git check-attr -a --source=HEAD styles.css

# For all tracked files
git ls-files | git check-attr --stdin -a | grep -v unspecified

This explains a whole family of mysteries at a stroke:

Symptom Command Usual cause
A file's diff comes out as binary git check-attr diff -- <f> -diff or binary in .gitattributes
A file never merges git check-attr merge -- <f> merge=binary or merge=ours
Line endings change by themselves git check-attr text eol -- <f> text, eol=crlf, or core.autocrlf
A file is transformed on committing git check-attr filter -- <f> A filter (LFS, clean/smudge)
git blame gives odd results .git-blame-ignore-revs (06-03)

git ls-files: what is really in the index

git status interprets; git ls-files shows. It is the direct window onto the index.

# What is tracked
git ls-files

# With metadata: mode, hash and stage
git ls-files --stage app.js
100644 6f2b9d4a8c1e5f3b7d9a2c6e4f8b1d3a5c7e9f2b 0	app.js
Field Meaning
100644 A normal file. 100755 = executable. 120000 = symbolic link. 160000 = submodule
Hash The blob that is in the index
0 The stage. 0 = normal. 1/2/3 = conflict (lesson 03-05)

That last field is gold during a conflict:

git ls-files --stage | awk '$3 != 0'
100644 4f8a2e6... 1	app.js
100644 b52c9d1... 2	app.js
100644 7d3a8f4... 3	app.js

Three stages: the common base, ours and theirs. Exactly what we saw in lesson 03-05, now visible in the raw.

The modes that solve mysteries:

# Why does Git say this script has changed if all I did was set permissions?
git ls-files --stage build.sh
100644 8a1f6c3... 0	build.sh
ls -l build.sh
-rwxr-xr-x 1 ana ana 412 Jul 28 10:02 build.sh

Index 100644, disk executable: that is the change. It is corrected with:

git update-index --chmod=+x build.sh

And the --eol mode, which solves the line-endings mystery:

git ls-files --eol app.js styles.css index.html
i/lf    w/crlf  attr/text=auto  	app.js
i/lf    w/lf    attr/            	styles.css
i/crlf  w/crlf  attr/-text       	index.html
Column Meaning
i/ How it is in the index (what is stored in the repository)
w/ How it is in the working directory (on disk)
attr/ Which attribute applies to it

The first line is Carla's healthy case on Windows: LF in the repository, CRLF on her disk (lesson 08-04). The third is a problem: i/crlf means CRLF has been stored inside the repository, which is precisely what you do not want.

Other useful modes:

git ls-files --others                        # untracked
git ls-files --others --exclude-standard      # untracked and not ignored
git ls-files --ignored --exclude-standard     # ignored
git ls-files --deleted                        # deleted from disk but still in the index
git ls-files --modified                       # modified
git ls-files --unmerged                       # in conflict

--others --exclude-standard is exactly the list of "new files" that git status shows, without the rest of the output. And --deleted explains the "I deleted the file and Git keeps complaining".

  1. Plumbing for inspecting the graph

When the question is about the repository's structure, the answer lies in the plumbing layer we saw in lesson 01-04.

git rev-parse: translating anything into a hash

git rev-parse HEAD
git rev-parse main
git rev-parse HEAD~3
git rev-parse GT-241@{2}
git rev-parse v1.5.0^{commit}      # an annotated tag points at a tag object; this gives the commit

And its informational modes, which answer questions about the environment:

git rev-parse --show-toplevel        # the project root
git rev-parse --git-dir              # where the .git is
git rev-parse --git-common-dir       # the shared .git (worktrees, 06-06)
git rev-parse --abbrev-ref HEAD      # the current branch's name
git rev-parse --is-inside-work-tree  # true/false
git rev-parse --is-bare-repository   # true/false
git rev-parse --symbolic-full-name @{u}   # the full upstream
/home/ana/projects/task-manager
/home/ana/projects/task-manager/.git
GT-241
true
refs/remotes/origin/GT-241

They are the basis of any script that has to work with repositories robustly.

git rev-list: counting and listing commits

# How many commits there are
git rev-list --count HEAD

# How many ahead and behind (lesson 09-03)
git rev-list --left-right --count main...origin/main

# Commits that touched a file
git rev-list HEAD -- app.js | head

# All reachable objects (lesson 08-06)
git rev-list --objects --all | wc -l

# The root commits (is there more than one? → unrelated histories)
git rev-list --max-parents=0 HEAD

# Only merges, or only what are not merges
git rev-list --merges HEAD | head
git rev-list --no-merges HEAD | head

That --max-parents=0 is the exact diagnosis for the unrelated histories of lesson 09-03: if it returns two hashes, there are two roots.

git cat-file --batch-check: bulk inspection

# The type and size of an object
echo 4f8a2e6 | git cat-file --batch-check
4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a commit 241
# Check whether it exists (without dumping the content)
git cat-file -e 4f8a2e6 && echo "it exists" || echo "it does not exist"

# A report of all the objects by type
git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype)' \
  | sort | uniq -c
  18432 blob
   3204 commit
  21908 tree
     47 tag

It is the same technique as the size analysis from lesson 08-06, applied here to understanding the repository's composition.

git verify-pack: what is inside a packfile

git verify-pack -v .git/objects/pack/pack-*.idx | head -5
b52c9d1e4a7f3c8b6d2e9a5f1c7b3d8e4a6f2c9b commit 241 168 12
7d3a8f4a2c6e9b1d5f3a8c6e2b9d4f7a1c5e8b3d tree   118  94 180
4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a blob  8241 2104 274
6f2b9d4a8c1e5f3b7d9a2c6e4f8b1d3a5c7e9f2b blob   412  87 2378 1 4f8a2e6c9b1d5e3a

Columns: hash, type, real size, compressed size, position in the pack and — on the last line — delta depth and base object. It is the low-level view of what lesson 08-06 explained: the last entry is stored as a difference from another.

# Statistics for the delta chains
git verify-pack -v .git/objects/pack/pack-*.idx | tail -20

git for-each-ref: all the references with their data

git for-each-ref --sort=-committerdate --format='%(refname:short)  %(objectname:short)  %(committerdate:relative)  %(authorname)' refs/heads
GT-241     9c4e7b2  2 hours ago    Ana Ferrer
GT-238     7d3a8f4  3 days ago     Bruno Salas
main       b52c9d1  4 days ago     Carla Vidal
# Branches with no upstream: they exist only on this machine (lesson 09-05)
git for-each-ref --format='%(refname:short) %(upstream)' refs/heads | awk '$2==""{print $1}'

# References pointing at non-existent objects (lesson 09-05)
git for-each-ref --format='%(refname) %(objectname)' | while read -r r s; do
  git cat-file -e "$s" 2>/dev/null || echo "BROKEN: $r"
done

git ls-remote: what is on the server, without downloading anything

git ls-remote origin
git ls-remote origin 'refs/heads/GT-*'
git ls-remote --heads origin main
b52c9d1e4a7f3c8b6d2e9a5f1c7b3d8e4a6f2c9b	refs/heads/main

It queries the server directly, without a fetch and without touching your repository. It answers questions such as "does that branch exist on the server?" or "is my origin/main up to date?":

[ "$(git ls-remote origin main | cut -f1)" = "$(git rev-parse origin/main)" ] \
  && echo "up to date" || echo "my copy of origin/main is out of date"

  1. Narrowing down with git diff --stat and git log

Before a fine-grained investigation, it is worth narrowing things down. These are the coarse-grained tools.

# What changed between two versions, by file
git diff --stat v1.4.0 v1.5.0
 app.js         |  84 ++++++++++++++++-----
 styles.css     |  12 ++--
 index.html     |   6 +-
 README.md      |  31 ++++++++
 4 files changed, 108 insertions(+), 25 deletions(-)
# Only the names, to see the scope at a glance
git diff --name-only v1.4.0 v1.5.0

# With the type of change: Added, Modified, Deleted, Renamed
git diff --name-status v1.4.0 v1.5.0

# A summary of renames and mode changes
git diff --summary v1.4.0 v1.5.0

# Compare only a branch's effect (lesson 07-02)
git diff --stat main...GT-241

And git log in investigation mode (lesson 06-04):

# Commits that touched one particular function
git log -L :calculatePending:app.js

# Commits that added or removed a string (pickaxe, lesson 02-06)
git log -S "localStorage" --oneline

# Commits whose diff matches a regular expression
git log -G "taskmanager\.tasks\.v[0-9]" --oneline

# Who and when, in a date range
git log --since="2026-07-01" --until="2026-07-31" --format='%h %an %ad %s' --date=short

# Only what came in through the first-parent line (lesson 08-02)
git log --first-parent --oneline main

-L :function:file is especially powerful and little known: it follows one particular function throughout the history, even when it moves within the file.

  1. A real investigation: from bisect to blame

Let us bring it all together in a complete case on task-manager.

The symptom. Carla reports: "The pending task counter shows one too many when there are tasks hidden by the filter. In version 1.4 it worked."

Step 1: reproduce

The minimum that causes the failure, written as an automatable test:

cat > /tmp/counter-test.sh <<'EOF'
#!/usr/bin/env bash
# Exits 0 if the counter is correct, 1 if not.
node -e '
  const fs = require("fs");
  const src = fs.readFileSync("app.js", "utf8");
  const ctx = { localStorage: { getItem: () => null, setItem: () => {} }, document: null };
  // ... minimal start-up of the application ...
  const tasks = [
    { text: "a", completed: false, hidden: false },
    { text: "b", completed: true,  hidden: false },
    { text: "c", completed: false, hidden: true  }
  ];
  const expected = 2;
  const actual = calculatePending(tasks);
  process.exit(actual === expected ? 0 : 1);
' 2>/dev/null
EOF
chmod +x /tmp/counter-test.sh

An automatable test is what turns bisect from tedious into instant.

Step 2: narrow down the range

git tag --sort=-creatordate | head -3
git diff --stat v1.4.0 v1.5.0 -- app.js
v1.5.0
v1.4.0
v1.3.0
 app.js | 84 ++++++++++++++++-----
 1 file changed, 62 insertions(+), 22 deletions(-)
git rev-list --count v1.4.0..v1.5.0
34

34 commits between the two versions. Going through them by hand is hours; bisect is five steps.

Step 3: git bisect run

Picking up from lesson 06-02:

git bisect start
git bisect bad v1.5.0
git bisect good v1.4.0
git bisect run /tmp/counter-test.sh
Bisecting: 16 revisions left to test after this (roughly 4 steps)
[7d3a8f4a2c6e9b1d5f3a8c6e2b9d4f7a1c5e8b3d] GT-238 extract the element creation
running  /tmp/counter-test.sh
Bisecting: 8 revisions left to test after this (roughly 3 steps)
...
4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a is the first bad commit
commit 4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a
Author: Bruno Salas <bruno.salas@example.com>
Date:   Wed Jul 15 11:23:04 2026 +0200

    GT-231 add the hidden tasks filter

 app.js | 18 ++++++++++++------
 1 file changed, 12 insertions(+), 6 deletions(-)
git bisect reset

From the symptom to the commit in five automatic steps. Now we know when it broke.

Step 4: understand the commit

git show 4f8a2e6
 function calculatePending(tasks) {
-  return tasks.filter(t => !t.completed).length;
+  return tasks.filter(t => !t.completed || t.hidden).length;
 }

There it is: the || t.hidden counts the hidden ones too. But knowing which line is at fault is not knowing why it is there, and fixing it without understanding can break what that commit set out to solve.

Step 5: git blame for the reason

Picking up from lesson 06-03:

git blame -L 12,20 app.js
4f8a2e6c (Bruno Salas  2026-07-15 11:23:04 +0200 12) function calculatePending(tasks) {
4f8a2e6c (Bruno Salas  2026-07-15 11:23:04 +0200 13)   return tasks.filter(t => !t.completed || t.hidden).length;
4f8a2e6c (Bruno Salas  2026-07-15 11:23:04 +0200 14) }
b52c9d1e (Ana Ferrer   2026-06-28 09:14:22 +0200 15)
b52c9d1e (Ana Ferrer   2026-06-28 09:14:22 +0200 16) function renderTasks() {
# The complete history of that function, even if it has moved
git log -L :calculatePending:app.js --format='%h %an %ad %s' --date=short
commit 4f8a2e6 Bruno Salas 2026-07-15 GT-231 add the hidden tasks filter
commit 8a1f6c3 Ana Ferrer  2026-05-02 GT-198 extract the calculation into its own function
commit 2f8c6e1 Ana Ferrer  2026-04-11 GT-142 show the pending counter
# The culprit commit's complete message: the "why"
git log -1 --format=%B 4f8a2e6
GT-231 add the hidden tasks filter

Archived tasks no longer appear in the list. The `hidden` flag is
added and filtered on in renderTasks().

The counter must carry on including them because the GT-231
specification says that "pending" counts every task that is not
completed, whether visible or not.

Here is the finding, and it is what changes the whole outcome. The behaviour is not a bug: it is a deliberate, documented decision in GT-231. What we have is a contradiction between two specifications: GT-231 says they should all be counted if they are not completed; Carla expects only the visible ones to be counted.

Had we "fixed" the line as soon as we saw it, we would have broken GT-231 and the cycle would have started again in a fortnight.

# Who else depends on this? (pickaxe, lesson 02-06)
git log -S "calculatePending" --oneline
git grep -n "calculatePending" -- '*.js'

The conclusion of the investigation is not a patch, but a question for the team: what does "pending" mean? And the answer, whatever it is, gets documented in the commit that implements it.

The route, in a table

Step Question Tool Lesson
1 What is the exact symptom? A reproducible test script
2 Which range should I search? git diff --stat, git rev-list --count 02-05
3 Which commit introduced it? git bisect run 06-02
4 What did that commit change? git show 02-06
5 Why is that line there? git blame, git log -L, %B 06-03, 08-01
6 Who depends on it? git log -S, git grep 02-06

bisect takes you from the symptom to the commit. blame and the commit message take you from the commit to the intention. The first without the second produces patches that break something else. And here you can see, in retrospect, why lesson 08-01 insisted so much on explaining the why in messages: that paragraph of Bruno's has saved a mistake.

  1. A catalogue of mysteries and their commands

The quick reference table for "Git is doing something odd".

Mystery Diagnostic command Usual cause
A file shows up as modified and I have not touched it git ls-files --eol <f>, git check-attr -a <f> Line endings, or a filter (08-04)
An ignored file appears in status git ls-files --error-unmatch <f> It was already tracked (08-03)
A file is not ignored even though it is in .gitignore git check-ignore -v <f> A rule in another file, or a badly placed negation
A file is ignored and should not be git check-ignore -v <f> A .gitignore in a parent directory, or the global one
A script loses its execute permission git ls-files --stage <f> Mode 100644 in the index; update-index --chmod=+x
A hook does not run GIT_TRACE=1 git commit, ls -l .git/hooks/ It is not executable, or core.hooksPath (06-01)
The commits' author is not who I expect git config --list --show-origin | grep user A local user.email overriding the global one
"Not a git repository" while inside one GIT_TRACE_SETUP=1 git status A worktree, a submodule, or a lost .git
push/fetch fail on authentication GIT_SSH_COMMAND="ssh -v" git fetch The wrong key offered first (04-03)
push/fetch are very slow GIT_TRACE_PACKET=1, git ls-remote | wc -l Thousands of references (08-06)
git status takes seconds GIT_TRACE_PERFORMANCE=1 git status Walking untracked files (08-06)
git log does not show a commit that exists git log --all, git reflog, git cat-file -t It is on another branch, or unreachable (09-04)
A merge says "Already up to date" when it is not git merge-base <a> <b>, git log --graph --all A reverted merge (05-06)
Two branches cannot be merged git rev-list --max-parents=0 HEAD Unrelated histories (09-03)
A submodule always appears as modified git diff --submodule, git ls-files --stage A different submodule commit (06-05)
The diff comes out as binary git check-attr diff -- <f> -diff or binary in .gitattributes
An alias does something unexpected GIT_TRACE=1 git <alias> The alias expansion (06-04)
Git behaves differently in two folders git config --list --show-origin --show-scope A conditional includeIf
The remote is on a commit I was not expecting git ls-remote origin <branch> Somebody pushed, or forced (09-03)

And a general diagnostic script, for when you do not even know where to start:

#!/usr/bin/env bash
# git-diagnostics.sh — a complete snapshot of the repository's real state
set -u
echo "=== ENVIRONMENT ==="
git --version
git rev-parse --show-toplevel
git rev-parse --git-dir
echo "branch: $(git rev-parse --abbrev-ref HEAD)"
echo "upstream: $(git rev-parse --symbolic-full-name '@{u}' 2>/dev/null || echo 'none')"

echo; echo "=== STATE ==="
git status -sb | head -20

echo; echo "=== DIVERGENCE ==="
git rev-list --left-right --count HEAD...@{u} 2>/dev/null || echo "no upstream"

echo; echo "=== KEY CONFIGURATION ==="
git config --list --show-scope 2>/dev/null \
  | grep -E '(user\.|core\.(autocrlf|eol|hooksPath|excludesFile|fsmonitor)|pull\.|push\.|merge\.|diff\.)'

echo; echo "=== ACTIVE HOOKS ==="
HOOKS_PATH=$(git config --get core.hooksPath || echo "$(git rev-parse --git-dir)/hooks")
find "$HOOKS_PATH" -maxdepth 1 -type f -perm -u+x ! -name '*.sample' 2>/dev/null

echo; echo "=== REFERENCES ==="
echo "local branches: $(git branch | wc -l) | remote: $(git branch -r | wc -l) | tags: $(git tag | wc -l)"
echo "unpublished: $(git for-each-ref --format='%(refname:short) %(upstream)' refs/heads | awk '$2==""{print $1}' | tr '\n' ' ')"

echo; echo "=== INTEGRITY ==="
git fsck --connectivity-only --no-progress 2>&1 | grep -vE '^(dangling|notice|Checking)' || echo "no errors"

echo; echo "=== LATEST MOVEMENTS ==="
git reflog --date=relative -8

Keep it. On the day you need it, it will save you fifteen minutes of scattered commands.

Common Mistakes and Tips

Mistake 1: changing things at random until it works. It leaves the problem latent and you learn nothing. Formulate a falsifiable hypothesis and test it.

Mistake 2: trusting memory instead of querying the real state. "I think that file is ignored" is not the same as git check-ignore -v.

Mistake 3: not isolating. Before investigating in depth, check whether the problem persists without the global configuration, without hooks and in a clean clone. That elimination costs a minute.

Mistake 4: switching on GIT_TRACE_PACKET from the outset. It is the deepest level and produces a great deal of noise. Start with GIT_TRACE and GIT_TRACE_SETUP.

Mistake 5: pasting GIT_CURL_VERBOSE output into a public ticket. It can contain Authorization headers (08-05). Review it first.

Mistake 6: using git config --list without --show-origin --show-scope. Without those options you do not know which value wins or where it comes from, which is exactly what you are investigating.

Mistake 7: fixing the line bisect points at without reading the commit message. You can break the functionality that commit set out to implement, as would have happened in section 7.

Mistake 8: attempting bisect without an automatable test. With git bisect run and a script, thirty commits are five automatic steps; by hand, it is half an hour of tedium and mistakes.

Mistake 9: confusing git status with reality. status interprets; ls-files --stage, check-attr and rev-parse show.

Tip 1: the method before the tools. Reproduce, isolate, query the real state, test the hypothesis.

Tip 2: git config --list --show-origin --show-scope as a reflex. It solves a surprising fraction of the "Git is doing something odd" cases.

Tip 3: git check-ignore -v and git check-attr -a. Two commands that give the exact answer to two of the most frequent day-to-day questions.

Tip 4: git ls-files --eol for any line-endings problem. The i/ and w/ table says it all at a glance.

Tip 5: keep the diagnostic script from section 8. It is the complete snapshot in thirty seconds.

Tip 6: write the test before bisecting. The effort is recouped on the first bisect run, and the test stays in the project.

Exercises

Exercise 1: the mystery of the perpetually modified file

  1. Create a repository with app.js, styles.css and index.html, and commit them.
  2. Add a .gitattributes with *.css text eol=crlf and commit it.
  3. Run git status and observe the result. Then run git ls-files --eol and git check-attr -a styles.css.
  4. Explain exactly what is happening using the i/ and w/ columns.
  5. Resolve it with git add --renormalize . (lesson 08-04) and check with ls-files --eol that the columns add up.
  6. Repeat the exercise with a file that .gitattributes marks as -diff and check what changes in git diff.

Exercise 2: configuration, ignores and traces

  1. Create a repository and configure a different user.email at the local level and at the global one.
  2. Use git config --list --show-origin --show-scope to determine which one wins, and confirm it by making a commit and looking at %ae.
  3. Create a .gitignore with *.log, a .git/info/exclude with !important.log and a global core.excludesFile with temp*. Create the three corresponding files and use git check-ignore -v on each to determine which rule decides.
  4. Create a pre-commit hook that prints something, make it non-executable, and try to commit. Use GIT_TRACE=1 to demonstrate that it is not launched.
  5. Make it executable and repeat, checking in the trace that it now does appear.
  6. Run GIT_TRACE_SETUP=1 git status from a subdirectory and explain each line of the output.

Exercise 3: the complete investigation

  1. Create a repository with an app.js containing a correct calculatePending function, and make twenty commits that touch other parts of the project.
  2. In an intermediate commit, introduce the fault from section 7 (adding || t.hidden) with a commit message that explains the why.
  3. Make ten more commits on top.
  4. Write a test script that exits with 0 if the function is correct and with 1 if not.
  5. Find the culprit commit with git bisect run and note how many steps it needed.
  6. Use git show, git blame -L and git log -L :calculatePending:app.js to reconstruct that function's complete history.
  7. Read the culprit commit's complete message with git log -1 --format=%B and explain why the right answer is not "change the line".

Solutions

Solution 1:

rm -rf /tmp/p9-06 && mkdir /tmp/p9-06 && cd /tmp/p9-06 && git init -q -b main
git config user.name "Carla Vidal"; git config user.email "carla.vidal@example.com"
printf 'console.log(1);\n' > app.js
printf 'body { margin: 0; }\n' > styles.css
printf '<h1>Manager</h1>\n' > index.html
git add . && git commit -q -m "chore: initial structure"

# 2. The .gitattributes
printf '*.css text eol=crlf\n' > .gitattributes
git add .gitattributes && git commit -q -m "chore: force CRLF in the CSS files"
# 3. The symptom
git status --short
(no output)

Surprisingly, nothing. The attribute is applied when writing to disk, and the file on disk still has LF. Force the update:

rm styles.css && git checkout -- styles.css
git status --short
file styles.css
 M styles.css
styles.css: ASCII text, with CRLF line terminators

There is the typical mystery: a modified file that nobody has touched.

git ls-files --eol
git check-attr -a styles.css
i/lf    w/crlf  attr/text eol=crlf 	styles.css
i/lf    w/lf    attr/              	app.js
i/lf    w/lf    attr/              	index.html
i/lf    w/lf    attr/              	.gitattributes
styles.css: text: set
styles.css: eol: crlf
# 4. The explanation
git diff --stat styles.css
 styles.css | 2 +-

Reading the columns:

  • i/lf: in the index there is LF, which is what was stored in the original commit.
  • w/crlf: on disk there is CRLF, because the eol=crlf attribute converts it on checkout.
  • Git compares index and disk byte for byte, sees a difference at every line ending, and marks it as modified.

It is not an error: it is the attribute working, on a file that was stored before the attribute existed.

# 5. The solution
git add --renormalize .
git status --short
git commit -q -m "chore: renormalise the line endings"
git ls-files --eol styles.css
M  styles.css
i/lf    w/crlf  attr/text eol=crlf 	styles.css
git status --short
(no output)

Clean. --renormalize rewrites the index applying the current attributes: now the index stores LF in the knowledge of the attribute, and the comparison adds up. It is exactly the procedure from lesson 08-04.

# 6. With -diff
printf '*.css text eol=crlf\nindex.html -diff\n' > .gitattributes
git add .gitattributes && git commit -q -m "chore: index.html without diff"
printf '<h1>Task manager</h1>\n' > index.html
git diff index.html
git check-attr diff -- index.html
diff --git a/index.html b/index.html
index 8a1f6c3..4f8a2e6 100644
Binary files a/index.html and b/index.html differ
index.html: diff: unset

-diff makes Git treat the file as binary for diff purposes. It is useful for minified and generated files, and baffling if you do not know it is set: git check-attr diff resolves it in a second.

Solution 2:

rm -rf /tmp/p9-06b && mkdir /tmp/p9-06b && cd /tmp/p9-06b && git init -q -b main
git config --global user.email "ana.ferrer@example.com" 2>/dev/null
git config user.name "Ana Ferrer"
git config user.email "ana@personal.example"      # local, different

# 2. Which one wins
git config --list --show-origin --show-scope | grep user.email
global	/home/ana/.gitconfig	user.email=ana.ferrer@example.com
local	.git/config	user.email=ana@personal.example
git config --get user.email
echo "x" > f.txt && git add . && git commit -q -m "test"
git log -1 --format='%ae'
ana@personal.example
ana@personal.example

The local one wins, because it is closer to the repository. It is exactly the mechanism from section 3, and the usual cause of "I have set the email correctly".

# 3. The three layers of ignores
printf '*.log\n' > .gitignore
printf '!important.log\n' > .git/info/exclude
printf 'temp*\n' > /tmp/global-gitignore
git config core.excludesFile /tmp/global-gitignore

touch output.log important.log temp-1.txt normal.txt

for f in output.log important.log temp-1.txt normal.txt; do
  printf '%-18s ' "$f"
  git check-ignore -v "$f" || echo "(not ignored)"
done
output.log         .gitignore:1:*.log	output.log
important.log      .git/info/exclude:1:!important.log	important.log
temp-1.txt         /tmp/global-gitignore:1:temp*	temp-1.txt
normal.txt         (not ignored)

Each one decided by a different file, and check-ignore -v names it with its line number. Note important.log: the matching rule is the negation, and that is why the file is not ignored:

git status --short | grep important
?? important.log

When check-ignore -v returns a pattern beginning with !, it means "this rule rescued it".

# 4. The hook that does not run
cat > .git/hooks/pre-commit <<'EOF'
#!/usr/bin/env bash
echo "HOOK EXECUTED"
EOF
# without chmod +x
echo "y" >> f.txt
GIT_TRACE=1 git commit -q -am "hook test" 2>&1 | grep -ci "pre-commit"
git log -1 --format=%s
0
hook test

Zero mentions of the hook in the trace, and the commit went through. The hook was not launched.

ls -l .git/hooks/pre-commit
-rw-r--r-- 1 ana ana 46 Jul 31 13:02 .git/hooks/pre-commit
# 5. With execute permission
chmod +x .git/hooks/pre-commit
echo "z" >> f.txt
GIT_TRACE=1 git commit -am "hook test 2" 2>&1 | grep -i "pre-commit"
13:03:11.402 run-command.c:657  trace: run_command: '.git/hooks/pre-commit'
HOOK EXECUTED

There it is: run_command launches it and the echo appears. GIT_TRACE=1 is the definitive diagnosis for "my hook does not run" (lesson 06-01).

# 6. GIT_TRACE_SETUP from a subdirectory
mkdir -p src/components && cd src/components
GIT_TRACE_SETUP=1 git status 2>&1 | head -6
13:04:22.101 trace.c:318  setup: git_dir: /tmp/p9-06b/.git
13:04:22.101 trace.c:319  setup: git_common_dir: /tmp/p9-06b/.git
13:04:22.101 trace.c:320  setup: worktree: /tmp/p9-06b
13:04:22.101 trace.c:321  setup: cwd: /tmp/p9-06b/src/components
13:04:22.101 trace.c:322  setup: prefix: src/components/
Line What it says
git_dir Where the .git in use is
git_common_dir The shared .git: different in a worktree (06-06)
worktree The project root
cwd Where you launched the command from
prefix The relative path Git prepends to the arguments

That prefix explains why git add . from a subdirectory only adds that subdirectory, and why patterns are interpreted the way they are.

Solution 3:

rm -rf /tmp/p9-06c && mkdir /tmp/p9-06c && cd /tmp/p9-06c && git init -q -b main
git config user.name "Ana Ferrer"; git config user.email "ana.ferrer@example.com"

cat > app.js <<'EOF'
function calculatePending(tasks) {
  return tasks.filter(t => !t.completed).length;
}
module.exports = { calculatePending };
EOF
git add . && git commit -q -m "GT-142 show the pending counter"

# 1. Twenty filler commits
for i in $(seq 1 20); do echo "// change $i" >> others.js; git add .; git commit -q -m "chore: change $i"; done
# 2. The culprit commit, with a message that explains the why
sed -i 's/!t.completed)/!t.completed || t.hidden)/' app.js
git commit -q -am "GT-231 add the hidden tasks filter

Archived tasks no longer appear in the list. The \`hidden\` flag is
added and filtered on in renderTasks().

The counter must carry on including them because the GT-231
specification says that \"pending\" counts every task that is not
completed, whether visible or not."
CULPRIT=$(git rev-parse --short HEAD)

# 3. Ten more commits
for i in $(seq 21 30); do echo "// change $i" >> others.js; git add .; git commit -q -m "chore: change $i"; done
git rev-list --count HEAD
32
# 4. The test
cat > /tmp/counter-test.sh <<'EOF'
#!/usr/bin/env bash
node -e '
  const { calculatePending } = require(process.cwd() + "/app.js");
  const tasks = [
    { completed: false, hidden: false },
    { completed: true,  hidden: false },
    { completed: false, hidden: true  }
  ];
  process.exit(calculatePending(tasks) === 2 ? 0 : 1);
' 2>/dev/null
EOF
chmod +x /tmp/counter-test.sh
/tmp/counter-test.sh; echo "current status: $?"
current status: 1

The test fails at HEAD: reproduced.

# 5. Bisect
git bisect start
git bisect bad HEAD
git bisect good HEAD~31
git bisect run /tmp/counter-test.sh 2>&1 | tail -12
Bisecting: 15 revisions left to test after this (roughly 4 steps)
running  '/tmp/counter-test.sh'
Bisecting: 7 revisions left to test after this (roughly 3 steps)
running  '/tmp/counter-test.sh'
Bisecting: 3 revisions left to test after this (roughly 2 steps)
running  '/tmp/counter-test.sh'
Bisecting: 1 revision left to test after this (roughly 1 step)
running  '/tmp/counter-test.sh'
4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a is the first bad commit
    GT-231 add the hidden tasks filter
bisect found first bad commit
git bisect reset
echo "Expected culprit: $CULPRIT"
Expected culprit: 4f8a2e6

Five automatic steps for 31 commits. The base-2 logarithm of 31 is a little under 5: exactly what lesson 06-02 predicted.

# 6. The function's history
git show 4f8a2e6 -- app.js
 function calculatePending(tasks) {
-  return tasks.filter(t => !t.completed).length;
+  return tasks.filter(t => !t.completed || t.hidden).length;
 }
git blame -L 1,3 app.js
4f8a2e6c (Ana Ferrer 2026-07-31 13:12:04 +0200 1) function calculatePending(tasks) {
4f8a2e6c (Ana Ferrer 2026-07-31 13:12:04 +0200 2)   return tasks.filter(t => !t.completed || t.hidden).length;
8a1f6c3d (Ana Ferrer 2026-07-31 13:11:58 +0200 3) }
git log -L :calculatePending:app.js --format='%h %ad %s' --date=short | grep -E '^commit|^[0-9a-f]{7} '
4f8a2e6 2026-07-31 GT-231 add the hidden tasks filter
8a1f6c3 2026-07-31 GT-142 show the pending counter

Two commits in that function's whole life. -L :function:file follows it even if it moves elsewhere within the file, which blame -L 1,3 would not do.

# 7. The why
git log -1 --format=%B 4f8a2e6
GT-231 add the hidden tasks filter

Archived tasks no longer appear in the list. The `hidden` flag is
added and filtered on in renderTasks().

The counter must carry on including them because the GT-231
specification says that "pending" counts every task that is not
completed, whether visible or not.

Why the right answer is not "change the line":

The behaviour is intentional and is justified in writing. There is no programming error: there are two specifications that contradict each other. GT-231 says the counter should include the hidden ones; what Carla expects is the opposite.

If the || t.hidden is simply removed:

  1. GT-231 breaks, which somebody asked for and somebody signed off.
  2. Nobody will know why, because the commit that undoes it will probably say "fix the counter".
  3. In a fortnight, whoever asked for GT-231 will open an identical ticket in the opposite direction, and the cycle will start over.

The correct way out is to take the contradiction to whoever can resolve it, and for the decision — whatever it turns out to be — to end up written in the message of the commit that implements it (lesson 08-01), with a reference to both tickets.

Bruno's message has saved a mistake. That three-line paragraph is the difference between a correction and a loop.

Conclusion

This lesson was about understanding why Git is doing what it is doing.

  • The method comes before the tools: reproduce with the minimal command, isolate by removing variables (global configuration, hooks, a clean clone), query the real state rather than the remembered one, and test a concrete hypothesis before changing anything.
  • The trace variables show what is happening underneath: GIT_TRACE for subcommands, aliases and hooks; GIT_TRACE_SETUP to know which repository Git thinks it is using; GIT_SSH_COMMAND="ssh -v" and GIT_CURL_VERBOSE for authentication and the network; GIT_TRACE_PACKET for the protocol. Start with the gentle ones and review the output before sharing it.
  • git config --list --show-origin --show-scope solves a surprising fraction of the mysteries, because it says which value wins and which file it comes from, including conditional includeIf entries.
  • git check-ignore -v and git check-attr -a answer precisely the two most frequent day-to-day questions: why a file is (or is not) ignored, and which attributes apply to it.
  • git ls-files is the window onto the index: --stage for modes and conflict stages, --eol for line endings with its i/ and w/ columns, --others --exclude-standard for what is genuinely new.
  • The plumbing answers questions about the graph without interpretation: rev-parse to translate into hashes and learn about the environment, rev-list to count and list, cat-file --batch-check for bulk inspection, verify-pack for the inside of a packfile, for-each-ref for all the references and ls-remote for the server without downloading anything.
  • And the complete investigation: bisect takes you from the symptom to the commit; blame and the commit message take you from the commit to the intention. Skipping the second step produces patches that break something else.

The module, in one idea

Module 8 ended by announcing disasters. This one has solved them all, and the underlying lesson is a single one:

In Git, almost nothing is truly lost. What gets lost is your composure.

Commits are immutable objects that survive even when no reference points at them (09-01). reset moves a branch, it does not destroy history (09-02). A divergence is a pending decision, not a breakdown (09-03). The reflog remembers where you have been, and with it come back the reset --hard, the deleted branch, the failed rebase and the deleted stash (09-04). A damaged repository is a logistics problem, because any colleague's clone is an almost complete copy (09-05). And when none of that fits, there are traces, plumbing and a method (09-06).

What is genuinely fragile is what never became an object: the uncommitted change, the untracked file. That is why the most profitable advice in the whole module is not a command but a habit: commit early, mark the spot with git branch before anything risky, and publish your branches.

What is coming

Ana, Bruno, Carla and Diego have mastered Git in task-manager. They know how to build the history, manipulate it with judgement, collaborate with a process, maintain good habits and get out of trouble.

But task-manager is four files and a team of four people. The real world is bigger and stranger.

There are projects with fifty thousand files and twenty years of history, where an unoptimised git status takes half a minute. There are repositories that store videos, 3D models and design files of hundreds of megabytes, and that need a different system for storing them. There are organisations where Git is used not by one person at a terminal but by a hundred deployment pipelines that clone, tag and publish with no human intervention. There are integrations with editors, with ticketing systems, with review platforms and with analysis tools that completely change the daily experience. And there is a Git that carries on evolving: SHA-256, references packed in a new format, partial clones, sparse indexes.

In module 10: Git in the Real World we will look at case studies of how real projects and organisations use Git, integration with other tools in day-to-day work, Git LFS for large files, how to scale Git in enormous repositories and monorepos, the role of Git in DevOps as a central piece of continuous delivery, and where Git is heading in the next few years.

We start by looking at how others do it, in lesson 10-01: Case Studies.

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