mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 01:29:02 +08:00
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:
co-authored by
Claude Opus 5.5
parent
f93e89a82d
commit
983c4df374
+23
-2
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user