mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
feat(storage): adopt existing worktree indexes and report shared-store state (#3352)
A shared checkout gets <repo>/.gitnexus/store.json pointing at its store slot; resolution follows it only when it names that checkout's own slot. An existing local index seeds the slot and is left in place. status (text and --json) and doctor report the store, whether the graph is shared or private, and any leftover local index, which clean --local-index removes while keeping the pointer. With GITNEXUS_SHARED_STORE=off a previously shared checkout indexes into its own .gitnexus again and never writes a commit graph. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8cdb05c22e
commit
c86cca40b6
12 changed files with 447 additions and 14 deletions
|
|
@ -36,8 +36,11 @@ import { t } from './i18n/index.js';
|
|||
import { getGlobalDir } from '../storage/global-dir.js';
|
||||
import { STORES_DIR } from '../storage/shared-store.js';
|
||||
import {
|
||||
findLegacyLocalIndex,
|
||||
reclaimAfterSlotRemoval,
|
||||
reclaimSharedStore,
|
||||
removeLegacyLocalIndex,
|
||||
removeSharedStorePointer,
|
||||
type ReclaimResult,
|
||||
} from '../storage/shared-store-lifecycle.js';
|
||||
|
||||
|
|
@ -154,6 +157,11 @@ const cleanStaleBranchSlots = async (force: boolean): Promise<void> => {
|
|||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string =>
|
||||
bytes >= 1024 * 1024
|
||||
? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
: `${Math.ceil(bytes / 1024)} KB`;
|
||||
|
||||
const reportReclaim = (result: ReclaimResult | null): void => {
|
||||
if (!result) return;
|
||||
if (result.removed.length > 0) {
|
||||
|
|
@ -192,12 +200,40 @@ export const cleanCommand = async (options?: {
|
|||
stale?: boolean;
|
||||
branch?: string;
|
||||
gc?: boolean;
|
||||
localIndex?: boolean;
|
||||
}) => {
|
||||
if (options?.gc) {
|
||||
await collectSharedStores();
|
||||
return;
|
||||
}
|
||||
|
||||
// --local-index: delete a pre-adoption index left in <checkout>/.gitnexus
|
||||
// after the checkout moved into a shared store (#3352). Keeps the pointer.
|
||||
if (options?.localIndex) {
|
||||
const repo = await findRepo(process.cwd());
|
||||
if (!repo) {
|
||||
console.log(t('clean.notFoundHere'));
|
||||
return;
|
||||
}
|
||||
const legacy = await findLegacyLocalIndex(repo.repoPath, repo.storagePath);
|
||||
if (!legacy) {
|
||||
console.log(t('clean.localIndex.none'));
|
||||
return;
|
||||
}
|
||||
if (!options.force) {
|
||||
console.log(
|
||||
t('clean.localIndex.preview', { path: legacy.dir, size: formatBytes(legacy.bytes) }),
|
||||
);
|
||||
console.log(`\n${t('common.runForceConfirm')}`);
|
||||
return;
|
||||
}
|
||||
await removeLegacyLocalIndex(repo.repoPath, repo.storagePath);
|
||||
console.log(
|
||||
t('clean.localIndex.deleted', { path: legacy.dir, size: formatBytes(legacy.bytes) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// --stale: reclaim leftover per-branch slots whose recorded branch is not
|
||||
// a live local head (#3331). Exclusive arm before --branch.
|
||||
if (options?.stale) {
|
||||
|
|
@ -386,7 +422,9 @@ export const cleanCommand = async (options?: {
|
|||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(repo.repoPath);
|
||||
console.log(t('common.deleted', { target: storagePath }));
|
||||
reportReclaim(await reclaimAfterSlotRemoval(storagePath));
|
||||
const reclaim = await reclaimAfterSlotRemoval(storagePath);
|
||||
if (reclaim) await removeSharedStorePointer(repo.repoPath);
|
||||
reportReclaim(reclaim);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to delete:');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
|
||||
import { findLegacyLocalIndex } from '../storage/shared-store-lifecycle.js';
|
||||
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
|
||||
import { isHttpMode } from '../core/embeddings/http-client.js';
|
||||
import {
|
||||
|
|
@ -411,6 +412,14 @@ export const doctorCommand = async () => {
|
|||
branches: entry?.branches,
|
||||
});
|
||||
const leftoverLines = leftoverBranchSlotDoctorLines(slots);
|
||||
// A pre-adoption index left in <repo>/.gitnexus after this checkout moved
|
||||
// into a shared store (#3352).
|
||||
const legacy = await findLegacyLocalIndex(cwdRepo.repoPath, cwdRepo.storagePath);
|
||||
if (legacy) {
|
||||
leftoverLines.push(
|
||||
t('status.legacyLocalIndex', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }),
|
||||
);
|
||||
}
|
||||
if (leftoverLines.length === 0) return;
|
||||
console.log('');
|
||||
for (const line of leftoverLines) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export const en = {
|
|||
'list.clusters': 'Clusters',
|
||||
'list.processes': 'Processes',
|
||||
'list.unknown': 'unknown',
|
||||
'status.sharedStoreShared': 'Shared index: store {{key}}, shared graph for commit {{commit}}',
|
||||
'status.sharedStorePrivate': 'Shared index: store {{key}}, private graph (local changes)',
|
||||
'status.legacyLocalIndex':
|
||||
'Leftover local index: {{path}} ({{size}}); remove it with `gitnexus clean --local-index --force`',
|
||||
'status.notGitRepo': 'Not a git repository.',
|
||||
'status.staleKuzu': 'Repository has a stale KuzuDB index from a previous version.',
|
||||
'status.rebuildLadybug': 'Run: gitnexus analyze (rebuilds the index with LadybugDB)',
|
||||
|
|
@ -67,6 +71,10 @@ 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.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.',
|
||||
'clean.localIndex.deleted': 'Deleted the leftover local index at {{path}} ({{size}}).',
|
||||
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
|
||||
'clean.branchNotIndexed':
|
||||
'No indexed branch named "{{branch}}" for this repository. Use `gitnexus clean --stale` to reclaim leftover branch indexes, or `gitnexus list` to see recorded names.',
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ export const zhCN = {
|
|||
'list.clusters': '聚类',
|
||||
'list.processes': '流程',
|
||||
'list.unknown': 'unknown',
|
||||
'status.sharedStoreShared': '共享索引:存储 {{key}},提交 {{commit}} 的共享图',
|
||||
'status.sharedStorePrivate': '共享索引:存储 {{key}},私有图(有本地更改)',
|
||||
'status.legacyLocalIndex':
|
||||
'残留的本地索引:{{path}}({{size}});使用 `gitnexus clean --local-index --force` 删除',
|
||||
'status.notGitRepo': '当前目录不是 git 仓库。',
|
||||
'status.staleKuzu': '仓库包含旧版本遗留的 KuzuDB 索引。',
|
||||
'status.rebuildLadybug': '运行:gitnexus analyze (使用 LadybugDB 重建索引)',
|
||||
|
|
@ -64,6 +68,9 @@ export const zhCN = {
|
|||
'clean.shared.storeRemoved': '共享存储:已删除 {{path}}(没有剩余检出)。',
|
||||
'clean.gc.none': '没有可回收的共享存储。',
|
||||
'clean.gc.store': '共享存储 {{path}}:移除了 {{members}} 个检出,删除了 {{graphs}} 个提交图。',
|
||||
'clean.localIndex.none': '此检出中没有残留的本地索引。',
|
||||
'clean.localIndex.preview': '将删除 {{path}} 处残留的本地索引({{size}})。共享索引不受影响。',
|
||||
'clean.localIndex.deleted': '已删除 {{path}} 处残留的本地索引({{size}})。',
|
||||
'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
|
||||
'clean.branchNotIndexed':
|
||||
'该仓库没有名为 “{{branch}}” 的已索引分支。使用 `gitnexus clean --stale` 回收残留分支索引,或使用 `gitnexus list` 查看已记录名称。',
|
||||
|
|
|
|||
|
|
@ -360,6 +360,10 @@ program
|
|||
'--gc',
|
||||
'Drop shared-store checkouts whose worktree is gone and delete commit graphs nothing references',
|
||||
)
|
||||
.option(
|
||||
'--local-index',
|
||||
'Delete the index left in <repo>/.gitnexus after this checkout moved into a shared store',
|
||||
)
|
||||
.option(
|
||||
'--lbug-sidecars',
|
||||
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
* Shows the indexing status of the current repository.
|
||||
*/
|
||||
|
||||
import { resolveGraphPath, storeRootOfCheckoutSlot } from '../storage/shared-store.js';
|
||||
import { describeSharedGraph, findLegacyLocalIndex } from '../storage/shared-store-lifecycle.js';
|
||||
import path from 'path';
|
||||
import {
|
||||
getStoragePaths,
|
||||
|
|
@ -349,6 +351,17 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
!isWorkingTreeDirty(repo.repoPath));
|
||||
|
||||
const isUpToDate = metadataIsCurrent && contentIsCurrent;
|
||||
// Shared sibling store (#3352): which graph this checkout reads, and any
|
||||
// pre-adoption index still sitting in <repo>/.gitnexus.
|
||||
const storeRoot = storeRootOfCheckoutSlot(repo.storagePath);
|
||||
const sharedStore = storeRoot
|
||||
? {
|
||||
key: path.basename(storeRoot),
|
||||
graph: describeSharedGraph(resolveGraphPath(repo.storagePath), repo.storagePath),
|
||||
commit: activeMeta.lastCommit,
|
||||
}
|
||||
: null;
|
||||
const legacyLocalIndex = await findLegacyLocalIndex(repo.repoPath, repo.storagePath);
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
|
|
@ -369,6 +382,10 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
runnerIdentity: currentRunnerIdentity,
|
||||
},
|
||||
contentDrift: describeContentDrift(contentDrift),
|
||||
sharedStore,
|
||||
legacyLocalIndex: legacyLocalIndex
|
||||
? { path: legacyLocalIndex.dir, bytes: legacyLocalIndex.bytes }
|
||||
: null,
|
||||
status: isUpToDate ? 'up-to-date' : 'stale',
|
||||
}),
|
||||
);
|
||||
|
|
@ -382,6 +399,24 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
console.log(t('status.workspaceIndexLabel', { primary: repo.meta.branch ?? '' }));
|
||||
}
|
||||
|
||||
if (sharedStore) {
|
||||
console.log(
|
||||
sharedStore.graph === 'shared'
|
||||
? t('status.sharedStoreShared', {
|
||||
key: sharedStore.key,
|
||||
commit: sharedStore.commit.slice(0, 7),
|
||||
})
|
||||
: t('status.sharedStorePrivate', { key: sharedStore.key }),
|
||||
);
|
||||
}
|
||||
if (legacyLocalIndex) {
|
||||
console.log(
|
||||
t('status.legacyLocalIndex', {
|
||||
path: legacyLocalIndex.dir,
|
||||
size: `${Math.ceil(legacyLocalIndex.bytes / 1024)} KB`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
console.log(`${t('status.indexed')}: ${new Date(activeMeta.indexedAt).toLocaleString()}`);
|
||||
console.log(`${t('status.indexedCommit')}: ${activeMeta.lastCommit?.slice(0, 7)}`);
|
||||
console.log(`${t('status.currentCommit')}: ${currentCommit?.slice(0, 7)}`);
|
||||
|
|
|
|||
|
|
@ -165,8 +165,14 @@ import {
|
|||
defaultStoragePath,
|
||||
requireRegisteredStoragePath,
|
||||
requireStoragePath,
|
||||
resolveStoragePath,
|
||||
} from '../storage/storage-resolver.js';
|
||||
import { resolveSharedStore, type SharedStoreLayout } from '../storage/shared-store.js';
|
||||
import {
|
||||
isSharedStoreDisabled,
|
||||
resolveSharedStore,
|
||||
storeRootOfCheckoutSlot,
|
||||
type SharedStoreLayout,
|
||||
} from '../storage/shared-store.js';
|
||||
import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
|
||||
import {
|
||||
ensurePrivateSharedGraph,
|
||||
|
|
@ -1130,11 +1136,16 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
|
|||
const storageRequirements = options.force
|
||||
? ANALYZE_FORCE_STORAGE_REQUIREMENTS
|
||||
: ANALYZE_STORAGE_REQUIREMENTS;
|
||||
const sharedStore = options.noShare
|
||||
const sharingOff = options.noShare || isSharedStoreDisabled();
|
||||
const sharedStore = sharingOff
|
||||
? undefined
|
||||
: (resolveSharedStore(repoPath) ?? (await resolveOptedInStore(repoPath, options.shareWith)));
|
||||
// A checkout still registered in a store after sharing was turned off
|
||||
// indexes into its own `.gitnexus` again; its slot is left for `clean --gc`.
|
||||
const leavingStore =
|
||||
!sharedStore && storeRootOfCheckoutSlot(resolveStoragePath(repoPath)) !== null;
|
||||
const explicitStorage =
|
||||
sharedStore?.checkoutSlot ?? (options.noShare ? defaultStoragePath(repoPath) : undefined);
|
||||
sharedStore?.checkoutSlot ?? (leavingStore ? defaultStoragePath(repoPath) : undefined);
|
||||
const storagePath = explicitStorage
|
||||
? await requireRegisteredStoragePath(
|
||||
{ path: repoPath, storagePath: explicitStorage },
|
||||
|
|
@ -1169,9 +1180,11 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
|
|||
: {};
|
||||
const paths = getStoragePaths(repoPath, placement.branch, storagePath);
|
||||
const { metaPath } = paths;
|
||||
// Analyze always writes a shared slot's own graph; a recorded `graphPath`
|
||||
// only redirects readers.
|
||||
const lbugPath = sharedStore ? path.join(path.dirname(metaPath), LBUG_DIRECTORY) : paths.lbugPath;
|
||||
// Analyze always writes a store slot's own graph; a recorded `graphPath`
|
||||
// (an immutable commit graph) only redirects readers.
|
||||
const lbugPath = storeRootOfCheckoutSlot(storagePath)
|
||||
? path.join(path.dirname(metaPath), LBUG_DIRECTORY)
|
||||
: paths.lbugPath;
|
||||
return {
|
||||
storagePath,
|
||||
repoHasGit,
|
||||
|
|
@ -1308,7 +1321,7 @@ export async function runFullAnalysis(
|
|||
if (flatShared) {
|
||||
await publishSharedGraph(flatShared, repoPath, writeTarget.currentCommit, log);
|
||||
} else if (slotToLeave) {
|
||||
await leaveSharedStore(slotToLeave, log);
|
||||
await leaveSharedStore(repoPath, slotToLeave, log);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -33,11 +33,15 @@ import {
|
|||
commitGraphDir,
|
||||
resolveGraphPath,
|
||||
resolveSharedStore,
|
||||
SHARED_STORE_POINTER,
|
||||
sharedStoreLayout,
|
||||
storeRootOfCheckoutSlot,
|
||||
type SharedStoreLayout,
|
||||
} from '../storage/shared-store.js';
|
||||
import { reclaimAfterSlotRemoval } from '../storage/shared-store-lifecycle.js';
|
||||
import {
|
||||
reclaimAfterSlotRemoval,
|
||||
writeSharedStorePointer,
|
||||
} from '../storage/shared-store-lifecycle.js';
|
||||
import { GITNEXUS_DIR, INDEX_METADATA_FILE, LBUG_DIRECTORY } from '../storage/storage-constants.js';
|
||||
import { wipeLbugDbFiles } from './lbug/lbug-adapter.js';
|
||||
import { inspectLbugSidecars } from './lbug/sidecar-recovery.js';
|
||||
|
|
@ -339,6 +343,7 @@ export const publishSharedGraph = async (
|
|||
// The up-to-date fast path skips registration; a seeded or adopted checkout
|
||||
// must still end up registered at its slot.
|
||||
await registerRepo(repoPath, meta, { storagePath: slot });
|
||||
await writeSharedStorePointer(repoPath, layout);
|
||||
};
|
||||
|
||||
export { withStoreLock };
|
||||
|
|
@ -403,8 +408,13 @@ export const optedInSlotToLeave = async (repoPath: string): Promise<string | und
|
|||
* `<repo>/.gitnexus`: delete its old store slot and reclaim what only that
|
||||
* slot referenced.
|
||||
*/
|
||||
export const leaveSharedStore = async (previousSlot: string, log: Log): Promise<void> => {
|
||||
export const leaveSharedStore = async (
|
||||
repoPath: string,
|
||||
previousSlot: string,
|
||||
log: Log,
|
||||
): Promise<void> => {
|
||||
await fs.rm(previousSlot, { recursive: true, force: true });
|
||||
await fs.rm(path.join(repoPath, GITNEXUS_DIR, SHARED_STORE_POINTER), { force: true });
|
||||
await reclaimAfterSlotRemoval(previousSlot);
|
||||
log(`Shared store: left ${previousSlot}.`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,10 +16,11 @@ import { acquireIndexLock, requireExclusiveIndexLock } from './index-lock.js';
|
|||
import { loadMeta } from './repo-meta.js';
|
||||
import {
|
||||
resolveSharedStore,
|
||||
SHARED_STORE_POINTER,
|
||||
storeRootOfCheckoutSlot,
|
||||
type SharedStoreLayout,
|
||||
} from './shared-store.js';
|
||||
import { LBUG_DIRECTORY } from './storage-constants.js';
|
||||
import { GITNEXUS_DIR, LBUG_DIRECTORY } from './storage-constants.js';
|
||||
|
||||
type StoreRoot = Pick<SharedStoreLayout, 'root'>;
|
||||
|
||||
|
|
@ -155,3 +156,78 @@ export const describeSharedGraph = (
|
|||
path.resolve(graphPath) === path.join(path.resolve(storagePath), LBUG_DIRECTORY)
|
||||
? 'private'
|
||||
: 'shared';
|
||||
|
||||
/** Files a shared checkout keeps in `<checkout>/.gitnexus`; everything else there is legacy. */
|
||||
const POINTER_DIR_KEEP = new Set([SHARED_STORE_POINTER, '.gitignore']);
|
||||
|
||||
/**
|
||||
* Point `<checkout>/.gitnexus` at the checkout's store slot (#3352 R16). The
|
||||
* directory's other contents — a pre-adoption index — are left untouched.
|
||||
*/
|
||||
export const writeSharedStorePointer = async (
|
||||
checkoutPath: string,
|
||||
layout: Pick<SharedStoreLayout, 'key' | 'checkoutSlot'>,
|
||||
): Promise<void> => {
|
||||
const dir = path.join(checkoutPath, GITNEXUS_DIR);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(dir, SHARED_STORE_POINTER),
|
||||
`${JSON.stringify({ version: 1, storeKey: layout.key, checkoutSlot: layout.checkoutSlot }, null, 2)}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(dir, '.gitignore'), '*\n', { flag: 'wx' }).catch(() => {});
|
||||
};
|
||||
|
||||
/** Remove the pointer file (the directory stays if it holds anything else). */
|
||||
export const removeSharedStorePointer = async (checkoutPath: string): Promise<void> => {
|
||||
const dir = path.join(checkoutPath, GITNEXUS_DIR);
|
||||
await fs.rm(path.join(dir, SHARED_STORE_POINTER), { force: true });
|
||||
const rest = await listDir(dir);
|
||||
if (rest.length === 1 && rest[0] === '.gitignore')
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
const sizeOf = async (target: string): Promise<number> => {
|
||||
const stat = await fs.lstat(target).catch(() => null);
|
||||
if (!stat) return 0;
|
||||
if (!stat.isDirectory()) return stat.size;
|
||||
let total = 0;
|
||||
for (const name of await listDir(target)) total += await sizeOf(path.join(target, name));
|
||||
return total;
|
||||
};
|
||||
|
||||
export interface LegacyLocalIndex {
|
||||
dir: string;
|
||||
entries: string[];
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pre-adoption index left in `<checkout>/.gitnexus` after the checkout moved
|
||||
* into a shared store (#3352 R13). Null when the checkout is not shared or
|
||||
* the directory holds only the pointer.
|
||||
*/
|
||||
export const findLegacyLocalIndex = async (
|
||||
checkoutPath: string,
|
||||
storagePath: string,
|
||||
): Promise<LegacyLocalIndex | null> => {
|
||||
if (!storeRootOfCheckoutSlot(storagePath)) return null;
|
||||
const dir = path.join(checkoutPath, GITNEXUS_DIR);
|
||||
const entries = (await listDir(dir)).filter((name) => !POINTER_DIR_KEEP.has(name));
|
||||
if (entries.length === 0) return null;
|
||||
let bytes = 0;
|
||||
for (const name of entries) bytes += await sizeOf(path.join(dir, name));
|
||||
return { dir, entries, bytes };
|
||||
};
|
||||
|
||||
/** Delete a legacy local index, keeping the pointer. Returns what was removed. */
|
||||
export const removeLegacyLocalIndex = async (
|
||||
checkoutPath: string,
|
||||
storagePath: string,
|
||||
): Promise<LegacyLocalIndex | null> => {
|
||||
const legacy = await findLegacyLocalIndex(checkoutPath, storagePath);
|
||||
if (!legacy) return null;
|
||||
for (const name of legacy.entries) {
|
||||
await fs.rm(path.join(legacy.dir, name), { recursive: true, force: true });
|
||||
}
|
||||
return legacy;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,11 +23,16 @@ 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 { GITNEXUS_DIR, 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';
|
||||
export const STORES_DIR = 'stores';
|
||||
/**
|
||||
* Written into `<checkout>/.gitnexus/` when a checkout's index lives in a
|
||||
* shared store, so tools that probe the checkout can find it (#3352 R16).
|
||||
*/
|
||||
export const SHARED_STORE_POINTER = 'store.json';
|
||||
|
||||
// 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.
|
||||
|
|
@ -227,3 +232,25 @@ export const resolveGraphPath = (storagePath: string): string => {
|
|||
path.basename(graph) === LBUG_DIRECTORY && isDirectChild(path.join(root, 'commits'), commitDir);
|
||||
return valid ? graph : own;
|
||||
};
|
||||
|
||||
/**
|
||||
* The store slot named by `<checkout>/.gitnexus/store.json`, or null. The
|
||||
* recorded slot must be this checkout's own slot under the stores directory,
|
||||
* so a copied or hand-edited pointer cannot redirect reads to another index.
|
||||
*/
|
||||
export const readSharedStorePointer = (checkoutPath: string): string | null => {
|
||||
let recorded: unknown;
|
||||
try {
|
||||
recorded = (
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.join(checkoutPath, GITNEXUS_DIR, SHARED_STORE_POINTER), 'utf-8'),
|
||||
) as { checkoutSlot?: unknown }
|
||||
).checkoutSlot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return null;
|
||||
const slot = path.resolve(recorded);
|
||||
if (!storeRootOfCheckoutSlot(slot)) return null;
|
||||
return path.basename(slot) === slotName(checkoutPath) ? slot : null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,13 +9,25 @@ import {
|
|||
LEGACY_METADATA_FILE,
|
||||
LBUG_DIRECTORY,
|
||||
} from './storage-constants.js';
|
||||
import { resolveGraphPath, resolveSharedStore } from './shared-store.js';
|
||||
import {
|
||||
readSharedStorePointer,
|
||||
resolveGraphPath,
|
||||
resolveSharedStore,
|
||||
SHARED_STORE_POINTER,
|
||||
} from './shared-store.js';
|
||||
import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js';
|
||||
|
||||
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']);
|
||||
const INDEX_LOCK_ARTIFACTS = new Set([
|
||||
'analyze.lock',
|
||||
'analyze.lock.guard',
|
||||
// A shared-store checkout's pointer (#3352) and the ignore file beside it
|
||||
// are not index data either.
|
||||
SHARED_STORE_POINTER,
|
||||
'.gitignore',
|
||||
]);
|
||||
|
||||
export type StorageState =
|
||||
| 'invalid_param'
|
||||
|
|
@ -314,6 +326,8 @@ export const resolveStoragePath = (repoPath: string): string => {
|
|||
// 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;
|
||||
const pointed = readSharedStorePointer(resolvedRepoPath);
|
||||
if (pointed && fs.existsSync(pointed)) return pointed;
|
||||
|
||||
return defaultStoragePath(resolvedRepoPath);
|
||||
};
|
||||
|
|
|
|||
192
gitnexus/test/integration/shared-store-adoption.test.ts
Normal file
192
gitnexus/test/integration/shared-store-adoption.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
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 { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
readSharedStorePointer,
|
||||
resolveSharedStore,
|
||||
SHARED_STORE_ENV,
|
||||
type SharedStoreLayout,
|
||||
} from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* #3352 U8 — existing worktree indexes are adopted into the store without
|
||||
* being deleted, and status/doctor/clean make the leftover visible and
|
||||
* removable.
|
||||
*/
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const layoutOf = (checkout: string): SharedStoreLayout => {
|
||||
const layout = resolveSharedStore(checkout);
|
||||
expect(layout).not.toBeNull();
|
||||
return layout as SharedStoreLayout;
|
||||
};
|
||||
|
||||
describe('shared store adoption and reporting (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let savedSwitch: string | undefined;
|
||||
let savedCwd: string;
|
||||
let main: string;
|
||||
let wt: string;
|
||||
|
||||
const analyze = async (checkout: string) => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
return runFullAnalysis(checkout, {}, { onProgress: () => {} });
|
||||
};
|
||||
|
||||
const runIn = async (checkout: string, fn: () => Promise<void>): Promise<string[]> => {
|
||||
process.chdir(checkout);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
await fn();
|
||||
return log.mock.calls.map((c) => String(c[0]));
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
process.chdir(savedCwd);
|
||||
}
|
||||
};
|
||||
|
||||
const statusJson = async (checkout: string) => {
|
||||
const { statusCommand } = await import('../../src/cli/status.js');
|
||||
const lines = await runIn(checkout, () => statusCommand({ json: true }));
|
||||
return JSON.parse(lines[lines.length - 1]) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Index `wt` the pre-store way, into its own `.gitnexus`. */
|
||||
const legacyIndex = async (checkout: string): Promise<void> => {
|
||||
process.env[SHARED_STORE_ENV] = 'off';
|
||||
try {
|
||||
await analyze(checkout);
|
||||
} finally {
|
||||
delete process.env[SHARED_STORE_ENV];
|
||||
}
|
||||
expect(existsSync(path.join(checkout, '.gitnexus', 'lbug'))).toBe(true);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
savedCwd = process.cwd();
|
||||
tmpHome = await createTempDir('gitnexus-adopt-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-adopt-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
savedSwitch = process.env[SHARED_STORE_ENV];
|
||||
delete process.env[SHARED_STORE_ENV];
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export function alpha() { return 1; }\n');
|
||||
git(main, 'add', '-A');
|
||||
git(main, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init');
|
||||
wt = path.join(root, 'wt');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt', wt);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(savedCwd);
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
if (savedSwitch === undefined) delete process.env[SHARED_STORE_ENV];
|
||||
else process.env[SHARED_STORE_ENV] = savedSwitch;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('Covers F4: adopts a worktree index into the store and leaves the old files', async () => {
|
||||
await legacyIndex(wt);
|
||||
const legacyGraph = await fs.readFile(path.join(wt, '.gitnexus', 'lbug'));
|
||||
|
||||
const result = await analyze(wt);
|
||||
|
||||
expect(result.alreadyUpToDate).toBe(true); // seeded from its own index
|
||||
const layout = layoutOf(wt);
|
||||
expect((await fs.readdir(layout.commitsDir)).filter((n) => !n.startsWith('.'))).toHaveLength(1);
|
||||
expect(await fs.readFile(path.join(wt, '.gitnexus', 'lbug'))).toEqual(legacyGraph);
|
||||
expect(readSharedStorePointer(wt)).toBe(layout.checkoutSlot);
|
||||
expect((await listRegisteredRepos()).find((e) => e.path === wt)?.storagePath).toBe(
|
||||
layout.checkoutSlot,
|
||||
);
|
||||
}, 240_000);
|
||||
|
||||
it('status reports the shared graph and the leftover index with its removal command', async () => {
|
||||
await legacyIndex(wt);
|
||||
await analyze(wt);
|
||||
|
||||
const json = await statusJson(wt);
|
||||
expect(json.sharedStore).toEqual({
|
||||
key: layoutOf(wt).key,
|
||||
graph: 'shared',
|
||||
commit: git(wt, 'rev-parse', 'HEAD'),
|
||||
});
|
||||
expect(json.legacyLocalIndex).toMatchObject({ path: path.join(wt, '.gitnexus') });
|
||||
|
||||
const { statusCommand } = await import('../../src/cli/status.js');
|
||||
const text = (await runIn(wt, () => statusCommand({}))).join('\n');
|
||||
expect(text).toMatch(/shared graph for commit/);
|
||||
expect(text).toMatch(/gitnexus clean --local-index --force/);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --local-index deletes only the leftover files and keeps the pointer', async () => {
|
||||
await legacyIndex(wt);
|
||||
await analyze(wt);
|
||||
const { cleanCommand } = await import('../../src/cli/clean.js');
|
||||
|
||||
await runIn(wt, () => cleanCommand({ localIndex: true }));
|
||||
expect(existsSync(path.join(wt, '.gitnexus', 'lbug'))).toBe(true); // preview only
|
||||
|
||||
await runIn(wt, () => cleanCommand({ localIndex: true, force: true }));
|
||||
expect((await fs.readdir(path.join(wt, '.gitnexus'))).sort()).toEqual([
|
||||
'.gitignore',
|
||||
'store.json',
|
||||
]);
|
||||
expect((await statusJson(wt)).legacyLocalIndex).toBeNull();
|
||||
expect((await statusJson(wt)).status).toBe('up-to-date');
|
||||
}, 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');
|
||||
await analyze(wt);
|
||||
expect((await statusJson(wt)).sharedStore).toMatchObject({ graph: 'private' });
|
||||
}, 240_000);
|
||||
|
||||
it('indexes into .gitnexus with sharing turned off and never writes the commit graph', async () => {
|
||||
await analyze(wt);
|
||||
const layout = layoutOf(wt);
|
||||
const commitDir = (await fs.readdir(layout.commitsDir)).find((n) => !n.startsWith('.'));
|
||||
const commitGraph = path.join(layout.commitsDir, commitDir as string, 'lbug');
|
||||
const before = await fs.readFile(commitGraph);
|
||||
|
||||
await fs.writeFile(path.join(wt, 'b.ts'), 'export function beta() { return 2; }\n');
|
||||
process.env[SHARED_STORE_ENV] = 'off';
|
||||
try {
|
||||
await analyze(wt);
|
||||
} finally {
|
||||
delete process.env[SHARED_STORE_ENV];
|
||||
}
|
||||
|
||||
expect(existsSync(path.join(wt, '.gitnexus', 'lbug'))).toBe(true);
|
||||
expect((await listRegisteredRepos()).find((e) => e.path === wt)?.storagePath).toBe(
|
||||
path.join(wt, '.gitnexus'),
|
||||
);
|
||||
expect(await fs.readFile(commitGraph)).toEqual(before);
|
||||
}, 240_000);
|
||||
|
||||
it('rejects a pointer that names another checkout slot', async () => {
|
||||
await analyze(wt);
|
||||
const pointer = path.join(wt, '.gitnexus', 'store.json');
|
||||
const other = layoutOf(main).checkoutSlot;
|
||||
await fs.writeFile(pointer, JSON.stringify({ version: 1, checkoutSlot: other }));
|
||||
expect(readSharedStorePointer(wt)).toBeNull();
|
||||
await fs.writeFile(pointer, JSON.stringify({ version: 1, checkoutSlot: '/etc' }));
|
||||
expect(readSharedStorePointer(wt)).toBeNull();
|
||||
await fs.writeFile(pointer, 'not json');
|
||||
expect(readSharedStorePointer(wt)).toBeNull();
|
||||
}, 240_000);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue