A gitignore that keeps macOS clutter out of your repository
A pull request arrives with three changed files and one of them is .DS_Store. Somebody adds a rule for it, the file keeps showing up, and a second rule gets added to a second place. Two weeks later nobody can say which of the three ignore files is actually in effect. The mechanics of gitignore are simple, but the way Git layers several sources of rules is where the confusion starts, and macOS contributes a set of files that no other platform produces.
The three places Git reads ignore rules from
Git does not have one ignore file. It has a stack, and the order matters. The official documentation at git-scm.com sets out the precedence, from highest to lowest.
First, .gitignore files inside the working tree. These can sit in any directory, and the documentation is precise about how they combine: patterns come from a .gitignore file in the same directory as the path, or any parent directory up to the top of the working tree, "with patterns in the higher level files being overridden by those in lower level files down to the directory containing the file." A rule in a subdirectory therefore beats a rule at the repository root.
Second, the file the documentation names $GIT_COMMON_DIR/info/exclude, which in an ordinary clone is .git/info/exclude. It lives inside the repository's own metadata, is never committed, and is never shared. It is the right place for something that is specific to one clone on one machine.
Third, the file named by the core.excludesFile setting. This is the per-user, all-repositories layer. Its default location is worth memorising, because plenty of advice online names a different path:
Its default value is $XDG_CONFIG_HOME/git/ignore. If $XDG_CONFIG_HOME is either not set or empty, $HOME/.config/git/ignore is used instead. Source: git-scm.com
The layer a rule belongs in is decided by who else needs it. A build output directory belongs in the committed .gitignore, because every clone produces it. A personal scratch folder belongs in info/exclude. And anything produced by the operating system rather than the project belongs in the global file, which is the point most teams get wrong.
Which macOS files actually leak in, and why
The clutter is not random. Each file has a cause, and knowing the cause tells you where it will appear.
.DS_Store stores Finder's per-folder view settings: icon positions, window size, sort order, and the chosen view. One is created in any folder a Finder window has displayed, including folders inside a repository. Nothing in a project needs it, and because it changes whenever a window is resized, it produces a stream of meaningless diffs.
Files beginning with ._ are AppleDouble files. They hold resource forks and extended attributes for a file whose destination cannot store them natively. They appear when files are written to a volume that is not an Apple file system, which is why they turn up in archives, on USB sticks formatted as FAT or exFAT, and on network shares. A repository cloned to such a volume, or a zip built on a Mac and unpacked elsewhere, carries them along. The related __MACOSX directory is the same mechanism inside a zip archive.
.Spotlight-V100, .fseventsd, .Trashes, .TemporaryItems and .DocumentRevisions-V100 belong to Spotlight indexing, the file system events log, the trash, and version history. These appear at the root of a volume rather than inside an arbitrary folder, so they matter when a repository or a backup sits at the top level of an external disk.
A custom folder icon produces a file whose name is Icon followed by a carriage return, which is why plain text rules written by hand often fail to match it. GitHub's own collection of templates, in Global/macOS.gitignore, handles that entry correctly along with the Time Machine, quota, and AFP share artefacts. Copying that file is faster and more accurate than assembling the list by hand.
Put the macOS rules in the global file, not the project one
There is a genuine trade-off here, and both choices are defensible.
| Where the macOS rules live | What that gets | What it costs |
|---|---|---|
Global ~/.config/git/ignore |
Every repository on the machine is covered, including ones cloned in a hurry. The project's .gitignore describes only the project. |
Teammates on other platforms are not covered, so the rules have to exist on each machine. |
Committed .gitignore in each repository |
Protection travels with the repository, so a new contributor on a Mac is covered on the first clone. | Every repository carries rules about an operating system it has nothing to do with, and the list has to be maintained in many places. |
The reason to prefer the global file is that it matches the cause. .DS_Store is created by the Finder on one person's machine, not by the project, so a project-level rule is describing somebody else's desktop. The counter-argument is real, though: a repository with mixed contributors gets fewer accidents if the rules are committed. A common compromise is the global file for personal machines plus a short macOS block in the repository for shared projects, accepting the duplication.
Setting up the global file takes two commands, and the path can be anything as long as the setting points at it.
mkdir -p ~/.config/git
curl -o ~/.config/git/ignore \
https://raw.githubusercontent.com/github/gitignore/main/Global/macOS.gitignore
git config --global core.excludesFile ~/.config/git/ignore
Confirming the setting later is a single read of git config --global core.excludesFile, which is worth doing on a machine set up long ago, because an older ~/.gitignore_global path may still be configured and quietly winning.
The pattern rules that cause the most wasted time
Four details in the pattern format account for most of the rules that look correct and do nothing.
A slash changes the scope
A pattern with no slash matches at any depth. A pattern containing a slash anywhere except the end is anchored to the directory of the .gitignore file itself. So build ignores every directory named build, anywhere, while /build ignores only the one at the repository root. Using the anchored form by accident is the usual reason a nested node_modules stays visible.
A trailing slash restricts to directories
cache/ matches only a directory. Without the slash, a file named cache is matched too. When the intent is specifically a directory, the slash is worth adding, because it prevents a same-named file being hidden by mistake.
Negation cannot rescue a file inside an excluded directory
This is the one that costs hours. The documentation states it without hedging:
It is not possible to re-include a file if a parent directory of that file is excluded. Git doesn't list excluded directories for performance reasons, so any patterns on contained files have no effect, no matter where they are defined. Source: git-scm.com
So assets/ followed by !assets/logo.svg keeps the logo ignored. The working form excludes the contents rather than the directory, then re-includes: assets/* followed by !assets/logo.svg. For a deeper path, each intermediate directory has to be re-included as well.
Double asterisk has three separate meanings
A leading **/ matches in all directories. A trailing /** matches everything inside a directory. A /**/ in the middle matches zero or more directories, which is how docs/**/draft.md reaches a file at any depth under docs.
The project-level rules a Mac project still needs
Once the operating system files are handled globally, the committed .gitignore can go back to describing the project. On a Mac that list usually has three groups, and they are easy to tell apart.
Build outputs are the largest group and the least controversial. For Xcode projects that means the build directory and the per-user state inside a project or workspace bundle, which changes every time a window is moved or a breakpoint is set. For Swift packages it is the .build directory. For anything using npm it is node_modules, which is reconstructible from a lock file and should never be committed. The test of whether something belongs here is simple: if a clean clone can regenerate it with one command, it is an output rather than a source.
Local configuration is the second group, and the stakes are higher. A .env file holding credentials must be ignored before it is ever created, not after, because a committed secret stays in the history even once the file is removed. Committing a .env.example with the variable names and no values gives new contributors the shape without the secrets. GitHub's template collection at github.com/github/gitignore has per-language starting points for both of these groups.
Editor and tool state is the third and the one with the least agreement. Some teams commit a shared editor configuration deliberately, so that formatting rules are identical for everyone, and ignore only the per-user files inside it. Others ignore the whole directory. Either is workable as long as the choice is made once rather than per contributor.
What does not belong in the committed file is anything that exists because of how one person works. A personal notes file, a scratch script, or a directory of downloaded sample data goes in info/exclude for that clone. Auditing the current state is a single command: git ls-files | grep -E '\.DS_Store|node_modules|\.env$' shows whether anything from these groups has already slipped into the index, and it is worth running on an inherited repository before adding rules that will appear to do nothing.
Cleaning up what Git already tracks
Adding a rule does nothing to a file already under version control. The documentation puts it in four words: "Files already tracked by Git are not affected." This is why a freshly added .DS_Store rule appears to be ignored.
Untracking without deleting is the fix. git rm --cached removes the file from the index while leaving it on disk, and the next commit records the removal.
git ls-files '*.DS_Store' # what is tracked today
git ls-files -z '*.DS_Store' | xargs -0 git rm --cached -- # drop it from the index only
git commit -m "Stop tracking Finder view settings"
Two diagnostic commands are worth keeping to hand. git check-ignore -v <path> names the exact file, line number, and pattern that caused a path to be ignored, which answers the question of which layer is winning without any guessing. git status --ignored lists what is being skipped, which catches the opposite mistake of a rule that is too broad and is quietly hiding a file somebody needs.
One more habit reduces recurrence. .DS_Store files appear in whichever folders have been opened in a Finder window, so a project browsed heavily on one machine accumulates them in exactly the directories that get the most attention. Working in a window where the folder view and the shell are the same window means fewer Finder visits to repository subdirectories, and the comparison of file managers that include a terminal covers which tools do that and how. The same window also makes git status --ignored output easier to act on, because the folder being discussed is already in view.
What to change first
Point core.excludesFile at a global ignore file containing GitHub's macOS template, then run git check-ignore -v on any file that is still appearing to confirm which layer is responsible. If the repository already tracks .DS_Store, one git rm --cached -r and one commit ends it for everyone. A window that keeps folders and a shell side by side removes the step that creates most of these files in the first place, and Atriens is built that way.
Frequently asked questions
Why is my .gitignore rule being ignored?
The most common cause is that the file is already tracked, because ignore rules only apply to untracked files. Run git rm --cached <path> and commit. If the file was never tracked, run git check-ignore -v <path>, which names the file and line of the pattern actually in effect, or prints nothing if no pattern matches.
Should the macOS rules go in the project .gitignore or a global file?
A global file matches the cause, because these files come from one person's Finder rather than from the project. Committing them to the repository covers contributors who have not set up a global file, at the cost of maintaining the same list in every repository. Teams with mixed setups often do both.
How do I stop .DS_Store files being created at all?
Creation inside a folder happens when a Finder window displays it, so it cannot be switched off per folder. Turning off .DS_Store creation on network volumes is a separate system setting, and it does not affect local disks. Ignoring them in Git and untracking the existing ones is the reliable route.
Can one rule exclude a directory but keep one file inside it?
Not by excluding the directory itself, because Git does not look inside an excluded directory. Exclude the contents with assets/* and then re-include with !assets/logo.svg. For a nested path, every intermediate directory needs re-including as well.