mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
fix(storage): address review feedback on the shared store (#3374)
- Hold the slot index lock for the whole server embedding job, not just the graph copy. - --no-share re-registers and deletes the old slot under that slot's index lock, so a running shared analyze cannot re-register it. - clean --gc previews without --force, like every other destructive arm. - status reports a pinned branch index as private. - Slot names prefix Windows device names that carry an extension. - A slot or store named ..<name> is a legal direct child. - Shared-store suites run in the serialized lbug-db vitest project and clear an inherited GITNEXUS_SHARED_STORE. - Doc and test accuracy fixes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
28902962dd
commit
d0894ae4b7
22 changed files with 199 additions and 46 deletions
|
|
@ -298,9 +298,10 @@ function resolveEntryStoragePath(entry) {
|
|||
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
|
||||
}
|
||||
|
||||
// A single path segment: `..repo-<hash>` is a legal slot name, `..` is not.
|
||||
function isDirectChild(parent, child) {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a
|
||||
|
|
|
|||
|
|
@ -298,9 +298,10 @@ function resolveEntryStoragePath(entry) {
|
|||
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
|
||||
}
|
||||
|
||||
// A single path segment: `..repo-<hash>` is a legal slot name, `..` is not.
|
||||
function isDirectChild(parent, child) {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a
|
||||
|
|
|
|||
|
|
@ -298,9 +298,10 @@ function resolveEntryStoragePath(entry) {
|
|||
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
|
||||
}
|
||||
|
||||
// A single path segment: `..repo-<hash>` is a legal slot name, `..` is not.
|
||||
function isDirectChild(parent, child) {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ const reportReclaim = (result: ReclaimResult | null): void => {
|
|||
};
|
||||
|
||||
/** `clean --gc`: collect every shared store under GITNEXUS_HOME (#3352). */
|
||||
const collectSharedStores = async (): Promise<void> => {
|
||||
const collectSharedStores = async (force: boolean): Promise<void> => {
|
||||
const storesDir = path.join(getGlobalDir(), STORES_DIR);
|
||||
const names = await fs.readdir(storesDir).catch(() => [] as string[]);
|
||||
if (names.length === 0) {
|
||||
|
|
@ -175,9 +175,10 @@ const collectSharedStores = async (): Promise<void> => {
|
|||
}
|
||||
for (const name of names) {
|
||||
const root = path.join(storesDir, name);
|
||||
const result = await reclaimSharedStore(root, { gc: true });
|
||||
// Without --force this is a preview: same selection, nothing deleted.
|
||||
const result = await reclaimSharedStore(root, { gc: true, dryRun: !force });
|
||||
console.log(
|
||||
t('clean.gc.store', {
|
||||
t(force ? 'clean.gc.store' : 'clean.gc.preview', {
|
||||
path: root,
|
||||
members: result.droppedMembers.length,
|
||||
graphs: result.removed.length,
|
||||
|
|
@ -186,6 +187,7 @@ const collectSharedStores = async (): Promise<void> => {
|
|||
if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length }));
|
||||
if (result.storeRemoved) console.log(t('clean.shared.storeRemoved', { path: root }));
|
||||
}
|
||||
if (!force) console.log(`\n${t('common.runForceConfirm')}`);
|
||||
};
|
||||
|
||||
export const cleanCommand = async (options?: {
|
||||
|
|
@ -198,7 +200,7 @@ export const cleanCommand = async (options?: {
|
|||
localIndex?: boolean;
|
||||
}) => {
|
||||
if (options?.gc) {
|
||||
await collectSharedStores();
|
||||
await collectSharedStores(options.force === true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ export const en = {
|
|||
'clean.gc.none': 'No shared stores to collect.',
|
||||
'clean.gc.store':
|
||||
'Shared store {{path}}: dropped {{members}} checkout(s), removed {{graphs}} commit graph(s).',
|
||||
'clean.gc.preview':
|
||||
'Shared store {{path}}: would drop {{members}} checkout(s) and remove {{graphs}} commit graph(s).',
|
||||
'clean.localIndex.none': 'No leftover local index in this checkout.',
|
||||
'clean.localIndex.preview':
|
||||
'This will delete the leftover local index at {{path}} ({{size}}). The shared index is not affected.',
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export const zhCN = {
|
|||
'clean.shared.storeRemoved': '共享存储:已删除 {{path}}(没有剩余检出)。',
|
||||
'clean.gc.none': '没有可回收的共享存储。',
|
||||
'clean.gc.store': '共享存储 {{path}}:移除了 {{members}} 个检出,删除了 {{graphs}} 个提交图。',
|
||||
'clean.gc.preview': '共享存储 {{path}}:将移除 {{members}} 个检出并删除 {{graphs}} 个提交图。',
|
||||
'clean.localIndex.none': '此检出中没有残留的本地索引。',
|
||||
'clean.localIndex.preview': '将删除 {{path}} 处残留的本地索引({{size}})。共享索引不受影响。',
|
||||
'clean.localIndex.deleted': '已删除 {{path}} 处残留的本地索引({{size}})。',
|
||||
|
|
|
|||
|
|
@ -358,7 +358,12 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
const sharedStore = storeRoot
|
||||
? {
|
||||
key: path.basename(storeRoot),
|
||||
graph: describeSharedGraph(resolveGraphPath(repo.storagePath), repo.storagePath),
|
||||
// A pinned branch index (`branches/<slug>/lbug`) is always private;
|
||||
// only the flat slot can point at a shared commit graph.
|
||||
graph:
|
||||
activeMeta === repo.meta
|
||||
? describeSharedGraph(resolveGraphPath(repo.storagePath), repo.storagePath)
|
||||
: ('private' as const),
|
||||
commit: activeMeta.lastCommit,
|
||||
}
|
||||
: null;
|
||||
|
|
|
|||
|
|
@ -1325,8 +1325,11 @@ export async function runFullAnalysis(
|
|||
// Leaving a store (`--no-share`, or sharing turned off): the up-to-date
|
||||
// path does not re-register, so point the registry at the new storage
|
||||
// before the old slot goes away.
|
||||
await registerLeftStore(repoPath, writeTarget.storagePath);
|
||||
if (slotToLeave) await leaveSharedStore(repoPath, slotToLeave, log);
|
||||
if (slotToLeave) {
|
||||
await leaveSharedStore(repoPath, slotToLeave, writeTarget.storagePath, log);
|
||||
} else {
|
||||
await registerLeftStore(repoPath, writeTarget.storagePath);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { createHash, randomUUID } from 'crypto';
|
|||
import { constants as fsConstants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { acquireIndexLock } from '../storage/index-lock.js';
|
||||
import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
|
||||
import { commitDistanceToHead, getRemoteUrl, isWorkingTreeDirty } from '../storage/git.js';
|
||||
import {
|
||||
canonicalizePath,
|
||||
|
|
@ -448,16 +448,25 @@ export const optedInSlotToLeave = async (repoPath: string): Promise<string | und
|
|||
};
|
||||
|
||||
/**
|
||||
* After a successful `--no-share` run re-registered the checkout at
|
||||
* `<repo>/.gitnexus`: delete its old store slot and reclaim what only that
|
||||
* slot referenced.
|
||||
* After a successful `--no-share` run: re-register the checkout at its new
|
||||
* storage and delete its old store slot, both under the old slot's index lock
|
||||
* so an analyze still running on that slot cannot re-register it afterwards
|
||||
* or write into a deleted directory. Then reclaim what only that slot used.
|
||||
*/
|
||||
export const leaveSharedStore = async (
|
||||
repoPath: string,
|
||||
previousSlot: string,
|
||||
newStoragePath: string,
|
||||
log: Log,
|
||||
): Promise<void> => {
|
||||
await fs.rm(previousSlot, { recursive: true, force: true });
|
||||
const lock = await acquireIndexLock(previousSlot);
|
||||
try {
|
||||
requireExclusiveIndexLock(lock, `Cannot acquire the index lock at ${previousSlot}.`);
|
||||
await registerLeftStore(repoPath, newStoragePath);
|
||||
await fs.rm(previousSlot, { recursive: true, force: true });
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await removeSharedStorePointer(repoPath);
|
||||
await reclaimAfterSlotRemoval(previousSlot);
|
||||
log(`Shared store: left ${previousSlot}.`);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@
|
|||
* CORS is restricted to localhost, private/LAN networks, and the deployed site.
|
||||
*/
|
||||
|
||||
import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
|
||||
import {
|
||||
acquireIndexLock,
|
||||
requireExclusiveIndexLock,
|
||||
type IndexLockHandle,
|
||||
} from '../storage/index-lock.js';
|
||||
import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js';
|
||||
import { resolveGraphPath } from '../storage/shared-store.js';
|
||||
import { reclaimAfterSlotRemoval } from '../storage/shared-store-lifecycle.js';
|
||||
|
|
@ -2130,22 +2134,20 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// Set inside withLbugDb, read after it closes (#2790).
|
||||
let partialRunError: string | undefined;
|
||||
let partialRunDetail: AnalyzeJobPartialOutcome | undefined;
|
||||
// The in-memory repo lock only serializes this server; a CLI analyze
|
||||
// in another process guards the slot with the index lock, so hold it
|
||||
// for the whole embedding write, released in the finally below.
|
||||
let slotLock: IndexLockHandle | undefined;
|
||||
try {
|
||||
slotLock = await acquireIndexLock(storagePath);
|
||||
requireExclusiveIndexLock(
|
||||
slotLock,
|
||||
`Cannot acquire the index lock at ${storagePath}; refusing an unlocked embedding run.`,
|
||||
);
|
||||
// Writes go to the slot's own graph; a shared-store checkout
|
||||
// reading an immutable commit graph (#3352) takes a private copy.
|
||||
// The in-memory repo lock only serializes this server; a CLI
|
||||
// analyze in another process guards the slot with the index lock.
|
||||
const slotLock = await acquireIndexLock(storagePath);
|
||||
try {
|
||||
requireExclusiveIndexLock(
|
||||
slotLock,
|
||||
`Cannot acquire the index lock at ${storagePath}; refusing to copy the shared graph.`,
|
||||
);
|
||||
if (!(await ensurePrivateSharedGraph(storagePath, () => {}))) {
|
||||
throw new Error('The shared graph this repository reads is gone. Re-run analyze.');
|
||||
}
|
||||
} finally {
|
||||
slotLock.release();
|
||||
if (!(await ensurePrivateSharedGraph(storagePath, () => {}))) {
|
||||
throw new Error('The shared graph this repository reads is gone. Re-run analyze.');
|
||||
}
|
||||
const lbugPath = path.join(storagePath, LBUG_DIRECTORY);
|
||||
const ftsSession = await loadFtsSession(storagePath);
|
||||
|
|
@ -2380,6 +2382,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
});
|
||||
}
|
||||
} finally {
|
||||
slotLock?.release();
|
||||
clearTimeout(embedTimeout);
|
||||
releaseRepoLock(repoLockPath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,9 +214,11 @@ const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
|
|||
* across branches (#2106 KTD7). When `branch` is provided, both `lbugPath`
|
||||
* and `metaPath` are scoped under `branches/<slug>/`. For the flat call
|
||||
* (no `branch`), `storagePath` and `lbugPath` remain byte-identical to the
|
||||
* pre-multi-branch behavior (#2106); `metaPath`'s FILENAME changed from
|
||||
* `meta.json` to `gitnexus.json` (PR #2363) — `saveMeta` keeps a `meta.json`
|
||||
* mirror in sync for consumers that still read the legacy name.
|
||||
* pre-multi-branch behavior (#2106), except that a shared-store checkout slot
|
||||
* (#3352) returns the commit graph its metadata records (`resolveGraphPath`).
|
||||
* `metaPath`'s FILENAME changed from `meta.json` to `gitnexus.json`
|
||||
* (PR #2363) — `saveMeta` keeps a `meta.json` mirror in sync for consumers
|
||||
* that still read the legacy name.
|
||||
*
|
||||
* Each branch slot has its own metadata file:
|
||||
* - Primary/flat: <repo>/.gitnexus/gitnexus.json
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ const orphanMembers = async (slots: string[]): Promise<Set<string>> => {
|
|||
*/
|
||||
export const reclaimSharedStoreLocked = async (
|
||||
storeRoot: string,
|
||||
opts: { gc?: boolean } = {},
|
||||
opts: { gc?: boolean; dryRun?: boolean } = {},
|
||||
): Promise<ReclaimResult> => {
|
||||
const result: ReclaimResult = { removed: [], kept: [], droppedMembers: [], storeRemoved: false };
|
||||
const checkoutsDir = path.join(storeRoot, 'checkouts');
|
||||
|
|
@ -105,7 +105,7 @@ export const reclaimSharedStoreLocked = async (
|
|||
if (opts.gc) {
|
||||
const orphans = await orphanMembers(slots);
|
||||
for (const slot of orphans) {
|
||||
await fs.rm(slot, { recursive: true, force: true });
|
||||
if (!opts.dryRun) await fs.rm(slot, { recursive: true, force: true });
|
||||
result.droppedMembers.push(slot);
|
||||
}
|
||||
slots = slots.filter((slot) => !orphans.has(slot));
|
||||
|
|
@ -118,6 +118,10 @@ export const reclaimSharedStoreLocked = async (
|
|||
for (const name of await listDir(commitsDir)) {
|
||||
const dir = path.join(commitsDir, name);
|
||||
if (referenced.has(dir)) continue;
|
||||
if (opts.dryRun) {
|
||||
if (!name.startsWith('.')) result.removed.push(dir);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
if (!name.startsWith('.')) result.removed.push(dir);
|
||||
|
|
@ -129,7 +133,7 @@ export const reclaimSharedStoreLocked = async (
|
|||
}
|
||||
|
||||
const remaining = (await listDir(checkoutsDir)).length + (await listDir(commitsDir)).length;
|
||||
if (remaining === 0) {
|
||||
if (remaining === 0 && !opts.dryRun) {
|
||||
// The lock directory lives inside the store; removing it while held is
|
||||
// safe on POSIX and is retried on the next reclaim elsewhere.
|
||||
await fs
|
||||
|
|
@ -149,7 +153,7 @@ export const reclaimSharedStoreLocked = async (
|
|||
*/
|
||||
export const reclaimSharedStore = async (
|
||||
storeRoot: string,
|
||||
opts: { gc?: boolean } = {},
|
||||
opts: { gc?: boolean; dryRun?: boolean } = {},
|
||||
): Promise<ReclaimResult> => {
|
||||
if (!existsSync(storeRoot)) {
|
||||
return { removed: [], kept: [], droppedMembers: [], storeRemoved: false };
|
||||
|
|
|
|||
|
|
@ -196,9 +196,10 @@ export const commitGraphDir = (
|
|||
return path.join(layout.commitsDir, `${commit}-${featureKey}`);
|
||||
};
|
||||
|
||||
// A single path segment: `..repo-<hash>` is a legal slot name, `..` is not.
|
||||
const isDirectChild = (parent: string, child: string): boolean => {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* Kept free of imports from `storage-resolver.ts` and `shared-store.ts` so both
|
||||
* can use them without importing each other (#3352). `storage-resolver.ts`
|
||||
* re-exports everything here, so existing import sites are unchanged.
|
||||
* re-exports the two env-var names, so existing import sites are unchanged.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
|
@ -25,7 +25,8 @@ const sanitizeSlotBasename = (value: string): string => {
|
|||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
// Windows reserves device names with any extension too (`CON.txt`).
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -152,6 +152,17 @@ describe('shared store adoption and reporting (#3352)', () => {
|
|||
expect((await statusJson(wt)).status).toBe('up-to-date');
|
||||
}, 240_000);
|
||||
|
||||
it('status reports a pinned branch index as private even when the flat slot is shared', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await analyze(wt);
|
||||
// The flat slot now holds `wt` and points at a shared commit graph. A
|
||||
// different checked-out branch pinned with --branch gets its own index.
|
||||
git(wt, 'checkout', '-q', '-b', 'pinned');
|
||||
await runFullAnalysis(wt, { branch: 'pinned' }, { onProgress: () => {} });
|
||||
const json = await statusJson(wt);
|
||||
expect(json.sharedStore).toMatchObject({ graph: 'private' });
|
||||
}, 240_000);
|
||||
|
||||
it('status reports a private graph for an edited worktree', async () => {
|
||||
await analyze(wt);
|
||||
await fs.writeFile(path.join(wt, 'a.ts'), 'export function alphaEdited() { return 1; }\n');
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { execFileSync } from 'child_process';
|
|||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { featureKeyOf, publishSharedGraph } from '../../src/core/shared-store-analyze.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
|
|
@ -18,6 +18,16 @@ import {
|
|||
} from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
// These suites exercise sharing; an inherited opt-out would silently disable it.
|
||||
const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE;
|
||||
beforeAll(() => {
|
||||
delete process.env.GITNEXUS_SHARED_STORE;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE;
|
||||
else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch;
|
||||
});
|
||||
|
||||
/**
|
||||
* #3352 — linked worktrees at one commit share one immutable commit graph in
|
||||
* the store under GITNEXUS_HOME, and a second worktree's analyze reuses it
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { execFileSync } from 'child_process';
|
|||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { withStoreLock } from '../../src/storage/shared-store-lifecycle.js';
|
||||
import { loadMeta } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
|
|
@ -12,6 +12,16 @@ import {
|
|||
} from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
// These suites exercise sharing; an inherited opt-out would silently disable it.
|
||||
const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE;
|
||||
beforeAll(() => {
|
||||
delete process.env.GITNEXUS_SHARED_STORE;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE;
|
||||
else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch;
|
||||
});
|
||||
|
||||
/**
|
||||
* #3352 U5 — linked worktrees keep one parse cache and ParsedFile store per
|
||||
* shared store, and one member's prune never evicts chunks another member
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { execFileSync } from 'child_process';
|
|||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
resolveSharedStore,
|
||||
|
|
@ -15,6 +15,16 @@ import {
|
|||
} from '../../src/storage/shared-store-lifecycle.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
// These suites exercise sharing; an inherited opt-out would silently disable it.
|
||||
const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE;
|
||||
beforeAll(() => {
|
||||
delete process.env.GITNEXUS_SHARED_STORE;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE;
|
||||
else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch;
|
||||
});
|
||||
|
||||
/**
|
||||
* #3352 U6 — clean removes only what no remaining member references.
|
||||
*/
|
||||
|
|
@ -123,6 +133,21 @@ describe('shared store clean (#3352)', () => {
|
|||
expect(await commitDirs(layout)).toHaveLength(1);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --gc without --force previews and deletes nothing', async () => {
|
||||
await analyze(wtA);
|
||||
await fs.writeFile(path.join(wtB, 'b.ts'), 'export function beta() { return 2; }\n');
|
||||
commitAll(wtB, 'b');
|
||||
await analyze(wtB);
|
||||
const slotB = layoutOf(wtB).checkoutSlot;
|
||||
git(main, 'worktree', 'remove', '--force', wtB);
|
||||
|
||||
const logs = await cleanIn(main, { gc: true });
|
||||
|
||||
expect(existsSync(slotB)).toBe(true);
|
||||
expect(await commitDirs(layoutOf(wtA))).toHaveLength(2);
|
||||
expect(logs.join('\n')).toMatch(/would drop 1 checkout\(s\) and remove 1 commit graph/);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --gc drops a deleted worktree and the graph only it referenced', async () => {
|
||||
await analyze(wtA);
|
||||
await fs.writeFile(path.join(wtB, 'b.ts'), 'export function beta() { return 2; }\n');
|
||||
|
|
@ -133,7 +158,7 @@ describe('shared store clean (#3352)', () => {
|
|||
expect(await commitDirs(layout)).toHaveLength(2);
|
||||
|
||||
git(main, 'worktree', 'remove', '--force', wtB);
|
||||
const logs = await cleanIn(main, { gc: true });
|
||||
const logs = await cleanIn(main, { gc: true, force: true });
|
||||
|
||||
expect(existsSync(slotB)).toBe(false);
|
||||
expect(await commitDirs(layout)).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,21 @@ import { execFileSync } from 'child_process';
|
|||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getStoragePaths, listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { resolveSharedStore, type SharedStoreLayout } from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
// These suites exercise sharing; an inherited opt-out would silently disable it.
|
||||
const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE;
|
||||
beforeAll(() => {
|
||||
delete process.env.GITNEXUS_SHARED_STORE;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE;
|
||||
else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch;
|
||||
});
|
||||
|
||||
/**
|
||||
* #3352 U7 — an independent clone joins a shared store only by explicit
|
||||
* opt-in, and only when its remote matches the member it names.
|
||||
|
|
|
|||
|
|
@ -2,12 +2,22 @@ import { execFileSync } from 'child_process';
|
|||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ensurePrivateSharedGraph } from '../../src/core/shared-store-analyze.js';
|
||||
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
|
||||
import { resolveSharedStore, type SharedStoreLayout } from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
// These suites exercise sharing; an inherited opt-out would silently disable it.
|
||||
const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE;
|
||||
beforeAll(() => {
|
||||
delete process.env.GITNEXUS_SHARED_STORE;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE;
|
||||
else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch;
|
||||
});
|
||||
|
||||
/**
|
||||
* #3352 U4 — a checkout that needs its own graph is seeded from the nearest
|
||||
* commit graph and updated incrementally instead of rebuilt from scratch.
|
||||
|
|
@ -274,7 +284,8 @@ describe('ensurePrivateSharedGraph', () => {
|
|||
const meta = await loadMeta(slot);
|
||||
delete meta?.graphPath;
|
||||
await saveMeta(slot, meta as NonNullable<typeof meta>);
|
||||
await fs.writeFile(path.join(slot, 'lbug'), 'private graph bytes');
|
||||
expect(await ensurePrivateSharedGraph(slot, () => {})).toBe(true);
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(false);
|
||||
expect(await fs.readFile(path.join(slot, 'lbug'), 'utf-8')).toBe('private graph bytes');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -314,3 +314,29 @@ describe('resolveStoragePath store tier', () => {
|
|||
expect(resolveStoragePath(wts[0])).toBe(registered);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slot naming edge cases (#3352 review)', () => {
|
||||
it.each(['CON.txt', 'com1.log', 'Lpt9.tar.gz'])(
|
||||
'prefixes a reserved Windows device name with an extension: %s',
|
||||
(base) => {
|
||||
expect(storageSlotName(path.join(path.sep, 'tmp', base))).toMatch(
|
||||
new RegExp(`^repository-${base.replace('.', '\\.')}-[0-9a-f]{12}$`),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps an ordinary name that only starts like a device name', () => {
|
||||
expect(storageSlotName(path.join(path.sep, 'tmp', 'console'))).toMatch(/^console-/);
|
||||
});
|
||||
|
||||
it('accepts a checkout slot whose name starts with two dots', async () => {
|
||||
const layout = sharedStoreLayout('..repo-0123456789ab', '/tmp/..checkout');
|
||||
const graph = path.join(commitGraphDir(layout, 'abc1234', 'deadbeef'), 'lbug');
|
||||
await fs.mkdir(layout.checkoutSlot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(layout.checkoutSlot, 'gitnexus.json'),
|
||||
JSON.stringify({ graphPath: graph }),
|
||||
);
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(graph);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,6 +98,14 @@ export default defineConfig({
|
|||
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
|
||||
'test/integration/lbug-interrupted-checkpoint-recovery.test.ts',
|
||||
'test/integration/lbug-readonly-init.test.ts',
|
||||
// Shared sibling store (#3352): each file runs real analyses and opens
|
||||
// the resulting LadybugDB graphs.
|
||||
'test/integration/shared-store-analyze.test.ts',
|
||||
'test/integration/shared-store-seed.test.ts',
|
||||
'test/integration/shared-store-cache.test.ts',
|
||||
'test/integration/shared-store-clean.test.ts',
|
||||
'test/integration/shared-store-clone-optin.test.ts',
|
||||
'test/integration/shared-store-adoption.test.ts',
|
||||
'test/integration/analyze-wal-checkpoint-failure.test.ts',
|
||||
'test/integration/lbug-non-ascii-path.test.ts',
|
||||
'test/integration/lbug-conn-serialization.test.ts',
|
||||
|
|
@ -202,6 +210,12 @@ export default defineConfig({
|
|||
// Excluded here because it is included by `lbug-db` above; a file
|
||||
// in two projects would be collected (and run) twice.
|
||||
'test/unit/incremental-index-extension-dml-gate.test.ts',
|
||||
'test/integration/shared-store-analyze.test.ts',
|
||||
'test/integration/shared-store-seed.test.ts',
|
||||
'test/integration/shared-store-cache.test.ts',
|
||||
'test/integration/shared-store-clean.test.ts',
|
||||
'test/integration/shared-store-clone-optin.test.ts',
|
||||
'test/integration/shared-store-adoption.test.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue