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)