mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
- analyze --no-share leaves the shared store even when routed to a branch sub-index (gate on sharedStore, not placement.branch) - clean --gc aborts a store whose member listing is unreadable instead of treating it as empty, and skips non-directory entries under stores/ - reusing an existing commit graph no longer fails a finished analyze when the redundant private graph cannot be wiped - a store pointer holding JSON null, a number, a string, or an array is treated as invalid - clean, remove, and DELETE /api/repo hold the checkout slot's index lock while deleting the slot - the server analyze launcher waits on the checkout slot the worker actually writes - sync the Factory hook copy; isolate hook tests from storage overrides; use a junction for the Windows worktree alias; the relative GITNEXUS_HOME test now sets a relative home Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
import { execFileSync } from 'child_process';
|
|
import fs from 'fs/promises';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import {
|
|
commitGraphDir,
|
|
isSharedStoreDisabled,
|
|
readSharedStorePointer,
|
|
resolveGraphPath,
|
|
resolveSharedStore,
|
|
resolveSharedStoreKey,
|
|
SHARED_STORE_ENV,
|
|
sharedStoreLayout,
|
|
type SharedStoreLayout,
|
|
} from '../../../src/storage/shared-store.js';
|
|
import { sanitizeSlotBasename } from '../../../src/storage/storage-slot.js';
|
|
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;
|
|
let home: string;
|
|
|
|
const makeTempDir = async (prefix: string): Promise<string> => {
|
|
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)));
|
|
temporaryPaths.push(dir);
|
|
return dir;
|
|
};
|
|
|
|
const git = (cwd: string, ...args: string[]): void => {
|
|
execFileSync('git', args, { cwd, stdio: 'ignore' });
|
|
};
|
|
|
|
/** A committed repo; `worktrees` names linked worktrees created beside it. */
|
|
const makeRepo = async (worktrees: string[] = []): Promise<{ main: string; wts: string[] }> => {
|
|
const parent = await makeTempDir('gn-shared-store-');
|
|
const main = path.join(parent, 'main');
|
|
await fs.mkdir(main);
|
|
git(main, 'init', '-q', '-b', 'main');
|
|
git(
|
|
main,
|
|
'-c',
|
|
'user.email=t@t',
|
|
'-c',
|
|
'user.name=t',
|
|
'commit',
|
|
'-q',
|
|
'--allow-empty',
|
|
'-m',
|
|
'init',
|
|
);
|
|
const wts = worktrees.map((name) => {
|
|
const wt = path.join(parent, name);
|
|
git(main, 'worktree', 'add', '-q', '-b', name, wt);
|
|
return wt;
|
|
});
|
|
return { main, wts };
|
|
};
|
|
|
|
// Only these keys are read by isSharedStoreDisabled/resolveSharedStoreKey.
|
|
const cleanEnv = (): NodeJS.ProcessEnv => ({});
|
|
|
|
const layoutOf = (checkoutPath: string): SharedStoreLayout => {
|
|
const layout = resolveSharedStore(checkoutPath, cleanEnv());
|
|
expect(layout).not.toBeNull();
|
|
return layout as SharedStoreLayout;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
home = await makeTempDir('gn-shared-home-');
|
|
process.env.GITNEXUS_HOME = home;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
|
else process.env.GITNEXUS_HOME = savedHome;
|
|
await Promise.all(
|
|
temporaryPaths.splice(0).map((p) => fs.rm(p, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
describe('resolveSharedStoreKey', () => {
|
|
it('gives the main checkout and every linked worktree the same key', async () => {
|
|
const { main, wts } = await makeRepo(['wt-a', 'wt-b']);
|
|
const keys = [main, ...wts].map((p) => resolveSharedStoreKey(p, cleanEnv()));
|
|
expect(keys[0]).toMatch(/^main-[0-9a-f]{12}$/);
|
|
expect(new Set(keys).size).toBe(1);
|
|
});
|
|
|
|
it('keeps a repository without linked worktrees on local storage', async () => {
|
|
const { main } = await makeRepo();
|
|
expect(resolveSharedStoreKey(main, cleanEnv())).toBeNull();
|
|
});
|
|
|
|
it('gives two unrelated repos with the same basename different keys', async () => {
|
|
const a = await makeRepo(['wt']);
|
|
const b = await makeRepo(['wt']);
|
|
const keyA = resolveSharedStoreKey(a.main, cleanEnv());
|
|
const keyB = resolveSharedStoreKey(b.main, cleanEnv());
|
|
expect(keyA).not.toBeNull();
|
|
expect(keyA).not.toBe(keyB);
|
|
});
|
|
|
|
it('does not share a subdirectory of a checkout', async () => {
|
|
const { main } = await makeRepo(['wt']);
|
|
const sub = path.join(main, 'pkg');
|
|
await fs.mkdir(sub);
|
|
expect(resolveSharedStoreKey(sub, cleanEnv())).toBeNull();
|
|
});
|
|
|
|
it('does not share a non-git folder', async () => {
|
|
const dir = await makeTempDir('gn-shared-nogit-');
|
|
expect(resolveSharedStoreKey(dir, cleanEnv())).toBeNull();
|
|
});
|
|
|
|
it('does not treat a gitdir file without commondir (submodule shape) as a worktree', async () => {
|
|
const dir = await makeTempDir('gn-shared-submodule-');
|
|
const modules = path.join(dir, 'modules', 'sub');
|
|
await fs.mkdir(modules, { recursive: true });
|
|
await fs.writeFile(path.join(dir, '.git'), `gitdir: ${modules}\n`);
|
|
expect(resolveSharedStoreKey(dir, cleanEnv())).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
[{ [SHARED_STORE_ENV]: 'off' }],
|
|
[{ [SHARED_STORE_ENV]: 'FALSE' }],
|
|
[{ [SHARED_STORE_ENV]: '0' }],
|
|
[{ [STORAGE_PATH_ENV]: '/tmp/explicit-index' }],
|
|
[{ [STORAGE_ROOT_ENV]: '/tmp/index-root' }],
|
|
])('returns null for every checkout when disabled by %o', async (env) => {
|
|
const { main, wts } = await makeRepo(['wt']);
|
|
expect(isSharedStoreDisabled(env)).toBe(true);
|
|
expect(resolveSharedStoreKey(main, env)).toBeNull();
|
|
expect(resolveSharedStoreKey(wts[0], env)).toBeNull();
|
|
});
|
|
|
|
it('treats an unrecognized switch value as enabled', () => {
|
|
expect(isSharedStoreDisabled({ [SHARED_STORE_ENV]: 'on' })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('sharedStoreLayout', () => {
|
|
it('places every area inside the store under GITNEXUS_HOME', async () => {
|
|
const { main, wts } = await makeRepo(['wt']);
|
|
const layout = layoutOf(wts[0]);
|
|
const root = path.join(home, 'stores', layout.key);
|
|
expect(layout).toEqual({
|
|
key: resolveSharedStoreKey(main, cleanEnv()),
|
|
root,
|
|
cachesDir: path.join(root, 'caches'),
|
|
commitsDir: path.join(root, 'commits'),
|
|
checkoutsDir: path.join(root, 'checkouts'),
|
|
checkoutSlot: path.join(root, 'checkouts', storageSlotName(wts[0])),
|
|
canonicalCheckout: main,
|
|
});
|
|
});
|
|
|
|
it('gives each checkout its own slot', async () => {
|
|
const { main, wts } = await makeRepo(['wt']);
|
|
const a = layoutOf(main);
|
|
const b = layoutOf(wts[0]);
|
|
expect(a.root).toBe(b.root);
|
|
expect(a.checkoutSlot).not.toBe(b.checkoutSlot);
|
|
});
|
|
|
|
it('maps a symlinked spelling of a worktree to the same slot', async () => {
|
|
const { wts } = await makeRepo(['wt']);
|
|
const link = path.join(await makeTempDir('gn-shared-link-'), 'alias');
|
|
await fs.symlink(wts[0], link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
expect(layoutOf(link).checkoutSlot).toBe(layoutOf(wts[0]).checkoutSlot);
|
|
});
|
|
|
|
it.each(['..', '../escape', 'a/../../b'])(
|
|
'rejects a key that escapes the stores dir: %s',
|
|
(key) => {
|
|
expect(() => sharedStoreLayout(key, '/tmp/x')).toThrow(/escapes the stores directory/);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('commitGraphDir', () => {
|
|
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout');
|
|
|
|
it('names one directory per commit and feature key', () => {
|
|
expect(commitGraphDir(layout, 'abc1234', 'deadbeef')).toBe(
|
|
path.join(layout.commitsDir, 'abc1234-deadbeef'),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
['../../x', 'deadbeef'],
|
|
['ABC1234', 'deadbeef'],
|
|
['abc1234', '../etc'],
|
|
['abc1234', 'short'],
|
|
])('rejects commit %s / feature key %s', (commit, featureKey) => {
|
|
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')],
|
|
[
|
|
'in-progress publish staging',
|
|
(l: SharedStoreLayout) => path.join(l.commitsDir, '.publish-0f3c', '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);
|
|
});
|
|
});
|
|
|
|
describe('slot naming edge cases (#3352 review)', () => {
|
|
it.each(['CON.txt', 'com1.log', 'Lpt9.tar.gz'])(
|
|
'prefixes a reserved device name with an extension on Windows only: %s',
|
|
(base) => {
|
|
expect(sanitizeSlotBasename(base, 'win32')).toBe(`repository-${base}`);
|
|
expect(sanitizeSlotBasename(base, 'linux')).toBe(base);
|
|
},
|
|
);
|
|
|
|
it.each(['CON', 'nul', 'COM1'])('prefixes an exact device name on every platform: %s', (base) => {
|
|
expect(sanitizeSlotBasename(base, 'win32')).toBe(`repository-${base}`);
|
|
expect(sanitizeSlotBasename(base, 'linux')).toBe(`repository-${base}`);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('bare repositories (#3352 review)', () => {
|
|
it('shares worktrees of a bare repository, keyed by the bare dir, with no main checkout', async () => {
|
|
const parent = await makeTempDir('gn-shared-bare-');
|
|
const src = path.join(parent, 'src');
|
|
await fs.mkdir(src);
|
|
git(src, 'init', '-q', '-b', 'main');
|
|
git(
|
|
src,
|
|
'-c',
|
|
'user.email=t@t',
|
|
'-c',
|
|
'user.name=t',
|
|
'commit',
|
|
'-q',
|
|
'--allow-empty',
|
|
'-m',
|
|
'init',
|
|
);
|
|
const bare = path.join(parent, 'repo.git');
|
|
git(parent, 'clone', '-q', '--bare', src, bare);
|
|
const wtA = path.join(parent, 'wt-a');
|
|
const wtB = path.join(parent, 'wt-b');
|
|
git(bare, 'worktree', 'add', '-q', '-b', 'a', wtA);
|
|
git(bare, 'worktree', 'add', '-q', '-b', 'b', wtB);
|
|
|
|
const a = layoutOf(wtA);
|
|
expect(layoutOf(wtB).root).toBe(a.root);
|
|
expect(a.key).toMatch(/^repo\.git-[0-9a-f]{12}$/);
|
|
expect(a.canonicalCheckout).toBeNull();
|
|
// The bare dir itself has no working tree to analyze.
|
|
expect(resolveSharedStoreKey(bare, cleanEnv())).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('readSharedStorePointer malformed content (#3352 review)', () => {
|
|
it.each(['null', '42', '"text"', '[]'])(
|
|
'returns null for a pointer whose JSON is %s',
|
|
async (body) => {
|
|
const dir = await makeTempDir('gn-shared-ptr-');
|
|
await fs.mkdir(path.join(dir, '.gitnexus'));
|
|
await fs.writeFile(path.join(dir, '.gitnexus', 'store.json'), body);
|
|
expect(readSharedStorePointer(dir)).toBeNull();
|
|
},
|
|
);
|
|
});
|