template-jj/scripts/test-jj-hooks.sh
sid 89c1400c83 feat(planctl): scripts/jj-hook.sh planctl session tracking (parent 3.0)
- planctl_start_ticks helper: Linux safe /proc/<pid>/stat parse (LastIndex ')'
  + awk field 20); macOS ps -o lstart + date epoch conversion (R6.2, R7.3)
- session-start: validate canonical-UUID session_id, append CLAUDE_SESSION_ID
  to $CLAUDE_ENV_FILE (if set), atomically write /tmp/planctl-sessions/<PPID>
  with UUID + start-stamp via mktemp + mv (R6.2, R7.1, R7.3)
- session-end: rm -f /tmp/planctl-sessions/<PPID> (idempotent) (R7.2, R7.3)
- test-jj-hooks.sh: PID file lifecycle test + non-UUID rejection test (R7.1,
  R7.2)
- Manual smoke: PID file + $CLAUDE_ENV_FILE updates verified

Parent 3.0 from dev/plans/26174-planctl-context-tokens/tasks.md
2026-04-23 18:52:52 -06:00

1044 lines
34 KiB
Bash
Executable file

#!/usr/bin/env bash
# test-jj-hooks.sh — Integration tests for jj-hook.sh multi-agent workflow
#
# Spins up temporary jj repos, simulates concurrent agent sessions,
# and verifies file-scoped commits, dedup, and isolation.
#
# Usage: scripts/test-jj-hooks.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Prerequisites ──────────────────────────────────────────────
for cmd in jj jq; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "requires $cmd — skipping" >&2
exit 0
fi
done
# ── Test framework ─────────────────────────────────────────────
PASS=0
FAIL=0
TESTS=0
RED='\033[0;31m'
GREEN='\033[0;32m'
BOLD='\033[1m'
RESET='\033[0m'
assert_eq() {
local desc="$1" expected="$2" actual="$3"
TESTS=$((TESTS + 1))
if [ "$expected" = "$actual" ]; then
PASS=$((PASS + 1))
printf " ${GREEN}pass${RESET} %s\n" "$desc"
else
FAIL=$((FAIL + 1))
printf " ${RED}FAIL${RESET} %s\n" "$desc"
printf " expected: %s\n" "$expected"
printf " actual: %s\n" "$actual"
fi
}
assert_contains() {
local desc="$1" needle="$2" haystack="$3"
TESTS=$((TESTS + 1))
if printf '%s' "$haystack" | grep -qF "$needle"; then
PASS=$((PASS + 1))
printf " ${GREEN}pass${RESET} %s\n" "$desc"
else
FAIL=$((FAIL + 1))
printf " ${RED}FAIL${RESET} %s\n" "$desc"
printf " expected to contain: %s\n" "$needle"
printf " actual: %s\n" "$haystack"
fi
}
assert_not_contains() {
local desc="$1" needle="$2" haystack="$3"
TESTS=$((TESTS + 1))
if ! printf '%s' "$haystack" | grep -qF "$needle"; then
PASS=$((PASS + 1))
printf " ${GREEN}pass${RESET} %s\n" "$desc"
else
FAIL=$((FAIL + 1))
printf " ${RED}FAIL${RESET} %s\n" "$desc"
printf " expected NOT to contain: %s\n" "$needle"
fi
}
assert_file_exists() {
local desc="$1" path="$2"
TESTS=$((TESTS + 1))
if [ -f "$path" ]; then
PASS=$((PASS + 1))
printf " ${GREEN}pass${RESET} %s\n" "$desc"
else
FAIL=$((FAIL + 1))
printf " ${RED}FAIL${RESET} %s\n" "$desc"
printf " file not found: %s\n" "$path"
fi
}
assert_ne() {
local desc="$1" unexpected="$2" actual="$3"
TESTS=$((TESTS + 1))
if [ "$unexpected" != "$actual" ]; then
PASS=$((PASS + 1))
printf " ${GREEN}pass${RESET} %s\n" "$desc"
else
FAIL=$((FAIL + 1))
printf " ${RED}FAIL${RESET} %s\n" "$desc"
printf " expected different from: %s\n" "$unexpected"
fi
}
assert_file_not_exists() {
local desc="$1" path="$2"
TESTS=$((TESTS + 1))
if [ ! -f "$path" ]; then
PASS=$((PASS + 1))
printf " ${GREEN}pass${RESET} %s\n" "$desc"
else
FAIL=$((FAIL + 1))
printf " ${RED}FAIL${RESET} %s\n" "$desc"
printf " file should not exist: %s\n" "$path"
fi
}
# ── Cleanup trap ──────────────────────────────────────────────
CLEANUP_DIRS=()
CLEANUP_GLOBS=()
cleanup_on_exit() {
for dir in "${CLEANUP_DIRS[@]}"; do
rm -rf "$dir" 2>/dev/null
done
for glob in "${CLEANUP_GLOBS[@]}"; do
# shellcheck disable=SC2086
rm -f $glob 2>/dev/null
done
}
trap cleanup_on_exit EXIT
# ── Helpers ────────────────────────────────────────────────────
WORKDIR=""
hook() {
local action="$1" json="$2"
echo "$json" | "$WORKDIR/scripts/jj-hook.sh" "$action"
}
# Portable backdate: set file mtime to N minutes ago (works on macOS + Linux)
backdate_file() {
local file="$1" minutes="$2"
if date -d "1 minute ago" '+%Y' >/dev/null 2>&1; then
# GNU date/touch (Linux)
touch -d "${minutes} minutes ago" "$file"
else
# BSD date/touch (macOS)
touch -t "$(date -v-${minutes}M '+%Y%m%d%H%M.%S')" "$file"
fi
}
# Description of the latest committed revision (parent of working copy)
latest_desc() {
jj log --no-graph -r '@-' -T 'description' 2>/dev/null
}
# Change-id of @- (stable across auto-rebases)
parent_change_id() {
jj log --no-graph -r '@-' -T 'change_id.short(8)' 2>/dev/null
}
# Stat diff of a specific revision
rev_stat() {
jj diff --stat -r "${1:-@-}" 2>/dev/null
}
# Working-copy stat diff
wc_stat() {
jj diff --stat 2>/dev/null
}
setup_repo() {
WORKDIR=$(mktemp -d)
CLEANUP_DIRS+=("$WORKDIR")
cd "$WORKDIR"
jj git init 2>/dev/null
# Ensure user identity is set for commits
jj config set --repo user.name "Test"
jj config set --repo user.email "test@test"
# Copy hook scripts into the temp repo and commit them as baseline
mkdir -p scripts
cp "$SCRIPT_DIR/jj-hook.sh" scripts/
cp "$SCRIPT_DIR/_lib.sh" scripts/
chmod +x scripts/jj-hook.sh
jj commit -m "test: add hook scripts" 2>/dev/null
# Compute repo ID for tracking file cleanup (must match hook's logic)
TEST_REPO_ID=$(printf '%s' "$WORKDIR" | cksum | cut -d' ' -f1)
CLEANUP_GLOBS+=("/tmp/jj-claude-${TEST_REPO_ID}-*-files" "/tmp/jj-claude-${TEST_REPO_ID}-*-pid" "/tmp/jj-claude-${TEST_REPO_ID}-*-base" "/tmp/jj-claude-${TEST_REPO_ID}.lock")
}
teardown_repo() {
cd /tmp
rm -f /tmp/jj-claude-${TEST_REPO_ID}-*-files /tmp/jj-claude-${TEST_REPO_ID}-*-pid /tmp/jj-claude-${TEST_REPO_ID}-*-base
rmdir /tmp/jj-claude-${TEST_REPO_ID}.lock 2>/dev/null || true
rm -rf "$WORKDIR"
}
# ── Tests ──────────────────────────────────────────────────────
test_post_edit_creates_tracking_file() {
printf "\n${BOLD}post-edit: creates tracking file with relative path${RESET}\n"
setup_repo
echo "hello" > file1.txt
hook post-edit '{"session_id":"tst_ed01_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
assert_file_exists "tracking file created" "/tmp/jj-claude-${TEST_REPO_ID}-tst_ed01-files"
assert_eq "contains relative path" "file1.txt" "$(cat /tmp/jj-claude-${TEST_REPO_ID}-tst_ed01-files)"
teardown_repo
}
test_post_edit_deduplicates() {
printf "\n${BOLD}post-edit: deduplicates same file${RESET}\n"
setup_repo
echo "v1" > file1.txt
hook post-edit '{"session_id":"tst_ed02_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
echo "v2" > file1.txt
hook post-edit '{"session_id":"tst_ed02_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
local lines
lines=$(wc -l < /tmp/jj-claude-${TEST_REPO_ID}-tst_ed02-files | tr -d ' ')
assert_eq "single entry (no duplicate)" "1" "$lines"
teardown_repo
}
test_post_edit_tracks_multiple_files() {
printf "\n${BOLD}post-edit: tracks multiple different files${RESET}\n"
setup_repo
echo "a" > file1.txt
echo "b" > file2.txt
hook post-edit '{"session_id":"tst_ed03_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
hook post-edit '{"session_id":"tst_ed03_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file2.txt"}}'
local lines
lines=$(wc -l < /tmp/jj-claude-${TEST_REPO_ID}-tst_ed03-files | tr -d ' ')
assert_eq "two entries" "2" "$lines"
assert_contains "file1 tracked" "file1.txt" "$(cat /tmp/jj-claude-${TEST_REPO_ID}-tst_ed03-files)"
assert_contains "file2 tracked" "file2.txt" "$(cat /tmp/jj-claude-${TEST_REPO_ID}-tst_ed03-files)"
teardown_repo
}
test_post_edit_no_describe() {
printf "\n${BOLD}post-edit: does NOT call jj describe${RESET}\n"
setup_repo
local desc_before
desc_before=$(jj log --no-graph -r '@' -T 'description' 2>/dev/null)
echo "content" > file1.txt
hook post-edit '{"session_id":"tst_ed04_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
local desc_after
desc_after=$(jj log --no-graph -r '@' -T 'description' 2>/dev/null)
assert_eq "@ description unchanged" "$desc_before" "$desc_after"
teardown_repo
}
test_session_start_startup_snapshots() {
printf "\n${BOLD}session-start (startup): snapshots pre-existing changes${RESET}\n"
setup_repo
echo "pre-existing work" > existing.txt
hook session-start '{"session_id":"tst_ss01_xxxxx","source":"startup"}'
local desc
desc=$(latest_desc)
assert_contains "snapshot commit message" "snapshot: pre-agent working copy" "$desc"
teardown_repo
}
test_session_start_resume_no_snapshot() {
printf "\n${BOLD}session-start (resume): does NOT snapshot${RESET}\n"
setup_repo
echo "in-progress work" > wip.txt
local id_before
id_before=$(parent_change_id)
hook session-start '{"session_id":"tst_ss02_xxxxx","source":"resume"}'
local id_after
id_after=$(parent_change_id)
assert_eq "no new commit (@- unchanged)" "$id_before" "$id_after"
assert_file_exists "tracking file still created" "/tmp/jj-claude-${TEST_REPO_ID}-tst_ss02-files"
teardown_repo
}
test_session_start_creates_empty_tracking_file() {
printf "\n${BOLD}session-start: creates empty tracking file${RESET}\n"
setup_repo
hook session-start '{"session_id":"tst_ss03_xxxxx","source":"resume"}'
assert_file_exists "tracking file exists" "/tmp/jj-claude-${TEST_REPO_ID}-tst_ss03-files"
local size
size=$(wc -c < /tmp/jj-claude-${TEST_REPO_ID}-tst_ss03-files | tr -d ' ')
assert_eq "tracking file is empty" "0" "$size"
teardown_repo
}
test_session_end_commits_tracked_then_untracked() {
printf "\n${BOLD}session-end: tracked files in one commit, untracked in another${RESET}\n"
setup_repo
# Session A edits file1
echo "agent-a-work" > file1.txt
hook post-edit '{"session_id":"tst_se01_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
# Untracked file (e.g. from Bash command)
echo "bash-output" > file2.txt
hook session-end '{"session_id":"tst_se01_xxxxx"}'
# @- is the untracked commit (second), @-- is the tracked commit (first)
local untracked_commit
untracked_commit=$(rev_stat '@-')
assert_contains "untracked commit has file2" "file2.txt" "$untracked_commit"
local tracked_commit
tracked_commit=$(rev_stat '@--')
assert_contains "tracked commit has file1" "file1.txt" "$tracked_commit"
teardown_repo
}
test_session_end_no_empty_commit() {
printf "\n${BOLD}session-end: no commit when no files changed${RESET}\n"
setup_repo
# Start session, don't edit anything
hook session-start '{"session_id":"tst_se02_xxxxx","source":"resume"}'
local id_before
id_before=$(parent_change_id)
hook session-end '{"session_id":"tst_se02_xxxxx"}'
local id_after
id_after=$(parent_change_id)
assert_eq "no new commit" "$id_before" "$id_after"
teardown_repo
}
test_session_end_cleans_tracking_file() {
printf "\n${BOLD}session-end: removes tracking file${RESET}\n"
setup_repo
echo "data" > file1.txt
hook post-edit '{"session_id":"tst_se03_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
hook session-end '{"session_id":"tst_se03_xxxxx"}'
assert_file_not_exists "tracking file removed" "/tmp/jj-claude-${TEST_REPO_ID}-tst_se03-files"
teardown_repo
}
test_session_end_commit_message_format() {
printf "\n${BOLD}session-end: commit message format${RESET}\n"
setup_repo
echo "content" > file1.txt
hook post-edit '{"session_id":"tst_se04_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
hook session-end '{"session_id":"tst_se04_xxxxx"}'
local desc
desc=$(latest_desc)
assert_contains "wip prefix with session id" "wip(claude:tst_se04):" "$desc"
assert_contains "filename in summary" "file1.txt" "$desc"
teardown_repo
}
test_session_end_fallback_no_tracking_file() {
printf "\n${BOLD}session-end: fallback commits everything without tracking file${RESET}\n"
setup_repo
# Create changes without post-edit tracking (simulates pre-upgrade session)
echo "orphaned work" > orphan.txt
local id_before
id_before=$(parent_change_id)
hook session-end '{"session_id":"tst_se05_xxxxx"}'
local id_after
id_after=$(parent_change_id)
assert_ne "fallback commit created" "$id_before" "$id_after"
local desc
desc=$(latest_desc)
assert_contains "fallback message" "auto-snapshot after session" "$desc"
teardown_repo
}
test_session_end_many_files_summary() {
printf "\n${BOLD}session-end: >5 files shows 'and N more' summary${RESET}\n"
setup_repo
for i in $(seq 1 8); do
echo "content $i" > "file${i}.txt"
hook post-edit '{"session_id":"tst_se06_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file'"$i"'.txt"}}'
done
hook session-end '{"session_id":"tst_se06_xxxxx"}'
local desc
desc=$(latest_desc)
assert_contains "has 'and N more'" "and 3 more" "$desc"
# Verify the commit body lists all files
local full_desc
full_desc=$(jj log --no-graph -r '@-' -T 'description' 2>/dev/null)
assert_contains "body lists file6" "file6.txt" "$full_desc"
assert_contains "body lists file8" "file8.txt" "$full_desc"
teardown_repo
}
test_concurrent_separate_commits() {
printf "\n${BOLD}concurrent: two sessions get separate file-scoped commits${RESET}\n"
setup_repo
# Session A tracks file1
echo "agent-a-work" > file1.txt
hook post-edit '{"session_id":"tst_cc01_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
# Session B tracks file2
echo "agent-b-work" > file2.txt
hook post-edit '{"session_id":"tst_cc02_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file2.txt"}}'
# End session A — commits only file1
hook session-end '{"session_id":"tst_cc01_xxxxx"}'
local desc_a
desc_a=$(latest_desc)
assert_contains "session A commit has file1" "file1.txt" "$desc_a"
assert_not_contains "session A excludes file2" "file2.txt" "$desc_a"
# file2 still in working copy
local wc
wc=$(wc_stat)
assert_contains "file2 still uncommitted" "file2.txt" "$wc"
# End session B — commits only file2
hook session-end '{"session_id":"tst_cc02_xxxxx"}'
local desc_b
desc_b=$(latest_desc)
assert_contains "session B commit has file2" "file2.txt" "$desc_b"
assert_not_contains "session B excludes file1" "file1.txt" "$desc_b"
teardown_repo
}
test_concurrent_same_file_no_duplicate() {
printf "\n${BOLD}concurrent: second session skips already-committed file${RESET}\n"
setup_repo
echo "shared-content" > shared.txt
# Both sessions track the same file
hook post-edit '{"session_id":"tst_cd01_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/shared.txt"}}'
hook post-edit '{"session_id":"tst_cd02_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/shared.txt"}}'
# Session 1 commits it
hook session-end '{"session_id":"tst_cd01_xxxxx"}'
local id_after_first
id_after_first=$(parent_change_id)
# Session 2 — file already committed, no diff remaining
hook session-end '{"session_id":"tst_cd02_xxxxx"}'
local id_after_second
id_after_second=$(parent_change_id)
assert_eq "no duplicate commit" "$id_after_first" "$id_after_second"
teardown_repo
}
test_subdirectory_paths() {
printf "\n${BOLD}post-edit: handles subdirectory paths correctly${RESET}\n"
setup_repo
mkdir -p src/components
echo "widget code" > src/components/Widget.tsx
hook post-edit '{"session_id":"tst_sp01_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/src/components/Widget.tsx"}}'
assert_contains "relative nested path" "src/components/Widget.tsx" "$(cat /tmp/jj-claude-${TEST_REPO_ID}-tst_sp01-files)"
hook session-end '{"session_id":"tst_sp01_xxxxx"}'
local desc
desc=$(latest_desc)
assert_contains "commit has basename" "Widget.tsx" "$desc"
local committed
committed=$(rev_stat)
assert_contains "nested file committed" "Widget.tsx" "$committed"
teardown_repo
}
test_session_start_resets_tracking_file() {
printf "\n${BOLD}session-start: resets stale tracking file${RESET}\n"
setup_repo
# Simulate leftover tracking file from a crashed session
echo "stale/path.txt" > /tmp/jj-claude-${TEST_REPO_ID}-tst_ss04-files
hook session-start '{"session_id":"tst_ss04_xxxxx","source":"resume"}'
local size
size=$(wc -c < /tmp/jj-claude-${TEST_REPO_ID}-tst_ss04-files | tr -d ' ')
assert_eq "tracking file reset to empty" "0" "$size"
teardown_repo
}
test_session_start_reaps_stale_tracking_files() {
printf "\n${BOLD}session-start: reaps stale tracking files (>2h old)${RESET}\n"
setup_repo
# Create a fake stale tracking file and backdate it 3 hours (180 min)
local stale_file="/tmp/jj-claude-${TEST_REPO_ID}-tst_dead-files"
echo "some/old/file.txt" > "$stale_file"
backdate_file "$stale_file" 180
# Create a fresh tracking file for a "concurrent" session (should NOT be reaped)
local fresh_file="/tmp/jj-claude-${TEST_REPO_ID}-tst_live-files"
echo "active/file.txt" > "$fresh_file"
assert_file_exists "stale file exists before" "$stale_file"
assert_file_exists "fresh file exists before" "$fresh_file"
hook session-start '{"session_id":"tst_reap_xxxxx","source":"resume"}'
assert_file_not_exists "stale file reaped" "$stale_file"
assert_file_exists "fresh file preserved" "$fresh_file"
rm -f "$fresh_file"
teardown_repo
}
test_session_end_commits_untracked_changes() {
printf "\n${BOLD}session-end: commits untracked changes when no other sessions${RESET}\n"
setup_repo
# Start a session and track file1 via post-edit
hook session-start '{"session_id":"tst_ut01_xxxxx","source":"resume"}'
echo "tracked" > file1.txt
hook post-edit '{"session_id":"tst_ut01_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
# Create a file via "Bash" (not tracked by post-edit)
echo "untracked bash output" > generated.txt
# End session — should commit tracked files, then commit untracked leftovers
hook session-end '{"session_id":"tst_ut01_xxxxx"}'
# The tracked commit is @-- (two back), the untracked commit is @-
local untracked_desc
untracked_desc=$(jj log --no-graph -r '@-' -T 'description' 2>/dev/null)
assert_contains "untracked commit message" "untracked changes (non-Edit/Write)" "$untracked_desc"
local untracked_stat
untracked_stat=$(rev_stat '@-')
assert_contains "generated.txt in untracked commit" "generated.txt" "$untracked_stat"
teardown_repo
}
test_session_end_skips_untracked_when_other_sessions() {
printf "\n${BOLD}session-end: skips untracked changes when other sessions active${RESET}\n"
setup_repo
# Start session A
hook session-start '{"session_id":"tst_ut02_xxxxx","source":"resume"}'
echo "tracked" > file1.txt
hook post-edit '{"session_id":"tst_ut02_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
# Simulate another active session's tracking file
echo "some/file.txt" > /tmp/jj-claude-${TEST_REPO_ID}-tst_othr-files
# Create untracked file
echo "bash output" > generated.txt
# End session A — should commit tracked but NOT untracked (other session active)
hook session-end '{"session_id":"tst_ut02_xxxxx"}'
# The most recent commit (@-) should be the tracked-files commit
local desc
desc=$(latest_desc)
assert_contains "tracked commit created" "file1.txt" "$desc"
assert_not_contains "no untracked commit" "untracked changes" "$desc"
# generated.txt should still be in working copy
local wc
wc=$(wc_stat)
assert_contains "generated.txt still in working copy" "generated.txt" "$wc"
# Clean up fake session file
rm -f /tmp/jj-claude-${TEST_REPO_ID}-tst_othr-files
teardown_repo
}
test_session_start_creates_pid_file() {
printf "\n${BOLD}session-start: creates PID file${RESET}\n"
setup_repo
hook session-start '{"session_id":"tst_pf01_xxxxx","source":"resume"}'
assert_file_exists "PID file created" "/tmp/jj-claude-${TEST_REPO_ID}-tst_pf01-pid"
local pid_content
pid_content=$(cat /tmp/jj-claude-${TEST_REPO_ID}-tst_pf01-pid)
# PID should be a number
assert_eq "PID is numeric" "true" "$(echo "$pid_content" | grep -qE '^[0-9]+$' && echo true || echo false)"
teardown_repo
}
test_session_end_removes_pid_file() {
printf "\n${BOLD}session-end: removes PID file${RESET}\n"
setup_repo
hook session-start '{"session_id":"tst_pf02_xxxxx","source":"resume"}'
assert_file_exists "PID file exists after start" "/tmp/jj-claude-${TEST_REPO_ID}-tst_pf02-pid"
echo "content" > file1.txt
hook post-edit '{"session_id":"tst_pf02_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
hook session-end '{"session_id":"tst_pf02_xxxxx"}'
assert_file_not_exists "PID file removed" "/tmp/jj-claude-${TEST_REPO_ID}-tst_pf02-pid"
teardown_repo
}
test_session_start_reaps_dead_pid_sessions() {
printf "\n${BOLD}session-start: reaps orphaned sessions with dead PIDs${RESET}\n"
setup_repo
# Create a dead PID: start a subprocess and let it exit
sleep 0 &
local dead_pid=$!
wait $dead_pid 2>/dev/null || true
# Create orphaned tracking + PID files with the dead PID
echo "orphan/file.txt" > /tmp/jj-claude-${TEST_REPO_ID}-tst_dead-files
echo "$dead_pid" > /tmp/jj-claude-${TEST_REPO_ID}-tst_dead-pid
assert_file_exists "orphan tracking file exists before" "/tmp/jj-claude-${TEST_REPO_ID}-tst_dead-files"
assert_file_exists "orphan PID file exists before" "/tmp/jj-claude-${TEST_REPO_ID}-tst_dead-pid"
hook session-start '{"session_id":"tst_rp01_xxxxx","source":"resume"}'
assert_file_not_exists "orphan tracking file reaped" "/tmp/jj-claude-${TEST_REPO_ID}-tst_dead-files"
assert_file_not_exists "orphan PID file reaped" "/tmp/jj-claude-${TEST_REPO_ID}-tst_dead-pid"
teardown_repo
}
test_session_start_preserves_live_pid_sessions() {
printf "\n${BOLD}session-start: preserves sessions with live PIDs${RESET}\n"
setup_repo
# Use our own PID as a known-live process
local live_pid=$$
# Create tracking + PID files with a live PID
echo "active/file.txt" > /tmp/jj-claude-${TEST_REPO_ID}-tst_live-files
echo "$live_pid" > /tmp/jj-claude-${TEST_REPO_ID}-tst_live-pid
hook session-start '{"session_id":"tst_rp02_xxxxx","source":"resume"}'
assert_file_exists "live tracking file preserved" "/tmp/jj-claude-${TEST_REPO_ID}-tst_live-files"
assert_file_exists "live PID file preserved" "/tmp/jj-claude-${TEST_REPO_ID}-tst_live-pid"
rm -f /tmp/jj-claude-${TEST_REPO_ID}-tst_live-files /tmp/jj-claude-${TEST_REPO_ID}-tst_live-pid
teardown_repo
}
test_session_end_reaps_dead_before_untracked_check() {
printf "\n${BOLD}session-end: reaps dead sessions before untracked check${RESET}\n"
setup_repo
# Start the real session
hook session-start '{"session_id":"tst_rp03_xxxxx","source":"resume"}'
echo "tracked" > file1.txt
hook post-edit '{"session_id":"tst_rp03_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
# Create untracked file (via "Bash")
echo "bash output" > generated.txt
# Create a dead session that would normally block the untracked commit
sleep 0 &
local dead_pid=$!
wait $dead_pid 2>/dev/null || true
echo "ghost/file.txt" > /tmp/jj-claude-${TEST_REPO_ID}-tst_ghos-files
echo "$dead_pid" > /tmp/jj-claude-${TEST_REPO_ID}-tst_ghos-pid
# End session — dead session should be reaped, allowing untracked commit
hook session-end '{"session_id":"tst_rp03_xxxxx"}'
# The untracked commit should exist (dead session was reaped)
local untracked_desc
untracked_desc=$(jj log --no-graph -r '@-' -T 'description' 2>/dev/null)
assert_contains "untracked commit created (dead session reaped)" "untracked changes (non-Edit/Write)" "$untracked_desc"
assert_file_not_exists "dead tracking file cleaned" "/tmp/jj-claude-${TEST_REPO_ID}-tst_ghos-files"
assert_file_not_exists "dead PID file cleaned" "/tmp/jj-claude-${TEST_REPO_ID}-tst_ghos-pid"
teardown_repo
}
test_squash_wip_refuses_dirty_worktree() {
printf "\n${BOLD}squash-wip: refuses with uncommitted changes${RESET}\n"
setup_repo
# Create a main bookmark and a wip commit
echo "base" > base.txt
jj commit -m "initial commit" 2>/dev/null
jj bookmark set main -r '@-' 2>/dev/null
echo "work" > work.txt
jj commit -m "wip(claude:tst_sq00): work.txt" 2>/dev/null
# Add uncommitted changes to working copy
echo "dirty" > dirty.txt
# squash-wip should refuse
local exit_code=0
"$WORKDIR/scripts/jj-hook.sh" squash-wip < /dev/null 2>/dev/null || exit_code=$?
assert_eq "exits with error" "1" "$exit_code"
# The wip commit should still be there (not squashed)
local count
count=$(jj log --no-graph -r 'main..@-' -T 'change_id.short(8) ++ "\n"' 2>/dev/null | grep -c . || true)
assert_eq "wip commit untouched" "1" "$count"
teardown_repo
}
test_squash_wip() {
printf "\n${BOLD}squash-wip: consolidates multiple wip commits into one${RESET}\n"
setup_repo
# Create initial content and set up main bookmark
echo "base" > base.txt
jj commit -m "initial commit" 2>/dev/null
jj bookmark set main -r '@-' 2>/dev/null
# Create several wip commits
echo "work1" > work1.txt
jj commit -m "wip(claude:tst_sq01): work1.txt" 2>/dev/null
echo "work2" > work2.txt
jj commit -m "wip(claude:tst_sq02): work2.txt" 2>/dev/null
echo "work3" > work3.txt
jj commit -m "wip(claude:tst_sq01): work3.txt" 2>/dev/null
# Count commits before squash
local before_count
before_count=$(jj log --no-graph -r 'main..@-' -T 'change_id.short(8) ++ "\n"' 2>/dev/null | grep -c . || true)
assert_eq "3 wip commits before squash" "3" "$before_count"
# Run squash-wip (no stdin needed)
"$WORKDIR/scripts/jj-hook.sh" squash-wip < /dev/null
# After squash: should be 1 commit between main and @
local after_count
after_count=$(jj log --no-graph -r 'main..@-' -T 'change_id.short(8) ++ "\n"' 2>/dev/null | grep -c . || true)
assert_eq "1 commit after squash" "1" "$after_count"
# The squashed commit should mention both session IDs
local squash_desc
squash_desc=$(latest_desc)
assert_contains "squash message has count" "3 commits" "$squash_desc"
assert_contains "squash message has session tst_sq01" "tst_sq01" "$squash_desc"
assert_contains "squash message has session tst_sq02" "tst_sq02" "$squash_desc"
# All files should be present in the squashed commit
local squash_stat
squash_stat=$(rev_stat)
assert_contains "work1.txt in squashed commit" "work1.txt" "$squash_stat"
assert_contains "work2.txt in squashed commit" "work2.txt" "$squash_stat"
assert_contains "work3.txt in squashed commit" "work3.txt" "$squash_stat"
teardown_repo
}
test_session_start_creates_base_file() {
printf "\n${BOLD}session-start: creates base revision file${RESET}\n"
setup_repo
hook session-start '{"session_id":"tst_bf01_xxxxx","source":"resume"}'
assert_file_exists "base file created" "/tmp/jj-claude-${TEST_REPO_ID}-tst_bf01-base"
local base_content
base_content=$(cat /tmp/jj-claude-${TEST_REPO_ID}-tst_bf01-base)
assert_eq "base is 12 chars" "12" "${#base_content}"
teardown_repo
}
# spec:26174-planctl-context-tokens/R7.1+R7.2+D§7.4
# Exercises the planctl context-window integration: session-start with a
# canonical-UUID session_id must create /tmp/planctl-sessions/<PPID> with the
# UUID on line 1 and a numeric start-stamp on line 2; session-end must remove it.
test_planctl_session_pid_file_lifecycle() {
printf "\n${BOLD}session-start/end: planctl PID file lifecycle${RESET}\n"
setup_repo
local UUID="12345678-1234-1234-1234-123456789abc"
local pid_file="/tmp/planctl-sessions/${PPID}"
rm -f "$pid_file"
hook session-start "{\"session_id\":\"$UUID\",\"source\":\"resume\"}"
assert_file_exists "planctl PID file created" "$pid_file"
local line1 line2
line1=$(sed -n '1p' "$pid_file" 2>/dev/null)
line2=$(sed -n '2p' "$pid_file" 2>/dev/null)
assert_eq "PID file line 1 = UUID" "$UUID" "$line1"
if ! [[ "$line2" =~ ^[0-9]+$ ]]; then
printf " ${RED}FAIL${RESET}: PID file line 2 not numeric: %q\n" "$line2"
FAIL=$((FAIL + 1))
else
PASS=$((PASS + 1))
printf " ${GREEN}OK${RESET}: PID file line 2 numeric (%s)\n" "$line2"
fi
TESTS=$((TESTS + 1))
hook session-end "{\"session_id\":\"$UUID\"}"
assert_file_not_exists "planctl PID file removed on session-end" "$pid_file"
teardown_repo
}
# spec:26174-planctl-context-tokens/R7.1+D§4.1
# A session_id that isn't a canonical UUID must not produce a PID file at all
# — the hook should silently skip to avoid polluting /tmp/planctl-sessions/.
test_planctl_session_nonuuid_rejected() {
printf "\n${BOLD}session-start: non-UUID session_id rejected${RESET}\n"
setup_repo
local pid_file="/tmp/planctl-sessions/${PPID}"
rm -f "$pid_file"
hook session-start '{"session_id":"tst_nu01_xxxxx","source":"resume"}'
assert_file_not_exists "non-UUID does not create PID file" "$pid_file"
teardown_repo
}
test_session_end_removes_base_file() {
printf "\n${BOLD}session-end: removes base file${RESET}\n"
setup_repo
hook session-start '{"session_id":"tst_bf02_xxxxx","source":"resume"}'
assert_file_exists "base file exists after start" "/tmp/jj-claude-${TEST_REPO_ID}-tst_bf02-base"
echo "content" > file1.txt
hook post-edit '{"session_id":"tst_bf02_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
hook session-end '{"session_id":"tst_bf02_xxxxx"}'
assert_file_not_exists "base file removed" "/tmp/jj-claude-${TEST_REPO_ID}-tst_bf02-base"
teardown_repo
}
test_session_end_creates_bookmark() {
printf "\n${BOLD}session-end: creates wip bookmark for session${RESET}\n"
setup_repo
echo "content" > file1.txt
hook post-edit '{"session_id":"tst_bm01_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/file1.txt"}}'
hook session-end '{"session_id":"tst_bm01_xxxxx"}'
local bookmarks
bookmarks=$(jj bookmark list 2>/dev/null)
assert_contains "wip bookmark created" "wip/claude-tst_bm01" "$bookmarks"
teardown_repo
}
test_session_end_fallback_creates_bookmark() {
printf "\n${BOLD}session-end: fallback (no tracking file) also creates bookmark${RESET}\n"
setup_repo
echo "orphaned work" > orphan.txt
hook session-end '{"session_id":"tst_bm02_xxxxx"}'
local bookmarks
bookmarks=$(jj bookmark list 2>/dev/null)
assert_contains "wip bookmark created on fallback" "wip/claude-tst_bm02" "$bookmarks"
teardown_repo
}
test_squash_wip_cleans_bookmarks() {
printf "\n${BOLD}squash-wip: cleans up wip bookmarks${RESET}\n"
setup_repo
echo "base" > base.txt
jj commit -m "initial commit" 2>/dev/null
jj bookmark set main -r '@-' 2>/dev/null
# Create wip commits with bookmarks
echo "work1" > work1.txt
jj commit -m "wip(claude:tst_sq03): work1.txt" 2>/dev/null
jj bookmark set "wip/claude-tst_sq03" -r '@-' 2>/dev/null
"$WORKDIR/scripts/jj-hook.sh" squash-wip < /dev/null
local bookmarks
bookmarks=$(jj bookmark list 2>/dev/null)
assert_not_contains "wip bookmark cleaned up" "wip/claude-tst_sq03" "$bookmarks"
teardown_repo
}
test_auto_squash_on_session_end() {
printf "\n${BOLD}session-end: auto-squash when JJ_HOOK_AUTO_SQUASH=1${RESET}\n"
setup_repo
# Set up main bookmark
echo "base" > base.txt
jj commit -m "initial commit" 2>/dev/null
jj bookmark set main -r '@-' 2>/dev/null
# Create a wip commit via session lifecycle
echo "work1" > work1.txt
hook post-edit '{"session_id":"tst_as01_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/work1.txt"}}'
hook session-end '{"session_id":"tst_as01_xxxxx"}'
# Now start another session with auto-squash
hook session-start '{"session_id":"tst_as02_xxxxx","source":"resume"}'
echo "work2" > work2.txt
hook post-edit '{"session_id":"tst_as02_xxxxx","tool_input":{"file_path":"'"$WORKDIR"'/work2.txt"}}'
# End with auto-squash enabled
JJ_HOOK_AUTO_SQUASH=1 hook session-end '{"session_id":"tst_as02_xxxxx"}'
# After auto-squash: should be 1 commit between main and @-
local after_count
after_count=$(jj log --no-graph -r 'main..@-' -T 'change_id.short(8) ++ "\n"' 2>/dev/null | grep -c . || true)
assert_eq "1 squashed commit after auto-squash" "1" "$after_count"
local squash_desc
squash_desc=$(latest_desc)
assert_contains "squash message" "squashed" "$squash_desc"
teardown_repo
}
test_status_shows_base_revision() {
printf "\n${BOLD}status: shows base revision column${RESET}\n"
setup_repo
hook session-start '{"session_id":"tst_st01_xxxxx","source":"resume"}'
local status_output
status_output=$("$WORKDIR/scripts/jj-hook.sh" status < /dev/null 2>/dev/null)
assert_contains "status shows BASE column" "BASE" "$status_output"
assert_contains "status shows session" "tst_st01" "$status_output"
teardown_repo
}
test_reap_cleans_base_files() {
printf "\n${BOLD}reap: removes base files for dead sessions${RESET}\n"
setup_repo
# Create a dead PID session with base file
sleep 0 &
local dead_pid=$!
wait $dead_pid 2>/dev/null || true
echo "orphan/file.txt" > /tmp/jj-claude-${TEST_REPO_ID}-tst_rb01-files
echo "$dead_pid" > /tmp/jj-claude-${TEST_REPO_ID}-tst_rb01-pid
echo "abc123" > /tmp/jj-claude-${TEST_REPO_ID}-tst_rb01-base
"$WORKDIR/scripts/jj-hook.sh" reap < /dev/null
assert_file_not_exists "base file reaped" "/tmp/jj-claude-${TEST_REPO_ID}-tst_rb01-base"
teardown_repo
}
# ── Run all tests ──────────────────────────────────────────────
printf "\n${BOLD}jj-hook.sh multi-agent workflow tests${RESET}\n"
printf "======================================\n"
test_post_edit_creates_tracking_file
test_post_edit_deduplicates
test_post_edit_tracks_multiple_files
test_post_edit_no_describe
test_session_start_startup_snapshots
test_session_start_resume_no_snapshot
test_session_start_creates_empty_tracking_file
test_session_start_resets_tracking_file
test_session_start_reaps_stale_tracking_files
test_session_end_commits_tracked_then_untracked
test_session_end_no_empty_commit
test_session_end_cleans_tracking_file
test_session_end_commit_message_format
test_session_end_fallback_no_tracking_file
test_session_end_many_files_summary
test_concurrent_separate_commits
test_concurrent_same_file_no_duplicate
test_subdirectory_paths
test_session_end_commits_untracked_changes
test_session_end_skips_untracked_when_other_sessions
test_session_start_creates_pid_file
test_session_end_removes_pid_file
test_session_start_reaps_dead_pid_sessions
test_session_start_preserves_live_pid_sessions
test_session_end_reaps_dead_before_untracked_check
test_session_start_creates_base_file
test_session_end_removes_base_file
test_session_end_creates_bookmark
test_planctl_session_pid_file_lifecycle
test_planctl_session_nonuuid_rejected
test_session_end_fallback_creates_bookmark
test_squash_wip_refuses_dirty_worktree
test_squash_wip
test_squash_wip_cleans_bookmarks
test_auto_squash_on_session_end
test_status_shows_base_revision
test_reap_cleans_base_files
# ── Summary ────────────────────────────────────────────────────
printf "\n======================================\n"
if [ "$FAIL" -eq 0 ]; then
printf "${GREEN}${BOLD}All %d tests passed${RESET}\n\n" "$TESTS"
else
printf "${BOLD}%d passed, ${RED}%d failed${RESET}${BOLD} (of %d)${RESET}\n\n" "$PASS" "$FAIL" "$TESTS"
exit 1
fi