feat: branch-aware collection naming via SOCRATICODE_BRANCH_AWARE

- detectGitBranch() detects current git branch via git rev-parse
- sanitizeBranchName() converts branch names to Qdrant-safe suffixes
- When SOCRATICODE_BRANCH_AWARE=true, projectIdFromPath appends __branch
  to create separate indexes per branch
- Explicit SOCRATICODE_PROJECT_ID takes precedence (no branch suffix)
- 14 unit tests for sanitization, detection, and integration

Relates to #19
This commit is contained in:
Giancarlo Erra
2026-04-08 14:19:57 +01:00
parent 61e868cf9e
commit 3a4139d714
2 changed files with 152 additions and 3 deletions
+49 -1
View File
@@ -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;
}
/**
+103 -2
View File
@@ -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}$/);
});
});
});