]> git.ekhem.eu.org Git - guix.git/commitdiff
[ai] Refine merge-worktree: shorter, tighter, DRY, fix detached-HEAD detection.
authorJakub Czajka <jakub@ekhem.eu.org>
Mon, 20 Jul 2026 15:41:41 +0000 (15:41 +0000)
committerJakub Czajka <jakub@ekhem.eu.org>
Fri, 24 Jul 2026 09:12:32 +0000 (09:12 +0000)
.claude/commands/merge-worktree.md
.claude/hooks/block-worktrees
.claude/scripts/merge-worktree

index c30a07d3d0ab0d347de9000fa811ef70909167d3..7634a9fb1507e483bfb0990889bbd627cf94025f 100644 (file)
@@ -5,70 +5,41 @@ 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.
+Merge the current worktree branch into main, rebasing for linear
+history.
 
 ## 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":
+This command must be invoked from within a worktree. 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.
+1. **Run merge**:
+   Execute `$CLAUDE_PROJECT_DIR/.claude/scripts/merge-worktree`. The
+   script infers the branch from the worktree path.
 
-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.
+   Handle its exit code:
 
-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):** If run with `--rebase`, the rebase completed —
+     tell the user and offer to re-run without flags to merge.
+     Otherwise the merge completed cleanly — proceed to step 2.
 
-   - **0 (success):** The merge completed cleanly. Proceed to step 5.
+   - **2 (error):** Read the script's stderr message. Always ask the
+     user whether to re-run with `--rebase` (preserves main's
+     commits) or `--force` (discards main's commits), but adjust the
+     question to the error:
 
-   - **1 (error):** Report the error to the user and stop.
+     - **Diverged:** explain that main has commits the branch lacks.
 
-   - **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.
+     - **Conflicts:** explain which files conflict and that
+       `--force` skips conflict checking, while `--rebase` will
+       surface conflicts for manual resolution.
 
-   - **3 (conflicts detected):** The script found merge conflicts.
-     Proceed to step 4.
+     - **Rebase failed:** explain that the rebase hit conflicts.
+       After the user resolves them and runs
+       `git rebase --continue`, re-run merge-worktree to merge.
 
-   If the worktree directory was deleted out-of-band, the script
-   auto-detects this and enters recovery mode (prunes stale
-   registration, deletes branch).
+     - For all other errors, report the message and stop.
 
-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
+2. **Return to the main project**:
+   Call `ExitWorktree` with `action: "keep"`.
index 2017fd7170e16b9abc61bf92e85d9ff9c72c8353..84889b1efe4ac32e7fb2033cdd1160d838404b1f 100755 (executable)
@@ -7,7 +7,7 @@
 # Matches all tool types (* — runs after create-worktree PWD gate).
 # Only active when already inside a worktree.
 
-if ! echo "$PWD" | grep -q '/worktrees/'
+if ! echo "$PWD" | sed --quiet '\|/worktrees/|q0;$q1'
 then
     exit 0
 fi
@@ -44,7 +44,7 @@ Write|Edit)
         | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')
     case "$path" in
         "$CLAUDE_PROJECT_DIR"*)
-            if ! echo "$path" | grep -Fq "$worktree"
+            if ! echo "$path" | sed --quiet "\|$worktree|q0;\$q1"
             then
                 die "Write blocked: target outside worktree."
             fi
@@ -61,16 +61,19 @@ Bash)
             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."
+            if ! echo "$cmd" \
+                | sed --quiet '/merge-worktree/q0;$q1'
+            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"
+            if echo "$cmd" | sed --quiet "\|$worktree|q0;\$q1"
             then : # references own worktree — allowed
-            elif echo "$cmd" | grep -q 'merge-worktree'
+            elif echo "$cmd" \
+                | sed --quiet '/merge-worktree/q0;$q1'
             then : # merge-worktree — allowed
             else die "Blocked: reference to main project."
             fi ;;
@@ -79,4 +82,4 @@ Bash)
 
 esac
 
-exit 0
\ No newline at end of file
+exit 0
index bc71ed0ac2c0ec5e0ecdb38ed9870749074b4841..3561f1e24597f14691170517a4a2bd511ad7efcb 100755 (executable)
 # 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
+# merge-worktree — Merge a worktree branch into main.
 
-set -eu
+set -o errexit -o nounset
 
 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
+# Absolute paths to Guix profile directories, avoiding $PATH lookup.
+GUIX_PROFILE="$HOME/.guix-profile"
+_last="${XDG_DATA_DIRS##*:}"
+SYSTEM_PROFILE="${_last%/share}"
 
-# ── Arguments ─────────────────────────────────────────
+die() { "$SYSTEM_PROFILE/bin/echo" "Error: $1" >&2; exit 2; }
 
-branch="${1:-}"
-flag="${2:-}"
+flag="${1:-}"
 
-if [ -z "$branch" ]
+if [ -n "$flag" ] && [ "$flag" != "--force" ] && [ "$flag" != "--rebase" ]
 then
-    echo "Usage: merge-worktree <branch> [--force] [--recover]" >&2
-    echo "       merge-worktree --list-stale" >&2
-    exit 1
+    die "Unknown argument: $flag"
 fi
 
-if [ "$branch" = "main" ]
+if [ $# -gt 1 ]
 then
-    echo "Error: Cannot merge main into itself." >&2
-    exit 1
+    die "Unexpected additional arguments: $*"
 fi
 
-# Validate branch.
-if ! git -C "$PROJECT_DIR" rev-parse \
-    --verify "$branch" >/dev/null 2>&1
+session_id=$(echo "$PWD" \
+    | "$SYSTEM_PROFILE/bin/sed" --quiet 's|.*/worktrees/\([^/]\{1,\}\).*|\1|p')
+if [ -z "$session_id" ]
 then
-    echo "Error: Branch '$branch' does not exist." >&2
-    exit 1
+    die "Must be run from inside a worktree."
 fi
 
-# Find worktree path.
-worktree_path=$(git -C "$PROJECT_DIR" worktree list \
-    | grep -F "[$branch]" | awk '{print $1}')
-
-# ── Explicit recovery ─────────────────────────────────
+branch="worktree-${session_id}"
 
-if [ "$flag" = "--recover" ]
+if [ -n "$("$GUIX_PROFILE/bin/git" -C "$PWD" status --porcelain)" ]
 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
+    die "Worktree has uncommitted changes. Commit or stash first."
 fi
 
-# ── Auto-detect: worktree directory missing ───────────
-
-if [ -z "$worktree_path" ] || [ ! -d "$worktree_path" ]
+# Main project must have no uncommitted changes.
+if [ -n "$("$GUIX_PROFILE/bin/git" -C "$PROJECT_DIR" status \
+    --porcelain --untracked-files=no)" ]
 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
+    die "Main worktree has uncommitted changes. Commit or stash first."
 fi
 
-# Check the worktree itself is clean.
-if [ -n "$(git -C "$worktree_path" status --porcelain)" ]
+if [ "$flag" = "--rebase" ]
 then
-    echo "Error: Worktree has uncommitted changes." >&2
-    echo "  Commit or stash them first, then re-run." >&2
-    exit 1
+    if ! "$GUIX_PROFILE/bin/git" rebase main
+    then
+        die "Rebase failed. Resolve conflicts in this worktree with" \
+            " git rebase --continue and re-run merge-worktree to" \
+            " merge into main."
+    fi
+    exit 0
 fi
 
-# Check main worktree is clean.
-if [ -n "$(git -C "$PROJECT_DIR" \
-    status --porcelain --untracked-files=no)" ]
+if [ "$flag" = "--force" ]
 then
-    echo "Error: Main worktree has uncommitted changes." >&2
-    echo "  Commit or stash them first." >&2
-    exit 1
+    "$GUIX_PROFILE/bin/git" -C "$PROJECT_DIR" checkout -B main "$branch" 2>/dev/null
+    exit 0
 fi
 
-# Ensure main is restored on any exit (rebase failure,
-# interruption).
-trap 'git -C "$PROJECT_DIR" checkout main \
-    2>/dev/null || true' EXIT
+# Restore main checkout on exit for safety.
+trap '"$GUIX_PROFILE/bin/git" -C "$PROJECT_DIR" checkout main 2>/dev/null' 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 \
+if ! "$GUIX_PROFILE/bin/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 ──
+    die "Branch '$branch' diverged from main." \
+        " Re-run with --rebase or --force."
+fi
 
-    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
+conflicts=$("$GUIX_PROFILE/bin/git" -C "$PROJECT_DIR" merge-tree \
+    --write-tree main "$branch" 2>&1 \
+    | "$SYSTEM_PROFILE/bin/sed" --quiet \
+        's/^[0-7]\{6\} [0-9a-f]\{40\} [123] //p' \
+    | "$SYSTEM_PROFILE/bin/sort" --unique)
+if [ -n "$conflicts" ]
+then
+    die "Conflicts detected: $conflicts Re-run with --force to override."
 fi
 
-echo "=== Branch '$branch' merged into main. ==="
-echo "=== Use ExitWorktree to return to main. ==="
\ No newline at end of file
+
+"$GUIX_PROFILE/bin/git" -C "$PROJECT_DIR" checkout -B main "$branch"