Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Git & GitHub  Module 2 – Recording changes

Ignore files with .gitignore

Write .gitignore patterns that work, find the rule behind an ignored path with git check-ignore -v, untrack committed files and keep local rules local.

  • Beginner
  • 20 minutes
  • Examples run with git 2.54.0, Python 3.14.8 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Write .gitignore patterns with wildcards, directory slashes, anchors and negation
  • Find the rule that ignores a path with git check-ignore -v
  • Remove a file that was committed by mistake from the index without deleting it
  • Choose between .gitignore, .git/info/exclude and a global excludes file

Before you start

What you’ll learn · Before you start

What you will learn

  • Write .gitignore patterns with wildcards, directory slashes, anchors and negation
  • Find the rule that ignores a path with git check-ignore -v
  • Remove a file that was committed by mistake from the index without deleting it
  • Choose between .gitignore, .git/info/exclude and a global excludes file

Before you start

On this page

A .gitignore file lists patterns for files that Git should leave untracked: build output, installed packages, logs, caches, editor and operating system clutter, and files with passwords or settings for one computer. Once a path matches, git status stops listing it and git add . skips it, so nobody commits it by accident. Each line is one pattern: *.log matches every name that ends in .log, a trailing / limits a pattern to folders, a leading / ties it to the top of the repository, and ! makes an exception. The rule people trip over most: ignoring only ever applies to files Git does not track yet. A file that is already committed stays tracked until you remove it from the index with git rm --cached. When a path is ignored and you cannot see why, git check-ignore -v names the file, line and pattern that decided.

The sessions below all run real Git 2.54.0 on the ordering app of a college canteen, with a Python API and a JavaScript front end.

A first .gitignore

Before any rules, git status lists everything a working project produces, most of which nobody should commit:

The same project before and after a .gitignore Python · first_gitignore.py
from session import run

run("""
git init canteen-app
cd canteen-app
mkdir -p api/__pycache__ .venv/bin web/src web/node_modules/left-pad web/dist logs
touch api/main.py api/__pycache__/main.cpython-314.pyc .venv/pyvenv.cfg .venv/bin/python
touch web/package.json web/src/app.js web/node_modules/left-pad/index.js web/dist/app.js
touch logs/server.log .env .env.example .DS_Store
""", quiet=True)
run("""
# A Python API and a JavaScript front end, before any .gitignore
git status -s -uall
cp ../files/canteen.gitignore .gitignore
git status -s -uall
git status -s --ignored
# What git clean -X would delete: ignored files, and with -d ignored folders too
git clean -X -n
git clean -X -d -n
""")

Output

# A Python API and a JavaScript front end, before any .gitignore
$ git status -s -uall
?? .DS_Store
?? .env
?? .env.example
?? .venv/bin/python
?? .venv/pyvenv.cfg
?? api/__pycache__/main.cpython-314.pyc
?? api/main.py
?? logs/server.log
?? web/dist/app.js
?? web/node_modules/left-pad/index.js
?? web/package.json
?? web/src/app.js
$ cp ../files/canteen.gitignore .gitignore
$ git status -s -uall
?? .env.example
?? .gitignore
?? api/main.py
?? web/package.json
?? web/src/app.js
$ git status -s --ignored
?? .env.example
?? .gitignore
?? api/
?? web/
!! .DS_Store
!! .env
!! .venv/
!! api/__pycache__/
!! logs/
!! web/dist/
!! web/node_modules/
# What git clean -X would delete: ignored files, and with -d ignored folders too
$ git clean -X -n
Would remove .DS_Store
Would remove .env
$ git clean -X -d -n
Would remove .DS_Store
Would remove .env
Would remove .venv/
Would remove api/__pycache__/
Would remove logs/
Would remove web/dist/
Would remove web/node_modules/

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 first_gitignore.py

With the rules in place, git status -s -uall (which lists every untracked file rather than whole folders) shows only the files that belong in the repository, and .gitignore itself, which should be committed so that everyone on the project shares it. git status --ignored shows the other side: each !! line is an ignored file or folder.

The last two commands are a dry run of cleaning up. git clean -X deletes ignored files, which is a quick way to get back to a fresh state, but without -d it does not go into untracked folders, so only the two files were listed; with -d, the folders are included. -n shows what would go, and that is the way to start: git clean deletes for good, with no copy anywhere. Its lower-case cousin -x deletes ignored and untracked files, your new work included.

How a pattern matches

