mirror of
https://github.com/automazeio/ccpm.git
synced 2025-10-09 13:41:06 +03:00
Merge pull request #76 from flyingrobots/main
fix: Add automatic worktree support for Bash tool directory reset issue
This commit is contained in:
130
.claude/hooks/README.md
Normal file
130
.claude/hooks/README.md
Normal 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!**
|
||||
189
.claude/hooks/bash-worktree-fix.sh
Executable file
189
.claude/hooks/bash-worktree-fix.sh
Executable 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 "$@"
|
||||
12
.claude/settings.json.example
Normal file
12
.claude/settings.json.example
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user