DevFox Labs
HomeLearningToolsAboutContact
beginner25 minby DevFox

Choosing What Git Tracks

Keep your repository to source and nothing else. .gitignore pattern syntax, why ignoring a tracked file does nothing, git rm --cached, check-ignore, and global ignores.

  • Git
  • Security

On this page

  • What does not belong in a repo
  • The file that does the ignoring
  • Patterns, one line at a time
  • Anchoring to the root, and un-ignoring
  • More than one .gitignore
  • Ignoring only works on untracked files
  • When a secret is already committed
  • Working out which rule is winning
  • Ignores that are only yours
  • A starting file you do not have to write
  • A cheat sheet to keep
  • Where to go next
DevFox Labs

Structured lessons and courses for developers who care about craft.

Platform

HomeLearningAll lessonsAboutContact

Legal

Terms of ServicePrivacy PolicyCookie Policy

© 2026 DevFox Labs. All rights reserved.

    A repository should hold the recipe, not the cake. Somewhere on your disk right now there is probably a dependency folder larger than everything you have ever written, a config file with a live database password in it, and a scattering of files your editor left behind without asking. None of that is your project, and none of it belongs in history.

    By the end of this lesson your repository will contain your source and nothing else, and you will know exactly how to pull something back out when it slips through anyway.

    What does not belong in a repo

    The junk that ends up in repositories falls into four families, and it helps to be able to name them.

    Build output and dependencies

    Regenerable. One command rebuilds them.

    node_modules/, dist/, target/, __pycache__/, .venv/. Enormous, often machine-specific, full of binaries compiled for your operating system and absolute paths out of your home directory.

    Secrets

    Permanent in a way almost nothing else in Git is.

    .env, credentials.json, private keys, *.pem. This is the family that gets its own section below, and the reason this lesson exists at all.

    Editor and OS junk

    Not dangerous. Just noise.

    .DS_Store, Thumbs.db, .idea/, *.swp. Clutters every diff and starts arguments about whose editor settings win.

    Large binaries and logs

    Forever, and every clone pays for them.

    Video, datasets, *.log, *.sqlite. Git keeps every version of every file, so a 200 MB asset edited ten times is two gigabytes every future clone downloads — even after you delete it.

    Only one of these four is dangerous. All four are worth keeping out.

    Regenerable, secret, or huge

    Before adding anything to a commit, ask whether it is regenerable, secret, or huge. A yes to any one of those three means it does not belong in the repository. That single question catches almost everything, and it is short enough to actually remember at the keyboard.

    The file that does the ignoring

    You tell Git what to skip with a .gitignore file, usually in the root of your repository. The format is deliberately boring: one pattern per line, lines starting with # are comments, and blank lines are ignored so you can group rules with breathing room.

    text
    # .gitignore
    
    # Dependencies
    node_modules/
    
    # Secrets
    .env

    Files matching those patterns become ignored: Git will not list them in git status, and git add . walks straight past them. Commit the .gitignore itself so everyone who clones the project ignores the same things — it is one of the few files whose whole job is to be shared.

    Patterns, one line at a time

    A bare name matches that name anywhere in the repository, at any depth — as a file or as a directory.

    text
    .env          # matches .env, config/.env, apps/api/.env

    Adding a trailing slash restricts the match to directories only. This matters more than it looks: build would also ignore a compiled executable named build, while build/ ignores only the folder.

    text
    build/        # matches the build/ directory anywhere
                  # does NOT match a file called build

    An asterisk matches any run of characters except a slash, which makes it the workhorse for extensions.

    text
    *.log         # matches error.log, logs/app.log
    *.tmp

    A question mark matches exactly one character.

    text
    draft?.txt    # matches draft1.txt and draftA.txt
                  # does NOT match draft10.txt or draft.txt

    Two asterisks cross directory boundaries, which is how you reach into nested paths.

    text
    docs/**/*.pdf # any .pdf at any depth under docs/
    **/logs/      # a logs/ directory anywhere
    build/**      # everything inside build/, at any depth

    Anchoring to the root, and un-ignoring

    By default patterns float — they match at every level. A leading slash anchors the pattern to the directory containing the .gitignore, which for a root-level file means the repository root.

    text
    /build/       # only the build/ folder at the repo root
    build/        # any build/ folder, including src/build/

    A slash anywhere else in the pattern anchors it too, so doc/frotz/ matches only the top-level doc/frotz, while frotz/ matches it anywhere.

    An exclamation mark negates a pattern, re-including something an earlier line excluded. Order matters: the last matching rule wins.

    text
    *.log
    !important.log   # keep this one, ignore the rest

    Now the gotcha that costs people an afternoon. You cannot un-ignore a file inside an ignored directory. Git does not descend into an excluded directory at all, so it never sees the file you are trying to rescue.

    text
    # Does NOT work — Git never looks inside assets/
    assets/
    !assets/logo.svg
    
    # Works — ignore the contents, then rescue one file
    assets/*
    !assets/logo.svg

    Exclude the contents, not the folder

    Whenever you need one survivor from an otherwise ignored directory, ignore dir/* rather than dir/. Git still walks into the directory, so the ! line gets a chance to match. If the file you want sits deeper, you must also re-include each directory on the way down.

    More than one .gitignore

    Any directory can have its own .gitignore, and its patterns apply to that directory and everything beneath it. A rule about compiled Python belongs next to the Python code, not in a root file a front-end developer has to scroll past.

    The files compose from the top down, and the nearest one has the final say. A pattern in api/.gitignore beats a conflicting pattern in the root .gitignore, exactly like a local variable shadowing a global one. So a subdirectory can re-include something the root ignored — as long as the root did not ignore the whole directory, per the rule above.

    Ignoring only works on untracked files

    Here is the rule that trips up nearly everyone, and the reason so many people conclude .gitignore "does not work".

    .gitignore only affects untracked files. It is a bouncer at the door, not a way to remove someone already sitting at the bar. Once a file has been committed even once, Git considers it tracked, and it will keep reporting and committing changes to that file no matter what patterns you write.

    The fix is to untrack it while leaving it on your disk:

    bash
    git rm --cached .env

    git rm --cached removes the file from the index — Git's record of what it is tracking — but does not touch the copy in your working directory. The file stays exactly where it is and Git stops watching it. Because it is now untracked, your .gitignore finally takes effect.

    To re-apply your ignore rules across a whole repository, clear the index and rebuild it:

    bash
    git rm -r --cached .        # untrack everything (files stay)
    git add .                   # re-add, now obeying .gitignore
    git status                  # only ignored files show as deleted
    git commit -m "Apply .gitignore to tracked files"

    Commit or stash your in-progress work first, and read that git status carefully — the only deletions listed should be files you meant to stop tracking.

    Do not forget --cached

    Running git rm .env without --cached deletes the file from your disk as well as the index. For a secrets file that exists nowhere else, there is nothing to restore it from. Also note that untracking a file records a deletion, so teammates who pull will lose their copy — tell them before you do it to a shared file.

    When a secret is already committed

    Say a .env with a real API key made it into a commit last week. Removing it now, in a new commit, does not remove it from history. The old commit still contains it, and anyone with the repository can read it with a single git show. A later deletion hides it from the current checkout and nothing more.

    If that commit was ever pushed, treat the credential as leaked. Not "probably fine" — leaked. Clones, forks, CI caches, and your host's backups may all hold a copy, and no command you run locally reaches any of them. The only step that genuinely helps is rotating the secret: issue a new key and revoke the old one.

    Purging a file from every past commit is possible, but it rewrites every commit ID from that point forward and forces everyone to re-clone. That belongs to the advanced course, and it is never a substitute for rotation.

    Rotate first, clean up second

    Rotate the credential the moment you notice, before you touch Git at all. Then add the file to .gitignore, run git rm --cached, and commit. Cleaning history without rotating leaves you exposed while feeling safe, which is the worst of both outcomes.

    Working out which rule is winning

    When a file is invisible and you cannot see why, stop guessing and ask Git directly:

    bash
    git check-ignore -v build/app.js
    text
    .gitignore:4:build/     build/app.js

    The answer reads as source file, line number, and the pattern that matched, followed by the path. It tells you not only which rule is ignoring the file but which file the rule lives in — what you need when a global ignore or a nested .gitignore is the culprit. If nothing prints, no rule matches.

    By default check-ignore also consults the index, so a tracked file reports as not ignored, which is the truth. Add --no-index to ask whether the pattern would match if the file were untracked. That distinction is often the whole diagnosis.

    To see everything you are currently ignoring:

    bash
    git status --ignored

    Ignores that are only yours

    Your editor is your business. If you use Vim and your teammate uses an IDE that scatters .idea/ folders, neither preference belongs in the project's .gitignore — that file should describe the project, not the tools of whoever set it up.

    Put personal patterns in a global ignore instead:

    bash
    git config --global core.excludesFile ~/.gitignore_global
    text
    # ~/.gitignore_global
    .DS_Store
    Thumbs.db
    .idea/
    .vscode/
    *.swp

    Those patterns now apply in every repository on your machine, and nobody has to review them. Git also reads ~/.config/git/ignore with no configuration at all, if you would rather set nothing.

    For patterns that belong to one repository but should not be shared, edit .git/info/exclude. Same syntax, same effect, but it lives inside .git/ and is never committed — ideal for a scratch file like notes-to-self.md or a local override you use while debugging.

    Start every new repo with your defaults

    Put your standard excludes in ~/.git-template/info/exclude and run git config --global init.templateDir ~/.git-template. Git copies that template into the .git directory of every repository you create from then on, so a fresh git init already knows your habits.

    A starting file you do not have to write

    You rarely need to write a .gitignore from scratch. https://gitignore.io generates one for any combination of language, framework, editor, and operating system. GitHub keeps a curated collection at https://github.com/github/gitignore, where most of those generated files ultimately come from.

    Here is a realistic root .gitignore for a project with a Node front end and a Python backend:

    text
    # --- Dependencies ---
    node_modules/
    .venv/
    venv/
    
    # --- Build output ---
    dist/
    build/
    *.egg-info/
    __pycache__/
    *.py[cod]
    
    # --- Secrets and local config ---
    .env
    .env.*
    !.env.example        # keep the template, ignore real values
    *.pem
    
    # --- Test and tooling caches ---
    coverage/
    .pytest_cache/
    .mypy_cache/
    .ruff_cache/
    
    # --- Logs and local databases ---
    *.log
    npm-debug.log*
    *.sqlite3

    Note the !.env.example line. Committing a placeholder that lists which variables are needed, with no real values, is the kindest thing you can do for the next person who clones the project.

    A cheat sheet to keep

    bash
    git check-ignore -v <path>       # which rule ignores this path
    git check-ignore -v --no-index <path>  # ignore the index too
    git status --ignored             # list ignored files
    git rm --cached <file>           # untrack, keep file on disk
    git rm -r --cached .             # untrack everything (files stay)
    git add .                        # re-add, now obeying .gitignore
    git commit -m "Apply .gitignore" # record the untracking
    git config --global core.excludesFile ~/.gitignore_global
    git config --global init.templateDir ~/.git-template

    Where to go next

    Your repository now holds what it should, and you can prove it with git check-ignore instead of hoping. That is a quieter git status, smaller clones, and one fewer way to leak a key.

    Next comes undoing things safely — unstaging a file you added by mistake, discarding an edit you regret, and stepping back from a commit without losing work. It pairs naturally with this lesson: the moment you notice something has slipped into your history is the moment you want those commands at hand. And once you push to a shared remote, what you committed becomes everyone's, which is why the contents are worth getting right first.

    For practice, run git rm -r --cached . and git add . on a project you already have, and read the git status carefully before committing. Whatever shows up as deleted is the stuff that should never have been there. Fixing it now is a five-minute job; finding it in a year is not.