Files
get-shit-done/tests/bug-1906-hook-relative-paths.test.cjs
Tom Boucher 98094210cc fix(#2557): Gemini/Antigravity local hook commands use relative paths, not \$CLAUDE_PROJECT_DIR
\$CLAUDE_PROJECT_DIR is Claude Code-specific. Gemini CLI doesn't set it, and on
Windows its path-join logic doubled the value producing unresolvable paths like
D:\Projects\GSD\'D:\Projects\GSD'. Gemini runs project hooks with project root
as cwd, so bare relative paths (e.g. node .gemini/hooks/gsd-check-update.js)
are cross-platform and correct. Claude Code and others still use the env var.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:36:10 -04:00

85 lines
3.1 KiB
JavaScript

/**
* Regression tests for bug #1906
*
* Local installs must anchor hook command paths to $CLAUDE_PROJECT_DIR so
* hooks resolve correctly regardless of the shell's current working directory.
*
* The original bug: local install hook commands used bare relative paths like
* `node .claude/hooks/gsd-context-monitor.js`. Claude Code persists the bash
* tool's cwd between calls, so a single `cd subdir && …` early in a session
* permanently broke every hook for the rest of that session.
*
* The fix prefixes all local hook commands with "$CLAUDE_PROJECT_DIR"/ so
* path resolution is always anchored to the project root.
*/
'use strict';
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
// All hooks that the installer registers for local installs
const HOOKS = [
'gsd-statusline.js',
'gsd-check-update.js',
'gsd-context-monitor.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-workflow-guard.js',
'gsd-validate-commit.sh',
'gsd-session-state.sh',
'gsd-phase-boundary.sh',
];
describe('bug #1906: local hook commands use $CLAUDE_PROJECT_DIR', () => {
let src;
before(() => {
src = fs.readFileSync(INSTALL_SRC, 'utf-8');
});
test('localPrefix definition includes $CLAUDE_PROJECT_DIR for non-Gemini runtimes', () => {
// localPrefix is now a ternary — Gemini/Antigravity use bare dirName (#2557),
// all other runtimes use "$CLAUDE_PROJECT_DIR"/ to anchor hook paths.
assert.match(src, /const localPrefix\s*=[\s\S]*?"\$CLAUDE_PROJECT_DIR"/,
'localPrefix definition must include "$CLAUDE_PROJECT_DIR" branch for non-Gemini runtimes');
});
for (const hook of HOOKS) {
test(`${hook} local command uses localPrefix (not bare dirName)`, () => {
// Find all local command strings for this hook
// The pattern is: `<runner> ' + localPrefix + '/hooks/<hook>'`
// or the old broken pattern: `<runner> ' + dirName + '/hooks/<hook>'`
const hookEscaped = hook.replace(/\./g, '\\.');
const brokenPattern = new RegExp(
`['"](?:node|bash)\\s['"]\\s*\\+\\s*dirName\\s*\\+\\s*['"]/hooks/${hookEscaped}['"]`
);
assert.ok(
!brokenPattern.test(src),
`${hook} must not use bare dirName — should use localPrefix for cwd-independent resolution`
);
});
}
test('no local hook command uses bare dirName + /hooks/', () => {
// Broader check: no local (non-global) hook path should use dirName directly
// The pattern `': '<runner> ' + dirName + '/hooks/'` is the broken form
const lines = src.split('\n');
const offenders = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Match lines that build local hook commands with bare dirName
if (/['"](?:node|bash)\s['"][^;]*\+\s*dirName\s*\+\s*['"]\/hooks\//.test(line)) {
offenders.push(`line ${i + 1}: ${line.trim()}`);
}
}
assert.equal(offenders.length, 0,
'Found local hook commands using bare dirName instead of localPrefix:\n' +
offenders.join('\n'));
});
});