Files
codegraph/__tests__/git-ignored-dirs-scope.test.ts
T
Colby MchenryandClaude Opus 5.5 a278c2ee12 fix(scope): a folder git ignores matches only itself, not every folder with its name (#2858)
The scope matcher that the indexer, sync and the file watcher share also
takes in the folders `git ls-files -o -i --exclude-standard --directory`
reports as ignored (PR #1754, for issue #1728). They were added to it as
gitignore patterns. git reports them relative to the root, but a pattern of
one segment, like `src/`, has no anchor in gitignore syntax and matches at
any depth. So an ignored root `src/`, holding only a gitignored jib agent,
hid every module's `src/` in a Maven monorepo, and the index came up empty.
As patterns, folder names were also read as syntax: `[draft]` as a
character class, `a[b` as a regex that throws on the first match (the git
listing then fell back to a filesystem walk, and the watcher threw on every
event), `#tmp` as a comment and `!keep` as a negation.

The reported folders now stay root-relative paths in a set, and a path is
out of scope when it is one of them or lies inside one. Nothing is parsed,
so there is nothing to anchor or escape. Escaping was not an option: in
ignore 7.0.5 an escaped `?` never matches a literal `?`, an escaped
backslash before a `.` turns the dot into a wildcard, and every match is
case-insensitive. git prints these paths relative to its cwd, so for a
project in a subfolder of a repo they are relative to that subfolder, the
root the matcher is asked about.

The CLI hint for gitignored nested repos wrote their paths into
codegraph.json `includeIgnored` the same unanchored way, so an opt-in for
`child/` also took in any later `archive/child/`. It now writes a
root-anchored pattern with `*` and `[` escaped.

Validation:
- New git-ignored-dirs-scope tests (9): all fail on main and pass with the
  fix. They cover the issue's layout (scan, index, scoped sync), the lib/,
  build/ and dist/ variants, a `[draft]` folder, the watcher with a
  `scratch[1` folder, a project in a subfolder of its repo with `#tmp` and
  `!keep` folders, and the CLI hint end to end.
- A/B with the kernel in both arms, on real repos after writing the root
  folders a local build writes, against git's own view (ls-files --cached
  --others --exclude-standard, less codegraph's defaults). Files indexed of
  those in scope, main then fix: stencil 1015 and 1707 of 1707, Sentinel
  with the reporter's jib layout 104 and 1423 of 1423, ant-design 2851 and
  3138 of 3138, wger 767 and 770 of 770. The fix misses no file in scope
  and adds none outside it.
- For Sentinel, ant-design and wger, the fix's graph on the built tree is
  byte-identical to main's on a clean checkout. ant-design's 53 changed
  call sites move `useLocale` from the docs site's hook to
  components/locale/useLocale.ts. In stencil, every edge the fix adds on
  the built tree is also in main's graph on a clean checkout, and the 62
  name matches it no longer makes are unresolved there as well.
- Controls gin and flask: byte-identical graphs.
- Index time, median of 3 interleaved: gin 2572 and 2545 ms, flask 2400
  and 2378 ms; the repos above take longer only by their restored files.
  The scope step with 486 and 599 reported entries (a `.DS_Store` in
  every folder): main 206 and 416 ms, fix 105 and 155 ms.

Fixes #2849

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:06:41 +00:00

235 lines
11 KiB
TypeScript

/**
* The folders git reports as ignored are scoped as literal paths (#2849).
*
* The scope matcher the indexer, sync and watcher share also takes in the
* folders `git ls-files -o -i --exclude-standard --directory` reports (#1728).
* They used to be added as gitignore patterns, where a path of one segment has
* no anchor: an ignored root `src/` (holding only a gitignored jib agent) hid
* every `src/` in a Maven monorepo, and the index came up empty. A pattern
* also reads a folder's name as syntax: `[draft]` as a character class, `a[b`
* as a regex that throws on the first match, `#tmp` as a comment and `!keep`
* as a negation. The CLI's `includeIgnored` hint wrote repo paths into
* codegraph.json the same unanchored way.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import ignore from 'ignore';
import CodeGraph from '../src/index';
import {
buildScopeIgnore,
findUnindexedIgnoredRepos,
includeIgnoredPatternFor,
scanDirectory,
scanDirectoryAsync,
} from '../src/extraction';
import { FileWatcher } from '../src/sync/watcher';
import { clearProjectConfigCache } from '../src/project-config';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
}
function write(file: string, content: string): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
/** A repo at `dir` with `tracked` committed, then `untracked` written for `.gitignore` to hide. */
function makeRepo(dir: string, tracked: Record<string, string>, untracked: Record<string, string> = {}): void {
for (const [rel, content] of Object.entries(tracked)) write(path.join(dir, rel), content);
git(dir, 'init', '-q');
git(dir, 'add', '-A');
git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init');
for (const [rel, content] of Object.entries(untracked)) write(path.join(dir, rel), content);
}
/** What git reports as ignored-untracked, relative to `cwd`. */
function gitIgnored(cwd: string): string[] {
return git(cwd, 'ls-files', '-z', '-o', '-i', '--exclude-standard', '--directory').split('\0').filter(Boolean);
}
/** `rel` and every folder above it, in the forms the watcher asks about. */
function pathAndFolders(rel: string): string[] {
const parts = rel.split('/');
return [rel, ...parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/') + '/')];
}
const sorted = (files: Iterable<string>): string[] => [...files].sort();
describe('folders git reports as ignored are scoped as literal paths (#2849)', () => {
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-git-ignored-dirs-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(root, { recursive: true, force: true });
});
const JAVA = 'services/example/src/main/java/Example.java';
const javaClass = (method: string) => `public class Example { public int ${method}() { return 1; } }\n`;
it('indexes and syncs a monorepo whose root src/ holds only a gitignored jib agent', async () => {
makeRepo(root, { '.gitignore': '*jib*\n**/opt/datadog\n', [JAVA]: javaClass('run') }, {
'src/main/jib/opt/datadog/dd-java-agent.jar': 'jar\n',
});
expect(gitIgnored(root)).toContain('src/');
expect(scanDirectory(root)).toEqual([JAVA]);
expect(await scanDirectoryAsync(root)).toEqual([JAVA]);
const scope = buildScopeIgnore(root);
for (const p of pathAndFolders(JAVA)) expect(scope.ignores(p), p).toBe(false);
expect(scope.ignores('src/')).toBe(true);
expect(scope.ignores('src/main/jib/opt/datadog/dd-java-agent.jar')).toBe(true);
const cg = CodeGraph.initSync(root);
try {
expect((await cg.indexAll()).filesIndexed).toBe(1);
// A scoped sync of an edit there updates the file rather than dropping it.
write(path.join(root, JAVA), javaClass('stop'));
await cg.sync({ paths: [JAVA] });
expect(cg.getFiles().map((f) => f.path)).toEqual([JAVA]);
expect(cg.getNodesInFile(JAVA).map((n) => n.name)).toContain('stop');
} finally {
cg.close();
}
});
it.each([
{ name: 'lib', gitignore: '/lib/\n', nested: 'packages/util/lib/strings.ts', output: 'lib/index.js' },
// A feature folder kept in src/build/ is indexed (#2681).
{ name: 'build', gitignore: '/build/\n', nested: 'app/src/build/feature.ts', output: 'build/out.js' },
// A nested dist/ the project re-includes on purpose.
{ name: 'dist', gitignore: '/dist/\n!packages/legacy/dist/\n', nested: 'packages/legacy/dist/legacy.js', output: 'dist/bundle.js' },
])('keeps a tracked $name/ below the root when git reports the root $name/ ignored', ({ name, gitignore, nested, output }) => {
makeRepo(root, { '.gitignore': gitignore, 'index.ts': 'export const main = 1;\n', [nested]: 'export const kept = 1;\n' }, {
[output]: 'module.exports = 1;\n',
});
expect(gitIgnored(root)).toEqual([`${name}/`]);
expect(sorted(scanDirectory(root))).toEqual(sorted(['index.ts', nested]));
const scope = buildScopeIgnore(root);
for (const p of pathAndFolders(nested)) expect(scope.ignores(p), p).toBe(false);
expect(scope.ignores(`${name}/`)).toBe(true);
expect(scope.ignores(output)).toBe(true);
});
it('reads a reported folder named like a character class as that one folder', () => {
makeRepo(root, { '.gitignore': '*draft*\n', 'src/a/alpha.ts': 'export const alpha = 1;\n', 'src/b.ts': 'export const b = 1;\n' }, {
'[draft]/notes.ts': 'export const draft = 1;\n',
});
expect(gitIgnored(root)).toEqual(['[draft]/']);
// As a pattern, `[draft]/` matched any folder named d, r, a, f or t.
expect(sorted(scanDirectory(root))).toEqual(['src/a/alpha.ts', 'src/b.ts']);
const scope = buildScopeIgnore(root);
for (const p of pathAndFolders('src/a/alpha.ts')) expect(scope.ignores(p), p).toBe(false);
expect(scope.ignores('[draft]/notes.ts')).toBe(true);
});
it('keeps the watcher on files under a nested namesake and off the reported folders, whatever their names', async () => {
makeRepo(root, { '.gitignore': '*jib*\nscratch*/\n', [JAVA]: javaClass('run') }, {
'src/main/jib/Agent.java': 'public class Agent {}\n',
// An unterminated character class: as a pattern it threw on every match.
'scratch[1/s.ts': 'export const s = 1;\n',
});
expect(gitIgnored(root)).toEqual(expect.arrayContaining(['src/', 'scratch[1/']));
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = new FileWatcher(root, syncFn, { inertForTests: true, debounceMs: 60_000 });
try {
expect(watcher.start()).toBe(true);
await watcher.waitUntilReady();
watcher.ingestEventForTests(JAVA);
watcher.ingestEventForTests('src/main/jib/Agent.java');
watcher.ingestEventForTests('scratch[1/s.ts');
expect(watcher.getPendingFiles().map((p) => p.path)).toEqual([JAVA]);
} finally {
watcher.stop();
}
const scope = buildScopeIgnore(root);
for (const p of pathAndFolders(JAVA)) expect(scope.ignores(p), p).toBe(false);
expect(scope.ignores('scratch[1/')).toBe(true);
});
it('anchors the folders reported for a project in a subfolder of the repo to that subfolder', () => {
const java = 'services/a/src/main/java/A.java';
makeRepo(root, {
'.gitignore': '*jib*\n/proj/#tmp/\n/proj/!keep/\n',
'src/outside.ts': 'export const outside = 1;\n',
[`proj/${java}`]: 'public class A {}\n',
'proj/lib/keep.ts': 'export const keep = 1;\n',
}, {
'proj/src/main/jib/agent.jar': 'jar\n',
'proj/#tmp/t.ts': 'export const t = 1;\n',
'proj/!keep/k.ts': 'export const k = 1;\n',
});
const proj = path.join(root, 'proj');
// git prints them relative to its cwd, the project: `src/`, not `proj/src/`.
expect(gitIgnored(proj)).toEqual(expect.arrayContaining(['src/', '#tmp/', '!keep/']));
expect(sorted(scanDirectory(proj))).toEqual(['lib/keep.ts', java]);
const scope = buildScopeIgnore(proj);
for (const p of pathAndFolders(java)) expect(scope.ignores(p), p).toBe(false);
expect(scope.ignores('src/main/jib/agent.jar')).toBe(true);
// Neither the repo root's rules nor these names reach the matcher any other
// way; as patterns, they were a comment and a negation.
expect(scope.ignores('#tmp/t.ts')).toBe(true);
expect(scope.ignores('!keep/k.ts')).toBe(true);
});
it('suggests an includeIgnored entry that opts in only the repo it found', () => {
makeRepo(path.join(root, 'mtc-a'), { 'src/a.ts': 'export const a = 1;\n' });
makeRepo(root, { '.gitignore': '/mtc-a/\n/archive/\n' });
// An empty index at a super-repo prints the opt-in snippet (#1156).
const out = execFileSync(process.execPath, [BIN, 'init', '-y', root], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000,
env: {
...process.env,
CODEGRAPH_NO_DAEMON: '1',
CODEGRAPH_WASM_RELAUNCHED: '1',
CODEGRAPH_TELEMETRY: '0',
DO_NOT_TRACK: '1',
NO_COLOR: '1',
},
});
const snippet = out.match(/\{ "includeIgnored": \[[^\]]*\] \}/)?.[0];
expect(snippet, out).toBeDefined();
const { includeIgnored } = JSON.parse(snippet!) as { includeIgnored: string[] };
expect(includeIgnored).toEqual(['/mtc-a/']);
fs.writeFileSync(path.join(root, 'codegraph.json'), JSON.stringify({ includeIgnored }));
clearProjectConfigCache();
// Later an old copy of the same repo lands in another gitignored folder.
// The opt-in is for the repo the hint named, not for every `mtc-a/`.
makeRepo(path.join(root, 'archive', 'mtc-a'), { 'src/old.ts': 'export const old = 1;\n' });
expect(scanDirectory(root)).toEqual(['mtc-a/src/a.ts']);
expect(findUnindexedIgnoredRepos(root)).toEqual(['archive/mtc-a/']);
}, 120_000);
it('includeIgnoredPatternFor anchors the path and escapes glob characters', () => {
const optsIn = (repo: string, dir: string) => ignore().add(includeIgnoredPatternFor(repo)).ignores(dir);
expect(includeIgnoredPatternFor('repos/a/')).toBe('/repos/a/');
expect(optsIn('mtc-a/', 'mtc-a/')).toBe(true);
expect(optsIn('mtc-a/', 'archive/mtc-a/')).toBe(false);
expect(optsIn('[draft]/', '[draft]/')).toBe(true);
expect(optsIn('[draft]/', 'd/')).toBe(false);
expect(optsIn('a*b/', 'a*b/')).toBe(true);
expect(optsIn('a*b/', 'axxb/')).toBe(false);
expect(optsIn('#tmp/', '#tmp/')).toBe(true);
expect(optsIn('!keep/', '!keep/')).toBe(true);
});
});