Repository: colbymchenry/codegraph (2026-07-16T21:01:06Z)

Issue:
includeIgnored in codegraph.json does not revive embedded git repos on init/index (v1.4.1)
## Summary

`codegraph.json`'s `includeIgnored` (introduced in v1.1.1 per #970/#980) no longer opts gitignored embedded git repos back into the index. `codegraph init`/`codegraph index` report the repos as excluded and print the exact config the user already has as the "fix," in a loop.

## Environment

- CodeGraph: 1.4.1 (npm install, `@colbymchenry/codegraph`)
- OS: macOS (Darwin arm64)
- Project shape: a meta-repo whose root `.gitignore` excludes `/repos/`, which contains 3 independently-git-tracked projects (`repos/a/`, `repos/b/`, `repos/c/`), each with its own `.git` and its own `.codegraph/` index.

## Repro

`.gitignore` at root:
```
/repos/
```

`codegraph.json` at root:
```json
{
  "includeIgnored": ["repos/a/", "repos/b/", "repos/c/"]
}
```

Run:
```
codegraph init
```

## Actual

```
▲  No files found to index
▲  Your .gitignore excludes 3 nested git repositories here, so they were not indexed: repos/a, repos/b, repos/c.
●  If they're your code, add them to codegraph.json and re-index:
●    { "includeIgnored": ["repos/a/", "repos/b/", "repos/c/"] }
```

The suggested config is byte-for-byte identical to what's already in `codegraph.json` — confirmed the file is present, valid JSON, correctly parsed via `python3 -m json.tool`, and unmodified across `codegraph uninit && codegraph init`, `codegraph index -f`, with and without trailing slashes.

## Expected

Per issue #970 (comment from @colbymchenry, 2026-06-XX): `includeIgnored` is documented and was originally shipped specifically to opt embedded git repos in a gitignored directory back into the index:

> If you ever *do* want nested repos under a gitignored dir indexed, that's now an explicit opt-in via codegraph.json:
> `{ "includeIgnored": ["some/dir/"] }`

The README (Configuration section) still documents the same contract today: "`includeIgnored` only revives embedded git repos, not plain source."

With the config above, `codegraph init` should discover and index the 3 nested repos as part of the root project.

## Workaround found

Swapping the key from `includeIgnored` to `include` (documented as being for *plain gitignored source*, explicitly *not* for embedded git repos) does work — it finds and indexes all files in the nested repos:

```json
{
  "include": ["repos/a/", "repos/b/", "repos/c/"]
}
```

This indexed 1105 files / 18k nodes successfully. So the file-discovery path for `include` correctly walks into gitignored embedded repos, but the dedicated `includeIgnored` path (whatever gates specifically on nested-`.git` detection) appears to no longer trigger — looks like a regression introduced somewhere after v1.1.1, possibly during the work that added `include` in #1163/PR #1063, since both features touch the same ignore/discovery logic.

## Impact

Anyone relying on `includeIgnored` to index a "folder of repos" workspace root (the documented, supported use case per #514/#837) currently gets zero files indexed at the root, with no error beyond the exclusion notice — the suggested fix in the CLI's own message doesn't fix it. `include` is a viable workaround once you know to look for it, but it's a different, undocumented-for-this-purpose knob.


Discussion:
Fixed in #1318 (merged to main, ships in the next release). Your workaround observation pinpointed it: the `include` walk matches per-file, but `includeIgnored` gated on nested-repo discovery — and that gate tested your patterns against the **parent** ignored directory only. With `.gitignore: /repos/`, git reports a single ignored entry `repos/`, which `"repos/a/"` (the exact spelling `codegraph init`'s hint told you to use!) can never match. Hence the loop: correct config, zero effect, same suggestion again.

Now:

- **Both spellings work.** `"includeIgnored": ["repos/a/", "repos/b/", "repos/c/"]` opts in exactly those repos; `"includeIgnored": ["repos/"]` opts in every repo under the directory. Your exact meta-repo layout is a pinned regression test, including the partial case (opting in only `repos/a/` leaves `repos/b/` out and still hinted).
- **The hint stops nagging** about repos that are already configured.
- The watcher's discovery shares the fixed path, so auto-sync scope matches the index.

You can switch back from the `include` workaround to `includeIgnored` after upgrading (`codegraph uninit && codegraph init`, or `index -f`) — `includeIgnored` keeps the "only revive embedded repos, not plain gitignored source" contract the README documents, which `include` doesn't.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 536110e7c..61633871a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- `codegraph.json`'s `includeIgnored` works again for the "folder of repos" layout: when one `.gitignore` rule covers a parent directory (`/repos/`) holding several embedded git repositories, opting in the individual repos (`"includeIgnored": ["repos/a/"]` — the exact spelling `codegraph init`'s own hint suggests) previously matched nothing and indexed zero files, looping the same suggestion back at you. Both spellings now work — name the parent directory to opt in everything under it, or name individual repos to opt in just those — and the hint no longer re-suggests repos that are already configured. (#1295)
 - Method calls on literals (`", ".join(...)` in Python, `"x".split(...)` in JavaScript, and the like) no longer produce call edges to unrelated project functions that happen to share the builtin's name — a codebase with a function called `join`, `get`, or `update` could show phantom callers from every string-builtin use. Additionally, a function nested inside another function is now only matched as a call target from inside its container, since it isn't reachable from anywhere else. Blast-radius and affected-test results get cleaner on Python and JavaScript codebases especially. (#1230)
 - Go method calls through a struct field (`target.conn.Exec(...)`) no longer bind to unrelated same-named local methods when the field's type is external — `conn *sql.DB` calls were being attributed to a local interface that happened to declare `Exec`, fabricating internal dependencies. Chained field calls now resolve by inferring the field's declared type from the struct definition: in-project types (including unexported ones like chi's `tree *node`) gain correct, validated call edges that never existed before, and external types (standard library, third-party modules) are left unlinked instead of guessed. (#1276)
 - TypeScript/JavaScript method calls through an imported singleton (`import { store } from './store'; store.notify()`) now resolve to the class method instead of the exported constant, so `codegraph callers` sees cross-file callers of the method — previously only same-file calls were attributed and a method used everywhere could look unused. The same declaration-based type inference applies across the languages that share it (Python, Java, Kotlin, Go, and more), and a failed inference keeps the old edge rather than guessing. (#1292)
diff --git a/src/extraction/index.ts b/src/extraction/index.ts
index f2e7e047a..2459478f4 100644
--- a/src/extraction/index.ts
+++ b/src/extraction/index.ts
@@ -839,6 +839,10 @@ export function findUnindexedIgnoredRepos(rootDir: string): string[] {
     if (defaults.ignores(dir)) continue; // node_modules etc. — never project code
     if (includeIgnored?.ignores(normalizePath(dir))) continue; // already opted in — nothing to nag about
     for (const repo of findNestedGitRepos(path.join(rootDir, dir), dir)) {
+      // Per-repo opt-in check, mirroring findIgnoredEmbeddedRepos: a child
+      // pattern (`repos/a/`) doesn't match the parent dir above but DOES
+      // cover this repo — it's indexed, so don't nag about it (#1295).
+      if (includeIgnored?.ignores(normalizePath(repo))) continue;
       repos.push(repo);
       if (repos.length >= UNINDEXED_IGNORED_REPO_HINT_CAP) return repos;
     }
@@ -867,8 +871,21 @@ function findIgnoredEmbeddedRepos(repoDir: string, includeIgnored: Ignore | null
   const repos: string[] = [];
   for (const dir of listIgnoredDirs(repoDir)) {
     if (defaults.ignores(dir)) continue;
-    if (!includeIgnored.ignores(normalizePath(prefix + dir))) continue;
-    repos.push(...findNestedGitRepos(path.join(repoDir, dir), dir));
+    const nested = findNestedGitRepos(path.join(repoDir, dir), dir);
+    if (includeIgnored.ignores(normalizePath(prefix + dir))) {
+      // The whole ignored dir is opted in — every nested repo under it counts.
+      repos.push(...nested);
+    } else {
+      // A single gitignore rule often covers the PARENT of the opted-in
+      // repos: `.gitignore: /repos/` lists `repos/` as ONE ignored entry,
+      // while `includeIgnored: ["repos/a/"]` (the CLI hint's own suggested
+      // spelling) names the child — which never matches the parent path, so
+      // the opt-in silently did nothing (#1295). Match each nested repo
+      // root individually so both spellings work. The walk is bounded
+      // (depth/entry caps in findNestedGitRepos) and only runs when
+      // includeIgnored is configured at all.
+      repos.push(...nested.filter((r) => includeIgnored.ignores(normalizePath(prefix + r))));
+    }
   }
   return repos;
 }


Test patch:
diff --git a/__tests__/multi-repo-workspace.test.ts b/__tests__/multi-repo-workspace.test.ts
index 2e6b3c63d..282834038 100644
--- a/__tests__/multi-repo-workspace.test.ts
+++ b/__tests__/multi-repo-workspace.test.ts
@@ -120,6 +120,45 @@ describe('multi-repo workspaces (#514) + .gitignore-respect default (#970, #976)
       expect(files).toContain('tools.ts'); // the parent's own tracked code still indexes
     });
 
+    it('child-pattern spelling revives repos whose PARENT dir carries the gitignore rule (#1295)', () => {
+      // `.gitignore: /repos/` lists `repos/` as ONE ignored entry, while the
+      // CLI hint suggests `includeIgnored: ["repos/a/", "repos/b/"]` — the
+      // child spelling. That never matched the parent path, so the documented
+      // opt-in silently indexed nothing.
+      write(path.join(ws, 'repos/a/a.ts'), 'export const a = 1;\n');
+      write(path.join(ws, 'repos/b/b.ts'), 'export const b = 2;\n');
+      makeRepo(path.join(ws, 'repos/a'));
+      makeRepo(path.join(ws, 'repos/b'));
+      write(path.join(ws, '.gitignore'), '/repos/\n');
+      writeConfig({ includeIgnored: ['repos/a/', 'repos/b/'] });
+      makeRepo(ws);
+
+      const files = scanDirectory(ws);
+      expect(files).toContain('repos/a/a.ts');
+      expect(files).toContain('repos/b/b.ts');
+      // Discovery (the watcher path) agrees with the scanner.
+      expect(discoverEmbeddedRepoRoots(ws).sort()).toEqual(['repos/a/', 'repos/b/']);
+      // And the CLI hint has nothing left to nag about.
+      expect(findUnindexedIgnoredRepos(ws)).toEqual([]);
+    });
+
+    it('child-pattern spelling opts in ONLY the named repo; siblings stay out and stay hinted (#1295)', () => {
+      write(path.join(ws, 'repos/a/a.ts'), 'export const a = 1;\n');
+      write(path.join(ws, 'repos/b/b.ts'), 'export const b = 2;\n');
+      makeRepo(path.join(ws, 'repos/a'));
+      makeRepo(path.join(ws, 'repos/b'));
+      write(path.join(ws, '.gitignore'), '/repos/\n');
+      writeConfig({ includeIgnored: ['repos/a/'] });
+      makeRepo(ws);
+
+      const files = scanDirectory(ws);
+      expect(files).toContain('repos/a/a.ts');
+      expect(files.some((f) => f.startsWith('repos/b/'))).toBe(false);
+      expect(discoverEmbeddedRepoRoots(ws)).toEqual(['repos/a/']);
+      // The unopted sibling is still worth hinting about.
+      expect(findUnindexedIgnoredRepos(ws)).toEqual(['repos/b/']);
+    });
+
     it('only re-includes the opted-in dir, not every gitignored dir', () => {
       // `packages/` is opted in; `scratch/` (also holding a repo) is NOT.
       write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() {}\n');