feat(index): multi-repo workspaces index as a whole (#514) (#837)

A workspace holding several independent git repos failed two ways:

- Enumeration: a super-repo whose .gitignore hides its child repos
  (/packages/) indexed 0 of their files — git never lists ignored dirs,
  and the #193 embedded-repo recursion only fired for UNTRACKED dirs.
  Gitignored embedded repos are now discovered (ignored-dirs listing +
  bounded .git search) and enumerated by their own git ls-files.
- Change detection: git status in the parent says nothing about embedded
  repos (untracked OR ignored), so codegraph sync missed every child
  change. Status now recurses per embedded repo.

ScopeIgnore is the new single source of truth for indexer + watcher
scope: parent rules for ordinary paths, the child repo's own rules for
paths inside it, built-in defaults uniformly on full paths (a git repo
inside node_modules is an npm git-dependency, not project code), and
ancestors of embedded roots are never pruned (the Linux per-directory
watcher must descend to reach them).

Non-git workspace roots already worked via the per-directory gitignore
walk — locked in by test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-12 12:41:22 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 13b2575dfe
commit ed117ef90b
4 changed files with 479 additions and 45 deletions
+9 -8
View File
@@ -24,7 +24,7 @@
* per-file watches are never needed.
*
* Excluded trees (node_modules/, dist/, .git/, …) are filtered via the
* indexer's `buildDefaultIgnore` (built-in default-ignore dirs + the project's
* indexer's `buildScopeIgnore` (built-in default-ignore dirs + the project's
* .gitignore) — on Linux they're never descended into (so they cost no watch),
* and on macOS/Windows the single recursive stream still covers them but their
* events are dropped before any sync is scheduled. Either way the watcher's
@@ -33,8 +33,7 @@
import * as fs from 'fs';
import * as path from 'path';
import type { Ignore } from 'ignore';
import { isSourceFile, buildDefaultIgnore } from '../extraction';
import { isSourceFile, buildScopeIgnore, type ScopeIgnore } from '../extraction';
import { logDebug, logWarn } from '../errors';
import { normalizePath } from '../utils';
import { isCodeGraphDataDir } from '../directory';
@@ -200,10 +199,12 @@ export class FileWatcher {
* deterministically gate on watcher readiness.
*/
private readyWaiters: Array<() => void> = [];
// The shared ignore matcher (built-in defaults + project .gitignore), built
// once at start(). Same source of truth the indexer uses, so watcher scope
// can never diverge from index scope.
private ignoreMatcher: Ignore | null = null;
// The shared scope matcher (built-in defaults + project .gitignore, with
// embedded child repos matched by their OWN rules — #514), built once at
// start(). Same source of truth the indexer uses, so watcher scope can
// never diverge from index scope. An embedded repo created after start()
// joins the scope on the next watcher restart / re-index.
private ignoreMatcher: ScopeIgnore | null = null;
private readonly projectRoot: string;
private readonly debounceMs: number;
@@ -244,7 +245,7 @@ export class FileWatcher {
}
// Reuse the indexer's ignore set so the watcher and indexer agree on scope.
this.ignoreMatcher = buildDefaultIgnore(this.projectRoot);
this.ignoreMatcher = buildScopeIgnore(this.projectRoot);
try {
if (this.inertForTests) {