Developers today are entering a new era with Claude Code.
- Claude Code can edit multiple parts of your project at once to satisfy a complex query, while considering the whole codebase.
- It’s a powerful feature, but it sometimes cause unexpected changes and the project ends up broken.
- In this situation, users have no clear way to recover.
- You could make commits during each task, but that clutters your Git history.
- A better option is an automated checkpoint system using
git stash
and Claude’s Hooks. - What is 'Hooks'? - They're like event listeners for Claude Code, allowing you to run a command or script when certain events happen (like when Claude starts or stops).
- Check out the official docs for more details: https://docs.claude.com/en/docs/claude-code/hooks
- This setup creates a stash every time Claude Code finishes a single complete answer.
- It keeps up to 10 checkpoints, dropping the oldest when the limit is reached.
1. Set up project structure
├── .claude/
│ ├── settings.local.json
│ └── logs/
├── ...
└── checkpoint.sh
2. Configure settings.local.json
file
- Make sure to replace
YOUR_PROJECT_DIR
with the actual path to your project.
{
"permissions": {
"deny": [
]
},
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "YOUR_PROJECT_DIR/checkpoint.sh",
"timeout": 30000
}
]
}
]
}
}
3. Set up checkpoint.sh
file
- You can customize the checkpoint rules(max number of stashes or logging behavior)
- If you run into permission issues, you might need to run
chmod +x checkpoint.sh
to make the file executable.
#!/bin/bash
# Claude Code stop hook: checkpoint manager
# Log directory and file
LOG_DIR=".claude/logs"
LOG_FILE="$LOG_DIR/checkpoint.log"
mkdir -p "$LOG_DIR"
# Logging level via env vars
CHECKPOINT_DEBUG=${CHECKPOINT_DEBUG:-false}
CHECKPOINT_VERBOSE=${CHECKPOINT_VERBOSE:-true}
# Log rotation: >50KB, keep current + .old
rotate_logs() {
if [ -f "$LOG_FILE" ] && [ $(wc -c < "$LOG_FILE" 2>/dev/null || echo 0) -gt 51200 ]; then
# Remove old file, move current to .old
[ -f "$LOG_FILE.old" ] && rm -f "$LOG_FILE.old"
mv "$LOG_FILE" "$LOG_FILE.old"
fi
}
# Logging helpers (conditional)
log_debug() {
[ "$CHECKPOINT_DEBUG" = "true" ] && echo "[$(date '+%H:%M:%S')] DEBUG: $1" >> "$LOG_FILE"
}
log_info() {
[ "$CHECKPOINT_VERBOSE" = "true" ] && echo "[$(date '+%H:%M:%S')] INFO: $1" >> "$LOG_FILE"
}
log_error() {
echo "[$(date '+%H:%M:%S')] ERROR: $1" >> "$LOG_FILE"
}
# Rotate logs
rotate_logs
# Begin debug
log_debug "Stop hook started with args: $*"
# Read Claude Code JSON input
HOOK_DATA=""
if [ -t 0 ]; then
log_debug "No stdin data detected (terminal mode)"
else
log_debug "Reading JSON data from stdin"
HOOK_DATA=$(cat)
log_debug "Received JSON: $HOOK_DATA"
fi
# Guard: stop_hook_active true => exit (loop prevention)
if echo "$HOOK_DATA" | grep -q '"stop_hook_active":\s*true'; then
log_info "Stop hook already active, exiting to prevent loop"
exit 0
fi
# Verify git repo
if ! git rev-parse --git-dir >/dev/null 2>&1; then
log_debug "Not a git repository, exiting"
exit 1
fi
# Check working tree changes
GIT_STATUS=$(git status --porcelain 2>/dev/null)
if [ -z "$GIT_STATUS" ]; then
log_debug "No changes detected, exiting"
exit 0
fi
log_info "Changes detected: $(echo "$GIT_STATUS" | wc -l) files"
# Build stash message (arg or git status)
if [ -n "$1" ]; then
MESSAGE="$1"
log_debug "Using command line message: $MESSAGE"
else
# From status, take filenames sorted by mtime (max 3)
FILES_WITH_TIME=""
while IFS= read -r line; do
if [ -n "$line" ]; then
FILE_PATH=$(echo "$line" | cut -c4-)
if [ -f "$FILE_PATH" ]; then
# Store with mtime in seconds
MOD_TIME=$(stat -f "%m" "$FILE_PATH" 2>/dev/null || echo "0")
FILES_WITH_TIME="$FILES_WITH_TIME$MOD_TIME:$FILE_PATH\n"
fi
fi
done <<< "$GIT_STATUS"
# Sort by mtime desc, keep names
SORTED_FILES=$(echo -e "$FILES_WITH_TIME" | grep -v "^$" | sort -rn -t: -k1 | cut -d: -f2- | head -3)
# Compose message
CHANGED_FILES=$(echo "$SORTED_FILES" | tr '\n' ' ' | sed 's/ $//')
TOTAL_COUNT=$(echo "$GIT_STATUS" | wc -l)
if [ $TOTAL_COUNT -gt 3 ]; then
MESSAGE="$CHANGED_FILES (and $(($TOTAL_COUNT - 3)) more)"
else
MESSAGE="$CHANGED_FILES"
fi
log_debug "Generated message from git status (sorted by modification time): $MESSAGE"
fi
# Make checkpoint label
TIMESTAMP=$(date '+%y%m%d:%H:%M:%S')
STASH_MESSAGE="agent: $TIMESTAMP $MESSAGE"
log_info "Creating checkpoint: $STASH_MESSAGE"
# Run git stash
if git stash push --include-untracked -m "$STASH_MESSAGE" >/dev/null 2>&1; then
log_info "Stash created successfully"
# Reapply stash
if git stash apply stash@{0} >/dev/null 2>&1; then
log_info "Stash reapplied successfully"
else
log_debug "Warning: Failed to reapply stash"
fi
else
log_debug "Error: Failed to create stash"
exit 1
fi
# Agent stash lifecycle (max 10)
AGENT_STASH_COUNT=$(git stash list 2>/dev/null | grep "agent:" | wc -l)
log_debug "Current agent stash count: $AGENT_STASH_COUNT"
while [ $AGENT_STASH_COUNT -gt 10 ]; do
OLDEST_AGENT_STASH=$(git stash list 2>/dev/null | grep "agent:" | tail -1 | cut -d: -f1)
if [ -n "$OLDEST_AGENT_STASH" ]; then
log_info "Removing oldest agent stash: $OLDEST_AGENT_STASH"
git stash drop "$OLDEST_AGENT_STASH" >/dev/null 2>&1
AGENT_STASH_COUNT=$((AGENT_STASH_COUNT - 1))
else
break
fi
done
# Success message
FINAL_MESSAGE="Checkpoint saved: stash@{0} - $STASH_MESSAGE"
echo "$FINAL_MESSAGE"
log_info "$FINAL_MESSAGE"
4. Restore from a stash
View checkpoints:
git stash list
Example:
Restore one:
git stash apply stash@{0}
Top comments (0)