Files
get-shit-done/tests/bug-3129-validate-commit-git-bypass.test.cjs
Tom Boucher 7827e1ddee fix(#3129): replace bypassed bash regex with token-walk git-cmd.js classifier (#3141)
* fix(#3129): replace bypassed bash regex with token-walk git-cmd.js classifier

Root cause: gsd-validate-commit.sh used:
  if [[ "$CMD" =~ ^git[[:space:]]+commit ]]
This regex silently bypasses Conventional Commits enforcement for:
  git -C /path commit -m ...     (working-directory prefix)
  GIT_AUTHOR_NAME=x git commit   (env-var prefix)
  /usr/bin/git commit -m ...     (full-path executable)

Fix: introduces hooks/lib/git-cmd.js with isGitSubcommand(cmd, sub) —
a token-walk classifier that handles all four forms by:
  1. Skipping leading VAR=VALUE env assignments
  2. Validating the git executable (basename check for full-path support)
  3. Consuming git global options (-C <path>, --git-dir=, -p, etc.)
  4. Checking the subcommand token

The hook delegates to this classifier via node shell-out. node is
already called twice in this hook (config check + JSON parse), so no
new runtime dependency.

This becomes the single source of truth for all hooks that gate on
git subcommands (pre-commit-review-gate, post-push-verify, etc.).

Regression test: 27 assertions — tokenize correctness, 12 must-match
cases (including all 3 bypass forms), 8 must-not-match cases, 3 source
checks. All are real behavioral tests, not string comparisons.
Suite: 7035/7035. Closes #3129.

* fix(lint+hook+changeset): allow-test-rule, fix HOOK_DIR quote injection, fix changeset pr+typo
2026-05-05 15:02:15 -04:00

120 lines
5.1 KiB
JavaScript

'use strict';
// allow-test-rule: reads hook shell script to verify delegation pattern — structural contract test, not source-grep
// Regression tests for bug #3129.
//
// gsd-validate-commit.sh used `[[ "$CMD" =~ ^git[[:space:]]+commit ]]` to
// detect git commit invocations. This regex silently bypasses Conventional
// Commits enforcement for three real git commit forms:
// 1. git -C /some/path commit -m "..." (working-directory prefix)
// 2. GIT_AUTHOR_NAME=x git commit "..." (env-var prefix)
// 3. /usr/bin/git commit -m "..." (full path)
//
// Fix: the hook delegates detection to hooks/lib/git-cmd.js isGitSubcommand(),
// a token-walk classifier that correctly handles all four forms. The module
// is the canonical single source of truth for all hooks that gate on git commits.
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const ROOT = path.join(__dirname, '..');
const { isGitSubcommand, tokenize } = require(path.join(ROOT, 'hooks', 'lib', 'git-cmd.js'));
// ── tokenize ─────────────────────────────────────────────────────────────────
describe('git-cmd.js tokenize', () => {
test('splits bare command', () => {
assert.deepEqual(tokenize('git commit -m "msg"'), ['git', 'commit', '-m', 'msg']);
});
test('handles single-quoted args', () => {
assert.deepEqual(tokenize("git commit -m 'my message'"), ['git', 'commit', '-m', 'my message']);
});
test('handles env-prefix assignment', () => {
assert.deepEqual(
tokenize('GIT_AUTHOR_NAME=Alice git commit -m "fix"'),
['GIT_AUTHOR_NAME=Alice', 'git', 'commit', '-m', 'fix'],
);
});
test('handles -C path', () => {
assert.deepEqual(
tokenize('git -C /some/path commit -m "x"'),
['git', '-C', '/some/path', 'commit', '-m', 'x'],
);
});
});
// ── isGitSubcommand: must-match cases ────────────────────────────────────────
describe('git-cmd.js isGitSubcommand: should match commit', () => {
const cases = [
['bare form', 'git commit -m "feat: add thing"'],
['single-quoted message', "git commit -m 'fix: typo'"],
['with --no-verify', 'git commit --no-verify -m "wip"'],
['-C path form (bug #3129)', 'git -C /some/path commit -m "fix: x"'],
['env-prefix form (bug #3129)', 'GIT_AUTHOR_NAME=Alice git commit -m "fix"'],
['full-path form (bug #3129)', '/usr/bin/git commit -m "feat: y"'],
['multiple env vars', 'GIT_AUTHOR_NAME=A GIT_AUTHOR_EMAIL=b@c git commit -m "x"'],
['--git-dir= flag', 'git --git-dir=.git commit -m "x"'],
['--git-dir two-token', 'git --git-dir .git commit -m "x"'],
['--no-pager before subcommand', 'git --no-pager commit -m "x"'],
['-C + full path', '/usr/bin/git -C /proj commit -m "x"'],
['-p paginate flag', 'git -p commit -m "x"'],
];
for (const [desc, cmd] of cases) {
test(desc, () => {
assert.ok(isGitSubcommand(cmd, 'commit'), `Expected match for: ${cmd}`);
});
}
});
// ── isGitSubcommand: must-not-match cases ────────────────────────────────────
describe('git-cmd.js isGitSubcommand: should NOT match commit', () => {
const cases = [
['git push', 'git push origin main'],
['git status', 'git status'],
['git add', 'git add .'],
['git log', 'git log --oneline'],
['not git at all', 'npm install'],
['empty string', ''],
['git checkout (not commit)', 'git checkout main'],
['git -C path push', 'git -C /path push'],
];
for (const [desc, cmd] of cases) {
test(desc, () => {
assert.ok(!isGitSubcommand(cmd, 'commit'), `Expected NO match for: ${cmd}`);
});
}
});
// ── gsd-validate-commit.sh source check ──────────────────────────────────────
describe('gsd-validate-commit.sh delegates to git-cmd.js', () => {
const hookSrc = fs.readFileSync(
path.join(ROOT, 'hooks', 'gsd-validate-commit.sh'), 'utf8',
);
test('hook no longer uses the stale ^git\\s+commit bash regex', () => {
assert.ok(
!hookSrc.includes('^git[[:space:]]+commit'),
'gsd-validate-commit.sh still uses the bypassed regex — fix not applied',
);
});
test('hook delegates to git-cmd.js isGitSubcommand', () => {
assert.ok(
hookSrc.includes('git-cmd.js') && hookSrc.includes('isGitSubcommand'),
'gsd-validate-commit.sh does not reference git-cmd.js or isGitSubcommand',
);
});
test('hooks/lib/git-cmd.js exists at the expected install path', () => {
assert.ok(
fs.existsSync(path.join(ROOT, 'hooks', 'lib', 'git-cmd.js')),
'hooks/lib/git-cmd.js does not exist — library file missing',
);
});
});