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:
390
sdk/src/query/state-mutation.test.ts
Normal file
390
sdk/src/query/state-mutation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user