The 900 MB repository

The intern cloned the repo on Monday. By Thursday the clone was 900 megabytes, the pull requests were failing on a full disk, and the archaeology was grim: someone had committed node_modules in 2021, a teammate had added their entire macOS Photos library in a misfired drag-and-drop, and there was a directory named backup/ containing three previous versions of the project. None of this was Git's fault. Git tracks exactly what you tell it to track. The repo was simply missing — or had a badly wrong — its .gitignore.

A .gitignore file is a few lines of pattern syntax that tell Git what never to track: dependency caches, build output, OS junk like .DS_Store, editor scratch state, and the .env files that hold your secrets. Writing one from memory is a rite of passage everyone performs badly once — forgetting __pycache__, ignoring dist/ but not build/, committing a .env with production keys. Writing one from a generator that knows your stack is the version of that rite that takes eleven seconds.

The .gitignore Generator on this page takes checkboxes — languages, frameworks, operating systems, editors — and merges curated templates into one file with section comments explaining what each block is for. It runs entirely in the browser, covers fifty-plus stacks, and includes the modern entries most template collections still miss, like the full family of .env variants. This article covers the pattern semantics underneath, a fully annotated merged template, and the failure modes that bite people who copy-paste without reading.

Assembling a .gitignore that matches your stack

  1. Check your stack

    Node, Python, Go, Rust, Java, Unity, and friends. Each adds that ecosystem's dependency directories, build outputs, and tool caches — the files that are reproducible from source and therefore never belong in history.

  2. Add your operating systems

    macOS contributes .DS_Store and friends, Windows contributes Thumbs.db, Linux contributes a couple of quieter offenders. Everyone who clones the repo benefits, because junk arrives with every contributor's laptop, not just yours.

  3. Add your editors

    JetBrains, VS Code, Vim, Emacs. The generator distinguishes between ignore-everything and keep-shared-settings strategies — see the .vscode note in the FAQ below.

  4. Flip the secret-handling switches

    The .env family: .env, .env.local, .env.*.local, and the environment-specific variants. On by default, because an ignored secret is infinitely preferable to a committed one.

  5. Review the merged template

    The output arrives with section comments — # Node, # macOS, # Environment — and per-line explanations on hover. Delete blocks you know you don't need; a lean .gitignore is easier to audit than a kitchen-sink one.

  6. Copy into your repository root

    Save as .gitignore at the repo root, commit it, and verify with git status that your build directories went dark. If something still shows up that shouldn't, the pattern-semantics section below will tell you why.

Pattern semantics, minus the folklore

Git's ignore language is small, and most of its confusion comes from three characters: the slash, the star, and the exclamation point. Here is the whole grammar that matters in practice.

PatternMatchesNotes
node_modules/Any directory named node_modules, at any depthTrailing slash means directories only, contents included
distFile or directory named dist, anywhereNo slash = unanchored, matches at every level
/distOnly the dist at the .gitignore's own levelLeading slash anchors the pattern
*.logEvery file ending .log, everywhereClassic wildcard form
build/*Everything inside build, but not build itselfThe wildcard that makes re-inclusion possible
!keep.meDo not ignore keep.meNegation — see the parent-directory trap
**/temptemp at any depthDouble star crosses directory boundaries
doc/**/notesnotes anywhere under docDouble star in the middle works too
The eight patterns that cover 99% of real .gitignore files.

Two rules do the most damage when missed. First, the last matching pattern wins — order matters, and a negation placed above the rule it opposes is simply dead text. Second, Git does not descend into ignored directories, so you cannot re-include a file whose parent directory is excluded. `build/` followed by `!build/keep.txt` silently fails; `build/*` followed by `!build/keep.txt` works, because with the wildcard form the directory itself was never excluded, only its contents. This asymmetry is the single most upvoted gitignore question in the history of the internet, give or take.

And one rule that outranks all of them: .gitignore affects only untracked files. If Git is already tracking a file, no pattern will untrack it. The fix is `git rm --cached <file>` followed by a commit — cached removes it from the index while leaving your working copy alone. When a whole directory needs untracking, `git rm -r --cached <dir>` does it in one stroke, and the newly-ignored files vanish from every future diff. To debug which rule matches a given path, `git check-ignore -v <path>` prints the exact file, line, and pattern responsible — the underused command that turns ignore debugging from divination into lookup.

