The previous lesson left task-manager with a history that is readable, bisectable and reversible. But an impeccable history can contain rubbish, and task-manager's does contain it: Bruno pushed the entire node_modules folder without noticing — 14,000 files and 180 MB —, Carla has been scattering Thumbs.db files across the image directories, Bruno's macOS leaves a .DS_Store in every folder he opens in Finder, and in some commit from months ago there is a config.js file with the test database password in it.

This lesson is about the first line of defence against all of that: .gitignore, the mechanism by which you tell Git which files it should not even consider versioning. We mentioned it in passing in lesson 02-04, when looking at git status, and we promised to develop it here.

It is a deceptively simple file. Its syntax has corners that confuse everybody — negation, the leading slash, the directory trap — and, above all, it hides a rule that is the cause of 90 % of the Git questions on forums: .gitignore is no use for stopping the tracking of a file that is already versioned.

Contents

  1. Why what is not versioned matters
  2. What must never go in: a table by category
  3. How it works: precedence and scope
  4. Complete syntax of the patterns
  5. Negation with ! and its trap
  6. The three levels: project, local and global
  7. The rule that confuses everybody: it only affects untracked files
  8. Untracking with git rm --cached
  9. Debugging with git check-ignore -v
  10. Forcing with git add -f
  11. Templates by language
  12. The pattern for secrets: .env and .env.example

  1. Why what is not versioned matters

Versioning what should not be versioned has four costs, and all four are bigger than they look:

Cost Concrete consequence
Permanent size The 180 MB of node_modules stay in the history for ever, even if they are deleted afterwards. Every clone downloads them (lesson 08-06).
Absurd conflicts Generated files (compiled output, lockfiles, .idea/workspace.xml) change on every machine and cause conflicts on every merge, with no meaning at all.
Noise in reviews A PR with 30 useful lines and 4,000 lines of artefacts cannot be reviewed (lesson 07-02).
Security risk A secret in the repository is replicated to every clone and is not removed by deleting the file. That is what we will see in lesson 08-05.

There is one principle that orders every decision:

You version the source, not the result. If a file can be regenerated from other files in the repository, it is not versioned. If it contains information belonging to one machine or one person, it is not versioned. If it is a secret, it is never versioned.

With two important exceptions that are worth knowing, because they are the ones that confuse:

  • Dependency lockfiles (package-lock.json, yarn.lock, Cargo.lock in applications) are versioned. They are not a result: they are the source of reproducibility. Without them, two people install different versions.
  • Shared project configuration (.editorconfig, .gitattributes, the part of .vscode/ that defines tasks and recommended extensions) is versioned. It is source, not result.

  1. What must never go in: a table by category

Category Examples Why not
Dependencies node_modules/, vendor/, bower_components/, venv/, .venv/, target/ (Rust) They are regenerated with npm ci, composer install, pip install -r. Thousands of files, tens or hundreds of MB, and different content depending on the operating system.
Build artefacts dist/, build/, out/, *.o, *.class, *.pyc, __pycache__/, generated *.min.js They are the output of the code that is versioned. They change on every build and generate guaranteed conflicts.
Operating system files .DS_Store, ._*, .Spotlight-V100 (macOS); Thumbs.db, Desktop.ini, $RECYCLE.BIN/ (Windows); .directory (Linux) They have nothing to do with the project: they are file-explorer metadata. Bruno and Carla dirty the repository without meaning to.
Editor and IDE configuration .idea/, personal .vscode/settings.json, *.swp, *.swo, .project, .classpath, *.sublime-workspace They reflect personal preferences and absolute paths on one machine. They cause conflicts between whoever uses one editor and whoever uses another.
Secrets and credentials .env, .env.local, config/secrets.yml, *.pem, *.key, id_rsa, service credential files Never. A secret in the history is a compromised secret, even if you delete it afterwards (lesson 08-05).
Logs and temporary files *.log, logs/, tmp/, *.tmp, *.bak, *.swp, npm-debug.log* Ephemeral by definition. Nobody is going to consult them a year from now.
Local databases and dumps *.sqlite, *.db, dump.sql, local-data/ Large, binary and containing data that may be personal.
Large, generated binary files Test videos, exported images, *.zip, *.tar.gz Every version is stored whole (lesson 08-06). If they are necessary binaries, the solution is Git LFS, lesson 10-03.
Coverage and test results coverage/, .nyc_output/, junit.xml, .pytest_cache/ The output of one particular run.
Tool caches .cache/, .parcel-cache/, .eslintcache, .next/, .nuxt/ Rebuildable and machine-specific.

