diff --git a/src/config.ts b/src/config.ts index 46ce910..f816378 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,43 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited +import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +// ── Branch detection ───────────────────────────────────────────────────── + +/** + * Detect the current git branch for a project path. + * Returns `null` if the path is not inside a git repository or detection fails. + */ +export function detectGitBranch(projectPath: string): string | null { + try { + const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: path.resolve(projectPath), + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + // "HEAD" is returned for detached HEAD state — treat as no branch + return branch && branch !== "HEAD" ? branch : null; + } catch { + return null; + } +} + +/** + * Sanitize a git branch name for use in Qdrant collection names. + * Replaces characters outside `[a-zA-Z0-9_-]` with underscores, + * collapses consecutive underscores, and strips leading/trailing underscores. + */ +export function sanitizeBranchName(branch: string): string { + return branch + .replace(/[^a-zA-Z0-9_-]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); +} + /** * Generate a stable project ID from an absolute folder path. * Uses a short SHA-256 prefix so collection names stay Qdrant-friendly. @@ -12,6 +46,10 @@ import path from "node:path"; * of hashing the path. This lets multiple directory trees (e.g. git * worktrees) share a single Qdrant index. The value must contain only * characters valid in a Qdrant collection name (`[a-zA-Z0-9_-]`). + * + * When `SOCRATICODE_BRANCH_AWARE` is `"true"` (and no explicit project ID + * is set), the current git branch name is appended to the hash, producing + * a separate set of collections per branch. */ export function projectIdFromPath(folderPath: string): string { const explicit = process.env.SOCRATICODE_PROJECT_ID?.trim(); @@ -24,7 +62,17 @@ export function projectIdFromPath(folderPath: string): string { return explicit; } const normalized = path.resolve(folderPath); - return createHash("sha256").update(normalized).digest("hex").slice(0, 12); + let id = createHash("sha256").update(normalized).digest("hex").slice(0, 12); + + // Branch-aware mode: append sanitized branch name to isolate per-branch indexes + if (process.env.SOCRATICODE_BRANCH_AWARE === "true") { + const branch = detectGitBranch(normalized); + if (branch) { + id = `${id}__${sanitizeBranchName(branch)}`; + } + } + + return id; } /** diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 4465169..bbecb3a 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -4,17 +4,23 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { collectionName, contextCollectionName, graphCollectionName, loadLinkedProjects, projectIdFromPath, resolveLinkedCollections } from "../../src/config.js"; +import { collectionName, contextCollectionName, detectGitBranch, graphCollectionName, loadLinkedProjects, projectIdFromPath, resolveLinkedCollections, sanitizeBranchName } from "../../src/config.js"; describe("config", () => { - // Clean up env override between tests + // 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", () => { @@ -277,4 +283,99 @@ describe("config", () => { expect(collections[0].name).not.toBe(collections[1].name); }); }); + + // ── 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"); + }); + }); + + describe("detectGitBranch", () => { + it("detects a branch in the current repo", () => { + // This test runs inside the socraticode git repo + const branch = detectGitBranch(process.cwd()); + expect(branch).toBeTruthy(); + expect(typeof branch).toBe("string"); + }); + + 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", () => { + process.env.SOCRATICODE_BRANCH_AWARE = "true"; + // Run from current repo directory so git detection works + const id = projectIdFromPath(process.cwd()); + expect(id).toContain("__"); + // Hash part + __ + branch + const parts = id.split("__"); + expect(parts[0]).toMatch(/^[0-9a-f]{12}$/); + expect(parts[1]).toBeTruthy(); + }); + + it("produces valid Qdrant collection names with branch suffix", () => { + process.env.SOCRATICODE_BRANCH_AWARE = "true"; + const id = projectIdFromPath(process.cwd()); + const coll = collectionName(id); + // Must be valid Qdrant name: [a-zA-Z0-9_-]+ + expect(coll).toMatch(/^[a-zA-Z0-9_-]+$/); + }); + + 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}$/); + }); + }); });