One merged template, annotated line by line

A common real-world selection: a TypeScript Node service developed on macOS in VS Code. Here is the merged output, exactly as the generator assembles it, with the reasoning inline.

# --- Node ---
node_modules/          # dependencies: reproducible from package-lock.json
npm-debug.log*         # crash logs from local runs
yarn-debug.log*        # same for yarn
yarn-error.log*
coverage/              # test coverage output, regenerated on every run
.nyc_output/

# --- Build output ---
dist/                  # compiled/bundled output — build it, don't ship it in git
build/
*.tsbuildinfo          # TypeScript incremental build cache

# --- Environment ---
.env                   # real secrets, never committed
.env.local
.env.*.local           # .env.development.local, .env.test.local, .env.production.local

# --- macOS ---
.DS_Store              # Finder metadata, appears the moment anyone browses in Finder
._*                    # AppleDouble resource-fork files on non-HFS filesystems
.Spotlight-V100
.Trashes

# --- Editors ---
.vscode/*              # ignore everything in .vscode, but…
!.vscode/settings.json # …keep shared project settings
!.vscode/extensions.json # …and the recommended-extensions list
.idea/
*.swp                  # Vim swap files

Read the .env block carefully, because it encodes a modern convention. The .env.*.local pattern catches the environment-specific variants that frameworks like Next.js and Vite generate and document — the files that hold the real secrets for local development, staging, and production. The committed companion file, .env.example, lists the same keys with placeholder values, so new contributors learn what configuration exists without learning your database password. The generator's defaults follow this convention, and if you take one paragraph from this article, take that one: ignored secrets are a speed bump, committed secrets are an incident — and if a secret ever reaches history, rotation is the only real remedy, because history is forever.

The .vscode block demonstrates the re-inclusion pattern from the semantics section in its practical form: ignore the whole directory with a wildcard, then negate the two files teams actually share. Extensions lists and shared formatting settings travel with the repo; everything personal stays local. That is the difference between a template that says 'ignore .vscode' and one that says why, and which files to carve back out.

The macOS section has a Windows counterpart that cross-platform teams learn the hard way. Explorer and the shell scatter Thumbs.db (thumbnail caches) and ehthumbs.db in every folder with images, Desktop.ini rides along with any customized folder view, and a USB stick or shared drive can leave $RECYCLE.BIN directories inside a working tree. The template block that ends all of it is five lines: Thumbs.db, ehthumbs.db, Desktop.ini, $RECYCLE.BIN/, and *.lnk for shortcuts that only make sense on the machine that created them. Teams that skip it spend one code review per new Windows contributor explaining why those files keep appearing in the diff.

Anatomy of a leaked key: what happens in the first hour

A secret pushed to a public repository is not a slow leak — it is a fire alarm. Automated crawlers index new pushes continuously, and multiple incident-response teams have measured credential theft from public repos happening in minutes, not days. AWS keys are the classic target: an attacker with a live pair spins up EC2 instances for cryptocurrency mining within the hour, and the bill lands on the account owner. The 2016 Uber breach, which exposed data on 57 million riders and drivers, started with credentials sitting in a contractor's private GitHub repository — private, not public, and still not private enough.

  1. Minute zero: rotate first, clean second

    The instinct is to delete the file from the repo. The priority is the opposite: revoke or rotate the credential at its provider — deactivate the AWS key, regenerate the database password, reissue the API token. Removal stops future pushes; rotation stops the copy already in history, in every clone, and in every crawler's index.

  2. Minute five: untrack and commit

    git rm --cached .env removes the file from the index while keeping the local copy, and the .gitignore rule you should already have prevents re-adding it. Push the fix so teammates' clones stop carrying the file forward.

  3. Minute thirty: rewrite history if the repo is shared

    The old commits still contain the secret. git filter-repo --invert-paths --path .env rewrites every commit that touched the file; it is the tool GitHub's own documentation now recommends over the error-prone git filter-branch. Force-push the rewritten history, and note that every commit hash after the rewrite changes, so open pull requests need rebasing.

  4. Hour one: notify and audit

    Tell your team to re-clone from the rewritten history, expire old reflog entries with git reflog expire --expire=now --all followed by git gc --prune=now on any surviving copies, and check the provider's usage logs for activity between the leak and the rotation. GitHub's secret scanning, when enabled, reports exposed supported token types to both you and the provider.

When gitignore isn't the right tool

Honest limits, because a generator that pretends .gitignore solves every repo problem is selling pattern-matching as policy:

  • Already-tracked files. .gitignore is prophylaxis, not removal. git rm --cached is the cure; see above.
  • Secrets already committed. Ignoring .env after pushing it does nothing about the copy in history. Rotate the credential, then clean history with filter-repo if the situation demands it. The ignore rule is for the next file, not the last one.
  • Personal noise in a shared repo. Your own scratch files, note drafts, and experimental scripts that nobody else should see belong in .git/info/exclude — same syntax, local-only effect, no PR noise.
  • Machine-wide junk. .DS_Store everywhere? Set core.excludesFile once, globally, and stop re-ignoring it per repository for the rest of your career.
  • Files other systems need. Some CI setups expect certain ignored-by-convention files to exist; some Docker builds expect .env. Coordinate the convention with the tooling before ignoring something your pipeline actually reads.
  • Version-controlled configuration. The inverse mistake: teams who ignore files they should commit — shared lint configs, lockfiles, CI definitions. Dependency lockfiles belong in the repository; dependency caches do not.

When a pattern refuses to work: check-ignore, then the negation trap

The rule that a pattern refuses to work has a tool for exactly this moment, and almost nobody runs it before posting the question. git check-ignore -v tells you which file, which line, and which pattern made the decision — or prints nothing at all, which is its own answer:

$ git check-ignore -v build/temp.js
.gitignore:12:build/	build/temp.js

Read the columns left to right: the file that owns the pattern (.gitignore), the line number (12), the pattern itself (build/), and the path being tested (build/temp.js). The output ends one argument instantly: if the winning pattern came from a different file than you expected — .git/info/exclude, a global excludes file, a parent directory's .gitignore — you have found the collision. No output means nothing matches, and the file is not ignored at all.

The second half of the debugging ritual is the broken negation, and it fails in a way that looks like a Git bug. This file re-includes nothing:

# Broken: the directory is ignored, so Git never looks inside it
logs/
!logs/keep.md

# Working: ignore the contents, not the directory itself
logs/*
!logs/keep.md

The difference is the slash-star. Git does not descend into an excluded directory, so a negation for a file inside it is never even evaluated. logs/* matches the entries inside the directory rather than the directory, which keeps Git looking — and gives the negation something to win against, because last-match-wins applies to those inner entries. Pair it with git status --ignored to watch the directory turn from ignored to partially tracked before committing the fix.

Recovering a bloated repository, measured step by step

Take the 900 MB repository from the opening of this article and fix it. The numbers below are realistic for a mid-size Node service where node_modules — a few thousand packages, hundreds of megabytes — was committed for two years before anyone noticed.

StepCommandEffect
Measure the damagegit count-objects -vHReports the pack size — here, 900 MB
Untrack the junkgit rm -r --cached node_modulesIndex drops the directory; disk copy stays
Commit the removalgit commit -m "stop tracking node_modules"Future snapshots are clean, history still heavy
Rewrite historygit filter-repo --invert-paths --path node_modulesEvery commit touching it is rewritten away
Force-pushgit push --forceThe remote loses the blobs; clones must re-clone
Reclaim local spacegit reflog expire --expire=now --all && git gc --prune=nowDrops unreachable objects from the local store
Untracking alone does not shrink clones — the blobs live in history until the rewrite

Two realities of the rewrite deserve advance warning. First, git filter-repo rewrites commit hashes from the first affected commit onward, which invalidates every open pull request and every teammate's local branch — schedule it, announce it, and have everyone re-clone afterward. Second, the size win arrives only after the server runs its own garbage collection; GitHub's pack optimization reclaims the space on its schedule, so the repo's reported size may lag the rewrite by a day. The preventive version of this entire section is one checkbox and eleven seconds at repo-creation time, which is the quiet sales pitch for every generator on this page.

The people who generate these daily

The audience is anyone who creates repositories, which at this point is anyone who writes code. The patterns differ by seniority, and they are worth distinguishing.

New developers meet the tool at repo-creation time, and the generator doubles as a checklist of what they don't yet know exists — nobody tells a beginner that macOS scatters .DS_Store like confetti until their first cross-platform PR review. Working professionals regenerate on new projects weekly and, more often, retrofit: inheriting a repo whose .gitignore predates the framework switch, or debugging why the build directory keeps showing up in git status. Team leads and platform engineers care about the convention layer — consistent .env handling, shared editor settings, the lockfile rules — and use a generator to standardize what the organization's repo template contains. Teachers and workshop leaders hand a generated .gitignore to students as part of day-one setup, which quietly prevents a hundred support questions during week two's 'why is my commit 400 megabytes' hour. And open-source maintainers field the resulting PRs, which is why their review comments about 'add this to .gitignore, not here' are among the most repeated sentences in software.

Demand for this is as evergreen as git itself — every new repository in the world needs one of these files, and the modern combinations keep multiplying: Rust on Windows in Fleet, Python in Codespaces with devcontainers, Unity teams fighting their own unique battle with Library/ directories. Checkbox generators earn their keep precisely because nobody should memorize all of it.

Three sources of .gitignore files, compared

SourceStrengthWeakness
GitHub's github/gitignore repoCommunity-maintained, per-language canonical filesOne language per file — no merging of stack + OS + editor
gitignore.io (Toptal)Search-based combos, long historySingle search string, terse output, no per-line explanations
This generatorCheckbox merge of stack + OS + editor, section comments, .env-family defaultsYou still need to read the output — templates are starting points, not law
All three produce valid Git pattern syntax; the difference is assembly and explanation.

A note on trust: the GitHub template repository is the upstream reference most generators borrow from, including the good parts and the occasional stale entry. Any generated file should be read before committed — not because generators are wrong, but because your project knows things about itself that no checkbox can. A React Native project and a Next.js project both check the Node box, and what they need after that diverges immediately.

Templates ship inside the page

The generator's template library is bundled with the page, and the merge happens in your browser. No stack selections, no project structure hints, no generated files travel anywhere. The tool works offline once loaded, which is a surprisingly practical property — the airplane-and-laptop repository bootstrap is a real scenario, and 'I'll generate the .gitignore when I get connectivity' is a sentence that precedes committed junk more often than anyone admits.

There is also nothing to log even if we wanted to: a .gitignore contains no secrets and no personal data beyond your stack fingerprint, and the architecture keeps even that local. The design philosophy is the same across this whole cluster of developer tools — cron building, license generation, and this one all run client-side because shipping code to a server for assembly that JavaScript can do locally is overhead for you and risk for everyone.

.gitignore questions from the trenches

What's the difference between .gitignore and .git/info/exclude?

Both use the same pattern syntax, but .gitignore is committed to the repository and shared with everyone who clones it, while .git/info/exclude lives in your local .git directory and applies only to you. Team-wide ignores (build output, dependencies) belong in .gitignore; personal ignores (your editor scratch files, local experiment scripts) belong in .git/info/exclude or your global excludes file.

Why is a file still tracked after I added it to .gitignore?

Because .gitignore only prevents untracked files from being added — it does nothing to files Git is already tracking. Untrack the file with git rm --cached path/to/file (the --cached flag keeps your local copy) and commit that removal. From then on the ignore rule applies. This is the single most common .gitignore confusion.

Should I ignore the whole .vscode folder?

Commonly yes, but some teams commit specific shared settings. The middle path: ignore .vscode/* and re-include the files your team shares (for example, !.vscode/settings.json and !.vscode/extensions.json). That keeps individual preferences local while shared project settings travel with the repo.

How do I ignore an entire directory?

Append a trailing slash: build/ ignores any directory named build at any depth, along with everything inside it. Without the slash, the pattern build matches both files and directories named build. The slash form is what you want for tool output directories like node_modules/ and __pycache__/.

Can I re-include a file inside an ignored directory?

Not if you ignored the directory itself. Git doesn't descend into ignored directories, so a negation like !build/keep.txt under a build/ rule never gets evaluated. Ignore the directory's contents with a wildcard instead — build/* followed by !build/keep.txt works, because the directory itself is no longer excluded. Re-including files under an excluded parent is the classic pattern that fails.

Should .env files ever be committed?

No. Environment files holding secrets should be ignored ( .env, .env.local, .env.*.local ) and committed as a sanitized .env.example instead, with keys but placeholder values. If a real secret ever lands in history, removing the file later is not enough — rotate the secret, because git history keeps every version ever committed.

Where does my global gitignore live?

Wherever core.excludesFile points. Set it once with git config --global core.excludesFile ~/.gitignore_global, then put OS junk like .DS_Store and editor scratch files there so you never commit them from any repository again. Personal and machine-level noise belongs in the global file; project dependencies belong in each repo's .gitignore.

Does the generator upload my project details anywhere?

No. The template library ships inside the page itself, and the merge you build is assembled in your browser. Nothing about your stack, project names, or chosen frameworks is transmitted — there is nothing sensitive in a .gitignore, but there is also no reason for us to see yours.

Can I have multiple .gitignore files in one repository?

Yes. Git reads a .gitignore in every directory, and the patterns in each apply relative to that directory's location. Deeper files take precedence in the last-match-wins order, so a rule in docs/.gitignore can override one at the root. The common uses are monorepos — one file per service with its own build outputs — and vendored directories you want to treat as a single unit. Most projects stay simpler with one root file and anchored paths.

What's the difference between .gitignore and .gitattributes?

They answer different questions. .gitignore decides which paths never enter the repository. .gitattributes decides how Git handles the files that do enter: line-ending normalization for cross-platform text files, which diff driver to use, export-ignore rules for archive tarballs. Ignoring a path makes attributes for it irrelevant; attributes on a tracked path — like marking *.png binary to stop diff noise — are where the two files cooperate.

How do I test my .gitignore before committing it?

Two dry-run checks cover it. Run git add . -n (the -n flag previews without staging) and scan the list for anything that should have been excluded. For one specific path, git check-ignore -v path prints exactly which rule matched it, or nothing if the file is headed for the index. Doing both on a fresh clone catches the ordering mistakes and the untracked-file surprises before they become a teammate's pull request comment.

Why does my .gitignore work on my Mac but not in CI?

Filesystem case sensitivity. macOS and Windows keep core.ignoreCase on by default, so a pattern written Build/ silently matches a directory named build — then a Linux CI runner with a case-sensitive filesystem honors the pattern exactly as written and lets the directory through. The fix is to match the real capitalization of every path in the file, and to run git check-ignore -v on the CI box when in doubt.

Can I write comments and filenames with special characters in .gitignore?

Yes, with two escaping rules. A line starting with # is a comment, so a file literally named #notes.txt needs a leading backslash: \#notes.txt. And trailing spaces are stripped from patterns, so a filename that genuinely ends in a space needs a trailing backslash to survive — filename\ instead of filename. Everything else, including internal spaces, needs no escaping.

How do I see or clean up the files my .gitignore is hiding?

git status --ignored lists ignored files alongside untracked ones — the fastest way to confirm a new pattern catches what you intended. For cleanup, git clean -Xn is the dry run that shows only ignored files, git clean -Xd deletes them, and the lowercase -x variants also sweep untracked files, so read the flag twice before running it. None of these touch tracked files; that is what git rm --cached is for.

Neighboring tools for repo hygiene

A repository's first hour usually involves three files, and the other two have their own generators here. The Open Source License Generator fills the LICENSE file with your name and year in — the file that decides who may legally use your code, and the one whose absence silently forbids everyone from doing anything. The Cron Expression Builder handles the scheduled-jobs side of repo setup once the project grows automation. And before committing anything environment-shaped, run your .env through the ENV File Validator, which catches the malformed keys and stray quotes that break containerized builds in the most confusing ways.

LICENSE, .gitignore, and a validated .env.example — the three-file bootstrap of a repository that respects its future contributors.

Documentation behind the patterns

The pattern grammar is documented authoritatively in Git's own manual; the template conventions come from the community repository. These are the references to keep open:

Sources and further reading

We cite primary sources (standards bodies, government agencies, official documentation) wherever they exist so you can verify every number on this page yourself.