DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Build a .gitignore Engine in JavaScript: Last-Match-Wins, Path-Aware Globs, and the Parent Directory Trap

Almost everyone reads a .gitignore top to bottom like a shopping list and assumes the first rule that matches decides. It is the opposite. Git evaluates every line and keeps the last one that matched, and that one decides.

That single choice is the whole reason ! negation can exist. If the first match won, a ! could never override anything written above it.

Then a second rule quietly overrides the first: git does not test paths against patterns, it walks directories, and it never walks into a directory it has already decided to ignore. So this does exactly nothing:

node_modules/
!node_modules/patched.js
Enter fullscreen mode Exit fullscreen mode

The file is unreachable, not un-matched.

I wrote the whole matcher by hand — line parser, glob compiler, ancestor walk — and then checked it against real git. Here it is.

Before anything else: it only hides untracked files

Adding .env to .gitignore after you already committed .env changes precisely nothing. The file is in the index, and the index outranks every pattern in the file.

$ echo ".env" >> .gitignore
$ git status
  modified:   .env          # still tracked, still watched

$ git rm --cached .env      # out of the index, still on disk
$ git status
  deleted:    .env          # NOW the pattern applies
Enter fullscreen mode Exit fullscreen mode

This is the single most-asked git question in existence, and no amount of pattern-writing fixes it. Everything below only ever applies to files git is not already tracking.

The line parser has four rules and three of them bite

A blank line matches nothing. A line whose first raw character is # is a comment — checked before any trimming, which is why \#draft.md is a real pattern for a file literally named #draft.md.

The trailing-space rule is the strange one. Git strips trailing spaces, but only literal spaces (never tabs), and a backslash cancels the strip:

function trimTrailingSpaces(s){
  let lastSpace = -1;
  for (let i = 0; i < s.length; i++) {
    const c = s.charAt(i);
    if (c === " ") { if (lastSpace < 0) lastSpace = i; continue; }
    if (c === "\\") { i++; if (i >= s.length) return s; }  // "\ " survives
    lastSpace = -1;
  }
  return lastSpace >= 0 ? s.slice(0, lastSpace) : s;
}
Enter fullscreen mode Exit fullscreen mode

So build targets build, but build\ targets a file whose name genuinely ends in a space. Leading whitespace, meanwhile, is never stripped — an accidentally indented rule is a rule about a filename that starts with spaces, and it will silently match nothing forever.

Then peel the two flags off:

let body = trimTrailingSpaces(raw), negated = false, dirOnly = false;
if (body.charAt(0) === "!") { negated = true; body = body.slice(1); }
if (body.endsWith("/"))     { dirOnly = true; body = body.slice(0, -1); }
if (body === "") return { kind: "blank" };     // "!" or "/" alone is inert
Enter fullscreen mode Exit fullscreen mode

Note that only one trailing slash is consumed. foo// ends up as the pattern foo/, which matches nothing at all — a real typo that produces a silently dead rule.

A slash anywhere anchors the pattern

This is the branch that explains most "why did it match that" reports. After the trailing slash is gone, scan the body for a /:

const anchored = body.indexOf("/") !== -1;

function patternMatches(p, path, isDir, baseDir){
  if (p.dirOnly && !isDir) return false;
  let rel = path;
  if (baseDir) {                                   // nested .gitignore
    if (path.slice(0, baseDir.length + 1) !== baseDir + "/") return false;
    rel = path.slice(baseDir.length + 1);
  }
  if (p.anchored) return p.regex.test(rel);        // the whole relative path
  return p.regex.test(rel.slice(rel.lastIndexOf("/") + 1));  // basename only
}
Enter fullscreen mode Exit fullscreen mode

No slash means the pattern is matched against the basename, so build fires at any depth. A slash anywhere — leading, middle, it does not matter — means it is matched against the whole path relative to the directory holding that .gitignore, and a leading slash is then just noise that gets stripped.

  • buildbuild, a/build, a/b/c/build
  • /buildbuild only
  • src/buildsrc/build only

A star stops at a slash

gitignore globs are path-aware, which is a different language from shell globbing. * is "any run of characters containing no slash". ? is exactly one non-slash character.

