6 minute read

“A gitignore file specifies intentionally untracked files that Git should ignore. Files already tracked by Git are not affected.” — Git documentation

Why Is Git Still Tracking a File I Ignored?

The answer is usually in Git’s index, not in another .gitignore edit.

Open your repository and run this against the file Git refuses to forget:

git ls-files --error-unmatch -- tools/publisher/post_publisher.py

If it prints the path, the file is tracked. If it reports that the path did not match any file known to Git, it is not tracked. By the end of this post, you will be able to distinguish an index problem from an ignore-rule problem and choose the command that fixes the right one.

The surprising part is that a tracked file may match an ignore rule while git check-ignore -v prints nothing. By default, that command does not report tracked files. Silence is not proof that no ignore pattern matches.

Editing .gitignore is the wrong first move

.gitignore controls whether untracked paths are candidates for addition and whether they appear in ordinary git status output. It does not remove paths already stored in Git’s index.

An ignore rule can hide an untracked file; it cannot evict a tracked one from the index.

That distinction decides almost every next step:

What you observe Index state What the observation means Correct next move
git ls-files prints the path Tracked Ignore rules do not untrack it Use git rm --cached if tracking should stop
git ls-files errors and git check-ignore -v names a rule Untracked and ignored The reported pattern is blocking addition Fix the rule or use git add -f intentionally
Both commands produce no match Untracked and not ignored The path may be wrong, missing, or evaluated in the wrong repository Verify the repository root and spelling
A parent directory matches Usually untracked beneath an ignored directory Git may never descend far enough for a file-level exception Rewrite the directory rule and its negations

That table is the working model: first establish index membership, then inspect ignore rules.

git check-ignore -v identifies the rule, file, and line

For an untracked path, ask Git which rule matched:

git check-ignore -v -- tools/publisher/post_publisher.py

A result resembles this:

.gitignore:12:*.py    tools/publisher/post_publisher.py

Read it from left to right:

  • .gitignore is the file containing the rule.
  • 12 is the line number.
  • *.py is the matching pattern.
  • The final field is the path being tested.

The source may instead be .git/info/exclude or the user-level excludes file configured through core.excludesFile.

For a tracked file, add --no-index when you specifically want to know whether an ignore rule would match it:

git check-ignore -v --no-index -- tools/publisher/post_publisher.py

This does not change the file’s tracked state. It only makes the diagnostic include paths already present in the index.

Without --no-index, no output has several possible meanings: the path is not ignored, the path is tracked, the path is wrong, or the command is running in the wrong repository. That ambiguity is why I check the index first.

Verify which repository is answering

Ignore rules are repository-dependent. Before trusting the output, verify the worktree root:

cd /path/to/repo
git rev-parse --show-toplevel

The result should identify /path/to/repo. If it points somewhere else, every ignore answer that follows applies to a different repository context.

A plain index lookup is useful but silent on failure:

git ls-files -- tools/publisher/post_publisher.py

If it prints the path, the path is tracked. No output could mean an untracked path, a spelling mismatch, or an absent untracked file. A tracked file deleted from the working tree still appears because git ls-files reads the index, not just the filesystem.

For a definitive result, use strict mode:

git ls-files --error-unmatch -- tools/publisher/post_publisher.py

I prefer a command that fails explicitly over one that communicates through tasteful silence.

Parent-directory rules can swallow the file

A file does not need to match a pattern directly. Its parent directory may already be ignored:

git check-ignore -v -- \
  tools/publisher \
  tools/publisher/post_publisher.py

If the directory matches, the file beneath it is collateral. This also explains why an apparently reasonable negation can fail:

tools/publisher/
!tools/publisher/post_publisher.py

Git does not normally traverse an excluded directory to discover a re-included child. When the intent is to ignore the directory’s contents except for one file, write the rule so the directory remains traversable:

tools/publisher/*
!tools/publisher/post_publisher.py

For deeper paths, each excluded parent component may also need to be re-included. Negation rules must appear after the patterns they override, and the exact rule set depends on which parent patterns already match.

Ignore rules have three common scopes

A verbose match can come from:

  1. A repository .gitignore, including one in a subdirectory.
  2. The repository-local .git/info/exclude file.
  3. A user-level excludes file configured with core.excludesFile.

Check the last source with:

git config --get core.excludesFile

A repository .gitignore can be committed and shared. .git/info/exclude remains local to that clone. A user-level excludes file may affect every repository for that user, so changing it to fix one path has a wider scope than it first appears.

Use the smallest scope that expresses the intent. A local exception should not become a global policy by accident.

The fix depends on whether the file is tracked

If the file is untracked and incorrectly ignored, the tactical option is a forced add:

git add -f -- tools/publisher/post_publisher.py

That bypasses ignore rules for this addition. It is appropriate when the exception is intentional and narrow. The longer-lived fix is to correct the matching ignore pattern so future additions do not require institutional memory.

If the file is tracked and should remain on disk but leave the index, use:

git rm --cached -- tools/publisher/post_publisher.py

Then add the appropriate ignore rule:

tools/publisher/post_publisher.py

Commit both changes if the policy should apply to other clones. Inspect staged or modified content first: git rm --cached can refuse when index and working-tree states make removal unsafe, and adding -f reflexively can discard the warning rather than resolve it.

Common verbose matches usually tell the story quickly:

  • *.py means a broad language-level rule caught the file.
  • tools/publisher/ means the directory rule swallowed it.
  • .git/info/exclude means the behavior is local to the clone.
  • A configured user-level excludes file means other repositories may see the same behavior.

Why not begin with git check-ignore?

The strongest case for starting there is good: for an untracked ignored file, git check-ignore -v immediately identifies the exact source, line, and pattern. When that is the known failure mode, it is the shortest route.

I still start with the index when the symptom is ambiguous. Default git check-ignore silence does not distinguish a tracked path from a path with no matching rule, while git ls-files --error-unmatch gives a binary answer. Editing .gitignore before checking the index is the wrong first move.

This workflow has a boundary. It diagnoses ignore rules and index membership; it does not explain every quiet git status. Sparse checkout, skip-worktree, and assume-unchanged can produce different visibility surprises and need separate inspection.

Run the three-command triage

From the repository root:

git ls-files --error-unmatch -- tools/publisher/post_publisher.py
git check-ignore -v --no-index -- tools/publisher/post_publisher.py
git ls-files --others --ignored --exclude-standard

The first command establishes whether the index owns the path. The second identifies any matching rule even when the file is tracked. The third lists ignored untracked paths using Git’s standard exclusion sources without mixing in ordinary untracked-file noise.

Pick one path in your repository that has behaved strangely and run the three commands. Which fact was hidden from you: the path’s index state, the matching pattern, or the scope where that pattern was defined?

Updated: