From 0bceb689a7d51c6f3334141fe7a3246280dc52e3 Mon Sep 17 00:00:00 2001 From: Glen <5329798+gbryer@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:56:19 +1000 Subject: [PATCH] feat: add GSD_PROJECT_ID env var to override project hash (#1600) Extract validateProjectId() and validate at startup in bootstrapAutoSession() so users get immediate feedback on invalid values. repoIdentity() returns the custom ID directly when set. --- src/resources/extensions/gsd/auto-start.ts | 12 +++++++++- src/resources/extensions/gsd/repo-identity.ts | 23 ++++++++++++++++--- .../gsd/tests/repo-identity-worktree.test.ts | 22 +++++++++++++++++- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 5f8cbce0b..7a9920689 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -20,7 +20,7 @@ import { resolveSkillDiscoveryMode, getIsolationMode, } from "./preferences.js"; -import { ensureGsdSymlink } from "./repo-identity.js"; +import { ensureGsdSymlink, validateProjectId } from "./repo-identity.js"; import { migrateToExternalState, recoverFailedMigration } from "./migrate-external.js"; import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; import { gsdRoot, resolveMilestoneFile, milestonesDir } from "./paths.js"; @@ -130,6 +130,16 @@ export async function bootstrapAutoSession( } try { + // Validate GSD_PROJECT_ID early so the user gets immediate feedback + const customProjectId = process.env.GSD_PROJECT_ID; + if (customProjectId && !validateProjectId(customProjectId)) { + ctx.ui.notify( + `GSD_PROJECT_ID must contain only alphanumeric characters, hyphens, and underscores. Got: "${customProjectId}"`, + "error", + ); + return releaseLockAndReturn(); + } + // Ensure git repo exists if (!nativeIsRepo(base)) { const mainBranch = diff --git a/src/resources/extensions/gsd/repo-identity.ts b/src/resources/extensions/gsd/repo-identity.ts index 42c792e11..ae03e9ca2 100644 --- a/src/resources/extensions/gsd/repo-identity.ts +++ b/src/resources/extensions/gsd/repo-identity.ts @@ -92,14 +92,31 @@ function resolveGitRoot(basePath: string): string { } } +/** + * Validate a GSD_PROJECT_ID value. + * + * Must contain only alphanumeric characters, hyphens, and underscores. + * Call this once at startup so the user gets immediate feedback on bad values. + */ +export function validateProjectId(id: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(id); +} + /** * Compute a stable identity for a repository. * - * SHA-256 of `${remoteUrl}\n${resolvedRoot}`, truncated to 12 hex chars. - * Deterministic: same repo always produces the same hash regardless of - * which worktree the caller is inside. + * If `GSD_PROJECT_ID` is set, returns it directly (validation is expected + * to have already happened at startup via `validateProjectId`). + * + * Otherwise returns SHA-256 of `${remoteUrl}\n${resolvedRoot}`, truncated + * to 12 hex chars. Deterministic: same repo always produces the same hash + * regardless of which worktree the caller is inside. */ export function repoIdentity(basePath: string): string { + const projectId = process.env.GSD_PROJECT_ID; + if (projectId) { + return projectId; + } const remoteUrl = getRemoteUrl(basePath); const root = resolveGitRoot(basePath); const input = `${remoteUrl}\n${root}`; diff --git a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts index b5b894382..693ff2040 100644 --- a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; -import { externalGsdRoot, ensureGsdSymlink } from "../repo-identity.ts"; +import { repoIdentity, externalGsdRoot, ensureGsdSymlink, validateProjectId } from "../repo-identity.ts"; import { createTestContext } from "./test-helpers.ts"; const { assertEq, assertTrue, report } = createTestContext(); @@ -57,6 +57,26 @@ async function main(): Promise { assertEq(preservedDirState, join(worktreePath, ".gsd"), "worktree .gsd directory is left in place for sync-based refresh"); assertTrue(lstatSync(join(worktreePath, ".gsd")).isDirectory(), "worktree .gsd directory remains a directory"); assertTrue(existsSync(join(worktreePath, ".gsd", "milestones", "stale.txt")), "existing worktree .gsd directory contents remain available for sync logic"); + + console.log("\n=== GSD_PROJECT_ID overrides computed repo hash ==="); + process.env.GSD_PROJECT_ID = "my-project"; + assertEq(repoIdentity(base), "my-project", "repoIdentity returns GSD_PROJECT_ID when set"); + assertEq(externalGsdRoot(base), join(stateDir, "projects", "my-project"), "externalGsdRoot uses GSD_PROJECT_ID"); + delete process.env.GSD_PROJECT_ID; + + console.log("\n=== GSD_PROJECT_ID falls back to hash when unset ==="); + const hashIdentity = repoIdentity(base); + assertTrue(/^[0-9a-f]{12}$/.test(hashIdentity), "repoIdentity returns 12-char hex hash when GSD_PROJECT_ID is unset"); + + console.log("\n=== validateProjectId rejects invalid values ==="); + for (const invalid of ["has spaces", "path/traversal", "dot..dot", "back\\slash"]) { + assertTrue(!validateProjectId(invalid), `validateProjectId rejects invalid value: "${invalid}"`); + } + + console.log("\n=== validateProjectId accepts valid values ==="); + for (const valid of ["my-project", "foo_bar", "abc123", "A-Z_0-9"]) { + assertTrue(validateProjectId(valid), `validateProjectId accepts valid value: "${valid}"`); + } } finally { delete process.env.GSD_STATE_DIR; rmSync(base, { recursive: true, force: true });