- 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
1044 lines
34 KiB
Bash
Executable file
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
|