GitNexus/gitnexus/test/integration/multi-branch-analyze.test.ts

247 lines
11 KiB
TypeScript

import { runtimeParseIdentity } from '../helpers/runtime-parse-identity.js';
import { execSync, execFileSync } from 'child_process';
import fs from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { getStoragePaths, loadMeta, listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { createTempDir } from '../helpers/test-db.js';
import { isDetectRejectWarning } from '../helpers/detect-reject-warning.js';
const parseIdentity = await runtimeParseIdentity();
/**
* #2106/#2354 — branch handling end-to-end. Proves that a plain analyze
* always updates the flat workspace index (following the checked-out working
* tree, no `branches/` sub-directory, no slot-ownership friction), that an
* explicit `--branch` run pins a separate index under
* `.gitnexus/branches/<slug>/` without touching the flat slot, and that a
* pinned sub-index shadowed by a later plain analyze on the same branch is
* cleaned up.
*/
const git = (args: string[], cwd: string): string =>
execSync(['git', ...args].join(' '), { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
const commit = (cwd: string, message: string): void => {
git(['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', message], cwd);
};
describe('multi-branch analyze (#2106)', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let savedGitnexusHome: string | undefined;
beforeEach(async () => {
// Isolate the global registry so the full analyze runs below don't write
// to the developer's real ~/.gitnexus/registry.json.
tmpHome = await createTempDir('gitnexus-multibranch-home-');
savedGitnexusHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
});
afterEach(async () => {
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedGitnexusHome;
await tmpHome.cleanup();
});
it('a plain analyze follows a branch switch into the flat workspace slot (#2354)', async () => {
const tmp = await createTempDir('gitnexus-multibranch-');
const repo = tmp.dbPath;
try {
git(['init'], repo);
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
git(['add', '-A'], repo);
commit(repo, 'a');
// Normalise the branch name across git defaults (master vs main).
git(['branch', '-M', 'main'], repo);
const mainCommit = git(['rev-parse', 'HEAD'], repo);
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(repo, {}, { onProgress: () => {} });
// First analyze lands in the flat slot, byte-identical layout.
const flat = getStoragePaths(repo);
expect(path.dirname(flat.lbugPath)).toBe(flat.storagePath);
expect(existsSync(flat.lbugPath)).toBe(true);
const flatMeta = await loadMeta(flat.storagePath);
expect(flatMeta?.branch).toBe('main');
expect(flatMeta?.lastCommit).toBe(mainCommit);
// Switch to a feature branch with different content and re-analyze.
git(['checkout', '-b', 'feature/x'], repo);
await fs.writeFile(path.join(repo, 'b.ts'), 'export const b = 2;\n');
git(['add', '-A'], repo);
commit(repo, 'b');
const featureCommit = git(['rev-parse', 'HEAD'], repo);
expect(featureCommit).not.toBe(mainCommit);
await runFullAnalysis(repo, {}, { onProgress: () => {} });
// The flat workspace index followed the working tree — updated in place,
// no `branches/` sub-directory, no slot-ownership error or warning.
expect(existsSync(flat.lbugPath)).toBe(true);
const flatMetaAfter = await loadMeta(flat.storagePath);
expect(flatMetaAfter?.branch).toBe('feature/x');
expect(flatMetaAfter?.lastCommit).toBe(featureCommit);
expect(existsSync(path.join(flat.storagePath, 'branches'))).toBe(false);
// The registry follows along: one entry, relabelled, no branches[].
const entries = await listRegisteredRepos();
const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo));
expect(entry).toBeDefined();
expect(entry?.branch).toBe('feature/x');
expect(entry?.lastCommit).toBe(featureCommit);
expect(entry?.branches).toBeUndefined();
} finally {
await tmp.cleanup();
}
}, 180_000);
it('an explicit --branch run pins a sub-index; a later plain analyze on that branch reclaims it', async () => {
const tmp = await createTempDir('gitnexus-multibranch-pin-');
const repo = tmp.dbPath;
try {
git(['init'], repo);
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
git(['add', '-A'], repo);
commit(repo, 'a');
git(['branch', '-M', 'main'], repo);
const mainCommit = git(['rev-parse', 'HEAD'], repo);
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(repo, {}, { onProgress: () => {} });
const flat = getStoragePaths(repo);
// main records its live chunk keys so a later branch prune can keep them.
const mainCacheKeys = (await loadMeta(flat.storagePath))?.cacheKeys ?? [];
expect(mainCacheKeys.length).toBeGreaterThan(0);
// Pin the feature branch into its own sub-index with explicit --branch.
git(['checkout', '-b', 'feature/x'], repo);
await fs.writeFile(path.join(repo, 'b.ts'), 'export const b = 2;\n');
git(['add', '-A'], repo);
commit(repo, 'b');
const featureCommit = git(['rev-parse', 'HEAD'], repo);
await runFullAnalysis(repo, { branch: 'feature/x' }, { onProgress: () => {} });
// The flat (main) index is untouched — NOT overwritten by the pinned run.
expect(existsSync(flat.lbugPath)).toBe(true);
const flatMetaAfter = await loadMeta(flat.storagePath);
expect(flatMetaAfter?.branch).toBe('main');
expect(flatMetaAfter?.lastCommit).toBe(mainCommit);
// The pinned index is a separate DB under branches/<slug>/.
const branchPaths = getStoragePaths(repo, 'feature/x');
const branchDir = path.dirname(branchPaths.lbugPath);
expect(branchDir.includes(path.join('.gitnexus', 'branches'))).toBe(true);
expect(existsSync(branchPaths.lbugPath)).toBe(true);
const branchMeta = await loadMeta(branchDir);
expect(branchMeta?.branch).toBe('feature/x');
expect(branchMeta?.lastCommit).toBe(featureCommit);
// #2106 R6: the pinned analyze must NOT have evicted main's chunks from
// the SHARED parse cache (they were unioned in via main's recorded keys).
const { loadParseCache } = await import('../../src/storage/parse-cache.js');
const sharedCache = await loadParseCache(flat.storagePath, parseIdentity, flat.storagePath);
const onDisk = sharedCache.onDiskKeys ?? new Set<string>();
for (const k of mainCacheKeys) {
expect(onDisk.has(k), `main chunk ${k} survives the feature prune`).toBe(true);
}
// The global registry keeps one entry per path: flat label at top level,
// the pinned branch nested under branches[] (#2106 U4).
let entries = await listRegisteredRepos();
let entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo));
expect(entry?.branch).toBe('main');
expect(entry?.branches?.map((b) => b.branch)).toEqual(['feature/x']);
// A plain analyze on the pinned branch adopts the flat workspace slot
// and removes the now-shadowed sub-index (#2354): the flat handle would
// always win for this label, leaving the sub-index unreachable bloat.
await runFullAnalysis(repo, {}, { onProgress: () => {} });
const reclaimed = await loadMeta(flat.storagePath);
expect(reclaimed?.branch).toBe('feature/x');
expect(reclaimed?.lastCommit).toBe(featureCommit);
expect(existsSync(branchDir)).toBe(false);
entries = await listRegisteredRepos();
entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo));
expect(entry?.branch).toBe('feature/x');
expect(entry?.branches).toBeUndefined();
} finally {
await tmp.cleanup();
}
}, 180_000);
it('a detached-HEAD re-analyze preserves the primary stamp (no later overwrite)', async () => {
const tmp = await createTempDir('gitnexus-multibranch-detached-');
const repo = tmp.dbPath;
try {
git(['init'], repo);
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
git(['add', '-A'], repo);
commit(repo, 'a');
git(['branch', '-M', 'main'], repo);
const mainCommit = git(['rev-parse', 'HEAD'], repo);
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(repo, {}, { onProgress: () => {} });
const flat = getStoragePaths(repo);
expect((await loadMeta(flat.storagePath))?.branch).toBe('main');
// Detach HEAD (what CI's actions/checkout does) and force a rebuild of the
// flat/primary index. The primary stamp must NOT be stripped.
git(['checkout', mainCommit], repo); // detached
await runFullAnalysis(repo, { force: true }, { onProgress: () => {} });
expect((await loadMeta(flat.storagePath))?.branch).toBe('main');
// Now an explicit --branch analyze must still route to a sub-dir (the
// stamp survived), leaving the flat index intact rather than updating it.
git(['checkout', '-b', 'feature/y'], repo);
await fs.writeFile(path.join(repo, 'b.ts'), 'export const b = 2;\n');
git(['add', '-A'], repo);
commit(repo, 'b');
await runFullAnalysis(repo, { branch: 'feature/y' }, { onProgress: () => {} });
const flatMeta = await loadMeta(flat.storagePath);
expect(flatMeta?.branch).toBe('main');
expect(flatMeta?.lastCommit).toBe(mainCommit); // flat NOT touched by the pinned run
expect(existsSync(getStoragePaths(repo, 'feature/y').lbugPath)).toBe(true);
} finally {
await tmp.cleanup();
}
}, 180_000);
it('an auto-detected branch the rules forbid lands on the flat slot (#2106 R1)', async () => {
const tmp = await createTempDir('gitnexus-multibranch-r1-');
const repo = tmp.dbPath;
try {
git(['init'], repo);
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
git(['add', '-A'], repo);
commit(repo, 'a');
// A backtick is valid in a git ref but rejected by validateBranchName.
// execFileSync (no shell) so the backtick is not interpreted.
execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: repo, stdio: 'pipe' });
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const logs: string[] = [];
await runFullAnalysis(
repo,
{},
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
);
// The forbidden ref was normalized to null → flat slot, no branch field,
// and no branches/ sub-directory created for an unqueryable slug.
const flat = getStoragePaths(repo);
expect(existsSync(flat.lbugPath)).toBe(true);
expect((await loadMeta(flat.storagePath))?.branch).toBeUndefined();
expect(existsSync(path.join(flat.storagePath, 'branches'))).toBe(false);
const warnings = logs.filter(isDetectRejectWarning);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('feat`x');
} finally {
await tmp.cleanup();
}
}, 180_000);
});