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

258
sdk/src/query/commit.ts Normal file
View File

@@ -0,0 +1,258 @@
/**
* Git commit and check-commit query handlers.
*
* Ported from get-shit-done/bin/lib/commands.cjs (cmdCommit, cmdCheckCommit)
* and core.cjs (execGit). Provides commit creation with message sanitization
* and pre-commit validation.
*
* @example
* ```typescript
* import { commit, checkCommit } from './commit.js';
*
* await commit(['docs: update state', '.planning/STATE.md'], '/project');
* // { data: { committed: true, hash: 'abc1234', message: 'docs: update state', files: [...] } }
*
* await checkCommit([], '/project');
* // { data: { can_commit: true, reason: 'commit_docs_enabled', ... } }
* ```
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { planningPaths } from './helpers.js';
import type { QueryHandler } from './utils.js';
// ─── execGit ──────────────────────────────────────────────────────────────
/**
* Run a git command in the given working directory.
*
* Ported from core.cjs lines 531-542.
*
* @param cwd - Working directory for the git command
* @param args - Git command arguments (e.g., ['commit', '-m', 'msg'])
* @returns Object with exitCode, stdout, and stderr
*/
export function execGit(cwd: string, args: string[]): { exitCode: number; stdout: string; stderr: string } {
const result = spawnSync('git', args, {
cwd,
stdio: 'pipe',
encoding: 'utf-8',
});
return {
exitCode: result.status ?? 1,
stdout: (result.stdout ?? '').toString().trim(),
stderr: (result.stderr ?? '').toString().trim(),
};
}
// ─── sanitizeCommitMessage ────────────────────────────────────────────────
/**
* Sanitize a commit message to prevent prompt injection.
*
* Ported from security.cjs sanitizeForPrompt.
* Strips zero-width characters, null bytes, and neutralizes
* known injection markers that could hijack agent context.
*
* @param text - Raw commit message
* @returns Sanitized message safe for git commit
*/
export function sanitizeCommitMessage(text: string): string {
if (!text || typeof text !== 'string') return '';
let sanitized = text;
// Strip null bytes
sanitized = sanitized.replace(/\0/g, '');
// Strip zero-width characters that could hide instructions
sanitized = sanitized.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD]/g, '');
// Neutralize XML/HTML tags that mimic system boundaries
sanitized = sanitized.replace(/<(\/?)?(?:system|assistant|human)>/gi,
(_match, slash) => `\uFF1C${slash || ''}system-text\uFF1E`);
// Neutralize [SYSTEM] / [INST] markers
sanitized = sanitized.replace(/\[(SYSTEM|INST)\]/gi, '[$1-TEXT]');
// Neutralize <<SYS>> markers
sanitized = sanitized.replace(/<<\s*SYS\s*>>/gi, '\u00ABSYS-TEXT\u00BB');
return sanitized;
}
// ─── commit ───────────────────────────────────────────────────────────────
/**
* Stage files and create a git commit.
*
* Checks commit_docs config (unless --force), sanitizes message,
* stages specified files (or all .planning/), and commits.
*
* @param args - args[0]=message, remaining=file paths or flags (--force, --amend, --no-verify)
* @param projectDir - Project root directory
* @returns QueryResult with commit result
*/
export const commit: QueryHandler = async (args, projectDir) => {
const allArgs = [...args];
// Extract flags
const hasForce = allArgs.includes('--force');
const hasAmend = allArgs.includes('--amend');
const hasNoVerify = allArgs.includes('--no-verify');
const nonFlagArgs = allArgs.filter(a => !a.startsWith('--'));
const message = nonFlagArgs[0];
const filePaths = nonFlagArgs.slice(1);
if (!message && !hasAmend) {
return { data: { committed: false, reason: 'commit message required' } };
}
// Check commit_docs config unless --force
if (!hasForce) {
const paths = planningPaths(projectDir);
try {
const raw = await readFile(paths.config, 'utf-8');
const config = JSON.parse(raw) as Record<string, unknown>;
if (config.commit_docs === false) {
return { data: { committed: false, reason: 'commit_docs disabled' } };
}
} catch {
// No config or malformed — allow commit
}
}
// Sanitize message
const sanitized = message ? sanitizeCommitMessage(message) : message;
// Stage files
const filesToStage = filePaths.length > 0 ? filePaths : ['.planning/'];
for (const file of filesToStage) {
execGit(projectDir, ['add', file]);
}
// Check if anything is staged
const diffResult = execGit(projectDir, ['diff', '--cached', '--name-only']);
const stagedFiles = diffResult.stdout ? diffResult.stdout.split('\n').filter(Boolean) : [];
if (stagedFiles.length === 0) {
return { data: { committed: false, reason: 'nothing staged' } };
}
// Build commit command
const commitArgs = hasAmend
? ['commit', '--amend', '--no-edit']
: ['commit', '-m', sanitized];
if (hasNoVerify) commitArgs.push('--no-verify');
const commitResult = execGit(projectDir, commitArgs);
if (commitResult.exitCode !== 0) {
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
return { data: { committed: false, reason: 'nothing to commit' } };
}
return { data: { committed: false, reason: commitResult.stderr || 'commit failed', exitCode: commitResult.exitCode } };
}
// Get short hash
const hashResult = execGit(projectDir, ['rev-parse', '--short', 'HEAD']);
const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
return { data: { committed: true, hash, message: sanitized, files: stagedFiles } };
};
// ─── checkCommit ──────────────────────────────────────────────────────────
/**
* Validate whether a commit can proceed.
*
* Checks commit_docs config and staged file state.
*
* @param _args - Unused
* @param projectDir - Project root directory
* @returns QueryResult with { can_commit, reason, commit_docs, staged_files }
*/
export const checkCommit: QueryHandler = async (_args, projectDir) => {
const paths = planningPaths(projectDir);
let commitDocs = true;
try {
const raw = await readFile(paths.config, 'utf-8');
const config = JSON.parse(raw) as Record<string, unknown>;
if (config.commit_docs === false) {
commitDocs = false;
}
} catch {
// No config — default to allowing commits
}
// Check staged files
const diffResult = execGit(projectDir, ['diff', '--cached', '--name-only']);
const stagedFiles = diffResult.stdout ? diffResult.stdout.split('\n').filter(Boolean) : [];
if (!commitDocs) {
// If commit_docs is false, check if any .planning/ files are staged
const planningFiles = stagedFiles.filter(f => f.startsWith('.planning/') || f.startsWith('.planning\\'));
if (planningFiles.length > 0) {
return {
data: {
can_commit: false,
reason: `commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged`,
commit_docs: false,
staged_files: planningFiles,
},
};
}
}
return {
data: {
can_commit: true,
reason: commitDocs ? 'commit_docs_enabled' : 'no_planning_files_staged',
commit_docs: commitDocs,
staged_files: stagedFiles,
},
};
};
// ─── commitToSubrepo ─────────────────────────────────────────────────────
export const commitToSubrepo: QueryHandler = async (args, projectDir) => {
const message = args[0];
const filesIdx = args.indexOf('--files');
const files = filesIdx >= 0 ? args.slice(filesIdx + 1) : [];
if (!message) {
return { data: { committed: false, reason: 'commit message required' } };
}
try {
for (const file of files) {
const resolved = join(projectDir, file);
if (!resolved.startsWith(projectDir)) {
return { data: { committed: false, reason: `file path escapes project: ${file}` } };
}
}
const fileArgs = files.length > 0 ? files : ['.'];
spawnSync('git', ['-C', projectDir, 'add', ...fileArgs], { stdio: 'pipe' });
const commitResult = spawnSync(
'git', ['-C', projectDir, 'commit', '-m', message],
{ stdio: 'pipe', encoding: 'utf-8' },
);
if (commitResult.status !== 0) {
return { data: { committed: false, reason: commitResult.stderr || 'commit failed' } };
}
const hashResult = spawnSync(
'git', ['-C', projectDir, 'rev-parse', '--short', 'HEAD'],
{ encoding: 'utf-8' },
);
const hash = hashResult.stdout.trim();
return { data: { committed: true, hash, message } };
} catch (err) {
return { data: { committed: false, reason: String(err) } };
}
};