diff --git a/README.md b/README.md index 0a6119c..888bc01 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,32 @@ wtutils-init # interactive setup (requires gum) Per-project configuration via `.claude/chamber.config` (JSON). Run `wtutils-init` to set up interactively. +### EnterWorktree reconcile hook + +Claude Code's built-in **`EnterWorktree`** tool creates worktrees from its own +`worktree.baseRef` (default `fresh` = `origin/`, usually `main`). +It does **not** consult `chamber.config`, so in repos whose integration branch +is not the default branch (e.g. `default_base: origin/dev`), every +`EnterWorktree` branch is based on `main` — polluting PRs with `dev→main` +promotion merges — and the chamber post-create `hooks` (e.g. `pnpm install`) +never run. + +`wtutils/enterworktree-reconcile.sh` is a **PostToolUse hook** on `EnterWorktree` +that fixes this. After the tool creates the worktree it: + +1. **Re-bases** the new branch onto `chamber.config` `default_base` — but + **only** when the worktree is a freshly-created, *empty* one (clean tree, no + commits of its own beyond the base it was cut from). If the worktree has any + uncommitted changes or its own commits, the hook refuses to touch history and + logs a skip reason. It never clobbers work. +2. Runs the chamber post-create `hooks` and applies `symlink_files` / + `symlink_dirs` / beads redirect (reusing `wt--setup-worktree`). + +`install.sh` symlinks the hook into `~/.claude/hooks/` and prints the +`settings.json` snippet needed to register it (the repo does not edit +`settings.json` itself). Activity is logged to +`~/.claude/hooks/enterworktree-reconcile.log`. + ## Install ### Standalone diff --git a/install.sh b/install.sh index 58055ab..2deb4b0 100755 --- a/install.sh +++ b/install.sh @@ -13,6 +13,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTALL_DIR="$HOME/.claude/scripts" +HOOKS_DIR="$HOME/.claude/hooks" +SETTINGS_FILE="$HOME/.claude/settings.json" # --- Options --- dry_run=false @@ -32,28 +34,38 @@ for arg in "$@"; do esac done -# --- Symlink map: source → target --- +# --- Symlink map: source → target (relative to INSTALL_DIR) --- declare -a LINKS=( "claudegrep/claudegrep:claudegrep" "wtutils/wtutils.sh:wtutils.sh" "wtutils/init.sh:wtutils-init" ) +# --- Hook symlink map: source → target (relative to HOOKS_DIR) --- +# Hooks are wired into ~/.claude/settings.json (see the EnterWorktree note at +# the end of this script). The repo does not manage settings.json itself. +declare -a HOOK_LINKS=( + "wtutils/enterworktree-reconcile.sh:enterworktree-reconcile.sh" +) + # --- Install --- mkdir -p "$INSTALL_DIR" +mkdir -p "$HOOKS_DIR" installed=0 skipped=0 errors=0 -for entry in "${LINKS[@]}"; do - src="${SCRIPT_DIR}/${entry%%:*}" - target="${INSTALL_DIR}/${entry##*:}" +# install_link +# Symlinks src→target with the same update/force/skip semantics for both the +# scripts dir and the hooks dir. +install_link() { + local src="$1" target="$2" label="$3" if [ ! -f "$src" ]; then - echo " SKIP ${entry%%:*} (source not found)" + echo " SKIP $label (source not found)" ((skipped++)) || true - continue + return fi if [ -e "$target" ] || [ -L "$target" ]; then @@ -90,12 +102,21 @@ for entry in "${LINKS[@]}"; do fi ((installed++)) || true fi +} + +for entry in "${LINKS[@]}"; do + install_link "${SCRIPT_DIR}/${entry%%:*}" "${INSTALL_DIR}/${entry##*:}" "${entry%%:*}" +done + +for entry in "${HOOK_LINKS[@]}"; do + install_link "${SCRIPT_DIR}/${entry%%:*}" "${HOOKS_DIR}/${entry##*:}" "${entry%%:*}" done -# Make claudegrep executable +# Make executables executable if ! $dry_run; then chmod +x "$SCRIPT_DIR/claudegrep/claudegrep" chmod +x "$SCRIPT_DIR/wtutils/init.sh" + chmod +x "$SCRIPT_DIR/wtutils/enterworktree-reconcile.sh" fi echo "" @@ -108,3 +129,39 @@ else echo " which claudegrep # should be $INSTALL_DIR/claudegrep" echo " claudegrep --help" fi + +# --- settings.json: EnterWorktree reconcile hook registration --------------- +# The reconcile hook only fires if it's registered as a PostToolUse hook on the +# EnterWorktree tool in ~/.claude/settings.json. We do NOT edit settings.json +# automatically (it holds personal config); instead we detect and instruct. +echo "" +registered=false +if [ -f "$SETTINGS_FILE" ] && grep -q "enterworktree-reconcile.sh" "$SETTINGS_FILE" 2>/dev/null; then + registered=true +fi + +if $registered; then + echo "settings.json: EnterWorktree reconcile hook is registered. ✓" +else + cat <<'EOF' +ACTION REQUIRED — register the EnterWorktree reconcile hook in +~/.claude/settings.json so it honors chamber.config default_base + hooks. + +Add this object to .hooks.PostToolUse (alongside any existing EnterWorktree +entry; it can share the matcher or be its own object): + + { + "matcher": "EnterWorktree", + "hooks": [ + { + "type": "command", + "command": "bash ~/.claude/hooks/enterworktree-reconcile.sh" + } + ] + } + +If you already have a PostToolUse object with matcher "EnterWorktree|ExitWorktree" +(e.g. running worktree-cwd-notify.sh), you can instead append the reconcile +command to that object's "hooks" array — order does not matter. +EOF +fi diff --git a/wtutils/enterworktree-reconcile.sh b/wtutils/enterworktree-reconcile.sh new file mode 100755 index 0000000..bbe21c8 --- /dev/null +++ b/wtutils/enterworktree-reconcile.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# enterworktree-reconcile.sh — Claude Code PostToolUse hook for EnterWorktree +# +# WHY THIS EXISTS +# --------------- +# Claude Code's built-in `EnterWorktree` tool creates a git worktree under +# /.claude/worktrees/ on a new branch, based on its own +# `worktree.baseRef` setting (default `fresh` = origin/, i.e. +# usually `main`). It does NOT consult the per-repo `.claude/chamber.config` +# `default_base`, and it does NOT run the chamber post-create `hooks` or apply +# `symlink_files`. The `wt-add` shell path (wtutils.sh) honors all of these; the +# built-in tool does not. So in repos whose integration branch is NOT the +# default branch (e.g. manabot-alpha: default_base = origin/dev, PRs target +# dev), every EnterWorktree branch is based on `main`, polluting PRs with the +# dev->main promotion merges and skipping dependency install. +# +# WHAT THIS DOES +# -------------- +# Runs as a PostToolUse hook on the EnterWorktree tool. After the tool creates +# the worktree it: +# 1. Re-bases the brand-new branch onto chamber.config `default_base` +# (e.g. origin/dev) IF — and only if — the branch is a freshly-created, +# empty worktree that was based on the wrong base. (Safety, see below.) +# 2. Runs the chamber post-create `hooks` + applies `symlink_files` / +# `symlink_dirs` / beads redirect, by reusing wtutils.sh's +# `wt--setup-worktree`. +# +# SAFETY (this runs on EVERY EnterWorktree create) +# ------------------------------------------------ +# The re-base step is gated hard. It only proceeds when ALL hold: +# * chamber.config declares a `default_base` +# * the worktree tree is CLEAN (no staged/unstaged/untracked changes) +# * HEAD has NO commits of its own beyond the base it was created from — i.e. +# HEAD is reachable from origin/ (the "wrong base"), so +# resetting it cannot lose any work +# * default_base resolves to a real commit that differs from HEAD +# If any check fails we DO NOT touch history — we still run setup hooks (always +# safe / idempotent) and log a skip reason. We use `git reset --hard`, but ONLY +# after proving HEAD has no unique commits and the tree is clean, so it can +# never discard work. +# +# INPUT (stdin JSON, standard Claude Code PostToolUse payload): +# { "tool_name": "EnterWorktree", "cwd": "", ... } +# The `cwd` is the worktree Claude switched into (the just-created one). +# +# This hook must NEVER block Claude. All paths exit 0; failures are logged. + +set -u +trap 'exit 0' EXIT + +LOG="${CHAMBER_RECONCILE_LOG:-$HOME/.claude/hooks/enterworktree-reconcile.log}" +log() { printf '%s [reconcile] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*" >>"$LOG" 2>/dev/null || true; } + +# --- Worktree cleanliness: no staged, unstaged, or untracked changes ------- +reconcile_tree_is_clean() { + local dir="$1" + # --porcelain output is empty iff nothing is staged/unstaged/untracked. + [ -z "$(git -C "$dir" status --porcelain 2>/dev/null)" ] +} + +# --- The safety-gated re-base ---------------------------------------------- +# Re-point a freshly-created, empty worktree branch onto $default_base. +# Returns silently (logs) without touching history if any safety gate fails. +reconcile_rebase() { + local wt="$1" repo_root="$2" default_base="$3" + + # Resolve default_base (e.g. "origin/dev") to a commit. If we can't, try a + # fetch (best-effort; offline must not break the hook), then resolve again. + local target_sha + target_sha=$(git -C "$wt" rev-parse --verify --quiet "${default_base}^{commit}" 2>/dev/null) + if [ -z "$target_sha" ]; then + local remote="${default_base%%/*}" branch="${default_base#*/}" + if [ "$remote" != "$default_base" ]; then + git -C "$wt" fetch --quiet "$remote" "$branch" 2>/dev/null || true + fi + target_sha=$(git -C "$wt" rev-parse --verify --quiet "${default_base}^{commit}" 2>/dev/null) + fi + if [ -z "$target_sha" ]; then + log "default_base '$default_base' does not resolve to a commit; skipping rebase. wt='$wt'" + return 0 + fi + + local head_sha + head_sha=$(git -C "$wt" rev-parse --verify --quiet HEAD 2>/dev/null) + if [ -z "$head_sha" ]; then + log "cannot resolve HEAD; skipping rebase. wt='$wt'" + return 0 + fi + + # Already on/under default_base? Then EnterWorktree happened to pick the right + # base (or a later one). Nothing to do — and never move a branch BACKWARD. + if [ "$head_sha" = "$target_sha" ]; then + log "HEAD already at default_base ($default_base); no rebase needed. wt='$wt'" + return 0 + fi + if git -C "$wt" merge-base --is-ancestor "$head_sha" "$target_sha" 2>/dev/null; then + log "HEAD is ancestor of default_base; fast-forwarding to $default_base. wt='$wt'" + fi + + # SAFETY GATE 1: tree must be pristine. + if ! reconcile_tree_is_clean "$wt"; then + log "WORKTREE DIRTY — refusing to rebase, work present. wt='$wt'" + return 0 + fi + + # SAFETY GATE 2: HEAD must have NO unique commits — it must be reachable from + # the base EnterWorktree created it from. We don't know that base name for + # certain, so we prove the stronger, safe condition: HEAD has no commits that + # are absent from origin/ (the tool's base universe). If HEAD + # is an ancestor of origin/HEAD's branch tip, resetting loses nothing. + # + # Determine origin's default branch (what `fresh` bases on): origin/HEAD. + local origin_head wrong_base_sha + origin_head=$(git -C "$repo_root" symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null) + origin_head="${origin_head##refs/remotes/}" # e.g. origin/main + if [ -n "$origin_head" ]; then + wrong_base_sha=$(git -C "$wt" rev-parse --verify --quiet "${origin_head}^{commit}" 2>/dev/null) + fi + + # The branch is "empty/fresh" if HEAD is an ancestor of (or equal to) the + # base it was cut from. Accept either: ancestor of origin/ OR + # ancestor of default_base (the latter already returned above). If HEAD is + # NOT an ancestor of the wrong base, it carries unique commits -> bail. + if [ -n "${wrong_base_sha:-}" ]; then + if [ "$head_sha" = "$wrong_base_sha" ] || \ + git -C "$wt" merge-base --is-ancestor "$head_sha" "$wrong_base_sha" 2>/dev/null; then + : # safe: HEAD has no commits beyond origin/ + else + log "HEAD carries commits beyond origin default ($origin_head) — has work; refusing rebase. wt='$wt'" + return 0 + fi + else + # Couldn't determine origin/HEAD. Be conservative: only proceed if HEAD has + # zero commits relative to default_base's merge-base (i.e. no unique work + # vs default_base). Count commits unique to HEAD vs default_base. + local ahead + ahead=$(git -C "$wt" rev-list --count "${target_sha}..${head_sha}" 2>/dev/null || echo "?") + if [ "$ahead" != "0" ]; then + log "cannot confirm origin default and HEAD is $ahead commit(s) ahead of default_base; refusing rebase. wt='$wt'" + return 0 + fi + fi + + # All gates passed. Re-point the branch (and worktree) at default_base. + local branch_name + branch_name=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null) + log "REBASE: '$branch_name' ${head_sha:0:8} -> $default_base ${target_sha:0:8} (clean, no unique commits). wt='$wt'" + if git -C "$wt" reset --hard "$target_sha" >>"$LOG" 2>&1; then + log "REBASE ok: '$branch_name' now at $default_base ($target_sha)." + else + log "REBASE FAILED for '$branch_name'; left as-is." + fi + return 0 +} + +# --- Read hook input ------------------------------------------------------- +input=$(cat 2>/dev/null || true) + +tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null) +[ "$tool_name" = "EnterWorktree" ] || exit 0 + +wt=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) +if [ -z "$wt" ] || [ ! -d "$wt" ]; then + log "no usable cwd in payload; skipping (cwd='$wt')" + exit 0 +fi + +# Only act on worktrees the built-in tool creates: /.claude/worktrees/. +# This avoids touching `path:`-entered existing worktrees or anything unexpected. +is_fresh_location=1 +case "$wt" in + */.claude/worktrees/*) ;; + *) + is_fresh_location=0 + log "cwd not under .claude/worktrees ('$wt'); setup only, no rebase" + ;; +esac + +# Resolve to a real git worktree. +if ! git -C "$wt" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + log "cwd is not a git work tree ('$wt'); skipping" + exit 0 +fi + +# The MAIN repo root, so we read chamber.config from the canonical repo. +repo_root=$(git -C "$wt" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}') +if [ -z "$repo_root" ] || [ ! -d "$repo_root" ]; then + repo_root=$(git -C "$wt" rev-parse --show-toplevel 2>/dev/null) +fi +if [ -z "$repo_root" ] || [ ! -d "$repo_root" ]; then + log "could not resolve repo root from '$wt'; skipping" + exit 0 +fi + +# --- Source wtutils.sh for config reading + shared setup ------------------- +WTUTILS="${WTUTILS_PATH:-$HOME/.claude/scripts/wtutils.sh}" +if [ ! -f "$WTUTILS" ]; then + log "wtutils.sh not found at '$WTUTILS'; cannot read chamber.config; skipping" + exit 0 +fi +# shellcheck source=/dev/null +source "$WTUTILS" 2>/dev/null || { log "failed to source wtutils.sh; skipping"; exit 0; } + +config_file="$repo_root/.claude/chamber.config" +if [ ! -f "$config_file" ]; then + log "no chamber.config at '$config_file'; nothing to honor; skipping" + exit 0 +fi + +# ============================================================================= +# STEP 1 — Re-base the fresh branch onto default_base (SAFELY) +# ============================================================================= +if [ "$is_fresh_location" -eq 1 ]; then + default_base=$(wt--config-read "$repo_root" "default_base" "") + if [ -z "$default_base" ]; then + log "no default_base in chamber.config; skipping rebase (setup only). wt='$wt'" + else + reconcile_rebase "$wt" "$repo_root" "$default_base" + fi +fi + +# ============================================================================= +# STEP 2 — Run chamber setup (symlinks + beads + claude proj dir + hooks) +# These are idempotent/additive and always safe to (re)run. +# ============================================================================= +if declare -f wt--setup-worktree >/dev/null 2>&1; then + log "running chamber setup for '$wt'" + # wt--setup-worktree prints progress to stdout; redirect into the log. + wt--setup-worktree "$repo_root" "$wt" >>"$LOG" 2>&1 || log "wt--setup-worktree returned non-zero (continuing)" +else + log "wt--setup-worktree not available; skipping setup" +fi + +exit 0