A Git super-repo whose `.gitignore` excludes its child repositories indexed ~nothing at the parent: CodeGraph respects `.gitignore` by default (#970, #1065), so the excluded children were skipped and `codegraph init` printed "Done" with 0 nodes — even though `init` inside each child worked fine. The empty index was silent and unexplained. `init`/`index` now detect the gitignored child repos they skipped when an index comes up empty of symbols, name them, and — in an interactive terminal — offer to index them (writing an `includeIgnored` entry to codegraph.json and re-indexing on the spot); non-interactive runs print the exact codegraph.json snippet to add. Gated on nodesCreated === 0, so a project that deliberately keeps gitignored reference clones out of a working index is never nagged. - extraction: findUnindexedIgnoredRepos — the inverse of discoverEmbeddedRepoRoots (bounded, skips default-ignored dirs, respects existing includeIgnored) - project-config: addIncludeIgnoredPatterns — create/merge codegraph.json, idempotent, refuses to clobber malformed JSON - cli: wire the detect-name-offer flow into both `init` and `index` - tests: +13 covering detection, config writing, and the no-nag gate Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a9e8fa48a1
commit
e65a39746c
@@ -14,7 +14,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { loadIncludeIgnoredPatterns, loadExtensionOverrides, clearProjectConfigCache } from '../src/project-config';
|
||||
import { loadIncludeIgnoredPatterns, loadExtensionOverrides, clearProjectConfigCache, addIncludeIgnoredPatterns } from '../src/project-config';
|
||||
|
||||
describe('includeIgnored loader (codegraph.json)', () => {
|
||||
let dir: string;
|
||||
@@ -87,3 +87,58 @@ describe('includeIgnored loader (codegraph.json)', () => {
|
||||
expect(loadIncludeIgnoredPatterns(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addIncludeIgnoredPatterns (codegraph.json writer, #1156)', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-addincludeignored-'));
|
||||
clearProjectConfigCache();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearProjectConfigCache();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
const readConfig = () => JSON.parse(fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf-8'));
|
||||
|
||||
it('creates codegraph.json when none exists', () => {
|
||||
expect(addIncludeIgnoredPatterns(dir, ['mtc-a/', 'mtc-b/'])).toBe(2);
|
||||
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['mtc-a/', 'mtc-b/']);
|
||||
});
|
||||
|
||||
it('merges into an existing list, preserving other keys and de-duping', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'codegraph.json'),
|
||||
JSON.stringify({ extensions: { '.foo': 'typescript' }, includeIgnored: ['mtc-a/'] }),
|
||||
);
|
||||
expect(addIncludeIgnoredPatterns(dir, ['mtc-a/', 'mtc-b/'])).toBe(1); // only mtc-b/ is new
|
||||
const parsed = readConfig();
|
||||
expect(parsed.includeIgnored).toEqual(['mtc-a/', 'mtc-b/']);
|
||||
expect(parsed.extensions).toEqual({ '.foo': 'typescript' }); // untouched
|
||||
});
|
||||
|
||||
it('is idempotent — re-adding the same patterns adds nothing', () => {
|
||||
addIncludeIgnoredPatterns(dir, ['mtc-a/']);
|
||||
expect(addIncludeIgnoredPatterns(dir, ['mtc-a/'])).toBe(0);
|
||||
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['mtc-a/']);
|
||||
});
|
||||
|
||||
it('replaces a non-array includeIgnored value rather than crashing', () => {
|
||||
fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({ includeIgnored: 'oops' }));
|
||||
expect(addIncludeIgnoredPatterns(dir, ['mtc-a/'])).toBe(1);
|
||||
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['mtc-a/']);
|
||||
});
|
||||
|
||||
it('refuses to clobber a malformed existing codegraph.json (throws, leaves file intact)', () => {
|
||||
const bad = '{ not: valid json ';
|
||||
fs.writeFileSync(path.join(dir, 'codegraph.json'), bad);
|
||||
expect(() => addIncludeIgnoredPatterns(dir, ['mtc-a/'])).toThrow();
|
||||
expect(fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf-8')).toBe(bad);
|
||||
});
|
||||
|
||||
it('writes pretty-printed, newline-terminated JSON', () => {
|
||||
addIncludeIgnoredPatterns(dir, ['mtc-a/']);
|
||||
const raw = fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf-8');
|
||||
expect(raw.endsWith('\n')).toBe(true);
|
||||
expect(raw).toContain('\n "includeIgnored"'); // 2-space indent
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ 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';
|
||||
import { scanDirectory, buildScopeIgnore, discoverEmbeddedRepoRoots, findUnindexedIgnoredRepos } from '../src/extraction';
|
||||
import { clearProjectConfigCache } from '../src/project-config';
|
||||
|
||||
function git(cwd: string, ...args: string[]): void {
|
||||
@@ -361,4 +361,81 @@ describe('multi-repo workspaces (#514) + .gitignore-respect default (#970, #976)
|
||||
expect(discoverEmbeddedRepoRoots(child)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUnindexedIgnoredRepos: the skipped-child-repos hint (#1156)', () => {
|
||||
// The reported layout: a super-repo whose `.gitignore` excludes its child
|
||||
// repos, so `init` at the parent correctly indexes ~nothing. This detector
|
||||
// is the inverse of `discoverEmbeddedRepoRoots` — it names exactly the repos
|
||||
// the default scan skipped so the CLI can offer to opt them in.
|
||||
it('names the gitignored child repos a default index skipped', () => {
|
||||
write(path.join(ws, 'mtc-activity/src/a.ts'), 'export const a = 1;\n');
|
||||
write(path.join(ws, 'mtc-admin/src/b.ts'), 'export const b = 2;\n');
|
||||
makeRepo(path.join(ws, 'mtc-activity'));
|
||||
makeRepo(path.join(ws, 'mtc-admin'));
|
||||
write(path.join(ws, '.gitignore'), 'mtc-*/\n');
|
||||
write(path.join(ws, 'AGENTS.md'), '# docs\n');
|
||||
makeRepo(ws);
|
||||
|
||||
// Nothing of the child repos indexes by default — the symptom being fixed.
|
||||
expect(scanDirectory(ws).some((f) => f.startsWith('mtc-'))).toBe(false);
|
||||
// ...but the detector names them (trailing-slashed, valid includeIgnored patterns).
|
||||
expect(findUnindexedIgnoredRepos(ws).sort()).toEqual(['mtc-activity/', 'mtc-admin/']);
|
||||
});
|
||||
|
||||
it('excludes repos already opted in via includeIgnored (only the rest remain)', () => {
|
||||
write(path.join(ws, 'mtc-activity/src/a.ts'), 'export const a = 1;\n');
|
||||
write(path.join(ws, 'mtc-admin/src/b.ts'), 'export const b = 2;\n');
|
||||
makeRepo(path.join(ws, 'mtc-activity'));
|
||||
makeRepo(path.join(ws, 'mtc-admin'));
|
||||
write(path.join(ws, '.gitignore'), 'mtc-*/\n');
|
||||
writeConfig({ includeIgnored: ['mtc-activity/'] });
|
||||
makeRepo(ws);
|
||||
|
||||
expect(findUnindexedIgnoredRepos(ws)).toEqual(['mtc-admin/']);
|
||||
});
|
||||
|
||||
it('returns [] when every gitignored repo is already opted in (nothing to nag)', () => {
|
||||
write(path.join(ws, 'pkgs/a/src/a.ts'), 'export const a = 1;\n');
|
||||
makeRepo(path.join(ws, 'pkgs/a'));
|
||||
write(path.join(ws, '.gitignore'), '/pkgs/\n');
|
||||
writeConfig({ includeIgnored: ['pkgs/'] });
|
||||
makeRepo(ws);
|
||||
|
||||
expect(findUnindexedIgnoredRepos(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does NOT report nested repos that are NOT gitignored (they already index)', () => {
|
||||
// Scenario A: an untracked, non-ignored nested repo is indexed via the
|
||||
// untracked-embedded path, so there is nothing to hint about.
|
||||
write(path.join(ws, 'sub/src/a.ts'), 'export const a = 1;\n');
|
||||
makeRepo(path.join(ws, 'sub'));
|
||||
write(path.join(ws, 'app.ts'), 'export const app = 0;\n');
|
||||
makeRepo(ws); // sub/ stays untracked, not ignored
|
||||
|
||||
expect(findUnindexedIgnoredRepos(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a gitignored node_modules even when it holds a git repo', () => {
|
||||
write(path.join(ws, 'node_modules/dep/index.js'), 'module.exports = 1;\n');
|
||||
makeRepo(path.join(ws, 'node_modules/dep'));
|
||||
write(path.join(ws, '.gitignore'), 'node_modules/\n');
|
||||
makeRepo(ws);
|
||||
|
||||
expect(findUnindexedIgnoredRepos(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds repos nested inside a gitignored data dir, not just top-level ones', () => {
|
||||
write(path.join(ws, 'refs/lib-a/x.ts'), 'export const x = 1;\n');
|
||||
makeRepo(path.join(ws, 'refs/lib-a'));
|
||||
write(path.join(ws, '.gitignore'), '/refs/\n');
|
||||
makeRepo(ws);
|
||||
|
||||
expect(findUnindexedIgnoredRepos(ws)).toEqual(['refs/lib-a/']);
|
||||
});
|
||||
|
||||
it('returns [] for a non-git directory', () => {
|
||||
write(path.join(ws, 'a.ts'), 'export const a = 1;\n'); // no git init at all
|
||||
expect(findUnindexedIgnoredRepos(ws)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user