Merge pull request #1550 from Tibsfox/fix/commit-docs-hook-1395-v2
feat(hooks): add check-commit guard for commit_docs enforcement
This commit is contained in:
@@ -430,6 +430,11 @@ async function runCommand(command, args, cwd, raw) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'check-commit': {
|
||||||
|
commands.cmdCheckCommit(cwd, raw);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'commit-to-subrepo': {
|
case 'commit-to-subrepo': {
|
||||||
const message = args[1];
|
const message = args[1];
|
||||||
const filesIndex = args.indexOf('--files');
|
const filesIndex = args.indexOf('--files');
|
||||||
|
|||||||
@@ -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 = {
|
module.exports = {
|
||||||
cmdGenerateSlug,
|
cmdGenerateSlug,
|
||||||
cmdCurrentTimestamp,
|
cmdCurrentTimestamp,
|
||||||
@@ -976,4 +1009,5 @@ module.exports = {
|
|||||||
cmdTodoMatchPhase,
|
cmdTodoMatchPhase,
|
||||||
cmdScaffold,
|
cmdScaffold,
|
||||||
cmdStats,
|
cmdStats,
|
||||||
|
cmdCheckCommit,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1694,3 +1694,60 @@ describe('stats command', () => {
|
|||||||
assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user