GitNexus/gitnexus/test/unit/group/sync-unreadable-repos.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

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']);
});
});