Files
msd-core/sdk/src/query/workspace.ts
Tom Boucher 002bcf2a8a fix(2137): skip worktree isolation when .gitmodules detected (#2144)
* feat(sdk): add typed query foundation and gsd-sdk query (Phase 1)

Add sdk/src/query registry and handlers with tests, GSDQueryError, CLI query wiring, and supporting type/tool-scoping hooks. Update CHANGELOG. Vitest 4 constructor mock fixes in milestone-runner tests.

Made-with: Cursor

* fix(2137): skip worktree isolation when .gitmodules detected

When a project contains git submodules, worktree isolation cannot
correctly handle submodule commits — three separate gaps exist in
worktree setup, executor commit protocol, and merge-back. Rather
than patch each gap individually, detect .gitmodules at phase start
and fall back to sequential execution, which handles submodules
transparently (Option B).

Affected workflows: execute-phase.md, quick.md

---------

Co-authored-by: David Sienkowski <dave@sienkowski.com>
2026-04-12 08:33:04 -04:00

132 lines
4.7 KiB
TypeScript

/**
* Workspace-aware state resolution — scopes .planning/ paths to a
* GSD_WORKSTREAM or GSD_PROJECT environment context.
*
* Port of planningDir() workspace logic from get-shit-done/bin/lib/core.cjs
* (line 669+). Provides WorkspaceContext reading and validated path scoping.
*
* Security: workspace names are validated to reject path traversal (T-14-05).
*
* @example
* ```typescript
* import { resolveWorkspaceContext, workspacePlanningPaths } from './workspace.js';
*
* const ctx = resolveWorkspaceContext();
* // { workstream: 'backend', project: null }
*
* const paths = workspacePlanningPaths('/my/project', ctx);
* // paths.state → '/my/project/.planning/workstreams/backend/STATE.md'
* ```
*/
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { toPosixPath } from './helpers.js';
import type { PlanningPaths } from './helpers.js';
// ─── Types ─────────────────────────────────────────────────────────────────
/**
* Resolved workspace context from environment variables.
*/
export interface WorkspaceContext {
/** Active workstream name (from GSD_WORKSTREAM env var), or null */
workstream: string | null;
/** Active project name (from GSD_PROJECT env var), or null */
project: string | null;
}
// ─── Validation ────────────────────────────────────────────────────────────
/**
* Validate a workspace or project name.
*
* Rejects names that could cause path traversal (T-14-05):
* - Empty string
* - Names containing '/' or '\'
* - Names containing '..' sequences
*
* @param name - Workspace or project name to validate
* @param kind - Label for error messages ('workstream' or 'project')
* @throws GSDError with Validation classification on invalid name
*/
function validateWorkspaceName(name: string, kind: string): void {
if (!name || name.trim() === '') {
throw new GSDError(
`${kind} name must not be empty`,
ErrorClassification.Validation,
);
}
if (name.includes('/') || name.includes('\\')) {
throw new GSDError(
`${kind} name must not contain path separators: ${name}`,
ErrorClassification.Validation,
);
}
if (name.includes('..')) {
throw new GSDError(
`${kind} name must not contain '..' (path traversal): ${name}`,
ErrorClassification.Validation,
);
}
}
// ─── resolveWorkspaceContext ───────────────────────────────────────────────
/**
* Read GSD_WORKSTREAM and GSD_PROJECT environment variables.
*
* Returns a WorkspaceContext with null values when the env vars are not set.
*
* @returns Resolved workspace context
*/
export function resolveWorkspaceContext(): WorkspaceContext {
return {
workstream: process.env['GSD_WORKSTREAM'] || null,
project: process.env['GSD_PROJECT'] || null,
};
}
// ─── workspacePlanningPaths ────────────────────────────────────────────────
/**
* Return PlanningPaths scoped to the active workspace or project.
*
* When context has a workstream set: base = .planning/workstreams/<ws>/
* When context has a project set: base = .planning/projects/<project>/
* When context is null or empty: base = .planning/ (default)
*
* Workspace and project names are validated before path construction.
*
* @param projectDir - Absolute project root path
* @param context - Optional workspace context (defaults to no scoping)
* @returns PlanningPaths scoped to the active workspace
* @throws GSDError if workspace/project name fails validation
*/
export function workspacePlanningPaths(
projectDir: string,
context?: WorkspaceContext,
): PlanningPaths {
let base: string;
if (context?.workstream != null) {
validateWorkspaceName(context.workstream, 'workstream');
base = join(projectDir, '.planning', 'workstreams', context.workstream);
} else if (context?.project != null) {
validateWorkspaceName(context.project, 'project');
base = join(projectDir, '.planning', 'projects', context.project);
} else {
base = join(projectDir, '.planning');
}
return {
planning: toPosixPath(base),
state: toPosixPath(join(base, 'STATE.md')),
roadmap: toPosixPath(join(base, 'ROADMAP.md')),
project: toPosixPath(join(base, 'PROJECT.md')),
config: toPosixPath(join(base, 'config.json')),
phases: toPosixPath(join(base, 'phases')),
requirements: toPosixPath(join(base, 'REQUIREMENTS.md')),
};
}