From d700d55fd4ddd0094e909526b733d35e58c245f1 Mon Sep 17 00:00:00 2001 From: johnson Date: Tue, 28 Jul 2026 16:19:57 -0400 Subject: [PATCH] fix: verify active Notes hook chain --- README.md | 2 +- lib/hooks.sh | 46 +++++++++++++++++++++++++++++++--------------- test/stage.bats | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c17d2b9..faca7b0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 457](https://img.shields.io/badge/tests-457-brightgreen?style=flat)](test/) +[![tests: 459](https://img.shields.io/badge/tests-459-brightgreen?style=flat)](test/) ![lints: 8](https://img.shields.io/badge/lints-8-blue?style=flat) [![license: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) diff --git a/lib/hooks.sh b/lib/hooks.sh index bf2c8e3..e279175 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -27,17 +27,27 @@ _render_notes_hook_template() { "$template" } -# Ensure a hook dispatcher is installed for the given hook type. +_active_git_hooks_dir() { + local repo_root hooks_dir + repo_root=$(git -C "$TARGET_DIR" rev-parse --show-toplevel) || return 1 + hooks_dir=$(git -C "$TARGET_DIR" rev-parse --git-path hooks) || return 1 + case "$hooks_dir" in + /*) printf '%s\n' "$hooks_dir" ;; + *) printf '%s/%s\n' "$repo_root" "$hooks_dir" ;; + esac +} + +# Ensure the exact Notes dispatcher is installed in Git's active hooks path. # Usage: ensure_hook_dispatcher ensure_hook_dispatcher() { local hook_type="${1:?usage: ensure_hook_dispatcher }" - local hooks_dir="$TARGET_DIR/.git/hooks" + local hooks_dir + hooks_dir=$(_active_git_hooks_dir) || return 1 local dispatcher="$hooks_dir/$hook_type" mkdir -p "$hooks_dir/${hook_type}.d" - # Only install if the dispatcher isn't already in place - if ! grep -q "${hook_type}.d" "$dispatcher" 2>/dev/null; then + if ! cmp -s "$HOOKS_DIR/dispatcher" "$dispatcher"; then cp "$HOOKS_DIR/dispatcher" "$dispatcher" chmod +x "$dispatcher" fi @@ -48,7 +58,9 @@ ensure_hook_dispatcher() { # installed it rather than whichever `notes` command appears first on PATH. install_encryption_hook() { ensure_hook_dispatcher pre-commit - local target="$TARGET_DIR/.git/hooks/pre-commit.d/encryption" + local hooks_dir + hooks_dir=$(_active_git_hooks_dir) || return 1 + local target="$hooks_dir/pre-commit.d/encryption" _render_notes_hook_template "$HOOKS_DIR/encryption.template" "." > "$target" chmod +x "$target" } @@ -58,7 +70,9 @@ install_encryption_hook() { install_obfuscation_hook() { local notes_dir="${1:-notes}" ensure_hook_dispatcher pre-commit - local target="$TARGET_DIR/.git/hooks/pre-commit.d/obfuscation" + local hooks_dir + hooks_dir=$(_active_git_hooks_dir) || return 1 + local target="$hooks_dir/pre-commit.d/obfuscation" _render_notes_hook_template "$HOOKS_DIR/obfuscation.template" "$notes_dir" > "$target" chmod +x "$target" } @@ -69,9 +83,11 @@ install_obfuscation_hook() { install_double_tracking_hook() { local notes_dir="${1:-notes}" ensure_hook_dispatcher pre-commit + local hooks_dir + hooks_dir=$(_active_git_hooks_dir) || return 1 # Name sorts after `obfuscation` so the auto-obfuscation hook can fix a # naively-staged readable before this guard runs. - local target="$TARGET_DIR/.git/hooks/pre-commit.d/verify-double-tracking" + local target="$hooks_dir/pre-commit.d/verify-double-tracking" _render_notes_hook_template "$HOOKS_DIR/verify-double-tracking.template" "$notes_dir" > "$target" chmod +x "$target" } @@ -100,17 +116,15 @@ _installed_hook_matches() { required_pre_commit_hooks_ready() { local notes_dir="${1:-notes}" - local hooks_dir="$TARGET_DIR/.git/hooks" + local hooks_dir + hooks_dir=$(_active_git_hooks_dir) || return 1 local dispatcher="$hooks_dir/pre-commit" local fragments="$hooks_dir/pre-commit.d" local obfuscation_hook="$fragments/obfuscation" local installed_mise_bin [ -x "$dispatcher" ] || return 1 - if ! cmp -s "$HOOKS_DIR/dispatcher" "$dispatcher" \ - && ! grep -qF 'pre-commit.d' "$dispatcher" 2>/dev/null; then - return 1 - fi + cmp -s "$HOOKS_DIR/dispatcher" "$dispatcher" || return 1 [ -x "$obfuscation_hook" ] || return 1 installed_mise_bin=$(sed -n 's/^MISE_BIN="\(.*\)"$/\1/p' \ "$obfuscation_hook") @@ -151,25 +165,27 @@ install_manifest_merge_driver() { # After a branch checkout changes the manifest, post-checkout reconciles stale names. install_deobfuscation_hook() { local notes_dir="${1:-notes}" + local hooks_dir + hooks_dir=$(_active_git_hooks_dir) || return 1 local commit_template="$HOOKS_DIR/post-commit-deobfuscate.template" local merge_template="$HOOKS_DIR/post-merge-deobfuscate.template" local checkout_template="$HOOKS_DIR/post-checkout-deobfuscate.template" # Install for post-commit (deobfuscate after committing) ensure_hook_dispatcher post-commit - local target="$TARGET_DIR/.git/hooks/post-commit.d/deobfuscation" + local target="$hooks_dir/post-commit.d/deobfuscation" _render_notes_hook_template "$commit_template" "$notes_dir" > "$target" chmod +x "$target" # Install for post-merge (deobfuscate after pulling) ensure_hook_dispatcher post-merge - local merge_target="$TARGET_DIR/.git/hooks/post-merge.d/deobfuscation" + local merge_target="$hooks_dir/post-merge.d/deobfuscation" _render_notes_hook_template "$merge_template" "$notes_dir" > "$merge_target" chmod +x "$merge_target" # Install for post-checkout (deobfuscate after branch checkout) ensure_hook_dispatcher post-checkout - local checkout_target="$TARGET_DIR/.git/hooks/post-checkout.d/deobfuscation" + local checkout_target="$hooks_dir/post-checkout.d/deobfuscation" _render_notes_hook_template "$checkout_template" "$notes_dir" > "$checkout_target" chmod +x "$checkout_target" } diff --git a/test/stage.bats b/test/stage.bats index d8ad6c9..f7e4047 100644 --- a/test/stage.bats +++ b/test/stage.bats @@ -104,6 +104,50 @@ setup() { [ -z "$output" ] } +@test "notes stage refuses a dispatcher that does not run required fragments" { + cat > "$NOTES_CALLER_PWD/.git/hooks/pre-commit" <<'HOOK' +#!/usr/bin/env bash +# Looks compatible by mentioning pre-commit.d, but executes nothing. +exit 0 +HOOK + chmod +x "$NOTES_CALLER_PWD/.git/hooks/pre-commit" + echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md" + + run notes stage alpha.md + + [ "$status" -ne 0 ] + [[ "$output" == *"pre-commit hooks are missing or stale"* ]] + run git -C "$NOTES_CALLER_PWD" diff --cached --name-only + [ -z "$output" ] + + notes install-hooks --yes + run notes stage alpha.md + [ "$status" -eq 0 ] + git -C "$NOTES_CALLER_PWD" commit -q -m "repaired dispatcher" + run git -C "$NOTES_CALLER_PWD" show --name-only --format= HEAD + [[ "$output" != *"notes/alpha.md"* ]] +} + +@test "notes stage checks and repairs the active core.hooksPath" { + git -C "$NOTES_CALLER_PWD" config core.hooksPath .active-hooks + echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md" + + run notes stage alpha.md + + [ "$status" -ne 0 ] + [[ "$output" == *"pre-commit hooks are missing or stale"* ]] + run git -C "$NOTES_CALLER_PWD" diff --cached --name-only + [ -z "$output" ] + + notes install-hooks --yes + [ -x "$NOTES_CALLER_PWD/.active-hooks/pre-commit" ] + run notes stage alpha.md + [ "$status" -eq 0 ] + git -C "$NOTES_CALLER_PWD" commit -q -m "active hooks path" + run git -C "$NOTES_CALLER_PWD" show --name-only --format= HEAD + [[ "$output" != *"notes/alpha.md"* ]] +} + @test "notes stage dry-run remains available without required hooks" { rm -rf "$NOTES_CALLER_PWD/.git/hooks/pre-commit" \ "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d"