Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<default-branch>`, 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
Expand Down
71 changes: 64 additions & 7 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <src-abs> <target-abs> <src-label>
# 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
Expand Down Expand Up @@ -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 ""
Expand All @@ -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
234 changes: 234 additions & 0 deletions wtutils/enterworktree-reconcile.sh
Original file line number Diff line number Diff line change
@@ -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
# <repo>/.claude/worktrees/<name> on a new branch, based on its own
# `worktree.baseRef` setting (default `fresh` = origin/<default-branch>, 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/<default-branch> (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": "<new worktree path>", ... }
# 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/<default-branch> (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/<default> 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/<default-branch>
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: <repo>/.claude/worktrees/<name>.
# 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