Merge pull request #1266 from gsd-build/fix/stale-hook-check-path-1249

fix: stale hook detection checks wrong directory path
This commit is contained in:
Tom Boucher
2026-03-20 16:32:08 -04:00
committed by GitHub
4 changed files with 61 additions and 1 deletions

View File

@@ -409,6 +409,39 @@ git bisect bad # or good, based on testing
100 commits between working and broken: ~7 tests to find exact breaking commit.
## Follow the Indirection
**When:** Code constructs paths, URLs, keys, or references from variables — and the constructed value might not point where you expect.
**The trap:** You read code that builds a path like `path.join(configDir, 'hooks')` and assume it's correct because it looks reasonable. But you never verified that the constructed path matches where another part of the system actually writes/reads.
**How:**
1. Find the code that **produces** the value (writer/installer/creator)
2. Find the code that **consumes** the value (reader/checker/validator)
3. Trace the actual resolved value in both — do they agree?
4. Check every variable in the path construction — where does each come from? What's its actual value at runtime?
**Common indirection bugs:**
- Path A writes to `dir/sub/hooks/` but Path B checks `dir/hooks/` (directory mismatch)
- Config value comes from cache/template that wasn't updated
- Variable is derived differently in two places (e.g., one adds a subdirectory, the other doesn't)
- Template placeholder (`{{VERSION}}`) not substituted in all code paths
**Example:** Stale hook warning persists after update
```
Check code says: hooksDir = path.join(configDir, 'hooks')
configDir = ~/.claude
→ checks ~/.claude/hooks/
Installer says: hooksDest = path.join(targetDir, 'hooks')
targetDir = ~/.claude/get-shit-done
→ writes to ~/.claude/get-shit-done/hooks/
MISMATCH: Checker looks in wrong directory → hooks "not found" → reported as stale
```
**The discipline:** Never assume a constructed path is correct. Resolve it to its actual value and verify the other side agrees. When two systems share a resource (file, directory, key), trace the full path in both.
## Technique Selection
| Situation | Technique |
@@ -419,6 +452,7 @@ git bisect bad # or good, based on testing
| Know the desired output | Working backwards |
| Used to work, now doesn't | Differential debugging, Git bisect |
| Many possible causes | Comment out everything, Binary search |
| Paths, URLs, keys constructed from variables | Follow the indirection |
| Always | Observability first (before making changes) |
## Combining Techniques

View File

@@ -3900,6 +3900,11 @@ function install(isGlobal, runtime = 'claude') {
}
}
// Clear stale update cache so next session re-evaluates hook versions
// targetDir is e.g. ~/.claude/get-shit-done/, parent is the config dir
const updateCacheFile = path.join(path.dirname(targetDir), 'cache', 'gsd-update-check.json');
try { fs.unlinkSync(updateCacheFile); } catch (e) { /* cache may not exist yet */ }
if (failures.length > 0) {
console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
process.exit(1);

View File

@@ -65,9 +65,10 @@ const child = spawn(process.execPath, ['-e', `
} catch (e) {}
// Check for stale hooks — compare hook version headers against installed VERSION
// Hooks live inside get-shit-done/hooks/, not configDir/hooks/
let staleHooks = [];
if (configDir) {
const hooksDir = path.join(configDir, 'hooks');
const hooksDir = path.join(configDir, 'get-shit-done', 'hooks');
try {
if (fs.existsSync(hooksDir)) {
const hookFiles = fs.readdirSync(hooksDir).filter(f => f.startsWith('gsd-') && f.endsWith('.js'));

View File

@@ -968,6 +968,26 @@ describe('stale hook filter', () => {
});
});
// ─── stale hook path regression (#1249) ──────────────────────────────────────
describe('stale hook path', () => {
test('gsd-check-update.js checks get-shit-done/hooks/ not configDir/hooks/', () => {
const content = fs.readFileSync(
path.join(__dirname, '..', 'hooks', 'gsd-check-update.js'), 'utf-8'
);
assert.ok(
content.includes("path.join(configDir, 'get-shit-done', 'hooks')"),
'stale hook check must look in configDir/get-shit-done/hooks/, not configDir/hooks/'
);
assert.ok(
!content.includes("path.join(configDir, 'hooks')") ||
content.indexOf("path.join(configDir, 'get-shit-done', 'hooks')") <
content.indexOf("path.join(configDir, 'hooks')") + 100, // allow the old pattern only if corrected version exists first
'should not use the wrong hooks path'
);
});
});
// ─── resolveWorktreeRoot ─────────────────────────────────────────────────────
describe('resolveWorktreeRoot', () => {