Every pattern follows a few rules, and the slashes matter most:

  • A blank line matches nothing, and a line that starts with # is a comment. Spaces at the end of a line are dropped unless a backslash escapes them, and \# or \! at the start stands for a literal # or !.
  • * matches anything except /, ? matches one character except /, and [abc] or [a-z] match one character from a set; [!abc] matches one that is not in it.
  • A pattern without a slash in it, such as *.log or .env, matches a name at any depth.
  • A slash at the start or in the middle, as in /build or docs/*.pdf, anchors the pattern to the folder of the .gitignore file.
  • A slash at the end, as in tmp/, matches folders only, never a file with that name.
  • ** crosses folders: **/foo matches foo anywhere, a/**/b matches a/b and a/x/y/b, and out/** matches everything inside out/.
  • ! re-includes a path that an earlier pattern excluded, and the last pattern that matches decides.

Here are twenty paths checked against one .gitignore that uses each kind of pattern. --no-index checks the rules without looking at what is tracked, and the paths need not exist, which makes it a quick way to try rules out:

Twenty paths against one .gitignore Python · twenty_paths.py
from session import run

run("""
git init patterns
cd patterns
""", quiet=True)
run("""
cp ../files/patterns.gitignore .gitignore
git check-ignore -v -n --no-index --stdin < ../files/paths.txt
""")

Output

$ cp ../files/patterns.gitignore .gitignore
$ git check-ignore -v -n --no-index --stdin < ../files/paths.txt
.gitignore:2:.env	.env
.gitignore:2:.env	config/.env
.gitignore:4:*.log	server.log
.gitignore:4:*.log	logs/app.log
.gitignore:6:!keep.log	logs/keep.log
.gitignore:8:tmp/	tmp/notes.txt
::	src/tmp
.gitignore:10:/build	build/app.js
::	src/build/app.js
.gitignore:12:docs/*.pdf	docs/guide.pdf
::	docs/old/guide.pdf
.gitignore:14:reports/**/*.pdf	reports/marks.pdf
.gitignore:14:reports/**/*.pdf	reports/term1/marks.pdf
.gitignore:16:cache?	cache1/data.bin
::	cache12/data.bin
.gitignore:18:*.py[co]	app.pyc
::	app.py
.gitignore:20:out/**	out/site/index.html
::	out
::	README.md

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 twenty_paths.py

Each line is the source, the line number and the pattern that decided, then the path; :: means no pattern matched. The numbers count the comment lines too, so .gitignore:4:*.log is the fourth line of the file.

What each pattern did with those paths:

  • .env matched .env and config/.env: no slash, so any depth.
  • *.log matched server.log and logs/app.log, and the later !keep.log brought logs/keep.log back.
  • tmp/ matched tmp/notes.txt, inside a folder called tmp, but not src/tmp, which is a file.
  • /build matched build/app.js at the top, but not src/build/app.js.
  • docs/*.pdf matched docs/guide.pdf but not docs/old/guide.pdf, because * stops at /.
  • reports/**/*.pdf matched both reports/marks.pdf and reports/term1/marks.pdf: **/ also stands for no folder at all.
  • cache? matched cache1/data.bin but not cache12/data.bin: ? is exactly one character.
  • *.py[co] matched app.pyc but not app.py.
  • out/** matched out/site/index.html, everything inside out/, but not out itself.

Matching is case-sensitive by default. On macOS and Windows, though, git init and git clone usually set core.ignoreCase to true, because those file systems treat README and readme as one name, and then patterns match names in any case. A rule that works on your laptop can therefore miss a file on a Linux build server: write patterns in the case the files really use.

A file inside an ignored folder

A common surprise looks like this: a project ignores its build folder but wants to keep one file in it, and the exception does nothing.

An exception that never applies, and the fix Python · negation_trap.py
from session import run

run("""
git init canteen-app
cd canteen-app
mkdir build
touch build/app.js build/keep.txt
""", quiet=True)
run("""
cp ../files/trap.gitignore .gitignore
cat .gitignore
git status -s -uall
git check-ignore -v build/keep.txt
# Ignore what is inside build/, not the folder itself
cp ../files/fixed.gitignore .gitignore
cat .gitignore
git check-ignore -v build/keep.txt build/app.js
git status -s -uall
""")

Output

$ cp ../files/trap.gitignore .gitignore
$ cat .gitignore
build/
!build/keep.txt
$ git status -s -uall
?? .gitignore
$ git check-ignore -v build/keep.txt
.gitignore:1:build/	build/keep.txt
# Ignore what is inside build/, not the folder itself
$ cp ../files/fixed.gitignore .gitignore
$ cat .gitignore
build/*
!build/keep.txt
$ git check-ignore -v build/keep.txt build/app.js
.gitignore:2:!build/keep.txt	build/keep.txt
.gitignore:1:build/*	build/app.js
$ git status -s -uall
?? .gitignore
?? build/keep.txt

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 negation_trap.py

git status does not list build/keep.txt, and git check-ignore -v shows why: line 1, build/, decides, even though line 2 is later. When a folder is ignored, Git does not look inside it at all, so no pattern about a file in it, wherever it is written, can bring the file back. The documentation says so directly: a file cannot be re-included if a parent folder of it is excluded.

With build/ Git never looks inside the folder, so keep.txt stays ignored; with build/* it looks inside and keeps keep.txt.Rules: build/then !build/keep.txtRules: build/*then !build/keep.txtbuild/ is ignored:Git does not look insideapp.js: never readkeep.txt: never read(both stay ignored)build/ is not ignored:Git looks insideapp.js: ignored by build/*keep.txt: kept by the ! rulethe fix

Why an exception inside an ignored folder does nothing, and the fix

Text description of the diagram

The diagram has two panels, one above the other, joined by an arrow labelled "the fix".

  1. The top panel uses the rules build/ and then !build/keep.txt. The folder build/ is ignored, so Git does not look inside it. The box for its files, drawn with a dashed border, says that app.js and keep.txt are never read, so both stay ignored and the exception for keep.txt never applies.
  2. The bottom panel uses the rules build/* and then !build/keep.txt. The folder build/ itself is not ignored, so Git looks inside it: app.js is ignored by build/*, and keep.txt is kept, because the later rule !build/keep.txt matches it.

The fix is to ignore the folder’s contents instead of the folder: build/*. The folder itself is no longer ignored, so Git looks inside, line 1 ignores build/app.js, and line 2 re-includes build/keep.txt. For a file deeper down, each folder on the way needs the same treatment, for example logs/*, !logs/archive/, logs/archive/* and !logs/archive/keep.txt.

Files that are already tracked

.gitignore only affects untracked files, and even an ignored file can be added on purpose with git add -f, after which it is tracked like any other. Here config.local was committed before anyone wrote a rule for it:

A rule for a file Git already tracks Python · tracked_file.py
from session import run

run("""
git init canteen-app
cd canteen-app
echo "port = 8080" > config.local
git add config.local
git commit -m "Add local settings"
""", quiet=True)
run("""
# config.local was committed before anyone wrote a rule for it
echo config.local > .gitignore
echo "port = 8081" > config.local
git status -s
git check-ignore -v config.local
git check-ignore -v --no-index config.local
git ls-files --cached --ignored --exclude-standard
git rm --cached config.local
git status -s
git add .gitignore
git commit -m "Stop tracking config.local"
git status -s --ignored
""")

Output

# config.local was committed before anyone wrote a rule for it
$ echo config.local > .gitignore
$ echo "port = 8081" > config.local
$ git status -s
 M config.local
?? .gitignore
$ git check-ignore -v config.local
[exit status 1]
$ git check-ignore -v --no-index config.local
.gitignore:1:config.local	config.local
$ git ls-files --cached --ignored --exclude-standard
config.local
$ git rm --cached config.local
rm 'config.local'
$ git status -s
D  config.local
?? .gitignore
$ git add .gitignore
$ git commit -m "Stop tracking config.local"
[main cef261d] Stop tracking config.local
 2 files changed, 1 insertion(+), 1 deletion(-)
 create mode 100644 .gitignore
 delete mode 100644 config.local
$ git status -s --ignored
!! config.local

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tracked_file.py

After the rule was added, git status still listed the edit to config.local ( M), because a tracked file stays tracked. git check-ignore -v printed nothing and exited with status 1: by default it skips tracked files, since rules do not apply to them. With --no-index it shows the rule that would apply. git ls-files --cached --ignored --exclude-standard is the command to remember here: it lists every tracked file that your ignore rules match, which is the list of files someone committed by mistake.

git rm --cached config.local removed the file from the index and left it on disk, the commit made the removal permanent, and from then on the rule keeps the file out: git status --ignored reports it with !!. Everyone who pulls that commit has the file deleted from their working tree, so tell your team before you untrack a file they rely on.

Ignoring is not hiding

A .gitignore rule cannot protect a password or key that was already committed: the file stays in every earlier commit, and anyone with a copy of the repository can read it. Treat such a secret as leaked. Change or revoke it first, then remove it from history; a later lesson in this track covers that clean-up.

Where ignore rules live

Git reads patterns from three kinds of file. Put each rule where the people who need it will get it:

  • .gitignore, at the top of the repository or in any folder, is committed, so it applies to everyone who clones the repository. It is the place for what the project’s build creates: node_modules/, __pycache__/, dist/, logs.
  • .git/info/exclude is never committed and applies to this copy of the repository only: your own scratch files in one project.
  • The global excludes file, ~/.config/git/ignore or the file that core.excludesFile names, applies to every repository on your computer: your editor’s and operating system’s files, such as .DS_Store or swap files.
A rule for one repository and a rule for all of them Python · where_rules_live.py
from session import run

run("""
git init canteen-app
cd canteen-app
touch notes.txt .DS_Store app.py
""", quiet=True)
run("""
git status -s
# Only for this repository, never committed
echo notes.txt >> .git/info/exclude
# For every repository on this computer: the file Git reads when core.excludesFile is not set
mkdir -p ~/.config/git
echo .DS_Store > ~/.config/git/ignore
git status -s
git check-ignore -v notes.txt .DS_Store
""")

Output

$ git status -s
?? .DS_Store
?? app.py
?? notes.txt
# Only for this repository, never committed
$ echo notes.txt >> .git/info/exclude
# For every repository on this computer: the file Git reads when core.excludesFile is not set
$ mkdir -p ~/.config/git
$ echo .DS_Store > ~/.config/git/ignore
$ git status -s
?? app.py
$ git check-ignore -v notes.txt .DS_Store
.git/info/exclude:7:notes.txt	notes.txt
~/.config/git/ignore:1:.DS_Store	.DS_Store

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 where_rules_live.py

The session wrote one rule into each file, and git check-ignore -v named both sources. Git prints the global file’s full path; the session shows its home folder as ~. To use a different global file, run git config --global core.excludesFile <path>.

When rules disagree, gitignore(5) gives the order. A pattern given on the command line wins first. Then come the .gitignore files, where a .gitignore in a deeper folder overrides those above it and its patterns match relative to its own folder. Then .git/info/exclude, and last the global file. Within one file, the last matching pattern decides.

Start from a template

You rarely need to write a .gitignore from nothing. GitHub keeps its recommended templates, one per language, framework and editor, in the github/gitignore repository, released under CC0, and offers them when you create a repository. Its Global folder holds templates for editors and operating systems, which fit the global excludes file better than a project’s .gitignore. Read any template before you commit it: it may ignore a file your project does want, a lock file, say.

.gitignore Generator Combine templates for your stack, add your own rules and test paths against them, with the deciding line for each.

Practise: keep exactly the right files

Exercise · Medium · JavaScript

Write a .gitignore that keeps exactly the right files

The college canteen's ordering app has a Python API in api/, a JavaScript front end in web/ and a few folders of generated files. Write its .gitignore in gitignore.mjs, as the text of the template string: one rule per line, each starting at the beginning of its line, because spaces before a pattern are part of it.

These paths must be ignored:

  • .env and api/.env, which hold passwords;
  • api/__pycache__/main.cpython-314.pyc, api/routes/__pycache__/menu.cpython-314.pyc and .venv/bin/python;
  • web/node_modules/react/index.js and web/dist/app.js;
  • logs/server.log and logs/old/server-1.log.gz;
  • build/canteen-0.1.0.whl;
  • .DS_Store and web/.DS_Store.

These must be kept (not ignored):

  • .env.example, api/main.py, api/routes/menu.py, web/src/app.js and web/package.json;
  • web/dist/.gitkeep, an empty file that keeps the folder in the repository;
  • logs/README.md, which explains what the logs are;
  • docs/build/index.html, the documentation's own build page.

The sample tests read your rules the way Git does, with one .gitignore at the top of the repository and the default case-sensitive matching, and each failure names the rule that decided, as git check-ignore -v would. None of these rules needs a backslash; if you use one anyway, write it twice, because the rules sit in a JavaScript template string.

Starter code · gitignore.mjs

// The canteen app's .gitignore: one rule per line, each at the start of its line.
export const gitignore = `
`;
The sample tests · gitignore.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { gitignore } from './gitignore.mjs';

// ---- How Git reads a .gitignore (gitignore(5), and the wildmatch and directory rules of Git's dir.c) ----------------
// One .gitignore at the top of the repository, case-sensitive. Every expected value below is what
// `git check-ignore -v -n --no-index` printed for the reference rules (Git 2.54.0).

const MATCH = 0, NOMATCH = 1, ABORT_ALL = -1, ABORT_TO_STARSTAR = -2;
const CLASSES = {
  alnum: (c) => /[A-Za-z0-9]/.test(c), alpha: (c) => /[A-Za-z]/.test(c), blank: (c) => c === ' ' || c === '\t',
  cntrl: (c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127, digit: (c) => /[0-9]/.test(c),
  graph: (c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) < 127, lower: (c) => /[a-z]/.test(c),
  print: (c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) < 127, punct: (c) => /[!-/:-@[-`{-~]/.test(c),
  space: (c) => /[ \t\n\r\f\v]/.test(c), upper: (c) => /[A-Z]/.test(c), xdigit: (c) => /[0-9A-Fa-f]/.test(c),
};
const isGlobSpecial = (c) => c === '*' || c === '?' || c === '[' || c === '\\';

function dowild(p, pi, t, ti, pathname) {
  for (; pi < p.length; pi++, ti++) {
    let pch = p[pi];
    const tch = t[ti];
    if (tch === undefined && pch !== '*') return ABORT_ALL;
    switch (pch) {
      case '\\':
        pi++;
        pch = p[pi];
        if (tch !== pch) return NOMATCH;
        continue;
      case '?':
        if (pathname && tch === '/') return NOMATCH;
        continue;
      case '*': {
        let matchSlash;
        if (p[pi + 1] === '*') {
          const prev = pi - 1;
          pi++;
          while (p[pi + 1] === '*') pi++;
          const next = p[pi + 1];
          if ((prev < 0 || p[prev] === '/') && (next === undefined || next === '/' || (next === '\\' && p[pi + 2] === '/'))) {
            if (next === '/' && dowild(p, pi + 2, t, ti, pathname) === MATCH) return MATCH;
            matchSlash = true;
          } else matchSlash = !pathname;
        } else matchSlash = !pathname;
        pi++;
        if (pi >= p.length) {
          if (!matchSlash && t.indexOf('/', ti) !== -1) return ABORT_TO_STARSTAR;
          return MATCH;
        }
        if (!matchSlash && p[pi] === '/') {
          const slash = t.indexOf('/', ti);
          if (slash === -1) return ABORT_ALL;
          ti = slash;
          continue;
        }
        for (;;) {
          if (ti >= t.length) break;
          if (!isGlobSpecial(p[pi])) {
            const want = p[pi];
            while (ti < t.length && (matchSlash || t[ti] !== '/')) {
              if (t[ti] === want) break;
              ti++;
            }
            if (t[ti] !== want) return matchSlash ? ABORT_ALL : ABORT_TO_STARSTAR;
          }
          const matched = dowild(p, pi, t, ti, pathname);
          if (matched !== NOMATCH) {
            if (!matchSlash || matched !== ABORT_TO_STARSTAR) return matched;
          } else if (!matchSlash && t[ti] === '/') return ABORT_TO_STARSTAR;
          ti++;
        }
        return ABORT_ALL;
      }
      case '[': {
        pi++;
        pch = p[pi];
        if (pch === '^') pch = '!';
        const negated = pch === '!';
        if (negated) {
          pi++;
          pch = p[pi];
        }
        let prev = '';
        let matched = false;
        do {
          if (pch === undefined) return ABORT_ALL;
          if (pch === '\\') {
            pi++;
            pch = p[pi];
            if (pch === undefined) return ABORT_ALL;
            if (tch === pch) matched = true;
          } else if (pch === '-' && prev && p[pi + 1] !== undefined && p[pi + 1] !== ']') {
            pi++;
            pch = p[pi];
            if (pch === '\\') {
              pi++;
              pch = p[pi];
              if (pch === undefined) return ABORT_ALL;
            }
            if (tch <= pch && tch >= prev) matched = true;
            pch = '';
          } else if (pch === '[' && p[pi + 1] === ':') {
            const end = p.indexOf(']', pi + 2);
            if (end === -1) return ABORT_ALL;
            if (end - 1 < pi + 2 || p[end - 1] !== ':') {
              if (tch === '[') matched = true;
            } else {
              const isIn = CLASSES[p.slice(pi + 2, end - 1)];
              if (!isIn) return ABORT_ALL;
              if (isIn(tch)) matched = true;
              pi = end;
              pch = '';
            }
          } else if (tch === pch) matched = true;
          prev = pch;
          pi++;
          pch = p[pi];
        } while (pch !== ']');
        if (matched === negated || (pathname && tch === '/')) return NOMATCH;
        continue;
      }
      default:
        if (tch !== pch) return NOMATCH;
        continue;
    }
  }
  return ti < t.length ? NOMATCH : MATCH;
}
const wildmatch = (pattern, text, pathname) => dowild(pattern, 0, text, 0, pathname) === MATCH;

function trimTrailingSpaces(line) {
  let end = line.length;
  while (end > 0 && line[end - 1] === ' ') {
    let backslashes = 0;
    for (let i = end - 2; i >= 0 && line[i] === '\\'; i--) backslashes++;
    if (backslashes % 2 === 1) break;
    end--;
  }
  return line.slice(0, end);
}

function parseGitignore(text) {
  const rules = [];
  String(text ?? '').split('\n').forEach((raw, i) => {
    let pattern = trimTrailingSpaces(raw.replace(/\r$/, ''));
    if (pattern === '' || pattern.startsWith('#')) return;
    const negate = pattern.startsWith('!');
    if (negate) pattern = pattern.slice(1);
    let dirOnly = false;
    if (pattern.endsWith('/') && pattern.length > 1) {
      dirOnly = true;
      pattern = pattern.slice(0, -1);
    }
    const anchored = pattern.includes('/');
    if (pattern.startsWith('/')) pattern = pattern.slice(1);
    if (pattern === '') return;
    rules.push({ line: i + 1, text: raw.trim(), pattern, negate, dirOnly, anchored });
  });
  return rules;
}

function lastMatch(rules, path, isDir) {
  const base = path.slice(path.lastIndexOf('/') + 1);
  for (let i = rules.length - 1; i >= 0; i--) {
    const r = rules[i];
    if (r.dirOnly && !isDir) continue;
    if (r.anchored ? wildmatch(r.pattern, path, true) : wildmatch(r.pattern, base, false)) return r;
  }
  return null;
}

/** Whether Git ignores a file, the rule that decides, and the ignored folder it sits in, if any. */
function checkIgnore(rules, path) {
  const parts = path.split('/');
  for (let n = 1; n < parts.length; n++) {
    const folder = parts.slice(0, n).join('/');
    const rule = lastMatch(rules, folder, true);
    if (rule && !rule.negate) return { ignored: true, rule, folder };
  }
  const rule = lastMatch(rules, path, false);
  return { ignored: !!rule && !rule.negate, rule, folder: null };
}

