]> git.ekhem.eu.org Git - guix.git/commitdiff
[ai] Add merge workflow: branch-summary, merge script, and skill doc.
authorJakub Czajka <jakub@ekhem.eu.org>
Mon, 6 Jul 2026 09:24:17 +0000 (11:24 +0200)
committerJakub Czajka <jakub@ekhem.eu.org>
Fri, 17 Jul 2026 13:47:16 +0000 (13:47 +0000)
.claude/commands/merge-worktree.md [new file with mode: 0644]
.claude/hooks/block-worktrees [new file with mode: 0755]
.claude/hooks/create-worktree [new file with mode: 0755]
.claude/scripts/merge-worktree [new file with mode: 0755]
.claude/settings.json

diff --git a/.claude/commands/merge-worktree.md b/.claude/commands/merge-worktree.md
new file mode 100644 (file)
index 0000000..c30a07d
--- /dev/null
@@ -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
+   <selected-branch>`.  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 <name> has diverged from main. Force-reset
+     main to <name>?" 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 <selected-branch> --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 <selected-branch>`
+     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 <selected-branch>`
+     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 (executable)
index 0000000..2017fd7
--- /dev/null
@@ -0,0 +1,82 @@
+#!/bin/sh
+# Copyright (c) 2026 Jakub Czajka <jakub@ekhem.eu.org>
+# 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 (executable)
index 0000000..06cefb7
--- /dev/null
@@ -0,0 +1,36 @@
+#!/bin/sh
+# Copyright (c) 2026 Jakub Czajka <jakub@ekhem.eu.org>
+# 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 (executable)
index 0000000..dfd3496
--- /dev/null
@@ -0,0 +1,237 @@
+#!/bin/sh
+# Copyright (c) 2026 Jakub Czajka <jakub@ekhem.eu.org>
+# 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 <branch-name> [--force]    # normal merge
+#   merge-worktree <branch-name> --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 <branch> [--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
index 2c30fb5718c8dc291cacdd5b1b210dad7d384548..3366007a4d0684e28c8aa0ea5769bc5ab15ce44c 100644 (file)
@@ -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"
           }
         ]
       }