What Should Actually Be Excluded
A poorly maintained .gitignore causes generated files, credentials, and IDE configuration to accidentally end up in the repository, or forces teammates to constantly discard local changes. This article explains global and project-specific rules, exact pattern syntax, the risk of already-tracked files, and a practical baseline for PHP and Magento projects.
Table of Contents
- 1. Why a well-thought-out .gitignore saves time and headaches
- 2. Global vs. project-specific .gitignore: where each rule belongs
- 3. Pattern syntax: negation, directory rules, and globstar
- 4. Order and precedence: which rule wins
- 5. The risk: ignoring files that are already tracked
- 6. A sensible baseline .gitignore for PHP and Magento 2 projects
- 7. Tools: git check-ignore, git status --ignored, and gitignore.io
- 8. Common mistakes when using .gitignore
- 9. .gitignore strategies compared side by side
- 10. Summary
- 11. FAQ
1. Why a well-thought-out .gitignore saves time and headaches
A .gitignore file is not a formality; it is one of the few configuration files with a direct, lasting effect on how clean a repository stays over years. Without sensible rules, generated assets, dependency folders like vendor/ or node_modules/, build artifacts, and local IDE configuration end up in the commit history. This doesn't just bloat the repository unnecessarily, it also creates noise on every git status that obscures real changes and makes code review harder.
The second, often underestimated effect concerns team consistency: when every developer handles their own locally ignored files differently, inconsistent working states emerge. A file ignored by one person and accidentally committed by another leads to merge conflicts that have nothing to do with the actual code. A centralized, versioned .gitignore strategy with a clear split between global, project-specific, and local solves this structurally instead of improvising it team by team.
2. Global vs. project-specific .gitignore: where each rule belongs
Git recognizes three levels of ignore rules that differ fundamentally in scope. The global .gitignore applies to all of a user's repositories and belongs outside any single project, typically at ~/.gitignore_global, activated via git config --global core.excludesFile. Only local-system artifacts belong here: .DS_Store on macOS, Thumbs.db on Windows, and personal editor folders like .idea/ or .vscode/, unless the team versions shared editor settings.
The project-specific .gitignore at the repository root gets committed and defines what applies to every teammate: build directories, dependency folders, generated configuration files. These rules are part of the project's architecture and deserve the same review scrutiny as any other file. A third, often forgotten level is .git/info/exclude in the local repository: it works like a project-specific .gitignore but is never committed, making it suitable for purely personal, non-team-relevant exceptions, such as a temporary debug script.
# Set up a global gitignore for OS- and editor-specific artifacts
git config --global core.excludesFile '~/.gitignore_global'
cat > ~/.gitignore_global << 'EOF'
# macOS
.DS_Store
.AppleDouble
# Windows
Thumbs.db
desktop.ini
# Personal editor state (not shared with the team)
.idea/
.vscode/
*.swp
EOF
# Purely local, per-clone exceptions never get committed
echo "scratch-debug.php" >> .git/info/exclude
3. Pattern syntax: negation, directory rules, and globstar
The pattern syntax of .gitignore looks simple at first glance, but it has several details that regularly lead to wrong assumptions. A pattern without a leading slash matches at any directory depth, while /build only matches the build directory at the repository root. A trailing slash like logs/ explicitly limits the pattern to directories and leaves same-named files untouched. Without that slash, the pattern matches both files and directories with that name.
Negation with ! lets you re-include a previously excluded file, but has one important limitation: if a parent directory is already ignored, a file inside it cannot be brought back via negation, because Git never scans that directory's contents in the first place. The globstar ** matches any number of directory levels, for example **/cache/ for every cache folder regardless of depth, or logs/**/*.log for every .log file in arbitrarily deep subfolders of logs. Comments start with #, and a literal leading hash character is escaped with a backslash.
# Comment lines start with a hash
# Matches "debug.log" anywhere in the tree
debug.log
# Leading slash: only the root-level "build" directory
/build
# Trailing slash: directories only, same-named files stay tracked
cache/
# Globstar: matches "cache" at any depth
**/cache/
# Globstar mid-pattern: any .log file under logs/, any depth
logs/**/*.log
# Negation: re-include one specific file...
*.log
!important.log
# ...but this does NOT work if the parent dir is already ignored:
build/
!build/keep-this.txt # never re-included, Git never scans into build/
# Escaping a literal leading hash or exclamation mark
\#not-a-comment.txt
\!not-negation.txt
4. Order and precedence: which rule wins
When multiple .gitignore files and rules exist at the same time, Git applies them in a fixed order: first the global configuration, then the .gitignore files from the repository root down to the directory containing the affected file, and finally .git/info/exclude. Within this chain, the rule that is read last and is more specific wins. A .gitignore in a subdirectory can therefore selectively override rules from the root .gitignore for that subtree, or lift them again with !, as long as no parent directory is entirely excluded.
This cascade matters especially in modular PHP projects with multiple packages, for example a Magento module directory with its own build process. Instead of maintaining every exception centrally in the root .gitignore, each module can carry its own local .gitignore with module-specific rules. It's important to keep the ordering in mind: a very general rule like *.cache in the root file cannot simply be undone in a subfolder with !important.cache if a parent directory is additionally ignored, blocking access to that subfolder altogether.
5. The risk: ignoring files that are already tracked
The most common misconception when working with .gitignore: a pattern gets added to the file, but the affected file still shows up in git status and in every subsequent commit. The reason is simple and clearly documented in Git's own docs, yet regularly overlooked: .gitignore only applies to untracked files. A file that has already been added and committed once with git add and git commit stays tracked, no matter what rules get added afterward.
The correct fix is git rm --cached, which removes a file from the index without deleting it from the working directory. After that step, the .gitignore rule takes effect as expected. Things get critical when already-tracked files contain sensitive data, for example a .env with database credentials: git rm --cached only removes the file from future commits, the credentials remain irrevocably visible in the existing history to anyone with repository access. In that case, ignoring the file after the fact is not enough; the history needs to be scrubbed with git filter-repo, and every exposed credential must be rotated without exception.
# A file was committed by accident, then added to .gitignore afterwards
echo "app/etc/env.php" >> .gitignore
git status
# ... app/etc/env.php still shows up as "modified" - .gitignore has no effect
# Untrack it without deleting the local working copy
git rm --cached app/etc/env.php
git commit -m "Stop tracking app/etc/env.php, now covered by .gitignore"
# Verify: the file is gone from the index but still exists on disk
ls app/etc/env.php # still present locally
git ls-files app/etc/env.php # empty output, no longer tracked
# If real secrets were ever committed, rm --cached is NOT enough:
# the credentials remain visible in every historical commit.
# Purge history and then rotate every exposed credential immediately.
git filter-repo --path app/etc/env.php --invert-paths
6. A sensible baseline .gitignore for PHP and Magento 2 projects
A good baseline consistently separates source code that belongs under version control from generated or environment-specific artifacts that don't. In Magento 2, that primarily means var/ with caches, logs, and session data, generated/ with automatically generated proxy and factory classes, plus pub/static/ and pub/media/, whose contents are produced by setup:static-content:deploy or by product data imports. Composer dependencies in vendor/ also belong excluded, since composer.lock already documents the exact versions and guarantees reproducible installs.
Environment-specific configuration files like app/etc/env.php with database credentials and the crypt key must never go into the repository, even if staging and production values differ. Instead, an env.php.dist template without real values, kept under version control, is the recommended pattern. Node dependencies for the Hyvä Tailwind build and editor/OS artifacts round out the baseline. Important: this file should exist from day one of a new project, not get patched in later once dozens of wrong files are already tracked.
# .gitignore - baseline for a PHP / Magento 2 project
# Composer dependencies (composer.lock guarantees reproducible installs)
/vendor/
# Node dependencies for the Hyva Tailwind build pipeline
/node_modules/
# Magento generated code, caches, logs and session data
/generated/
/var/*
!/var/.htaccess
# Deployed static content and imported/uploaded media
/pub/static/*
!/pub/static/.htaccess
/pub/media/*
!/pub/media/.htaccess
# Environment-specific configuration with real credentials
/app/etc/env.php
/app/etc/config.local.php
# Local environment overrides
.env
.env.local
# Build output of the Tailwind/Hyva theme pipeline
/app/design/frontend/**/web/tailwind/tailwind-cache.css
# Editor and OS artifacts (prefer the global gitignore for these,
# duplicated here so a fresh clone is safe without extra setup)
.idea/
.vscode/
.DS_Store
Thumbs.db
# PHP tooling caches
.phpunit.cache/
.php-cs-fixer.cache
7. Tools: git check-ignore, git status --ignored, and gitignore.io
When a pattern doesn't behave as expected, guessing is the slowest path to a fix. git check-ignore -v <path> shows exactly which rule, in which file and line, is responsible for a given path, including the full precedence chain of global, project-wide, and local .gitignore. That's especially useful when several .gitignore files exist across nested directories and it's unclear which one actually excludes a file, or is silently defeating a negation rule.
git status --ignored lists every ignored file in the current working directory and reliably reveals whether a .gitignore rule is accidentally too broad and catches important files along with it. To bootstrap a new project, gitignore.io and the official GitHub gitignore collection provide vetted templates for common languages and frameworks, a solid starting point, but they should always be sharpened for the specific project rather than adopted unmodified.
# .github/workflows/gitignore-check.yml
# Fail CI if a file matching .gitignore was accidentally committed
name: gitignore-check
on: [pull_request]
jobs:
check-tracked-ignored-files:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fail if any tracked file matches .gitignore
run: |
# -c: cached (tracked) files, -i: ignored, --exclude-standard: use .gitignore
tracked_but_ignored=$(git ls-files -ci --exclude-standard)
if [ -n "$tracked_but_ignored" ]; then
echo "These tracked files match .gitignore rules:"
echo "$tracked_but_ignored"
exit 1
fi
8. Common mistakes when using .gitignore
The most common mistake is the one already described: a file gets ignored even though it's long since tracked, without running git rm --cached. A second classic mistake involves case sensitivity: on case-insensitive filesystems like macOS and Windows a pattern appears to work, while the same rule on a case-sensitive Linux CI server fails to match a differently-cased file. A third mistake is trusting that .gitignore provides security: an ignored .env file only protects against future commits, not against existing copies in backups, CI artifacts, or the Git history itself.
Silent misconfiguration through whitespace is also common: a pattern with an accidental trailing space is no longer interpreted as expected, since Git treats the space as part of the pattern unless it's escaped. The ordering of negation rules is equally underestimated: if !important.log appears before the *.log rule that's supposed to exclude it, the negation has no effect, because later rules in the file win. Knowing these pitfalls in advance saves debugging sessions that would otherwise drag on for hours.
9. .gitignore strategies compared side by side
Most .gitignore problems trace back to a handful of recurring scenarios that are entirely avoidable with the right approach. The table below contrasts typical missteps with the recommended strategy.
| Scenario | Wrong approach | Recommended strategy | Benefit |
|---|---|---|---|
| node_modules/ already tracked | Just add it to .gitignore | git rm -r --cached node_modules/ |
File actually becomes untracked |
| .env with real credentials | Ignore it later, leave history intact | Scrub history, rotate credentials | No stale credentials left in history |
| IDE configuration (.idea/, .vscode/) | Re-maintain in every project .gitignore | Global .gitignore via core.excludesFile | Configured once, active for every repo |
| Generated assets (var/, generated/) | Commit the whole directory "just in case" | Ignore consistently, regenerate via CI | Small repository, no merge conflicts |
| Un-ignoring a file in a subfolder | Negation after the parent dir is excluded | Never fully exclude the parent directory | Negation works as expected |
The table reveals a recurring pattern: most .gitignore problems don't stem from missing rules, but from doing the steps in the wrong order, especially when ignoring a file after it was already tracked. Treating git rm --cached as a routine part of the workflow rather than an emergency fix avoids most of these pitfalls from the start.
Mironsoft
Git workflows, repository hygiene, and deployment setup for PHP and Magento teams
Repository cluttered with unnecessary files?
We clean up existing repositories, safely remove accidentally tracked files from history, and set up a clean, team-wide .gitignore strategy for your PHP and Magento stack.
Repository audit
Identify accidentally tracked files, secrets, and legacy clutter
History cleanup
Safe removal of sensitive data and rotation of affected credentials
Baseline setup
Global and project-specific .gitignore for the entire team workflow
10. Summary
A well-thought-out .gitignore strategy keeps three levels cleanly separated: global for personal system and editor artifacts, project-specific and versioned for team-wide build and configuration exclusions, and local via .git/info/exclude for purely personal exceptions. The pattern syntax, with a leading slash, a trailing slash for directories, globstar ** for arbitrary depth, and negation with !, covers virtually every use case, as long as you know the precedence rules and remember that an already-excluded parent directory renders a negation useless.
The most important practical point remains: .gitignore only affects untracked files. Already-committed files must be explicitly removed from the index with git rm --cached, and for sensitive data that alone isn't enough, it requires an actual history scrub plus credential rotation. A solid baseline for PHP and Magento 2 projects, set up from day one instead of patched in later, avoids exactly this kind of painful cleanup work.
.gitignore Strategies - The Essentials at a Glance
Three levels
Global (core.excludesFile) for personal artifacts, project .gitignore for team rules, .git/info/exclude for local exceptions.
Pattern syntax
Leading slash: root-relative. Trailing slash: directories only. **: arbitrary depth. !: negation, ineffective if the parent is ignored.
Already-tracked files
.gitignore only affects untracked files. git rm --cached is mandatory; for secrets, also scrub history.
Magento baseline
vendor/, node_modules/, var/, generated/, pub/static/, app/etc/env.php must be excluded consistently.