Skip to content

Instantly share code, notes, and snippets.

@ViktorStiskala
Created April 11, 2026 00:10
Show Gist options
  • Select an option

  • Save ViktorStiskala/37fb0a87077255daaef9a376c1ea8f7b to your computer and use it in GitHub Desktop.

Select an option

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.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash $HOME/.claude/hooks/stop-stash.sh"
}
]
}
]
}
}
#!/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