// ---- The checks -------------------------------------------------------------------------------------------------

const rules = parseGitignore(gitignore);

function expectIgnored(path) {
  const r = checkIgnore(rules, path);
  const why = r.rule ? `line ${r.rule.line}, "${r.rule.text}", brings it back` : 'no rule matches it';
  assert.ok(r.ignored, `${path} should be ignored, but ${why}`);
}

function expectKept(path) {
  const r = checkIgnore(rules, path);
  if (!r.ignored) return;
  const where = r.folder ? `it ignores the folder ${r.folder}/, and Git never looks inside an ignored folder` : 'it matches';
  assert.ok(false, `${path} should be kept, but line ${r.rule.line}, "${r.rule.text}", ignores it: ${where}`);
}

test('secrets: every .env is ignored, the example file is kept', () => {
  expectIgnored('.env');
  expectIgnored('api/.env');
  expectKept('.env.example');
});

test('Python: caches and the virtual environment are ignored, the code is kept', () => {
  expectIgnored('api/__pycache__/main.cpython-314.pyc');
  expectIgnored('api/routes/__pycache__/menu.cpython-314.pyc');
  expectIgnored('.venv/bin/python');
  expectKept('api/main.py');
  expectKept('api/routes/menu.py');
});

test('JavaScript: installed packages and built files are ignored, the source is kept', () => {
  expectIgnored('web/node_modules/react/index.js');
  expectIgnored('web/dist/app.js');
  expectKept('web/src/app.js');
  expectKept('web/package.json');
});

