* fix(sdk): forward --ws workstream flag through query dispatch (closes #2524) - cli.ts: pass args.ws as workstream to registry.dispatch() - registry.ts: add workstream? param to dispatch(), thread to handler - utils.ts: add optional workstream? to QueryHandler type signature - helpers.ts: planningPaths() accepts workstream? and uses relPlanningPath() - All ~26 query handlers updated to receive and pass workstream to planningPaths() - Config/commit/intel handlers use _workstream (project-global, not scoped) - Add failing-then-passing test: tests/bug-2524-sdk-query-ws-flag.test.cjs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sdk): forward workstream to all downstream query helpers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(test): rewrite #2524 test as static source assertions — no sdk/dist build in CI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
/**
|
|
* 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 { spawnSync } from 'node:child_process';
|
|
import { GSDError } from '../errors.js';
|
|
import { planningPaths, resolvePathUnderProject } 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, _workstream) => {
|
|
const allArgs = [...args];
|
|
|
|
// Extract flags
|
|
const hasForce = allArgs.includes('--force');
|
|
const hasAmend = allArgs.includes('--amend');
|
|
const hasNoVerify = allArgs.includes('--no-verify');
|
|
const filesIndex = allArgs.indexOf('--files');
|
|
const endIndex = filesIndex !== -1 ? filesIndex : allArgs.length;
|
|
// CodeRabbit #6: don't strip arbitrary `--foo` tokens from commit messages
|
|
const knownFlags = new Set(['--force', '--amend', '--no-verify']);
|
|
const messageArgs = allArgs.slice(0, endIndex).filter(a => !knownFlags.has(a));
|
|
const message = messageArgs.join(' ') || undefined;
|
|
const filePaths =
|
|
filesIndex !== -1 ? allArgs.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
|
|
|
|
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) {
|
|
const addResult = execGit(projectDir, ['add', file]);
|
|
if (addResult.exitCode !== 0) {
|
|
return { data: { committed: false, reason: addResult.stderr || `failed to stage ${file}`, exitCode: addResult.exitCode } };
|
|
}
|
|
}
|
|
|
|
// 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: string[] = 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, _workstream) => {
|
|
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: {
|
|
allowed: false,
|
|
can_commit: false,
|
|
reason: `commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged`,
|
|
commit_docs: false,
|
|
staged_files: planningFiles,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
data: {
|
|
allowed: true,
|
|
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, _workstream) => {
|
|
const filesIdx = args.indexOf('--files');
|
|
const endIdx = filesIdx >= 0 ? filesIdx : args.length;
|
|
const knownFlags = new Set(['--force', '--amend', '--no-verify']);
|
|
const messageArgs = args.slice(0, endIdx).filter(a => !knownFlags.has(a));
|
|
const message = messageArgs.join(' ') || undefined;
|
|
const files = filesIdx >= 0 ? args.slice(filesIdx + 1).filter(a => !a.startsWith('--')) : [];
|
|
|
|
if (!message) {
|
|
return { data: { committed: false, reason: 'commit message required' } };
|
|
}
|
|
|
|
const paths = planningPaths(projectDir);
|
|
let config: Record<string, unknown> = {};
|
|
try {
|
|
const raw = await readFile(paths.config, 'utf-8');
|
|
config = JSON.parse(raw) as Record<string, unknown>;
|
|
} catch {
|
|
/* no config */
|
|
}
|
|
const subRepos = config.sub_repos as string[] | undefined;
|
|
if (!subRepos || subRepos.length === 0) {
|
|
return {
|
|
data: { committed: false, reason: 'no sub_repos configured in .planning/config.json' },
|
|
};
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
return { data: { committed: false, reason: '--files required for commit-to-subrepo' } };
|
|
}
|
|
|
|
const sanitized = sanitizeCommitMessage(message);
|
|
if (!sanitized && message) {
|
|
return { data: { committed: false, reason: 'commit message empty after sanitization' } };
|
|
}
|
|
|
|
try {
|
|
for (const file of files) {
|
|
try {
|
|
await resolvePathUnderProject(projectDir, file);
|
|
} catch (err) {
|
|
if (err instanceof GSDError) {
|
|
return { data: { committed: false, reason: `${err.message}: ${file}` } };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const fileArgs = files.length > 0 ? files : ['.'];
|
|
const addResult = spawnSync('git', ['-C', projectDir, 'add', ...fileArgs], { stdio: 'pipe', encoding: 'utf-8' });
|
|
if (addResult.status !== 0) {
|
|
return { data: { committed: false, reason: addResult.stderr || 'git add failed' } };
|
|
}
|
|
|
|
const commitResult = spawnSync(
|
|
'git', ['-C', projectDir, 'commit', '-m', sanitized],
|
|
{ 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: sanitized } };
|
|
} catch (err) {
|
|
return { data: { committed: false, reason: String(err) } };
|
|
}
|
|
};
|