Merge branch 'main' into feat/pdg-impact-mode

This commit is contained in:
Gergő Magyar 2026-06-18 17:44:55 +01:00 • committed by GitHub
commit f4d4034994
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 159 additions and 17 deletions

View file

@ -1,15 +1,19 @@
/**
* Server Mapping Configuration
*
* Reads ~/.gitnexus/server-mapping.json to map repo names to service names.
* Reads getGlobalDir()/server-mapping.json to map repo names to service names.
* Used in embedding text to enrich metadata with microservice context.
*/
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { getGlobalDir } from '../../storage/repo-manager.js';
const MAPPING_FILE = path.join(os.homedir(), '.gitnexus', 'server-mapping.json');
// Sourced from getGlobalDir() so it honors GITNEXUS_HOME (the Docker image sets
// GITNEXUS_HOME=/data/gitnexus); falls back to ~/.gitnexus when unset. Wrapped in
// path.resolve() for parity with the clone/upload roots (git-clone.ts CLONE_ROOT,
// upload-paths.ts UPLOAD_ROOT) so a relative GITNEXUS_HOME still yields an absolute path.
const MAPPING_FILE = path.resolve(path.join(getGlobalDir(), 'server-mapping.json'));
let cachedMapping: Record<string, string> | null = null;

View file

@ -1,20 +1,29 @@
/**
* Git Clone Utility
*
* Shallow-clones repositories into ~/.gitnexus/repos/{name}/.
* Shallow-clones repositories into the clone root (getGlobalDir()/repos/{name}/).
* If already cloned, does git pull instead.
*/
import { spawn } from 'child_process';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { isIP } from 'net';
import { logger } from '../core/logger.js';
import { parseRepoNameFromUrl } from '../storage/git.js';
import { getGlobalDir } from '../storage/repo-manager.js';
/** Root directory for all cloned repositories. Targets must resolve inside this. */
const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
/**
* Root directory for all cloned repositories. Targets must resolve inside this.
*
* Sourced from getGlobalDir() so it honors GITNEXUS_HOME — the Docker image sets
* GITNEXUS_HOME=/data/gitnexus, the persistent volume that also holds the
* registry and indexes. Without this, clones landed in the container's
* ephemeral ~/.gitnexus/repos and were lost on container recreation while the
* registry still pointed at the dead path. Falls back to ~/.gitnexus when the
* env var is unset (CLI / local installs), matching the prior behavior exactly.
*/
const CLONE_ROOT = path.resolve(path.join(getGlobalDir(), 'repos'));
// A valid git repository name is filesystem-safe: alphanumerics plus `. _ -`.
// Rejecting anything else (including `..`, `/`, `\`, shell metacharacters)

View file

@ -1,7 +1,7 @@
/**
* Upload working-directory paths.
*
* Browser folder uploads are written into ~/.gitnexus/uploads/{name}/ — a
* Browser folder uploads are written into getGlobalDir()/uploads/{name}/ — a
* sibling of the clone root (git-clone.ts CLONE_ROOT) — so an uploaded repo
* persists and behaves like a cloned one (the graph UI's /api/file reads its
* files after analysis, and DELETE /api/repo removes it). Staging happens in
@ -12,12 +12,18 @@
*/
import path from 'path';
import os from 'os';
import { sanitizeRepoName } from '../storage/git.js';
import { REPO_NAME_PATTERN } from './git-clone.js';
import { getGlobalDir } from '../storage/repo-manager.js';
/** Root directory for all uploaded repositories. Targets must resolve inside this. */
export const UPLOAD_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'uploads'));
/**
* Root directory for all uploaded repositories. Targets must resolve inside this.
*
* Sourced from getGlobalDir() so it honors GITNEXUS_HOME and stays a sibling of
* the clone root on the same (in Docker, persistent) volume. Falls back to
* ~/.gitnexus when the env var is unset.
*/
export const UPLOAD_ROOT = path.resolve(path.join(getGlobalDir(), 'uploads'));
/** Prefix for per-upload staging directories created under UPLOAD_ROOT. */
export const STAGING_PREFIX = '.staging-';

View file

@ -31,6 +31,16 @@ import os from 'node:os';
import fs from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { getRemoteOriginUrl } from '../../src/storage/git.js';
import { getGlobalDir } from '../../src/storage/repo-manager.js';
// CLONE_ROOT now derives from getGlobalDir() (GITNEXUS_HOME || ~/.gitnexus), so
// assertions must mirror that derivation rather than hardcoding ~/.gitnexus —
// otherwise an ambient GITNEXUS_HOME (e.g. a CI runner that sets it) makes the
// "direct child of the clone root" assertions fail. We call getGlobalDir()
// directly (the same function production CLONE_ROOT uses) so the test cannot
// drift from production if that derivation ever changes. Computed at module
// load, the same point CLONE_ROOT is frozen, so the two always agree.
const EXPECTED_CLONE_ROOT = path.resolve(path.join(getGlobalDir(), 'repos'));
describe('git-clone', () => {
describe('extractRepoName', () => {
@ -112,11 +122,9 @@ describe('git-clone', () => {
});
describe('getCloneDir', () => {
it('returns path under ~/.gitnexus/repos/', () => {
it('returns path under the clone root (getGlobalDir()/repos/)', () => {
const dir = getCloneDir('my-repo');
expect(dir).toContain('.gitnexus');
expect(dir).toMatch(/repos/);
expect(dir).toContain('my-repo');
expect(dir).toBe(path.join(EXPECTED_CLONE_ROOT, 'my-repo'));
});
it('rejects ".." to prevent path-traversal escape from the clone root', () => {
@ -131,7 +139,7 @@ describe('git-clone', () => {
});
it('returned path is always a direct child of the clone root', () => {
const cloneRoot = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
const cloneRoot = EXPECTED_CLONE_ROOT;
const dir = getCloneDir('my-repo');
const rel = path.relative(cloneRoot, path.resolve(dir));
// path.relative from the parent to the child must be just the child name —
@ -476,7 +484,7 @@ describe('git-clone', () => {
//
// These tests do NOT mock spawn — the barrier throws synchronously
// before git is invoked, so the rejection is observable directly.
const cloneRoot = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
const cloneRoot = EXPECTED_CLONE_ROOT;
it('rejects an absolute target outside CLONE_ROOT', async () => {
await expect(cloneOrPull('https://github.com/a/b.git', '/etc/passwd')).rejects.toThrow(

View file

@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs/promises';
/**
* Regression guard: the clone root (git-clone.ts), upload root (upload-paths.ts),
* and server-mapping file (embeddings/server-mapping.ts) must follow
* GITNEXUS_HOME, not the bare home directory.
*
* The Docker image sets GITNEXUS_HOME=/data/gitnexus — the persistent volume
* that also holds the registry and indexes. Before this fix these three roots
* used os.homedir() directly, so clones/uploads landed in the container's
* ephemeral ~/.gitnexus and were lost on container recreation while the
* registry (which honors GITNEXUS_HOME) still pointed at the dead path.
*
* The roots are module-level constants resolved at import time from
* getGlobalDir(), so each case sets the env var, resets the module registry,
* and re-imports under the new value.
*/
describe('GITNEXUS_HOME path roots', () => {
const savedHome = process.env.GITNEXUS_HOME;
afterEach(() => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
vi.resetModules();
});
// customHome is only needed by the GITNEXUS_HOME-set cases below; the two
// fallback cases manage their own (or no) temp dir, so its lifecycle lives in
// this nested block rather than a shared beforeEach.
describe('with GITNEXUS_HOME set', () => {
// mkdtemp (not a fixed os.tmpdir() name) so a co-located process cannot
// pre-create or symlink the path before our writes land
// (CodeQL js/insecure-temporary-file).
let customHome: string;
beforeEach(async () => {
customHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-home-roots-'));
});
afterEach(async () => {
await fs.rm(customHome, { recursive: true, force: true });
});
it('clone root follows GITNEXUS_HOME', async () => {
process.env.GITNEXUS_HOME = customHome;
vi.resetModules();
const { getCloneDir } = await import('../../src/server/git-clone.js');
expect(getCloneDir('my-repo')).toBe(path.resolve(customHome, 'repos', 'my-repo'));
});
it('upload root follows GITNEXUS_HOME', async () => {
process.env.GITNEXUS_HOME = customHome;
vi.resetModules();
const { UPLOAD_ROOT, getUploadDir } = await import('../../src/server/upload-paths.js');
expect(UPLOAD_ROOT).toBe(path.resolve(customHome, 'uploads'));
expect(getUploadDir('my-repo')).toBe(path.resolve(customHome, 'uploads', 'my-repo'));
});
it('server-mapping file is read from GITNEXUS_HOME', async () => {
process.env.GITNEXUS_HOME = customHome;
await fs.writeFile(
path.join(customHome, 'server-mapping.json'),
JSON.stringify({ 'my-repo': 'payments-service' }),
'utf-8',
);
vi.resetModules();
const { readServerMapping } = await import('../../src/core/embeddings/server-mapping.js');
expect(await readServerMapping('my-repo')).toBe('payments-service');
});
});
it('falls back to ~/.gitnexus when GITNEXUS_HOME is unset', async () => {
delete process.env.GITNEXUS_HOME;
vi.resetModules();
const { getCloneDir } = await import('../../src/server/git-clone.js');
const { UPLOAD_ROOT } = await import('../../src/server/upload-paths.js');
expect(getCloneDir('my-repo')).toBe(
path.resolve(os.homedir(), '.gitnexus', 'repos', 'my-repo'),
);
expect(UPLOAD_ROOT).toBe(path.resolve(os.homedir(), '.gitnexus', 'uploads'));
});
it('server-mapping falls back to ~/.gitnexus when GITNEXUS_HOME is unset', async () => {
// os.homedir() honors $HOME (and $USERPROFILE on Windows), so redirect the
// home dir to a tmp path to exercise the real fallback (getGlobalDir() ->
// ~/.gitnexus) without writing into the developer's actual
// ~/.gitnexus/server-mapping.json.
const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-fallback-home-'));
const savedProcessHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
delete process.env.GITNEXUS_HOME;
process.env.HOME = fakeHome;
process.env.USERPROFILE = fakeHome;
try {
await fs.mkdir(path.join(fakeHome, '.gitnexus'), { recursive: true });
await fs.writeFile(
path.join(fakeHome, '.gitnexus', 'server-mapping.json'),
JSON.stringify({ 'my-repo': 'fallback-service' }),
'utf-8',
);
vi.resetModules();
const { readServerMapping } = await import('../../src/core/embeddings/server-mapping.js');
expect(await readServerMapping('my-repo')).toBe('fallback-service');
} finally {
if (savedProcessHome === undefined) delete process.env.HOME;
else process.env.HOME = savedProcessHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
await fs.rm(fakeHome, { recursive: true, force: true });
}
});
});