GitNexus/gitnexus/test/unit/group/sync-registry-identity.test.ts
mengkaka 79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* 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>
2026-09-12 20:31:55 +00:00

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