Skip to content

[BUG] Root .apm hooks can duplicate when _apm_source changes with checkout directory #1329

@imk1t

Description

@imk1t

Describe the bug
Root .apm/hooks/*.json hooks can be duplicated in generated merged hook configs when the _apm_source value changes between installs.

The source of truth has exactly one hook entry, but generated files can contain two semantically identical hook entries. The only difference is _apm_source, for example:

  • <current-checkout-dir>
  • <old-checkout-dir-or-generated-source-id>

The stale source id does not exist in the source of truth and appears only in previously generated hook config files.

This looks like a stale source-id healing/idempotency bug. The hook integrator removes prior entries only when _apm_source matches the current package/source name. If the same root .apm content is later installed from a different checkout directory name, stale hook entries from the old _apm_source survive and the fresh hook is appended.

To Reproduce

  1. In a repository with root .apm/hooks/pre-push-review.json, ensure the hook source of truth contains one PreToolUse entry.
  2. Seed generated config with the same hook tagged by an old source id, for example:
    • .codex/hooks.json entry with _apm_source: "<old-checkout-dir-or-generated-source-id>"
    • .claude/settings.json entry with _apm_source: "<old-checkout-dir-or-generated-source-id>"
  3. Run apm install / the repository sync flow from a checkout whose directory name is different.
  4. Inspect the generated merged hook config.

Observed result:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .codex/hooks/pre-push-review.sh"
          }
        ],
        "_apm_source": "<current-checkout-dir>"
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .codex/hooks/pre-push-review.sh"
          }
        ],
        "_apm_source": "<old-checkout-dir-or-generated-source-id>"
      }
    ]
  }
}

Expected behavior
Re-running install/sync for the same root .apm content should be idempotent.

The generated merged hook configs should contain exactly one semantic copy of the root hook. Stale entries created by an old root-content _apm_source should either be removed or migrated.

For root .apm content, _apm_source should preferably be stable across checkout directories, for example derived from apm.yml metadata rather than the current filesystem directory name.

Environment (please complete the following information):

  • OS: macOS
  • Python Version: 3.12.12
  • APM Version: 0.13.0
  • VSCode Version (if relevant): N/A

Logs
No command failure is emitted. Validation can still pass because current verification does not check for duplicate hook entries.

A minimal local reproduction against the current hook integrator produced:

2 ['<old-checkout-dir-or-generated-source-id>', '<current-checkout-dir>']

Additional context
This likely affects all merge-based hook targets:

  • Claude: .claude/settings.json
  • Codex: .codex/hooks.json
  • Gemini: .gemini/settings.json
  • Cursor: .cursor/hooks.json
  • Windsurf: .windsurf/hooks.json

These targets share the same merged-hook implementation. Copilot is different because it writes individual hook JSON files under .github/hooks/, so it is not affected by the same in-array duplicate-entry failure mode. However, checkout-directory-derived names may still create stale-file cleanup edge cases for Copilot.

Related issues:

The distinct bug here is source-id drift for root .apm hooks: the semantic hook is identical, but the stale entry survives because its old _apm_source no longer matches the current install's source id.

Metadata

Metadata

Assignees

No one assigned

    Labels

    type/bugSomething does not work as documented.

    Type

    No type

    Projects

    Status

    In Progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions