Project-level .codex/hooks.json is silently ignored when Codex runs inside a git worktree

Open 💬 9 comments Opened Jun 9, 2026 by yaodiff
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

0.137.0

What subscription do you have?

pro

Which model were you using?

gpt-5.5

What platform is your computer?

Linux 5.15.120.bsk.3-amd64 x86_64

What terminal emulator and version are you using (if applicable)?

tmux

Codex doctor report

{
  "schemaVersion": 1,
  "generatedAt": "1780985871s since unix epoch",
  "overallStatus": "warning",
  "codexVersion": "0.137.0",
  "checks": {
    "app_server.status": {
      "id": "app_server.status",
      "category": "app-server",
      "status": "ok",
      "summary": "background server is not running",
      "details": {
        "control socket": "~/.codex/app-server-control/app-server-control.sock",
        "daemon state dir": "~/.codex/app-server-daemon",
        "mode": "ephemeral",
        "pid file": "~/.codex/app-server-daemon/app-server.pid (missing)",
        "settings": "~/.codex/app-server-daemon/settings.json (missing)",
        "status": "not running",
        "update-loop pid file": "~/.codex/app-server-daemon/app-server-updater.pid (missing)"
      },
      "remediation": null,
      "durationMs": 0
    },
    "auth.credentials": {
      "id": "auth.credentials",
      "category": "auth",
      "status": "ok",
      "summary": "auth is configured",
      "details": {
        "auth file": "~/.codex/auth.json",
        "auth storage mode": "File",
        "stored API key": "false",
        "stored ChatGPT tokens": "true",
        "stored agent identity": "false",
        "stored auth mode": "chatgpt"
      },
      "remediation": null,
      "durationMs": 0
    },
    "config.load": {
      "id": "config.load",
      "category": "config",
      "status": "ok",
      "summary": "config loaded",
      "details": {
        "CODEX_HOME": "~/.codex",
        "config.toml": "~/.codex/config.toml",
        "config.toml parse": "ok",
        "cwd": "<worktree-root>/<project>",
        "enabled feature flags": "shell_tool, unified_exec, shell_snapshot, sqlite, memories, hooks, enable_request_compression, network_proxy, multi_agent, apps, tool_suggest, plugins, in_app_browser, browser_use, browser_use_external, computer_use, plugin_sharing, image_generation, skill_mcp_dependency_install, steer, guardian_approval, goals, collaboration_modes, tool_call_mcp_elicitation, personality, fast_mode, tui_app_server, workspace_dependencies",
        "feature flag overrides": "terminal_resize_reflow=false, memories=true, network_proxy=true",
        "feature flags enabled": "28",
        "legacy feature flag": "codex_hooks -> hooks",
        "log dir": "~/.codex/log",
        "mcp servers": "1",
        "model": "gpt-5.5",
        "model provider": "openai",
        "sqlite home": "~/.codex"
      },
      "remediation": null,
      "durationMs": 0
    },
    "git.environment": {
      "id": "git.environment",
      "category": "git",
      "status": "ok",
      "summary": "git version 2.39.5",
      "details": {
        ".git entry": "file -> <repo-cache>/<project>.git/worktrees/<project>",
        "PATH git #1": "/usr/bin/git",
        "PATH git #2": "/bin/git",
        "PATH git entries": "2",
        "git branch": "<feature-branch>",
        "git build options": "git version 2.39.5; cpu: x86_64; no commit associated with this build; sizeof-long: 8; sizeof-size_t: 8; shell-path: /bin/sh",
        "git exec path": "/usr/lib/git-core",
        "git version": "git version 2.39.5",
        "repo detected": "true",
        "repo root": "<worktree-root>/<project>",
        "selected git": "/usr/bin/git"
      },
      "remediation": null,
      "durationMs": 33
    },
    "installation": {
      "id": "installation",
      "category": "install",
      "status": "ok",
      "summary": "installation looks consistent",
      "details": {
        "PATH codex #1": "~/.nvm/versions/node/v24.13.0/bin/codex",
        "current executable": "<nvm-root>/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
        "install context": "npm",
        "managed by bun": "false",
        "managed by npm": "true",
        "managed package root": "<nvm-root>/lib/node_modules/@openai/codex",
        "npm update target": "~/.nvm/versions/node/v24.13.0/lib/node_modules/@openai/codex"
      },
      "remediation": null,
      "durationMs": 76
    },
    "mcp.config": {
      "id": "mcp.config",
      "category": "mcp",
      "status": "ok",
      "summary": "MCP configuration is locally consistent",
      "details": {
        "configured servers": "1",
        "disabled servers": "0",
        "stdio servers": "1"
      },
      "remediation": null,
      "durationMs": 0
    },
    "network.env": {
      "id": "network.env",
      "category": "network",
      "status": "ok",
      "summary": "network-related environment looks readable",
      "details": {
        "proxy env vars present": "HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY, no_proxy"
      },
      "remediation": null,
      "durationMs": 0
    },
    "network.provider_reachability": {
      "id": "network.provider_reachability",
      "category": "reachability",
      "status": "ok",
      "summary": "active provider endpoints are reachable over HTTP",
      "details": {
        "ChatGPT base URL": "https://chatgpt.com/backend-api/ reachable (HTTP 403)",
        "reachability mode": "ChatGPT auth"
      },
      "remediation": null,
      "durationMs": 454
    },
    "network.websocket_reachability": {
      "id": "network.websocket_reachability",
      "category": "websocket",
      "status": "ok",
      "summary": "Responses WebSocket handshake succeeded",
      "details": {
        "DNS": "1 IPv4, 1 IPv6, first IPv4",
        "auth mode": "chatgpt",
        "connect timeout": "15000 ms",
        "endpoint": "wss://chatgpt.com/backend-api/<redacted>",
        "handshake result": "HTTP 101 Switching Protocols",
        "model provider": "openai",
        "models etag present": "true",
        "provider name": "OpenAI",
        "proxy env vars present": "HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY, no_proxy",
        "reasoning header": "false",
        "server model present": "false",
        "supports websockets": "true",
        "wire API": "responses"
      },
      "remediation": null,
      "durationMs": 1003
    },
    "runtime.provenance": {
      "id": "runtime.provenance",
      "category": "runtime",
      "status": "ok",
      "summary": "running npm on linux-x86_64",
      "details": {
        "commit": "unknown",
        "install method": "npm",
        "platform": "linux-x86_64",
        "version": "0.137.0"
      },
      "remediation": null,
      "durationMs": 0
    },
    "runtime.search": {
      "id": "runtime.search",
      "category": "search",
      "status": "ok",
      "summary": "search is OK (bundled)",
      "details": {
        "search provider": "bundled"
      },
      "remediation": null,
      "durationMs": 0
    },
    "sandbox.helpers": {
      "id": "sandbox.helpers",
      "category": "sandbox",
      "status": "ok",
      "summary": "sandbox configuration is readable",
      "details": {
        "approval policy": "OnRequest",
        "filesystem sandbox": "unrestricted",
        "network sandbox": "enabled"
      },
      "remediation": null,
      "durationMs": 0
    },
    "state.paths": {
      "id": "state.paths",
      "category": "state",
      "status": "ok",
      "summary": "state paths and databases are inspectable",
      "details": {
        "CODEX_HOME": "~/.codex (dir)",
        "active rollout files": "137 files, 179334758 total bytes",
        "archived rollout files": "2 files, 92761 total bytes",
        "goals DB": "~/.codex/goals_1.sqlite (file) integrity ok",
        "log DB": "~/.codex/logs_2.sqlite (file) integrity ok",
        "memories DB": "~/.codex/memories_1.sqlite (file) integrity ok",
        "state DB": "~/.codex/state_5.sqlite (file) integrity ok"
      },
      "remediation": null,
      "durationMs": 562
    },
    "state.rollout_db_parity": {
      "id": "state.rollout_db_parity",
      "category": "threads",
      "status": "ok",
      "summary": "rollout files and state DB thread inventory agree",
      "details": {
        "default model provider": "openai",
        "rollout DB active files": "137",
        "rollout DB active rows": "137",
        "rollout DB archived files": "2",
        "rollout DB archived rows": "2",
        "rollout DB model providers": "openai=139",
        "rollout DB rows": "139",
        "rollout DB sources": "vscode=98, exec=23, cli=10, subagent:review=5, subagent:thread_spawn=2, subagent:other=1"
      },
      "remediation": null,
      "durationMs": 5479
    },
    "system.environment": {
      "id": "system.environment",
      "category": "system",
      "status": "ok",
      "summary": "OS language en-US",
      "details": {
        "LANG": "zh_CN.UTF-8",
        "LC_ALL": "en_US.UTF-8",
        "LC_CTYPE": "UTF-8",
        "os": "Linux [64-bit]",
        "os language": "en-US",
        "os type": "Linux",
        "os version": "Unknown"
      },
      "remediation": null,
      "durationMs": 11
    },
    "terminal.env": {
      "id": "terminal.env",
      "category": "terminal",
      "status": "ok",
      "summary": "terminal metadata was detected",
      "details": {
        "COLORTERM": "truecolor",
        "FORCE_COLOR": "3",
        "TERM": "tmux-256color",
        "color output": "disabled (stdout is not a terminal)",
        "effective locale": "en_US.UTF-8",
        "stderr is terminal": "false",
        "stdin is terminal": "false",
        "stdout is terminal": "false",
        "terminal": "unknown",
        "terminal size": "80x24"
      },
      "remediation": null,
      "durationMs": 2
    },
    "terminal.title": {
      "id": "terminal.title",
      "category": "title",
      "status": "ok",
      "summary": "terminal title default",
      "details": {
        "terminal title activity": "true",
        "terminal title items": "activity, project-name",
        "terminal title project source": "git repo root",
        "terminal title project value": "<project>",
        "terminal title source": "default"
      },
      "remediation": null,
      "durationMs": 0
    },
    "updates.status": {
      "id": "updates.status",
      "category": "updates",
      "status": "warning",
      "summary": "update configuration is locally consistent",
      "details": {
        "cached latest version": "0.137.0",
        "check for update on startup": "true",
        "last checked at": "2026-06-08T17:54:25Z",
        "latest version probe": "curl: (22) The requested URL returned error: 403",
        "update action": "npm install -g @openai/codex",
        "version cache": "~/.codex/version.json"
      },
      "remediation": null,
      "durationMs": 470
    }
  }
}

What issue are you seeing?

When Codex runs inside a git worktree (.git is a file, not a directory), project-level hooks defined in <worktree-root>/.codex/hooks.json are silently not loaded or executed. Only user-level hooks from ~/.codex/hooks.json fire.

I tested three projects on the same machine with identical user-level hooks config:

| Project | .git type | Project hooks.json SessionStart hooks | Actual SessionStart hooks fired |
|---------|------------|----------------------------------------|-------------------------------|
| test_case | directory (normal repo) | 1 | 3 (2 user + 1 project) |
| crm_space | file (worktree) | 2 | 2 (user only, 0 project) |
| infra_product | file (worktree) | 2 | 2 (user only, 0 project) |

Key observations:

  • --dangerously-bypass-hook-trust does not change the result — still only 2 hooks in worktree mode. This rules out a trust issue.
  • codex doctor correctly identifies the worktree as a project (project source: git repo root, showing the worktree path).
  • The .codex/hooks.json file exists and is valid JSON at the worktree root.
  • infra_product has 13 trusted_hash entries in ~/.codex/config.toml for its project hooks — they still don't fire.

No error messages are shown. The project hooks are simply not discovered.

What steps can reproduce the bug?

# 1. Create a repo with a worktree
cd /tmp && mkdir hook-repro && cd hook-repro
git init main && cd main
git config user.email "test@test.com" && git config user.name "test"
echo "hello" > file.txt && git add . && git commit -m "init"
git worktree add ../wt -b feature

