fix(index): a deleted file git still lists is skipped, not reported as unreadable (#2928)

A full index reads every path the scan lists, and `git ls-files` keeps
listing a tracked file deleted from the working tree until the deletion is
staged. So `codegraph init` and `codegraph index` stat'ed the missing file,
got ENOENT and reported "1 files could not be read" with an errors.log
entry. The errored file also kept the run from recording the commit it
indexed, so `codegraph status` fell back to a full scan afterwards.

The batch reader in indexAll and the single-file indexFile now take ENOENT
and ENOTDIR to mean nothing is there: the file is skipped and counted as
skipped, so the run reads as complete rather than PARTIAL. A symlink that
points at nothing is skipped the same way, as the directory walk for
projects without git already did. Every other read failure (EACCES, EIO, a
folder where a file was) is still reported. On POSIX a path under a file
fails realpath with ENOTDIR, which validatePathWithinRoot rejected as if it
escaped the root; it is now judged like any missing leaf, by its nearest
existing ancestor, so it reaches the reader and is skipped too. Discovery
is unchanged, with no extra stat per tracked file.

Validation:
- index-gone-tracked-files.test.ts (new): all 5 cases fail on main's
  source and pass with the fix: committed and staged deletions, a folder
  replaced by a file, a dangling symlink, and EACCES still reported by
  both the full index and indexFiles. Mutating the single-file path, the
  skipped tally or the EACCES boundary fails the test each time.
- security.test.ts: a path under an existing file validates, and one under
  a symlink to a file outside the root is still refused.
- The issue's CLI repro: main prints "1 files could not be read" and writes
  errors.log; the fix prints "Indexed 1 files", writes no errors.log, and
  status reports nothing pending.

Reported in PR #2910 by @xianzhi-xzw, who also filed issue #2904. This
reimplements the fix independently, at read time rather than with an lstat
of every tracked file during discovery.

Fixes #2904

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-27 18:03:50 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f93e89a82d
commit 983c4df374
5 changed files with 182 additions and 4 deletions
+23 -2
View File
@@ -28,7 +28,7 @@ import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLangu
import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns, PROJECT_CONFIG_FILENAME } from '../project-config';
import { isCodeGraphDataDir } from '../directory';
import { logDebug, logWarn } from '../errors';
import { validatePathWithinRoot, normalizePath } from '../utils';
import { validatePathWithinRoot, normalizePath, isMissingPathError } from '../utils';
import ignore, { Ignore } from 'ignore';
import { detectFrameworks, getFrameworkResolver } from '../resolution/frameworks';
import { declaredDependencies } from '../resolution/frameworks/package-deps';
@@ -2579,6 +2579,14 @@ export class ExtractionOrchestrator {
const content = bytes.toString('utf-8');
return { filePath: fp, content, stats, error: null as Error | null };
} catch (err) {
// Nothing at the path is not a file that could not be read (#2904):
// git keeps listing a tracked file deleted from the working tree
// until the deletion is staged, a symlink can point at nothing (the
// directory walk skips one quietly), and a file can go between the
// scan and this read. Skip it. EACCES, EIO and the like still report.
if (isMissingPathError(err)) {
return { filePath: fp, content: null as string | null, stats: null as fs.Stats | null, error: null as Error | null, gone: true };
}
return { filePath: fp, content: null as string | null, stats: null as fs.Stats | null, error: err as Error };
}
})
@@ -2586,7 +2594,7 @@ export class ExtractionOrchestrator {
// Dispatch each readable file into the bounded parse window; the window
// stores results on the main thread as they arrive.
for (const { filePath, content, stats, error, skipped } of fileContents) {
for (const { filePath, content, stats, error, skipped, gone } of fileContents) {
if (signal?.aborted) { aborted = true; break; }
if (skipped) {
@@ -2596,6 +2604,15 @@ export class ExtractionOrchestrator {
continue;
}
if (gone) {
// Listed by the scan but not on disk: skipped, stored as nothing. It
// still counts toward the tallies, or the run would read as PARTIAL.
processed++;
filesSkipped++;
onProgress?.({ phase: 'parsing', current: processed, total });
continue;
}
if (error || content === null || stats === null) {
processed++;
filesErrored++;
@@ -2921,6 +2938,10 @@ export class ExtractionOrchestrator {
}
content = read.bytes === null ? oversizeStamp(stats.size) : read.bytes.toString('utf8');
} catch (error) {
// Nothing at the path: nothing to index, and not a read failure (#2904).
if (isMissingPathError(error)) {
return { nodes: [], edges: [], unresolvedReferences: [], errors: [], durationMs: 0 };
}
return {
nodes: [],
edges: [],
+13 -2
View File
@@ -124,6 +124,16 @@ export function lexicalPathWithinRoot(projectRoot: string, filePath: string): st
return isWithinDir(resolved, path.resolve(projectRoot)) ? resolved : null;
}
/**
* Whether a filesystem error means nothing is at the path: ENOENT, or ENOTDIR,
* which POSIX reports instead when a folder on the way is a file (Windows
* reports ENOENT for both).
*/
export function isMissingPathError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | null)?.code;
return code === 'ENOENT' || code === 'ENOTDIR';
}
/**
* Validate that a file path stays within the project root, resolving symlinks.
*
@@ -188,7 +198,8 @@ export function validatePathWithinRoot(
// nearest existing ancestor before allowing a write path: otherwise
// `project/.codegraph/ui -> /outside` plus a missing `trails/` directory
// passes the lexical check and mkdir follows the symlink out of the root.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return null;
// A path under a file (ENOTDIR on POSIX) is a missing leaf too.
if (!isMissingPathError(err)) return null;
}
let ancestor = path.dirname(resolved);
@@ -200,7 +211,7 @@ export function validatePathWithinRoot(
}
return null;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return null;
if (!isMissingPathError(err)) return null;
const parent = path.dirname(ancestor);
if (parent === ancestor) return null;
ancestor = parent;