Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, a method called on an element of a class field, like `this.handlers[type].handle()` or `this.items[0].save()`, now links to the method of the type the field is declared to hold, such as `Item[]` or `Record<string, Handler>`, instead of to any project method that shares its name, often another class's, and is left unlinked when that type isn't written down, as in plain JavaScript. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows. (#2766)
- Indexes built on machines set to different languages now agree. When a TypeScript `this.field.method()` call finds its type declared in two equally near apps, a C++ class names its base through an alias two headers declare differently, or a React Native module's Swift class has a copy in two app targets, the tie is now broken the same way everywhere. Before, it followed the system's language settings, so two machines could link the same call to different code. Thanks @bompus.
- `codegraph sync` no longer fails with "FOREIGN KEY constraint failed" in a project whose code lazy-loads a workspace package, like Supabase Studio's `dynamic(() => import('icons').then((mod) => mod.EdgeFunctions))` or vben's `() => import('@vben/layouts').then((m) => m.IFrameView)`. Before, every sync after an edit in such a project rolled back, so the index stopped following your changes until a full re-index. Thanks @bompus for the report and @inth3shadows. (#2565)
- `codegraph status`, and the status your agent sees through `codegraph_status`, now report uncommitted edits, new files and deletions in a project that sits in a folder of its git repository, like a monorepo package or an `app/` folder beside a `server/` one. Before, they said the index was up to date while `codegraph sync` found the very same changes. A file put back with git after a sync now shows as pending there too, and a project in a folder that its repository's `.gitignore` leaves out is checked file by file instead of always reading as unchanged. Thanks @innoxxicide for the report and the first fix. (#1636)
- In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty.
- In Delphi, a routine with an attribute on one of its parameters, like `const [ref] AValue: Integer` or DelphiMVCFramework's `const [MVCFromBody] Person: TPerson`, is now indexed with its calls, and so is every class member declared after it. Before, the routine was lost or its calls were credited to the file, the parameter showed up as a field of the class, and the members after it were missing. Thanks @stalexteam for the report. (#2545)
- In Delphi, a `for … in` loop that declares its variable inline, like `for var lItem in AList do` or `for var lHandle: THandle in lHandles do`, now has its calls indexed, and a call on a typed loop variable reaches that type's method. Before, the whole loop was skipped, so a routine calling a method only from such a loop was missing from that method's callers. Thanks @stalexteam for the report. (#2546)
Expand Down
190 changes: 190 additions & 0 deletions __tests__/git-status-subdir-project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* Change detection for a project in a folder below its repository root (#1636).
*
* `git status --porcelain` prints paths from the repository root whatever
* folder it runs in, and reports the whole repository. A project in `app/`
* read `app/src/index.js` as its own `app/src/index.js`, found nothing at
* `app/app/src/index.js`, and dropped it: `codegraph status` (and the MCP
* freshness check) said the index was up to date while `codegraph sync`
* re-indexed the very same files.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as cp from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { getGitChangedFiles } from '../src/extraction';

// A pass-through execFileSync, so a test can read the exact git commands run.
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
});

const CLEAN = { added: [], modified: [], removed: [] };

function makeRepo(prefix: string): { repo: string; git: (...args: string[]) => string; write: (rel: string, body: string) => void } {
const repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
const git = (...args: string[]) =>
cp.execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) as string;
const write = (rel: string, body: string) => {
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
fs.writeFileSync(path.join(repo, rel), body);
};
git('init', '-q');
git('config', 'user.email', 'test@example.invalid');
git('config', 'user.name', 'Test');
git('config', 'commit.gpgsign', 'false');
return { repo, git, write };
}

/** The `git status` runs made since the last clear, as [cwd, args]. */
function gitStatusRuns(): Array<[string, string[]]> {
return vi.mocked(cp.execFileSync).mock.calls
.filter(([cmd, args]) => cmd === 'git' && Array.isArray(args) && args[0] === 'status')
.map(([, args, opts]) => [String((opts as { cwd?: string } | undefined)?.cwd ?? ''), args as string[]]);
}

describe('a project in a folder below its repository root (#1636)', () => {
let repo: string;
let project: string;
let git: (...args: string[]) => string;
let write: (rel: string, body: string) => void;
let cg: CodeGraph | undefined;
const changes = () => cg!.getChangedFiles();

beforeEach(async () => {
({ repo, git, write } = makeRepo('cg-subdir-project-'));
project = path.join(repo, 'app');
write('.gitignore', '.codegraph/\n');
write('outside.js', 'function outside() {}\n');
write('app/src/index.js', 'function hello() {}\n');
write('app/src/other.js', 'function other() {}\n');
git('add', '-A');
git('commit', '-qm', 'init');
cg = CodeGraph.initSync(project);
await cg.indexAll();
expect(changes()).toEqual(CLEAN);
});

afterEach(() => {
vi.mocked(cp.execFileSync).mockClear();
cg?.close();
cg = undefined;
fs.rmSync(repo, { recursive: true, force: true });
});

it('sees an edited, an added and a deleted file, as sync does', async () => {
write('app/src/index.js', 'function renamedHello() { return 2; }\n');
write('app/src/added.js', 'function added() {}\n');
fs.unlinkSync(path.join(project, 'src', 'other.js'));
expect(getGitChangedFiles(project)).toEqual({ added: ['src/added.js'], modified: ['src/index.js'], deleted: ['src/other.js'] });
expect(changes()).toEqual({ added: ['src/added.js'], modified: ['src/index.js'], removed: ['src/other.js'] });

const synced = await cg!.sync();
expect([synced.filesAdded, synced.filesModified, synced.filesRemoved]).toEqual([1, 1, 1]);
expect(changes()).toEqual(CLEAN);
});

it('leaves out changes outside the project, and asks git about the project only', () => {
vi.mocked(cp.execFileSync).mockClear();
write('outside.js', 'function outside() { return 1; }\n');
write('sibling/new.js', 'function sibling() {}\n');
expect(changes()).toEqual(CLEAN);
const runs = gitStatusRuns().filter(([cwd]) => cwd === project);
expect(runs.length).toBeGreaterThan(0);
for (const [, args] of runs) expect(args.slice(-2)).toEqual(['--', '.']);
});

it('sees an indexed edit that git restored, after a reopen too (#1878)', async () => {
write('app/src/index.js', 'function dirtyHello() {}\n');
await cg!.sync();
git('checkout', '--', 'app/src/index.js');
expect(git('status', '--porcelain')).toBe('');
expect(changes().modified).toEqual(['src/index.js']);
cg!.close();
cg = CodeGraph.openSync(project);
expect(changes().modified).toEqual(['src/index.js']);
await cg.sync();
expect(changes()).toEqual(CLEAN);
});

it("matches the project's own .gitignore against its paths", () => {
// A tracked file under a folder the project's .gitignore anchors (`/generated/`):
// git status still lists its edits, and the full index leaves it out. The
// rule matches `generated/out.js`, never the repository's `app/generated/out.js`.
write('app/generated/out.js', 'function generated() {}\n');
git('add', '-A');
git('commit', '-qm', 'generated');
write('app/.gitignore', '/generated/\n');
git('add', '-A');
git('commit', '-qm', 'ignore generated');
write('app/generated/out.js', 'function generated() { return 1; }\n');
write('app/src/kept.js', 'function kept() {}\n');
expect(getGitChangedFiles(project)).toEqual({ added: ['src/kept.js'], modified: [], deleted: [] });
expect(changes()).toEqual({ added: ['src/kept.js'], modified: [], removed: [] });
});

it('reads an embedded repository inside the project', () => {
const embedded = path.join(project, 'vendored');
fs.mkdirSync(embedded);
cp.execFileSync('git', ['init', '-q'], { cwd: embedded, stdio: 'pipe' });
write('app/vendored/lib.js', 'function lib() {}\n');
expect(changes().added).toEqual(['vendored/lib.js']);
});
});

describe('a project in a folder its repository ignores (#1636)', () => {
it('falls back to the full scan, which sees the edits', async () => {
const { repo, git, write } = makeRepo('cg-ignored-project-');
const project = path.join(repo, 'app');
write('.gitignore', 'app/\n');
write('outside.js', 'function outside() {}\n');
write('app/src/index.js', 'function hello() {}\n');
git('add', '-A');
git('commit', '-qm', 'init; app/ is ignored');
const cg = CodeGraph.initSync(project);
try {
await cg.indexAll();
expect(cg.getChangedFiles()).toEqual(CLEAN);
write('app/src/index.js', 'function renamedHello() { return 2; }\n');
write('app/src/added.js', 'function added() {}\n');
// git can say nothing about an ignored folder, so it is not asked.
expect(getGitChangedFiles(project)).toBeNull();
expect(cg.getChangedFiles()).toEqual({ added: ['src/added.js'], modified: ['src/index.js'], removed: [] });
} finally {
cg.close();
fs.rmSync(repo, { recursive: true, force: true });
}
});
});

describe('a project at its repository root', () => {
it('runs exactly the git status it always ran', async () => {
const { repo, git, write } = makeRepo('cg-root-project-');
write('.gitignore', '.codegraph/\n');
write('src/index.js', 'function hello() {}\n');
git('add', '-A');
git('commit', '-qm', 'init');
const asked = () => vi.mocked(cp.execFileSync).mock.calls.map(([, args]) => (args as string[]).join(' '));
// From the first open on, so an answer kept from the index run counts too.
vi.mocked(cp.execFileSync).mockClear();
const cg = CodeGraph.initSync(repo);
try {
await cg.indexAll();
write('src/index.js', 'function hello() { return 1; }\n');
write('src/added.js', 'function added() {}\n');
const beforeStatus = asked().length;
expect(cg.getChangedFiles()).toEqual({ added: ['src/added.js'], modified: ['src/index.js'], removed: [] });
const runs = gitStatusRuns();
expect(runs.length).toBeGreaterThan(0);
for (const [, args] of runs) expect(args).toEqual(['status', '--porcelain', '--no-renames', '-z', '-uall']);
expect(asked().filter((a) => a.includes('--show-prefix'))).toEqual([]);
expect(asked().slice(beforeStatus).filter((a) => a.startsWith('check-ignore'))).toEqual([]);
} finally {
cg.close();
fs.rmSync(repo, { recursive: true, force: true });
}
});
});
106 changes: 82 additions & 24 deletions src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1383,6 +1383,53 @@ function collectGitFiles(repoDir: string, prefix: string, files: Set<string>, em
}
}

