Files
msd-core/sdk/src/query/workspace.test.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

120 lines
4.6 KiB
TypeScript

/**
* Unit tests for workspace-aware state resolution.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { resolveWorkspaceContext, workspacePlanningPaths } from './workspace.js';
// ─── resolveWorkspaceContext ───────────────────────────────────────────────
describe('resolveWorkspaceContext', () => {
afterEach(() => {
delete process.env['GSD_WORKSTREAM'];
delete process.env['GSD_PROJECT'];
});
it('returns null values when env vars not set', () => {
delete process.env['GSD_WORKSTREAM'];
delete process.env['GSD_PROJECT'];
const ctx = resolveWorkspaceContext();
expect(ctx.workstream).toBeNull();
expect(ctx.project).toBeNull();
});
it('reads GSD_WORKSTREAM from env', () => {
process.env['GSD_WORKSTREAM'] = 'backend';
const ctx = resolveWorkspaceContext();
expect(ctx.workstream).toBe('backend');
});
it('reads GSD_PROJECT from env', () => {
process.env['GSD_PROJECT'] = 'api-server';
const ctx = resolveWorkspaceContext();
expect(ctx.project).toBe('api-server');
});
it('reads both vars when both are set', () => {
process.env['GSD_WORKSTREAM'] = 'ws1';
process.env['GSD_PROJECT'] = 'proj1';
const ctx = resolveWorkspaceContext();
expect(ctx.workstream).toBe('ws1');
expect(ctx.project).toBe('proj1');
});
});
// ─── workspacePlanningPaths ────────────────────────────────────────────────
describe('workspacePlanningPaths', () => {
const projectDir = '/my/project';
it('returns default .planning/ when no context provided', () => {
const paths = workspacePlanningPaths(projectDir);
expect(paths.planning).toContain('.planning');
expect(paths.planning).not.toContain('workstreams');
expect(paths.planning).not.toContain('projects');
expect(paths.state).toContain('STATE.md');
expect(paths.phases).toContain('phases');
});
it('returns default .planning/ when context has no workspace or project', () => {
const paths = workspacePlanningPaths(projectDir, { workstream: null, project: null });
expect(paths.planning).not.toContain('workstreams');
expect(paths.planning).not.toContain('projects');
});
it('scopes to .planning/workstreams/<ws> when workstream set', () => {
const paths = workspacePlanningPaths(projectDir, { workstream: 'backend', project: null });
expect(paths.planning).toContain('workstreams/backend');
expect(paths.state).toContain('workstreams/backend/STATE.md');
expect(paths.phases).toContain('workstreams/backend/phases');
});
it('scopes to .planning/projects/<project> when project set', () => {
const paths = workspacePlanningPaths(projectDir, { workstream: null, project: 'api-server' });
expect(paths.planning).toContain('projects/api-server');
expect(paths.state).toContain('projects/api-server/STATE.md');
});
it('workstream takes precedence over project when both set', () => {
const paths = workspacePlanningPaths(projectDir, { workstream: 'ws1', project: 'proj1' });
expect(paths.planning).toContain('workstreams/ws1');
expect(paths.planning).not.toContain('projects');
});
it('throws on empty workstream name', () => {
expect(() => workspacePlanningPaths(projectDir, { workstream: '', project: null }))
.toThrow('empty');
});
it('throws on workstream name containing forward slash', () => {
expect(() => workspacePlanningPaths(projectDir, { workstream: 'ws/bad', project: null }))
.toThrow('path separators');
});
it('throws on workstream name containing backslash', () => {
expect(() => workspacePlanningPaths(projectDir, { workstream: 'ws\\bad', project: null }))
.toThrow('path separators');
});
it('throws on workstream name containing ".."', () => {
expect(() => workspacePlanningPaths(projectDir, { workstream: '../escape', project: null }))
.toThrow('..');
});
it('throws on project name containing path separators', () => {
expect(() => workspacePlanningPaths(projectDir, { workstream: null, project: '../../bad' }))
.toThrow('path separators');
});
it('all path fields are defined', () => {
const paths = workspacePlanningPaths(projectDir, { workstream: 'ws1', project: null });
expect(paths.planning).toBeDefined();
expect(paths.state).toBeDefined();
expect(paths.roadmap).toBeDefined();
expect(paths.project).toBeDefined();
expect(paths.config).toBeDefined();
expect(paths.phases).toBeDefined();
expect(paths.requirements).toBeDefined();
});
});