Created
April 11, 2026 00:10
-
-
Save ViktorStiskala/37fb0a87077255daaef9a376c1ea8f7b to your computer and use it in GitHub Desktop.
Claude Code `Stop` hook: auto-stash all working changes after each Claude Code turn.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "hooks": { | |
| "Stop": [ | |
| { | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "bash $HOME/.claude/hooks/stop-stash.sh" | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| STASH_PREFIX="claude" | |
| STASH_LIST_FMT='--format=%gd %gs' | |
| MAX_SESSION_STASHES=10 # Max stashes to keep per session | |
| MAX_TOTAL_STASHES=50 # Max total claude stashes across all sessions | |
| PRUNE_AGE_DAYS=7 # Only prune stashes older than this many days | |
| FLOCK_TIMEOUT=10 # Seconds to wait for stash lock before skipping | |
| PRUNE_TIMEOUT=5 # Max seconds for pruning before it's killed | |
| # --- Dependencies --- | |
| command -v jq &>/dev/null || exit 0 | |
| # Stop hook: auto-stash all working changes after each Claude turn. | |
| # | |
| # Keeps up to MAX_SESSION_STASHES stashes per session, skipping | |
| # duplicates by comparing tree hashes. Prunes stashes beyond | |
| # MAX_TOTAL_STASHES that are older than PRUNE_AGE_DAYS. | |
| # | |
| # Configuration (variables at the top of this file): | |
| # STASH_PREFIX Prefix for stash names (default: "claude") | |
| # STASH_LIST_FMT git stash list format string | |
| # MAX_SESSION_STASHES Max stashes per session before oldest is dropped | |
| # MAX_TOTAL_STASHES Max total prefixed stashes across all sessions | |
| # PRUNE_AGE_DAYS Only prune stashes older than this many days | |
| # FLOCK_TIMEOUT Seconds to wait for stash lock before skipping | |
| # PRUNE_TIMEOUT Max seconds for pruning before it's killed | |
| # | |
| # Automatically saves a snapshot of your working directory after each | |
| # Claude turn, so you never lose in-progress work. Each session keeps | |
| # a rolling history of up to 10 snapshots. If nothing changed since the | |
| # last snapshot, it's skipped. Old snapshots are cleaned up automatically. | |
| # | |
| # All stash operations are protected by a file lock so multiple Claude | |
| # sessions in the same repo won't interfere with each other. | |
| # | |
| # Reads the hook JSON payload from stdin: | |
| # - session_id (short hash for dedup + stash message) | |
| # - transcript_path (first user prompt used as session name) | |
| # - cwd (project directory to operate in) | |
| # | |
| # Flow: | |
| # | |
| # ┌──────────────────────┐ | |
| # │ Read JSON stdin │ | |
| # └──────────┬───────────┘ | |
| # │ | |
| # v | |
| # ┌──────────────────────┐ ┌──────────┐ | |
| # │ stop_hook_active? ├─ yes ──> │ exit 0 │ | |
| # └──────────┬───────────┘ └──────────┘ | |
| # │ no | |
| # v | |
| # ┌──────────────────────┐ | |
| # │ cd into cwd │ ┌──────────┐ | |
| # │ inside git repo? ├── no ──> │ exit 0 │ | |
| # └──────────┬───────────┘ └──────────┘ | |
| # │ yes | |
| # v | |
| # ┌──────────────────────┐ ┌──────────┐ | |
| # │ any changes? ├── no ──> │ exit 0 │ | |
| # └──────────┬───────────┘ └──────────┘ | |
| # │ yes | |
| # v | |
| # ┌──────────────────────────────────────────────────────┐ | |
| # │ Extract session_id, transcript_path │ | |
| # │ Extract session name from transcript │ | |
| # └──────────────────────────┬───────────────────────────┘ | |
| # │ | |
| # v | |
| # ┌──────────────────────────────────────────────────────┐ | |
| # │ Backup git index (trap restores on exit) │ | |
| # │ git add -A │ | |
| # │ HASH = git stash create │ | |
| # │ Build DESCRIPTION │ | |
| # └──────────────────────────┬───────────────────────────┘ | |
| # │ | |
| # v | |
| # ┌──────────────────────┐ ┌──────────┐ | |
| # │ HASH empty? ├─ yes ──> │ exit 0 │ | |
| # └──────────┬───────────┘ └──────────┘ | |
| # │ no | |
| # v | |
| # ╔════════════════════════════════════════════════════════════════════════════════════════════════════╗ | |
| # ║ flock -w FLOCK_TIMEOUT ║ | |
| # ║ ║ | |
| # ║ ┌────────────────────────────────────────┐ ┌──────────────────────────────────────────┐ ║ | |
| # ║ │ flock timeout? ├─ yes ─>│ exit 0 (skip, no output) │ ║ | |
| # ║ └────────────────────┬───────────────────┘ └──────────────────────────────────────────┘ ║ | |
| # ║ │ no ║ | |
| # ║ v ║ | |
| # ║ ┌────────────────────────────────────────┐ ┌──────────────────────────────────────────┐ ║ | |
| # ║ │ tree == most recent session stash? ├─ yes ─>│ exit 0 (skip, no output) │ ║ | |
| # ║ └────────────────────┬───────────────────┘ └──────────────────────────────────────────┘ ║ | |
| # ║ │ no ║ | |
| # ║ v ║ | |
| # ║ ┌────────────────────────────────────────┐ ┌──────────────────────────────────────────┐ ║ | |
| # ║ │ count >= MAX_SESSION_STASHES? ├─ yes ─>│ drop oldest session stash │ ║ | |
| # ║ └────────────────────┬───────────────────┘ └────────────────────┬─────────────────────┘ ║ | |
| # ║ │ no │ ║ | |
| # ║ │<────────────────────────────────────────────────┘ ║ | |
| # ║ v ║ | |
| # ║ ┌────────────────────────────────────────┐ ║ | |
| # ║ │ git stash store -m DESCRIPTION │ ║ | |
| # ║ └────────────────────┬───────────────────┘ ║ | |
| # ║ v ║ | |
| # ║ ┌────────────────────────────────────────┐ ║ | |
| # ║ │ JSON: {"systemMessage": "..."} │ ║ | |
| # ║ └────────────────────┬───────────────────┘ ║ | |
| # ║ v ║ | |
| # ║ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ ║ | |
| # ║ │ prune (background, killed after PRUNE_TIMEOUT) │ ║ | |
| # ║ │ stashes > MAX_TOTAL_STASHES AND older than PRUNE_AGE_DAYS -> drop in reverse order │ ║ | |
| # ║ └────────────────────────────────────────────────────────────────────────────────────────────┘ ║ | |
| # ║ ║ | |
| # ╚═══════════════════════╤════════════════════════════════════════════════════════════════════════════╝ | |
| # │ | |
| # v | |
| # ┌────────────────────────────────┐ | |
| # │ exit 0 (trap restores index) │ | |
| # └────────────────────────────────┘ | |
| # | |
| # The stash is non-destructive: working directory stays untouched. | |
| # | |
| # Stash name format: | |
| # claude/2026-04-08_20-54-32/a1b2c3d4/refactor-auth-middleware-to-use-async | |
| # Read stdin payload (hook sends JSON via stdin) | |
| HOOK_PAYLOAD=$(cat) | |
| # --- Guard against forced continuations --- | |
| STOP_HOOK_ACTIVE=$(echo "$HOOK_PAYLOAD" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") | |
| if [[ "$STOP_HOOK_ACTIVE" == "true" ]]; then | |
| exit 0 | |
| fi | |
| # --- Use cwd from payload --- | |
| HOOK_CWD=$(echo "$HOOK_PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null || true) | |
| if [[ -n "$HOOK_CWD" ]]; then | |
| cd "$HOOK_CWD" | |
| fi | |
| # Only run inside a git repo | |
| git rev-parse --is-inside-work-tree &>/dev/null || exit 0 | |
| # Skip repos with no commits (empty repo / orphan branch) | |
| git rev-parse --verify HEAD &>/dev/null || exit 0 | |
| # Skip during merge conflicts (unmerged paths) | |
| if git ls-files -u 2>/dev/null | head -1 | grep -q .; then | |
| exit 0 | |
| fi | |
| # Check if there are any changes at all (tracked + untracked) | |
| if git diff --quiet HEAD 2>/dev/null && \ | |
| git diff --cached --quiet 2>/dev/null && \ | |
| [[ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]]; then | |
| exit 0 | |
| fi | |
| # --- Extract session info from payload --- | |
| SESSION_ID=$(echo "$HOOK_PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null || true) | |
| TRANSCRIPT_PATH=$(echo "$HOOK_PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null || true) | |
| SHORT_ID="${SESSION_ID:0:8}" | |
| # --- Extract session name from transcript --- | |
| SESSION_NAME="" | |
| if [[ -n "$TRANSCRIPT_PATH" && -f "$TRANSCRIPT_PATH" ]]; then | |
| mapfile -tn 200 LINES < "$TRANSCRIPT_PATH" | |
| SESSION_NAME=$( | |
| printf '%s\n' "${LINES[@]}" \ | |
| | rg '"type":"user"' \ | |
| | rg -v '<command-name>|<command-message>|<local-command-|<teammate-message|tool_use_id|"isMeta":true|Request interrupted by user' \ | |
| | head -n5 \ | |
| | jq -r ' | |
| .message.content | |
| | if type == "string" then . | |
| elif type == "array" then map(select(.type == "text") | .text) | join(" ") | |
| else empty | |
| end | |
| | gsub("<[^>]+>"; "") | |
| ' 2>/dev/null \ | |
| | sed '/^[[:space:]]*$/d' \ | |
| | head -n1 \ | |
| | head -c 120 \ | |
| | tr '[:upper:]' '[:lower:]' \ | |
| | sed 's/[^a-z0-9 ]/ /g' \ | |
| | sed 's/ */ /g; s/^ //; s/ $//' \ | |
| | tr ' ' '-' \ | |
| || true | |
| ) | |
| # Fallback: teammate/sub-agent session → teammate-<id>-<description> | |
| if [[ -z "$SESSION_NAME" ]]; then | |
| SESSION_NAME=$( | |
| printf '%s\n' "${LINES[@]}" \ | |
| | rg '<teammate-message' \ | |
| | head -n1 \ | |
| | jq -r ' | |
| .message.content | |
| | split("\n") | |
| | [ (.[0] | capture("teammate_id=\"(?<id>[^\"]+)\"") | "teammate-" + .id), | |
| (.[1:] | map(gsub("<[^>]+>"; "") | gsub("^\\s+|\\s+$"; "")) | map(select(length > 0)) | .[0] // empty) ] | |
| | map(select(. != null)) | join("-") | |
| ' 2>/dev/null \ | |
| | head -c 120 \ | |
| | tr '[:upper:]' '[:lower:]' \ | |
| | sed 's/[^a-z0-9 -]/ /g' \ | |
| | sed 's/ */ /g; s/^ //; s/ $//' \ | |
| | tr ' ' '-' \ | |
| | sed 's/--*/-/g' \ | |
| || true | |
| ) | |
| fi | |
| fi | |
| # --- Prepare private index (never touches the real .git/index) --- | |
| GIT_DIR=$(git rev-parse --git-dir) | |
| GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null || echo "$GIT_DIR") | |
| TEMP_INDEX="" | |
| cleanup() { | |
| rm -f "$TEMP_INDEX" | |
| } | |
| trap cleanup EXIT ERR INT TERM | |
| ORIGINAL_INDEX="${GIT_DIR}/index" | |
| TEMP_INDEX=$(mktemp "${TMPDIR:-/tmp}/claude-stash-index.XXXXXX") | |
| cp -f "$ORIGINAL_INDEX" "$TEMP_INDEX" | |
| # --- Create stash using private index --- | |
| # Note: git stash create writes commit/tree/blob objects to .git/objects/ | |
| # even when dedup later skips git stash store. These unreferenced objects | |
| # are cleaned up automatically by git gc (default: after 2 weeks). | |
| GIT_INDEX_FILE="$TEMP_INDEX" git add -A | |
| HASH=$(GIT_INDEX_FILE="$TEMP_INDEX" git stash create) | |
| if [[ -z "$HASH" ]]; then | |
| # cleanup runs via trap | |
| exit 0 | |
| fi | |
| # --- Build description --- | |
| TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S") | |
| # File-based slug as fallback when no transcript is available | |
| CHANGED_FILES=$(GIT_INDEX_FILE="$TEMP_INDEX" git diff --cached --name-only HEAD 2>/dev/null | head -5) | |
| FILE_COUNT=$(GIT_INDEX_FILE="$TEMP_INDEX" git diff --cached --name-only HEAD 2>/dev/null | wc -l | tr -d ' ') | |
| if [[ -n "$CHANGED_FILES" ]]; then | |
| FILE_SLUG=$(echo "$CHANGED_FILES" \ | |
| | xargs -I{} basename {} \ | |
| | sed 's/\.[^.]*$//' \ | |
| | tr '\n' '-' \ | |
| | sed 's/-$//' \ | |
| | tr '[:upper:]' '[:lower:]' \ | |
| | sed 's/[^a-z0-9-]/-/g' \ | |
| | sed 's/--*/-/g' \ | |
| | cut -c1-60) | |
| if [[ "$FILE_COUNT" -gt 5 ]]; then | |
| FILE_SLUG="${FILE_SLUG}-and-$((FILE_COUNT - 5))-more" | |
| fi | |
| else | |
| FILE_SLUG="session-changes" | |
| fi | |
| # Prefer session name (first prompt), fall back to file slug | |
| SLUG="${SESSION_NAME:-$FILE_SLUG}" | |
| if [[ -n "$SHORT_ID" ]]; then | |
| DESCRIPTION="${STASH_PREFIX}/${TIMESTAMP}/${SHORT_ID}/${SLUG}" | |
| else | |
| DESCRIPTION="${STASH_PREFIX}/${TIMESTAMP}/${SLUG}" | |
| fi | |
| # --- Locked section: dedup, cap, store, prune --- | |
| # All stash-list mutations are serialized via flock to prevent | |
| # concurrent sessions from racing on stash indices. | |
| LOCK_FILE="${GIT_COMMON_DIR}/claude-stash.lock" | |
| ( | |
| trap 'rm -f "$LOCK_FILE"' EXIT | |
| if command -v flock &>/dev/null; then | |
| flock -w "$FLOCK_TIMEOUT" 200 || exit 0 | |
| fi | |
| # --- Deduplicate: skip if tree matches most recent session stash --- | |
| if [[ -n "$SHORT_ID" ]]; then | |
| NEW_TREE=$(git rev-parse "${HASH}^{tree}" 2>/dev/null || true) | |
| LATEST_SESSION_STASH=$(git stash list "$STASH_LIST_FMT" 2>/dev/null \ | |
| | grep "${STASH_PREFIX}/[^/]*/${SHORT_ID}/" \ | |
| | head -1 \ | |
| | awk '{print $1}' \ | |
| | tr -d ':' \ | |
| || true) | |
| if [[ -n "$LATEST_SESSION_STASH" && -n "$NEW_TREE" ]]; then | |
| PREV_TREE=$(git rev-parse "${LATEST_SESSION_STASH}^{tree}" 2>/dev/null || true) | |
| if [[ "$NEW_TREE" == "$PREV_TREE" ]]; then | |
| # Working tree unchanged since last stash — skip | |
| exit 0 | |
| fi | |
| fi | |
| # Cap at MAX_SESSION_STASHES: drop oldest for this session if at limit | |
| SESSION_STASHES=$(git stash list "$STASH_LIST_FMT" 2>/dev/null \ | |
| | grep "${STASH_PREFIX}/[^/]*/${SHORT_ID}/" \ | |
| || true) | |
| SESSION_COUNT=$(echo "$SESSION_STASHES" | grep -c . || true) | |
| if [[ "$SESSION_COUNT" -ge "$MAX_SESSION_STASHES" ]]; then | |
| OLDEST_REF=$(echo "$SESSION_STASHES" \ | |
| | tail -1 \ | |
| | awk '{print $1}' \ | |
| | tr -d ':') | |
| if [[ -n "$OLDEST_REF" ]]; then | |
| git stash drop "$OLDEST_REF" 2>/dev/null || true | |
| fi | |
| fi | |
| fi | |
| # --- Store --- | |
| git stash store -m "$DESCRIPTION" "$HASH" | |
| # --- Output systemMessage --- | |
| jq -n --arg desc "$DESCRIPTION" '{ | |
| "systemMessage": ("Working tree stashed as: " + $desc) | |
| }' 2>/dev/null || echo "{\"systemMessage\":\"Working tree stashed as: $DESCRIPTION\"}" | |
| # --- Prune old stashes: keep last MAX_TOTAL_STASHES, delete PRUNE_AGE_DAYS+ old --- | |
| # Run in background with PRUNE_TIMEOUT watchdog so pruning can't block the hook. | |
| _prune() { | |
| CUTOFF=$(date -v-${PRUNE_AGE_DAYS}d +"%Y-%m-%d" 2>/dev/null \ | |
| || date -d "${PRUNE_AGE_DAYS} days ago" +"%Y-%m-%d" 2>/dev/null \ | |
| || true) | |
| if [[ -n "$CUTOFF" ]]; then | |
| DROP_LIST=() | |
| COUNT=0 | |
| while IFS= read -r line; do | |
| [[ -z "$line" ]] && continue | |
| COUNT=$((COUNT + 1)) | |
| if [[ "$COUNT" -le "$MAX_TOTAL_STASHES" ]]; then | |
| continue | |
| fi | |
| STASH_DATE=$(echo "$line" | sed -n "s/.*${STASH_PREFIX}\/\([0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}\)_.*/\1/p") | |
| if [[ -z "$STASH_DATE" ]]; then | |
| continue | |
| fi | |
| if [[ "$STASH_DATE" < "$CUTOFF" ]]; then | |
| STASH_REF=$(echo "$line" | awk '{print $1}' | tr -d ':') | |
| DROP_LIST+=("$STASH_REF") | |
| fi | |
| done < <(git stash list "$STASH_LIST_FMT" 2>/dev/null | grep "${STASH_PREFIX}/" || true) | |
| for (( i=${#DROP_LIST[@]}-1; i>=0; i-- )); do | |
| git stash drop "${DROP_LIST[$i]}" 2>/dev/null || true | |
| done | |
| fi | |
| } | |
| _prune 200>&- & | |
| PRUNE_PID=$! | |
| ( sleep "$PRUNE_TIMEOUT"; kill "$PRUNE_PID" 2>/dev/null || true ) 200>&- & | |
| TIMER_PID=$! | |
| wait "$PRUNE_PID" 2>/dev/null || true | |
| kill "$TIMER_PID" 2>/dev/null || true | |
| wait "$TIMER_PID" 2>/dev/null || true | |
| ) 200>"$LOCK_FILE" | |
| # cleanup runs via trap, removing temp index | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment