From bf6d333dc6dd727507ff0bbb77505578780cb37a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 30 Jul 2026 23:49:40 +0200 Subject: [PATCH] Store all worktrees under a shared ~/Code/worktrees root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Claude Code's default drops worktrees inside each repo under .claude/worktrees, cluttering the repo and scattering them across the filesystem. A global WorktreeCreate hook redirects every repo's worktrees to ~/Code/worktrees// instead. * The hook fully replaces git's default worktree creation, which also disables Claude Code's native .worktreeinclude copy step. So the script copies the gitignored local config a fresh checkout lacks — devenv.*, .envrc, .env, .mcp.json, and .claude local settings/hooks — from the repo's main checkout into each worktree. * New branches start fresh off origin's default branch (falling back to HEAD when there's no origin), matching Claude Code's own default. The path is namespaced by repo so slugs can't collide across repos. * Wrapped via writeShellApplication so shellcheck gates the script at build time — a non-zero exit or stray stdout would otherwise abort worktree creation. Docs: https://code.claude.com/docs/en/hooks.md#worktreecreate --- config/claude/settings.json | 10 ++++++ modules/claude.nix | 10 ++++++ modules/claude/worktree-create.sh | 51 +++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 modules/claude/worktree-create.sh diff --git a/config/claude/settings.json b/config/claude/settings.json index 59a1820..830cb67 100644 --- a/config/claude/settings.json +++ b/config/claude/settings.json @@ -17,6 +17,16 @@ } ] } + ], + "WorktreeCreate": [ + { + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/worktree-create.sh" + } + ] + } ] }, "statusLine": { diff --git a/modules/claude.nix b/modules/claude.nix index bb0bff6..77f5e40 100644 --- a/modules/claude.nix +++ b/modules/claude.nix @@ -17,6 +17,15 @@ let runtimeInputs = [ pkgs.jq ]; text = builtins.readFile ./claude/block-dangerous-git.sh; }; + worktreeCreate = pkgs.writeShellApplication { + name = "worktree-create"; + runtimeInputs = [ + pkgs.jq + pkgs.git + pkgs.coreutils + ]; + text = builtins.readFile ./claude/worktree-create.sh; + }; in { imports = [ ./agents.nix ]; @@ -32,4 +41,5 @@ in # Static scripts referenced by settings.json under stable ~/.claude paths. home.file.".claude/statusline-command.sh".source = lib.getExe statusline; home.file.".claude/hooks/block-dangerous-git.sh".source = lib.getExe blockDangerousGit; + home.file.".claude/hooks/worktree-create.sh".source = lib.getExe worktreeCreate; } diff --git a/modules/claude/worktree-create.sh b/modules/claude/worktree-create.sh new file mode 100644 index 0000000..fff84aa --- /dev/null +++ b/modules/claude/worktree-create.sh @@ -0,0 +1,51 @@ +# Runs under writeShellApplication (set -euo pipefail). Claude Code's +# WorktreeCreate hook: it fully replaces the default `git worktree add`, so this +# script both places the worktree under ~/Code/worktrees// and copies +# local, gitignored config into it. The created path MUST be the last stdout +# line; all other output goes to stderr; any non-zero exit aborts creation. + +INPUT=$(cat) +NAME=$(echo "$INPUT" | jq -r '.name') +CWD=$(echo "$INPUT" | jq -r '.cwd') + +# The repo's primary working tree — our copy source and the basis for — +# regardless of whether Claude runs from a linked worktree. +COMMON_DIR=$(git -C "$CWD" rev-parse --path-format=absolute --git-common-dir) +MAIN_ROOT=$(dirname "$COMMON_DIR") +REPO=$(basename "$MAIN_ROOT") + +DEST="$HOME/Code/worktrees/$REPO/$NAME" + +# Base ref: origin's default branch (fresh). Fall back to HEAD if there's no origin. +ORIGIN_HEAD=$(git -C "$MAIN_ROOT" symbolic-ref --quiet refs/remotes/origin/HEAD || true) +if [ -z "$ORIGIN_HEAD" ]; then + git -C "$MAIN_ROOT" remote set-head origin --auto >&2 || true + ORIGIN_HEAD=$(git -C "$MAIN_ROOT" symbolic-ref --quiet refs/remotes/origin/HEAD || true) +fi +BASE_REF=${ORIGIN_HEAD#refs/remotes/} +[ -z "$BASE_REF" ] && BASE_REF=HEAD + +mkdir -p "$(dirname "$DEST")" + +# New branch named after the slug; if it already exists, check it out instead. +if git -C "$MAIN_ROOT" show-ref --verify --quiet "refs/heads/$NAME"; then + git -C "$MAIN_ROOT" worktree add "$DEST" "$NAME" >&2 +else + git -C "$MAIN_ROOT" worktree add -b "$NAME" "$DEST" "$BASE_REF" >&2 +fi + +# Carry uncommitted local config from the main checkout, if present. +CARRY=( + devenv.nix devenv.yaml devenv.lock devenv.local.nix + .envrc .env .mcp.json + .claude/settings.json .claude/settings.local.json .claude/hooks +) +for item in "${CARRY[@]}"; do + src="$MAIN_ROOT/$item" + if [ -e "$src" ]; then + mkdir -p "$(dirname "$DEST/$item")" + cp -R "$src" "$DEST/$item" >&2 + fi +done + +echo "$DEST" -- 2.51.2