DEV Community

Cover image for Don’t Let Claude Mess Up Your Code: Git Stash Checkpoint System for Claude Code
ztor2
ztor2

Posted on

Don’t Let Claude Mess Up Your Code: Git Stash Checkpoint System for Claude Code

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 
Enter fullscreen mode Exit fullscreen mode

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 } ] } ] } } 
Enter fullscreen mode Exit fullscreen mode

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" 
Enter fullscreen mode Exit fullscreen mode

4. Restore from a stash

View checkpoints:

git stash list 
Enter fullscreen mode Exit fullscreen mode

Example:

Restore one:

git stash apply stash@{0} 
Enter fullscreen mode Exit fullscreen mode

Top comments (0)