mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* feat(storage): add configurable index storage and content retention tiers Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH, GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in main's FTS skip, embed-session, and help-text updates. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Keep legacy registry rows on the local storage fallback, resolve symlinks before the destructive-path guard, and align hook lookup with CLI branch slugs, branch-slot metadata, and longest-path match. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Only list swept upload directories after a successful removal so callers cannot treat a permission or transient rm failure as gone. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Document that getStoragePath may consult registered storage while this module still does not mutate the global registry. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): close review findings for external indexes and retention Re-inspect ownership under the analyze lock, fail-closed when the registry file is missing, and keep skip-git hook discovery plus retention fields on HTTP/MCP list surfaces. /api/file stays 410 unless contentRetention is full. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * Address PR review feedback (#3060) Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix macOS hook test expecting realpath'd registry paths. resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that. * Address gitnexus-check warnings on hook install docs and slot tests. The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory. * Align the HTTP catalog source-scan with skippable resolveRepo validation. resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true. * Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear. Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time. * Settle bridge stamps before writing so CI size/mtime matches stay stable. LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches. * Type the settled bridge stat as fs.Stats so tsc does not see bigint. Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI. * Keep the bridge mtime stamp exact so same-size swaps still fail the pair check. Co-authored-by: Cursor <cursoragent@cursor.com> * Wrap the bridge stamp predicate so prettier --check stays green. Co-authored-by: Cursor <cursoragent@cursor.com> * Require a quiet interval before stamping a settled bridge file. Co-authored-by: Cursor <cursoragent@cursor.com> * Reuse shared storage and settle helpers instead of local copies. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
301 lines
9.8 KiB
TypeScript
301 lines
9.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import fs from 'node:fs/promises';
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { syncGroup } from '../../../src/core/group/sync.js';
|
|
import { RegistryAmbiguousTargetError } from '../../../src/storage/repo-manager.js';
|
|
import { createTempDir } from '../../helpers/test-db.js';
|
|
import type { GroupConfig } from '../../../src/core/group/types.js';
|
|
import { GroupService } from '../../../src/core/group/service.js';
|
|
import type { GroupToolPort } from '../../../src/core/group/service.js';
|
|
|
|
const initLbugMock = vi.fn(async () => {});
|
|
|
|
vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({
|
|
initLbug: (...args: unknown[]) => initLbugMock(...args),
|
|
executeParameterized: vi.fn(async () => []),
|
|
pinRepo: vi.fn(() => () => {}),
|
|
getMaxResidentRepos: vi.fn(() => 5),
|
|
}));
|
|
|
|
const makeConfig = (repos: Record<string, string>, extra?: Partial<GroupConfig>): GroupConfig => ({
|
|
version: 1,
|
|
name: 'test',
|
|
description: '',
|
|
repos,
|
|
links: [],
|
|
packages: {},
|
|
detect: {
|
|
http: false,
|
|
graphql: false,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
includes: false,
|
|
workspace_deps: false,
|
|
},
|
|
matching: {},
|
|
...extra,
|
|
});
|
|
|
|
const row = (
|
|
tmpHome: string,
|
|
name: string,
|
|
clone: string,
|
|
): {
|
|
name: string;
|
|
path: string;
|
|
storagePath: string;
|
|
indexedAt: string;
|
|
lastCommit: string;
|
|
} => {
|
|
const repoPath = path.join(tmpHome, 'repos', clone);
|
|
const storagePath = path.join(repoPath, '.gitnexus');
|
|
mkdirSync(path.join(storagePath, 'lbug'), { recursive: true });
|
|
writeFileSync(
|
|
path.join(storagePath, 'gitnexus.json'),
|
|
JSON.stringify({
|
|
repoPath,
|
|
storagePath,
|
|
indexedAt: '2026-01-01T00:00:00.000Z',
|
|
lastCommit: 'abc123',
|
|
}),
|
|
);
|
|
return {
|
|
name,
|
|
path: repoPath,
|
|
storagePath,
|
|
indexedAt: '2026-01-01T00:00:00.000Z',
|
|
lastCommit: 'abc123',
|
|
};
|
|
};
|
|
|
|
describe('syncGroup registry name identity', () => {
|
|
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
|
let savedGitnexusHome: string | undefined;
|
|
let registryPath: string;
|
|
|
|
beforeEach(async () => {
|
|
initLbugMock.mockReset();
|
|
initLbugMock.mockResolvedValue(undefined);
|
|
tmpHome = await createTempDir('gitnexus-sync-registry-id-');
|
|
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
|
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
|
registryPath = path.join(tmpHome.dbPath, 'registry.json');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
|
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
|
await tmpHome.cleanup();
|
|
});
|
|
|
|
it('throws RegistryAmbiguousTargetError and does not rewrite group dir files', async () => {
|
|
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
|
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
|
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
|
|
|
const groupDir = path.join(tmpHome.dbPath, 'groups', 'g');
|
|
await fs.mkdir(groupDir, { recursive: true });
|
|
const contractsPath = path.join(groupDir, 'contracts.json');
|
|
const prior = '{"contracts":[],"crossLinks":[],"marker":"keep"}\n';
|
|
await fs.writeFile(contractsPath, prior);
|
|
|
|
await expect(syncGroup(makeConfig({ 'demo/api': 'demo-api' }), { groupDir })).rejects.toSatisfy(
|
|
(err: unknown) => {
|
|
expect(err).toBeInstanceOf(RegistryAmbiguousTargetError);
|
|
const amb = err as RegistryAmbiguousTargetError;
|
|
expect(amb.matches).toHaveLength(2);
|
|
expect(amb.matches.map((m) => m.path).sort()).toEqual([a.path, b.path].sort());
|
|
return true;
|
|
},
|
|
);
|
|
|
|
expect(await fs.readFile(contractsPath, 'utf-8')).toBe(prior);
|
|
await expect(fs.access(path.join(groupDir, 'bridge.lbug'))).rejects.toThrow();
|
|
});
|
|
|
|
it('records an unknown yaml value as missing and still extracts other members', async () => {
|
|
const known = row(tmpHome.dbPath, 'backend-repo', 'backend');
|
|
await fs.writeFile(registryPath, JSON.stringify([known]));
|
|
|
|
const result = await syncGroup(
|
|
makeConfig({ 'app/backend': 'backend-repo', 'app/ghost': 'ghost' }),
|
|
{ skipWrite: true },
|
|
);
|
|
|
|
expect(result.missingRepos).toEqual(['app/ghost']);
|
|
expect(result.unreadableRepos).toEqual([]);
|
|
expect(result.repoSnapshots['app/backend']).toEqual({
|
|
indexedAt: known.indexedAt,
|
|
lastCommit: known.lastCommit,
|
|
});
|
|
});
|
|
|
|
it('treats a registry member with foreign storage metadata as unreadable', async () => {
|
|
const known = row(tmpHome.dbPath, 'backend-repo', 'backend');
|
|
await fs.writeFile(
|
|
path.join(known.storagePath, 'gitnexus.json'),
|
|
JSON.stringify({ repoPath: path.join(tmpHome.dbPath, 'repos', 'other') }),
|
|
);
|
|
await fs.writeFile(registryPath, JSON.stringify([known]));
|
|
|
|
const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), {
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.missingRepos).toEqual([]);
|
|
expect(result.unreadableRepos).toEqual(['app/backend']);
|
|
expect(initLbugMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('treats mixed missing and ambiguous names as a terminal ambiguity with no write', async () => {
|
|
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
|
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
|
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
|
|
|
const groupDir = path.join(tmpHome.dbPath, 'groups', 'g');
|
|
await fs.mkdir(groupDir, { recursive: true });
|
|
const contractsPath = path.join(groupDir, 'contracts.json');
|
|
await fs.writeFile(contractsPath, '{"keep":true}');
|
|
|
|
await expect(
|
|
syncGroup(makeConfig({ 'demo/api': 'demo-api', 'app/ghost': 'ghost' }), { groupDir }),
|
|
).rejects.toBeInstanceOf(RegistryAmbiguousTargetError);
|
|
|
|
expect(await fs.readFile(contractsPath, 'utf-8')).toBe('{"keep":true}');
|
|
});
|
|
|
|
it('injected resolveRepoHandle still bypasses default name matching', async () => {
|
|
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
|
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
|
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
|
|
|
const result = await syncGroup(makeConfig({ 'demo/api': 'demo-api' }), {
|
|
skipWrite: true,
|
|
resolveRepoHandle: async (_name, groupPath) => ({
|
|
id: 'injected',
|
|
path: groupPath,
|
|
repoPath: a.path,
|
|
storagePath: a.storagePath,
|
|
}),
|
|
});
|
|
|
|
expect(result.missingRepos).toEqual([]);
|
|
expect(result.unreadableRepos).toEqual([]);
|
|
});
|
|
|
|
it('does not treat a filesystem path yaml value as a registry hit', async () => {
|
|
const known = row(tmpHome.dbPath, 'backend-repo', 'backend');
|
|
await fs.writeFile(registryPath, JSON.stringify([known]));
|
|
|
|
const result = await syncGroup(makeConfig({ 'app/backend': known.path }), { skipWrite: true });
|
|
|
|
expect(result.missingRepos).toEqual(['app/backend']);
|
|
expect(result.repoSnapshots['app/backend']).toBeUndefined();
|
|
});
|
|
|
|
it('injected resolveRepoHandle plus workspace_deps does not throw on duplicate names', async () => {
|
|
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
|
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
|
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
|
|
|
const result = await syncGroup(
|
|
makeConfig(
|
|
{ 'demo/api': 'demo-api' },
|
|
{
|
|
detect: {
|
|
http: false,
|
|
graphql: false,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
includes: false,
|
|
workspace_deps: true,
|
|
},
|
|
},
|
|
),
|
|
{
|
|
skipWrite: true,
|
|
resolveRepoHandle: async (_name, groupPath) => ({
|
|
id: 'injected',
|
|
path: groupPath,
|
|
repoPath: a.path,
|
|
storagePath: a.storagePath,
|
|
}),
|
|
},
|
|
);
|
|
|
|
expect(result.missingRepos).toEqual([]);
|
|
});
|
|
|
|
it('injected resolveRepoHandle plus workspace_deps still bypasses name lookup after extraction failure', async () => {
|
|
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
|
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
|
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
|
initLbugMock.mockRejectedValueOnce(new Error('init failed'));
|
|
|
|
const result = await syncGroup(
|
|
makeConfig(
|
|
{ 'demo/api': 'demo-api' },
|
|
{
|
|
detect: {
|
|
http: false,
|
|
graphql: false,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
includes: false,
|
|
workspace_deps: true,
|
|
},
|
|
},
|
|
),
|
|
{
|
|
skipWrite: true,
|
|
resolveRepoHandle: async (_name, groupPath) => ({
|
|
id: 'injected',
|
|
path: groupPath,
|
|
repoPath: a.path,
|
|
storagePath: a.storagePath,
|
|
}),
|
|
},
|
|
);
|
|
|
|
expect(result.missingRepos).toEqual([]);
|
|
expect(result.unreadableRepos).toEqual(['demo/api']);
|
|
});
|
|
|
|
it('MCP groupSync returns { error } for an ambiguous registry name', async () => {
|
|
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
|
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
|
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
|
|
|
const groupDir = path.join(tmpHome.dbPath, 'groups', 'g1');
|
|
await fs.mkdir(groupDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(groupDir, 'group.yaml'),
|
|
`version: 1
|
|
name: g1
|
|
repos:
|
|
demo/api: demo-api
|
|
`,
|
|
);
|
|
|
|
const port: GroupToolPort = {
|
|
resolveRepo: vi.fn(),
|
|
impact: vi.fn(),
|
|
query: vi.fn(),
|
|
impactByUid: vi.fn(),
|
|
context: vi.fn(),
|
|
};
|
|
const svc = new GroupService(port);
|
|
const payload = (await svc.groupSync({ name: 'g1' })) as { error?: string };
|
|
|
|
expect(payload.error).toBeDefined();
|
|
expect(payload.error).toContain('demo-api');
|
|
expect(payload.error).toContain(a.path);
|
|
expect(payload.error).toContain(b.path);
|
|
expect(payload.error).toMatch(/unique registry name/i);
|
|
expect(payload.error).not.toMatch(/Pass the absolute path/);
|
|
});
|
|
});
|