Files
codegraph/__tests__/git-status-subdir-project.test.ts
T
Colby MchenryandClaude Opus 5.5 1cb4aab913 fix(status): a project below its repository root sees its uncommitted changes (#2932)
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>
2026-10-27 18:56:18 +00:00

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 });
}
});
});