mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
* feat(analyze): expose process-detection budget overrides (#3313) Operators can raise or lower process count, branching, trace depth, and the entry-point candidate pool via CLI, .gitnexusrc, or GITNEXUS_* without changing shipped defaults. A budget-only change re-detects flows on the next analyze without --force. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): say invalid budget flags still honor env A rejected --max-processes value was described as falling back to the built-in default even when GITNEXUS_MAX_* still won the next precedence tier. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(analyze): share process-detection defaults and skip unused walks Keep DEFAULT_CONFIG aligned with the budget resolver and count symbols only when maxProcesses is still dynamic. Co-authored-by: Cursor <cursoragent@cursor.com> * style(analyze): wrap process-detection budget files for prettier Co-authored-by: Cursor <cursoragent@cursor.com> * docs(analyze): name the real process-detection default formula Co-authored-by: Cursor <cursoragent@cursor.com> * docs(analyze): stop calling maxProcesses*2 a hard trace quota Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): say invalid env budget tokens fall back to defaults Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): recertify process-detection after in-place FTS abort (#3324) Persist processDetection.uncertified on the in-place FTS dirty stamp when the budget mismatched so a flagless retry cannot keep rewritten flows. Qualify .gitnexusrc fail-fast copy and tighten related tests. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): skip live dirty stamp on atomic incremental (#3324) POSIX atomic incremental mutates a staging copy, so stamping live incrementalInProgress before swap made a crash force-rebuild a healthy index. Align analyze --help with CLI > .gitnexusrc > env > default. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(changelog): drop the atomic-incremental dirty-stamp note The code fix stays; Unreleased no longer lists that recovery change. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): survive FTS SIGSEGV in --limit e2e CREATE_FTS_INDEX can kill the setup analyze on some WSL hosts (status null). Rebuild with --skip-fts and skip BM25-only query --limit cases unless GITNEXUS_REQUIRE_FTS=1. Refs #3324 Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): mark update-check child at import Writing refresh-started from fetch() raced a 30s poll against cold tsx boot on a loaded default-project worker. Refs #3324 Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3324) Isolate default-budget FTS crash-marker tests from GITNEXUS_MAX_* env, assert uncertify-before-FTS order and deferred flow detection on park recovery, drop the dangling "then" from entry-point help, and correct stale streamGraphEmit docs without skipping the process-detection stamp. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(changelog): drop Unreleased process-detection notes Keep the #3313 / #3322 code; Unreleased changelog matches main until release. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
251 lines
8.6 KiB
TypeScript
251 lines
8.6 KiB
TypeScript
import { spawn, spawnSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../../helpers/cli-entry.js';
|
|
import { cleanupTempDirSync } from '../../helpers/test-db.js';
|
|
import { packageVersion } from '../../../src/core/package-version.js';
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, '../../..');
|
|
const installedVersion = packageVersion();
|
|
const tempDirs: string[] = [];
|
|
|
|
function tempHome(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cli-update-notice-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function seededCache(home: string): string {
|
|
const file = path.join(home, 'update-check.json');
|
|
fs.writeFileSync(
|
|
file,
|
|
`${JSON.stringify({
|
|
lastCheckAt: '2000-01-01T00:00:00.000Z',
|
|
registry: 'https://registry.npmjs.org',
|
|
latestVersion: '99.0.0',
|
|
})}\n`,
|
|
);
|
|
return file;
|
|
}
|
|
|
|
function localeEnv(home: string): NodeJS.ProcessEnv {
|
|
return {
|
|
...process.env,
|
|
GITNEXUS_HOME: home,
|
|
CI: '',
|
|
GITNEXUS_NO_UPDATE_NOTIFIER: '',
|
|
NO_UPDATE_NOTIFIER: '',
|
|
GITNEXUS_LANG: 'en',
|
|
LC_ALL: '',
|
|
LC_MESSAGES: '',
|
|
LANG: 'C',
|
|
};
|
|
}
|
|
|
|
function cli(args: string[], home: string) {
|
|
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: localeEnv(home),
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
cleanupTempDirSync(dir);
|
|
}
|
|
});
|
|
|
|
describe('CLI update notice subprocess behavior', () => {
|
|
it('keeps non-TTY stdout byte-clean and neither emits nor spawns a refresh child', () => {
|
|
const home = tempHome();
|
|
const cache = seededCache(home);
|
|
const before = fs.readFileSync(cache, 'utf8');
|
|
|
|
// `list` is a normal command (not --version/--help, which skip the notifier).
|
|
const result = cli(['list'], home);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).not.toContain('is available');
|
|
expect(result.stdout).not.toContain('99.0.0 is available');
|
|
expect(fs.readFileSync(cache, 'utf8')).toBe(before);
|
|
expect(fs.existsSync(path.join(home, 'update-check.lock'))).toBe(false);
|
|
});
|
|
|
|
it('keeps help output unchanged and hides the internal refresh command', () => {
|
|
const result = cli(['--help'], tempHome());
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain('Usage: gitnexus [options] [command]');
|
|
expect(result.stdout).toContain('update');
|
|
expect(result.stdout).not.toContain('__update-check');
|
|
expect(result.stderr).toBe('');
|
|
});
|
|
|
|
it('prints a versioned command banner on stderr for a normal command', () => {
|
|
const result = cli(['list'], tempHome());
|
|
|
|
expect(result.stderr).toMatch(/GitNexus List \([^)]+\)/);
|
|
expect(result.stdout).not.toMatch(/GitNexus List \(/);
|
|
});
|
|
|
|
it('documents that gitnexus update installs via npm i -g', () => {
|
|
const result = cli(['update', '--help'], tempHome());
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toMatch(/npm i -g gitnexus@<x\.y\.z>/);
|
|
expect(result.stderr).toBe('');
|
|
});
|
|
|
|
it('runs the hidden refresh command without writing stdout', () => {
|
|
const result = cli(['__update-check'], tempHome());
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toBe('');
|
|
});
|
|
|
|
it('lets the parent exit without waiting for a detached refresh child', async () => {
|
|
const home = tempHome();
|
|
const project = path.join(home, 'project');
|
|
const installedPackage = path.join(project, 'node_modules', 'gitnexus');
|
|
fs.mkdirSync(installedPackage, { recursive: true });
|
|
fs.cpSync(path.join(repoRoot, 'src'), path.join(installedPackage, 'src'), {
|
|
recursive: true,
|
|
});
|
|
fs.copyFileSync(
|
|
path.join(repoRoot, 'package.json'),
|
|
path.join(installedPackage, 'package.json'),
|
|
);
|
|
fs.symlinkSync(
|
|
path.join(repoRoot, 'node_modules'),
|
|
path.join(installedPackage, 'node_modules'),
|
|
'dir',
|
|
);
|
|
|
|
// The refresh child parks inside fetch() until this test releases it. That
|
|
// orders the parent's exit against work that is provably still in flight,
|
|
// instead of racing it against a wall-clock budget: the child's real cost
|
|
// (node boot, tsx transpile, a lock acquisition that shells out to
|
|
// ps/powershell) has no bounded upper limit on a loaded CI runner.
|
|
const started = path.join(home, 'refresh-started');
|
|
const release = path.join(home, 'refresh-release');
|
|
const preload = path.join(home, 'mock-refresh.mjs');
|
|
fs.writeFileSync(
|
|
preload,
|
|
`import fs from 'node:fs';
|
|
Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
|
|
// Mark the detached child at --import time, before tsx compiles the CLI.
|
|
// Writing this from fetch() raced a 30s poll against cold boot + lock
|
|
// acquisition on a loaded default-project worker (status: poll timeout).
|
|
if (process.argv.includes('__update-check')) {
|
|
fs.writeFileSync(${JSON.stringify(started)}, '');
|
|
}
|
|
globalThis.fetch = async () => {
|
|
// Bounded so an abandoned child (test failed before releasing, temp home
|
|
// already deleted) still exits instead of spinning forever.
|
|
const deadline = Date.now() + 90_000;
|
|
while (!fs.existsSync(${JSON.stringify(release)}) && Date.now() < deadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
return new Response(JSON.stringify({ version: '99.0.0' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
};
|
|
`,
|
|
);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const parent = spawn(
|
|
process.execPath,
|
|
[path.join(installedPackage, 'src', 'cli', 'index.ts'), 'list'],
|
|
{
|
|
cwd: project,
|
|
stdio: 'ignore',
|
|
env: {
|
|
...localeEnv(home),
|
|
NODE_OPTIONS:
|
|
`--import ${tsxLoaderUrl()} --import ${pathToFileURL(preload).href}`.trim(),
|
|
},
|
|
},
|
|
);
|
|
parent.once('error', reject);
|
|
parent.once('exit', (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`notifier parent exited ${String(code)}`));
|
|
});
|
|
});
|
|
|
|
// The parent already exited above. `started` is written by the child's
|
|
// --import hook, so this wait is "did the detached process actually
|
|
// start?" not "did tsx finish compiling the CLI?" The fetch mock still
|
|
// parks until `release` so the cache cannot appear before we unblock it.
|
|
const cache = path.join(home, 'update-check.json');
|
|
await expect.poll(() => fs.existsSync(started), { timeout: 30_000, interval: 50 }).toBe(true);
|
|
expect(fs.existsSync(cache)).toBe(false);
|
|
|
|
fs.writeFileSync(release, '');
|
|
await expect.poll(() => fs.existsSync(cache), { timeout: 60_000, interval: 50 }).toBe(true);
|
|
expect(JSON.parse(fs.readFileSync(cache, 'utf8'))).toMatchObject({
|
|
latestVersion: '99.0.0',
|
|
registry: 'https://registry.npmjs.org',
|
|
});
|
|
}, 120_000);
|
|
|
|
it('prints the localized notice on a forced-TTY stderr and keeps stdout clean', () => {
|
|
const home = tempHome();
|
|
fs.writeFileSync(
|
|
path.join(home, 'update-check.json'),
|
|
`${JSON.stringify({
|
|
lastCheckAt: new Date().toISOString(),
|
|
registry: 'https://registry.npmjs.org',
|
|
latestVersion: '99.0.0',
|
|
})}\n`,
|
|
);
|
|
const project = path.join(home, 'project');
|
|
const installedPackage = path.join(project, 'node_modules', 'gitnexus');
|
|
fs.mkdirSync(installedPackage, { recursive: true });
|
|
fs.cpSync(path.join(repoRoot, 'src'), path.join(installedPackage, 'src'), {
|
|
recursive: true,
|
|
});
|
|
fs.copyFileSync(
|
|
path.join(repoRoot, 'package.json'),
|
|
path.join(installedPackage, 'package.json'),
|
|
);
|
|
fs.symlinkSync(
|
|
path.join(repoRoot, 'node_modules'),
|
|
path.join(installedPackage, 'node_modules'),
|
|
'dir',
|
|
);
|
|
|
|
const preload = path.join(home, 'force-tty.mjs');
|
|
fs.writeFileSync(
|
|
preload,
|
|
`Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });\n`,
|
|
);
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(installedPackage, 'src', 'cli', 'index.ts'), 'list'],
|
|
{
|
|
cwd: project,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: {
|
|
...localeEnv(home),
|
|
NODE_OPTIONS: `--import ${tsxLoaderUrl()} --import ${pathToFileURL(preload).href}`.trim(),
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.stderr).toContain(
|
|
`GitNexus 99.0.0 is available (you are running ${installedVersion}).`,
|
|
);
|
|
expect(result.stdout).not.toContain('99.0.0 is available');
|
|
});
|
|
});
|