- On startup, daemon sweeps orphaned /tmp/jj-claude-*-files from pre-daemon hook versions and dead-PID tracking files from all repos - Cross-session file conflicts detected on post-edit and returned in response (logged as warnings, non-blocking) - session-start now returns inventory of all other active sessions with their tracked files, PIDs, and ages - Stale session reap reduced from 2h to 30min default (matches idle shutdown), configurable via JJ_HOOK_STALE_MIN env var - Debounce interval configurable via JJ_HOOK_DEBOUNCE_SEC env var (default 3s, max 30s) — previously documented but not implemented - handleEvent response changed from bare bool to structured JSON with ok/error/ sessions/conflicts fields (backward compatible — ok field still present) - 30 Go tests (up from 13): inventory, conflicts, env vars, legacy cleanup
197 lines
6.5 KiB
Bash
Executable file
197 lines
6.5 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# jj-hook.sh — Claude Code hook client for jj-commitd
|
|
#
|
|
# Thin client that sends events to the jj-commitd daemon over a Unix socket.
|
|
# The daemon handles all jj operations (debounced commits, session tracking,
|
|
# reaping, bookmarks).
|
|
#
|
|
# If the daemon isn't running, session-start launches it automatically.
|
|
# If the daemon binary isn't found, falls back to simple file tracking
|
|
# with commit-at-session-end (the original behavior).
|
|
#
|
|
# Usage from Claude Code hooks:
|
|
# scripts/jj-hook.sh session-start
|
|
# scripts/jj-hook.sh session-end
|
|
# scripts/jj-hook.sh post-edit
|
|
# Manual:
|
|
# scripts/jj-hook.sh squash-wip
|
|
# scripts/jj-hook.sh status
|
|
# scripts/jj-hook.sh reap
|
|
|
|
set -uo pipefail
|
|
|
|
warn() { echo "jj-hook.sh: $*" >&2; }
|
|
|
|
# shellcheck source=_lib.sh
|
|
source "$(dirname "${BASH_SOURCE[0]}")/_lib.sh"
|
|
|
|
ACTION="${1:-}"
|
|
# SHA256-based repo ID — must match Go daemon's repoID() function
|
|
REPO_ID=$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)
|
|
SOCK="/tmp/jj-commitd-${REPO_ID}.sock"
|
|
|
|
# ── Daemon management ────────────────────────────────────────────
|
|
|
|
find_daemon() {
|
|
local script_dir
|
|
script_dir="$(dirname "${BASH_SOURCE[0]}")"
|
|
|
|
for candidate in \
|
|
"${script_dir}/../bin/jj-commitd" \
|
|
"${script_dir}/../cmd/jj-commitd/jj-commitd" \
|
|
"$(command -v jj-commitd 2>/dev/null || true)" \
|
|
"${HOME}/go/bin/jj-commitd" \
|
|
"${GOPATH:-${HOME}/go}/bin/jj-commitd"; do
|
|
[ -n "$candidate" ] && [ -x "$candidate" ] && echo "$candidate" && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
ensure_daemon() {
|
|
# Already running?
|
|
if [ -S "$SOCK" ]; then
|
|
if echo '{"event":"status"}' | nc -U -w1 "$SOCK" >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
rm -f "$SOCK"
|
|
fi
|
|
|
|
local daemon
|
|
daemon=$(find_daemon) || return 1
|
|
|
|
REPO_ROOT="$REPO_ROOT" nohup "$daemon" </dev/null >>/tmp/jj-commitd-"${REPO_ID}".log 2>&1 &
|
|
|
|
# Wait for socket (up to 2s)
|
|
local attempts=0
|
|
while [ ! -S "$SOCK" ] && [ "$attempts" -lt 20 ]; do
|
|
sleep 0.1
|
|
attempts=$((attempts + 1))
|
|
done
|
|
[ -S "$SOCK" ]
|
|
}
|
|
|
|
send_event() {
|
|
echo "$1" | nc -U -w2 "$SOCK" 2>/dev/null || true
|
|
}
|
|
|
|
# ── Fallback (no daemon) ────────────────────────────────────────
|
|
|
|
fallback_post_edit() {
|
|
local file="$1" session="$2"
|
|
local tracking="/tmp/jj-claude-${REPO_ID}-${session}-files"
|
|
if [ -f "$tracking" ]; then
|
|
grep -qxF "$file" "$tracking" 2>/dev/null || echo "$file" >> "$tracking"
|
|
else
|
|
echo "$file" >> "$tracking"
|
|
fi
|
|
}
|
|
|
|
fallback_session_end() {
|
|
local session="$1"
|
|
local tracking="/tmp/jj-claude-${REPO_ID}-${session}-files"
|
|
if [ -f "$tracking" ] && [ -s "$tracking" ]; then
|
|
if jj diff --summary 2>/dev/null | grep -q .; then
|
|
jj commit -m "wip(claude:${session}): session end" 2>/dev/null || true
|
|
fi
|
|
rm -f "$tracking"
|
|
fi
|
|
}
|
|
|
|
# ── Main dispatch ────────────────────────────────────────────────
|
|
|
|
case "$ACTION" in
|
|
session-start)
|
|
command -v jq >/dev/null 2>&1 || { cat >/dev/null; exit 0; }
|
|
INPUT=$(cat)
|
|
SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null | head -c 8)
|
|
[ -z "$SID" ] && SID="unknown"
|
|
|
|
if ensure_daemon; then
|
|
# Pass PPID (Claude Code process), not $$ (this short-lived script).
|
|
send_event "{\"event\":\"session-start\",\"session_id\":\"${SID}\",\"repo_root\":\"${REPO_ROOT}\",\"pid\":${PPID}}"
|
|
fi
|
|
;;
|
|
|
|
session-end)
|
|
command -v jq >/dev/null 2>&1 || { cat >/dev/null; exit 0; }
|
|
INPUT=$(cat)
|
|
SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null | head -c 8)
|
|
[ -z "$SID" ] && SID="unknown"
|
|
|
|
if [ -S "$SOCK" ]; then
|
|
send_event "{\"event\":\"session-end\",\"session_id\":\"${SID}\"}"
|
|
else
|
|
fallback_session_end "$SID"
|
|
fi
|
|
;;
|
|
|
|
post-edit)
|
|
command -v jq >/dev/null 2>&1 || { cat >/dev/null; exit 0; }
|
|
INPUT=$(cat)
|
|
SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null | head -c 8)
|
|
[ -z "$SID" ] && SID="unknown"
|
|
FILE_PATH=$(printf '%s\n' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
|
|
if [ -n "$FILE_PATH" ]; then
|
|
if [ -S "$SOCK" ]; then
|
|
send_event "{\"event\":\"post-edit\",\"session_id\":\"${SID}\",\"file\":\"${FILE_PATH}\",\"repo_root\":\"${REPO_ROOT}\"}"
|
|
else
|
|
REL_PATH="${FILE_PATH#"${REPO_ROOT}"/}"
|
|
fallback_post_edit "$REL_PATH" "$SID"
|
|
fi
|
|
fi
|
|
;;
|
|
|
|
squash-wip)
|
|
set -e
|
|
command -v jj >/dev/null 2>&1 || { warn "jj not found"; exit 1; }
|
|
cd "$REPO_ROOT"
|
|
|
|
if jj diff --summary 2>/dev/null | grep -q .; then
|
|
warn "uncommitted changes — commit or stash first"; exit 1
|
|
fi
|
|
|
|
MAIN_REV=$(jj log --no-graph -r 'main' -T 'commit_id.short(12)' 2>/dev/null)
|
|
[ -z "$MAIN_REV" ] && { warn "no 'main' bookmark found"; exit 1; }
|
|
|
|
WIP_REVS=$(jj log --no-graph -r 'main..@-' -T 'change_id.short(8) ++ "\n"' 2>/dev/null | grep -c . || true)
|
|
[ "$WIP_REVS" -eq 0 ] && { echo "nothing to squash"; exit 0; }
|
|
|
|
WIP_MESSAGES=$(jj log --no-graph -r 'main..@-' -T 'description.first_line() ++ "\n"' 2>/dev/null)
|
|
SESSION_IDS=$(echo "$WIP_MESSAGES" | grep -oE 'claude:[a-z0-9_]+' | sort -u | sed 's/claude://' | paste -sd, -)
|
|
SQUASH_MSG="wip(squashed): ${WIP_REVS} commits${SESSION_IDS:+ from sessions $SESSION_IDS}"
|
|
LAST_WIP=$(jj log --no-graph -r '@-' -T 'change_id.short(12)' 2>/dev/null)
|
|
|
|
jj new main -m "$SQUASH_MSG" 2>/dev/null || { warn "failed to create squash base"; exit 1; }
|
|
jj restore --from "$LAST_WIP" 2>/dev/null || { warn "restore failed"; jj undo 2>/dev/null; exit 1; }
|
|
jj commit -m "$SQUASH_MSG" 2>/dev/null || { warn "commit failed"; jj undo 2>/dev/null; exit 1; }
|
|
jj abandon "all:main..${LAST_WIP}" 2>/dev/null || true
|
|
|
|
for bm in $(jj bookmark list 2>/dev/null | grep -oE 'wip/claude-[^:]+' || true); do
|
|
jj bookmark delete "$bm" 2>/dev/null || true
|
|
done
|
|
echo "squashed ${WIP_REVS} wip commits into one: ${SQUASH_MSG}"
|
|
;;
|
|
|
|
status)
|
|
if [ -S "$SOCK" ]; then
|
|
send_event '{"event":"status"}'
|
|
else
|
|
echo "daemon not running (no socket at $SOCK)"
|
|
fi
|
|
;;
|
|
|
|
reap)
|
|
if [ -S "$SOCK" ]; then
|
|
send_event '{"event":"shutdown"}'
|
|
echo "shutdown signal sent"
|
|
else
|
|
echo "daemon not running"
|
|
fi
|
|
;;
|
|
|
|
*)
|
|
echo "Usage: jj-hook.sh {session-start|session-end|post-edit|squash-wip|status|reap}" >&2
|
|
exit 1
|
|
;;
|
|
esac
|