function segGlob(s){
  let out = "";
  for (let i = 0; i < s.length; i++) {
    const c = s.charAt(i);
    if (c === "\\" && i + 1 < s.length) { out += escRe(s.charAt(++i)); continue; }
    if (c === "*") { out += "[^/]*"; continue; }   // NOT ".*"
    if (c === "?") { out += "[^/]";  continue; }   // NOT "."
    if (c === "[") { const k = parseClass(s, i);
                     if (k) { out += k.re; i = k.next - 1; continue; } }
    out += escRe(c);
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Which is why src/*.js never matches src/util/a.js. You need src/**/*.js.

Bracket expressions follow the same law, and this part is easy to miss: a class can never match a /, even if you literally put one inside the brackets. So the compiled class gets a guard:

return { re: "(?!/)[" + (neg ? "^" : "") + body + "]", next: j };
Enter fullscreen mode Exit fullscreen mode

Git accepts [!abc] for negation (fnmatch style, not the regex ^), plus ranges and POSIX names like [[:digit:]].

** has exactly three useful shapes

A ** is only special when it forms a whole path segment. a**b is just two ordinary stars and still cannot cross a slash. As a segment it means "zero or more directories", which produces three idioms:

function compileGlob(body){
  const segs = splitSegments(body);
  let out = "", needSep = false;
  for (let i = 0; i < segs.length; i++) {
    const s = segs[i], last = i === segs.length - 1;
    if (/^\*\*+$/.test(s)) {
      if (last) { out += (needSep ? "/" : "") + ".+"; needSep = false; }
      else      { out += (needSep ? "/" : "") + "(?:[^/]+/)*"; needSep = false; }
    } else { out += (needSep ? "/" : "") + segGlob(s); needSep = true; }
  }
  return new RegExp("^" + out + "$");
}

// "**/build"  -> ^(?:[^/]+/)*build$    any depth, INCLUDING the root
// "cache/**"  -> ^cache/.+$            inside cache, never cache itself
// "a/**/b"    -> ^a/(?:[^/]+/)*b$      zero or more dirs between
Enter fullscreen mode Exit fullscreen mode

The zero-directory case in a/**/b — matching plain a/b — is the one hand-rolled matchers routinely get wrong. And cache/** deliberately not matching cache matters more than it looks: it is what keeps the directory itself walkable.

Last match wins, across a stack of files

Evaluating a path means running every applicable rule and keeping the last one that matched, rather than returning early on the first hit.

function lastMatch(files, path, isDir){
  const dirs = ancestorDirs(path);        // "a/b/c.txt" -> ["", "a", "a/b"]
  let win = null;
  for (const d of dirs) {                 // shallow first...
    const f = files[d];
    if (!f) continue;
    for (const p of f.patterns)           // ...later lines overwrite earlier
      if (patternMatches(p, path, isDir, d)) win = p;
  }
  return win;                             // may be a NEGATION
}
Enter fullscreen mode Exit fullscreen mode

Iterating shallow-to-deep and overwriting gives you both precedence rules for free: later lines beat earlier lines inside one file, and a deeper .gitignore beats a shallower one, because it simply runs last. A src/.gitignore containing !*.log really does re-include logs the root banned.

Which also means order is load-bearing:

*.log
!important.log     # important.log survives

!important.log
*.log              # ...and now it does not
Enter fullscreen mode Exit fullscreen mode

The walk — where the parent rule comes from

This is the function that makes the engine correct instead of merely plausible.

function classify(files, path, isDir){
  const segs = path.split("/");
  for (let i = 0; i < segs.length; i++) {
    const sub  = segs.slice(0, i + 1).join("/");
    const last = i === segs.length - 1;
    const win  = lastMatch(files, sub, last ? isDir : true);
    if (win && !win.negated)                       // an ancestor is excluded
      return { ignored: true, by: win, at: sub, viaParent: !last };
  }
  return { ignored: false, by: lastMatch(files, path, isDir), at: path };
}
Enter fullscreen mode Exit fullscreen mode

Git walks from the root and at every directory asks "is this excluded?". If yes it stops — it does not open the directory, it does not read a .gitignore inside it, it does not look at a single file below it. That is a performance decision first (one comparison skips a quarter of a million files in node_modules) and a semantic decision second.

Everything surprising about gitignore is downstream of this loop.

So when the walk stops at an ancestor, ask the question the walk skipped — and tell the user:

if (res.viaParent) {
  const own = lastMatch(files, path, isDir);
  if (own && own.negated) {
    res.trapped = true;      // a "!" that can never be reached
    res.trapBy  = own;
  }
}
Enter fullscreen mode Exit fullscreen mode

The documented workaround is to unignore at directory granularity first, so the walk can still get inside:

# BROKEN — the ! can never be reached
node_modules/
!node_modules/patched.js

# WORKS — unignore the container, then narrow
/*
!/keep/
!/.gitignore
Enter fullscreen mode Exit fullscreen mode

/* excludes every top-level entry; !/keep/ re-includes the directory, so git descends into it; and files inside keep are simply never matched by the anchored single-segment /*.

Prove it — real git is the oracle

This is one of the rare toys with a free reference implementation already installed on your machine. Generate a throwaway repo, write patterns and a tree, then compare.

// verdict for every file:  "!!" = ignored, "??" = untracked but visible
const out = run("git status --porcelain -z -uall --ignored=traditional");

// and the deciding line, verbatim:
//   $ git check-ignore -v --no-index docs/api/spec.tmp
//   .gitignore:19:docs/**/*.tmp   docs/api/spec.tmp
Enter fullscreen mode Exit fullscreen mode

check-ignore -v prints source, line number and pattern — the same three fields the engine should be able to name. One detail worth matching: git prints the parsed pattern, with the ! and trailing / reattached and trailing spaces already trimmed, not the raw line. Reconstruct it the same way and the two outputs compare byte for byte:

p.canon = (p.negated ? "!" : "") + body + (p.dirOnly ? "/" : "");
Enter fullscreen mode Exit fullscreen mode

I generated a repo with four nested .gitignore files, roughly forty patterns covering every rule above, and sixty paths — negation after a directory exclusion, /* plus !keep/, ** in all three positions, character classes, escaped # and !, trailing spaces, directory-only rules — and asserted both oracles for every path. 357 assertions, no disagreements.

One documented divergence, and it is the filesystem's fault rather than the engine's: NTFS is case-insensitive, so thumbs.db cannot coexist on disk with Thumbs.db. The lowercase half of the [Tt]humbs.db class is asserted against the engine alone.

Paste your own .gitignore and see which line decided what: https://dev48v.infy.uk/solve/day61-gitignore-tester.html

Top comments (0)