fix(server): resolve clone/upload/mapping roots from GITNEXUS_HOME

CLONE_ROOT (git-clone.ts), UPLOAD_ROOT (upload-paths.ts), and the
server-mapping file (embeddings/server-mapping.ts) computed their root
from os.homedir() directly, ignoring GITNEXUS_HOME. The Docker image
sets GITNEXUS_HOME=/data/gitnexus (the persistent volume that also holds
the registry and indexes), so cloned/uploaded repos and their .gitnexus
indexes instead landed in the container's ephemeral ~/.gitnexus and were
lost on container recreation, while registry.json (which honors
GITNEXUS_HOME) kept pointing at the now-dead path. That also defeated
incremental re-analysis: a recreated container re-clones from scratch and
full-rebuilds instead of git pull + incremental update.

Source all three from the existing getGlobalDir() helper — the same
GITNEXUS_HOME-aware primitive the registry and groups already use.
Behavior is unchanged when GITNEXUS_HOME is unset (CLI / local installs):
it falls back to ~/.gitnexus exactly as before. No signatures change and
no UPLOAD_ROOT consumers are touched.

Adds test/unit/gitnexus-home-roots.test.ts covering both the
GITNEXUS_HOME-set and unset paths for all three roots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Borozdenets Ilya 2026-06-16 15:07:53 +03:00
parent b895a20415
commit 7a322fb976
4 changed files with 98 additions and 11 deletions

View file

@ -1,15 +1,17 @@
/**
* 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.
const MAPPING_FILE = 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

@ -0,0 +1,70 @@
import { describe, it, expect, 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;
const customHome = path.join(os.tmpdir(), 'gitnexus-home-roots-test');
afterEach(async () => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
vi.resetModules();
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.mkdir(customHome, { recursive: true });
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'));
});
});