test('web/dist/ keeps its .gitkeep placeholder', () => {
  expectIgnored('web/dist/app.js');
  expectKept('web/dist/.gitkeep');
});

test('logs: every file in logs/ is ignored except logs/README.md', () => {
  expectIgnored('logs/server.log');
  expectIgnored('logs/old/server-1.log.gz');
  expectKept('logs/README.md');
});

test('build output is ignored only at the top: docs/build/ is kept', () => {
  expectIgnored('build/canteen-0.1.0.whl');
  expectKept('docs/build/index.html');
});

test('macOS folder files are ignored in every folder', () => {
  expectIgnored('.DS_Store');
  expectIgnored('web/.DS_Store');
});
A hint

Two of the kept files sit inside folders whose other files are ignored. A rule such as logs/ ignores the folder itself, and Git never looks inside an ignored folder, so a ! rule for a file in it has no effect. Ignore the folder's contents instead, logs/*, and then bring the one file back with !logs/README.md. For build, remember that a pattern with a slash at the start matches only at the top of the repository.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Run these sessions on your own computer

To repeat a session, save its script next to session.py, shown below, and the files folder of the examples. The helper creates each repository in a new folder and gives it a home folder of its own, so the global ignore file it writes in the last session is not yours, and nothing in your own settings changes the results.

