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:
258
sdk/src/query/commit.ts
Normal file
258
sdk/src/query/commit.ts
Normal 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) } };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user