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
- The diagnostic method, in four steps
- Trace variables: seeing what Git does underneath
- Debugging the effective configuration
- Debugging paths and patterns:
check-ignore,check-attr,ls-files - Plumbing for inspecting the graph
- Narrowing down with
git diff --statandgit log - A real investigation: from
bisecttoblame - A catalogue of mysteries and their commands
- 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.
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-testIf 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
.gitattributesmarks*.cssastext eol=crlf, thengit check-attr eol styles.cssmust saycrlf, andgit ls-files --eol styles.cssmust showw/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.
- 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: what Git is actually running
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' '[email protected]'
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):
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:
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.
GIT_TRACE_SETUP: where Git thinks it is
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
.gitit 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_dirandgit_common_dirdiffer. - The
.gitignorepatterns do not work as you expect: theprefixexplains 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.
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:
IdentitiesOnly yes is the key part: it forces it to offer only that one.
For HTTPS:
* 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 fillCareful:
GIT_CURL_VERBOSEcan displayAuthorizationheaders. 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.
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
pushrejected 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:
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_SETUPAnd 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."
- 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.
system /etc/gitconfig core.autocrlf=false global /home/ana/.gitconfig user.name=Ana Ferrer global /home/ana/.gitconfig [email protected] global /home/ana/.gitconfig pull.rebase=true local .git/config remote.origin.url=git.example.com:team/task-manager.git local .git/config [email protected] 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 byinclude.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 statusThe 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-personalIt 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.
- Debugging paths and patterns:
check-ignore, check-attr, ls-files
check-ignore, check-attr, ls-filesThis 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:
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:
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:
If a directory is ignored, Git does not even go into it, so a negation rule for a file inside it does not work:
git check-attr: which attributes apply
Picking up from lesson 08-04:
# 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 unspecifiedThis 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.
| 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:
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.shIndex 100644, disk executable: that is the change. It is corrected with:
And the --eol mode, which solves the line-endings mystery:
| 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".
- 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 commitAnd 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 | headThat --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
# 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 -cIt 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
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.
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/headsGT-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"
donegit ls-remote: what is on the server, without downloading anything
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"
- Narrowing down with
git diff --stat and git log
git diff --stat and git logBefore a fine-grained investigation, it is worth narrowing things down. These are the coarse-grained tools.
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-241And 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.
- A real investigation: from
bisect to blame
bisect to blameLet 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.shAn automatable test is what turns bisect from tedious into instant.
Step 2: narrow down the range
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:
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 <[email protected]> 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(-)
From the symptom to the commit in five automatic steps. Now we know when it broke.
Step 4: understand the commit
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:
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=shortcommit 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
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.
- 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 -8Keep 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
- Create a repository with
app.js,styles.cssandindex.html, and commit them. - Add a
.gitattributeswith*.css text eol=crlfand commit it. - Run
git statusand observe the result. Then rungit ls-files --eolandgit check-attr -a styles.css. - Explain exactly what is happening using the
i/andw/columns. - Resolve it with
git add --renormalize .(lesson 08-04) and check withls-files --eolthat the columns add up. - Repeat the exercise with a file that
.gitattributesmarks as-diffand check what changes ingit diff.
Exercise 2: configuration, ignores and traces
- Create a repository and configure a different
user.emailat the local level and at the global one. - Use
git config --list --show-origin --show-scopeto determine which one wins, and confirm it by making a commit and looking at%ae. - Create a
.gitignorewith*.log, a.git/info/excludewith!important.logand a globalcore.excludesFilewithtemp*. Create the three corresponding files and usegit check-ignore -von each to determine which rule decides. - Create a
pre-commithook that prints something, make it non-executable, and try to commit. UseGIT_TRACE=1to demonstrate that it is not launched. - Make it executable and repeat, checking in the trace that it now does appear.
- Run
GIT_TRACE_SETUP=1 git statusfrom a subdirectory and explain each line of the output.
Exercise 3: the complete investigation
- Create a repository with an
app.jscontaining a correctcalculatePendingfunction, and make twenty commits that touch other parts of the project. - In an intermediate commit, introduce the fault from section 7 (adding
|| t.hidden) with a commit message that explains the why. - Make ten more commits on top.
- Write a test script that exits with
0if the function is correct and with1if not. - Find the culprit commit with
git bisect runand note how many steps it needed. - Use
git show,git blame -Landgit log -L :calculatePending:app.jsto reconstruct that function's complete history. - Read the culprit commit's complete message with
git log -1 --format=%Band 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 "[email protected]"
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"Surprisingly, nothing. The attribute is applied when writing to disk, and the file on disk still has LF. Force the update:
There is the typical mystery: a modified file that nobody has touched.
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
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 theeol=crlfattribute 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.cssClean. --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.htmldiff --git a/index.html b/index.html index 8a1f6c3..4f8a2e6 100644 Binary files a/index.html and b/index.html differ
-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 "[email protected]" 2>/dev/null
git config user.name "Ana Ferrer"
git config user.email "[email protected]" # local, different
# 2. Which one wins
git config --list --show-origin --show-scope | grep user.emailglobal /home/ana/.gitconfig [email protected] local .git/config [email protected]
git config --get user.email
echo "x" > f.txt && git add . && git commit -q -m "test"
git log -1 --format='%ae'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)"
doneoutput.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:
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=%sZero mentions of the hook in the trace, and the commit went through. The hook was not launched.
# 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"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 -613: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 "[email protected]"
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# 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: $?"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 -12Bisecting: 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 commitFive automatic steps for 31 commits. The base-2 logarithm of 31 is a little under 5: exactly what lesson 06-02 predicted.
function calculatePending(tasks) {
- return tasks.filter(t => !t.completed).length;
+ return tasks.filter(t => !t.completed || t.hidden).length;
}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.
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:
GT-231breaks, which somebody asked for and somebody signed off.- Nobody will know why, because the commit that undoes it will probably say "fix the counter".
- In a fortnight, whoever asked for
GT-231will 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_TRACEfor subcommands, aliases and hooks;GIT_TRACE_SETUPto know which repository Git thinks it is using;GIT_SSH_COMMAND="ssh -v"andGIT_CURL_VERBOSEfor authentication and the network;GIT_TRACE_PACKETfor the protocol. Start with the gentle ones and review the output before sharing it. git config --list --show-origin --show-scopesolves a surprising fraction of the mysteries, because it says which value wins and which file it comes from, including conditionalincludeIfentries.git check-ignore -vandgit check-attr -aanswer 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-filesis the window onto the index:--stagefor modes and conflict stages,--eolfor line endings with itsi/andw/columns,--others --exclude-standardfor what is genuinely new.- The plumbing answers questions about the graph without interpretation:
rev-parseto translate into hashes and learn about the environment,rev-listto count and list,cat-file --batch-checkfor bulk inspection,verify-packfor the inside of a packfile,for-each-reffor all the references andls-remotefor the server without downloading anything. - And the complete investigation:
bisecttakes you from the symptom to the commit;blameand 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
- 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
