From ee949262995fcbff01128d87b4cdb69b2beb6ce1 Mon Sep 17 00:00:00 2001 From: Seungjae Jay Lee Date: Fri, 30 Oct 2026 13:12:02 -0400 Subject: [PATCH] fix(status): detect changes to included gitignored source --- CHANGELOG.md | 1 + .../included-ignored-source-status.test.ts | 52 +++++++++++++++++++ src/extraction/index.ts | 7 +++ 3 files changed, 60 insertions(+) create mode 100644 __tests__/included-ignored-source-status.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32df517fb..a2e5ccfcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- `codegraph status` now reports added, changed and deleted source files explicitly included despite Git ignoring them, including edits that preserve file size and modification time, instead of incorrectly reporting no pending changes. - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) diff --git a/__tests__/included-ignored-source-status.test.ts b/__tests__/included-ignored-source-status.test.ts new file mode 100644 index 000000000..e4dade6e7 --- /dev/null +++ b/__tests__/included-ignored-source-status.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { CodeGraph } from '../src'; + +describe('Explicitly included gitignored source status', () => { + let dir: string; + let cg: CodeGraph | undefined; + const write = (rel: string, source: string) => { + const file = path.join(dir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, source); + }; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-included-status-')); + }); + afterEach(() => { + cg?.destroy(); + cg = undefined; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('reports included additions, edits with preserved metadata, and deletions', async () => { + write('local/Device.java', 'package p; public class Device { public void run() {} }'); + write('local/excluded/Noise.java', 'public class Noise {}'); + write('.gitignore', 'local/\n'); + write('codegraph.json', JSON.stringify({ include: ['local/'], exclude: ['local/excluded/'] })); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=a@b.c', 'commit', '-qm', 'base'], { cwd: dir }); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByName('Device')).toHaveLength(1); + expect(cg.getNodesByName('Noise')).toHaveLength(0); + expect(cg.getChangedFiles()).toEqual({ added: [], modified: [], removed: [] }); + + const file = path.join(dir, 'local/Device.java'); + const originalStat = fs.statSync(file); + // Git never reports this ignored file, and metadata cannot prove it unchanged. + write('local/Device.java', 'package p; public class Device { public void end() {} }'); + fs.utimesSync(file, originalStat.atime, originalStat.mtime); + expect(fs.statSync(file).size).toBe(originalStat.size); + expect(cg.getChangedFiles().modified).toEqual(['local/Device.java']); + + write('local/New.java', 'package p; public class New {}'); + write('local/excluded/NewNoise.java', 'public class NewNoise {}'); + expect(cg.getChangedFiles().added).toEqual(['local/New.java']); + fs.rmSync(file); + expect(cg.getChangedFiles().removed).toEqual(['local/Device.java']); + }); +}); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 55f927c15..e9bed435a 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -4137,6 +4137,13 @@ export class ExtractionOrchestrator { // have been recreated locally; a previously indexed dirty path may have // vanished from git status after restore. Classify current disk vs DB once. const candidates = new Set([...gitChanges.deleted, ...gitChanges.modified, ...gitChanges.added, ...dirtyPaths!]); + const include = loadIncludeMatcher(this.rootDir); + if (include) { + for (const file of collectIncludedFilesForRoot(this.rootDir)) candidates.add(file); + for (const file of this.queries.getAllFiles()) { + if (include.ignores(file.path)) candidates.add(file.path); + } + } const scope = this.scopedSyncMatcher(); const overrides = loadExtensionOverrides(this.rootDir); for (const filePath of candidates) {