Skip to content

Deduplicate hooks from multiple discovery paths#303928

Open
lewing wants to merge 3 commits intomicrosoft:mainfrom
lewing:fix/hook-dedup-cross-path
Open

Deduplicate hooks from multiple discovery paths#303928
lewing wants to merge 3 commits intomicrosoft:mainfrom
lewing:fix/hook-dedup-cross-path

Conversation

@lewing
Copy link
Copy Markdown
Member

@lewing lewing commented Mar 22, 2026

When the same hook is defined in both .github/hooks/ and .claude/settings.json, computeHooks() fires it twice because it collects from all DEFAULT_HOOK_FILE_PATHS without deduplication.

This is a real problem for cross-tool projects that need hooks in both paths — .github/hooks/ for Copilot CLI/Gemini CLI and .claude/settings.json for Claude Code. Since VS Code reads both, users get double-fired hooks.

Changes

hookSchema.ts: Added deduplicateHooks() function that compares hooks by all their command fields (command, windows, linux, osx), cwd, env, and timeout. Uses a signature-based Set for O(n) dedup. First occurrence is kept, preserving path priority order from DEFAULT_HOOK_FILE_PATHS. Environment variables are sorted before comparison so {A:1, B:2} equals {B:2, A:1}.

promptsServiceImpl.ts: Calls deduplicateHooks() after all hooks are collected (from files + plugins) but before converting to immutable ChatRequestHooks. Adds trace logging when duplicates are removed.

hookSchema.test.ts: 11 test cases covering:

  • Empty/single hook (no-op fast path)
  • Identical commands dedup
  • Order preservation (first occurrence wins)
  • Different commands/cwd/timeout/env kept as distinct
  • Env comparison is key-order independent
  • Different platform overrides kept as distinct
  • Cross-path scenario (main use case)
  • Multiple duplicates across many entries

Fixes #303926

When the same hook is defined in both .github/hooks/ and
.claude/settings.json, computeHooks() collects both and fires them
twice per tool call. This adds a deduplication step after all hooks
are collected but before converting to immutable ChatRequestHooks.

Two hooks are considered identical if all their command fields
(command, windows, linux, osx), cwd, env, and timeout match.
First occurrence is kept, preserving the path priority order
from DEFAULT_HOOK_FILE_PATHS.

Fixes microsoft#303926

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 22, 2026 23:16
@vs-code-engineering vs-code-engineering bot added this to the 1.113.0 milestone Mar 22, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR prevents chat hooks from firing twice when the same hook is discovered via multiple configuration paths (notably .github/hooks/ and .claude/settings.json) by deduplicating equivalent hook commands after collection in computeHooks().

Changes:

  • Added deduplicateHooks() (signature-based) to remove duplicate IHookCommands while preserving first-seen order.
  • Applied hook deduplication in PromptsService.computeHooks() and added trace logging when duplicates are removed.
  • Added a dedicated deduplicateHooks test suite with coverage for ordering and field-difference cases.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts Adds hook signature computation + deduplicateHooks() utility.
src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts Runs deduplication on collected hooks before building ChatRequestHooks, with trace logging.
src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts Adds unit tests validating dedup behavior across relevant fields and ordering.

- Use JSON.stringify instead of NUL-joined parts for hook signature
  encoding to avoid ambiguity from separator chars in env keys/values
- Accept readonly IHookCommand[] in deduplicateHooks() to match
  ChatRequestHooks readonly arrays

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Use [...deduped] instead of 'as IHookCommand[]' cast to maintain
mutable array for the collectedHooks Map without weakening the
readonly return type of deduplicateHooks().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

for (const [hookType, commands] of collectedHooks) {
const deduped = deduplicateHooks(commands);
if (deduped.length < commands.length) {
this.logger.trace(`[PromptsService] Deduplicated ${hookType} hooks: ${commands.length} → ${deduped.length}`);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to also actually get the list of files that were deduped for debug events. I can take a look at that though because that flow needs some caching changes. Thanks!

@jrieken jrieken modified the milestones: 1.113.0, On Deck Mar 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hooks from multiple discovery paths fire duplicate when same hook defined in .github/hooks/ and .claude/settings.json

5 participants