The .gitignore the task-manager team ended up adopting:

# --- Dependencies ---------------------------------------------------
node_modules/

# --- Build artefacts ------------------------------------------------
dist/
build/
*.min.js
*.min.css

# --- Secrets (see 08-05) --------------------------------------------
.env
.env.*
!.env.example
*.pem
*.key

# --- Logs and temporary files ---------------------------------------
*.log
npm-debug.log*
tmp/
*.tmp
*.bak

# --- Coverage and caches --------------------------------------------
coverage/
.eslintcache
.cache/

# --- Operating system -----------------------------------------------
# macOS (Bruno)
.DS_Store
._*
.Spotlight-V100
.Trashes

# Windows (Carla)
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/

# Linux (Ana)
.directory
*~

# --- Editors --------------------------------------------------------
.idea/
*.swp
*.swo
.vscode/*
!.vscode/extensions.json
!.vscode/tasks.json

Notice the two uses of ! (negation) at the end of blocks: !.env.example and !.vscode/extensions.json. We explain them in section 5, along with the trap they hide.

  1. How it works: precedence and scope

Three rules govern the behaviour:

Rule 1: .gitignore applies per directory and downwards. A .gitignore affects its own directory and all of its subdirectories. You can have several in the same repository.

task-manager/
├── .gitignore            ← global rules for the project
├── app.js
├── images/
│   ├── .gitignore        ← rules only for images/ and its children
│   └── originals/
└── tests/
    └── .gitignore        ← rules only for tests/

Rule 2: the most specific pattern wins. Git evaluates, in this order of increasing priority:

  1. core.excludesFile (the user's global)
  2. .git/info/exclude (local to the repository)
  3. The .gitignore files of the directories, from the root downwards (the deepest one wins)
  4. The command line (git add -f)

Rule 3: within a single file, the last matching line wins. That is why negations go after the pattern they cancel:

*.log        # ignore every .log
!errors.log  # ...except this one  (correct: it goes afterwards)
!errors.log  # this negation is useless...
*.log        # ...because this later line ignores everything again

The .gitignore is versioned. It is part of the project and it must be in the first commit. If it is not, every person on the team has to work out for themselves what they must not push, and somebody will get it wrong.

  1. Complete syntax of the patterns

Git uses a syntax derived from shell globs, with a few peculiarities of its own.

Pattern What it does Example of what it matches
name Any file or directory with that name, at any depth app.log, src/app.log, a/b/c/app.log
name/ Only if it is a directory logs/ yes; a file called logs no
/name Only in the root of the .gitignore's directory /build matches build in the root, not src/build
* Any sequence of characters, without crossing / *.log matches error.log, not logs/error.log (although the bare name does)
? Exactly one character image?.png matches image1.png, not image10.png
[abc] One of those characters image[123].png matches image2.png
[0-9] One character from the range log[0-9].txt matches log7.txt
**/ Any number of directories (including none) **/temp matches temp, a/temp, a/b/temp
/** Everything inside logs/** matches all the contents of logs/
a/**/b b under a, at any depth a/b, a/x/b, a/x/y/b
!pattern Un-ignores what would have matched before (with limits: section 5)
# text Comment (whole line)
\#literal A file whose name starts with # Matches the file called #literal
name\ A literal trailing space (trailing spaces are ignored unless escaped)

The two subtleties you have to take on board

The leading slash anchors to the root. It is the most important difference and the most forgotten:

build/       # ignores ANY build directory, at any depth
/build/      # ignores ONLY the build in the project root

If your project has build/ in the root and also src/components/build/ which you do want to version, you need the leading slash.

A slash in the middle of the pattern also anchors. This is the rule that takes people by surprise: if the pattern contains a / in any position other than the final one, it is interpreted as relative to the .gitignore's directory, not as "at any depth".

doc/notes.txt    # only the doc/notes.txt in the root
notes.txt        # any notes.txt, at any depth
**/doc/notes.txt # any doc/notes.txt, at any depth

Commented examples

# Every .log, wherever they are
*.log

# But not those in the examples folder (see section 5)
!examples/*.log

# Only the build directory in the root
/build/

# Any directory called tmp, at any depth
tmp/

# The config.local.js in the root, not other config.local.js files
/config.local.js

# Any file whose name starts with "draft"
draft*

# Files numbered from 0 to 9
dump[0-9].sql

# All the contents of data/, but keeping the folder (see below)
data/*
!data/.gitkeep

# A file literally called "!important"
\!important

The .gitkeep trick

Git does not version directories, only files: an empty directory simply does not exist as far as Git is concerned. If your application needs data/ to exist on start-up, the usual pattern is:

data/*
!data/.gitkeep

And you create an empty data/.gitkeep file. There is nothing special about the name — Git does not know it — it is only a convention for saying "this file exists so that the directory exists".

Notice that we use data/* and not data/. The reason is the trap in the next section.

  1. Negation with ! and its trap

The rule that wastes the most time in any .gitignore:

If a directory is ignored, Git does not go into it. Therefore, you cannot un-ignore a file that is inside an ignored directory.

It is a deliberate optimisation: Git does not walk thousands of files in node_modules/ just in case some later rule rescues one of them. And it produces this failure, which looks like a bug in Git and is not:

# DOES NOT WORK
data/
!data/important.csv

data/important.csv is still ignored. Git has discarded the whole of data/ and has never got as far as evaluating the second line.

The correct way is to ignore the contents, not the directory:

# THIS DOES WORK
data/*
!data/important.csv

With data/*, Git does go into data/, ignores each of its children individually, and the negation can rescue one of them.

When there are several levels

If the file you want to rescue is deeper down, you have to un-ignore every intermediate level:

# Ignore everything except config/production/settings.json
*
!*/
!config/production/settings.json

Translated: ignore everything (*), but do not ignore the directories (!*/, necessary so that Git can go into them), and explicitly rescue that file.

This whitelist pattern — "ignore everything and rescue what I want" — is aggressive but very useful in repositories where what is versionable is a minority (for example, a configuration repository):

# Strict whitelist
*
!*/
!*.yml
!*.md
!.gitignore

The case of task-manager's .gitignore

Let us go back to the two negations we left pending:

.env
.env.*
!.env.example

It works because .env.* ignores files, not a directory, and the later negation rescues one. Correct.

