EnterWorktree validates hook-created (non-git) worktrees against the git worktree list, refusing to enter them

Status Open
Reported on v2.1.169
Maintainer reply None cached
Activity 7 comments · opened Jun 9, 2026

Summary

In a jj-colocated repo (git backend + Jujutsu) with a custom WorktreeCreate hook that creates jj workspaces (not git worktrees), the EnterWorktree tool is internally inconsistent:

  • The creation path correctly honors the custom hook — it fires WorktreeCreate, the hook runs jj workspace add, and a jj workspace is created at a sibling path.
  • The enter/validation path then assumes git worktrees and validates the just-created path against the git worktree list. Because a jj workspace is not a registered git worktree, validation fails and EnterWorktree refuses to switch the session into the directory the hook just created.

So the same tool delegates creation to a non-git hook, then immediately rejects the hook's output for not being a git worktree. The two halves disagree about what a "worktree" is.

(Note: this is distinct from #36205 — there the hook was not fired at all and a built-in worktree-add ran. As of v2.1.169 the hook now fires correctly; the remaining defect is that the result is validated against the git worktree list.)

Environment

  • Claude Code: 2.1.169
  • OS: macOS 26.4.1 (build 25E253), Darwin 25.4.0, arm64
  • VCS: Jujutsu (jj) colocated git repo, with a custom WorktreeCreate/WorktreeRemove hook that produces jj workspaces at a sibling <repo>-workspaces/<name> path (not git-registered worktrees)

Repro steps

  1. In a jj-colocated repo, configure a WorktreeCreate (and WorktreeRemove) hook that creates a non-git worktree — e.g. a jj workspace via jj workspace add — at a sibling path like <repo-parent>/<repo>-workspaces/<name>, and emits that absolute path on stdout per the hook contract.
  2. Start Claude Code normally (no --worktree).
  3. Invoke the EnterWorktree tool mid-session.
  4. Observe: the hook runs and creates the jj workspace, but EnterWorktree then rejects it.

Actual behavior

The hook successfully creates the jj workspace, then EnterWorktree validates it against the git worktree list and refuses to enter:

Cannot enter worktree: /Users/<user>/Code/.../swarm-infra-workspaces/import-gap-audit-report is not a registered worktree of /Users/<user>/Code/.../swarm-infra. Run 'git -C /Users/<user>/Code/.../swarm-infra worktree list' to see registered worktrees.

The workspace genuinely exists — it just isn't a git worktree. jj workspace list from the repo root confirms it (paths abbreviated):

$ jj workspace list
default: ppspyqzx 487583bb (empty) (no description set)
import-gap-audit-report: mypqqpnz eb08415b (empty) (no description set)
maybe-add-skaffold: mqlstzsp 5a09da1a (empty) (no description set)

import-gap-audit-report is a real, hook-created jj workspace that the git worktree list will never show — yet that git list is what the validation gates on.

Expected behavior

When worktree creation is delegated to a custom hook (WorktreeCreate), EnterWorktree must not validate the result against the git worktree list. It should either validate against the hook's notion of worktrees, or trust the hook's emitted stdout path and skip the git check entirely, then switch the session cwd into the hook-created directory.

Put simply: if creation is hook-driven (non-git), validation must be hook-driven (non-git) too. The create path and the enter/validate path should use a consistent definition of "worktree."

Impact

In a background session this is a hard block. The background write-isolation guard rejects all Write/Edit tool calls until EnterWorktree "succeeds" — and because validation always fails for a jj workspace, it never succeeds. The only workaround is to bypass EnterWorktree entirely and write directly to the hook-created workspace path (e.g. via Bash heredocs), defeating the isolation tooling.

Config (redacted)

The relevant hook block from ~/.claude/settings.json (secrets/tokens redacted; none appear in these hooks):

"worktree": { "baseRef": "fresh" },
"hooks": {
  "WorktreeCreate": [
    {
      "hooks": [{
        "type": "command",
        "command": "... create-worktree.sh \"$cwd\" \"$name\"  (reads name+cwd from hook JSON on stdin)",
        "timeout": 180
      }]
    }
  ],
  "WorktreeRemove": [
    {
      "hooks": [{ "type": "command", "command": "... remove-worktree.sh \"$repo\" \"$name\"" }]
    }
  ]
}

The create-worktree.sh hook handles both VCS backends and, for a jj repo, runs jj workspace add, creating the workspace at <repo-parent>/<repo>-workspaces/<name> and printing that absolute path on stdout (the documented WorktreeCreate stdout contract).

Related

  • #36205 — EnterWorktree previously ignored WorktreeCreate/WorktreeRemove hooks (the hook-firing half; now appears fixed). This report is about the remaining validation half.
  • #60497 — background/agent isolation: "worktree" doesn't fire WorktreeCreate (same class of git-vs-hook inconsistency, different creation path).

View original on GitHub ↗

6 Comments

KingMob · 2 months ago

Any news on this?

KingMob · 1 month ago

Any news on this?

ericssonlericson · 9 days ago

Still waiting on this.

ericssonlericson · 9 days ago

FWIW I made it work by making the jj workspace also be a git worktree. I think that should work without dangerous interactions, but I guess I'll be finding out. I think it might be possible to break it but should be enough for isolation purposes.

Here is .claude/hooks/jj-worktree-create.sh:

#!/bin/bash
# WorktreeCreate hook: create a jj workspace instead of a git worktree.
#
# Why not let Claude Code make a plain git worktree?
#   - It cuts from origin/<default-branch>, which in a jj repo lags `jj @` badly
#     (it was 11 commits behind when we tested).
#   - It has no .jj, so `jj st` run inside it silently walks UP to the main
#     checkout and reports/edits the main working copy. Verified empirically.
#
# Why not `git worktree add` and then colocate jj inside it? jj refuses:
#   "Cannot create a colocated jj repo inside a Git worktree."
# Git worktrees share one ref store, so two independent jj repos would fight
# over refs/heads/* on every import/export, and each would mint its own change
# ids for the same commits -- precisely what makes merging back painful.
#
# So: create a real jj workspace (shared repo, shared op log, shared change
# ids -- trivial to merge back), then ALSO register it with git by hand so it is
# visible to both VCSes. Without the git side, `git rev-parse --show-toplevel`
# from inside resolves to the main checkout and Claude Code's isolation guard
# rejects the directory outright -- correctly, since git commands there would
# write outside the worktree.
#
# `jj workspace add` has no --colocate (unlike `jj git init`), hence by hand.
#
# stdin:  {"hook_event_name":"WorktreeCreate","name":"<slug>", ...}
# stdout: absolute path of the new workspace (Claude Code reads the last
#         non-empty line; all diagnostics must go to stderr).
set -euo pipefail

name=$(jq -r '.name // empty')
[ -n "$name" ] || { echo "WorktreeCreate: no name in hook input" >&2; exit 1; }

root=$(jj workspace root 2>/dev/null) || {
  echo "WorktreeCreate: not inside a jj repo" >&2; exit 1; }

dest="$root/.claude/worktrees/$name"

# Idempotent: reuse an existing workspace rather than failing.
if [ -e "$dest/.jj" ]; then
  echo "$dest"
  exit 0
fi

jj -R "$root" workspace add --name "$name" "$dest" >&2

# --- make the workspace git-visible -------------------------------------
# jj commit ids ARE git commit ids in a colocated repo, so git can point
# straight at the workspace's parent commit. Detached HEAD at @- is what jj
# itself does when colocating: the jj working copy then shows up to git as
# uncommitted changes, which is the truthful view.
head=$(jj -R "$dest" log -r '@-' --no-graph -T 'commit_id')
admin="$root/.git/worktrees/$name"
mkdir -p "$admin"
echo "../.." > "$admin/commondir"   # shared object/ref store
echo "$dest/.git" > "$admin/gitdir" # back-pointer, used by `git worktree prune`
echo "$head" > "$admin/HEAD"
echo "gitdir: $admin" > "$dest/.git"

# Seed the index from HEAD, or git reports every tracked file as deleted.
# read-tree touches only the index, never the working tree.
git -C "$dest" read-tree HEAD

# `jj workspace add` does not self-ignore the way `jj git init` does (which
# writes .jj/.gitignore containing "/*"), so mimic it -- otherwise git sees an
# untracked .jj/ in every workspace.
echo '/*' > "$dest/.jj/.gitignore"

echo "$dest"

Here is .claude/hooks/jj-worktree-remove.sh

#!/bin/bash
# WorktreeRemove hook: forget the jj workspace, unregister it from git, and
# delete its directory. Counterpart to jj-worktree-create.sh.
#
# Deliberately conservative: refuses any path outside .claude/worktrees/ and
# any directory without its own .jj, so it can never reap the main checkout or
# a pre-existing plain git worktree.
#
# stdin: {"hook_event_name":"WorktreeRemove","worktree_path":"<abs path>", ...}
set -euo pipefail

path=$(jq -r '.worktree_path // empty')
[ -n "$path" ] || { echo "WorktreeRemove: no worktree_path in hook input" >&2; exit 1; }

case "$path" in
  */.claude/worktrees/*) ;;
  *) echo "WorktreeRemove: refusing path outside .claude/worktrees: $path" >&2; exit 1 ;;
esac
[ -e "$path/.jj" ] || { echo "WorktreeRemove: not a jj workspace: $path" >&2; exit 1; }

name=$(basename "$path")

# Run forget from the MAIN repo, not the workspace being removed -- jj warns
# and leaves the working copy behind if you forget the workspace you are in.
# `jj -R "$path" workspace root` is no help: it reports the workspace's own
# path. Instead follow the workspace's .jj/repo pointer, which is relative to
# .jj/ and points at <root>/.jj/repo, so strip those two components back off.
repo_ptr=$(cat "$path/.jj/repo") || {
  echo "WorktreeRemove: no .jj/repo pointer in $path" >&2; exit 1; }
repo_dir=$(realpath "$path/.jj/$repo_ptr") || {
  echo "WorktreeRemove: cannot resolve .jj/repo pointer: $repo_ptr" >&2; exit 1; }
root=$(dirname "$(dirname "$repo_dir")")

jj -R "$root" workspace forget "$name" >&2 || true

# Tear down the git-side registration the create hook built by hand. Removing
# the directories first and pruning after keeps git from complaining about a
# worktree whose path has vanished.
rm -rf "$root/.git/worktrees/$name"
rm -rf "$path"
git -C "$root" worktree prune >&2 || true

and .claude/settings.local.json:

{
  "hooks": {
    "WorktreeCreate": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/jj-worktree-create.sh\"",
            "timeout": 120,
            "statusMessage": "Creating jj workspace..."
          }
        ]
      }
    ],
    "WorktreeRemove": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/jj-worktree-remove.sh\"",
            "timeout": 120,
            "statusMessage": "Removing jj workspace..."
          }
        ]
      }
    ]
  }
}
ericssonlericson · 9 days ago

Ah. The limitation is that Git and Jujutsu disagree _inside the worktree/workspace hybrid_. From Git's point of view, it is a worktree of the root Git repository. From Jujutsu's point of view, it is a workspace from the root jj repo, so Jujutsu's backing Git repository is the root repository, not the worktree Git repository. This means the jj workspace updates are not stored in the Git worktree correctly, notably Git's HEAD will not be updated. So jj new in the jj workspace will not update the Git worktree's HEAD.

ericssonlericson · 9 days ago

Hm, looks like it is easier to just use git-worktree for isolation. The above limitation means git commands in isolation will use the wrong index, so git checkout etc will undo changes in the jj working copy. What you can do is set worktree.baseRef to head in settings.local.json:

{
  "worktree": {
    "baseRef": "head"
  }
}

Showing cached comments. Read the full discussion on GitHub ↗