From 5f1e896f908f02068793a21fc88008b78cf353f4 Mon Sep 17 00:00:00 2001 From: neallee Date: Fri, 10 Jul 2026 20:59:35 +0800 Subject: [PATCH] fix: remove legacy team hook on migration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bin/gstack-team-init | 7 +++++++ implementation-notes.html | 2 +- test/team-mode.test.ts | 30 ++++++++++++++++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 2687f7d73..a416d40fa 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -215,6 +215,13 @@ HOOK_EOF " 2>/dev/null GENERATED+=(".claude/settings.json") echo " + .claude/settings.json — PreToolUse hook registered" + + # Remove the obsolete Bash hook only after its replacement is generated and + # registered successfully. Other project hooks are left untouched. + if [ -e "$HOOKS_DIR/check-gstack.sh" ] || [ -L "$HOOKS_DIR/check-gstack.sh" ]; then + rm "$HOOKS_DIR/check-gstack.sh" + echo " - .claude/hooks/check-gstack.sh — removed legacy enforcement hook" + fi else echo " ! bun not found — manually add the PreToolUse hook to .claude/settings.json" fi diff --git a/implementation-notes.html b/implementation-notes.html index c2d307d35..d4e2fad0f 100644 --- a/implementation-notes.html +++ b/implementation-notes.html @@ -18,7 +18,7 @@
  • The generated hook resolves the user home in HOME, USERPROFILE, then os.homedir() order so POSIX, Git Bash, PowerShell, and cmd runners use the expected global install root.
  • The hook contract follows docs/spikes/claude-code-hook-mutation.md: pass-through writes {}; denial writes a hookSpecificOutput object for PreToolUse with permissionDecision: "deny".
  • Missing or unreadable gstack is an intentional denial with exit code zero. Install instructions are present in stderr and permissionDecisionReason, so the runner does not report a hook execution error.
  • -
  • Rerunning required mode replaces a matching legacy check-gstack.sh registration and removes duplicate matching registrations.
  • +
  • Rerunning required mode replaces a matching legacy check-gstack.sh registration, removes duplicate matching registrations, and deletes the obsolete Bash hook only after the CommonJS hook is generated and registered successfully. Unrelated project hooks remain unchanged.
  • Deviations

    diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index e3101a3ec..b555f471c 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -397,7 +397,7 @@ describe('gstack-team-init', () => { }); expectStructuredDeny(result, MISSING_PROJECT_REASON); - }); + }, 30_000); test('required: stale project paths deny through the platform default shell', () => { run(`${TEAM_INIT} required`, { cwd: tmpDir }); @@ -445,7 +445,7 @@ describe('gstack-team-init', () => { expectStructuredDeny(result, HOOK_LOAD_REASON); expect(result.stderr).not.toContain(projectDir); } - }); + }, 30_000); test('required: hook runs in PowerShell with USERPROFILE home fallback', () => { if (process.platform !== 'win32') return; @@ -472,7 +472,7 @@ describe('gstack-team-init', () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe(''); expect(JSON.parse(result.stdout)).toEqual({}); - }); + }, 30_000); test('required: missing gstack returns an intentional deny, not a hook error', () => { run(`${TEAM_INIT} required`, { cwd: tmpDir }); @@ -507,10 +507,14 @@ describe('gstack-team-init', () => { }); }); - test('required: rerun upgrades a legacy Bash hook without duplicates', () => { + test('required: rerun removes a legacy Bash hook without touching other hooks', () => { const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + const legacyHook = path.join(hooksDir, 'check-gstack.sh'); + const unrelatedHook = path.join(hooksDir, 'check-project.cjs'); + const unrelatedHookContents = "console.log('project hook');\n"; fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync(path.join(hooksDir, 'check-gstack.sh'), '#!/bin/bash\n'); + fs.writeFileSync(legacyHook, '#!/bin/bash\n'); + fs.writeFileSync(unrelatedHook, unrelatedHookContents); fs.writeFileSync( path.join(tmpDir, '.claude', 'settings.json'), JSON.stringify({ @@ -527,17 +531,31 @@ describe('gstack-team-init', () => { }, }), ); + execSync('git add .claude', { cwd: tmpDir }); + execSync('git commit -m "add legacy hook"', { cwd: tmpDir }); + + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + expect(fs.existsSync(legacyHook)).toBe(false); + expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents); run(`${TEAM_INIT} required`, { cwd: tmpDir }); const settings = JSON.parse( fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), ); + expect(fs.existsSync(legacyHook)).toBe(false); + expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents); + expect( + execSync('git status --short -- .claude/hooks/check-gstack.sh', { + cwd: tmpDir, + encoding: 'utf-8', + }), + ).toBe(' D .claude/hooks/check-gstack.sh\n'); expect(settings.hooks.PreToolUse).toHaveLength(1); expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1); expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain( 'check-gstack.cjs', ); - }); + }, 30_000); test('idempotent: running twice does not duplicate CLAUDE.md section', () => { run(`${TEAM_INIT} optional`, { cwd: tmpDir });