# 2. Add project-level hooks to the worktree
cd ../wt
mkdir -p .codex

cat > .codex/config.toml << 'EOF'
[features]
hooks = true
EOF

cat > .codex/hooks.json << 'EOF'
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "echo PROJECT_HOOK_FIRED >> /tmp/codex-hook-probe.log"
          }
        ]
      }
    ]
  }
}
EOF

# 3. Run codex in the worktree
rm -f /tmp/codex-hook-probe.log
codex exec --dangerously-bypass-hook-trust "echo hello"

# 4. Check
cat /tmp/codex-hook-probe.log
# Expected: contains "PROJECT_HOOK_FIRED"
# Actual:   file does not exist — project hook never fired

# 5. Compare: do the same in the main (non-worktree) repo
cd ../main
mkdir -p .codex
cp ../wt/.codex/config.toml .codex/
cp ../wt/.codex/hooks.json .codex/
rm -f /tmp/codex-hook-probe.log
codex exec --dangerously-bypass-hook-trust "echo hello"
cat /tmp/codex-hook-probe.log
# Result: contains "PROJECT_HOOK_FIRED" ✓

What is the expected behavior?

Project-level .codex/hooks.json placed at the worktree working tree root (the path returned by git rev-parse --show-toplevel) should be discovered and loaded, the same way it works in a normal (non-worktree) repository.

Additional information

Root cause hypothesis: In a git worktree, .git is a file containing gitdir: /path/to/.git/worktrees/<name>. The hooks discovery code likely resolves the project root by looking for a .git directory, or by using git rev-parse --git-common-dir (which returns the main repo's .git path), instead of using git rev-parse --show-toplevel (which correctly returns the worktree working tree root). This causes the hooks scanner to look for .codex/hooks.json at the wrong location.

Evidence supporting this: I found a stale trusted_hash entry in ~/.codex/config.toml keyed to a .codex/hooks.json path at the bare repo cache parent directory (where --git-common-dir resolves), not at the worktree working tree — suggesting codex previously resolved the hooks config path to that location.

Workaround: Register hooks at the user level (~/.codex/hooks.json) instead of project level. User-level hooks fire correctly regardless of worktree status.

View original on GitHub ↗

9 Comments

github-actions[bot] contributor · 2 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #26729

Powered by Codex Action

woolkingx · 2 months ago

This worktree failure mode is exactly why I ended up building codex-hooks as a schema-first operating layer around Codex hooks: not to replace upstream hooks, but to make discovery/runtime state inspectable.

Repo: https://github.com/woolkingx/codex-hooks

For this specific issue, the useful piece would be a doctor check that records:

  • session cwd
  • git rev-parse --show-toplevel
  • .git entry type (file vs directory)
  • discovered project hook path
  • rendered hook source list
  • last hook dispatch/log evidence

That would make the bug show up as "project hook source not discovered for worktree cwd" instead of silently falling back to user hooks. I can add this as a focused repro/doctor check on my side and link the output here if it helps triage.

Necmttn · 2 months ago

A focused regression would catch this cleanly:

  1. create a normal git repo with project .codex/hooks.json
  2. create a linked git worktree for that repo
  3. start Codex from the worktree cwd
  4. assert SessionStart discovers and dispatches the project hook

The other useful fix is observability. codex doctor or a hooks list should show session cwd, git rev-parse --show-toplevel, whether .git is a file or directory, each discovered hook source path, and the trust key/source used for that hook.

Then this failure becomes visible as "project hook source not discovered for worktree cwd" instead of silently falling back to user-level hooks.

---

_Generated with ax._

nyavana · 2 months ago

This is happening to me as well when using https://github.com/mindfold-ai/trellis

marenostrum1981 · 1 month ago

Happening too when using v.0.144.0

wakadorimk2 · 20 days ago

We reproduced the same worktree-specific behavior in a Windows Quiet Days repository.

Observed:

  • A valid project-local .codex/hooks.json exists at the worktree root.
  • git rev-parse --show-toplevel returns that worktree root.
  • The worktree .git entry is a file containing the linked-worktree gitdir.
  • /hooks shows the project-local PostCompact and SessionStart entries as 0 installed / 0 active, while user-level hooks remain visible.
  • Trusting the project hook does not change the discovered project hook source list.
  • This is consistent with the report's finding that --dangerously-bypass-hook-trust does not repair worktree discovery; the failure appears to occur before hook trust/dispatch.
  • Synthetic, unit, and integration tests for the project adapter pass, but fresh-session dispatch remains unverified because the project-local source is not discovered.

An experimental adapter and tests are preserved here:
https://github.com/wakadorimk2/quiet-days/tree/codex/goal-execution-state-v3-experimental

No private filesystem paths or secrets are included.

nordscope-fi · 18 days ago

Still present on 0.147.0 (macOS), and the rule looks narrower than "ignored in a worktree": the project hook path resolves to the main working tree.

hooks/list over codex app-server answers this without spending a model turn, which makes it easy to separate discovery from trust.

Setup

  • codex-cli 0.147.0, macOS 26.5.2, arm64
  • Main checkout with .codex/hooks.json committed, then git worktree add
  • Throwaway CODEX_HOME, hooks/list called with cwds: ["<worktree>"]
  • The worktree's .git is a file, as expected for a linked worktree

What hooks/list reports from inside the worktree

| Trusted in config.toml | Discovered | sourcePath |
|---|---|---|
| worktree path only | 26 | main checkout's .codex/hooks.json |
| main checkout path only | 26 | main checkout's .codex/hooks.json |
| neither | 0 | – |

Both trust spellings work, and in both cases the file that gets loaded is the main checkout's. The worktree's own .codex/hooks.json, which is a real committed file sitting at its root, is never the source.

The decisive case

Remove .codex/hooks.json from the main checkout and leave the worktree's own copy in place, with both paths trusted:

discovered from the worktree: 0

So the worktree's file is not merely lower-priority. It is invisible.

Why this reads as "silently ignored"

Anyone who runs an installer inside a worktree, or edits hooks on a branch checked out as one, gets exactly the reported symptom: a correct-looking .codex/hooks.json at the repository root, /hooks showing nothing from the project, and no error anywhere. If the main checkout happens to have a file too, something loads, and it is the wrong one, which is arguably worse than nothing.

This matches the earlier comment about a stale trusted_hash keyed to the path --git-common-dir resolves to rather than to the working tree.

Two suggestions, in order of how much they would help:

  1. Resolve the project layer against git rev-parse --show-toplevel rather than the common git directory.
  2. Failing that, emit a warning when a .codex/hooks.json exists at the current working tree root and is not the file being loaded. The whole cost of this bug is that it says nothing.
fordb · 9 days ago

Confirmed on macOS with bundled codex-cli 0.146.0-alpha.3.1; this can produce a convincing false diagnosis of unrelated lifecycle hooks.

Black-box comparison:

  1. In a git worktree, the worktree's committed .codex/hooks.json contained UserPromptSubmit, PreCompact, PostCompact, and SessionStart(source=compact) handlers. The main checkout's version contained the older UserPromptSubmit handlers only.
  2. codex exec -C <worktree> --dangerously-bypass-hook-trust displayed two UserPromptSubmit runs, then visibly auto-compacted after a shell tool call, but displayed no PreCompact or PostCompact run.
  3. I copied the same lifecycle configuration and command into a standalone disposable repository, explicitly trusted that repository, and reran the same forced-compaction probe.
  4. The standalone run displayed exactly one UserPromptSubmit, then PreCompact Completed, context compacted, PostCompact Stopped, and turn interrupted before the requested final response.
  5. Resuming the same session succeeded normally, and metadata-only hook telemetry correlated the prompt, pending compaction, stop, and resumed continuation by session/turn id.

This isolates config discovery rather than hook command behavior: in the worktree run, the number and event set of discovered hooks matched the main checkout's .codex/hooks.json; in the standalone run, they matched the disposable repo's file. Trust bypass did not make the worktree-local project layer win.

No private transcript or prompt/response content was inspected or uploaded; the comparison used visible hook status rows plus metadata-only local receipts.

abhijit-mehta · 2 days ago

Codex / GPT-5.6 Sol corroboration: this is still present in Codex CLI 0.149.1 on macOS arm64.

I ran an A/B test that isolates the lookup root:

  1. The linked worktree contained a valid, committed .codex/hooks.json; the primary checkout contained no .codex/ directory.
  2. From the linked worktree, /hooks showed PreToolUse 0 installed / 0 active. Exact project trust and --dangerously-bypass-hook-trust did not change discovery.
  3. I temporarily copied the identical hooks.json into the primary checkout.
  4. Without changing cwd, branch, or the linked worktree file, /hooks changed to PreToolUse 1 installed.
  5. A live codex exec --dangerously-bypass-hook-trust from the linked worktree then blocked git push --dry-run origin main before Git executed.
  6. From a nested directory in the linked worktree, a harmless printf canary showed PreToolUse Completed and ran successfully. The handler command used $(git rev-parse --show-toplevel), so execution correctly resolved the linked worktree's script.
  7. Removing the temporary primary-checkout copy restored the zero-hook result.

codex doctor correctly reported the linked worktree as repo root and showed its .git entry pointing into the primary checkout's shared git directory.

This confirms the split described in #23996: discovery is anchored to the primary checkout, while execution cwd/git-root resolution is anchored to the linked worktree. It is not only a missing-discovery case; when both branches have hook files, Codex can load policy from one branch and execute code from another.

That creates two risky failure modes:

  • Silent fail-open: a hook newly added on the linked branch is invisible when absent from the primary checkout.
  • Branch-skew/fail-closed: an older primary-checkout definition can invoke a handler absent or incompatible in the linked branch.

The current official docs list <repo>/.codex/hooks.json as a supported location and say /hooks lists configured hooks before trust decisions: https://learn.chatgpt.com/docs/hooks. Resolving discovery against the current worktree's git rev-parse --show-toplevel would match that contract. At minimum, Codex should warn when <current-worktree>/.codex/hooks.json exists but a different source is loaded (or none is loaded).

The practical workaround is to install the definition in the primary checkout or user hook layer, but neither preserves branch-local hook semantics.