mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 05:59:11 +08:00
When the indexed project is a folder of its git repository (a monorepo package, an app/ folder beside a server/ one), `codegraph status`, `status --json` and the freshness check behind codegraph_status reported the index up to date while `codegraph sync` found the very same changes. `git status --porcelain` prints paths from the repository root whatever folder it runs in, and reports the whole repository. Change detection joined `app/src/index.js` under the project, found nothing at `app/app/src/index.js`, and dropped it. The dirty paths a sync records for the restore check (from PR #1878) were stored the same way, so a file put back with git after a sync was missed too. And a project in a folder its repository gitignores got a permanently clean answer from git, which says nothing about an ignored folder. Change detection now asks where the project sits in its repository (`git rev-parse --show-prefix`, kept per folder, and skipped when the folder has its own .git). Below the root it runs git status with a `-- .` pathspec, takes the prefix off every path and leaves out anything outside the folder. The project's ignore rules match those project-relative paths, the recorded dirty paths are project-relative, and a project its repository ignores declines the git path as the scan already did, so callers take the full scan. A project at its repository root runs exactly the git command it ran before. Validation: - git-status-subdir-project.test.ts (new, 7 cases): edited, added and deleted files in a subdir project, matching sync; edits outside the project left out, with the pathspec asserted; the restore case, across a reopen; the project's own anchored .gitignore rule; an embedded repo inside the project; the gitignored-project fallback; and a repo-root project running exactly today's status command with no prefix call. 6 fail on main's source; the repo-root control passes on both. Four mutations (no pathspec, ignore rules on repo-relative paths, no fallback, no .git shortcut) each fail the test. - CLI repros: after an edit and an added file in app/, status --json reads 0/0/0 on main and 1 added + 1 modified with the fix; the same in a gitignored project; a subdir restore reads 0 on main and 1 modified with the fix (a repo-root project reads 1 on both). - The MCP freshness worker on a subdir project: 0/0/0 on main, 1 added + 1 modified with the fix. Reported in PR #1636 by @innoxxicide. This reimplements the fix independently, on top of the restore check that landed after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
8.4 KiB
TypeScript
191 lines
8.4 KiB
TypeScript
/**
|
|
* 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 });
|
|
}
|
|
});
|
|
});
|