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>
1271 lines
56 KiB
TypeScript
1271 lines
56 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import fsp from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { _captureLogger } from '../../../src/core/logger.js';
|
|
import type { BridgeHandle, GroupConfig, RepoHandle } from '../../../src/core/group/types.js';
|
|
import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js';
|
|
import { makeGroupToolPort, writeGroupYaml } from './fixtures.js';
|
|
|
|
/**
|
|
* A repo that is registered but whose index cannot be opened must not be
|
|
* reported as a MISSING repo, and must not silently replace a good
|
|
* contracts.json with an empty one.
|
|
*
|
|
* The failure this pins: `syncGroup` wrapped `initLbug` + extraction in a bare
|
|
* `catch {}` that pushed the repo onto `missingRepos` and discarded the error.
|
|
* A LadybugDB storage-version mismatch therefore surfaced as "repo not found",
|
|
* `group sync` printed `0 contracts, 0 cross-links` and exited 0, and the
|
|
* existing registry was overwritten with an empty one.
|
|
*
|
|
* Three of these cases exist because mutation testing showed the original four
|
|
* could not see the change they were named after:
|
|
* - a two-repo case, because with exactly one configured repo
|
|
* `unreadableRepos.length === configuredRepoCount` holds whenever anything
|
|
* fails, so deleting the `=== configuredRepoCount` conjunct — turning "every
|
|
* repo failed" into "any repo failed" — passed everything;
|
|
* - an all-missing case, because deleting the `unreadableRepos.length > 0`
|
|
* conjunct was caught only by a 9.7 s integration test in another directory;
|
|
* - a log assertion, because deleting both `logger.warn` calls — the entire
|
|
* stated purpose of the change — passed everything too.
|
|
*/
|
|
|
|
const LBUG_VERSION_ERROR =
|
|
'LadybugDB unavailable for backend-repo. Another process may be rebuilding the index. ' +
|
|
'Retry later. (Runtime exception: Trying to read a database file with a different version. ' +
|
|
'Database file version: 43, Current build storage version: 40)';
|
|
|
|
const initLbugMock = vi.fn();
|
|
const readRegistryStrictMock = vi.fn();
|
|
|
|
/**
|
|
* A SEPARATE mock from the strict one, and that separation is the whole point.
|
|
*
|
|
* Both exports used to resolve to one mock, so the refuses-to-sync case below —
|
|
* which drives the read by rejecting — got the same rejection whichever export
|
|
* `syncGroup` called. It would have passed identically against the lenient read
|
|
* it exists to rule out, which is to say it measured nothing about which read is
|
|
* used.
|
|
*
|
|
* The implementation here is the lenient export's real contract: `readRegistry`
|
|
* swallows EACCES and a corrupt file alike and answers `[]`. Pointing
|
|
* `syncGroup` at it therefore turns an unreadable registry back into "no repo is
|
|
* registered" — every configured repo MISSING, the total-failure guard off, a
|
|
* good contracts.json replaced by an empty one at exit 0 — and the case goes
|
|
* red. On this path production reaches the lenient export only under
|
|
* `detect.workspace_deps`, which `makeConfig` leaves off, so no other case in
|
|
* this file can see the split.
|
|
*/
|
|
const readRegistryLenientMock = vi.fn(async (..._args: unknown[]): Promise<never[]> => []);
|
|
|
|
vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({
|
|
initLbug: (...args: unknown[]) => initLbugMock(...args),
|
|
executeParameterized: vi.fn(async () => []),
|
|
pinRepo: vi.fn(() => () => {}),
|
|
getMaxResidentRepos: vi.fn(() => 5),
|
|
}));
|
|
|
|
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
|
|
return {
|
|
...actual,
|
|
readRegistry: (...args: unknown[]) => readRegistryLenientMock(...args),
|
|
readRegistryStrict: (...args: unknown[]) => readRegistryStrictMock(...args),
|
|
};
|
|
});
|
|
|
|
// This file focuses on failures from `initLbug` and extraction after a member
|
|
// has resolved. Keep its deliberately synthetic registry paths from being
|
|
// intercepted by the production storage inspection; the real registry-backed
|
|
// storage gate is covered by sync-registry-identity.test.ts.
|
|
vi.mock('../../../src/storage/storage-resolver.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../src/storage/storage-resolver.js')>();
|
|
return {
|
|
...actual,
|
|
requireRegisteredStoragePath: vi.fn(
|
|
async (entry: { storagePath: string }) => entry.storagePath,
|
|
),
|
|
};
|
|
});
|
|
|
|
/**
|
|
* Armed by the bridge-write-failure suite at the bottom of this file, `null`
|
|
* everywhere else. There is no filesystem shape that makes the real writer fail
|
|
* while `writeContractRegistry` — same directory, one line earlier in
|
|
* `syncGroup` — still succeeds, and that ordering is the whole subject of the
|
|
* warning under test.
|
|
*/
|
|
let writeBridgeFailure: Error | null = null;
|
|
|
|
/**
|
|
* Only the read-only OPEN legs are stubbed, so `runGroupImpact` can read the
|
|
* metadata a preserve sync just wrote without a native LadybugDB open of a
|
|
* placeholder file. The bridge write, `writeBridgeMeta`, `readBridgeMeta` and
|
|
* `bridgeMetaMatchesFile` all travel their real implementations — they are the
|
|
* code under test here, and `syncGroup` reaches the bridge write through this
|
|
* module too. The wrapper below is a pass-through in every test that does not
|
|
* arm `writeBridgeFailure`.
|
|
*
|
|
* It intercepts `writeBridgeUnlocked`, NOT the exported `writeBridge`: the swap
|
|
* comes in two halves, and `syncGroup` calls the lock-free one because it is
|
|
* already inside `withGroupSyncLock` (a second acquisition of a non-reentrant
|
|
* lock would hang every sync). Arming the acquiring wrapper instead would inject
|
|
* a fault into a function this path never calls, and the failure branch below
|
|
* would go quietly untested.
|
|
*/
|
|
vi.mock('../../../src/core/group/bridge-db.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../src/core/group/bridge-db.js')>();
|
|
return {
|
|
...actual,
|
|
writeBridgeUnlocked: vi.fn(async (...args: Parameters<typeof actual.writeBridgeUnlocked>) => {
|
|
if (writeBridgeFailure) throw writeBridgeFailure;
|
|
return actual.writeBridgeUnlocked(...args);
|
|
}),
|
|
getCachedBridgeReadOnly: vi.fn(
|
|
async (groupDir: string) =>
|
|
({ _db: {}, _conn: {}, groupDir, _readOnly: true }) as BridgeHandle,
|
|
),
|
|
queryBridge: vi.fn(async () => [] as Array<Record<string, unknown>>),
|
|
closeBridgeDb: vi.fn(async () => undefined),
|
|
};
|
|
});
|
|
|
|
/**
|
|
* Armed by the concurrent-sync suite at the bottom of this file, `null`
|
|
* everywhere else. It runs INSIDE the real group sync lock — after this sync
|
|
* acquired it, before its persist section starts — which is the one window in
|
|
* which another sync's write can land: extraction runs OUTSIDE the lock, so a
|
|
* sync that queued behind a winner is holding stats it took before the winner
|
|
* ever wrote. Nothing in-process can reach that window otherwise, and a rare
|
|
* interleave is not a test.
|
|
*/
|
|
let whileWaitingForTheGroupLock: (() => Promise<void>) | null = null;
|
|
|
|
/**
|
|
* A pass-through in every test that does not arm the hook: the REAL lock is
|
|
* acquired, on the real `<groupDir>/sync-lock`, exactly as production does.
|
|
*/
|
|
vi.mock('../../../src/core/group/group-lock.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../src/core/group/group-lock.js')>();
|
|
return {
|
|
...actual,
|
|
withGroupSyncLock: <T>(groupDir: string, operation: () => Promise<T>): Promise<T> =>
|
|
actual.withGroupSyncLock(groupDir, async () => {
|
|
const hook = whileWaitingForTheGroupLock;
|
|
whileWaitingForTheGroupLock = null;
|
|
if (hook) await hook();
|
|
return operation();
|
|
}),
|
|
};
|
|
});
|
|
|
|
const { syncGroup } = await import('../../../src/core/group/sync.js');
|
|
const { runGroupImpact } = await import('../../../src/core/group/cross-impact.js');
|
|
const { bridgeMetaMatchesFile, closeAllCachedBridges, readBridgeMeta, writeBridgeMeta } =
|
|
await import('../../../src/core/group/bridge-db.js');
|
|
|
|
const registryEntry = (name: string, dir: string) => ({
|
|
name,
|
|
path: `/repos/${dir}`,
|
|
storagePath: `/repos/${dir}/.gitnexus`,
|
|
indexedAt: '2026-01-01T00:00:00.000Z',
|
|
lastCommit: 'abc123',
|
|
});
|
|
|
|
const REGISTRY = [registryEntry('backend-repo', 'backend'), registryEntry('web-repo', 'web')];
|
|
|
|
const makeConfig = (repos: Record<string, string>): GroupConfig => ({
|
|
version: 1,
|
|
name: 'test',
|
|
description: '',
|
|
repos,
|
|
links: [],
|
|
packages: {},
|
|
detect: {
|
|
http: true,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
includes: false,
|
|
workspace_deps: false,
|
|
},
|
|
matching: {},
|
|
});
|
|
|
|
/**
|
|
* Resolve handles from a table keyed on the registry name, so a multi-repo case
|
|
* needs no branching inside the test body. An unknown name resolves to `null`,
|
|
* which is the production "not in the registry" answer.
|
|
*/
|
|
const handleTable = (names: readonly string[]) => {
|
|
const byName = new Map<string, RepoHandle>(
|
|
names.map((name) => [
|
|
name,
|
|
{
|
|
id: `pool-${name}`,
|
|
path: `/repos/${name}`,
|
|
repoPath: `/repos/${name}`,
|
|
storagePath: `/repos/${name}/.gitnexus`,
|
|
},
|
|
]),
|
|
);
|
|
return async (registryName: string): Promise<RepoHandle | null> =>
|
|
byName.get(registryName) ?? null;
|
|
};
|
|
|
|
/** `initLbug` is called with the pool id, so failures can be keyed on the repo. */
|
|
const failInitFor = (failingPoolIds: ReadonlySet<string>) => async (poolId: unknown) => {
|
|
if (failingPoolIds.has(String(poolId))) throw new Error(LBUG_VERSION_ERROR);
|
|
};
|
|
|
|
const PRIOR_REGISTRY = {
|
|
version: 1,
|
|
generatedAt: '2026-01-01T00:00:00.000Z',
|
|
repoSnapshots: {},
|
|
missingRepos: [],
|
|
contracts: [{ contractId: 'http::GET::/api/users' }],
|
|
crossLinks: [{ contractId: 'http::GET::/api/users' }],
|
|
};
|
|
|
|
/**
|
|
* The one warning that describes the RUN rather than a single repo: it is the
|
|
* only record carrying the whole-run repo lists. The per-repo load failures
|
|
* logged beside it carry `repo` / `groupPath` instead, so selecting on the list
|
|
* field cannot pick one of those up by accident.
|
|
*/
|
|
const totalFailureWarning = (cap: ReturnType<typeof _captureLogger>) =>
|
|
cap.records().find((r) => r.level === 40 && Array.isArray(r.unreadableRepos));
|
|
|
|
/**
|
|
* Read a file's bytes and its stat through ONE open handle.
|
|
*
|
|
* `stat(path)` followed by `readFile(path)` is two independent path
|
|
* resolutions with a window between them — a real check-then-use race, and one
|
|
* CodeQL flags as `js/file-system-race`. It also makes the assertion weaker
|
|
* than it reads: the two calls can land on different inodes, so "the bytes and
|
|
* the mtime are both unchanged" would not actually be a statement about one
|
|
* file. Since these tests exist to prove a specific file was left alone, that
|
|
* distinction is the whole point rather than a technicality.
|
|
*
|
|
* One handle, both answers, no second lookup.
|
|
*/
|
|
const snapshotFile = async (
|
|
filePath: string,
|
|
): Promise<{ text: string; size: number; mtimeMs: number }> => {
|
|
const handle = await fsp.open(filePath, 'r');
|
|
try {
|
|
const [bytes, stat] = await Promise.all([handle.readFile(), handle.stat()]);
|
|
return { text: bytes.toString('utf8'), size: stat.size, mtimeMs: stat.mtimeMs };
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
};
|
|
|
|
describe('syncGroup with an unreadable index', () => {
|
|
let groupDir: string;
|
|
|
|
beforeEach(() => {
|
|
initLbugMock.mockReset();
|
|
readRegistryStrictMock.mockReset();
|
|
readRegistryStrictMock.mockResolvedValue(REGISTRY);
|
|
// `mockClear`, not `mockReset`: the lenient answer IS its implementation
|
|
// (see its declaration), so resetting would erase the very behaviour that
|
|
// makes calling it distinguishable from calling the strict one.
|
|
readRegistryLenientMock.mockClear();
|
|
groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-unreadable-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(groupDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('reports an unopenable index as unreadable, not missing', async () => {
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
|
|
const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), {
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.unreadableRepos).toEqual(['app/backend']);
|
|
expect(result.failedRepos).toEqual([{ repo: 'app/backend', reason: LBUG_VERSION_ERROR }]);
|
|
// The repo IS registered — calling it "missing" sends the operator to
|
|
// `gitnexus analyze` for a problem that indexing will not fix.
|
|
expect(result.missingRepos).toEqual([]);
|
|
});
|
|
|
|
it('still reports a genuinely unregistered repo as missing', async () => {
|
|
const result = await syncGroup(makeConfig({ 'app/ghost': 'not-in-registry' }), {
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.missingRepos).toEqual(['app/ghost']);
|
|
expect(result.unreadableRepos).toEqual([]);
|
|
});
|
|
|
|
it('logs the underlying load error, with the repo it belongs to', async () => {
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
const cap = _captureLogger();
|
|
|
|
try {
|
|
await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { skipWrite: true });
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
|
|
// The whole point of the change is that this error reaches the operator.
|
|
// Asserting only on `unreadableRepos` left both `logger.warn` calls
|
|
// deletable with every test still green.
|
|
const warnings = cap.records().filter((r) => r.level === 40);
|
|
const loadFailure = warnings.find((r) => String(r.repo ?? '') === 'backend-repo');
|
|
|
|
expect(loadFailure).toBeDefined();
|
|
expect(String(loadFailure?.groupPath)).toBe('app/backend');
|
|
expect(JSON.stringify(loadFailure?.err)).toContain('Current build storage version');
|
|
});
|
|
|
|
it('preserves the previous contracts and refreshes the diagnostics when nothing could be read', async () => {
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
|
|
const contractsPath = path.join(groupDir, 'contracts.json');
|
|
fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY));
|
|
|
|
const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir });
|
|
|
|
expect(result.unreadableRepos).toEqual(['app/backend']);
|
|
expect(result.registryOutcome).toBe('preserved');
|
|
|
|
const onDisk = JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record<string, unknown>;
|
|
// The contracts are the previous run's and are kept verbatim — an
|
|
// extraction that read nothing is not evidence that the group has none.
|
|
expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts);
|
|
expect(onDisk.crossLinks).toEqual(PRIOR_REGISTRY.crossLinks);
|
|
// `generatedAt` dates the contracts, which did not change, so it does not
|
|
// move either — otherwise `group status` would claim this run produced them.
|
|
expect(onDisk.generatedAt).toBe(PRIOR_REGISTRY.generatedAt);
|
|
// ...but the diagnostic describing THIS run is refreshed, which is what
|
|
// makes `gitnexus group status` able to explain the failure afterwards.
|
|
expect(onDisk.unreadableRepos).toEqual(['app/backend']);
|
|
});
|
|
|
|
it('keeps the prior suppressedMatchStages instead of stamping this run request', async () => {
|
|
// The preserved registry describes an EARLIER sync. If this run's request
|
|
// were stamped onto it, a graph narrowed by `--exact-only` would be
|
|
// relabelled complete the moment a later plain sync failed to read
|
|
// anything — and `group_impact` reads exactly that field to decide whether
|
|
// its answer is a floor.
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
|
|
const contractsPath = path.join(groupDir, 'contracts.json');
|
|
fs.writeFileSync(
|
|
contractsPath,
|
|
JSON.stringify({ ...PRIOR_REGISTRY, suppressedMatchStages: ['wildcard'] }),
|
|
);
|
|
|
|
// This run asks for NO suppression, and fails to read anything.
|
|
const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir });
|
|
|
|
expect(result.registryOutcome).toBe('preserved');
|
|
// The result describes THIS run — it really did suppress nothing.
|
|
expect(result.suppressedMatchStages).toEqual([]);
|
|
|
|
const onDisk = JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record<string, unknown>;
|
|
// The file still describes the sync that produced its contracts.
|
|
expect(onDisk.suppressedMatchStages).toEqual(['wildcard']);
|
|
});
|
|
|
|
it('does not stamp this run request onto the preserved bridge metadata', async () => {
|
|
// Same property one artifact over. `bridge.lbug` is untouched on this path,
|
|
// so its meta.json must keep describing the sync that built it; otherwise
|
|
// contracts.json, meta.json and the database describe three different runs.
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
|
|
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY));
|
|
await writeBridgeMeta(groupDir, {
|
|
version: 1,
|
|
generatedAt: '2026-01-01T00:00:00.000Z',
|
|
missingRepos: [],
|
|
unreadableRepos: [],
|
|
suppressedMatchStages: ['wildcard'],
|
|
});
|
|
|
|
await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir });
|
|
|
|
const meta = await readBridgeMeta(groupDir);
|
|
expect(meta.suppressedMatchStages).toEqual(['wildcard']);
|
|
// ...while the diagnostics describing THIS run are refreshed, as before.
|
|
expect(meta.unreadableRepos).toEqual(['app/backend']);
|
|
});
|
|
|
|
it('writes nothing at all when there is no previous registry to preserve', async () => {
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
|
|
const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir });
|
|
|
|
// NOT `preserved`. Nothing exists to preserve, and the CLI turns that word
|
|
// into "the contracts from the previous sync are preserved" — which sends
|
|
// an operator whose group has never synced looking for a file that has
|
|
// never existed. Same class of confident-wrong-answer as the rest of this.
|
|
expect(result.registryOutcome).toBe('no-prior-registry');
|
|
expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(false);
|
|
});
|
|
|
|
it('reports `preserved` only when a prior registry was actually refreshed', async () => {
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY));
|
|
|
|
const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir });
|
|
|
|
expect(result.registryOutcome).toBe('preserved');
|
|
});
|
|
|
|
it('does not report `preserved` when the prior registry will not parse', async () => {
|
|
// An unparseable prior is not a thing that got carried forward either.
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
fs.writeFileSync(path.join(groupDir, 'contracts.json'), '{"truncated": ');
|
|
|
|
const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir });
|
|
|
|
expect(result.registryOutcome).toBe('no-prior-registry');
|
|
// ...and the unparseable file is left exactly as it was, not replaced.
|
|
expect(fs.readFileSync(path.join(groupDir, 'contracts.json'), 'utf8')).toBe('{"truncated": ');
|
|
});
|
|
|
|
it('names the previous sync in the total-failure warning when a prior registry was kept', async () => {
|
|
// This warning used to be emitted BEFORE the prior registry was resolved,
|
|
// so it promised "the contracts from the previous sync" without knowing
|
|
// whether there were any. This is the branch on which that promise is true.
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY));
|
|
const cap = _captureLogger();
|
|
|
|
let result;
|
|
try {
|
|
result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir });
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
|
|
const warning = totalFailureWarning(cap);
|
|
expect(warning).toBeDefined();
|
|
// The log and the console say the same thing about which of the two
|
|
// happened: the CLI picks its sentence from `registryOutcome`, and this is
|
|
// the outcome whose sentence keeps the previous contracts.
|
|
expect(result.registryOutcome).toBe('preserved');
|
|
expect(String(warning?.msg)).toContain('previous sync');
|
|
// Still a warning, still carrying the lists that name the cause.
|
|
expect(warning?.level).toBe(40);
|
|
expect(warning?.unreadableRepos).toEqual(['app/backend']);
|
|
expect(warning?.missingRepos).toEqual([]);
|
|
});
|
|
|
|
it('does not claim anything was preserved when there is no prior registry', async () => {
|
|
// The same total failure with nothing on disk to preserve. The warning said
|
|
// the contracts from the previous sync were being kept — to an operator
|
|
// whose group has never synced, about a file that has never existed, while
|
|
// the console line for this same run says the opposite. What the message
|
|
// says about disk has to be what happened on it.
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
const cap = _captureLogger();
|
|
|
|
let result;
|
|
try {
|
|
result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir });
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
|
|
const warning = totalFailureWarning(cap);
|
|
expect(warning).toBeDefined();
|
|
expect(result.registryOutcome).toBe('no-prior-registry');
|
|
expect(String(warning?.msg)).not.toMatch(/previous sync|preserv|keeping|kept/i);
|
|
expect(String(warning?.msg)).toContain('no previous contracts.json');
|
|
// ...and it is still a warning carrying the same lists as the branch above.
|
|
expect(warning?.level).toBe(40);
|
|
expect(warning?.unreadableRepos).toEqual(['app/backend']);
|
|
expect(warning?.missingRepos).toEqual([]);
|
|
});
|
|
|
|
it('still writes when only SOME configured repos are unreadable', async () => {
|
|
// The case that pins the word "every" in `everyRepoFailed`. With a single
|
|
// configured repo, "every repo failed" and "any repo failed" are the same
|
|
// predicate, so the guard could be widened to abort on a single skewed repo
|
|
// in a five-repo group — silently freezing contracts.json forever — with
|
|
// nothing going red.
|
|
initLbugMock.mockImplementation(failInitFor(new Set(['pool-backend-repo'])));
|
|
|
|
const result = await syncGroup(
|
|
makeConfig({ 'app/backend': 'backend-repo', 'app/web': 'web-repo' }),
|
|
{ groupDir, resolveRepoHandle: handleTable(['backend-repo', 'web-repo']) },
|
|
);
|
|
|
|
expect(result.unreadableRepos).toEqual(['app/backend']);
|
|
expect(result.missingRepos).toEqual([]);
|
|
expect(result.registryOutcome).toBe('written');
|
|
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(groupDir, 'contracts.json'), 'utf8'),
|
|
) as Record<string, unknown>;
|
|
// The partial result records which repo is unaccounted for, so a reader of
|
|
// contracts.json can tell a small registry from a complete one.
|
|
expect(onDisk.unreadableRepos).toEqual(['app/backend']);
|
|
});
|
|
|
|
it('records an empty unreadable list on a clean sync, not an absent one', async () => {
|
|
// `[]` is a measurement — "this sync accounted for every repo" — and it is
|
|
// a different claim from a registry that never recorded the field. Omitting
|
|
// the empty case made that state unreachable: every clean sync wrote a
|
|
// registry whose `unreadableRepos` was absent, so `gitnexus group status`
|
|
// reported it as not recorded and told the operator to re-run the sync that
|
|
// had just succeeded.
|
|
const result = await syncGroup(
|
|
makeConfig({ 'app/backend': 'backend-repo', 'app/web': 'web-repo' }),
|
|
{ groupDir, resolveRepoHandle: handleTable(['backend-repo', 'web-repo']) },
|
|
);
|
|
|
|
expect(result.unreadableRepos).toEqual([]);
|
|
expect(result.registryOutcome).toBe('written');
|
|
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(groupDir, 'contracts.json'), 'utf8'),
|
|
) as Record<string, unknown>;
|
|
expect(onDisk).toHaveProperty('unreadableRepos');
|
|
expect(onDisk.unreadableRepos).toEqual([]);
|
|
});
|
|
|
|
it('still writes when every repo is merely MISSING and none failed to load', async () => {
|
|
// A group whose repos were all deregistered legitimately syncs to empty.
|
|
// The guard must stay off here: it is gated on a load ERROR, not on an
|
|
// empty result. Dropping the `unreadableRepos.length > 0` conjunct would
|
|
// turn a deliberate deregistration into a registry frozen forever.
|
|
const result = await syncGroup(
|
|
makeConfig({ 'app/ghost': 'not-in-registry', 'app/phantom': 'also-absent' }),
|
|
{ groupDir },
|
|
);
|
|
|
|
expect(result.unreadableRepos).toEqual([]);
|
|
expect(result.missingRepos).toEqual(['app/ghost', 'app/phantom']);
|
|
expect(result.registryOutcome).toBe('written');
|
|
expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(true);
|
|
});
|
|
|
|
it('does not claim to preserve a file on a dry run', async () => {
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
const cap = _captureLogger();
|
|
|
|
let result;
|
|
try {
|
|
result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { skipWrite: true });
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
|
|
expect(result.registryOutcome).toBe('not-attempted');
|
|
// The total-failure warning talks about an existing contracts.json. A
|
|
// caller that asked not to write may not even have a group directory, so
|
|
// telling it the file was left untouched describes a file that need not
|
|
// exist.
|
|
// Selected on the sentence both total-failure messages share, so this stays
|
|
// decisive whichever of the two the persisting path would have emitted.
|
|
const totalFailureWarnings = cap
|
|
.records()
|
|
.filter((r) => String(r.msg ?? '').includes('No repo in this group could be read'));
|
|
expect(totalFailureWarnings).toEqual([]);
|
|
});
|
|
|
|
it('refuses to sync when the global registry cannot be read', async () => {
|
|
// `readRegistry` swallows every failure and returns `[]`, so an EACCES or a
|
|
// truncated registry.json presented as "no repo is registered": every
|
|
// configured repo resolved to MISSING, the total-failure guard stayed off
|
|
// (it needs a load error), and a good contracts.json was replaced by an
|
|
// empty one at exit 0. That is an unreadable condition reported as missing,
|
|
// one frame above the code this change fixes.
|
|
//
|
|
// Only the STRICT export is armed to reject. The lenient one is a separate
|
|
// mock answering `[]` — production's own lenient behaviour — so a `syncGroup`
|
|
// reading through it never sees this failure at all: it would sync a group
|
|
// whose every repo is "unregistered" and overwrite the prior registry, which
|
|
// is what makes each of the three assertions below a statement about which
|
|
// read was used rather than about EACCES.
|
|
const eacces = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
|
readRegistryStrictMock.mockRejectedValue(eacces);
|
|
|
|
const contractsPath = path.join(groupDir, 'contracts.json');
|
|
fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY));
|
|
|
|
await expect(
|
|
syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }),
|
|
).rejects.toThrow('EACCES');
|
|
|
|
expect(JSON.parse(fs.readFileSync(contractsPath, 'utf8'))).toEqual(PRIOR_REGISTRY);
|
|
// The direct form of the same claim, so a regression names itself instead of
|
|
// arriving as "expected a rejection, got a resolved sync".
|
|
expect(readRegistryLenientMock).not.toHaveBeenCalled();
|
|
expect(readRegistryStrictMock).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The preserve path rewrites `contracts.json` and deliberately does NOT rebuild
|
|
* `bridge.lbug` — the contracts that bridge holds are the ones being preserved,
|
|
* so rebuilding it from an extraction that read nothing is the one write that
|
|
* could lose them.
|
|
*
|
|
* But `meta.json`, not `contracts.json`, is where `runGroupImpact` reads
|
|
* completeness from. Leaving it alone therefore left the two files telling
|
|
* different stories: `contracts.json` said "this sync could not read app/backend"
|
|
* while a cross-repo query, reading the previous sync's metadata, answered
|
|
* `{ cross: [], truncated: false }` — fully accounted for. That is a confident
|
|
* wrong answer about the exact thing the completeness channel exists to make
|
|
* legible, and it is what R6 forbids.
|
|
*
|
|
* Refreshing the metadata is not free, though, and the naive version of it is
|
|
* worse than the bug. This file rewrites `meta.json` ATOMICALLY, so its mtime
|
|
* becomes now while `bridge.lbug`'s stays old — which is precisely the shape
|
|
* the unstamped write-order rule ACCEPTS. A refresh that just carried the old
|
|
* fields forward would therefore LAUNDER a pair that was already broken into
|
|
* one that passes `bridgeMetaMatchesFile`. Hence the explicit marker, and hence
|
|
* the cases below that pin a broken pair as still broken afterwards.
|
|
*/
|
|
describe('the preserve path and the bridge metadata beside it', () => {
|
|
let home: string;
|
|
let groupDir: string;
|
|
let dbPath: string;
|
|
let metaPath: string;
|
|
|
|
/** Fixed, whole-second instants — exactly representable on any filesystem. */
|
|
const WRITTEN_AT = new Date('2026-01-01T00:00:00.000Z');
|
|
const TEN_SECONDS_LATER = new Date('2026-01-01T00:00:10.000Z');
|
|
const PRIOR_META_GENERATED_AT = '2026-01-01T00:00:00.000Z';
|
|
|
|
beforeEach(async () => {
|
|
initLbugMock.mockReset();
|
|
readRegistryStrictMock.mockReset();
|
|
readRegistryStrictMock.mockResolvedValue(REGISTRY);
|
|
home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-preserve-bridge-'));
|
|
groupDir = path.join(home, 'groups', 'waveful');
|
|
dbPath = path.join(groupDir, 'bridge.lbug');
|
|
metaPath = path.join(groupDir, 'meta.json');
|
|
await writeGroupYaml(groupDir, ['app/backend']);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await closeAllCachedBridges();
|
|
await fsp.rm(home, { recursive: true, force: true });
|
|
});
|
|
|
|
const seedPriorRegistry = (): Promise<void> =>
|
|
fsp.writeFile(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY));
|
|
|
|
/** A stamped pair that matches: what a successful `writeBridge` leaves behind. */
|
|
const seedMatchingPair = async (): Promise<void> => {
|
|
await fsp.writeFile(dbPath, 'the previous sync database');
|
|
const stat = await fsp.stat(dbPath);
|
|
await writeBridgeMeta(groupDir, {
|
|
version: BRIDGE_SCHEMA_VERSION,
|
|
generatedAt: PRIOR_META_GENERATED_AT,
|
|
bridgeSize: stat.size,
|
|
bridgeMtimeMs: stat.mtimeMs,
|
|
missingRepos: [],
|
|
unreadableRepos: [],
|
|
});
|
|
};
|
|
|
|
/**
|
|
* The legacy shape, broken: metadata with no stamp sitting beside a database
|
|
* that was replaced after it. `unstampedMetaPairsByWriteOrder` is the ONLY
|
|
* thing that can see this, and it sees it purely through the two file times —
|
|
* which is why an atomic rewrite of `meta.json` erases the evidence.
|
|
*/
|
|
const seedUnstampedPairWithNewerDatabase = async (): Promise<void> => {
|
|
await fsp.writeFile(dbPath, 'a database swapped in after the metadata');
|
|
await writeBridgeMeta(groupDir, {
|
|
version: BRIDGE_SCHEMA_VERSION,
|
|
generatedAt: PRIOR_META_GENERATED_AT,
|
|
missingRepos: [],
|
|
unreadableRepos: [],
|
|
});
|
|
await fsp.utimes(metaPath, WRITTEN_AT, WRITTEN_AT);
|
|
await fsp.utimes(dbPath, TEN_SECONDS_LATER, TEN_SECONDS_LATER);
|
|
};
|
|
|
|
const runTotalFailureSync = () => {
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
return syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir });
|
|
};
|
|
|
|
const runSuccessfulSync = () => {
|
|
initLbugMock.mockReset();
|
|
return syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), {
|
|
groupDir,
|
|
resolveRepoHandle: handleTable(['backend-repo']),
|
|
});
|
|
};
|
|
|
|
const runImpact = () =>
|
|
runGroupImpact(
|
|
{ port: makeGroupToolPort(home), gitnexusDir: home },
|
|
{ name: 'waveful', repo: 'app/backend', target: 'publish', direction: 'upstream' },
|
|
);
|
|
|
|
const pairsAfterwards = async (): Promise<boolean> =>
|
|
bridgeMetaMatchesFile(groupDir, await readBridgeMeta(groupDir));
|
|
|
|
it('reports the repos this sync could not read as a lower bound on the next cross-repo query', async () => {
|
|
// The headline case, and the one that makes `contracts.json` and `group
|
|
// impact` describe the same set of unaccounted repos. Every other signal
|
|
// says "complete": the local walk finished, the bridge returned no
|
|
// crossings, no cap and no clock fired.
|
|
await seedPriorRegistry();
|
|
await seedMatchingPair();
|
|
|
|
const result = await runTotalFailureSync();
|
|
expect(result.registryOutcome).toBe('preserved');
|
|
|
|
const impact = await runImpact();
|
|
|
|
expect(impact).toMatchObject({
|
|
cross: [],
|
|
truncated: true,
|
|
truncationReason: 'incomplete-sync',
|
|
riskEpistemic: 'lower-bound',
|
|
truncatedRepos: ['app/backend'],
|
|
});
|
|
});
|
|
|
|
it('leaves the bridge database itself byte-for-byte untouched', async () => {
|
|
// The contracts this bridge holds are the ones being preserved. A refresh
|
|
// that rebuilt it would be the single write capable of losing them.
|
|
await seedPriorRegistry();
|
|
await seedMatchingPair();
|
|
const before = await snapshotFile(dbPath);
|
|
|
|
await runTotalFailureSync();
|
|
|
|
const after = await snapshotFile(dbPath);
|
|
expect(after.text).toBe(before.text);
|
|
expect(after.size).toBe(before.size);
|
|
expect(after.mtimeMs).toBe(before.mtimeMs);
|
|
});
|
|
|
|
it('keeps a stamped pair that already failed the check failing, with its stamp untouched', async () => {
|
|
// Re-stamping here would MANUFACTURE provenance: it would declare that this
|
|
// metadata describes the database beside it, which is the one thing the
|
|
// failed check just said is not known. The stamp fields are carried through
|
|
// verbatim instead — dropping them would leave an unstamped file whose
|
|
// freshly-moved mtime the write-order rule then accepts.
|
|
await seedPriorRegistry();
|
|
await fsp.writeFile(dbPath, 'a database this metadata was never written for');
|
|
await writeBridgeMeta(groupDir, {
|
|
version: BRIDGE_SCHEMA_VERSION,
|
|
generatedAt: PRIOR_META_GENERATED_AT,
|
|
bridgeSize: 999_999,
|
|
bridgeMtimeMs: 1_700_000_000_000,
|
|
missingRepos: [],
|
|
unreadableRepos: [],
|
|
});
|
|
expect(await pairsAfterwards()).toBe(false);
|
|
|
|
await runTotalFailureSync();
|
|
|
|
const after = await readBridgeMeta(groupDir);
|
|
expect(after.bridgeSize).toBe(999_999);
|
|
expect(after.bridgeMtimeMs).toBe(1_700_000_000_000);
|
|
expect(after.provenanceUnknown).toBe(true);
|
|
expect(after.unreadableRepos).toEqual(['app/backend']);
|
|
expect(await pairsAfterwards()).toBe(false);
|
|
});
|
|
|
|
it('keeps an unstamped pair that already failed the check failing, though the rewrite moves meta.json to now', async () => {
|
|
// The laundering case, and the only shape that exercises the write-order
|
|
// rule. Before the sync the database is NEWER than the metadata beside it,
|
|
// which is the inverted write order that rule rejects. The atomic rewrite
|
|
// then makes `meta.json` the newer file — the exact shape it ACCEPTS — so
|
|
// without an explicit marker a preserve sync would hand back "verified" for
|
|
// a pair it just found broken.
|
|
await seedPriorRegistry();
|
|
await seedUnstampedPairWithNewerDatabase();
|
|
expect(await pairsAfterwards()).toBe(false);
|
|
|
|
await runTotalFailureSync();
|
|
|
|
const dbStat = await fsp.stat(dbPath);
|
|
const metaStat = await fsp.stat(metaPath);
|
|
// The evidence the write-order rule reads has genuinely been inverted...
|
|
expect(metaStat.mtimeMs).toBeGreaterThanOrEqual(dbStat.mtimeMs);
|
|
const after = await readBridgeMeta(groupDir);
|
|
// ...no stamp was invented to replace it...
|
|
expect(after.bridgeSize).toBeUndefined();
|
|
expect(after.bridgeMtimeMs).toBeUndefined();
|
|
// ...and the verdict survives in the metadata, which is the only place it
|
|
// can, because the refresh cannot avoid moving the mtime.
|
|
expect(after.provenanceUnknown).toBe(true);
|
|
expect(await pairsAfterwards()).toBe(false);
|
|
|
|
const impact = await runImpact();
|
|
expect(impact).toMatchObject({
|
|
truncated: true,
|
|
truncationReason: 'incomplete-sync',
|
|
riskEpistemic: 'lower-bound',
|
|
});
|
|
});
|
|
|
|
it('never writes either reader-side field back into meta.json', async () => {
|
|
// `repoListsUnreadable` and `pairedWithDatabase` are things a READER
|
|
// computes ABOUT a file; both are documented NEVER PERSISTED. This path is
|
|
// the first code to read metadata and write it back, so a naive
|
|
// `writeBridgeMeta(await readBridgeMeta(dir))` persists whichever of them
|
|
// the read produced. A stale `pairedWithDatabase: true` on disk is actively
|
|
// poisonous: it tells every future reader the pair was verified.
|
|
await seedPriorRegistry();
|
|
await fsp.writeFile(dbPath, 'db');
|
|
await fsp.writeFile(
|
|
metaPath,
|
|
JSON.stringify({
|
|
version: BRIDGE_SCHEMA_VERSION,
|
|
generatedAt: PRIOR_META_GENERATED_AT,
|
|
// Not a list of repo paths, so `readBridgeMeta` answers with
|
|
// `repoListsUnreadable: true` on the object this path then rewrites.
|
|
missingRepos: { 'app/backend': true },
|
|
// A foreign writer, a hand-edit, or an earlier naive round-trip.
|
|
pairedWithDatabase: true,
|
|
}),
|
|
);
|
|
|
|
await runTotalFailureSync();
|
|
|
|
const raw = JSON.parse(await fsp.readFile(metaPath, 'utf8')) as Record<string, unknown>;
|
|
expect(raw).not.toHaveProperty('pairedWithDatabase');
|
|
expect(raw).not.toHaveProperty('repoListsUnreadable');
|
|
// ...and the unusable list was replaced by this run's real measurement,
|
|
// rather than being carried forward as garbage.
|
|
expect(raw.missingRepos).toEqual([]);
|
|
expect(raw.unreadableRepos).toEqual(['app/backend']);
|
|
});
|
|
|
|
it('completes when there is no bridge.lbug for the metadata to describe', async () => {
|
|
// `writeBridge` can leave this behind: the old database is renamed aside
|
|
// and the new one never arrives. The refresh must not stat a file that is
|
|
// not there, and must not vouch for one either.
|
|
await seedPriorRegistry();
|
|
await writeBridgeMeta(groupDir, {
|
|
version: BRIDGE_SCHEMA_VERSION,
|
|
generatedAt: PRIOR_META_GENERATED_AT,
|
|
bridgeSize: 4096,
|
|
bridgeMtimeMs: 1_700_000_000_000,
|
|
missingRepos: [],
|
|
unreadableRepos: [],
|
|
});
|
|
|
|
const result = await runTotalFailureSync();
|
|
|
|
expect(result.registryOutcome).toBe('preserved');
|
|
expect(fs.existsSync(dbPath)).toBe(false);
|
|
const after = await readBridgeMeta(groupDir);
|
|
expect(after.provenanceUnknown).toBe(true);
|
|
// Carried through verbatim: the stamp still records which database this
|
|
// metadata was written for, which is information, not a claim about what
|
|
// is on disk now.
|
|
expect(after.bridgeSize).toBe(4096);
|
|
expect(after.bridgeMtimeMs).toBe(1_700_000_000_000);
|
|
expect(after.unreadableRepos).toEqual(['app/backend']);
|
|
|
|
// The query that follows answers rather than throwing. With no database
|
|
// there is nothing to answer FROM, so it names the missing file and sends
|
|
// the operator to `group sync` — never a confident "nothing depends on
|
|
// this". (The lower-bound answer is the shape above, where a database IS
|
|
// present and the marker is what stops it being trusted.)
|
|
const impact = await runImpact();
|
|
expect(impact).toMatchObject({ error: expect.stringContaining('No bridge.lbug') });
|
|
});
|
|
|
|
it('does not manufacture metadata for a bridge that has never existed', async () => {
|
|
// Neither file is on disk, so there is no pair that could disagree with
|
|
// anything and nothing to keep honest. `readBridgeMeta` already answers
|
|
// `version: 0` — provenance unknown — for an absent file, and writing a
|
|
// `version: 0` file that says the same thing only invents state.
|
|
await seedPriorRegistry();
|
|
|
|
await runTotalFailureSync();
|
|
|
|
expect(fs.existsSync(metaPath)).toBe(false);
|
|
expect(fs.existsSync(dbPath)).toBe(false);
|
|
});
|
|
|
|
it('records this run against a database whose metadata is missing entirely', async () => {
|
|
// The other half of the pair being absent. The database is real and the
|
|
// metadata is gone — provenance is already unknown, and staying silent
|
|
// costs the operator the NAMES of the repos this run could not read.
|
|
await seedPriorRegistry();
|
|
await fsp.writeFile(dbPath, 'a database with no metadata beside it');
|
|
|
|
await runTotalFailureSync();
|
|
|
|
const after = await readBridgeMeta(groupDir);
|
|
expect(after.provenanceUnknown).toBe(true);
|
|
expect(after.unreadableRepos).toEqual(['app/backend']);
|
|
expect(await pairsAfterwards()).toBe(false);
|
|
});
|
|
|
|
it('does not launder the pair it already marked on a second preserve run', async () => {
|
|
// The invariant in its strongest form: no preserve run ever increases the
|
|
// number of pairs that pass the check. The second run reads metadata that
|
|
// now carries the marker, and the marker has to survive its own rewrite.
|
|
await seedPriorRegistry();
|
|
await seedUnstampedPairWithNewerDatabase();
|
|
|
|
await runTotalFailureSync();
|
|
expect(await pairsAfterwards()).toBe(false);
|
|
|
|
await runTotalFailureSync();
|
|
|
|
const after = await readBridgeMeta(groupDir);
|
|
expect(after.provenanceUnknown).toBe(true);
|
|
expect(await pairsAfterwards()).toBe(false);
|
|
});
|
|
|
|
it('clears the marker on the next successful sync', async () => {
|
|
// Nothing clears the marker deliberately: a successful `writeBridge`
|
|
// builds fresh metadata from a literal and simply never sets the field.
|
|
// That is what keeps a marked bridge from being marked forever.
|
|
await seedPriorRegistry();
|
|
await seedUnstampedPairWithNewerDatabase();
|
|
await runTotalFailureSync();
|
|
expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBe(true);
|
|
|
|
const ok = await runSuccessfulSync();
|
|
|
|
expect(ok.registryOutcome).toBe('written');
|
|
const after = await readBridgeMeta(groupDir);
|
|
expect(after.provenanceUnknown).toBeUndefined();
|
|
expect(await pairsAfterwards()).toBe(true);
|
|
});
|
|
|
|
it('control: a successful sync writes a pair that passes the check, unmarked', async () => {
|
|
// Without this, "the marker is absent after a good sync" could be true
|
|
// because the marker is absent from everything.
|
|
const ok = await runSuccessfulSync();
|
|
|
|
expect(ok.registryOutcome).toBe('written');
|
|
const after = await readBridgeMeta(groupDir);
|
|
expect(after.provenanceUnknown).toBeUndefined();
|
|
expect(after.unreadableRepos).toEqual([]);
|
|
expect(await pairsAfterwards()).toBe(true);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The branch taken when `writeBridge` throws. `contracts.json` has already been
|
|
* written and is canonical by then, so the failure is a recoverable degradation
|
|
* — and the warning is the ONLY thing this branch produces. The return value,
|
|
* `registryOutcome` and every file on disk are identical whether the sentence
|
|
* is true or not, which is why the text needs an assertion of its own instead of
|
|
* borrowing a state assertion from elsewhere in this file.
|
|
*/
|
|
describe('the warning after a failed bridge write', () => {
|
|
let groupDir: string;
|
|
|
|
beforeEach(() => {
|
|
initLbugMock.mockReset();
|
|
readRegistryStrictMock.mockReset();
|
|
readRegistryStrictMock.mockResolvedValue(REGISTRY);
|
|
writeBridgeFailure = null;
|
|
groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-bridge-write-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
writeBridgeFailure = null;
|
|
await closeAllCachedBridges();
|
|
fs.rmSync(groupDir, { recursive: true, force: true });
|
|
});
|
|
|
|
const runSync = () =>
|
|
syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), {
|
|
groupDir,
|
|
resolveRepoHandle: handleTable(['backend-repo']),
|
|
});
|
|
|
|
/** The bridge-failure warning is the only record carrying `groupDir`. */
|
|
const bridgeWarning = (cap: ReturnType<typeof _captureLogger>) =>
|
|
cap.records().find((r) => r.level === 40 && typeof r.groupDir === 'string');
|
|
|
|
it('withdraws the old bridge provenance when the registry advanced but the bridge write failed', async () => {
|
|
// The split-brain this guards: contracts.json commits, the bridge write
|
|
// then fails, and the previous database stays in place describing an
|
|
// EARLIER sync. Left vouching for itself, `group_impact` traverses that
|
|
// older graph and calls its answer complete while `group_contracts`
|
|
// reports the new registry — two public surfaces, contradictory claims,
|
|
// out of one sync. Marking provenance unknown withdraws the completeness
|
|
// claim without deleting a graph still useful as a floor.
|
|
// `unreadableRepos` is deliberately UNREADABLE here, not merely empty: that
|
|
// is what makes `readBridgeMeta` set the reader-only `repoListsUnreadable`
|
|
// on what it returns, so the assertion below can actually catch a
|
|
// read-modify-write writer round-tripping it back to disk. Seeded with a
|
|
// valid list the check passes whether or not the strip exists.
|
|
fs.writeFileSync(
|
|
path.join(groupDir, 'meta.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
generatedAt: '2026-01-01T00:00:00.000Z',
|
|
missingRepos: [],
|
|
unreadableRepos: 'not-a-list',
|
|
}),
|
|
);
|
|
expect((await readBridgeMeta(groupDir)).repoListsUnreadable).toBe(true);
|
|
expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBeUndefined();
|
|
|
|
writeBridgeFailure = new Error('ENOSPC: no space left on device');
|
|
await runSync();
|
|
|
|
expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBe(true);
|
|
|
|
// ...and the withdrawal must not persist the reader-only fields.
|
|
// `readBridgeMeta` sets both on what it returns, so a read-modify-write
|
|
// writer round-trips them unless the write boundary strips them.
|
|
// `pairedWithDatabase` is the poisonous one: persisted, it would tell every
|
|
// later reader the pair was verified when nothing verified it.
|
|
const raw = JSON.parse(fs.readFileSync(path.join(groupDir, 'meta.json'), 'utf8')) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(raw).not.toHaveProperty('pairedWithDatabase');
|
|
expect(raw).not.toHaveProperty('repoListsUnreadable');
|
|
});
|
|
|
|
// control: a sync whose bridge write SUCCEEDS must not withdraw provenance —
|
|
// otherwise every healthy sync would report its own answers as a floor.
|
|
it('control: a successful bridge write leaves provenance intact', async () => {
|
|
await runSync();
|
|
|
|
expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBeFalsy();
|
|
});
|
|
|
|
it('names the registry as intact and does not promise a truncation this branch never reports', async () => {
|
|
writeBridgeFailure = new Error('ENOSPC: no space left on device');
|
|
const cap = _captureLogger();
|
|
|
|
let result;
|
|
try {
|
|
result = await runSync();
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
|
|
// The registry write happens before the bridge write and is not rolled back
|
|
// — the reason this failure is survivable at all.
|
|
expect(result.registryOutcome).toBe('written');
|
|
|
|
const warning = bridgeWarning(cap);
|
|
expect(warning).toBeDefined();
|
|
expect(String(warning?.msg)).toContain('contracts.json is intact');
|
|
// The claim the code does not keep. A failed `writeBridge` leaves the
|
|
// PREVIOUS sync's database and the metadata stamped for it untouched, so the
|
|
// next cross-repo query reads a pair that checks out, finds no unreadable
|
|
// repos recorded in it, and answers `truncated: false` — from contracts this
|
|
// sync has already superseded. Nothing on this branch marks the bridge at
|
|
// all, so telling the operator to wait for `truncated` is telling them to
|
|
// wait for a signal that is never coming.
|
|
expect(String(warning?.msg)).not.toMatch(/truncat/i);
|
|
// What the code does guarantee instead: the registry is the good copy, the
|
|
// bridge may still answer from the previous sync, and only another sync
|
|
// replaces it.
|
|
expect(String(warning?.msg)).toMatch(/previous sync/i);
|
|
expect(String(warning?.msg)).toContain('group sync');
|
|
// ...and the underlying failure still reaches the operator.
|
|
expect(String(warning?.err)).toContain('ENOSPC');
|
|
expect(result.warnings).toEqual([expect.stringContaining('writeBridge failed')]);
|
|
expect(result.warnings[0]).toMatch(/previous sync/i);
|
|
});
|
|
|
|
it('control: a sync whose bridge write succeeds emits no such warning', async () => {
|
|
// Without this, "the warning does not promise a truncation" could be true
|
|
// because no warning is emitted on any run at all.
|
|
const cap = _captureLogger();
|
|
|
|
let result;
|
|
try {
|
|
result = await runSync();
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
|
|
expect(result.registryOutcome).toBe('written');
|
|
expect(bridgeWarning(cap)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* R9, the half a lock does not fix: serializing is not ordering.
|
|
*
|
|
* Both syncs run EXTRACTION outside the lock and only the persist section
|
|
* inside it, so a total-failure sync that queues behind a healthy one arrives at
|
|
* the critical section holding a snapshot of a group it read minutes ago. The
|
|
* preserve path then re-reads `contracts.json` as `prior` — and the file it
|
|
* finds is the winner's, written while this run waited — and stamps its own
|
|
* all-unreadable lists over it. That is not a rare interleave: it is what
|
|
* happens every time the total-failure sync loses the race, and it downgrades a
|
|
* registry that describes repos that were readable seconds earlier.
|
|
*
|
|
* The guard is a compare-and-swap on the prior file's own identity: stat it
|
|
* BEFORE acquiring, re-stat AFTER, and skip the diagnostic refresh when the two
|
|
* differ. Keyed on the file, not on `generatedAt`: that field is stamped when
|
|
* the registry object is built (before the lock), so a winner that waited writes
|
|
* one OLDER than the loser's start, and the preserve path carries it forward
|
|
* verbatim by design — after any preserve sync it does not date the write at
|
|
* all. File identity also needs no cross-process clock agreement and has no
|
|
* undefined case for an absent or unparseable timestamp.
|
|
*/
|
|
describe('a total-failure sync that reaches the group lock second', () => {
|
|
let groupDir: string;
|
|
let contractsPath: string;
|
|
let dbPath: string;
|
|
let metaPath: string;
|
|
|
|
/** What the sync that won the lock wrote while this one was still waiting. */
|
|
const WINNER_REGISTRY = {
|
|
version: 1,
|
|
generatedAt: '2026-02-02T00:00:00.000Z',
|
|
repoSnapshots: {},
|
|
missingRepos: [],
|
|
unreadableRepos: [],
|
|
contracts: [{ contractId: 'http::GET::/api/users' }, { contractId: 'http::POST::/api/users' }],
|
|
crossLinks: [{ contractId: 'http::GET::/api/users' }],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
initLbugMock.mockReset();
|
|
readRegistryStrictMock.mockReset();
|
|
readRegistryStrictMock.mockResolvedValue(REGISTRY);
|
|
whileWaitingForTheGroupLock = null;
|
|
groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-second-'));
|
|
contractsPath = path.join(groupDir, 'contracts.json');
|
|
dbPath = path.join(groupDir, 'bridge.lbug');
|
|
metaPath = path.join(groupDir, 'meta.json');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
whileWaitingForTheGroupLock = null;
|
|
await closeAllCachedBridges();
|
|
fs.rmSync(groupDir, { recursive: true, force: true });
|
|
});
|
|
|
|
const runTotalFailureSync = () => {
|
|
initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR));
|
|
return syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir });
|
|
};
|
|
|
|
const seedPriorRegistry = (): void =>
|
|
fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY));
|
|
|
|
/** The winner's write, landing while this sync waits on the lock. */
|
|
const winnerWritesTheRegistry = async (): Promise<void> => {
|
|
fs.writeFileSync(contractsPath, JSON.stringify(WINNER_REGISTRY));
|
|
};
|
|
|
|
const readOnDisk = (): Record<string, unknown> =>
|
|
JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record<string, unknown>;
|
|
|
|
it('leaves the registry the winning sync wrote exactly as it found it, and reports superseded', async () => {
|
|
seedPriorRegistry();
|
|
whileWaitingForTheGroupLock = winnerWritesTheRegistry;
|
|
|
|
const result = await runTotalFailureSync();
|
|
|
|
// Byte-identical to what the winner wrote. NOT the winner's contracts with
|
|
// this run's all-unreadable list stamped over them, which is what re-reading
|
|
// `prior` inside the lock produces — a registry that says every repo in the
|
|
// group is unreadable, written on top of a sync that had just read them.
|
|
expect(fs.readFileSync(contractsPath, 'utf8')).toBe(JSON.stringify(WINNER_REGISTRY));
|
|
expect(readOnDisk().unreadableRepos).toEqual([]);
|
|
// The existing outcome, not a new one: nothing was written and a prior
|
|
// registry was kept, which is exactly what `preserved` already means. A new
|
|
// value would fall through `cli/group.ts`'s outcome chain, which has no
|
|
// fallback branch, and falsify the guard asserting the sync tool's
|
|
// description names every reachable outcome.
|
|
expect(result.registryOutcome).toBe('superseded');
|
|
// ...and the caller still learns what THIS run could not read.
|
|
expect(result.unreadableRepos).toEqual(['app/backend']);
|
|
});
|
|
|
|
it('treats a registry that was absent before the lock and present after as changed', async () => {
|
|
// No prior file at all when this sync stat'd: on its own reading it was
|
|
// heading for `no-prior-registry` (write nothing), and then found a registry
|
|
// to "refresh" — one belonging to a sync it never overlapped in extraction.
|
|
whileWaitingForTheGroupLock = winnerWritesTheRegistry;
|
|
|
|
const result = await runTotalFailureSync();
|
|
|
|
expect(fs.readFileSync(contractsPath, 'utf8')).toBe(JSON.stringify(WINNER_REGISTRY));
|
|
expect(readOnDisk().unreadableRepos).toEqual([]);
|
|
expect(result.registryOutcome).toBe('superseded');
|
|
});
|
|
|
|
it('does not stamp this run into the bridge metadata beside the registry it skipped', async () => {
|
|
// `meta.json` is where `runGroupImpact` reads completeness from, so writing
|
|
// this run's lists there is the same downgrade one file over: it would
|
|
// report repos as unaccounted for that the winning sync had just accounted
|
|
// for. The refresh describes THIS run, and on this path this run is the
|
|
// stale one — and `refreshPreservedBridgeMeta` moves meta.json's mtime and
|
|
// can mark a pair `provenanceUnknown`, so it can only degrade a pair the
|
|
// winner left consistent. Nothing is written, which is what makes
|
|
// `preserved` an honest answer here.
|
|
fs.writeFileSync(dbPath, 'the winning sync database');
|
|
const dbStat = fs.statSync(dbPath);
|
|
await writeBridgeMeta(groupDir, {
|
|
version: BRIDGE_SCHEMA_VERSION,
|
|
generatedAt: '2026-02-02T00:00:00.000Z',
|
|
bridgeSize: dbStat.size,
|
|
bridgeMtimeMs: dbStat.mtimeMs,
|
|
missingRepos: [],
|
|
unreadableRepos: [],
|
|
});
|
|
const metaBefore = await snapshotFile(metaPath);
|
|
seedPriorRegistry();
|
|
whileWaitingForTheGroupLock = winnerWritesTheRegistry;
|
|
|
|
await runTotalFailureSync();
|
|
|
|
const metaAfter = await snapshotFile(metaPath);
|
|
expect(metaAfter.text).toBe(metaBefore.text);
|
|
expect(metaAfter.mtimeMs).toBe(metaBefore.mtimeMs);
|
|
const meta = await readBridgeMeta(groupDir);
|
|
expect(meta.unreadableRepos).toEqual([]);
|
|
expect(meta.provenanceUnknown).toBeUndefined();
|
|
});
|
|
|
|
it('refreshes as usual when it is the sync that got to the lock first', async () => {
|
|
// The same interleaving in the other order: the other sync is still
|
|
// extracting and has written nothing, so this run's stats match across the
|
|
// acquisition and the diagnostic refresh — the entire point of the preserve
|
|
// path — must still happen. A guard that fired on "a second sync exists"
|
|
// rather than on "the file changed" would freeze the diagnostics of every
|
|
// contended group.
|
|
seedPriorRegistry();
|
|
let otherSyncStillExtracting = false;
|
|
whileWaitingForTheGroupLock = async () => {
|
|
otherSyncStillExtracting = true;
|
|
};
|
|
|
|
const result = await runTotalFailureSync();
|
|
|
|
expect(otherSyncStillExtracting).toBe(true);
|
|
expect(result.registryOutcome).toBe('preserved');
|
|
const onDisk = readOnDisk();
|
|
expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts);
|
|
expect(onDisk.unreadableRepos).toEqual(['app/backend']);
|
|
});
|
|
|
|
it('control: an uncontended sync sees identical stats and refreshes as usual', async () => {
|
|
// Nothing armed at all, so the compare-and-swap runs over a file no one
|
|
// else touched. Without this, every assertion above could be satisfied by a
|
|
// guard that skipped the refresh on every run.
|
|
seedPriorRegistry();
|
|
|
|
const result = await runTotalFailureSync();
|
|
|
|
expect(result.registryOutcome).toBe('preserved');
|
|
const onDisk = readOnDisk();
|
|
expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts);
|
|
expect(onDisk.unreadableRepos).toEqual(['app/backend']);
|
|
});
|
|
});
|