From 9d90c7b420349dbc7fd7b2d6fe98ad86700b18bb Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Wed, 1 Apr 2026 16:11:00 -0700 Subject: [PATCH] feat(hooks): add check-commit guard for commit_docs enforcement (#1395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- get-shit-done/bin/gsd-tools.cjs | 5 +++ get-shit-done/bin/lib/commands.cjs | 34 ++++++++++++++++++ tests/commands.test.cjs | 57 ++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 8cdf43617..4fb0a9183 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -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'); diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index bbacb3542..f2f825900 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -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, }; diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index 53633a7b7..078fd30bd 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -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'); + }); +});