mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
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:
+49
-1
@@ -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
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user