From 4849e70641b21554d5107766d18e8096d0cef1e5 Mon Sep 17 00:00:00 2001 From: Jakub Czajka Date: Mon, 6 Jul 2026 11:24:17 +0200 Subject: [PATCH] [ai] Add merge workflow: branch-summary, merge script, and skill doc. --- .claude/commands/merge-worktree.md | 74 +++++++++ .claude/hooks/block-worktrees | 82 ++++++++++ .claude/hooks/create-worktree | 36 +++++ .claude/scripts/merge-worktree | 237 +++++++++++++++++++++++++++++ .claude/settings.json | 25 ++- 5 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/merge-worktree.md create mode 100755 .claude/hooks/block-worktrees create mode 100755 .claude/hooks/create-worktree create mode 100755 .claude/scripts/merge-worktree diff --git a/.claude/commands/merge-worktree.md b/.claude/commands/merge-worktree.md new file mode 100644 index 0000000..c30a07d --- /dev/null +++ b/.claude/commands/merge-worktree.md @@ -0,0 +1,74 @@ +--- +name: merge-worktree +description: Merge a workspace branch into main. +--- + +# merge-worktree + +Merge a worktree branch into main, rebasing for linear history. Can +also recover from a deleted worktree. + +## Instructions + +This command must be invoked from within a worktree (the pre-tool-use +hooks enforce this). When the user asks to merge a workspace, +integrate branch changes, or "commit to main": + +1. **Show available workspaces**: + Run `$CLAUDE_PROJECT_DIR/.claude/scripts/branch-summary` to display + a markdown table of all non-main branches 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_PROJECT_DIR/.claude/scripts/merge-worktree + `. The script checks for conflicts without + touching the working tree. Handle its exit code: + + - **0 (success):** The merge completed cleanly. Proceed to step 5. + + - **1 (error):** Report the error to the user and stop. + + - **2 (diverged):** The branch has diverged from main, so + fast-forward is not possible. Ask the user with a single-select + AskUserQuestion: "Branch has diverged from main. Force-reset + main to ?" Present "Force reset" and "Skip" as options. + If they choose "Force reset", re-run the merge-worktree script + with `--force` and proceed to step 5. If they choose "Skip", stop. + + - **3 (conflicts detected):** The script found merge conflicts. + Proceed to step 4. + + If the worktree directory was deleted out-of-band, the script + auto-detects this and enters recovery mode (prunes stale + registration, deletes branch). + +4. **Resolve merge conflicts**: + The script detected that a merge would cause conflicts. Use + AskUserQuestion (single-select) with three options: + + - **"Force reset (override)":** Re-run the merge-worktree script + as `merge-worktree --force` to reset main to + the branch state. After it succeeds, proceed to step 5. + + - **"Agent resolves":** In the main worktree at + `$CLAUDE_PROJECT_DIR`, merge the branch manually: + 1. `git checkout main` + 2. `git merge ` + 3. Resolve all conflicts, then commit. + After merging, proceed to step 5. + + - **"Resolve manually":** Instruct the user to resolve conflicts + in the main worktree at `$CLAUDE_PROJECT_DIR`: + 1. `git checkout main` + 2. `git merge ` + 3. Fix conflicts and commit. + After they confirm, proceed to step 5. + +5. **Return to the main project directory**: + Call the `ExitWorktree` tool with `action: "keep"`. This restores + the session's PWD to the main project directory while preserving + the worktree on disk for later manual cleanup. \ No newline at end of file diff --git a/.claude/hooks/block-worktrees b/.claude/hooks/block-worktrees new file mode 100755 index 0000000..2017fd7 --- /dev/null +++ b/.claude/hooks/block-worktrees @@ -0,0 +1,82 @@ +#!/bin/sh +# Copyright (c) 2026 Jakub Czajka +# License: GPL-3.0 or later. +# +# block-worktrees — Enforce worktree isolation. +# +# Matches all tool types (* — runs after create-worktree PWD gate). +# Only active when already inside a worktree. + +if ! echo "$PWD" | grep -q '/worktrees/' +then + exit 0 +fi + +stdin=$(cat) +tool=$(echo "$stdin" \ + | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p') +worktree="$CLAUDE_PROJECT_DIR/.claude/worktrees/$CLAUDE_CODE_SESSION_ID" + +die() { + echo \ + '{"hookSpecificOutput":{' \ + '"permissionDecision":"deny",' \ + '"permissionDecisionReason":"'"$1"'"}}' + exit 0 +} + +case "$tool" in +EnterWorktree) + die "Already in a worktree." ;; + +ExitWorktree) + action=$(echo "$stdin" \ + | sed -n 's/.*"action"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p') + if [ "$action" = "remove" ] + then + die "Worktree deletion is disabled. Use" \ + "ExitWorktree with action: keep instead." + fi + ;; + +Write|Edit) + path=$(echo "$stdin" \ + | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p') + case "$path" in + "$CLAUDE_PROJECT_DIR"*) + if ! echo "$path" | grep -Fq "$worktree" + then + die "Write blocked: target outside worktree." + fi + ;; + esac + ;; + +Bash) + cmd=$(echo "$stdin" \ + | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p') + + case "$cmd" in + *"git checkout"*|*"git switch"*) + die "Branch switching disabled." ;; + + *"git worktree remove"*|*"git worktree prune"*) + if ! echo "$cmd" | grep -q 'merge-worktree' + then die "Git on main blocked. Use /merge-worktree." + fi ;; + + *"$CLAUDE_PROJECT_DIR"*|\ + *'$CLAUDE_PROJECT_DIR'*|\ + *'${CLAUDE_PROJECT_DIR}'*) + if echo "$cmd" | grep -Fq "$worktree" + then : # references own worktree — allowed + elif echo "$cmd" | grep -q 'merge-worktree' + then : # merge-worktree — allowed + else die "Blocked: reference to main project." + fi ;; + esac + ;; + +esac + +exit 0 \ No newline at end of file diff --git a/.claude/hooks/create-worktree b/.claude/hooks/create-worktree new file mode 100755 index 0000000..06cefb7 --- /dev/null +++ b/.claude/hooks/create-worktree @@ -0,0 +1,36 @@ +#!/bin/sh +# Copyright (c) 2026 Jakub Czajka +# License: GPL-3.0 or later. +# +# create-worktree — PWD gate: force entry into a worktree. +# +# Matches all tool types (*). Denies every tool call (except +# EnterWorktree) unless already in this session's worktree. + +stdin=$(cat) + +# Exempt EnterWorktree. +if echo "$stdin" \ + | grep -q '"tool_name"[[:space:]]*:[[:space:]]*"EnterWorktree"' +then + exit 0 +fi + +# Deny if not in this session's worktree. +if ! echo "$PWD" | grep -q "/worktrees/${CLAUDE_CODE_SESSION_ID}" +then + 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 + +exit 0 \ No newline at end of file diff --git a/.claude/scripts/merge-worktree b/.claude/scripts/merge-worktree new file mode 100755 index 0000000..dfd3496 --- /dev/null +++ b/.claude/scripts/merge-worktree @@ -0,0 +1,237 @@ +#!/bin/sh +# Copyright (c) 2026 Jakub Czajka +# License: GPL-3.0 or later. +# +# merge-worktree — Merge a worktree branch into main, or recover +# from a deleted worktree. +# +# This script operates on the main worktree (CLAUDE_PROJECT_DIR) as a +# 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. +# +# The script does NOT remove the worktree or branch — that is handled +# by ExitWorktree (called by the merge-worktree skill after the merge). +# A subprocess cannot change the parent session's PWD, so letting the +# harness handle cleanup ensures the session returns to the main +# project directory. +# +# Usage: +# merge-worktree [--force] # normal merge +# merge-worktree --recover # cleanup after deletion +# merge-worktree --list-stale # list orphaned worktrees +# +# Exit codes: +# 0 — success +# 1 — error (validation, unclean worktree, rebase failure, etc.) +# 2 — branch diverged from main, needs --force +# 3 — merge conflicts detected, needs --force or manual resolution + +set -eu + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +# ── --list-stale ─────────────────────────────────────── + +if [ "${1:-}" = "--list-stale" ] +then + echo "=== Orphaned worktree branches ===" + found=0 + for b in $(git -C "$PROJECT_DIR" branch \ + | sed 's/^[* ]*//' | grep '^worktree-'); do + wt_path=$(git -C "$PROJECT_DIR" worktree list \ + | grep -F "[$b]" | awk '{print $1}' || true) + if [ -z "$wt_path" ] || [ ! -d "$wt_path" ] + then + echo " $b" + found=1 + fi + done + if [ "$found" -eq 0 ] + then + echo " (none)" + fi + exit 0 +fi + +# ── Arguments ───────────────────────────────────────── + +branch="${1:-}" +flag="${2:-}" + +if [ -z "$branch" ] +then + echo "Usage: merge-worktree [--force] [--recover]" >&2 + echo " merge-worktree --list-stale" >&2 + exit 1 +fi + +if [ "$branch" = "main" ]; then + echo "Error: Cannot merge main into itself." >&2 + exit 1 +fi + +# Validate branch. +if ! git -C "$PROJECT_DIR" rev-parse \ + --verify "$branch" >/dev/null 2>&1 +then + echo "Error: Branch '$branch' does not exist." >&2 + exit 1 +fi + +# Find worktree path. +worktree_path=$(git -C "$PROJECT_DIR" worktree list \ + | grep -F "[$branch]" | awk '{print $1}') + +# ── Explicit recovery ───────────────────────────────── + +if [ "$flag" = "--recover" ] +then + echo "=== Recovery: worktree '$branch' ===" + git -C "$PROJECT_DIR" worktree prune 2>/dev/null || true + git -C "$PROJECT_DIR" branch -D "$branch" 2>/dev/null || true + echo "=== Recovery complete: '$branch' cleaned up ===" + exit 0 +fi + +# ── Auto-detect: worktree directory missing ─────────── + +if [ -z "$worktree_path" ] || [ ! -d "$worktree_path" ] +then + echo "=== Worktree directory missing — entering recovery mode ===" + git -C "$PROJECT_DIR" worktree prune 2>/dev/null || true + git -C "$PROJECT_DIR" branch -D "$branch" 2>/dev/null || true + echo "=== Recovery complete: '$branch' cleaned up ===" + exit 0 +fi + +# Check the worktree itself is clean. +if [ -n "$(git -C "$worktree_path" status --porcelain)" ] +then + echo "Error: Worktree has uncommitted changes." >&2 + echo " Commit or stash them first, then re-run." >&2 + exit 1 +fi + +# Check main worktree is clean. +if [ -n "$(git -C "$PROJECT_DIR" \ + status --porcelain --untracked-files=no)" ] +then + echo "Error: Main worktree has uncommitted changes." >&2 + echo " Commit or stash them first." >&2 + exit 1 +fi + +# Ensure main is restored on any exit (rebase failure, +# interruption). +trap 'git -C "$PROJECT_DIR" checkout main \ + 2>/dev/null || true' EXIT + +# Fetch from origin. +echo "=== Fetching from origin ===" +git -C "$PROJECT_DIR" fetch origin + +# 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 + # ── Normal case: branch is based on main ── + + # ── Conflict pre-check ── + echo "=== Checking for merge conflicts ===" + merge_result=$(git -C "$PROJECT_DIR" merge-tree \ + --write-tree main "$branch" 2>&1) + conflict_files=$(echo "$merge_result" \ + | grep -E '^[0-7]{6} [0-9a-f]{40} [123] ' \ + | awk '{print $4}' | sort -u) + if [ -n "$conflict_files" ] + then + echo "Conflicts detected between main and '$branch'." >&2 + echo "Conflicting files:" >&2 + echo "$conflict_files" >&2 + if [ "$flag" = "--force" ] + then + echo "=== Resetting main to '$branch' (override) ===" + 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" + trap '' EXIT + echo "=== Main reset to '$branch' ===" + exit 0 + else + echo "Re-run with --force to reset main or resolve" \ + "manually." >&2 + exit 3 + fi + fi + + # 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 + + # 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 + + # 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 ── + + if [ "$flag" = "--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" + trap '' EXIT + 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 + +echo "=== Branch '$branch' merged into main. ===" +echo "=== Use ExitWorktree to return to main. ===" \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json index 2c30fb5..3366007 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,12 +9,35 @@ }, "hooks": { "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/create-worktree" + }, + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-worktrees" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-builds" + } + ] + }, { "matcher": "Bash", + "if": "Bash(make *)", "hooks": [ { "type": "command", - "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-commands" + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-builds" } ] } -- 2.47.3