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.
This commit is contained in:
Glen 2026-03-21 01:56:19 +10:00 committed by GitHub
parent 912dab1d81
commit 0bceb689a7
3 changed files with 52 additions and 5 deletions

View file

@ -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 =

View file

@ -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}`;

View file

@ -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<void> {
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 });