diff --git a/gitnexus/src/core/embeddings/server-mapping.ts b/gitnexus/src/core/embeddings/server-mapping.ts index fa685ac8f..00cf0f0da 100644 --- a/gitnexus/src/core/embeddings/server-mapping.ts +++ b/gitnexus/src/core/embeddings/server-mapping.ts @@ -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 | null = null; diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 16a7c48c5..477f47a6e 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -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) diff --git a/gitnexus/src/server/upload-paths.ts b/gitnexus/src/server/upload-paths.ts index ef957c85b..80fd6dd44 100644 --- a/gitnexus/src/server/upload-paths.ts +++ b/gitnexus/src/server/upload-paths.ts @@ -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-'; diff --git a/gitnexus/test/unit/gitnexus-home-roots.test.ts b/gitnexus/test/unit/gitnexus-home-roots.test.ts new file mode 100644 index 000000000..eeb6393e4 --- /dev/null +++ b/gitnexus/test/unit/gitnexus-home-roots.test.ts @@ -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')); + }); +});