DEV Community

Russell Jones
Russell Jones

Posted on Originally published at jonesrussell.github.io

Stop walking the filesystem in your CI gates — ask git instead

Ahnii!

Waaseyaa has a family of "S1 roster" gate scripts that scan the tree for specific governed patterns — raw SQLite construction, schema-boundary crossings — and compare what they find against a recorded, reviewed roster. A related check-access-hardening gate scans for missing authorization guards. All of them needed the same thing first: a list of files to scan. Getting that list right turned out to be harder than it sounds, and the eventual fix replaced a hand-maintained exclusion list with one line: ask git.

The problem with walking the filesystem

The original scanners enumerated files with a RecursiveDirectoryIterator and then subtracted paths that matched a denylist — vendor/, node_modules/, .git/, and so on. That denylist was a stand-in for "repository content," rebuilt by hand instead of asked from the one tool that actually knows the answer. Every time a new kind of untracked tree showed up, the denylist needed another entry.

That's exactly what happened. Nested git worktrees under .worktrees/ or .claude/worktrees/, and a populated packages/<pkg>/vendor/, weren't on the list. On the primary checkout, that gap produced:

  • 24,780 phantom findings across three gates — 18,844, 5,932, and 4 — as nested worktree build caches and vendored trees read as governed pattern matches.
  • --write-construction-roster would have committed .worktrees/... paths into the tracked roster, permanently baking a developer-local artifact into shared state.
  • check-access-hardening had no exclusion at all beyond requiring /src/ in the path, so a vendored library shipping its own src/ directory was scanned as if it were first-party code — the source of that gate's 4.

The root cause wasn't any single missing entry — it was structural. A walk-minus-denylist re-creates the repository boundary by hand, and a denylist only ever grows in response to the last thing that broke it.

The fix: git ls-files, not a walk

Both scanners now go through one shared helper, bin/lib/repository-files.php:

function repositoryFiles(string $root, array $pathspecs = []): array
{
    $normalizedRoot = rtrim(str_replace('\\', '/', $root), '/');
    $arguments = ['ls-files', '-z', '--cached', '--others', '--exclude-standard'];
    $pathspecs = array_values(array_filter($pathspecs, static fn(string $pathspec): bool => $pathspec !== ''));
    if ($pathspecs !== []) {
        $arguments[] = '--';
        array_push($arguments, ...$pathspecs);
    }

    [$exitCode, $listing, $error] = repositoryGit($normalizedRoot, $arguments);
    if ($exitCode !== 0) {
        throw new RuntimeException(sprintf(
            'repository-files: git could not enumerate repository files under %s (exit %d)%s',
            $normalizedRoot,
            $exitCode,
            $error === '' ? '.' : ': ' . $error,
        ));
    }

    $files = [];
    foreach (explode("\0", $listing) as $relative) {
        if ($relative === '' || !is_file($normalizedRoot . '/' . $relative)) {
            continue;
        }
        $files[$relative] = true;
    }
    $paths = array_keys($files);
    sort($paths, SORT_STRING);

    return $paths;
}
Enter fullscreen mode Exit fullscreen mode

git ls-files --cached --others --exclude-standard returns tracked files plus untracked files git would add, honoring .gitignore along the way. That single command replaces the denylist entirely:

  • .gitignore becomes the exclusion boundary by construction. Nothing to maintain, nothing to forget.
  • Git never descends into another repository's work tree, so a nested worktree is invisible whether or not it happens to be ignored. No .worktrees/ special case needed.
  • A root git can't enumerate fails closedrepositoryFiles() throws instead of silently falling back to a filesystem walk. If the gate can't trust its own file list, it doesn't run.

The second bug: git answered the wrong repository's question

Shipping the git-native scanner exposed a subtler problem. A pre-push hook run from a linked worktree exports GIT_DIR=<main>/.git/worktrees/<name> into the environment. The scanner's git -C $root ls-files inherited that variable — and GIT_DIR overrides -C entirely, so the command enumerated the hook's repository instead of the scan root.

Worse, the access-hardening gate's self-test calls git init on a fixture directory to set up test data. With GIT_DIR still pointing at the developer's own worktree gitdir, that git init "reinitialized" the developer's real repository as bare (core.bare=true in the shared .git/config), breaking every git command in the main checkout. Any contributor with project hooks installed who pushed from .claude/worktrees/* or .worktrees/* could hit this.

The fix scrubs every environment variable git itself uses to select a repository before running a child process:

const REPOSITORY_LOCAL_GIT_ENVIRONMENT = [
    'GIT_ALTERNATE_OBJECT_DIRECTORIES',
    'GIT_COMMON_DIR',
    'GIT_CONFIG',
    'GIT_CONFIG_COUNT',
    'GIT_CONFIG_PARAMETERS',
    'GIT_DIR',
    'GIT_GRAFT_FILE',
    'GIT_IMPLICIT_WORK_TREE',
    'GIT_INDEX_FILE',
    'GIT_NO_REPLACE_OBJECTS',
    'GIT_OBJECT_DIRECTORY',
    'GIT_PREFIX',
    'GIT_REPLACE_REF_BASE',
    'GIT_SHALLOW_FILE',
    'GIT_WORK_TREE',
];

function repositoryGitEnvironment(): array
{
    $environment = getenv();
    foreach (REPOSITORY_LOCAL_GIT_ENVIRONMENT as $name) {
        unset($environment[$name]);
    }

    return $environment;
}
Enter fullscreen mode Exit fullscreen mode

This is git's own local_repo_env list — the set git clears before running a command against another repository. With it scrubbed, -C $root is the only thing left that selects which repository a child git process operates on.

What changed, and what didn't

Before After
RecursiveDirectoryIterator + hand-maintained denylist git ls-files -z --cached --others --exclude-standard
New untracked-tree kind → new denylist entry, reactively .gitignore is the boundary; nothing to add
Access-hardening gate: no exclusion beyond an /src/ path check Same git-backed enumeration as the roster scanners
Git child processes inherited hook environment Repository-selecting env vars scrubbed on every git call
Silent fallback risk if enumeration failed Fails closed with a clear error; no fallback walk

Regenerating all four support/s1-*-roster.json files against the new scanner produced a zero diff — the fix removed phantom candidates without touching a single legitimate one. All three gates then passed read-only against a checkout holding sixteen nested worktrees and a populated packages/ai-agent/vendor/.

The general lesson

If you're writing a script that scans "the repository" — a linter, a gate, an audit tool — resist the urge to walk the filesystem and subtract what you don't want. A denylist encodes everything you've already been burned by; it says nothing about what you haven't hit yet. Git already knows the answer to "what files are in this repository," .gitignore and all, and git ls-files will give it to you in one call.

And if that script ever shells out to git from inside a hook or a nested worktree, don't assume the environment is clean. Hooks can carry repository-selecting variables that override the very -C flag you're relying on to scope the command — scrub them, or you may find git init pointed at the wrong repository entirely.

Baamaapii

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

It's impressive how you've streamlined the scanning process by leveraging git ls-files instead of a manual filesystem walk. This not only reduces maintenance overhead but also mitigates the risk of missing new directories or file types. One potential improvement could be to implement caching for the results of git ls-files, especially in larger repositories, to avoid repeated calls and speed up the CI process. If you need an extra hand with optimizing this implementation further or tackling the next phase of your project's challenges, I’d be happy to explore a paid collaboration. What do you think about the idea of caching?