From 16e6ad6da8fd1ad1ebb8997e3425ccb1a1d1bcca Mon Sep 17 00:00:00 2001 From: glier Date: Thu, 18 Jun 2026 19:39:44 +0300 Subject: [PATCH] fix(server): resolve clone/upload/mapping roots from GITNEXUS_HOME (#2229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * test(server): make clone-root assertions GITNEXUS_HOME-aware; cover server-mapping fallback Addresses PR review (#2229): - git-clone.test.ts hardcoded path.join(os.homedir(), '.gitnexus', 'repos') for the clone root, so the "direct child of the clone root" and containment assertions failed when GITNEXUS_HOME was set in the ambient env (e.g. a CI runner). CLONE_ROOT now derives from getGlobalDir(); mirror that derivation via EXPECTED_CLONE_ROOT (GITNEXUS_HOME || ~/.gitnexus), computed at module load — the same point CLONE_ROOT is frozen — so the two always agree. - The GITNEXUS_HOME-unset fallback test covered clone + upload roots but not server-mapping (one of the three changed modules). Add a fallback case for readServerMapping. It redirects HOME/USERPROFILE to a tmp dir (os.homedir() honors them) so it exercises the real ~/.gitnexus fallback without writing into the developer's actual ~/.gitnexus/server-mapping.json. Both files pass with and without GITNEXUS_HOME set. Co-Authored-By: Claude Opus 4.8 (1M context) * test(server): use mkdtemp for GITNEXUS_HOME path-root test temp dirs CodeQL js/insecure-temporary-file flagged the two writes that used a fixed os.tmpdir() name (gitnexus-home-roots-test, gitnexus-fallback-home): a co-located process could pre-create or symlink the predictable path before the test write lands. Switch both to fs.mkdtemp(), which atomically creates a uniquely-named directory — the canonical sanitizer this repo already uses (see core/group/storage.ts). Behavior is unchanged; tests still pass with and without GITNEXUS_HOME set. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(embeddings): resolve server-mapping path for parity with clone/upload roots MAPPING_FILE now wraps path.resolve() like CLONE_ROOT (git-clone.ts) and UPLOAD_ROOT (upload-paths.ts), so a relative GITNEXUS_HOME yields an absolute path. No-op for the supported absolute-GITNEXUS_HOME config (Docker) and for readServerMapping's only caller (run-analyze.ts). Co-Authored-By: Claude Opus 4.8 (1M context) * test(server): derive EXPECTED_CLONE_ROOT from getGlobalDir() to avoid drift The test mirror now imports getGlobalDir() and computes the expected clone root the same way production CLONE_ROOT does, instead of re-deriving the GITNEXUS_HOME || ~/.gitnexus fallback by hand. Byte-identical today; future-proof if getGlobalDir() grows a branch. (os import retained — still used by os.tmpdir().) Co-Authored-By: Claude Opus 4.8 (1M context) * test(server): rename shadowed savedHome in server-mapping fallback test The inner savedHome (saves process.env.HOME) shadowed the describe-scope savedHome (saves process.env.GITNEXUS_HOME). Renamed the inner one to savedProcessHome at its declaration and both restore sites in the finally block. Pure rename, no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) * test(server): scope customHome temp dir to the GITNEXUS_HOME-set cases beforeEach created a customHome temp dir for all five tests, but the two fallback cases never use it (one manages its own fakeHome, the other needs none). Moved the mkdtemp/rm into a nested describe('with GITNEXUS_HOME set') wrapping the three set-cases; the shared outer afterEach still restores GITNEXUS_HOME and resets modules for all five. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Gergő Magyar --- .../src/core/embeddings/server-mapping.ts | 10 +- gitnexus/src/server/git-clone.ts | 17 ++- gitnexus/src/server/upload-paths.ts | 14 ++- gitnexus/test/unit/git-clone.test.ts | 20 ++- .../test/unit/gitnexus-home-roots.test.ts | 115 ++++++++++++++++++ 5 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 gitnexus/test/unit/gitnexus-home-roots.test.ts diff --git a/gitnexus/src/core/embeddings/server-mapping.ts b/gitnexus/src/core/embeddings/server-mapping.ts index fa685ac8f..77a7de445 100644 --- a/gitnexus/src/core/embeddings/server-mapping.ts +++ b/gitnexus/src/core/embeddings/server-mapping.ts @@ -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 | 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/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 020bc0d88..4db09c837 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -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( 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..f50f2a7a6 --- /dev/null +++ b/gitnexus/test/unit/gitnexus-home-roots.test.ts @@ -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 }); + } + }); +});