.vscode/*
!.vscode/extensions.json
!.vscode/tasks.json

Notice .vscode/* and not .vscode/. If we had written .vscode/, the two negations would do nothing. This is exactly the mistake everybody makes the first time.

  1. The three levels: project, local and global

There are three places to declare exclusions, and choosing the wrong one is the cause of a recurring argument in the team: Carla proposed adding .idea/ to the project's .gitignore and Ana objected because nobody else uses that IDE. They were both partly right, and the problem was one of level.

Level File Is it versioned? Who does it affect? What for
Project .gitignore in the repository Yes The whole team What belongs to the project: node_modules/, dist/, .env, coverage/
Local .git/info/exclude No Only you, only in that repository Your own temporary files in that project: ana-notes.md, local-tests/
Global The path given by core.excludesFile No You, in all your repositories What belongs to your machine or your tooling: .DS_Store, .idea/, *.swp

The criterion

Would this file appear on the machine of anybody working on this project? If so, it goes in the project's .gitignore. If it appears only because of your operating system or your editor, it goes in your global. If it is a whim of yours in this particular repository, it goes in .git/info/exclude.

Strictly speaking, .DS_Store and .idea/ go in the global. In practice, almost every project also puts them in the project's .gitignore, as a safety net for anybody who does not have the global configured. It is defensive and it is reasonable; you just have to know that it is a deliberate duplication.

Configuring the global

# 1. Create the file
cat > ~/.gitignore_global <<'EOF'
# Operating system
.DS_Store
._*
Thumbs.db
Desktop.ini
.directory

# Editors
.idea/
*.swp
*.swo
*~
.vscode/

# Personal tooling
.env.personal
private-notes.md
EOF

# 2. Tell Git about it
git config --global core.excludesFile ~/.gitignore_global

# 3. Check
git config --get core.excludesFile

On macOS and Linux, Git already uses ~/.config/git/ignore without you having to configure anything if the file exists. It is the default location according to the XDG standard:

mkdir -p ~/.config/git
# and write the rules in ~/.config/git/ignore

.git/info/exclude

It is a normal .gitignore that lives inside .git/. Like everything in there (lesson 01-04), it is neither versioned nor cloned:

cat >> .git/info/exclude <<'EOF'
# My stuff in this repository, which nobody else needs to ignore
ana-notes.md
local-tests/
todays-dump.sql
EOF

The advantage over modifying the project's .gitignore: you do not clutter the shared file with your quirks, and you do not have to justify them in a review. The disadvantage: it is lost if you delete the repository and clone it again.

  1. The rule that confuses everybody: it only affects untracked files

Here is the source of 90 % of the questions about .gitignore:

.gitignore only affects UNTRACKED files. If a file is already in the index, Git will carry on recording its changes no matter how many patterns you write.

Remember the three states from lesson 01-03 and the cycle from lesson 02-03. .gitignore acts exclusively on the transition from untracked to staged: it tells Git "do not even offer it to me in git status, and do not pick it up with git add .". On a file that has already crossed that boundary, it has no effect at all.

The typical scenario, which happened to Bruno:

# Monday: Bruno pushes node_modules without noticing
git add .
git commit -m "chore: initial commit"
git push
# Tuesday: Ana sees it and adds the .gitignore
echo "node_modules/" >> .gitignore
git add .gitignore && git commit -m "chore: ignore node_modules"

git status
On branch main
Changes not staged for commit:
	modified:   node_modules/marked/package.json
	modified:   node_modules/.package-lock.json
	...

It is still there. The .gitignore has done nothing, because those files are already tracked.

flowchart LR
    A["Untracked"] -->|"git add"| B["Staged"]
    B -->|"git commit"| C["Committed / tracked"]
    A -.->|".gitignore acts HERE<br/>and only here"| A
    C -->|"git rm --cached"| A

The return arrow — git rm --cached — is the only way of taking a file back to the state where .gitignore can protect it.

  1. Untracking with git rm --cached

git rm --cached removes the file from the index but leaves it on your disk. From that moment it becomes untracked, the .gitignore starts to apply and it disappears from git status.

# A single file
git rm --cached config.js

# A whole directory (recursive)
git rm -r --cached node_modules/

# Check before touching anything (--dry-run modifies nothing)
git rm -r --cached --dry-run node_modules/

The complete flow, just as Ana did it:

# 1. Make sure the pattern is in the .gitignore
grep -q "^node_modules/" .gitignore || echo "node_modules/" >> .gitignore

# 2. Take it out of the index, keeping it on disk
git rm -r --cached node_modules/

# 3. Check that git status comes out clean
git status --short

# 4. Commit
git commit -m "chore: stop versioning node_modules"

# 5. Tell the team (see below)
git push

The trick for cleaning everything up at once

When there are many files that ought now to be ignored, there is a recipe that takes them all out at once, respecting the current .gitignore:

# Empties the index and rebuilds it applying the .gitignore in force
git rm -r --cached .
git add .
git status
git commit -m "chore: apply the .gitignore to the already versioned files"

git rm -r --cached . deletes nothing from disk: it empties the index. git add . fills it again, and this time it does respect the .gitignore. The difference between the two states is exactly the set of files that should not have been there.

Always check with git status before committing. If the .gitignore has an overly broad pattern, this recipe can untrack files you actually wanted.

The two important warnings

Warning 1: the file is deleted on everybody else's machine. As far as Git is concerned, a git rm --cached followed by a commit is a deletion. When Bruno and Carla pull, that file will disappear from their working copy. With node_modules/ it does not matter (it is regenerated with npm ci), but if you untrack a config.js that people need, you are deleting it for them. Tell the team before you do it.

Warning 2: it deletes nothing from the history. And this one is critical:

git rm --cached config.js
git commit -m "chore: stop versioning config.js"

The file disappears from the tip, but it is still in every previous commit:

git log --all --oneline -- config.js     # there they are
git show HEAD~5:config.js                # and its content is accessible

If config.js contained a password, the password is still in the repository and in every clone. git rm is not a security tool. The correct procedure for a secret that has already leaked — rotate the credential first, then rewrite the history — is the content of lesson 08-05.

  1. Debugging with git check-ignore -v

The day comes when a file is ignored and you do not know why, or the other way round. git check-ignore -v answers: it tells you which exclude file, which line and which pattern are making the decision.

git check-ignore -v dist/app.min.js
.gitignore:8:dist/	dist/app.min.js

It reads: the dist/ pattern, on line 8 of the .gitignore file, is the one that ignores dist/app.min.js.

Examples of the three possible answers:

# Ignored by a global rule
git check-ignore -v .DS_Store
/home/ana/.gitignore_global:3:.DS_Store	.DS_Store
# Ignored by a local, unversioned rule
git check-ignore -v ana-notes.md
.git/info/exclude:6:ana-notes.md	ana-notes.md
# NOT ignored: it prints nothing and returns exit code 1
git check-ignore -v app.js
echo $?
1

Useful options

# Several files at once
git check-ignore -v app.js dist/app.min.js .DS_Store node_modules/marked/index.js

# Also show the ones that are NOT ignored, and why
git check-ignore -v --no-index app.js

# Check EVERYTHING that git status hides
git status --ignored --short

git status --ignored deserves a section of its own, because it is the way to discover surprises:

git status --ignored --short
!! node_modules/
!! dist/
!! .env
!! coverage/
!! app.js.bak

Every !! is a file or directory that Git is hiding. If you see something there that you did want to version, you now know that a pattern is too broad.

The special case of --no-index

If a file is tracked, check-ignore can be confusing, because the patterns do match even though they have no effect. To ask "would any pattern match, regardless of whether it is tracked?":

git check-ignore -v --no-index config.js
.gitignore:12:config.js	config.js

That is: there is a pattern that would cover it, but since the file is already in the index, it has no effect. That diagnosis — "the pattern is fine, the problem is section 7" — is exactly what you need to know.

  1. Forcing with git add -f

Sometimes you need to version a file that a broad pattern is ignoring. git add -f (or --force) skips the rules for that particular operation:

git add -f dist/app.min.js

Without -f, Git warns you and does nothing:

The following paths are ignored by one of your .gitignore files:
dist/app.min.js
hint: Use -f if you really want to add them.

Beware of what happens next. Once added with -f, the file becomes tracked, and by the rule in section 7 the .gitignore stops having any effect on it for ever: every modification will show up in git status. -f is not a one-off exception, it is a permanent change of state.

That is why it is almost always better to fix the pattern than to force:

dist/
!dist/app.min.js     # the exception, written down and visible to everyone

That way the exception is documented in the repository instead of living in the memory of whoever typed -f one day.

And a warning that links to the next lesson: -f is the route by which secrets slip in. Somebody is in a hurry, Git complains that .env is ignored, and git add -f .env solves the problem of the next five minutes in exchange for creating a permanent one. If Git resists adding a file, stop and think about why.

  1. Templates by language

There is no need to write a .gitignore from scratch. There are community-maintained collections with templates by language, framework, operating system and editor; the best known is the github/gitignore repository, and most platforms offer you the choice of one when you create the repository.

Recommendations for using them:

  1. Start from your language's template, but read it. It contains rules for tools you may not use, and sometimes it ignores things you do want.
  2. Combine, do not blindly concatenate. A Node template plus a Python one plus three editor ones produces a 300-line file that nobody maintains.
  3. Group into sections with comments, as in the example in section 2. A .gitignore gets read many times.
  4. Operating system and editor rules belong in your global. If the whole team has their global properly configured, the project's .gitignore stays short and meaningful.
  5. Review it when the tooling changes. A .gitignore with rules for a bundler you stopped using two years ago is noise.

A useful check from time to time, to see which rules are no longer of any use:

# Ignored files that actually exist in your working copy
git status --ignored --short | grep '^!!'

If a rule never shows up here on any machine in the team, it is probably superfluous.

  1. The pattern for secrets: .env and .env.example

The real problem: the application needs to know the database URL, the mail API key and a session secret. Those values cannot be in the repository, but whoever clones the project has to know which values are needed and what they are called.

The conventional solution is two files:

.env — with the real values. Ignored, never versioned.

DATABASE_URL=postgres://taskmanager:a-real-password@localhost:5432/tasks
MAIL_API_KEY=the-real-value-that-does-not-go-into-the-repository
SESSION_SECRET=another-real-randomly-generated-value
PORT=3000

.env.example — with the keys and sample values, nothing real in it. Versioned.

# Copy this file to .env and fill in the real values.
# The .env is NEVER versioned (see .gitignore).

# PostgreSQL connection string
DATABASE_URL=postgres://user:password@localhost:5432/tasks

# Key for the mail delivery service.
# Ask Ana for it or generate a test one in the service's dashboard.
MAIL_API_KEY=put-your-key-here

# Session secret. Generate one with: openssl rand -hex 32
SESSION_SECRET=change-this-for-a-random-value

# Local port of the development server
PORT=3000

And in the .gitignore, with the negation we already know:

.env
.env.*
!.env.example

Why it works well:

  • Diego clones his fork, copies .env.example to .env, fills in his values and starts up. The documentation of what is needed is in the repository, up to date, because it is part of the code.
  • When somebody adds a new variable, the PR review notices that it is missing from .env.example. It is a good candidate for an automatic check in CI.
  • The sample values are obviously fake (put-your-key-here), so nobody mistakes them for real ones or tries to use them.

The three mistakes that ruin the pattern:

  1. Putting real values in .env.example "to make it more convenient". Then the versioned file is the one with the secret in it, and you have gained nothing.
  2. Writing .env* without the negation. The .env.example gets ignored too, nobody pushes it, and the whole pattern ceases to exist.
  3. Adding the .gitignore too late. If .env has already been committed at some point, git rm --cached takes it off the tip but not out of the history (section 8). The secret is still there.

That third case — the secret that has already leaked — has a procedure of its own, with an order of steps that matters a great deal: first you rotate the credential, then you rewrite the history. It is the heart of lesson 08-05.

Common Mistakes and Tips

Mistake 1: believing that .gitignore untracks an already versioned file. It is misunderstanding number one. It only acts on untracked files; for everything else, git rm --cached.

Mistake 2: data/ when you meant data/*. If the directory is ignored, Git does not go into it and no later negation works. Ignore the contents, not the directory.

Mistake 3: putting the negation before the pattern. The last matching line wins. !errors.log followed by *.log is useless.

Mistake 4: forgetting the leading slash. build/ ignores any build in the project; /build/ only the one in the root. It usually matters more than it seems.

Mistake 5: using git add -f as your usual solution. It makes the file tracked for ever and leaves the exception undocumented. Fix the pattern and write the negation.

Mistake 6: .env* without !.env.example. It breaks the whole secrets pattern.

Mistake 7: trusting git rm --cached to remove a secret. It only takes it off the tip. The history and every clone still hold it. Lesson 08-05.

Mistake 8: filling the project's .gitignore with personal rules. .idea/ and *.swp belong to your machine; they go in core.excludesFile. The project's .gitignore should contain only what would happen to anybody.

Tip 1: create the .gitignore in the first commit. Before installing dependencies. It is infinitely cheaper than cleaning up afterwards.

Tip 2: git status --ignored from time to time. It is the only way of seeing what Git is hiding, and of spotting overly broad patterns.

Tip 3: git check-ignore -v the moment you are in doubt. It answers in a second what otherwise turns into half an hour of trial and error.

Tip 4: configure your core.excludesFile once in your life. It saves you dirtying every repository you ever work in.

Tip 5: comment the .gitignore by sections. And explain the odd patterns. A year from now nobody will remember why !config/production/settings.json is there.

Tip 6: validate .env.example in CI. A check comparing the keys in .env.example with the ones the code reads from process.env stops them drifting apart.

Exercises

Exercise 1: the negation trap

In a test repository, create this structure:

data/
├── public.csv
├── private.csv
└── backups/
    └── private-2026.csv
  1. Write a .gitignore with data/ and !data/public.csv. Check with git status and with git check-ignore -v that public.csv is still ignored, and explain why.
  2. Fix it so that public.csv is versioned and everything else stays ignored.
  3. Now get data/backups/private-2026.csv versioned as well, without versioning private.csv.
  4. Verify each step with git check-ignore -v.

Exercise 2: cleaning up an already contaminated repository

Simulate Bruno's disaster:

  1. Create a repository, an app.js, a node_modules/ directory with three files, a .env with a fictitious password and a .DS_Store.
  2. Commit it all without a .gitignore (the initial mistake).
  3. Now add a correct .gitignore, with the secrets pattern from section 12.
  4. Check that git status still shows the files that should be ignored and explain why.
  5. Untrack them with the recipe from section 8, without deleting them from disk.
  6. Verify that git status comes out clean, that the files are still on your disk and that git status --ignored shows them as ignored.
  7. Show that the password in the .env is still accessible in the history and say which lesson solves that.

Exercise 3: the three levels

  1. Configure a global core.excludesFile with the rules for your operating system and your editor.
  2. In a test repository, add a personal-notes.md file to .git/info/exclude.
  3. Add the rule dist/ to the project's .gitignore.
  4. Create the three types of file and check with git check-ignore -v that each one is ignored by the correct level.
  5. For each of these files, decide with reasons which level the rule should be at: node_modules/, .idea/workspace.xml, carla-tests.js, coverage/, .DS_Store, .env.

Solutions

Solution 1:

mkdir -p /tmp/practice-ignore/data/backups && cd /tmp/practice-ignore
git init -b main
echo "a,b" > data/public.csv
echo "key,value" > data/private.csv
echo "hist" > data/backups/private-2026.csv
# 1. The version that DOES NOT work
cat > .gitignore <<'EOF'
data/
!data/public.csv
EOF

git status --short
?? .gitignore

data/public.csv does not show up: it is still ignored.

git check-ignore -v data/public.csv
.gitignore:1:data/	data/public.csv

The diagnosis is explicit: the decision is made by line 1, with the data/ pattern. The negation on line 2 is never evaluated, because Git, having ignored the data/ directory, does not go into it. It is a deliberate performance optimisation.

# 2. The correct version: ignore the contents, not the directory
cat > .gitignore <<'EOF'
data/*
!data/public.csv
EOF

git status --short
?? .gitignore
?? data/public.csv
git check-ignore -v data/public.csv   # no output, exit code 1: NOT ignored
git check-ignore -v data/private.csv
.gitignore:1:data/*	data/private.csv
# 3. Rescuing a file in a subdirectory too
cat > .gitignore <<'EOF'
data/*
!data/public.csv
!data/backups/
data/backups/*
!data/backups/private-2026.csv
EOF

git status --short
?? .gitignore
?? data/backups/private-2026.csv
?? data/public.csv

The key to step 3: you have to un-ignore the intermediate directory (!data/backups/) so that Git goes into it, and then ignore its contents again (data/backups/*) so that you can rescue only what you want. Every level of depth demands its own pair of lines.

Solution 2:

mkdir -p /tmp/practice-cleanup/node_modules/marked && cd /tmp/practice-cleanup
git init -b main
echo "console.log('task-manager');" > app.js
echo '{"name":"marked"}' > node_modules/marked/package.json
echo "module" > node_modules/marked/index.js
echo "{}" > node_modules/.package-lock.json
echo "DATABASE_URL=postgres://taskmanager:fake-password@localhost/tasks" > .env
touch .DS_Store
# 2. The initial mistake
git add . && git commit -m "chore: initial commit"
git ls-files
.DS_Store
.env
app.js
node_modules/.package-lock.json
node_modules/marked/index.js
node_modules/marked/package.json
# 3. The .gitignore that should have existed from the start
cat > .gitignore <<'EOF'
node_modules/
.env
.env.*
!.env.example
.DS_Store
EOF

cat > .env.example <<'EOF'
# Copy to .env and fill in the real values.
DATABASE_URL=postgres://user:password@localhost:5432/tasks
EOF
# 4. It is of no use yet
echo "change" >> node_modules/marked/index.js
git status --short
 M node_modules/marked/index.js
?? .env.example
?? .gitignore

It still shows up because it is already tracked. .gitignore only acts on the "untracked → staged" boundary, and these files crossed it in step 2.

# 5. The clean-up recipe
git rm -r --cached . > /dev/null
git add .
git status --short
D  .DS_Store
D  .env
A  .env.example
A  .gitignore
D  node_modules/.package-lock.json
D  node_modules/marked/index.js
D  node_modules/marked/package.json

The D entries are exactly the files that should not have been there. app.js does not appear because its content has not changed.

git commit -m "chore: apply the .gitignore to the already versioned files"
# 6. Verification
git status --short          # empty
ls -a                       # .env, .DS_Store and node_modules/ are STILL on disk
git status --ignored --short
!! .DS_Store
!! .env
!! node_modules/
# 7. The password is still in the history
git log --all --oneline -- .env
git show HEAD~1:.env
DATABASE_URL=postgres://taskmanager:fake-password@localhost/tasks

There it is. Anybody with access to the repository, now or five years from now, can recover it with a single command, and every existing clone contains it. git rm --cached protects the future, it does not repair the past. The correct procedure — rotate the credential first and then rewrite the history with git filter-repo — is the content of lesson 08-05.

Solution 3:

# 1. Global
cat > ~/.gitignore_global <<'EOF'
.DS_Store
Thumbs.db
.idea/
*.swp
*~
EOF
git config --global core.excludesFile ~/.gitignore_global
# 2 and 3. Local to the repository and to the project
mkdir /tmp/practice-levels && cd /tmp/practice-levels && git init -b main
echo "personal-notes.md" >> .git/info/exclude
echo "dist/" > .gitignore
# 4. One file of each type
mkdir dist && touch dist/app.min.js personal-notes.md .DS_Store app.js

git check-ignore -v dist/app.min.js personal-notes.md .DS_Store app.js
.gitignore:1:dist/	dist/app.min.js
.git/info/exclude:6:personal-notes.md	personal-notes.md
/home/ana/.gitignore_global:1:.DS_Store	.DS_Store

app.js does not appear: it is not ignored by any rule. Each of the other three is ignored by exactly the level it belongs to.

5. Where each rule goes:

File Correct level Reason
node_modules/ Project It belongs to the project: it will appear for anybody who runs npm install. Without it, anybody can push it.
.idea/workspace.xml Global It belongs to your IDE, not to the project. Ana with Vim and Bruno with VS Code never generate it. (Many projects duplicate it in the .gitignore as a safety net; it is a legitimate defensive decision.)
carla-tests.js .git/info/exclude It belongs to Carla and only to this repository. It has no reason to appear in the shared file nor to be justified in a review.
coverage/ Project It is generated by the project's testing tool on anybody's machine.
.DS_Store Global macOS generates it, not the project. Bruno having it in his global protects everyone; putting it in the project as well is a safety net.
.env Project, without a shadow of doubt It is the most important security rule in the repository and it cannot depend on each person having their global properly configured. It goes in the versioned .gitignore, always.

The last row is the underlying criterion: the greater the consequence of the rule being missing, the higher up it should be and the more versioned it should be.

Conclusion

The essentials of this lesson:

  • You version the source, not the result. Out go dependencies, build artefacts, operating system files, personal editor configuration, logs, temporary files, large binaries and — above all — secrets. In stay the dependency lockfiles and the shared project configuration.
  • The syntax has three rules you have to take on board: the trailing slash means "directories only", the leading slash anchors to the root, and any slash in the middle of the pattern also anchors. Within a file, the last matching line wins.
  • Negation with ! has a hard limit: you cannot un-ignore a file inside an ignored directory, because Git does not even go in. You ignore the contents (data/*), not the directory (data/).
  • There are three levels: the project's .gitignore (versioned, for what happens to anybody), .git/info/exclude (local, for your things in that repository) and core.excludesFile (global, for what your operating system or your editor generates). The more serious the consequence of a rule being missing, the higher up and the more versioned it should be.
  • The rule that confuses everybody: .gitignore only affects untracked files. On what is already versioned it has no effect. The way back is git rm --cached, which removes from the index but keeps on disk, with two warnings: it deletes the file on everybody else's machine when they pull, and it does not touch the history.
  • git check-ignore -v answers in a second which file, which line and which pattern are deciding. git status --ignored shows what Git is hiding from you.
  • git add -f forces the issue, but it makes the file tracked for ever. It is nearly always better to write the exception as a negation, so that it is documented.
  • The pattern for secrets is an ignored .env plus a versioned .env.example with keys and obviously fake values, plus the !.env.example negation. It is the best possible documentation of what configuration the project needs.

task-manager now knows which files must not go in. The other side of the problem remains: the files that do go in, but that Git should not treat as though they were all the same. A PNG is not a text file and there is no sense in trying to merge it. A CHANGELOG.md would like to be merged in a special way. And, above all, there is the problem Carla has been dragging along since module 1: every time she edits a file on Windows, git diff marks every line as modified.

That is the territory of lesson 08-04: File Attributes with .gitattributes.

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