/** * Integration test: WAL auto-checkpoint rename failure (#1741 / #1772). * * Drives the real `analyzeCommand` against a real LadybugDB instance and * provokes a genuine Ladybug-engine `IO exception: Error renaming file * .wal to .wal.checkpoint` by pre-planting a *directory* at the * `.wal.checkpoint` rename target. `fs.rename` (which Ladybug's native * `LocalFileSystem` ultimately invokes) cannot overwrite a non-empty * directory with a file on either POSIX or Windows, and Ladybug's * `doInitLbug` orphan-cleanup uses `fs.unlink` which fails on a directory * — so the blocker survives initialization and the next auto-checkpoint * fires the natural rename failure that motivated PR #1772. * * No test-only hooks, no env-var fault toggles in production code: we use * the same `GITNEXUS_WAL_CHECKPOINT_THRESHOLD=1` knob that real users have * available to force checkpointing on every write, then arrange a real * filesystem state that makes the rename impossible. * * Verifies that: * 1. The CLI exits non-zero. * 2. stderr contains the actionable recovery hint pointing at * `--wal-checkpoint-threshold 67108864` (the * `RECOMMENDED_WAL_CHECKPOINT_THRESHOLD` constant in `analyze.ts`). * 3. The recovery message references the * `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` env var as a parallel route. * * Empirically confirmed portable on Windows; the same mechanism is * expected to work on POSIX (`rename(2)` fails with `EISDIR`/`ENOTEMPTY` * when the target is a non-empty directory). If a future Ladybug release * changes the rename ordering, the loose match on the recovery hint * (rather than the exact engine error wording) keeps this test stable. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; import { spawnSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; import { fileURLToPath } from 'url'; import { cleanupTempDirSync } from '../helpers/test-db.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); let tmpParent: string; let suiteGitnexusHome: string; let repoPath: string; beforeAll(() => { tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-wal-checkpoint-e2e-')); suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-wal-checkpoint-home-')); repoPath = path.join(tmpParent, 'mini-repo'); fs.cpSync(FIXTURE_SRC, repoPath, { recursive: true }); spawnSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); spawnSync('git', ['add', '-A'], { cwd: repoPath, stdio: 'pipe' }); spawnSync('git', ['commit', '-m', 'initial commit'], { cwd: repoPath, stdio: 'pipe', env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test', }, }); }); afterAll(() => { if (tmpParent) cleanupTempDirSync(tmpParent); if (suiteGitnexusHome) cleanupTempDirSync(suiteGitnexusHome); }); const runAnalyze = (walCheckpointThreshold: string) => spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'analyze', '--skip-skills'], { cwd: repoPath, encoding: 'utf8', // Generous timeout: the test does real CSV/COPY work before the // first failing checkpoint, and CI runners are slow. timeout: process.env.CI ? 120_000 : 60_000, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, GITNEXUS_HOME: suiteGitnexusHome, // Skip ensureHeap re-exec (which drops the tsx loader). NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), GITNEXUS_WAL_CHECKPOINT_THRESHOLD: walCheckpointThreshold, CI: '1', }, }); describe('analyze WAL auto-checkpoint rename failure (real lbug, no mocks)', () => { it('surfaces the --wal-checkpoint-threshold recovery hint when the rename target is blocked', () => { // The checkpoint rename target must be a PREDICTABLE path so the blocker // can be pre-planted. A full rebuild builds into a per-run // `lbug.staging.` and checkpoints `lbug.staging..wal.checkpoint` // (#2658) — an unknowable name. An INCREMENTAL run instead writes the live // index in place, so its auto-checkpoint targets the fixed // `lbug.wal.checkpoint`. So: first do a clean full analyze to create the // index, then plant the blocker and drive an incremental analyze into it. const storageDir = path.join(repoPath, '.gitnexus'); // 1) Clean full analyze (no blocker) — builds the index into staging and // swaps it in. Must succeed; the staging checkpoint name is unblocked. // Use the normal 64 MiB threshold while seeding the index. Checkpointing // every setup write needlessly stalls schema and FTS creation on Windows. const first = runAnalyze('67108864'); expect(first.status === null ? 'timeout' : first.status, first.stderr).toBe(0); // 2) Change a tracked source file and commit, so the next analyze is an // incremental writeback (in-place), not a full rebuild. const churnFile = path.join(repoPath, 'src', 'logger.ts'); fs.appendFileSync(churnFile, `\nexport const walChurnMarker = ${Date.now()};\n`); const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test', }; spawnSync('git', ['add', '-A'], { cwd: repoPath, stdio: 'pipe' }); spawnSync('git', ['commit', '-m', 'churn for incremental'], { cwd: repoPath, stdio: 'pipe', env: gitEnv, }); // 3) Plant a non-empty directory at `lbug.wal.checkpoint`, the fixed rename // target of the in-place checkpoint. `fs.rename` cannot overwrite a // non-empty directory, and the adapter's orphan-sidecar cleanup uses // `fs.unlink` (which fails on a directory) — so the blocker persists through // `doInitLbug` and trips the auto-checkpoint the incremental writeback // forces at `GITNEXUS_WAL_CHECKPOINT_THRESHOLD=1`. const blockerDir = path.join(storageDir, 'lbug.wal.checkpoint'); fs.rmSync(blockerDir, { recursive: true, force: true }); fs.mkdirSync(blockerDir, { recursive: true }); fs.writeFileSync(path.join(blockerDir, 'blocker'), 'cannot-be-renamed-over'); // 4) Incremental analyze into the blocked checkpoint target. const result = runAnalyze('1'); const combined = `${result.stderr}\n${result.stdout}`; // The blocked checkpoint must produce an error, rather than a timeout. expect(result.status, combined).not.toBeNull(); expect(result.status).not.toBe(0); // Recovery hint must reference the CLI flag and the recommended // 64 MiB threshold (67_108_864 bytes). Both come from the // RECOMMENDED_WAL_CHECKPOINT_THRESHOLD constant in analyze.ts; keep // those values in sync with this assertion if the constant changes. expect(combined).toContain('gitnexus analyze --wal-checkpoint-threshold'); expect(combined).toContain('67108864'); // The env-var route should be advertised alongside the flag. expect(combined).toContain('GITNEXUS_WAL_CHECKPOINT_THRESHOLD'); }, 180_000); });