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>
This commit is contained in:
131
sdk/src/query/workspace.ts
Normal file
131
sdk/src/query/workspace.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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')),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user