.gitignore Strategies: What Should Actually Be Excluded
AI generated
git
HEAD
Git · Version Control · Best Practices · Magento 2
.gitignore Strategies
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.

12 min. read gitignore · git rm --cached · pattern syntax Git 2.x · PHP · Magento 2.4

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.

11. FAQ: .gitignore Strategies

1What's the difference between a global and a project-specific .gitignore?
Global (core.excludesFile) applies to all repositories and handles personal artifacts like .DS_Store. Project-specific is versioned and handles team-wide exclusions like vendor/.
2Why isn't my .gitignore entry working?
Usually because the file is already tracked. git check-ignore -v checks the pattern, git rm --cached removes already-tracked files from the index.
3How do I ignore a directory but not specific subfolders?
With negation !, but only if the parent folder isn't fully excluded, otherwise Git never scans into it at all.
4What does ** mean in a .gitignore?
Globstar for arbitrary directory depth. **/cache/ matches every cache folder, logs/**/*.log matches all .log files in arbitrarily deep subfolders.
5Why does an ignored file still show up in git status?
Because it was tracked before the rule existed. .gitignore isn't retroactive. git rm --cached removes it from the index.
6How do I safely remove an already-tracked file?
git rm --cached path plus a commit. For secrets, also scrub the history with git filter-repo and rotate credentials.
7Should .env be in .gitignore?
Always. .env and app/etc/env.php contain real credentials. An .env.example without real values serves as the template.
8What belongs in the .gitignore of a Magento 2 project?
At minimum vendor/, node_modules/, generated/, var/, pub/static/, pub/media/, app/etc/env.php, and app/etc/config.local.php.
9How do I find out which rule is ignoring a file?
git check-ignore -v path shows the file, line, and pattern responsible for the exclusion.
10Can I override .gitignore rules per subdirectory?
Yes. A .gitignore in a subdirectory supplements parent files, and later, more specific rules win.