/**
* Whether the repository `rootDir` sits in gitignores `rootDir` itself. Git
* then lists nothing inside it, so the git-backed scan and change detection
* both step aside for the directory walk. Asked only of a folder below its
* repository's root.
*/
function isIgnoredByEnclosingRepo(rootDir: string): boolean {
try {
// git check-ignore exits 0 if the path IS ignored, 1 if not
execFileSync(
'git',
['check-ignore', '-q', path.resolve(rootDir)],
{ cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
);
return true;
} catch {
return false;
}
}

/** {@link gitRepoPrefix} answers, by folder. */
const gitRepoPrefixes = new Map<string, string>();

/**
* Where `dir` sits in its git repository: '' at the repository's root, else the
* path from the root with a trailing slash (`app/`). Throws when `dir` is in no
* repository. `git status --porcelain` prints every path from the repository's
* root, whichever folder it runs in, so a project below the root takes this
* off (#1636). A folder with its own `.git` is a repository or worktree root and
* costs no git call; any other answer is kept, since a folder's place in its
* repository does not move under a running process.
*/
function gitRepoPrefix(dir: string): string {
if (fs.existsSync(path.join(dir, '.git'))) return '';
const key = path.resolve(dir);
let prefix = gitRepoPrefixes.get(key);
if (prefix === undefined) {
prefix = normalizePath(execFileSync(
'git',
['rev-parse', '--show-prefix'],
{ cwd: dir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
).trim());
gitRepoPrefixes.set(key, prefix);
}
return prefix;
}

/**
* Get all files visible to git (tracked + untracked but not ignored).
* Respects .gitignore at all levels (root, subdirectories) and descends into
Expand All @@ -1400,23 +1447,13 @@ function getGitVisibleFiles(rootDir: string): Set<string> | null {
{ cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
).trim();

if (path.resolve(gitRoot) !== path.resolve(rootDir)) {
try {
// git check-ignore exits 0 if the path IS ignored, 1 if not
execFileSync(
'git',
['check-ignore', '-q', path.resolve(rootDir)],
{ cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
);
// Directory is gitignored by parent repo — fall back to filesystem walk
logDebug('project root is gitignored by a parent repo — falling back to filesystem walk', {
rootDir,
gitRoot,
});
return null;
} catch {
// Not ignored — safe to use git ls-files
}
if (path.resolve(gitRoot) !== path.resolve(rootDir) && isIgnoredByEnclosingRepo(rootDir)) {
// Directory is gitignored by parent repo — fall back to filesystem walk
logDebug('project root is gitignored by a parent repo — falling back to filesystem walk', {
rootDir,
gitRoot,
});
return null;
}

const files = new Set<string>();
Expand Down Expand Up @@ -1495,7 +1532,12 @@ export function getGitChangedFiles(rootDir: string, sinceCommit?: string | null)
// so change detection sees the same custom-extension files the full index does.
const overrides = loadExtensionOverrides(rootDir);
const isSource = (filePath: string): boolean => isSourceFile(filePath, overrides, rootDir);
collectGitStatus(rootDir, '', changes, isSource, loadIncludeIgnoredMatcher(rootDir), loadExcludeMatcher(rootDir), sinceCommit ?? undefined);
// A project in a folder below its repository's root (#1636). Git can say
// nothing about a folder its repository ignores: decline, as the scan
// does, so callers take the full scan that indexed the project.
const repoPrefix = gitRepoPrefix(rootDir);
if (repoPrefix && isIgnoredByEnclosingRepo(rootDir)) return null;
collectGitStatus(rootDir, '', changes, isSource, loadIncludeIgnoredMatcher(rootDir), loadExcludeMatcher(rootDir), sinceCommit ?? undefined, repoPrefix);
return changes;
} catch {
return null;
Expand Down Expand Up @@ -1567,7 +1609,12 @@ export function canTrustGitFastPath(rootDir: string, sinceCommit?: string | null
}
}

function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, isSource: (filePath: string) => boolean, includeIgnored: Ignore | null = null, exclude: Ignore | null = null, sinceCommit?: string): void {
/**
* `repoPrefix` is where `repoDir` sits in its repository ({@link gitRepoPrefix}):
* '' for a repository root, which every embedded repo below is, and the
* folder's path from the root (`app/`) for a project below it (#1636).
*/
function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, isSource: (filePath: string) => boolean, includeIgnored: Ignore | null = null, exclude: Ignore | null = null, sinceCommit?: string, repoPrefix = ''): void {
const output = execFileSync(
'git',
// `-uall` lists individual untracked files instead of collapsing an
Expand All @@ -1576,7 +1623,12 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, isSo
// below). Nested untracked git repos still collapse to `?? repo/` even
// with `-uall` — git never crosses a repo boundary — so the recursion
// still handles them. (#1213)
['status', '--porcelain', '--no-renames', '-z', '-uall'],
// Below the repository's root, `-- .` keeps the report to this folder:
// git reports the whole repository otherwise. A root runs the command
// it always did.
repoPrefix
? ['status', '--porcelain', '--no-renames', '-z', '-uall', '--', '.']
: ['status', '--porcelain', '--no-renames', '-z', '-uall'],
{ cwd: repoDir, encoding: 'utf-8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
);

Expand All @@ -1587,9 +1639,9 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, isSo
// vendor/ dir, or a tracked file under a .gitignored dir, surfaces here as a
// change — so `codegraph status` (which reads getChangedFiles) reports a
// pending edit the full index never tracks and `sync` never clears. Matching
// repo-relative `rel` at each recursion level mirrors getGitVisibleFiles'
// `repoDir`-relative `rel` at each recursion level mirrors getGitVisibleFiles'
// ScopeIgnore: every embedded repo is judged by ITS OWN rules, never the
// parent's. (#766)
// parent's, and a project below its repository's root by its own. (#766)
const ig = buildDefaultIgnore(repoDir);

// One classifier for both candidate sources below, so a committed change is
Expand All @@ -1607,7 +1659,7 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, isSo
}

// Added (`??`) / modified files inside an excluded dir must not enter the
// index — match against the repo-relative path, same as the full scan. (#766)
// index — match against the `repoDir`-relative path, same as the full scan. (#766)
if (ig.ignores(rel)) return;
// User `codegraph.json` `exclude` (#999) is project-root-relative, so it's
// matched against the full path — sync must not re-add a tracked file the
Expand All @@ -1630,7 +1682,13 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, isSo
if (line.length < 4) continue; // Minimum: "XY file"

const statusCode = line.substring(0, 2);
const rel = normalizePath(line.substring(3));
let rel = normalizePath(line.substring(3));
// Porcelain paths run from the repository's root, whatever the cwd: take
// them back to this folder, and leave out anything outside it.
if (repoPrefix) {
if (!rel.startsWith(repoPrefix) || rel.length === repoPrefix.length) continue;
rel = rel.slice(repoPrefix.length);
}

// Untracked directory entries (trailing slash) may hide an embedded repo —
// collect for the recursion below instead of treating as a file.
Expand Down