session.py, which runs the sessions Python · session.py
"""Runs Git commands for real and prints them as a terminal session.

Each command of a script is shown after a "$ " prompt and run with /bin/sh in the session's folder; what it prints
follows it, standard output and standard error together, as a terminal shows them. A command that fails adds a line
with its exit status, and the session goes on. "cd <folder>" changes the folder of the commands that follow, and a
line that starts with "#" is printed as a comment.

Every run prints the same commit IDs, because Git runs with a fixed setup:
  * one author and committer, and one timestamp for every commit (Git's internal date format: seconds since the Unix
    epoch, then the time zone);
  * no system configuration, and a configuration file of the session's own (never your ~/.gitconfig) that sets only
    the name, the email and "main" as the name of the first branch;
  * a home folder of the session's own ("home", shown as ~), so ~/.config/git/ignore, the global ignore file Git
    reads by default, is the session's too;
  * no colours and no pager, because the output does not go to a terminal.

Git prints the author's date after git commit --amend and git revert (" Date: ..."); every commit of a session has
the same fixed time, so the session shows that line as " Date: …".

answers=["s", "y", "n"] types answers at Git's prompts ("Stage this hunk [y,n,q,...]?"), one per prompt, in order,
and shows each one after its prompt, as it appears on screen. editor="script.py" makes that Python script the editor
Git opens (for the e answer). quiet=True runs the commands and prints nothing: the setup of a repository that the
session then works on.
"""

