mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
* refactor(analyze): load detected-branch sanitization from core git-ref Keep the never-throw helper next to validateBranchName so run-analyze no longer imports CLI config parsing. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): warn once when a checkout name cannot label the index After the write lock settles, emit a single onLog warning and keep writing the workspace slot. Pin that run-analyze does not import CLI analyze-config. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): escape hidden checkout names in the detect-reject warning Keep the rejected ref visible in onLog without replaying bidi or quote characters, and document that sanitizeDetectedBranch rethrows unexpected errors. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): keep detect-reject warnings on one line (#3325) Git-legal U+2028/U+2029 checkout names were rejected as whitespace but left raw in the new onLog warning, so the message split across two lines. Escape those code points in the formatter without changing validateBranchName. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(analyze): keep C1 and Unicode spaces in detect-reject warnings Escape NEL and remaining whitespace as \uXXXX so stripControlCharacters cannot drop or disguise the rejected checkout name. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
244 lines
11 KiB
TypeScript
244 lines
11 KiB
TypeScript
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';
|
|
|
|
/**
|
|
* #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);
|
|
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);
|
|
});
|