mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
2c4d55ca50
Adds an optional `projectId` field to `.socraticode.json` so teams can
commit a stable project identifier to the repo. Without this field the
project ID is derived from the SHA-256 of the absolute checkout path,
which means the same project resolves to a different Qdrant collection
on every machine, OS user, filesystem layout, or worktree. With it,
every checkout addresses the same `codebase_*`, `codegraph_*`, and
`context_*` collections regardless of where the working tree lives on
disk.
This is the path-independent, multi-project complement to the existing
`SOCRATICODE_PROJECT_ID` env var. The env var is process-scoped and
global to all projects in a host, so it does not scale to a developer
who works on several projects on one laptop. The file is per-project
and shared across teammates via git.
Resolution precedence (highest first):
1. `SOCRATICODE_PROJECT_ID` env var (per-machine override)
2. `projectId` in .socraticode.json (committed, team-wide)
3. SHA-256 prefix of the absolute path (existing default)
Both override paths trim whitespace, validate against `[a-zA-Z0-9_-]+`,
and throw on invalid characters so a misconfigured value cannot
silently route a project to the wrong (or empty) collection. Malformed
JSON, missing fields, wrong types, and empty/whitespace-only values
fall through to the next precedence level so the MCP server stays
resilient against hand-edited config files. Branch-aware mode is
suppressed for either explicit override since explicit identifiers
are stable by intent.
Also fixes a pre-existing bug in `resolveLinkedCollections`: linked
projects were resolved via `coreProjectId(linkedPath)` (path hash
only), so a linked project that pinned its own `projectId` in
`.socraticode.json` would silently miss its actual data during
cross-project search. Linked-project resolution now goes through a
new `effectiveBaseProjectId` helper that honors the committed value,
preserving symmetry: a project addresses the same Qdrant collection
whether it is the current root or a linked dependency. Dedup is
tightened to use the same effective base ID, so two paths pinning the
same shared identifier collapse to a single result.
The env var deliberately does not leak into linked-project collection
names. It is process-scoped and applying it as a single value to every
linked path would collapse them onto the env-var collection, silently
losing per-project isolation.
Tests: 16 new cases in tests/unit/config.test.ts, written TDD-style
(RED to GREEN). Coverage:
- `projectIdFromPath` (13): file resolution, ignores path
differences when file projectId is set, whitespace trimming,
throws on invalid characters, falls back to hash on
empty/whitespace/wrong-type/null/missing-file/malformed-JSON,
env-var precedence over file, branch-suffix suppression, and
coexistence with `linkedProjects` in the same file.
- `resolveLinkedCollections` (3): linked project's committed
projectId honored, dedup on shared committed projectId, env var
does not leak into linked-project collection names.
The new branch-aware-suppression test explicitly disables git
`commit.gpgsign` and `tag.gpgsign` in its throwaway-repo fixture so
the test is robust against the developer's global git config.
Backwards compatible: zero behaviour change for users who do not adopt
the new field. The `SocratiCodeConfig` interface gains an optional
field; existing `linkedProjects` parsing is functionally identical
(routed through the new shared `loadSocratiCodeConfig` helper).
Composes cleanly with the recently-added `QDRANT_COLLECTION_PREFIX`:
prefix + projectId combine into `<prefix>codebase_<projectId>` as
expected.
README and DEVELOPER documentation updated: new "Team-Shared Index
(committed `projectId`)" section in README between Git Worktrees and
Cross-Project Search, and the env-var table notes the new precedence.
DEVELOPER's "Project ID & Collection Naming" section now documents
the three-level precedence and explains why both override paths
suppress the branch-aware suffix.
Co-authored-by: airmonitor <tomasz.szuster@gmail.com>
650 lines
26 KiB
TypeScript
650 lines
26 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { collectionName, contextCollectionName, detectGitBranch, graphCollectionName, loadLinkedProjects, projectIdFromPath, resolveLinkedCollections, sanitizeBranchName } from "../../src/config.js";
|
|
|
|
describe("config", () => {
|
|
// Clean up env overrides between tests
|
|
const originalEnv = process.env.SOCRATICODE_PROJECT_ID;
|
|
const originalBranchAware = process.env.SOCRATICODE_BRANCH_AWARE;
|
|
afterEach(() => {
|
|
if (originalEnv === undefined) {
|
|
delete process.env.SOCRATICODE_PROJECT_ID;
|
|
} else {
|
|
process.env.SOCRATICODE_PROJECT_ID = originalEnv;
|
|
}
|
|
if (originalBranchAware === undefined) {
|
|
delete process.env.SOCRATICODE_BRANCH_AWARE;
|
|
} else {
|
|
process.env.SOCRATICODE_BRANCH_AWARE = originalBranchAware;
|
|
}
|
|
});
|
|
|
|
describe("projectIdFromPath", () => {
|
|
it("returns a 12-character hex string", () => {
|
|
const id = projectIdFromPath("/some/project/path");
|
|
expect(id).toMatch(/^[0-9a-f]{12}$/);
|
|
});
|
|
|
|
it("returns the same ID for the same path", () => {
|
|
const id1 = projectIdFromPath("/some/project/path");
|
|
const id2 = projectIdFromPath("/some/project/path");
|
|
expect(id1).toBe(id2);
|
|
});
|
|
|
|
it("returns different IDs for different paths", () => {
|
|
const id1 = projectIdFromPath("/project/alpha");
|
|
const id2 = projectIdFromPath("/project/beta");
|
|
expect(id1).not.toBe(id2);
|
|
});
|
|
|
|
it("normalizes paths (resolves relative components)", () => {
|
|
const id1 = projectIdFromPath("/some/project/./path");
|
|
const id2 = projectIdFromPath("/some/project/path");
|
|
expect(id1).toBe(id2);
|
|
});
|
|
|
|
it("normalizes paths with parent directory references", () => {
|
|
const id1 = projectIdFromPath("/some/project/sub/../path");
|
|
const id2 = projectIdFromPath("/some/project/path");
|
|
expect(id1).toBe(id2);
|
|
});
|
|
|
|
it("handles paths with trailing slashes consistently", () => {
|
|
// path.resolve strips trailing slashes, so these should differ
|
|
// (or be the same depending on OS behavior)
|
|
const id1 = projectIdFromPath("/some/project");
|
|
const id2 = projectIdFromPath("/some/project/");
|
|
// path.resolve normalizes trailing slash, so they should match
|
|
expect(id1).toBe(id2);
|
|
});
|
|
|
|
it("uses SOCRATICODE_PROJECT_ID when set", () => {
|
|
process.env.SOCRATICODE_PROJECT_ID = "my-shared-project";
|
|
const id = projectIdFromPath("/some/project/path");
|
|
expect(id).toBe("my-shared-project");
|
|
});
|
|
|
|
it("ignores path differences when SOCRATICODE_PROJECT_ID is set", () => {
|
|
process.env.SOCRATICODE_PROJECT_ID = "shared";
|
|
const id1 = projectIdFromPath("/worktree/a");
|
|
const id2 = projectIdFromPath("/worktree/b");
|
|
expect(id1).toBe(id2);
|
|
});
|
|
|
|
it("trims whitespace from SOCRATICODE_PROJECT_ID", () => {
|
|
process.env.SOCRATICODE_PROJECT_ID = " my-project ";
|
|
expect(projectIdFromPath("/any/path")).toBe("my-project");
|
|
});
|
|
|
|
it("throws on invalid SOCRATICODE_PROJECT_ID characters", () => {
|
|
process.env.SOCRATICODE_PROJECT_ID = "invalid/name";
|
|
expect(() => projectIdFromPath("/any/path")).toThrow(
|
|
/SOCRATICODE_PROJECT_ID must match/,
|
|
);
|
|
});
|
|
|
|
it("falls back to hash when SOCRATICODE_PROJECT_ID is empty", () => {
|
|
process.env.SOCRATICODE_PROJECT_ID = " ";
|
|
const id = projectIdFromPath("/some/project/path");
|
|
expect(id).toMatch(/^[0-9a-f]{12}$/);
|
|
});
|
|
});
|
|
|
|
describe("collectionName", () => {
|
|
it("prefixes with codebase_", () => {
|
|
expect(collectionName("abc123def456")).toBe("codebase_abc123def456");
|
|
});
|
|
|
|
it("handles empty string", () => {
|
|
expect(collectionName("")).toBe("codebase_");
|
|
});
|
|
});
|
|
|
|
describe("graphCollectionName", () => {
|
|
it("prefixes with codegraph_", () => {
|
|
expect(graphCollectionName("abc123def456")).toBe("codegraph_abc123def456");
|
|
});
|
|
|
|
it("handles empty string", () => {
|
|
expect(graphCollectionName("")).toBe("codegraph_");
|
|
});
|
|
});
|
|
|
|
describe("contextCollectionName", () => {
|
|
it("prefixes with context_", () => {
|
|
expect(contextCollectionName("abc123def456")).toBe("context_abc123def456");
|
|
});
|
|
|
|
it("handles empty string", () => {
|
|
expect(contextCollectionName("")).toBe("context_");
|
|
});
|
|
});
|
|
|
|
describe("round-trip: path → projectId → collection names", () => {
|
|
it("produces valid Qdrant-friendly collection names", () => {
|
|
const projectId = projectIdFromPath("/Users/test/my-project");
|
|
const coll = collectionName(projectId);
|
|
const graphColl = graphCollectionName(projectId);
|
|
|
|
// Qdrant collection names must match: ^[a-zA-Z0-9_-]+$
|
|
expect(coll).toMatch(/^[a-zA-Z0-9_-]+$/);
|
|
expect(graphColl).toMatch(/^[a-zA-Z0-9_-]+$/);
|
|
expect(coll).toMatch(/^codebase_[0-9a-f]{12}$/);
|
|
expect(graphColl).toMatch(/^codegraph_[0-9a-f]{12}$/);
|
|
});
|
|
|
|
it("produces a valid context collection name", () => {
|
|
const projectId = projectIdFromPath("/Users/test/my-project");
|
|
const contextColl = contextCollectionName(projectId);
|
|
expect(contextColl).toMatch(/^[a-zA-Z0-9_-]+$/);
|
|
expect(contextColl).toMatch(/^context_[0-9a-f]{12}$/);
|
|
});
|
|
});
|
|
|
|
// ── Linked projects ─────────────────────────────────────────────────
|
|
|
|
describe("loadLinkedProjects", () => {
|
|
let tmpDir: string;
|
|
let projectDir: string;
|
|
let linkedDirA: string;
|
|
let linkedDirB: string;
|
|
const originalLinkedEnv = process.env.SOCRATICODE_LINKED_PROJECTS;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-test-"));
|
|
projectDir = path.join(tmpDir, "my-project");
|
|
linkedDirA = path.join(tmpDir, "linked-a");
|
|
linkedDirB = path.join(tmpDir, "linked-b");
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
fs.mkdirSync(linkedDirA, { recursive: true });
|
|
fs.mkdirSync(linkedDirB, { recursive: true });
|
|
delete process.env.SOCRATICODE_LINKED_PROJECTS;
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
if (originalLinkedEnv === undefined) {
|
|
delete process.env.SOCRATICODE_LINKED_PROJECTS;
|
|
} else {
|
|
process.env.SOCRATICODE_LINKED_PROJECTS = originalLinkedEnv;
|
|
}
|
|
});
|
|
|
|
it("returns empty array when no .socraticode.json exists", () => {
|
|
expect(loadLinkedProjects(projectDir)).toEqual([]);
|
|
});
|
|
|
|
it("reads linked projects from .socraticode.json", () => {
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ linkedProjects: ["../linked-a", "../linked-b"] }),
|
|
);
|
|
const result = loadLinkedProjects(projectDir);
|
|
expect(result).toHaveLength(2);
|
|
expect(result).toContain(linkedDirA);
|
|
expect(result).toContain(linkedDirB);
|
|
});
|
|
|
|
it("skips non-existent linked paths", () => {
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ linkedProjects: ["../linked-a", "../does-not-exist"] }),
|
|
);
|
|
const result = loadLinkedProjects(projectDir);
|
|
expect(result).toHaveLength(1);
|
|
expect(result).toContain(linkedDirA);
|
|
});
|
|
|
|
it("skips self-references", () => {
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ linkedProjects: [".", "../my-project"] }),
|
|
);
|
|
expect(loadLinkedProjects(projectDir)).toEqual([]);
|
|
});
|
|
|
|
it("reads from SOCRATICODE_LINKED_PROJECTS env var", () => {
|
|
process.env.SOCRATICODE_LINKED_PROJECTS = `${linkedDirA},${linkedDirB}`;
|
|
const result = loadLinkedProjects(projectDir);
|
|
expect(result).toHaveLength(2);
|
|
expect(result).toContain(linkedDirA);
|
|
expect(result).toContain(linkedDirB);
|
|
});
|
|
|
|
it("merges config file and env var without duplicates", () => {
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ linkedProjects: ["../linked-a"] }),
|
|
);
|
|
process.env.SOCRATICODE_LINKED_PROJECTS = `${linkedDirA},${linkedDirB}`;
|
|
const result = loadLinkedProjects(projectDir);
|
|
// linked-a appears in both sources but should be deduplicated
|
|
expect(result).toHaveLength(2);
|
|
expect(result).toContain(linkedDirA);
|
|
expect(result).toContain(linkedDirB);
|
|
});
|
|
|
|
it("handles malformed .socraticode.json gracefully", () => {
|
|
fs.writeFileSync(path.join(projectDir, ".socraticode.json"), "not valid json{{{");
|
|
expect(loadLinkedProjects(projectDir)).toEqual([]);
|
|
});
|
|
|
|
it("handles .socraticode.json with missing linkedProjects field", () => {
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ someOtherField: true }),
|
|
);
|
|
expect(loadLinkedProjects(projectDir)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("resolveLinkedCollections", () => {
|
|
let tmpDir: string;
|
|
let projectDir: string;
|
|
let linkedDir: string;
|
|
const savedLinkedEnv = process.env.SOCRATICODE_LINKED_PROJECTS;
|
|
const savedProjectIdEnv = process.env.SOCRATICODE_PROJECT_ID;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-test-"));
|
|
projectDir = path.join(tmpDir, "main-project");
|
|
linkedDir = path.join(tmpDir, "linked-lib");
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
fs.mkdirSync(linkedDir, { recursive: true });
|
|
delete process.env.SOCRATICODE_LINKED_PROJECTS;
|
|
delete process.env.SOCRATICODE_PROJECT_ID;
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
if (savedLinkedEnv === undefined) {
|
|
delete process.env.SOCRATICODE_LINKED_PROJECTS;
|
|
} else {
|
|
process.env.SOCRATICODE_LINKED_PROJECTS = savedLinkedEnv;
|
|
}
|
|
if (savedProjectIdEnv === undefined) {
|
|
delete process.env.SOCRATICODE_PROJECT_ID;
|
|
} else {
|
|
process.env.SOCRATICODE_PROJECT_ID = savedProjectIdEnv;
|
|
}
|
|
});
|
|
|
|
it("returns only current project when no links configured", () => {
|
|
const collections = resolveLinkedCollections(projectDir);
|
|
expect(collections).toHaveLength(1);
|
|
expect(collections[0].label).toBe("main-project");
|
|
expect(collections[0].name).toMatch(/^codebase_/);
|
|
});
|
|
|
|
it("returns current + linked collections with correct labels", () => {
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ linkedProjects: ["../linked-lib"] }),
|
|
);
|
|
const collections = resolveLinkedCollections(projectDir);
|
|
expect(collections).toHaveLength(2);
|
|
// Current project is first (highest priority)
|
|
expect(collections[0].label).toBe("main-project");
|
|
expect(collections[1].label).toBe("linked-lib");
|
|
// Different collection names
|
|
expect(collections[0].name).not.toBe(collections[1].name);
|
|
});
|
|
|
|
it("linked collections use base hash without branch suffix when SOCRATICODE_BRANCH_AWARE is true", () => {
|
|
process.env.SOCRATICODE_BRANCH_AWARE = "true";
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ linkedProjects: ["../linked-lib"] }),
|
|
);
|
|
|
|
// Get linked collection name with branch-aware on
|
|
const withBranch = resolveLinkedCollections(projectDir);
|
|
expect(withBranch).toHaveLength(2);
|
|
const linkedNameWithBranch = withBranch[1].name;
|
|
|
|
// Get linked collection name with branch-aware off
|
|
delete process.env.SOCRATICODE_BRANCH_AWARE;
|
|
const withoutBranch = resolveLinkedCollections(projectDir);
|
|
const linkedNameWithoutBranch = withoutBranch[1].name;
|
|
|
|
// Linked project collection name must be identical regardless of SOCRATICODE_BRANCH_AWARE
|
|
expect(linkedNameWithBranch).toBe(linkedNameWithoutBranch);
|
|
// And must NOT contain a branch suffix (double-underscore)
|
|
expect(linkedNameWithBranch).not.toContain("__");
|
|
});
|
|
|
|
it("honors a linked project's committed projectId", () => {
|
|
// Symmetry guarantee: the same project must resolve to the same
|
|
// Qdrant collection whether it's the current root or a linked
|
|
// dependency from another project. Without this, cross-project
|
|
// search would silently miss the linked project's data when that
|
|
// project pins its id via .socraticode.json.
|
|
fs.writeFileSync(
|
|
path.join(linkedDir, ".socraticode.json"),
|
|
JSON.stringify({ projectId: "linked-stable" }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ linkedProjects: ["../linked-lib"] }),
|
|
);
|
|
|
|
const collections = resolveLinkedCollections(projectDir);
|
|
expect(collections).toHaveLength(2);
|
|
expect(collections[1].name).toBe("codebase_linked-stable");
|
|
});
|
|
|
|
it("dedups linked projects that share a committed projectId with the current project", () => {
|
|
// Two physically distinct paths can declare the same shared
|
|
// projectId — they point to the same Qdrant collection, so the
|
|
// current project must not be queried twice (once as "self" and
|
|
// once as "linked").
|
|
fs.writeFileSync(
|
|
path.join(linkedDir, ".socraticode.json"),
|
|
JSON.stringify({ projectId: "shared-team-id" }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ projectId: "shared-team-id", linkedProjects: ["../linked-lib"] }),
|
|
);
|
|
|
|
const collections = resolveLinkedCollections(projectDir);
|
|
expect(collections).toHaveLength(1);
|
|
expect(collections[0].name).toBe("codebase_shared-team-id");
|
|
});
|
|
|
|
it("does not wrongly dedup a linked project when env override diverges from file projectId", () => {
|
|
// Regression for a subtle dedup misalignment: the dedup seed used
|
|
// the file-only `effectiveBaseProjectId`, while the current
|
|
// project's collection name comes from the env-aware
|
|
// `projectIdFromPath`. When `SOCRATICODE_PROJECT_ID` is set on the
|
|
// current project AND both projects pin the same `projectId` in
|
|
// their `.socraticode.json`, the two computations disagreed and
|
|
// the linked project was wrongly skipped — losing its data even
|
|
// though it lives in a collection genuinely distinct from the
|
|
// env-overridden current one.
|
|
process.env.SOCRATICODE_PROJECT_ID = "env-override";
|
|
fs.writeFileSync(
|
|
path.join(linkedDir, ".socraticode.json"),
|
|
JSON.stringify({ projectId: "shared-id" }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ projectId: "shared-id", linkedProjects: ["../linked-lib"] }),
|
|
);
|
|
|
|
const collections = resolveLinkedCollections(projectDir);
|
|
expect(collections).toHaveLength(2);
|
|
expect(collections[0].name).toBe("codebase_env-override");
|
|
expect(collections[1].name).toBe("codebase_shared-id");
|
|
});
|
|
|
|
it("does not leak SOCRATICODE_PROJECT_ID env var into linked-project collection names", () => {
|
|
// The env var is process-scoped and applies only to the current
|
|
// project. Without this guard, linked projects would all collapse
|
|
// onto the env-var collection name — wrong and silently lossy.
|
|
process.env.SOCRATICODE_PROJECT_ID = "current-only";
|
|
fs.writeFileSync(
|
|
path.join(projectDir, ".socraticode.json"),
|
|
JSON.stringify({ linkedProjects: ["../linked-lib"] }),
|
|
);
|
|
|
|
const collections = resolveLinkedCollections(projectDir);
|
|
expect(collections).toHaveLength(2);
|
|
expect(collections[0].name).toBe("codebase_current-only");
|
|
expect(collections[1].name).toMatch(/^codebase_[0-9a-f]{12}$/);
|
|
expect(collections[1].name).not.toBe("codebase_current-only");
|
|
});
|
|
});
|
|
|
|
// ── Branch awareness ────────────────────────────────────────────────
|
|
|
|
describe("sanitizeBranchName", () => {
|
|
it("passes through simple branch names", () => {
|
|
expect(sanitizeBranchName("main")).toBe("main");
|
|
expect(sanitizeBranchName("develop")).toBe("develop");
|
|
});
|
|
|
|
it("replaces slashes with underscores", () => {
|
|
expect(sanitizeBranchName("feat/my-feature")).toBe("feat_my-feature");
|
|
});
|
|
|
|
it("handles deeply nested branch names", () => {
|
|
expect(sanitizeBranchName("feature/JIRA-123/some-work")).toBe(
|
|
"feature_JIRA-123_some-work",
|
|
);
|
|
});
|
|
|
|
it("collapses consecutive underscores", () => {
|
|
expect(sanitizeBranchName("feat//double")).toBe("feat_double");
|
|
});
|
|
|
|
it("strips leading and trailing underscores", () => {
|
|
expect(sanitizeBranchName("/leading")).toBe("leading");
|
|
expect(sanitizeBranchName("trailing/")).toBe("trailing");
|
|
});
|
|
|
|
it("preserves hyphens", () => {
|
|
expect(sanitizeBranchName("my-branch-name")).toBe("my-branch-name");
|
|
});
|
|
|
|
it("replaces special characters", () => {
|
|
expect(sanitizeBranchName("feat@v2.0")).toBe("feat_v2_0");
|
|
});
|
|
|
|
it("returns empty string when all characters are invalid", () => {
|
|
expect(sanitizeBranchName("///")).toBe("");
|
|
expect(sanitizeBranchName("@@@")).toBe("");
|
|
expect(sanitizeBranchName("...")).toBe("");
|
|
});
|
|
});
|
|
|
|
/** Create a temporary git repo with a named branch and initial commit. */
|
|
function initTempRepo(tmpDir: string, branch: string): void {
|
|
execFileSync("git", ["init", "-b", branch, tmpDir]);
|
|
execFileSync("git", ["config", "user.name", "test"], { cwd: tmpDir });
|
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: tmpDir });
|
|
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: tmpDir });
|
|
}
|
|
|
|
describe("detectGitBranch", () => {
|
|
it("detects a branch in a git repo", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-git-"));
|
|
try {
|
|
initTempRepo(tmpDir, "test-branch");
|
|
const branch = detectGitBranch(tmpDir);
|
|
expect(branch).toBe("test-branch");
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("returns null for non-git directories", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-nogit-"));
|
|
try {
|
|
expect(detectGitBranch(tmpDir)).toBeNull();
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("projectIdFromPath with SOCRATICODE_BRANCH_AWARE", () => {
|
|
it("does not include branch suffix by default", () => {
|
|
const id = projectIdFromPath("/some/project/path");
|
|
expect(id).toMatch(/^[0-9a-f]{12}$/);
|
|
expect(id).not.toContain("__");
|
|
});
|
|
|
|
it("appends branch suffix when SOCRATICODE_BRANCH_AWARE=true", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-braware-"));
|
|
try {
|
|
initTempRepo(tmpDir, "my-feature");
|
|
process.env.SOCRATICODE_BRANCH_AWARE = "true";
|
|
const id = projectIdFromPath(tmpDir);
|
|
expect(id).toContain("__");
|
|
const parts = id.split("__");
|
|
expect(parts[0]).toMatch(/^[0-9a-f]{12}$/);
|
|
expect(parts[1]).toBe("my-feature");
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("produces valid Qdrant collection names with branch suffix", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-braware-"));
|
|
try {
|
|
initTempRepo(tmpDir, "feat/some-branch");
|
|
process.env.SOCRATICODE_BRANCH_AWARE = "true";
|
|
const id = projectIdFromPath(tmpDir);
|
|
const coll = collectionName(id);
|
|
// Must be valid Qdrant name: [a-zA-Z0-9_-]+
|
|
expect(coll).toMatch(/^[a-zA-Z0-9_-]+$/);
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not append branch when SOCRATICODE_PROJECT_ID is set", () => {
|
|
process.env.SOCRATICODE_BRANCH_AWARE = "true";
|
|
process.env.SOCRATICODE_PROJECT_ID = "explicit-id";
|
|
const id = projectIdFromPath(process.cwd());
|
|
expect(id).toBe("explicit-id");
|
|
expect(id).not.toContain("__");
|
|
});
|
|
|
|
it("does not append branch when SOCRATICODE_BRANCH_AWARE is not true", () => {
|
|
process.env.SOCRATICODE_BRANCH_AWARE = "false";
|
|
const id = projectIdFromPath(process.cwd());
|
|
expect(id).toMatch(/^[0-9a-f]{12}$/);
|
|
});
|
|
});
|
|
|
|
// ── projectId override via .socraticode.json ────────────────────────
|
|
//
|
|
// Allow teams to commit a stable `projectId` in `.socraticode.json` so
|
|
// every machine that checks out the project addresses the same Qdrant
|
|
// collection regardless of where the working tree lives on disk.
|
|
|
|
describe("projectIdFromPath with .socraticode.json projectId", () => {
|
|
let tmpDir: string;
|
|
let projectDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-projid-"));
|
|
projectDir = path.join(tmpDir, "my-project");
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
delete process.env.SOCRATICODE_PROJECT_ID;
|
|
delete process.env.SOCRATICODE_BRANCH_AWARE;
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeConfig(config: unknown): void {
|
|
fs.writeFileSync(path.join(projectDir, ".socraticode.json"), JSON.stringify(config));
|
|
}
|
|
|
|
it("uses projectId from .socraticode.json when env var is not set", () => {
|
|
writeConfig({ projectId: "team-shared-ios" });
|
|
expect(projectIdFromPath(projectDir)).toBe("team-shared-ios");
|
|
});
|
|
|
|
it("ignores path differences when file projectId is set", () => {
|
|
// Two different projects on disk both pin to the same id — they should share collections.
|
|
const otherDir = path.join(tmpDir, "another-project");
|
|
fs.mkdirSync(otherDir, { recursive: true });
|
|
writeConfig({ projectId: "shared-id" });
|
|
fs.writeFileSync(
|
|
path.join(otherDir, ".socraticode.json"),
|
|
JSON.stringify({ projectId: "shared-id" }),
|
|
);
|
|
expect(projectIdFromPath(projectDir)).toBe(projectIdFromPath(otherDir));
|
|
});
|
|
|
|
it("trims whitespace from file projectId", () => {
|
|
writeConfig({ projectId: " trimmed-id " });
|
|
expect(projectIdFromPath(projectDir)).toBe("trimmed-id");
|
|
});
|
|
|
|
it("throws on invalid characters in file projectId", () => {
|
|
writeConfig({ projectId: "invalid/name" });
|
|
expect(() => projectIdFromPath(projectDir)).toThrow(
|
|
/\.socraticode\.json.*projectId.*\[a-zA-Z0-9_-\]/,
|
|
);
|
|
});
|
|
|
|
it("falls back to path hash when file projectId is empty string", () => {
|
|
writeConfig({ projectId: "" });
|
|
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
|
});
|
|
|
|
it("falls back to path hash when file projectId is whitespace only", () => {
|
|
writeConfig({ projectId: " " });
|
|
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
|
});
|
|
|
|
it("falls back to path hash when file projectId has wrong type", () => {
|
|
writeConfig({ projectId: 12345 });
|
|
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
|
});
|
|
|
|
it("falls back to path hash when file projectId field is null", () => {
|
|
writeConfig({ projectId: null });
|
|
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
|
});
|
|
|
|
it("SOCRATICODE_PROJECT_ID env var takes precedence over file projectId", () => {
|
|
writeConfig({ projectId: "from-file" });
|
|
process.env.SOCRATICODE_PROJECT_ID = "from-env";
|
|
expect(projectIdFromPath(projectDir)).toBe("from-env");
|
|
});
|
|
|
|
it("uses file projectId when .socraticode.json is absent → falls back to hash", () => {
|
|
// No .socraticode.json written — current default behavior unchanged
|
|
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
|
});
|
|
|
|
it("falls back to hash when .socraticode.json is malformed JSON", () => {
|
|
fs.writeFileSync(path.join(projectDir, ".socraticode.json"), "not valid json{{{");
|
|
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
|
});
|
|
|
|
it("does not append branch suffix when file projectId is set", () => {
|
|
writeConfig({ projectId: "stable-id" });
|
|
process.env.SOCRATICODE_BRANCH_AWARE = "true";
|
|
// Initialize tmpDir as a git repo with a non-default branch to prove
|
|
// that branch-aware mode is suppressed by an explicit projectId.
|
|
// Disable gpg/ssh signing locally so the test is robust against the
|
|
// user's global git config (commit.gpgsign=true is common).
|
|
execFileSync("git", ["init", "-b", "feature-branch", projectDir]);
|
|
execFileSync("git", ["config", "user.name", "test"], { cwd: projectDir });
|
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: projectDir });
|
|
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: projectDir });
|
|
execFileSync("git", ["config", "tag.gpgsign", "false"], { cwd: projectDir });
|
|
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: projectDir });
|
|
|
|
const id = projectIdFromPath(projectDir);
|
|
expect(id).toBe("stable-id");
|
|
expect(id).not.toContain("__");
|
|
});
|
|
|
|
it("coexists with linkedProjects field in the same file", () => {
|
|
// Regression guard: adding projectId must not break linkedProjects parsing.
|
|
const linked = path.join(tmpDir, "linked");
|
|
fs.mkdirSync(linked, { recursive: true });
|
|
writeConfig({ projectId: "shared-id", linkedProjects: ["../linked"] });
|
|
expect(projectIdFromPath(projectDir)).toBe("shared-id");
|
|
// loadLinkedProjects is still happy — round-trip via resolveLinkedCollections
|
|
const collections = resolveLinkedCollections(projectDir);
|
|
expect(collections).toHaveLength(2);
|
|
expect(collections[0].name).toBe("codebase_shared-id");
|
|
});
|
|
});
|
|
});
|