import os
import re
import shlex
import subprocess
import sys

NAME, EMAIL = "Asha Rao", "asha.rao@example.com"
WHEN = "1767225600 +0530"

ROOT = os.getcwd()
HOME = os.path.join(ROOT, "home")
os.makedirs(HOME, exist_ok=True)
CONFIG = os.path.join(ROOT, "session.gitconfig")
with open(CONFIG, "w", encoding="utf-8") as f:
    f.write(f"[user]\n\tname = {NAME}\n\temail = {EMAIL}\n[init]\n\tdefaultBranch = main\n")

env = dict(os.environ)
env.update(
    HOME=HOME,
    XDG_CONFIG_HOME=os.path.join(HOME, ".config"),
    GIT_CONFIG_NOSYSTEM="1",
    GIT_CONFIG_GLOBAL=CONFIG,
    GIT_AUTHOR_DATE=WHEN,
    GIT_COMMITTER_DATE=WHEN,
    GIT_PAGER="cat",
    GIT_EDITOR="false",
)
cwd = ROOT

# The end of Git's hunk prompt, where the typed answer appears on screen. Git ends its last prompt with a line break
# of its own, which the typed answer's line break stands for.
PROMPT_END = re.compile(r"\]\? \n?")
# The date line of a commit summary ("[main 0fe2ef9] ..." followed by " Date: ...").
DATE_LINE = re.compile(r"^ Date: .+$", re.M)


def show_answers(text: str, answers: list[str]) -> str:
    """The output with each answer written after the prompt that read it."""
    pending = iter(answers)

    def typed(match: re.Match[str]) -> str:
        answer = next(pending, None)
        return match.group(0) if answer is None else f"]? {answer}\n"

    return PROMPT_END.sub(typed, text)


def run(script: str, answers: list[str] | None = None, quiet: bool = False, editor: str | None = None) -> None:
    """Runs each line of `script` and prints the session (nothing at all with quiet=True)."""
    global cwd
    for line in script.strip().splitlines():
        command = line.strip()
        if not command:
            continue
        if command.startswith("#"):
            if not quiet:
                print(command)
            continue
        if not quiet:
            print(f"$ {command}")
        words = shlex.split(command)
        if words[0] == "cd" and len(words) == 2:
            cwd = os.path.normpath(os.path.join(cwd, words[1]))
            continue
        typed = answers if answers and words[:2] == ["git", "add"] else None
        command_env = dict(env)
        if editor:
            command_env["GIT_EDITOR"] = shlex.join([sys.executable, os.path.join(ROOT, editor)])
        result = subprocess.run(
            command,
            shell=True,
            cwd=cwd,
            env=command_env,
            input="".join(f"{a}\n" for a in typed) if typed else "",
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
        )
        output = show_answers(result.stdout, typed) if typed else result.stdout
        if typed and output.endswith("\n\n"):
            # Git ends the session with a line break of its own after the last answer (see PROMPT_END).
            output = output[:-1]
        output = DATE_LINE.sub(" Date: …", output).replace(HOME + "/", "~/")
        if quiet:
            if result.returncode != 0:
                raise SystemExit(f"setup command failed: {command}\n{output}")
            continue
        print(output, end="" if output.endswith("\n") or not output else "\n")
        if result.returncode != 0:
            print(f"[exit status {result.returncode}]")

