mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
feat(storage): resolve the shared sibling-store identity and layout (#3352)
Linked worktrees of one repository resolve to one store under GITNEXUS_HOME/stores/<key>, keyed by the canonical git common dir. The resolver reads the .git entry directly, so hot paths spawn no git. Slot naming moves to a leaf module so storage-resolver and shared-store do not import each other. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49c37092da
commit
eaf6d24b92
4 changed files with 412 additions and 31 deletions
164
gitnexus/src/storage/shared-store.ts
Normal file
164
gitnexus/src/storage/shared-store.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Shared sibling index store (#3352).
|
||||
*
|
||||
* Linked worktrees of one repository share one store under the GitNexus home:
|
||||
*
|
||||
* <GITNEXUS_HOME>/stores/<key>/
|
||||
* caches/ parse-cache + durable ParsedFile store
|
||||
* commits/<commit>-<featureKey>/ one immutable graph per commit + settings
|
||||
* checkouts/<slot>/ one checkout's metadata, membership, and
|
||||
* private graph when it has local edits
|
||||
*
|
||||
* This module only resolves identity and names paths. It never creates,
|
||||
* writes, or deletes anything.
|
||||
*
|
||||
* Membership is decided from the `.git` entry alone (no `git` subprocess), so
|
||||
* the resolver stays cheap on hot paths (hooks, every CLI call). Only tree
|
||||
* roots participate — a subdirectory of a checkout never resolves to a store,
|
||||
* mirroring the `resolveRepoIdentityRoot` gate (#1259). A repository with no
|
||||
* linked worktree keeps its repository-local `.gitnexus`.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
||||
import { getGlobalDir } from './global-dir.js';
|
||||
import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js';
|
||||
|
||||
export const SHARED_STORE_ENV = 'GITNEXUS_SHARED_STORE';
|
||||
export const STORES_DIR = 'stores';
|
||||
|
||||
// Same canonical form as storage-resolver's `storageSlotName`, so a checkout's
|
||||
// slot name does not depend on which spelling (symlink, 8.3 name) reached it.
|
||||
const slotName = (p: string): string => {
|
||||
const resolved = path.resolve(p);
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = fs.realpathSync.native(resolved);
|
||||
} catch {
|
||||
canonical = resolved;
|
||||
}
|
||||
return slotNameForCanonicalPath(stripWindowsLongPathPrefix(canonical));
|
||||
};
|
||||
|
||||
const DISABLED_VALUES = new Set(['off', '0', 'false', 'no']);
|
||||
const COMMIT_RE = /^[0-9a-f]{7,64}$/;
|
||||
const FEATURE_KEY_RE = /^[0-9a-f]{8,64}$/;
|
||||
|
||||
export interface SharedStoreLayout {
|
||||
/** Store key: readable basename plus a hash of the canonical git common dir. */
|
||||
key: string;
|
||||
root: string;
|
||||
cachesDir: string;
|
||||
commitsDir: string;
|
||||
checkoutsDir: string;
|
||||
/** This checkout's slot — the registry `storagePath` for a shared checkout. */
|
||||
checkoutSlot: string;
|
||||
}
|
||||
|
||||
/** Sharing is off globally, or an explicit storage env override takes precedence. */
|
||||
export const isSharedStoreDisabled = (env: NodeJS.ProcessEnv = process.env): boolean => {
|
||||
const value = env[SHARED_STORE_ENV];
|
||||
if (value !== undefined && DISABLED_VALUES.has(value.trim().toLowerCase())) return true;
|
||||
return env[STORAGE_PATH_ENV] !== undefined || env[STORAGE_ROOT_ENV] !== undefined;
|
||||
};
|
||||
|
||||
const hasLinkedWorktrees = (commonDir: string): boolean => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(commonDir, 'worktrees')).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the git common dir for a tree root, or null when `checkoutPath` is
|
||||
* not a tree root (non-git folder or an arbitrary subdirectory).
|
||||
*/
|
||||
const readCommonDir = (checkoutPath: string): string | null => {
|
||||
const dotGit = path.join(checkoutPath, '.git');
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(dotGit);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (stat.isDirectory()) return dotGit;
|
||||
if (!stat.isFile()) return null;
|
||||
|
||||
// Linked worktree: `.git` is a file `gitdir: <common>/worktrees/<name>`, and
|
||||
// that per-worktree dir holds a `commondir` file pointing back at <common>.
|
||||
let gitDir: string;
|
||||
try {
|
||||
const match = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGit, 'utf-8'));
|
||||
if (!match) return null;
|
||||
gitDir = path.resolve(checkoutPath, match[1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf-8').trim();
|
||||
return path.resolve(gitDir, common);
|
||||
} catch {
|
||||
// Submodules also use a `gitdir:` file but have no `commondir`; they are
|
||||
// standalone repositories, not linked worktrees.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Store key for a checkout, or null when the checkout does not share.
|
||||
* Main checkout and every linked worktree of one repository get the same key.
|
||||
*/
|
||||
export const resolveSharedStoreKey = (
|
||||
checkoutPath: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null => {
|
||||
if (isSharedStoreDisabled(env)) return null;
|
||||
const commonDir = readCommonDir(path.resolve(checkoutPath));
|
||||
if (!commonDir || !hasLinkedWorktrees(commonDir)) return null;
|
||||
// `<repo>/.git` keys on `<repo>` for a readable name; a bare common dir
|
||||
// (`repo.git`) keys on itself. Both hash the canonical absolute path.
|
||||
const identity = path.basename(commonDir) === '.git' ? path.dirname(commonDir) : commonDir;
|
||||
return slotName(identity);
|
||||
};
|
||||
|
||||
/** Name every store path for `checkoutPath` under store `key`. */
|
||||
export const sharedStoreLayout = (key: string, checkoutPath: string): SharedStoreLayout => {
|
||||
const storesRoot = path.join(getGlobalDir(), STORES_DIR);
|
||||
const root = path.resolve(storesRoot, key);
|
||||
if (path.dirname(root) !== path.resolve(storesRoot)) {
|
||||
throw new Error(`Shared store key escapes the stores directory: ${key}`);
|
||||
}
|
||||
const checkoutsDir = path.join(root, 'checkouts');
|
||||
return {
|
||||
key,
|
||||
root,
|
||||
cachesDir: path.join(root, 'caches'),
|
||||
commitsDir: path.join(root, 'commits'),
|
||||
checkoutsDir,
|
||||
checkoutSlot: path.join(checkoutsDir, slotName(checkoutPath)),
|
||||
};
|
||||
};
|
||||
|
||||
/** Resolve the full layout for a checkout, or null when it does not share. */
|
||||
export const resolveSharedStore = (
|
||||
checkoutPath: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SharedStoreLayout | null => {
|
||||
const key = resolveSharedStoreKey(checkoutPath, env);
|
||||
return key ? sharedStoreLayout(key, checkoutPath) : null;
|
||||
};
|
||||
|
||||
/** Directory of the immutable graph for one commit and feature key. */
|
||||
export const commitGraphDir = (
|
||||
layout: SharedStoreLayout,
|
||||
commit: string,
|
||||
featureKey: string,
|
||||
): string => {
|
||||
if (!COMMIT_RE.test(commit)) throw new Error(`Invalid commit id for shared store: ${commit}`);
|
||||
if (!FEATURE_KEY_RE.test(featureKey)) {
|
||||
throw new Error(`Invalid feature key for shared store: ${featureKey}`);
|
||||
}
|
||||
return path.join(layout.commitsDir, `${commit}-${featureKey}`);
|
||||
};
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
|
@ -10,11 +9,9 @@ import {
|
|||
LEGACY_METADATA_FILE,
|
||||
LBUG_DIRECTORY,
|
||||
} from './storage-constants.js';
|
||||
import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js';
|
||||
|
||||
export const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH';
|
||||
export const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT';
|
||||
|
||||
const STORAGE_SLOT_HASH_LENGTH = 12;
|
||||
export { STORAGE_PATH_ENV, STORAGE_ROOT_ENV };
|
||||
|
||||
/** File-backend lock sidecars (`index-lock.ts`). Not ownership data. */
|
||||
const INDEX_LOCK_ARTIFACTS = new Set(['analyze.lock', 'analyze.lock.guard']);
|
||||
|
|
@ -211,37 +208,13 @@ const comparablePath = (value: string): string => {
|
|||
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
};
|
||||
|
||||
const sanitizeSlotBasename = (value: string): string => {
|
||||
// Linear: a quantified `/[. ]+$/` on attacker-controlled basenames is
|
||||
// js/polynomial-redos (CodeQL #1056). Cap first, then walk the tail once.
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
const code = sanitized.charCodeAt(end - 1);
|
||||
if (code !== 0x20 && code !== 0x2e) break;
|
||||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable slot name for one checkout inside a configured external storage root.
|
||||
* The canonical absolute path prevents symlink aliases from creating duplicate
|
||||
* slots, while the hash keeps same-basename repositories isolated.
|
||||
*/
|
||||
export const storageSlotName = (repoPath: string): string => {
|
||||
const canonical = canonicalRepoPath(repoPath);
|
||||
const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
const basename = sanitizeSlotBasename(path.basename(canonical));
|
||||
const digest = createHash('sha256')
|
||||
.update(identity)
|
||||
.digest('hex')
|
||||
.slice(0, STORAGE_SLOT_HASH_LENGTH);
|
||||
return `${basename}-${digest}`;
|
||||
};
|
||||
export const storageSlotName = (repoPath: string): string =>
|
||||
slotNameForCanonicalPath(canonicalRepoPath(repoPath));
|
||||
|
||||
export const defaultStoragePath = (repoPath: string): string =>
|
||||
path.join(resolveRepoPath(repoPath), GITNEXUS_DIR);
|
||||
|
|
|
|||
46
gitnexus/src/storage/storage-slot.ts
Normal file
46
gitnexus/src/storage/storage-slot.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Leaf naming primitives for external index slots.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'path';
|
||||
|
||||
export const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH';
|
||||
export const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT';
|
||||
|
||||
const STORAGE_SLOT_HASH_LENGTH = 12;
|
||||
|
||||
const sanitizeSlotBasename = (value: string): string => {
|
||||
// Linear: a quantified `/[. ]+$/` on attacker-controlled basenames is
|
||||
// js/polynomial-redos (CodeQL #1056). Cap first, then walk the tail once.
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
const code = sanitized.charCodeAt(end - 1);
|
||||
if (code !== 0x20 && code !== 0x2e) break;
|
||||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slot name for an already-canonical absolute path: sanitized basename plus a
|
||||
* short hash of the path (case-folded on Windows) so same-basename paths stay
|
||||
* isolated.
|
||||
*/
|
||||
export const slotNameForCanonicalPath = (canonical: string): string => {
|
||||
const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
const basename = sanitizeSlotBasename(path.basename(canonical));
|
||||
const digest = createHash('sha256')
|
||||
.update(identity)
|
||||
.digest('hex')
|
||||
.slice(0, STORAGE_SLOT_HASH_LENGTH);
|
||||
return `${basename}-${digest}`;
|
||||
};
|
||||
198
gitnexus/test/unit/storage/shared-store.test.ts
Normal file
198
gitnexus/test/unit/storage/shared-store.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
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,
|
||||
resolveSharedStore,
|
||||
resolveSharedStoreKey,
|
||||
SHARED_STORE_ENV,
|
||||
sharedStoreLayout,
|
||||
type SharedStoreLayout,
|
||||
} from '../../../src/storage/shared-store.js';
|
||||
import {
|
||||
STORAGE_PATH_ENV,
|
||||
STORAGE_ROOT_ENV,
|
||||
storageSlotName,
|
||||
} from '../../../src/storage/storage-resolver.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])),
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue