From 7c285f29a4888774ff5fbec23698178ac0f07b34 Mon Sep 17 00:00:00 2001 From: Jakub Czajka Date: Mon, 20 Jul 2026 15:41:41 +0000 Subject: [PATCH] [ai] Refine merge-worktree: shorter, tighter, DRY, fix detached-HEAD detection. --- .claude/commands/merge-worktree.md | 81 +++------- .claude/hooks/block-worktrees | 17 +- .claude/scripts/merge-worktree | 249 ++++++----------------------- 3 files changed, 86 insertions(+), 261 deletions(-) diff --git a/.claude/commands/merge-worktree.md b/.claude/commands/merge-worktree.md index c30a07d..7634a9f 100644 --- a/.claude/commands/merge-worktree.md +++ b/.claude/commands/merge-worktree.md @@ -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 - `. 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 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. + - **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 --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 +2. **Return to the main project**: + Call `ExitWorktree` with `action: "keep"`. diff --git a/.claude/hooks/block-worktrees b/.claude/hooks/block-worktrees index 2017fd7..84889b1 100755 --- a/.claude/hooks/block-worktrees +++ b/.claude/hooks/block-worktrees @@ -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 diff --git a/.claude/scripts/merge-worktree b/.claude/scripts/merge-worktree index bc71ed0..3561f1e 100755 --- a/.claude/scripts/merge-worktree +++ b/.claude/scripts/merge-worktree @@ -2,237 +2,88 @@ # 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 +# 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 [--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" -- 2.47.3