mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
Merge branch 'main' into feat/group-include-extractor
This commit is contained in:
commit
ed4b738435
4 changed files with 128 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue