No supported way to configure a custom branch name for worktrees #10

Closed
opened 2026-05-15 14:24:24 +02:00 by lz · 1 comment
Owner

No supported way to configure a custom branch name for worktrees

Summary

When using --worktree, Claude Code always creates a branch named
worktree-<name>. There is no built-in option to change this prefix or
use a different naming convention (e.g. feature/<name>, claude/<name>,
or just <name> with no prefix). This conflicts with team branch naming
conventions and produces noisy branch names in pull requests and code review
tools.

Expected behaviour

A configuration option such as worktreeBranchPrefix in settings.json
should allow setting an arbitrary prefix (or an empty string to use the
worktree name directly):

{
  "worktreeBranchPrefix": "feature/"
}

So claude --worktree add-dark-mode would create branch feature/add-dark-mode
instead of worktree-add-dark-mode.

Current workaround and its problems

The only current workaround is a WorktreeCreate hook that replaces the default
git logic entirely and applies a custom branch name. This works for creation, but
introduces a correctness problem in the paired WorktreeRemove hook:

  • WorktreeCreate creates the branch as e.g. feature/add-dark-mode
  • The worktree directory is kept flat: .claude/worktrees/add-dark-mode/
  • WorktreeRemove receives only worktree_path in its JSON payload —
    there is no branch_name field
  • The hook must therefore guess the branch name from the directory path,
    e.g. worktree-$(basename "$WORKTREE_PATH") — which is wrong the moment
    any custom prefix is used

The result is that WorktreeRemove silently fails to delete the branch.
The worktree directory is removed but the branch is left behind, requiring
manual cleanup with git branch -D.

A reliable workaround requires writing the branch name into a sentinel file
(.claude-worktree-branch) inside the worktree at creation time and reading
it back at removal time — which is fragile and should not be necessary.

Here is the full hook script that demonstrates the problem and the sentinel
file workaround:

#!/bin/bash
set -euo pipefail

# Requires: jq, git
# Input: JSON on stdin from Claude Code
# WorktreeCreate: { "hook_event_name": "WorktreeCreate", "cwd": "...", "name": "..." }
# WorktreeRemove: { "hook_event_name": "WorktreeRemove", "cwd": "...", "worktree_path": "..." }

INPUT=$(cat)

if ! command -v jq &>/dev/null; then
  echo "jq is required (brew install jq / apt install jq)" >&2
  exit 1
fi

HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
CWD=$(echo "$INPUT"       | jq -r '.cwd')

# --- Logging ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_FILE="$SCRIPT_DIR/../logs/worktree.log"
mkdir -p "$(dirname "$LOG_FILE")"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$HOOK_EVENT] $*" >> "$LOG_FILE"; }

# --- Helpers ---

git_root() { git -C "$1" rev-parse --show-toplevel; }

default_branch() {
  local root="$1"
  git -C "$root" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null \
    | sed 's|^origin/||' \
    || git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null \
    || echo "main"
}

# ---------------------------------------------------------------
worktree_create() {
  local NAME
  NAME=$(echo "$INPUT" | jq -r '.name')

  # Flat directory name (slashes → dashes, safe for filesystem)
  local DIR_SLUG="${NAME//\//-}"

  # Branch name: customise this line for your convention.
  # Examples:
  #   BRANCH_NAME="$NAME"           # no prefix, use name as-is
  #   BRANCH_NAME="feature/$NAME"   # gitflow
  #   BRANCH_NAME="claude/$NAME"    # team convention
  local BRANCH_NAME="feature/$NAME"

  local REPO_ROOT
  REPO_ROOT=$(git_root "$CWD")

  local WORKTREE_DIR="$REPO_ROOT/.claude/worktrees/$DIR_SLUG"

  log "create: name=$NAME branch=$BRANCH_NAME dir=$WORKTREE_DIR"

  local GITIGNORE="$REPO_ROOT/.gitignore"
  if ! grep -qF '.claude/worktrees/' "$GITIGNORE" 2>/dev/null; then
    [[ -s "$GITIGNORE" ]] && [[ "$(tail -c1 "$GITIGNORE" | wc -c)" -gt 0 ]] \
      && echo "" >> "$GITIGNORE"
    echo ".claude/worktrees/" >> "$GITIGNORE"
    log ".claude/worktrees/ added to .gitignore"
  fi

  mkdir -p "$(dirname "$WORKTREE_DIR")"

  if [[ -d "$WORKTREE_DIR" ]]; then
    log "directory exists, reusing"
    echo "$WORKTREE_DIR"
    exit 0
  fi

  local BASE
  BASE=$(default_branch "$REPO_ROOT")
  log "branching from $BASE"

  cd "$REPO_ROOT"

  if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
    log "branch exists, attaching"
    git worktree add "$WORKTREE_DIR" "$BRANCH_NAME" >&2
  else
    git worktree add -b "$BRANCH_NAME" "$WORKTREE_DIR" "origin/$BASE" >&2
  fi

  # NOTE: We store the branch name here because WorktreeRemove only receives
  # worktree_path — there is no branch_name field in the payload. Without this
  # sentinel file, the remove hook cannot know the branch name when a custom
  # prefix is used, since it differs from the directory name.
  echo "$BRANCH_NAME" > "$WORKTREE_DIR/.claude-worktree-branch"

  local INCLUDE_FILE="$REPO_ROOT/.worktreeinclude"
  if [[ -f "$INCLUDE_FILE" ]]; then
    log "copying files from .worktreeinclude"
    cd "$REPO_ROOT"
    git ls-files --others --ignored --exclude-from="$INCLUDE_FILE" -z \
      | tar --null -T - -cf - 2>/dev/null \
      | tar -xf - -C "$WORKTREE_DIR" 2>/dev/null \
      && log "files copied" \
      || log "warning: some files could not be copied (non-fatal)"
  fi

  log "created successfully"
  echo "$WORKTREE_DIR"
}

# ---------------------------------------------------------------
worktree_remove() {
  local WORKTREE_PATH
  WORKTREE_PATH=$(echo "$INPUT" | jq -r '.worktree_path')

  log "remove: path=$WORKTREE_PATH"

  if [[ ! -d "$WORKTREE_PATH" ]]; then
    log "directory already gone, nothing to do"
    exit 0
  fi

  # Read branch name from sentinel file written at creation time.
  # This is the only reliable way to recover the branch name when a custom
  # prefix is in use — the payload does not include branch_name.
  local BRANCH_NAME=""
  local BRANCH_FILE="$WORKTREE_PATH/.claude-worktree-branch"
  if [[ -f "$BRANCH_FILE" ]]; then
    BRANCH_NAME=$(cat "$BRANCH_FILE")
    log "branch from sentinel file: $BRANCH_NAME"
  else
    # Fallback: ask git directly (works only while the worktree is intact)
    BRANCH_NAME=$(git -C "$WORKTREE_PATH" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
    log "branch from git fallback: $BRANCH_NAME"
  fi

  local MAIN_REPO
  MAIN_REPO=$(git -C "$WORKTREE_PATH" worktree list --porcelain 2>/dev/null \
    | awk '/^worktree /{p=$2} END{print p}')

  log "main_repo=$MAIN_REPO  branch=$BRANCH_NAME"

  cd "$MAIN_REPO"
  git worktree remove "$WORKTREE_PATH" --force 2>/dev/null \
    || { log "git worktree remove failed, falling back to rm"; rm -rf "$WORKTREE_PATH"; }

  if [[ -n "$BRANCH_NAME" ]]; then
    if git branch -d "$BRANCH_NAME" 2>/dev/null; then
      log "branch deleted: $BRANCH_NAME"
    else
      log "warning: branch $BRANCH_NAME not deleted (unmerged or already gone — clean up manually)"
    fi
  fi

  log "removed successfully"
}

# ---------------------------------------------------------------
case "$HOOK_EVENT" in
  WorktreeCreate) worktree_create ;;
  WorktreeRemove) worktree_remove ;;
  *) echo "Unknown hook event: $HOOK_EVENT" >&2; exit 1 ;;
esac

Proposed fix

Either:

  1. Add a worktreeBranchPrefix (or worktreeBranchName) setting that
    controls what branch name the built-in logic uses, without requiring a
    full hook replacement; or

  2. Include branch_name in the WorktreeRemove hook payload alongside
    worktree_path, so hooks that customise branch naming can clean up
    correctly without resorting to sentinel files or fragile path inference.

Option 2 is a low-effort fix that unblocks the hook-based workaround
immediately, even before a first-class config option is added.

Environment

  • Claude Code (any recent version with --worktree support, v2.1.50+)
  • Affects all platforms
# No supported way to configure a custom branch name for worktrees ## Summary When using `--worktree`, Claude Code always creates a branch named `worktree-<name>`. There is no built-in option to change this prefix or use a different naming convention (e.g. `feature/<name>`, `claude/<name>`, or just `<name>` with no prefix). This conflicts with team branch naming conventions and produces noisy branch names in pull requests and code review tools. ## Expected behaviour A configuration option such as `worktreeBranchPrefix` in `settings.json` should allow setting an arbitrary prefix (or an empty string to use the worktree name directly): ```json { "worktreeBranchPrefix": "feature/" } ``` So `claude --worktree add-dark-mode` would create branch `feature/add-dark-mode` instead of `worktree-add-dark-mode`. ## Current workaround and its problems The only current workaround is a `WorktreeCreate` hook that replaces the default git logic entirely and applies a custom branch name. This works for creation, but introduces a correctness problem in the paired `WorktreeRemove` hook: - `WorktreeCreate` creates the branch as e.g. `feature/add-dark-mode` - The worktree directory is kept flat: `.claude/worktrees/add-dark-mode/` - `WorktreeRemove` receives only `worktree_path` in its JSON payload — there is no `branch_name` field - The hook must therefore *guess* the branch name from the directory path, e.g. `worktree-$(basename "$WORKTREE_PATH")` — which is wrong the moment any custom prefix is used The result is that `WorktreeRemove` silently fails to delete the branch. The worktree directory is removed but the branch is left behind, requiring manual cleanup with `git branch -D`. A reliable workaround requires writing the branch name into a sentinel file (`.claude-worktree-branch`) inside the worktree at creation time and reading it back at removal time — which is fragile and should not be necessary. Here is the full hook script that demonstrates the problem and the sentinel file workaround: ```bash #!/bin/bash set -euo pipefail # Requires: jq, git # Input: JSON on stdin from Claude Code # WorktreeCreate: { "hook_event_name": "WorktreeCreate", "cwd": "...", "name": "..." } # WorktreeRemove: { "hook_event_name": "WorktreeRemove", "cwd": "...", "worktree_path": "..." } INPUT=$(cat) if ! command -v jq &>/dev/null; then echo "jq is required (brew install jq / apt install jq)" >&2 exit 1 fi HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name') CWD=$(echo "$INPUT" | jq -r '.cwd') # --- Logging --- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOG_FILE="$SCRIPT_DIR/../logs/worktree.log" mkdir -p "$(dirname "$LOG_FILE")" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$HOOK_EVENT] $*" >> "$LOG_FILE"; } # --- Helpers --- git_root() { git -C "$1" rev-parse --show-toplevel; } default_branch() { local root="$1" git -C "$root" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null \ | sed 's|^origin/||' \ || git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null \ || echo "main" } # --------------------------------------------------------------- worktree_create() { local NAME NAME=$(echo "$INPUT" | jq -r '.name') # Flat directory name (slashes → dashes, safe for filesystem) local DIR_SLUG="${NAME//\//-}" # Branch name: customise this line for your convention. # Examples: # BRANCH_NAME="$NAME" # no prefix, use name as-is # BRANCH_NAME="feature/$NAME" # gitflow # BRANCH_NAME="claude/$NAME" # team convention local BRANCH_NAME="feature/$NAME" local REPO_ROOT REPO_ROOT=$(git_root "$CWD") local WORKTREE_DIR="$REPO_ROOT/.claude/worktrees/$DIR_SLUG" log "create: name=$NAME branch=$BRANCH_NAME dir=$WORKTREE_DIR" local GITIGNORE="$REPO_ROOT/.gitignore" if ! grep -qF '.claude/worktrees/' "$GITIGNORE" 2>/dev/null; then [[ -s "$GITIGNORE" ]] && [[ "$(tail -c1 "$GITIGNORE" | wc -c)" -gt 0 ]] \ && echo "" >> "$GITIGNORE" echo ".claude/worktrees/" >> "$GITIGNORE" log ".claude/worktrees/ added to .gitignore" fi mkdir -p "$(dirname "$WORKTREE_DIR")" if [[ -d "$WORKTREE_DIR" ]]; then log "directory exists, reusing" echo "$WORKTREE_DIR" exit 0 fi local BASE BASE=$(default_branch "$REPO_ROOT") log "branching from $BASE" cd "$REPO_ROOT" if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then log "branch exists, attaching" git worktree add "$WORKTREE_DIR" "$BRANCH_NAME" >&2 else git worktree add -b "$BRANCH_NAME" "$WORKTREE_DIR" "origin/$BASE" >&2 fi # NOTE: We store the branch name here because WorktreeRemove only receives # worktree_path — there is no branch_name field in the payload. Without this # sentinel file, the remove hook cannot know the branch name when a custom # prefix is used, since it differs from the directory name. echo "$BRANCH_NAME" > "$WORKTREE_DIR/.claude-worktree-branch" local INCLUDE_FILE="$REPO_ROOT/.worktreeinclude" if [[ -f "$INCLUDE_FILE" ]]; then log "copying files from .worktreeinclude" cd "$REPO_ROOT" git ls-files --others --ignored --exclude-from="$INCLUDE_FILE" -z \ | tar --null -T - -cf - 2>/dev/null \ | tar -xf - -C "$WORKTREE_DIR" 2>/dev/null \ && log "files copied" \ || log "warning: some files could not be copied (non-fatal)" fi log "created successfully" echo "$WORKTREE_DIR" } # --------------------------------------------------------------- worktree_remove() { local WORKTREE_PATH WORKTREE_PATH=$(echo "$INPUT" | jq -r '.worktree_path') log "remove: path=$WORKTREE_PATH" if [[ ! -d "$WORKTREE_PATH" ]]; then log "directory already gone, nothing to do" exit 0 fi # Read branch name from sentinel file written at creation time. # This is the only reliable way to recover the branch name when a custom # prefix is in use — the payload does not include branch_name. local BRANCH_NAME="" local BRANCH_FILE="$WORKTREE_PATH/.claude-worktree-branch" if [[ -f "$BRANCH_FILE" ]]; then BRANCH_NAME=$(cat "$BRANCH_FILE") log "branch from sentinel file: $BRANCH_NAME" else # Fallback: ask git directly (works only while the worktree is intact) BRANCH_NAME=$(git -C "$WORKTREE_PATH" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") log "branch from git fallback: $BRANCH_NAME" fi local MAIN_REPO MAIN_REPO=$(git -C "$WORKTREE_PATH" worktree list --porcelain 2>/dev/null \ | awk '/^worktree /{p=$2} END{print p}') log "main_repo=$MAIN_REPO branch=$BRANCH_NAME" cd "$MAIN_REPO" git worktree remove "$WORKTREE_PATH" --force 2>/dev/null \ || { log "git worktree remove failed, falling back to rm"; rm -rf "$WORKTREE_PATH"; } if [[ -n "$BRANCH_NAME" ]]; then if git branch -d "$BRANCH_NAME" 2>/dev/null; then log "branch deleted: $BRANCH_NAME" else log "warning: branch $BRANCH_NAME not deleted (unmerged or already gone — clean up manually)" fi fi log "removed successfully" } # --------------------------------------------------------------- case "$HOOK_EVENT" in WorktreeCreate) worktree_create ;; WorktreeRemove) worktree_remove ;; *) echo "Unknown hook event: $HOOK_EVENT" >&2; exit 1 ;; esac ``` ## Proposed fix Either: 1. Add a `worktreeBranchPrefix` (or `worktreeBranchName`) setting that controls what branch name the built-in logic uses, without requiring a full hook replacement; or 2. Include `branch_name` in the `WorktreeRemove` hook payload alongside `worktree_path`, so hooks that customise branch naming can clean up correctly without resorting to sentinel files or fragile path inference. Option 2 is a low-effort fix that unblocks the hook-based workaround immediately, even before a first-class config option is added. ## Environment - Claude Code (any recent version with `--worktree` support, v2.1.50+) - Affects all platforms
Author
Owner

I would distribute the script or an adaptation of it outside of the .claude directory within the worker image.

I would distribute the script or an adaptation of it outside of the `.claude` directory within the worker image.
lz closed this issue 2026-05-15 22:40:27 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#10
No description provided.