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.
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:
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
*.logor.env, matches a name at any depth. - A slash at the start or in the middle, as in
/buildordocs/*.pdf, anchors the pattern to the folder of the.gitignorefile. - A slash at the end, as in
tmp/, matches folders only, never a file with that name. **crosses folders:**/foomatchesfooanywhere,a/**/bmatchesa/banda/x/y/b, andout/**matches everything insideout/.!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:
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:
.envmatched.envandconfig/.env: no slash, so any depth.*.logmatchedserver.logandlogs/app.log, and the later!keep.logbroughtlogs/keep.logback.tmp/matchedtmp/notes.txt, inside a folder called tmp, but notsrc/tmp, which is a file./buildmatchedbuild/app.jsat the top, but notsrc/build/app.js.docs/*.pdfmatcheddocs/guide.pdfbut notdocs/old/guide.pdf, because*stops at/.reports/**/*.pdfmatched bothreports/marks.pdfandreports/term1/marks.pdf:**/also stands for no folder at all.cache?matchedcache1/data.binbut notcache12/data.bin:?is exactly one character.*.py[co]matchedapp.pycbut notapp.py.out/**matchedout/site/index.html, everything insideout/, but notoutitself.
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.
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.
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".
- 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.
- 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:
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/excludeis never committed and applies to this copy of the repository only: your own scratch files in one project.- The global excludes file,
~/.config/git/ignoreor the file thatcore.excludesFilenames, applies to every repository on your computer: your editor’s and operating system’s files, such as.DS_Storeor swap files.
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.
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:
.envandapi/.env, which hold passwords;api/__pycache__/main.cpython-314.pyc,api/routes/__pycache__/menu.cpython-314.pycand.venv/bin/python;web/node_modules/react/index.jsandweb/dist/app.js;logs/server.logandlogs/old/server-1.log.gz;build/canteen-0.1.0.whl;.DS_Storeandweb/.DS_Store.
These must be kept (not ignored):
.env.example,api/main.py,api/routes/menu.py,web/src/app.jsandweb/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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
"""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.
-
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.
-
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.
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 -vnames the file, line and pattern behind any path; add--no-indexfor tracked files and-nto see paths that no rule matches.- Ignoring never affects tracked files.
git ls-files --cached --ignored --exclude-standardfinds them, andgit rm --cachedstops 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 -npreviews deleting ignored files; add-dfor 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.
References
- gitignore, specifying intentionally untracked files to ignore (Git project)
- git-check-ignore documentation (Git project)
- git-status documentation (--ignored) (Git project)
- git-clean documentation (Git project)
- git-rm documentation (--cached) (Git project)
- git-ls-files documentation (Git project)
- git-config documentation (core.excludesFile, core.ignoreCase) (Git project)
- Ignoring files (GitHub)
- github/gitignore, a collection of .gitignore templates (GitHub)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress