diff --git a/gitnexus/src/storage/storage-resolver.ts b/gitnexus/src/storage/storage-resolver.ts index b44dcc4ad..1578b04c1 100644 --- a/gitnexus/src/storage/storage-resolver.ts +++ b/gitnexus/src/storage/storage-resolver.ts @@ -690,6 +690,20 @@ export const requireDeletableStoragePath = async (entry: { const expectedStoragePath = defaultStoragePath(repoPath); const storageIsLocal = isRepositoryLocalStoragePath(repoPath, actualStoragePath); + // Lookup may normalize extended-length paths, but deletion keeps the legacy + // fail-closed rule for a local registry entry with mixed namespace spellings. + if ( + process.platform === 'win32' && + storageIsLocal && + expectedStoragePath.startsWith('\\\\?\\') !== actualStoragePath.startsWith('\\\\?\\') + ) { + throw new StorageDeletionError( + expectedStoragePath, + actualStoragePath, + undefined, + 'repository-local storage must use the same extended-length path spelling as the repository', + ); + } const comparableStorage = comparablePath(actualStoragePath); const comparableRepo = comparablePath(repoPath); const comparableRoot = comparablePath(path.parse(actualStoragePath).root); diff --git a/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts b/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts index 0e4730a00..35bf15915 100644 --- a/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts +++ b/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts @@ -74,6 +74,7 @@ import { type RegistryEntry, } from '../../src/storage/repo-manager.js'; import { resolveRegisteredRepoEntry } from '../../src/server/api.js'; +import { requireDeletableStoragePath } from '../../src/storage/storage-resolver.js'; /** * The lookup every registry consumer performs — `resolveRegistryEntry`, @@ -164,6 +165,19 @@ describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => { assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }), ).rejects.toThrow(); }); + + itOnWindows.each([ + ['D:\\Projects\\repo', '\\\\?\\D:\\Projects\\repo\\.gitnexus'], + ['\\\\server\\share\\repo', '\\\\?\\UNC\\server\\share\\repo\\.gitnexus'], + ['\\\\?\\UNC\\server\\share\\repo', '\\\\server\\share\\repo\\.gitnexus'], + ])( + 'rejects mixed namespace spellings in the shared deletion guard: %s', + async (repoPath, storagePath) => { + await expect(requireDeletableStoragePath({ path: repoPath, storagePath })).rejects.toThrow( + 'same extended-length path spelling', + ); + }, + ); }); // The consumer surface the fix exists for: an MCP `repo` argument or an diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index 875e7dc47..8d68c8ddd 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -1909,6 +1909,7 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { }); it('preserves lastCommit / fileHashes / the dirty flag, and never restates a stale count', async () => { + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '0'); const STALE_COMMIT = '1111111111111111111111111111111111111111'; const STALE_HASHES = { 'src/app.ts': 'stale-hash' }; const LIVE_EMBEDDING_COUNT = 42; @@ -2000,6 +2001,7 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { _existingEmbeddings: unknown, pipelineOptions: EmbeddingPipelineOptions, ): Promise => { + snapshots.beforeCheckpoint = await loadMeta(storagePath); // Window 1 — fires before ANY embedding row exists. await pipelineOptions.onCheckpointWindowStart?.({ nodesProcessed: 0, @@ -2047,6 +2049,21 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { expect(runEmbeddingPipeline).toHaveBeenCalledTimes(1); + // Windows writes in place and advances the dirty marker to FTS; a + // staged rebuild retains full-rebuild until publication. Neither marker + // may be changed or cleared by an embedding checkpoint. + const dirtyBeforeCheckpoint = snapshots.beforeCheckpoint?.incrementalInProgress; + expect(dirtyBeforeCheckpoint).toMatchObject({ + phase: process.platform === 'win32' ? 'fts' : 'full-rebuild', + }); + for (const snapshot of [ + snapshots.windowStart, + snapshots.postWindow, + snapshots.secondWindow, + ]) { + expect(snapshot?.incrementalInProgress).toEqual(dirtyBeforeCheckpoint); + } + // ── Window 1: the checkpoint landed… ────────────────────────────── expect(snapshots.windowStart).toMatchObject({ embeddingCheckpoint: { @@ -2063,7 +2080,7 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { expect(snapshots.windowStart).toMatchObject({ lastCommit: STALE_COMMIT, fileHashes: STALE_HASHES, - incrementalInProgress: { phase: 'full-rebuild' }, + incrementalInProgress: dirtyBeforeCheckpoint, stats: { embeddings: 7 }, }); expect(snapshots.windowStart?.lastCommit).not.toBe(currentCommit); @@ -2072,7 +2089,7 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { expect(snapshots.postWindow).toMatchObject({ lastCommit: STALE_COMMIT, fileHashes: STALE_HASHES, - incrementalInProgress: { phase: 'full-rebuild' }, + incrementalInProgress: dirtyBeforeCheckpoint, stats: { embeddings: LIVE_EMBEDDING_COUNT }, });