# Copyright (c) 2026 Jakub Czajka <jakub@ekhem.eu.org>
# License: GPL-3.0 or later.
#
-# create-worktree - Enforce worktree entry on main.
+# create-worktree - Enforce worktree isolation.
#
-# Matches all tool types (*). Denies every tool call (except EnterWorktree)
-# unless already in this session's worktree.
+# Matches all tool types (*). Denies every tool call (except
+# EnterWorktree) unless already in this session's worktree. Once inside
+# the worktree, blocks Write/Edit/Bash from reaching outside it — the
+# only path out is /merge-worktree.
+
+# Capture stdin before any command consumes it.
+stdin=$($GUIX_BIN/cat)
# Let EnterWorktree pass through.
-if $GUIX_BIN/grep -q '"tool_name"[[:space:]]*:[[:space:]]*"EnterWorktree"'
+if $GUIX_BIN/echo "$stdin" \
+ | $GUIX_BIN/grep -q '"tool_name"[[:space:]]*:[[:space:]]*"EnterWorktree"'
then
exit 0
fi
-# Allow if already is this session's worktree.
-if $GUIX_BIN/echo "$PWD" | $GUIX_BIN/grep --quiet \
+# Deny if NOT in this session's worktree.
+if ! $GUIX_BIN/echo "$PWD" | $GUIX_BIN/grep --quiet \
"/worktrees/${CLAUDE_CODE_SESSION_ID}"
then
+ ${GUIX_BIN}/echo \
+ '{"hookSpecificOutput": {' \
+ '"hookEventName": "PreToolUse",' \
+ '"permissionDecision": "deny",' \
+ '"permissionDecisionReason": "Not in the right worktree. Call' \
+ 'EnterWorktree with name='"$CLAUDE_CODE_SESSION_ID"' to create and' \
+ 'switch into a worktree, then retry. You MUST use this session' \
+ 'name.",' \
+ '"systemMessage": "🚧 Not in the right worktree. Call EnterWorktree' \
+ 'with name='"$CLAUDE_CODE_SESSION_ID"'."' \
+ '}}'
exit 0
fi
-# Deny -- must EnterWorktree with this session's ID as the name.
-${GUIX_BIN}/echo \
- '{"hookSpecificOutput": {' \
- '"hookEventName": "PreToolUse",' \
- '"permissionDecision": "deny",' \
- '"permissionDecisionReason": "Not in the right worktree. Call' \
- 'EnterWorktree with name='"$CLAUDE_CODE_SESSION_ID"' to create and' \
- 'switch into a worktree, then retry. You MUST use this session name.",' \
- '"systemMessage": "🚧 Not in the right worktree. Call EnterWorktree with' \
- 'name='"$CLAUDE_CODE_SESSION_ID"'."' \
- '}}'
+# ── We are in the worktree ──────────────────────────────────────
+
+# Keep skills fresh — symlink from main so agents always see the
+# latest version.
+if [ -d "$PWD/.claude/skills" ] && [ ! -L "$PWD/.claude/skills" ]
+then
+ if [ -d "$CLAUDE_PROJECT_DIR/.claude/skills" ] && \
+ [ ! -L "$CLAUDE_PROJECT_DIR/.claude/skills" ]
+ then
+ rm -rf "$PWD/.claude/skills"
+ ln -s "$CLAUDE_PROJECT_DIR/.claude/skills" \
+ "$PWD/.claude/skills"
+ fi
+fi
+
+worktree="$CLAUDE_PROJECT_DIR/.claude/worktrees/$CLAUDE_CODE_SESSION_ID"
+tool=$($GUIX_BIN/echo "$stdin" \
+ | $GUIX_BIN/sed --quiet \
+ 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')
+
+case "$tool" in
+Write|Edit)
+ file_path=$($GUIX_BIN/echo "$stdin" \
+ | $GUIX_BIN/sed --quiet \
+ 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')
+ # Block writes to the main project (anything under
+ # $CLAUDE_PROJECT_DIR that is not inside this worktree).
+ case "$file_path" in
+ "$CLAUDE_PROJECT_DIR"*)
+ if ! $GUIX_BIN/echo "$file_path" \
+ | $GUIX_BIN/grep --fixed-strings --quiet "$worktree"
+ then
+ $GUIX_BIN/echo \
+ '{"hookSpecificOutput": {' \
+ '"hookEventName": "PreToolUse",' \
+ '"permissionDecision": "deny",' \
+ '"systemMessage": "🚫 Write blocked: target is outside' \
+ ' the worktree. Use the worktree path, not the' \
+ ' project root."' \
+ '}}'
+ exit 0
+ fi
+ ;;
+ esac
+ ;;
+
+Bash)
+ command=$($GUIX_BIN/echo "$stdin" \
+ | $GUIX_BIN/sed --quiet \
+ 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')
+
+ # Block cd to main or a different worktree.
+ case "$command" in
+ *"cd $CLAUDE_PROJECT_DIR"*|*"cd ${CLAUDE_PROJECT_DIR}"*)
+ if $GUIX_BIN/echo "$command" \
+ | $GUIX_BIN/grep --quiet \
+ "/\.claude/worktrees/${CLAUDE_CODE_SESSION_ID}"
+ then
+ # cd to own worktree — allowed.
+ :
+ elif $GUIX_BIN/echo "$command" \
+ | $GUIX_BIN/grep --quiet '/\.claude/worktrees/'
+ then
+ $GUIX_BIN/echo \
+ '{"hookSpecificOutput": {' \
+ '"hookEventName": "PreToolUse",' \
+ '"permissionDecision": "deny",' \
+ '"systemMessage": "🚫 Wrong worktree — stay in' \
+ ' your own."' \
+ '}}'
+ exit 0
+ else
+ $GUIX_BIN/echo \
+ '{"hookSpecificOutput": {' \
+ '"hookEventName": "PreToolUse",' \
+ '"permissionDecision": "deny",' \
+ '"systemMessage": "🚫 In a worktree — use the' \
+ ' worktree path, not the project root."' \
+ '}}'
+ exit 0
+ fi
+ ;;
+
+ *"git -C $CLAUDE_PROJECT_DIR"*|\
+ *"git -C ${CLAUDE_PROJECT_DIR}"*)
+ # Only merge-worktree may operate on main.
+ if $GUIX_BIN/echo "$command" \
+ | $GUIX_BIN/grep --quiet 'merge-worktree'
+ then
+ :
+ else
+ $GUIX_BIN/echo \
+ '{"hookSpecificOutput": {' \
+ '"hookEventName": "PreToolUse",' \
+ '"permissionDecision": "deny",' \
+ '"systemMessage": "🚫 Git on main blocked from' \
+ ' worktree. Make changes in the worktree, then' \
+ ' use /merge-worktree."' \
+ '}}'
+ exit 0
+ fi
+ ;;
+
+ *"> $CLAUDE_PROJECT_DIR"*|*">> $CLAUDE_PROJECT_DIR"*|\
+ *"> ${CLAUDE_PROJECT_DIR}"*|*">> ${CLAUDE_PROJECT_DIR}"*)
+ # Redirect to main project — block unless it targets
+ # this worktree.
+ if ! $GUIX_BIN/echo "$command" \
+ | $GUIX_BIN/grep --fixed-strings --quiet "$worktree"
+ then
+ $GUIX_BIN/echo \
+ '{"hookSpecificOutput": {' \
+ '"hookEventName": "PreToolUse",' \
+ '"permissionDecision": "deny",' \
+ '"systemMessage": "🚫 Redirect blocked: target is' \
+ ' outside the worktree."' \
+ '}}'
+ exit 0
+ fi
+ ;;
+ esac
+ ;;
+esac
+
+# All checks passed.
+exit 0
# branch-summary — Summarize non-main branches as a markdown table.
# List all local branches except main.
-branches=$($HOME/.guix-home/profile/bin/git \
+branches=$(git \
-C "$CLAUDE_PROJECT_DIR" for-each-ref \
--format='%(refname:short)' refs/heads/ \
| $GUIX_BIN/grep -v '^main$')
# Print up to 3 commit subjects from branch $1 as a comma-separated
# list, escaped for markdown table use.
format_commits() {
- $HOME/.guix-home/profile/bin/git -C "$CLAUDE_PROJECT_DIR" log --oneline \
+ git -C "$CLAUDE_PROJECT_DIR" log --oneline \
--format='%s' main.."$1" 2>/dev/null \
| head -3 \
| $GUIX_BIN/sed 's/|/\\|/g' \
# One row per branch.
echo "$branches" | while read -r b
do
- count=$($HOME/.guix-home/profile/bin/git \
+ count=$(git \
-C "$CLAUDE_PROJECT_DIR" rev-list --count \
main.."$b" 2>/dev/null)
if [ -z "$count" ] || [ "$count" -eq 0 ]
# merge-worktree — Merge a worktree branch into main.
#
# This script operates on the main worktree (CLAUDE_PROJECT_DIR) as a
-# subprocess, independent of the session's current directory. It rebases
-# the branch for linear history then fast-forwards main.
+# subprocess, independent of the session's current directory. When the
+# branch is based on main it rebases for linear history then
+# fast-forwards. When the branch has diverged (e.g. after squashing)
+# it can reset main with --force.
#
-# Usage: merge-worktree <branch-name>
+# Usage: merge-worktree <branch-name> [--force]
set -eu
branch="${1:-}"
+force="${2:-}"
if [ -z "$branch" ]
then
- echo "Usage: merge-worktree <branch-name>" >&2
+ echo "Usage: merge-worktree <branch-name> [--force]" >&2
exit 1
fi
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
# Validate branch.
-if ! $HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" rev-parse \
+if ! git -C "$PROJECT_DIR" rev-parse \
--verify "$branch" >/dev/null 2>&1
then
echo "Error: Branch '$branch' does not exist." >&2
fi
# Check main worktree is clean.
-if [ -n "$($HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" \
+if [ -n "$(git -C "$PROJECT_DIR" \
status --porcelain --untracked-files=no)" ]
then
echo "Error: Main worktree has uncommitted changes." >&2
fi
# Ensure main is restored on any exit (rebase failure, interruption).
-trap '$HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" checkout main \
+trap 'git -C "$PROJECT_DIR" checkout main \
2>/dev/null || true' EXIT
# Fetch from origin.
echo "=== Fetching from origin ==="
-$HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" fetch origin
+git -C "$PROJECT_DIR" fetch origin
-# Rebase branch onto main.
-echo "=== Rebasing $branch onto main ==="
-if ! $HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" \
- rebase main "$branch"
+# Determine whether this is a normal fast-forward or a divergent
+# branch that needs a reset.
+if git -C "$PROJECT_DIR" merge-base --is-ancestor \
+ main "$branch" 2>/dev/null
then
- rc=$?
- echo "Error: Rebase failed (conflicts)." >&2
- echo " Resolve them in the main worktree:" >&2
- echo " cd $PROJECT_DIR" >&2
- echo " git status # see conflicted files" >&2
- echo " # fix conflicts, then:" >&2
- echo " git rebase --continue" >&2
- echo " git checkout main" >&2
- echo " git merge --ff-only $branch" >&2
- exit $rc
-fi
+ # ── Normal case: branch is based on main ──
-# Switch back to main.
-echo "=== Switching to main ==="
-$HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" checkout main
+ # Rebase branch onto main.
+ echo "=== Rebasing $branch onto main ==="
+ if ! git -C "$PROJECT_DIR" \
+ rebase main "$branch"
+ then
+ rc=$?
+ echo "Error: Rebase failed (conflicts)." >&2
+ echo " Resolve them in the main worktree:" >&2
+ echo " cd $PROJECT_DIR" >&2
+ echo " git status # see conflicted files" >&2
+ echo " # fix conflicts, then:" >&2
+ echo " git rebase --continue" >&2
+ echo " git checkout main" >&2
+ echo " git merge --ff-only $branch" >&2
+ exit $rc
+ fi
-# Fast-forward merge.
-echo "=== Merging $branch into main (fast-forward) ==="
-if ! $HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" \
- merge --ff-only "$branch"
-then
- rc=$?
- echo "Error: Fast-forward merge failed." >&2
- echo " This should not happen after a successful rebase." >&2
- echo " Try: git -C $PROJECT_DIR merge $branch" >&2
- exit $rc
-fi
+ # Switch back to main if needed.
+ current=$(git -C "$PROJECT_DIR" branch --show-current)
+ if [ "$current" != "main" ]
+ then
+ echo "=== Switching to main ==="
+ git -C "$PROJECT_DIR" checkout main
+ fi
-echo "=== Branch '$branch' successfully merged into main ==="
+ # Fast-forward merge.
+ echo "=== Merging $branch into main (fast-forward) ==="
+ if ! git -C "$PROJECT_DIR" \
+ merge --ff-only "$branch"
+ then
+ rc=$?
+ echo "Error: Fast-forward merge failed." >&2
+ echo " This should not happen after a successful rebase." >&2
+ echo " Try: git -C $PROJECT_DIR merge $branch" >&2
+ exit $rc
+ fi
+
+ echo "=== Branch '$branch' successfully merged into main ==="
+else
+ # ── Divergent case: branch was rewritten (e.g. squashed) ──
+
+ if [ "$force" = "--force" ]
+ then
+ echo "=== Resetting main to $branch ==="
+ current=$(git -C "$PROJECT_DIR" branch --show-current)
+ if [ "$current" != "main" ]
+ then
+ git -C "$PROJECT_DIR" checkout main
+ fi
+ git -C "$PROJECT_DIR" reset --hard "$branch"
+ echo "=== Main reset to '$branch' ==="
+ else
+ echo "Branch '$branch' has diverged from main." >&2
+ echo "Fast-forward merge is not possible." >&2
+ echo "Re-run with --force to reset main to this branch." >&2
+ exit 2
+ fi
+fi
--- /dev/null
+/home/dak/guix/.claude/skills
\ No newline at end of file
+++ /dev/null
----
-name: merge-worktree
-description: Merge a workspace branch into main and optionally reconfigure.
----
-
-# merge-worktree
-
-Merge a worktree branch into main, rebasing for linear history, then
-optionally run guix reconfigure.
-
-## Instructions
-
-When the user asks to merge a workspace, integrate branch changes, or
-"commit to main":
-
-1. **Show available workspaces**:
- Run `.claude/scripts/branch-summary` to display a markdown table of
- all non-main branches (workspaces) and their commits.
-
-2. **Ask which workspace to merge**:
- Use AskUserQuestion (single-select). Present the workspace branch
- names from step 1 as options. If only one non-main branch exists,
- ask to confirm merging that one.
-
-3. **Execute the merge**:
- Run `.claude/scripts/merge-worktree <selected-branch>`.
- If the merge fails (rebase conflict), instruct the user to resolve
- conflicts in the main worktree at `$CLAUDE_PROJECT_DIR`, then retry.
-
-4. **Ask about guix reconfigure**:
- Use AskUserQuestion (single-select) with these options:
- - "guix home reconfigure"
- - "guix system reconfigure"
- - "Skip reconfigure"
-
-5. **Run reconfigure if selected**:
- Run the chosen command using Bash against the main worktree.
-
-6. **Offer branch cleanup**:
- Use AskUserQuestion (single-select) asking whether to delete the
- merged branch. Options: "Delete branch and worktree", "Keep branch".
-
-## Notes
-
-- The merge script `cd`s to `$CLAUDE_PROJECT_DIR` (main worktree) as a
- subprocess, independent of the session's worktree directory.
-- After the merge, `ExitWorktree` becomes available since the branch is
- now an ancestor of main.