Merge branch 'main' into feat/group-include-extractor

This commit is contained in:
Gergő Magyar 2026-05-08 10:36:59 +01:00 committed by GitHub
commit ed4b738435
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 128 additions and 5 deletions

View file

@ -3,11 +3,14 @@
* Lives in core/ so application code does not depend on the MCP package layer.
*/
import { execFileSync } from 'node:child_process';
import { execFile, execFileSync } from 'node:child_process';
import { promisify } from 'node:util';
import path from 'path';
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
const execFileAsync = promisify(execFile);
export interface StalenessInfo {
isStale: boolean;
commitsBehind: number;
@ -41,6 +44,39 @@ export function checkStaleness(repoPath: string, lastCommit: string): StalenessI
}
}
/**
* Async variant of {@link checkStaleness} spawns git as a child process
* instead of blocking the event loop. Used by `listRepos()` to check many
* repos in parallel (issue #1363: 200 repos × sync spawn 50 s).
*/
export async function checkStalenessAsync(
repoPath: string,
lastCommit: string,
): Promise<StalenessInfo> {
try {
// Note: promisified execFile captures stdout/stderr by default (no stdio option needed,
// unlike the sync variant which requires explicit stdio: ['pipe','pipe','pipe']).
const { stdout } = await execFileAsync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
cwd: repoPath,
encoding: 'utf-8',
});
const commitsBehind = parseInt(stdout.trim(), 10) || 0;
if (commitsBehind > 0) {
return {
isStale: true,
commitsBehind,
hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`,
};
}
return { isStale: false, commitsBehind: 0 };
} catch {
return { isStale: false, commitsBehind: 0 };
}
}
/**
* Compare a sibling-clone HEAD against an indexed `lastCommit`. Returns
* `undefined` when the indexed commit is not reachable from the sibling

View file

@ -40,7 +40,7 @@ import {
isVectorExtensionSupportedByPlatform,
} from '../../core/platform/capabilities.js';
import { PhaseTimer } from '../../core/search/phase-timer.js';
import { checkStaleness, checkCwdMatch } from '../../core/git-staleness.js';
import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
import { logger } from '../../core/logger.js';
// AI context generation is CLI-only (gitnexus analyze)
// import { generateAIContextFiles } from '../../cli/ai-context.js';
@ -554,8 +554,15 @@ export class LocalBackend {
byRemote.set(h.remoteUrl, list);
}
return handles.map((h) => {
const stale = checkStaleness(h.repoPath, h.lastCommit);
// Check staleness for all repos in parallel instead of sequentially.
// Each check spawns an async `git rev-list` — with 200 repos the sync
// variant took ~50 s; parallel async brings it under a second (#1363).
const stalenessResults = await Promise.all(
handles.map((h) => checkStalenessAsync(h.repoPath, h.lastCommit)),
);
return handles.map((h, i) => {
const stale = stalenessResults[i];
const selfNorm = norm(h.repoPath);
const siblings = h.remoteUrl
? (byRemote.get(h.remoteUrl) ?? []).filter((e) => norm(e.repoPath) !== selfNorm)

View file

@ -48,6 +48,7 @@ vi.mock('../../src/storage/repo-manager.js', () => ({
// tests don't shell out to git.
vi.mock('../../src/core/git-staleness.js', () => ({
checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }),
checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }),
checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }),
}));

View file

@ -8,7 +8,7 @@
*/
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'child_process';
import { checkStaleness } from '../../src/core/git-staleness.js';
import { checkStaleness, checkStalenessAsync } from '../../src/core/git-staleness.js';
// We test checkStaleness with a real git repo (the project itself)
// since mocking execFileSync across ESM modules is complex.
@ -65,3 +65,82 @@ describe('checkStaleness', () => {
expect(result.commitsBehind).toBe(0);
});
});
describe('checkStalenessAsync', () => {
it('returns not stale when HEAD matches lastCommit', async () => {
let headCommit: string;
try {
headCommit = execFileSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch {
return;
}
const result = await checkStalenessAsync(process.cwd(), headCommit);
expect(result.isStale).toBe(false);
expect(result.commitsBehind).toBe(0);
expect(result.hint).toBeUndefined();
});
it('returns stale when lastCommit is behind HEAD', async () => {
let previousCommit: string;
try {
previousCommit = execFileSync('git', ['rev-parse', 'HEAD~1'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch {
return;
}
if (!previousCommit) return;
const result = await checkStalenessAsync(process.cwd(), previousCommit);
expect(result.isStale).toBe(true);
expect(result.commitsBehind).toBeGreaterThan(0);
expect(result.hint).toContain('behind HEAD');
});
it('fails open when git command fails (e.g., invalid path)', async () => {
const result = await checkStalenessAsync('/nonexistent/path', 'abc123');
expect(result.isStale).toBe(false);
expect(result.commitsBehind).toBe(0);
});
it('fails open with invalid commit hash', async () => {
const result = await checkStalenessAsync(process.cwd(), 'not-a-real-commit-hash');
expect(result.isStale).toBe(false);
expect(result.commitsBehind).toBe(0);
});
it('parallel calls complete faster than sequential', async () => {
let headCommit: string;
try {
headCommit = execFileSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch {
return;
}
const cwd = process.cwd();
const N = 10;
// Parallel
const t0 = performance.now();
await Promise.all(Array.from({ length: N }, () => checkStalenessAsync(cwd, headCommit)));
const parallelMs = performance.now() - t0;
// Sequential sync
const t1 = performance.now();
for (let i = 0; i < N; i++) checkStaleness(cwd, headCommit);
const sequentialMs = performance.now() - t1;
// Parallel should be meaningfully faster than sequential.
// Use a generous ratio to avoid flakiness on slow CI machines.
expect(parallelMs).toBeLessThan(sequentialMs * 1.5);
});
});