feat(hooks): add check-commit guard for commit_docs enforcement (#1395)

Add check-commit command to gsd-tools that acts as a pre-commit guard.
When commit_docs is false, rejects commits that stage .planning/ files
with an actionable error message including the unstage command.

Recreated cleanly on current main — previous version carried stale
shared fixes that are now upstream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tibsfox
2026-04-01 16:11:00 -07:00
parent 94a8005f97
commit 9d90c7b420
3 changed files with 96 additions and 0 deletions

View File

@@ -430,6 +430,11 @@ async function runCommand(command, args, cwd, raw) {
break;
}
case 'check-commit': {
commands.cmdCheckCommit(cwd, raw);
break;
}
case 'commit-to-subrepo': {
const message = args[1];
const filesIndex = args.indexOf('--files');

View File

@@ -960,6 +960,39 @@ function cmdStats(cwd, format, raw) {
}
}
/**
* Check whether a commit should be allowed based on commit_docs config.
* When commit_docs is false, rejects commits that stage .planning/ files.
* Intended for use as a pre-commit hook guard.
*/
function cmdCheckCommit(cwd, raw) {
const config = loadConfig(cwd);
// If commit_docs is true (or not set), allow all commits
if (config.commit_docs !== false) {
output({ allowed: true, reason: 'commit_docs_enabled' }, raw, 'allowed');
return;
}
// commit_docs is false — check if any .planning/ files are staged
try {
const staged = execSync('git diff --cached --name-only', { cwd, encoding: 'utf-8' }).trim();
const planningFiles = staged.split('\n').filter(f => f.startsWith('.planning/') || f.startsWith('.planning\\'));
if (planningFiles.length > 0) {
error(
`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
planningFiles.map(f => ` ${f}`).join('\n') +
`\n\nTo unstage: git reset HEAD ${planningFiles.join(' ')}`
);
}
} catch {
// git diff --cached failed (no staged files or not a git repo) — allow
}
output({ allowed: true, reason: 'no_planning_files_staged' }, raw, 'allowed');
}
module.exports = {
cmdGenerateSlug,
cmdCurrentTimestamp,
@@ -976,4 +1009,5 @@ module.exports = {
cmdTodoMatchPhase,
cmdScaffold,
cmdStats,
cmdCheckCommit,
};

View File

@@ -1694,3 +1694,60 @@ describe('stats command', () => {
assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// check-commit command (#1395)
// ─────────────────────────────────────────────────────────────────────────────
describe('check-commit command', () => {
const { createTempGitProject } = require('./helpers.cjs');
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('allows commit when commit_docs is true', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: true })
);
const result = runGsdTools('check-commit', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.allowed, true);
});
test('allows commit when no .planning/ files staged and commit_docs is false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
// Stage a non-planning file
fs.writeFileSync(path.join(tmpDir, 'src.js'), 'console.log("hi")');
execSync('git add src.js', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.allowed, true);
});
test('blocks commit when .planning/ files staged and commit_docs is false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(!result.success, 'should block commit');
assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files');
assert.ok(result.error.includes('unstage'), 'error should suggest unstage command');
});
});