GitNexus/gitnexus/test/unit/gitnexus-home-roots.test.ts
glier 16e6ad6da8
Some checks are pending
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
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
fix(server): resolve clone/upload/mapping roots from GITNEXUS_HOME (#2229)
* 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>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-18 17:39:44 +01:00

115 lines
4.9 KiB
TypeScript

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 });
}
});
});