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
+204
View File
@@ -0,0 +1,204 @@
/**
* Multi-repo workspaces (#514): a directory holding several independent git
* repositories must index as a whole.
*
* Two enumeration paths are exercised:
* - git path: the workspace root is itself a git repo (a "super-repo") whose
* `.gitignore` hides the child repos to keep `git status` quiet. git never
* lists ignored dirs, so the embedded repos were invisible (0 files). They
* are now discovered via the ignored-directories listing and enumerated by
* their own `git ls-files`. (#193 covered the *untracked* embedded case.)
* - sync path: `git status` in the parent says nothing about embedded repos;
* change detection now recurses into them.
*
* The non-git-parent case (plain folder of repos) already worked via the
* filesystem walk — locked in here so it stays that way.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execFileSync } from 'child_process';
import CodeGraph from '../src/index';
import { scanDirectory, buildScopeIgnore, discoverEmbeddedRepoRoots } from '../src/extraction';
function git(cwd: string, ...args: string[]): void {
execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'ignore'] });
}
/** git init + commit everything currently in `dir` as one repo. */
function makeRepo(dir: string): void {
git(dir, 'init', '-q');
git(dir, 'add', '-A');
git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init', '--allow-empty');
}
function write(file: string, content: string): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
describe('multi-repo workspaces (#514)', () => {
let ws: string;
beforeEach(() => {
ws = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-multirepo-'));
});
afterEach(() => {
fs.rmSync(ws, { recursive: true, force: true });
});
it('indexes embedded repos hidden by the super-repo .gitignore', () => {
write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() { return 1; }\n');
write(path.join(ws, 'packages/proj-b/src/billing.ts'), 'export function charge() { return 2; }\n');
makeRepo(path.join(ws, 'packages/proj-a'));
makeRepo(path.join(ws, 'packages/proj-b'));
write(path.join(ws, '.gitignore'), '/packages/\n');
write(path.join(ws, 'tools.ts'), 'export function tool() { return 0; }\n');
makeRepo(ws);
const files = scanDirectory(ws);
expect(files).toContain('packages/proj-a/src/auth.ts');
expect(files).toContain('packages/proj-b/src/billing.ts');
expect(files).toContain('tools.ts'); // the parent's own tracked code still indexes
});
it('keeps respecting the parent .gitignore for the parent own (non-repo) dirs', () => {
write(path.join(ws, 'scratch/junk.ts'), 'export function junk() { return 9; }\n');
write(path.join(ws, 'src/app.ts'), 'export function app() { return 1; }\n');
write(path.join(ws, '.gitignore'), '/scratch/\n');
makeRepo(ws);
const files = scanDirectory(ws);
expect(files).toContain('src/app.ts');
// scratch/ is gitignored and contains NO embedded repo — stays excluded.
expect(files.some((f) => f.startsWith('scratch/'))).toBe(false);
});
it('never descends into git repos inside node_modules (npm git-dependencies)', () => {
// Embedded repo first (clean), node_modules dropped in afterwards —
// matching reality, where node_modules is never committed.
write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() {}\n');
makeRepo(path.join(ws, 'packages/proj-a'));
write(path.join(ws, 'packages/proj-a/node_modules/inner/src/evil2.ts'), 'export function evil2() {}\n');
makeRepo(path.join(ws, 'packages/proj-a/node_modules/inner')); // npm git-dep: has commits
// Workspace-level git-dep too.
write(path.join(ws, 'node_modules/git-dep/src/evil.ts'), 'export function evil() {}\n');
makeRepo(path.join(ws, 'node_modules/git-dep'));
write(path.join(ws, '.gitignore'), '/packages/\nnode_modules\n');
makeRepo(ws);
const files = scanDirectory(ws);
expect(files).toContain('packages/proj-a/src/auth.ts');
expect(files.some((f) => f.includes('node_modules'))).toBe(false);
});
it('still indexes UNTRACKED embedded repos (#193 regression)', () => {
write(path.join(ws, 'vendor-src/lib/src/util.ts'), 'export function util() {}\n');
makeRepo(path.join(ws, 'vendor-src/lib'));
write(path.join(ws, 'main.ts'), 'export function main() {}\n');
makeRepo(ws); // vendor-src/ is untracked (not ignored) — committed ws has only main.ts + nothing else
// NOTE: makeRepo committed vendor-src too via add -A… recreate untracked state:
git(ws, 'rm', '-r', '--cached', '-q', 'vendor-src');
git(ws, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'untrack');
const files = scanDirectory(ws);
expect(files).toContain('vendor-src/lib/src/util.ts');
expect(files).toContain('main.ts');
});
it('non-git workspace: walks children and respects each child own .gitignore', () => {
write(path.join(ws, 'proj-a/src/auth.ts'), 'export function login() {}\n');
write(path.join(ws, 'proj-a/build/out.ts'), 'export function generated() {}\n');
write(path.join(ws, 'proj-a/.gitignore'), 'build/\n');
write(path.join(ws, 'proj-b/src/billing.ts'), 'export function charge() {}\n');
makeRepo(path.join(ws, 'proj-a'));
makeRepo(path.join(ws, 'proj-b'));
// ws itself is NOT a git repo.
const files = scanDirectory(ws);
expect(files).toContain('proj-a/src/auth.ts');
expect(files).toContain('proj-b/src/billing.ts');
expect(files.some((f) => f.includes('build/'))).toBe(false);
});
it('does not search beyond the embedded-repo depth cap', () => {
// Repo buried 5 levels under the ignored dir — past EMBEDDED_REPO_SEARCH_DEPTH (4).
const deep = path.join(ws, 'pkgs/a/b/c/d/e');
write(path.join(deep, 'src/deep.ts'), 'export function deep() {}\n');
makeRepo(deep);
write(path.join(ws, 'main.ts'), 'export function main() {}\n');
write(path.join(ws, '.gitignore'), '/pkgs/\n');
makeRepo(ws);
const files = scanDirectory(ws);
expect(files).toContain('main.ts');
expect(files.some((f) => f.includes('deep.ts'))).toBe(false);
});
it('discovers embedded roots (ignored + untracked kinds); none for non-git roots', () => {
write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() {}\n');
makeRepo(path.join(ws, 'packages/proj-a'));
write(path.join(ws, 'vendor-src/lib/util.ts'), 'export function util() {}\n');
makeRepo(path.join(ws, 'vendor-src/lib'));
write(path.join(ws, '.gitignore'), '/packages/\n'); // vendor-src stays untracked
makeRepo(ws);
git(ws, 'rm', '-r', '--cached', '-q', 'vendor-src');
git(ws, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'untrack');
const roots = discoverEmbeddedRepoRoots(ws);
expect(roots).toContain('packages/proj-a/');
expect(roots).toContain('vendor-src/lib/');
const plain = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nongit-'));
try {
expect(discoverEmbeddedRepoRoots(plain)).toEqual([]);
} finally {
fs.rmSync(plain, { recursive: true, force: true });
}
});
it('ScopeIgnore: embedded files use the child rules; the watcher can descend to them', () => {
write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() {}\n');
write(path.join(ws, 'packages/proj-a/.gitignore'), 'build/\n');
makeRepo(path.join(ws, 'packages/proj-a'));
write(path.join(ws, '.gitignore'), '/packages/\n');
makeRepo(ws);
const scope = buildScopeIgnore(ws);
// Inside the embedded repo: the CHILD's rules decide.
expect(scope.ignores('packages/proj-a/src/auth.ts')).toBe(false);
expect(scope.ignores('packages/proj-a/build/out.ts')).toBe(true);
// Under the ignored dir but NOT in any embedded repo: parent rules apply.
expect(scope.ignores('packages/stray.ts')).toBe(true);
// Directory form: ancestors of an embedded root are never pruned —
// the Linux per-directory watcher must descend through `packages/`.
expect(scope.ignores('packages/')).toBe(false);
// Ordinary paths: unchanged semantics.
expect(scope.ignores('node_modules/dep/index.ts')).toBe(true);
expect(scope.ignores('src/app.ts')).toBe(false);
});
it('sync picks up a change inside a gitignored embedded repo', async () => {
write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() { return 1; }\n');
makeRepo(path.join(ws, 'packages/proj-a'));
write(path.join(ws, '.gitignore'), '/packages/\n');
makeRepo(ws);
const cg = CodeGraph.initSync(ws, { config: { include: ['**/*.ts'], exclude: [] } });
try {
await cg.indexAll();
expect(cg.searchNodes('login', { limit: 5 }).length).toBeGreaterThan(0);
// Change inside the embedded repo — invisible to the parent's `git status`.
write(path.join(ws, 'packages/proj-a/src/auth.ts'),
'export function login() { return 1; }\nexport function logout() { return 0; }\n');
await cg.sync();
expect(cg.searchNodes('logout', { limit: 5 }).length).toBeGreaterThan(0);
} finally {
cg.destroy();
}
});
});