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:
Tom Boucher
2026-04-12 08:33:04 -04:00
committed by GitHub
parent 58632e0718
commit 002bcf2a8a
63 changed files with 17872 additions and 23 deletions

View File

@@ -0,0 +1,390 @@
/**
* Unit tests for STATE.md mutation handlers.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, readFile, rm, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { existsSync } from 'node:fs';
// ─── Helpers (internal) ─────────────────────────────────────────────────────
/** Minimal STATE.md for testing. */
const MINIMAL_STATE = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: SDK-First Migration
status: executing
---
# Project State
## Project Reference
**Core value:** Test project
## Current Position
Phase: 10 (Read-Only Queries) — EXECUTING
Plan: 2 of 3
Status: Executing Phase 10
Last activity: 2026-04-08 -- Phase 10 execution started
Progress: [░░░░░░░░░░] 50%
## Performance Metrics
**Velocity:**
| Phase | Duration | Tasks | Files |
|-------|----------|-------|-------|
## Accumulated Context
### Decisions
None yet.
### Pending Todos
None yet.
### Blockers/Concerns
None yet.
## Session Continuity
Last session: 2026-04-07T10:00:00.000Z
Stopped at: Completed 10-02-PLAN.md
Resume file: None
`;
/** Create a minimal .planning directory for testing. */
async function setupTestProject(tmpDir: string, stateContent?: string): Promise<string> {
const planningDir = join(tmpDir, '.planning');
await mkdir(planningDir, { recursive: true });
await mkdir(join(planningDir, 'phases'), { recursive: true });
await writeFile(join(planningDir, 'STATE.md'), stateContent || MINIMAL_STATE, 'utf-8');
// Minimal ROADMAP.md for buildStateFrontmatter
await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## Current Milestone: v3.0 SDK-First Migration\n\n### Phase 10: Read-Only Queries\n\nGoal: Port queries.\n', 'utf-8');
await writeFile(join(planningDir, 'config.json'), '{"model_profile":"balanced"}', 'utf-8');
return tmpDir;
}
// ─── Import tests ───────────────────────────────────────────────────────────
describe('state-mutation imports', () => {
it('exports stateUpdate handler', async () => {
const mod = await import('./state-mutation.js');
expect(typeof mod.stateUpdate).toBe('function');
});
it('exports statePatch handler', async () => {
const mod = await import('./state-mutation.js');
expect(typeof mod.statePatch).toBe('function');
});
it('exports stateBeginPhase handler', async () => {
const mod = await import('./state-mutation.js');
expect(typeof mod.stateBeginPhase).toBe('function');
});
it('exports stateAdvancePlan handler', async () => {
const mod = await import('./state-mutation.js');
expect(typeof mod.stateAdvancePlan).toBe('function');
});
it('exports stateRecordMetric handler', async () => {
const mod = await import('./state-mutation.js');
expect(typeof mod.stateRecordMetric).toBe('function');
});
it('exports stateUpdateProgress handler', async () => {
const mod = await import('./state-mutation.js');
expect(typeof mod.stateUpdateProgress).toBe('function');
});
it('exports stateAddDecision handler', async () => {
const mod = await import('./state-mutation.js');
expect(typeof mod.stateAddDecision).toBe('function');
});
it('exports stateAddBlocker handler', async () => {
const mod = await import('./state-mutation.js');
expect(typeof mod.stateAddBlocker).toBe('function');
});
it('exports stateResolveBlocker handler', async () => {
const mod = await import('./state-mutation.js');
expect(typeof mod.stateResolveBlocker).toBe('function');
});
it('exports stateRecordSession handler', async () => {
const mod = await import('./state-mutation.js');
expect(typeof mod.stateRecordSession).toBe('function');
});
});
// ─── stateReplaceField ──────────────────────────────────────────────────────
describe('stateReplaceField', () => {
it('replaces bold format field', async () => {
const { stateReplaceField } = await import('./state-mutation.js');
const content = '**Status:** executing\n**Plan:** 1';
const result = stateReplaceField(content, 'Status', 'done');
expect(result).toContain('**Status:** done');
});
it('replaces plain format field', async () => {
const { stateReplaceField } = await import('./state-mutation.js');
const content = 'Status: executing\nPlan: 1';
const result = stateReplaceField(content, 'Status', 'done');
expect(result).toContain('Status: done');
});
it('returns null when field not found', async () => {
const { stateReplaceField } = await import('./state-mutation.js');
const result = stateReplaceField('no fields here', 'Missing', 'value');
expect(result).toBeNull();
});
it('is case-insensitive', async () => {
const { stateReplaceField } = await import('./state-mutation.js');
const content = '**status:** executing';
const result = stateReplaceField(content, 'Status', 'done');
expect(result).toContain('done');
});
});
// ─── acquireStateLock / releaseStateLock ─────────────────────────────────────
describe('acquireStateLock / releaseStateLock', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-lock-'));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('creates and removes lockfile', async () => {
const { acquireStateLock, releaseStateLock } = await import('./state-mutation.js');
const statePath = join(tmpDir, 'STATE.md');
await writeFile(statePath, 'test', 'utf-8');
const lockPath = await acquireStateLock(statePath);
expect(existsSync(lockPath)).toBe(true);
await releaseStateLock(lockPath);
expect(existsSync(lockPath)).toBe(false);
});
it('tracks lockPath in _heldStateLocks on acquire and removes on release', async () => {
const { acquireStateLock, releaseStateLock, _heldStateLocks } = await import('./state-mutation.js');
const statePath = join(tmpDir, 'STATE.md');
await writeFile(statePath, 'test', 'utf-8');
const lockPath = await acquireStateLock(statePath);
expect(_heldStateLocks.has(lockPath)).toBe(true);
await releaseStateLock(lockPath);
expect(_heldStateLocks.has(lockPath)).toBe(false);
});
it('returns lockPath on non-EEXIST errors instead of throwing', async () => {
// Simulate a non-EEXIST error by using a path in a non-existent directory
// This triggers ENOENT (not EEXIST), which should return lockPath gracefully
const { acquireStateLock } = await import('./state-mutation.js');
const badPath = join(tmpDir, 'nonexistent-dir', 'subdir', 'STATE.md');
// Should NOT throw — should return lockPath gracefully
const lockPath = await acquireStateLock(badPath);
expect(lockPath).toBe(badPath + '.lock');
});
});
// ─── stateUpdate ────────────────────────────────────────────────────────────
describe('stateUpdate', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-update-'));
await setupTestProject(tmpDir);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('updates a single field and round-trips through stateLoad', async () => {
const { stateUpdate } = await import('./state-mutation.js');
const { stateLoad } = await import('./state.js');
const result = await stateUpdate(['Status', 'Phase complete'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.updated).toBe(true);
// Verify round-trip
const loaded = await stateLoad([], tmpDir);
const loadedData = loaded.data as Record<string, unknown>;
// Status gets normalized by buildStateFrontmatter
expect(loadedData.status).toBeTruthy();
});
it('returns updated false when field not found', async () => {
const { stateUpdate } = await import('./state-mutation.js');
const result = await stateUpdate(['NonExistentField', 'value'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.updated).toBe(false);
});
it('throws on missing args', async () => {
const { stateUpdate } = await import('./state-mutation.js');
await expect(stateUpdate([], tmpDir)).rejects.toThrow(/field and value required/);
});
});
// ─── statePatch ─────────────────────────────────────────────────────────────
describe('statePatch', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-patch-'));
await setupTestProject(tmpDir);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('updates multiple fields in one lock cycle', async () => {
const { statePatch } = await import('./state-mutation.js');
const patches = JSON.stringify({ Status: 'done', Progress: '100%' });
const result = await statePatch([patches], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.patched).toBe(true);
// Verify file was updated
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(content).toContain('done');
});
});
// ─── stateBeginPhase ────────────────────────────────────────────────────────
describe('stateBeginPhase', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-begin-'));
await setupTestProject(tmpDir);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('sets all expected fields', async () => {
const { stateBeginPhase } = await import('./state-mutation.js');
const result = await stateBeginPhase(['11', 'State Mutations', '3'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.phase).toBe('11');
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(content).toContain('Executing Phase 11');
expect(content).toContain('State Mutations');
});
});
// ─── stateAdvancePlan ───────────────────────────────────────────────────────
describe('stateAdvancePlan', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-advance-'));
await setupTestProject(tmpDir);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('increments plan counter', async () => {
const { stateAdvancePlan } = await import('./state-mutation.js');
const result = await stateAdvancePlan([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.advanced).toBe(true);
expect(data.current_plan).toBe(3);
});
});
// ─── stateAddDecision ───────────────────────────────────────────────────────
describe('stateAddDecision', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-decision-'));
await setupTestProject(tmpDir);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('appends decision and removes placeholder', async () => {
const { stateAddDecision } = await import('./state-mutation.js');
const result = await stateAddDecision(['[Phase 10]: Use lockfile atomicity'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.added).toBe(true);
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(content).toContain('Use lockfile atomicity');
// Verify "None yet." was removed from the Decisions section specifically
const decisionsMatch = content.match(/###?\s*Decisions\s*\n([\s\S]*?)(?=\n###?|\n##[^#]|$)/i);
expect(decisionsMatch).not.toBeNull();
expect(decisionsMatch![1]).not.toContain('None yet.');
});
});
// ─── stateRecordSession ─────────────────────────────────────────────────────
describe('stateRecordSession', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-session-'));
await setupTestProject(tmpDir);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('updates session fields', async () => {
const { stateRecordSession } = await import('./state-mutation.js');
const result = await stateRecordSession(
['2026-04-08T12:00:00Z', 'Completed 11-01-PLAN.md'],
tmpDir
);
const data = result.data as Record<string, unknown>;
expect(data.recorded).toBe(true);
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(content).toContain('Completed 11-01-PLAN.md');
});
});