Interview questions

Fresher · Why does adding a file to .gitignore not stop Git from tracking it?

Ignore rules only tell Git which untracked files to leave alone when it looks for new files, as git status and git add . do. A file that is already tracked is in the index, so Git keeps comparing it with the last commit and listing its changes, whether a rule matches it or not. To stop tracking it, remove it from the index with git rm --cached <file>, which leaves the file on disk, and commit that together with the rule. From then on the rule keeps it out. Earlier commits still contain the file, so if it held a password, change the password as well.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A .gitignore has "logs/" and then "!logs/README.md", but README.md never appears in git status. Explain why, fix it without listing every log file, and show how you would prove that the fix works.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to senior

    A teammate committed .env with a database password, then added .env to .gitignore in the next commit and pushed both. Is the password safe now? Say what you do, in which order, and why.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A pattern without a slash matches at any depth; a slash at the start or in the middle anchors it; a slash at the end matches folders only. * stops at /, ** crosses it, and the last matching pattern decides.
  • A file inside an ignored folder cannot be re-included: ignore the folder’s contents (build/*) and then add the exception (!build/keep.txt).
  • git check-ignore -v names the file, line and pattern behind any path; add --no-index for tracked files and -n to see paths that no rule matches.
  • Ignoring never affects tracked files. git ls-files --cached --ignored --exclude-standard finds them, and git rm --cached stops tracking them without deleting them.
  • Project rules go in .gitignore, rules for one copy in .git/info/exclude, and your own tools’ files in the global excludes file.
  • git clean -X -n previews deleting ignored files; add -d for folders, and only then run it without -n.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 config.local is already committed. You add the line config.local to .gitignore and edit the file. What does git status -s show for it?

    Choose one answer.

    Show the answer to question 1

    Answer: M config.local: Git still tracks it, so the change is listed

    Ignore rules only decide which untracked files Git leaves alone. A tracked file stays tracked whatever .gitignore says, so its changes still show. git rm --cached config.local removes it from the index, and from the next commit on the rule keeps it out.

  2. Question 2 of 6 With this .gitignore, is logs/README.md ignored?

    Read the code, then choose one answer.

    logs/
    !logs/README.md
    Show the answer to question 2

    Answer: Yes. The folder is ignored, so Git never looks inside it and the second rule has no effect

    logs/ ignores the folder itself, and gitignore(5) says a file cannot be re-included when a parent folder is excluded. Write logs/* instead: it ignores what is inside the folder, so Git still looks in it and !logs/README.md brings the file back.

  3. Question 3 of 6 Which pattern ignores build/app.js but not docs/build/index.html?

    Choose one answer.

    Show the answer to question 3

    Answer: /build

    A slash at the start (or in the middle) of a pattern anchors it to the folder of the .gitignore, so /build matches only the top-level build. build, build/ and **/build all match a folder called build at any depth.

  4. Question 4 of 6 You want Git to ignore your editor's swap files in every repository on your computer, without changing any project. Where does the rule go?

    Choose one answer.

    Show the answer to question 4

    Answer: In the global excludes file, ~/.config/git/ignore by default, or the file core.excludesFile names

    The global excludes file applies to every repository you work in and is never committed. .git/info/exclude is for one repository only, and a project's .gitignore is shared with everyone who clones it, so it is the place for files that every developer's build creates.

  5. Question 5 of 6 git clean -X -n lists .env and .DS_Store but not the ignored folder node_modules/. Why?

    Choose one answer.

    Show the answer to question 5

    Answer: Without -d, git clean does not go into untracked folders, ignored ones included

    git clean leaves untracked folders alone unless you add -d. git clean -X -d -n lists the ignored folders too. -n only shows what would be deleted, and that is the safe way to start, because deleted files do not come back.

  6. Question 6 of 6 Which paths does the pattern docs/*.pdf match?

    Choose one answer.

    Show the answer to question 6

    Answer: docs/guide.pdf only, not docs/old/guide.pdf

    The slash anchors the pattern at the folder of the .gitignore, and * never matches a /, so only PDFs directly inside the top-level docs/ match. docs/**/*.pdf matches PDFs at any depth inside it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.