mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
feat(storage): resolve a shared checkout's graph and existing store slot (#3352)
getStoragePaths reads a flat slot's recorded graphPath only for checkout slots under the stores directory; other paths keep <storagePath>/lbug with no I/O. A recorded path outside the store's commit graphs is ignored. resolveStoragePath falls back to an existing store slot for an unregistered checkout, so reads never move to an empty slot. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eaf6d24b92
commit
91c423d107
5 changed files with 185 additions and 1 deletions
|
|
@ -51,6 +51,7 @@ import {
|
|||
type RepoMeta,
|
||||
} from './repo-meta.js';
|
||||
import { LBUG_DIRECTORY } from './storage-constants.js';
|
||||
import { resolveGraphPath } from './shared-store.js';
|
||||
import {
|
||||
defaultStoragePath,
|
||||
ensureStoragePathWritable,
|
||||
|
|
@ -234,7 +235,9 @@ export const getStoragePaths = (
|
|||
const baseDir = branch ? path.join(storagePath, BRANCHES_DIR, branchSlug(branch)) : storagePath;
|
||||
return {
|
||||
storagePath,
|
||||
lbugPath: path.join(baseDir, LBUG_DIRECTORY),
|
||||
// Branch slots are always private; a flat shared-store slot may read a
|
||||
// commit graph (#3352).
|
||||
lbugPath: branch ? path.join(baseDir, LBUG_DIRECTORY) : resolveGraphPath(storagePath),
|
||||
metaPath: path.join(baseDir, INDEX_METADATA_FILE), // Branch-specific metadata file
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ export interface RepoMeta {
|
|||
repoPath: string;
|
||||
/** Complete index directory selected for this successful analysis. */
|
||||
storagePath?: string;
|
||||
/**
|
||||
* Shared-store checkouts only (#3352): the graph this slot reads when it is
|
||||
* not `<storagePath>/lbug` — a commit graph under the same store. Resolved
|
||||
* and validated by `resolveGraphPath`.
|
||||
*/
|
||||
graphPath?: string;
|
||||
lastCommit: string;
|
||||
indexedAt: string;
|
||||
/** Missing on legacy metadata means the upstream-compatible `full` profile. */
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
||||
import { getGlobalDir } from './global-dir.js';
|
||||
import { INDEX_METADATA_FILE, LBUG_DIRECTORY } from './storage-constants.js';
|
||||
import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js';
|
||||
|
||||
export const SHARED_STORE_ENV = 'GITNEXUS_SHARED_STORE';
|
||||
|
|
@ -162,3 +163,53 @@ export const commitGraphDir = (
|
|||
}
|
||||
return path.join(layout.commitsDir, `${commit}-${featureKey}`);
|
||||
};
|
||||
|
||||
const isDirectChild = (parent: string, child: string): boolean => {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
};
|
||||
|
||||
/**
|
||||
* Store root for a checkout slot (`<stores>/<key>/checkouts/<slot>`), or null
|
||||
* when `storagePath` is not a checkout slot. Pure path check — no I/O.
|
||||
*/
|
||||
export const storeRootOfCheckoutSlot = (storagePath: string): string | null => {
|
||||
const storesRoot = path.resolve(getGlobalDir(), STORES_DIR);
|
||||
const slot = path.resolve(storagePath);
|
||||
const checkoutsDir = path.dirname(slot);
|
||||
const root = path.dirname(checkoutsDir);
|
||||
if (path.basename(checkoutsDir) !== 'checkouts') return null;
|
||||
if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return null;
|
||||
return root;
|
||||
};
|
||||
|
||||
/**
|
||||
* The graph a flat storage slot reads.
|
||||
*
|
||||
* Non-shared storage is always `<storagePath>/lbug`, with no I/O. A shared
|
||||
* checkout slot may record `graphPath` in its metadata, naming a commit graph
|
||||
* in the same store; any other recorded value (outside the store, a sibling's
|
||||
* private slot, unreadable metadata) falls back to the slot's own graph so a
|
||||
* hand-edited file cannot redirect reads.
|
||||
*/
|
||||
export const resolveGraphPath = (storagePath: string): string => {
|
||||
const own = path.join(storagePath, LBUG_DIRECTORY);
|
||||
const root = storeRootOfCheckoutSlot(storagePath);
|
||||
if (!root) return own;
|
||||
let recorded: unknown;
|
||||
try {
|
||||
recorded = (
|
||||
JSON.parse(fs.readFileSync(path.join(storagePath, INDEX_METADATA_FILE), 'utf-8')) as {
|
||||
graphPath?: unknown;
|
||||
}
|
||||
).graphPath;
|
||||
} catch {
|
||||
return own;
|
||||
}
|
||||
if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own;
|
||||
const graph = path.resolve(recorded);
|
||||
const commitDir = path.dirname(graph);
|
||||
const valid =
|
||||
path.basename(graph) === LBUG_DIRECTORY && isDirectChild(path.join(root, 'commits'), commitDir);
|
||||
return valid ? graph : own;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
LEGACY_METADATA_FILE,
|
||||
LBUG_DIRECTORY,
|
||||
} from './storage-constants.js';
|
||||
import { resolveSharedStore } from './shared-store.js';
|
||||
import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js';
|
||||
|
||||
export { STORAGE_PATH_ENV, STORAGE_ROOT_ENV };
|
||||
|
|
@ -308,6 +309,12 @@ export const resolveStoragePath = (repoPath: string): string => {
|
|||
const registered = registeredStoragePath(resolvedRepoPath);
|
||||
if (registered) return registered;
|
||||
|
||||
// Shared sibling store (#3352): an unregistered checkout whose slot already
|
||||
// exists (for example after the registry was reset). Checkouts only move INTO
|
||||
// the store at analyze time; a read never switches to an empty slot.
|
||||
const shared = resolveSharedStore(resolvedRepoPath);
|
||||
if (shared && fs.existsSync(shared.checkoutSlot)) return shared.checkoutSlot;
|
||||
|
||||
return defaultStoragePath(resolvedRepoPath);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import {
|
||||
commitGraphDir,
|
||||
isSharedStoreDisabled,
|
||||
resolveGraphPath,
|
||||
resolveSharedStore,
|
||||
resolveSharedStoreKey,
|
||||
SHARED_STORE_ENV,
|
||||
|
|
@ -15,8 +16,10 @@ import {
|
|||
import {
|
||||
STORAGE_PATH_ENV,
|
||||
STORAGE_ROOT_ENV,
|
||||
resolveStoragePath,
|
||||
storageSlotName,
|
||||
} from '../../../src/storage/storage-resolver.js';
|
||||
import { getStoragePaths } from '../../../src/storage/repo-manager.js';
|
||||
|
||||
const temporaryPaths: string[] = [];
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
|
|
@ -196,3 +199,117 @@ describe('commitGraphDir', () => {
|
|||
expect(() => commitGraphDir(layout, commit, featureKey)).toThrow(/Invalid/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGraphPath', () => {
|
||||
const writeSlotMeta = async (slot: string, meta: Record<string, unknown>): Promise<void> => {
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
await fs.writeFile(path.join(slot, 'gitnexus.json'), JSON.stringify(meta));
|
||||
};
|
||||
|
||||
it('returns <storagePath>/lbug for non-shared storage without reading metadata', async () => {
|
||||
const dir = await makeTempDir('gn-shared-local-');
|
||||
const storagePath = path.join(dir, '.gitnexus');
|
||||
await writeSlotMeta(storagePath, { graphPath: path.join(dir, 'elsewhere', 'lbug') });
|
||||
expect(resolveGraphPath(storagePath)).toBe(path.join(storagePath, 'lbug'));
|
||||
});
|
||||
|
||||
it('returns the recorded commit graph for a shared checkout slot', async () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a');
|
||||
const graph = path.join(commitGraphDir(layout, 'abc1234', 'deadbeef'), 'lbug');
|
||||
await writeSlotMeta(layout.checkoutSlot, { graphPath: graph });
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(graph);
|
||||
});
|
||||
|
||||
it('returns the slot graph when no graphPath is recorded', async () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a');
|
||||
await writeSlotMeta(layout.checkoutSlot, { repoPath: '/tmp/checkout-a' });
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(path.join(layout.checkoutSlot, 'lbug'));
|
||||
});
|
||||
|
||||
it('returns the slot graph when metadata is missing or unparseable', async () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a');
|
||||
const own = path.join(layout.checkoutSlot, 'lbug');
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(own);
|
||||
await fs.mkdir(layout.checkoutSlot, { recursive: true });
|
||||
await fs.writeFile(path.join(layout.checkoutSlot, 'gitnexus.json'), '{not json');
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(own);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'another store',
|
||||
() => path.join(home, 'stores', 'other-000000000000', 'commits', 'abc1234-deadbeef', 'lbug'),
|
||||
],
|
||||
[
|
||||
'a sibling private slot',
|
||||
(l: SharedStoreLayout) => path.join(l.checkoutsDir, 'sibling-000000000000', 'lbug'),
|
||||
],
|
||||
['outside GITNEXUS_HOME', () => '/etc/lbug'],
|
||||
['a relative path', () => 'commits/abc1234-deadbeef/lbug'],
|
||||
['a traversal', (l: SharedStoreLayout) => path.join(l.commitsDir, '..', '..', 'x', 'lbug')],
|
||||
[
|
||||
'a non-lbug file',
|
||||
(l: SharedStoreLayout) => path.join(l.commitsDir, 'abc1234-deadbeef', 'gitnexus.json'),
|
||||
],
|
||||
])('ignores a recorded graphPath in %s', async (_label, graphPathFor) => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a');
|
||||
await writeSlotMeta(layout.checkoutSlot, { graphPath: graphPathFor(layout) });
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(path.join(layout.checkoutSlot, 'lbug'));
|
||||
});
|
||||
|
||||
it('flows through getStoragePaths for the flat slot but not branch slots', async () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a');
|
||||
const graph = path.join(commitGraphDir(layout, 'abc1234', 'deadbeef'), 'lbug');
|
||||
await writeSlotMeta(layout.checkoutSlot, { graphPath: graph });
|
||||
expect(getStoragePaths('/tmp/checkout-a', undefined, layout.checkoutSlot).lbugPath).toBe(graph);
|
||||
expect(
|
||||
path.dirname(getStoragePaths('/tmp/checkout-a', 'feature', layout.checkoutSlot).lbugPath),
|
||||
).toMatch(/branches/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStoragePath store tier', () => {
|
||||
const savedPath = process.env[STORAGE_PATH_ENV];
|
||||
const savedRoot = process.env[STORAGE_ROOT_ENV];
|
||||
const savedSwitch = process.env[SHARED_STORE_ENV];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env[STORAGE_PATH_ENV];
|
||||
delete process.env[STORAGE_ROOT_ENV];
|
||||
delete process.env[SHARED_STORE_ENV];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of [
|
||||
[STORAGE_PATH_ENV, savedPath],
|
||||
[STORAGE_ROOT_ENV, savedRoot],
|
||||
[SHARED_STORE_ENV, savedSwitch],
|
||||
] as const) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an unregistered worktree on local storage while its slot does not exist', async () => {
|
||||
const { wts } = await makeRepo(['wt']);
|
||||
expect(resolveStoragePath(wts[0])).toBe(path.join(wts[0], '.gitnexus'));
|
||||
});
|
||||
|
||||
it('resolves an unregistered worktree to its existing store slot', async () => {
|
||||
const { wts } = await makeRepo(['wt']);
|
||||
const slot = layoutOf(wts[0]).checkoutSlot;
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
expect(resolveStoragePath(wts[0])).toBe(slot);
|
||||
});
|
||||
|
||||
it('prefers a registered storage path over an existing store slot', async () => {
|
||||
const { wts } = await makeRepo(['wt']);
|
||||
await fs.mkdir(layoutOf(wts[0]).checkoutSlot, { recursive: true });
|
||||
const registered = path.join(await makeTempDir('gn-shared-registered-'), 'index');
|
||||
await fs.writeFile(
|
||||
path.join(home, 'registry.json'),
|
||||
JSON.stringify([{ name: 'wt', path: wts[0], storagePath: registered }]),
|
||||
);
|
||||
expect(resolveStoragePath(wts[0])).toBe(registered);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue