fix(extraction): index nested non-submodule git repos (#193) (#217)

`codegraph init -i` from a git super-repo containing independent nested
git repositories (not submodules) reported "No files found to index":
git ls-files reports an embedded repo only as an opaque `subdir/` entry
and never lists its files. Detect embedded repos via that trailing-slash
signal and recurse `git ls-files` into each, indexing tracked + untracked
source and honoring each repo's own .gitignore.

Reported by @timxx.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-20 12:03:43 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 9b06b0edde
commit 07c093cc3f
3 changed files with 138 additions and 26 deletions
+54 -26
View File
@@ -125,10 +125,61 @@ export function shouldIncludeFile(
return false;
}
/**
* Collect git-visible files (tracked + untracked, .gitignore-respected) from the
* git repository rooted at `repoDir`, adding each to `files` with `prefix`
* prepended so paths stay relative to the original scan root.
*
* Recurses into embedded git repositories — nested repos that are NOT submodules
* (independent clones living inside the workspace, common in CMake "super-repo"
* layouts). The parent repo's `git ls-files` cannot see into them: tracked output
* skips them entirely, and untracked output reports them only as an opaque
* "subdir/" entry (trailing slash) rather than expanding their files. Each
* embedded repo is its own git boundary, so we re-run `git ls-files` inside it.
* (See issue #193.)
*/
function collectGitFiles(repoDir: string, prefix: string, files: Set<string>): void {
const gitOpts = { cwd: repoDir, encoding: 'utf-8' as const, timeout: 30000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] as ['pipe', 'pipe', 'pipe'] };
// Tracked files. --recurse-submodules pulls in files from active submodules,
// which the index would otherwise represent only as a commit pointer.
// Without this, monorepos using submodules index 0 files. (See issue #147.)
// Note: --recurse-submodules only supports -c/--cached and --stage modes — it
// can't be combined with -o, so untracked files are gathered separately below.
const tracked = execFileSync('git', ['ls-files', '-c', '--recurse-submodules'], gitOpts);
for (const line of tracked.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
files.add(normalizePath(prefix + trimmed));
}
}
// Untracked files (submodules manage their own untracked state). Embedded git
// repos surface here as a single "subdir/" entry that git refuses to descend
// into — recurse into those as their own repos so their source gets indexed.
const untracked = execFileSync('git', ['ls-files', '-o', '--exclude-standard'], gitOpts);
for (const line of untracked.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.endsWith('/')) {
// git only emits a trailing-slash directory entry for an embedded repo.
// Guard with a .git check anyway, and skip anything else exactly as git
// itself skips it (we never descend into a non-repo opaque dir).
const childDir = path.join(repoDir, trimmed);
if (fs.existsSync(path.join(childDir, '.git'))) {
collectGitFiles(childDir, prefix + trimmed, files);
}
continue;
}
files.add(normalizePath(prefix + trimmed));
}
}
/**
* Get all files visible to git (tracked + untracked but not ignored).
* Respects .gitignore at all levels (root, subdirectories).
* Returns null on failure (non-git project) so callers can fall back.
* Respects .gitignore at all levels (root, subdirectories) and descends into
* embedded (nested, non-submodule) git repos. Returns null on failure
* (non-git project) so callers can fall back to a filesystem walk.
*/
function getGitVisibleFiles(rootDir: string): Set<string> | null {
try {
@@ -157,30 +208,7 @@ function getGitVisibleFiles(rootDir: string): Set<string> | null {
}
const files = new Set<string>();
const gitOpts = { cwd: rootDir, encoding: 'utf-8' as const, timeout: 30000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] as ['pipe', 'pipe', 'pipe'] };
// Tracked files. --recurse-submodules pulls in files from active submodules,
// which the main repo's index would otherwise represent only as a commit pointer.
// Without this, monorepos using submodules index 0 files. (See issue #147.)
// Note: --recurse-submodules only supports -c/--cached and --stage modes — it
// can't be combined with -o, so untracked files are gathered separately below.
const tracked = execFileSync('git', ['ls-files', '-c', '--recurse-submodules'], gitOpts);
for (const line of tracked.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
files.add(normalizePath(trimmed));
}
}
// Untracked files in the main repo (submodules manage their own untracked state).
const untracked = execFileSync('git', ['ls-files', '-o', '--exclude-standard'], gitOpts);
for (const line of untracked.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
files.add(normalizePath(trimmed));
}
}
collectGitFiles(rootDir, '', files);
return files;
} catch {
return null;