diff --git a/bin/gstack-team-init b/bin/gstack-team-init index a416d40fa..bf5a07428 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -220,6 +220,9 @@ HOOK_EOF # 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" + if (cd "$REPO_ROOT" && git ls-files --error-unmatch -- .claude/hooks/check-gstack.sh >/dev/null 2>&1); then + GENERATED+=(".claude/hooks/check-gstack.sh") + fi echo " - .claude/hooks/check-gstack.sh — removed legacy enforcement hook" fi else diff --git a/implementation-notes.html b/implementation-notes.html index d4e2fad0f..c740af189 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, 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.
  • +
  • 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. If Git tracks the deleted hook, the printed git add command includes its path so the suggested migration commit stages the deletion; untracked removed hooks are omitted because they have no index change. Unrelated project hooks remain unchanged.
  • Deviations

    diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index b555f471c..17c815160 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -302,7 +302,7 @@ describe('gstack-team-init', () => { }); test('required: creates enforcement hook', () => { - run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const result = run(`${TEAM_INIT} required`, { cwd: tmpDir }); const hookPath = path.join(tmpDir, '.claude', 'hooks', 'check-gstack.cjs'); expect(fs.existsSync(hookPath)).toBe(true); expect( @@ -319,6 +319,7 @@ describe('gstack-team-init', () => { expect(hook).toContain("permissionDecision: 'deny'"); expect(hook).toContain('BLOCKED: gstack is not installed'); expect(hook).not.toContain('#!/bin/bash'); + expect(result.stdout).not.toMatch(/git add .*check-gstack\.sh/); }); test('required: registers one shell-neutral project hook', () => { @@ -534,22 +535,45 @@ 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 }); + const migration = run(`${TEAM_INIT} required`, { cwd: tmpDir }); expect(fs.existsSync(legacyHook)).toBe(false); expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents); + const suggestedGitAdd = migration.stdout + .split('\n') + .find(line => line.startsWith(' git add ')) + ?.trim(); + if (!suggestedGitAdd) { + throw new Error('gstack-team-init did not print a git add command'); + } + expect(suggestedGitAdd.split(/\s+/)).toContain( + '.claude/hooks/check-gstack.sh', + ); + execSync(suggestedGitAdd, { cwd: tmpDir }); + execSync('git commit -m "migrate required hook"', { cwd: tmpDir }); + expect( + execSync('git status --short', { cwd: tmpDir, encoding: 'utf-8' }), + ).toBe(''); - run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const rerun = 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); + const rerunGitAdd = rerun.stdout + .split('\n') + .find(line => line.startsWith(' git add ')) + ?.trim(); + if (!rerunGitAdd) { + throw new Error('gstack-team-init rerun did not print a git add command'); + } + expect(rerunGitAdd.split(/\s+/)).not.toContain( + '.claude/hooks/check-gstack.sh', + ); + execSync(rerunGitAdd, { cwd: tmpDir }); expect( - execSync('git status --short -- .claude/hooks/check-gstack.sh', { - cwd: tmpDir, - encoding: 'utf-8', - }), - ).toBe(' D .claude/hooks/check-gstack.sh\n'); + execSync('git status --short', { cwd: tmpDir, encoding: 'utf-8' }), + ).toBe(''); expect(settings.hooks.PreToolUse).toHaveLength(1); expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1); expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain(