Merge pull request #76 from flyingrobots/main

fix: Add automatic worktree support for Bash tool directory reset issue
This commit is contained in:
Ran Aroussi
2025-08-28 16:08:47 +01:00
committed by GitHub
3 changed files with 331 additions and 0 deletions

130
.claude/hooks/README.md Normal file
View File

@@ -0,0 +1,130 @@
# Claude Hooks Configuration
## Bash Worktree Fix Hook
This hook automatically fixes the Bash tool's directory reset issue when working in git worktrees.
### Problem
The Bash tool resets to the main project directory after every command, making it impossible to work in worktrees without manually prefixing every command with `cd /path/to/worktree &&`.
### Solution
The pre-tool-use hook automatically detects when you're in a worktree and injects the necessary `cd` prefix to all Bash commands.
### How It Works
1. **Detection**: Before any Bash command executes, the hook checks if `.git` is a file (worktree) or directory (main repo)
2. **Injection**: If in a worktree, prepends `cd /absolute/path/to/worktree && ` to the command
3. **Transparency**: Agents don't need to know about this - it happens automatically
### Configuration
Add to your `.claude/settings.json`:
```json
{
"hooks": {
"pre-tool-use": {
"Bash": {
"enabled": true,
"script": ".claude/hooks/bash-worktree-fix.sh",
"apply_to_subagents": true
}
}
}
}
```
### Testing
To test the hook:
```bash
# Enable debug mode
export CLAUDE_HOOK_DEBUG=true
# Test in main repo (should pass through)
.claude/hooks/bash-worktree-fix.sh "ls -la"
# Test in worktree (should inject cd)
cd /path/to/worktree
.claude/hooks/bash-worktree-fix.sh "npm install"
# Output: cd "/path/to/worktree" && npm install
```
### Advanced Features
The script handles:
- Background processes (`&`)
- Piped commands (`|`)
- Environment variable prefixes (`VAR=value command`)
- Commands that already have `cd`
- Commands using absolute paths
- Debug logging with `CLAUDE_HOOK_DEBUG=true`
### Edge Cases Handled
1. **Double-prefix prevention**: Won't add prefix if command already starts with `cd`
2. **Absolute paths**: Skips injection for commands using absolute paths
3. **Special commands**: Skips for `pwd`, `echo`, `export`, etc. that don't need context
4. **Background processes**: Correctly handles `&` at the end of commands
5. **Pipe chains**: Injects only at the start of pipe chains
### Troubleshooting
If the hook isn't working:
1. **Verify the hook is executable:**
```bash
chmod +x .claude/hooks/bash-worktree-fix.sh
```
2. **Enable debug logging to see what's happening:**
```bash
export CLAUDE_HOOK_DEBUG=true
```
3. **Test the hook manually with a sample command:**
```bash
cd /path/to/worktree
.claude/hooks/bash-worktree-fix.sh "npm test"
```
4. **Check that your settings.json is valid JSON:**
```bash
cat .claude/settings.json | python -m json.tool
```
### Integration with Claude
Once configured, this hook will:
- Automatically apply to all Bash tool invocations
- Work for both main agent and sub-agents
- Be completely transparent to users
- Eliminate the need for worktree-specific instructions
### Result
With this hook in place, agents can work in worktrees naturally:
**Agent writes:**
```bash
npm install
git status
npm run build
```
**Hook transforms to:**
```bash
cd /path/to/my/project/epic-feature && npm install
cd /path/to/my/project/epic-feature && git status
cd /path/to/my/project/epic-feature && npm run build
```
**Without the agent knowing or caring about the worktree context!**

View File

@@ -0,0 +1,189 @@
#!/bin/sh
# POSIX-compliant pre-tool-use hook for Bash tool
# If inside a Git *worktree checkout*, prefix the incoming command with:
# cd '<worktree_root>' && <original_command>
# No sh -c. No tokenization. Quoting preserved. Robust worktree detection.
DEBUG_MODE="${CLAUDE_HOOK_DEBUG:-false}"
debug_log() {
case "${DEBUG_MODE:-}" in
true|TRUE|1|yes|YES)
printf '%s\n' "DEBUG [bash-worktree-fix]: $*" >&2
;;
esac
}
# Safely single-quote a string for shell usage: foo'bar -> 'foo'"'"'bar'
shell_squote() {
printf "%s" "$1" | sed "s/'/'\"'\"'/g"
}
# Detect if CWD is inside a *linked worktree* and print the worktree root.
# Returns 0 with path on stdout if yes; 1 otherwise.
get_worktree_path() {
check_dir="$(pwd)"
if [ ! -d "${check_dir}" ]; then
debug_log "pwd is not a directory: ${check_dir}"
return 1
fi
while [ "${check_dir}" != "/" ]; do
if [ -f "${check_dir}/.git" ]; then
gitdir_content=""
if [ -r "${check_dir}/.git" ]; then
IFS= read -r gitdir_content < "${check_dir}/.git" || gitdir_content=""
# Strip a possible trailing CR (CRLF files)
gitdir_content=$(printf %s "$gitdir_content" | tr -d '\r')
else
debug_log "Unreadable .git file at: ${check_dir}"
fi
case "${gitdir_content}" in
gitdir:*)
gitdir_path=${gitdir_content#gitdir:}
# Trim leading spaces (portable)
while [ "${gitdir_path# }" != "${gitdir_path}" ]; do
gitdir_path=${gitdir_path# }
done
# Normalize to absolute
case "${gitdir_path}" in
/*) abs_gitdir="${gitdir_path}" ;;
*) abs_gitdir="${check_dir}/${gitdir_path}" ;;
esac
if [ -d "${abs_gitdir}" ]; then
case "${abs_gitdir}" in
*/worktrees/*)
debug_log "Detected worktree root: ${check_dir} (gitdir: ${abs_gitdir})"
printf '%s\n' "${check_dir}"
return 0
;;
*)
debug_log "Non-worktree .git indirection at: ${check_dir}"
return 1
;;
esac
else
debug_log "gitdir path does not exist: ${abs_gitdir}"
return 1
fi
;;
*)
debug_log "Unknown .git file format at: ${check_dir}"
return 1
;;
esac
elif [ -d "${check_dir}/.git" ]; then
# Regular repo with .git directory — not a linked worktree
debug_log "Found regular git repo at: ${check_dir}"
return 1
fi
check_dir=$(dirname "${check_dir}")
done
debug_log "No git repository found"
return 1
}
# Decide whether to skip prefixing.
# Returns 0 => SKIP (pass through as-is)
# Returns 1 => Prefix with cd
should_skip_command() {
cmd=$1
# Empty or whitespace-only?
# If there are no non-space characters, skip.
if [ -z "${cmd##*[![:space:]]*}" ]; then
debug_log "Skipping: empty/whitespace-only command"
return 0
fi
# Starts with optional spaces then 'cd' (with or without args)?
case "${cmd}" in
[[:space:]]cd|cd|[[:space:]]cd[[:space:]]*|cd[[:space:]]*)
debug_log "Skipping: command already begins with cd"
return 0
;;
esac
# Builtins / trivial commands that don't require dir context
case "${cmd}" in
:|[[:space:]]:|true|[[:space:]]true|false|[[:space:]]false|\
pwd|[[:space:]]pwd*|\
echo|[[:space:]]echo*|\
export|[[:space:]]export*|\
alias|[[:space:]]alias*|\
unalias|[[:space:]]unalias*|\
set|[[:space:]]set*|\
unset|[[:space:]]unset*|\
readonly|[[:space:]]readonly*|\
umask|[[:space:]]umask*|\
times|[[:space:]]times*|\
.|[[:space:]].[[:space:]]*)
debug_log "Skipping: trivial/builtin command"
return 0
;;
esac
# Do NOT skip absolute-path commands; many still depend on cwd.
# We want: cd '<root>' && /abs/cmd ... to preserve semantics.
return 1
}
# Inject the worktree prefix without changing semantics.
# We do NOT wrap in 'sh -c'. We just prepend 'cd ... && '.
# We preserve trailing '&' if present as the last non-space char.
inject_prefix() {
worktree_path=$1
command=$2
qpath=$(shell_squote "${worktree_path}")
# Right-trim spaces (portable loop)
trimmed=${command}
while [ "${trimmed% }" != "${trimmed}" ]; do
trimmed=${trimmed% }
done
case "${trimmed}" in
*"&")
cmd_without_bg=${trimmed%&}
while [ "${cmd_without_bg% }" != "${cmd_without_bg}" ]; do
cmd_without_bg=${cmd_without_bg% }
done
printf '%s\n' "cd '${qpath}' && ${cmd_without_bg} &"
;;
*)
printf '%s\n' "cd '${qpath}' && ${command}"
;;
esac
}
main() {
# Capture the raw command line exactly as provided
original_command="$*"
debug_log "Processing command: ${original_command}"
# Fast path: if not in a worktree, pass through unchanged
if ! worktree_path="$(get_worktree_path)"; then
debug_log "Not in worktree, passing through unchanged"
printf '%s\n' "${original_command}"
exit 0
fi
if should_skip_command "${original_command}"; then
debug_log "Passing through unchanged"
printf '%s\n' "${original_command}"
else
modified_command="$(inject_prefix "${worktree_path}" "${original_command}")"
debug_log "Modified command: ${modified_command}"
printf '%s\n' "${modified_command}"
fi
}
main "$@"

View File

@@ -0,0 +1,12 @@
{
"hooks": {
"pre-tool-use": {
"Bash": {
"enabled": true,
"script": ".claude/hooks/bash-worktree-fix.sh",
"description": "Automatically prepends worktree path to Bash commands when in a worktree",
"apply_to_subagents": true
}
}
}
}