Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .mise/tasks/obfuscate
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ while IFS=$'\t' read -r relpath id; do
new_count=$((new_count + 1))
done < "$renamed"

git -C "$TARGET_DIR" add -- "${obfuscated_paths[@]}"
git -C "$TARGET_DIR" add --sparse -- "${obfuscated_paths[@]}"
git -C "$TARGET_DIR" rm --cached --quiet --ignore-unmatch -- \
"${readable_paths[@]}"

Expand Down
7 changes: 7 additions & 0 deletions .mise/tasks/stage
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ source "$MISE_CONFIG_ROOT/lib/common.sh"
source "$MISE_CONFIG_ROOT/lib/obfuscate.sh"
source "$MISE_CONFIG_ROOT/lib/suppress.sh"
source "$MISE_CONFIG_ROOT/lib/changes.sh"
source "$MISE_CONFIG_ROOT/lib/hooks.sh"
require_git

notes_dir="${usage_dir:-notes}"
Expand Down Expand Up @@ -215,6 +216,12 @@ if [ "${usage_dry_run:-}" = "true" ]; then
exit 0
fi

if ! required_pre_commit_hooks_ready "$notes_dir"; then
echo "Error: required Notes pre-commit hooks are missing or stale; refusing to stage readable notes." >&2
echo "Run 'notes install-hooks --yes', then retry." >&2
exit 1
fi

# Stage each file using git add -f to bypass .git/info/exclude
staged=0
for relpath in ${to_stage[@]+"${to_stage[@]}"}; do
Expand Down
6 changes: 6 additions & 0 deletions .mise/tasks/unlock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
set -euo pipefail

source "$MISE_CONFIG_ROOT/lib/common.sh"
source "$MISE_CONFIG_ROOT/lib/hooks.sh"
require_git
require_initialized
require_rudi
Expand Down Expand Up @@ -35,3 +36,8 @@ if [ -f "$TARGET_DIR/notes/.manifest" ]; then
cd "$MISE_CONFIG_ROOT" && NOTES_CALLER_PWD="$TARGET_DIR" mise run -q deobfuscate
fi
fi

if ! required_pre_commit_hooks_ready notes; then
echo "Warning: required Notes pre-commit hooks are missing or stale." >&2
echo "Run 'notes install-hooks --yes' before staging readable notes." >&2
fi
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

**Collective memory, encrypted.**

[![tests: 450](https://img.shields.io/badge/tests-450-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)

Expand Down
103 changes: 86 additions & 17 deletions lib/hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ _hook_template_value() {
}

_render_notes_hook_template() {
local template="${1:?usage: _render_notes_hook_template <template> <notes-dir>}"
local notes_dir="${2:?usage: _render_notes_hook_template <template> <notes-dir>}"
local mise_bin
mise_bin=$(command -v mise) || {
echo "Error: mise not found; cannot install notes hooks" >&2
return 1
}
local template="${1:?usage: _render_notes_hook_template <template> <notes-dir> [mise-bin]}"
local notes_dir="${2:?usage: _render_notes_hook_template <template> <notes-dir> [mise-bin]}"
local mise_bin="${3:-}"
if [ -z "$mise_bin" ]; then
mise_bin=$(command -v mise) || {
echo "Error: mise not found; cannot install notes hooks" >&2
return 1
}
fi

sed \
-e "s|__NOTES_DIR__|$(_hook_template_value "$notes_dir")|g" \
Expand All @@ -25,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 <pre-commit|post-commit|post-merge|post-checkout>
ensure_hook_dispatcher() {
local hook_type="${1:?usage: ensure_hook_dispatcher <pre-commit|post-commit|post-merge|post-checkout>}"
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
Expand All @@ -46,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"
}
Expand All @@ -56,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"
}
Expand All @@ -67,13 +83,64 @@ 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"
}

_installed_hook_matches() {
local template="${1:?usage: _installed_hook_matches <template> <notes-dir> <installed-hook> [mise-bin]}"
local notes_dir="${2:?usage: _installed_hook_matches <template> <notes-dir> <installed-hook> [mise-bin]}"
local installed="${3:?usage: _installed_hook_matches <template> <notes-dir> <installed-hook> [mise-bin]}"
local mise_bin="${4:-}"
local expected

[ -x "$installed" ] || return 1
expected=$(mktemp) || return 1
if ! _render_notes_hook_template \
"$template" "$notes_dir" "$mise_bin" > "$expected"; then
rm -f "$expected"
return 1
fi
if cmp -s "$expected" "$installed"; then
rm -f "$expected"
return 0
fi
rm -f "$expected"
return 1
}

required_pre_commit_hooks_ready() {
local notes_dir="${1:-notes}"
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
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")
[ -n "$installed_mise_bin" ] || return 1
[ -x "$installed_mise_bin" ] || return 1
_installed_hook_matches \
"$HOOKS_DIR/encryption.template" "." "$fragments/encryption" \
"$installed_mise_bin" || return 1
_installed_hook_matches \
"$HOOKS_DIR/obfuscation.template" "$notes_dir" "$obfuscation_hook" \
"$installed_mise_bin" || return 1
_installed_hook_matches \
"$HOOKS_DIR/verify-double-tracking.template" "$notes_dir" \
"$fragments/verify-double-tracking" "$installed_mise_bin" || return 1
}

# Install the manifest merge driver.
# Configures git to use our custom merge driver for .manifest files.
install_manifest_merge_driver() {
Expand All @@ -98,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"
}
31 changes: 20 additions & 11 deletions lib/suppress.sh
Original file line number Diff line number Diff line change
Expand Up @@ -197,22 +197,28 @@ _rewrite_exclude_block() {
mv -f "$tmp" "$exclude_file"
}

# Apply an index flag to newline-delimited managed paths in one update-index process.
# Resolve candidates through NUL-delimited Git output so quoted non-ASCII paths
# stay exact, while missing stale-state paths are omitted from the batch.
# Apply index flags to newline-delimited managed paths, one update-index process
# per flag. Resolve candidates through NUL-delimited Git output so quoted
# non-ASCII paths stay exact, while missing stale-state paths are omitted.
_update_index_paths() {
local repo_root="${1:?usage: _update_index_paths <repo_root> <flag>}"
local flag="${2:?usage: _update_index_paths <repo_root> <flag>}"
local path
local repo_root="${1:?usage: _update_index_paths <repo_root> <flag...>}"
shift
[ "$#" -gt 0 ] || return 1
local flags=("$@")
local flag path
local paths=()

while IFS= read -r path; do
[ -n "$path" ] && paths+=("$path")
done
[ ${#paths[@]} -gt 0 ] || return 0

GIT_LITERAL_PATHSPECS=1 git -C "$repo_root" ls-files -z -- "${paths[@]}" |
git -C "$repo_root" update-index "$flag" -z --stdin 2>/dev/null
for flag in "${flags[@]}"; do
if ! GIT_LITERAL_PATHSPECS=1 git -C "$repo_root" ls-files -z -- "${paths[@]}" |
git -C "$repo_root" update-index "$flag" -z --stdin 2>/dev/null; then
return 1
fi
done
}

# Set assume-unchanged on obfuscated paths + add exclude entries for readable names.
Expand Down Expand Up @@ -264,15 +270,18 @@ clear_status_suppression() {
local repo_root="$RESOLVED_REPO_ROOT"
local notes_dir="$RESOLVED_NOTES_DIR"

# Clear assume-unchanged flags in one index update.
# Clear both status-suppression flags so managed opaque paths can be staged
# even when a sparse checkout or interrupted operation marked them skipped.
if [ ${#scoped_ids[@]} -gt 0 ] && [ -n "${scoped_ids[0]}" ]; then
if ! printf '%s\n' "${scoped_ids[@]/#/$notes_dir/}" |
_update_index_paths "$repo_root" --no-assume-unchanged; then
_update_index_paths "$repo_root" \
--no-assume-unchanged --no-skip-worktree; then
return 1
fi
else
if ! awk -F '\t' -v prefix="$notes_dir/" '$1 != "" { print prefix $1 }' "$manifest" |
_update_index_paths "$repo_root" --no-assume-unchanged; then
_update_index_paths "$repo_root" \
--no-assume-unchanged --no-skip-worktree; then
return 1
fi
fi
Expand Down
6 changes: 5 additions & 1 deletion test/changes_test_helper.bash
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
setup() {
setup_changes_fixture() {
export NOTES_CALLER_PWD="$BATS_TEST_TMPDIR"
source "$REPO_DIR/lib/common.sh"
source "$REPO_DIR/lib/obfuscate.sh"
Expand All @@ -24,6 +24,10 @@ setup() {
set_status_suppression "$NOTES_CALLER_PWD/notes"
}

setup() {
setup_changes_fixture
}

add_clean_numbered_notes() {
local count="$1"
local i=1 name
Expand Down
3 changes: 2 additions & 1 deletion test/commit.bats
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,10 @@ HOOK
# ── full lifecycle ────────────────────────────────────────────

@test "full cycle: edit → stage → commit → clean status" {
# Install hooks so post-commit deobfuscates
source "$REPO_DIR/lib/hooks.sh"
install_encryption_hook
install_obfuscation_hook
install_double_tracking_hook
install_deobfuscation_hook

# Verify clean status before edit
Expand Down
22 changes: 22 additions & 0 deletions test/encrypt.bats
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,25 @@ generate_test_key() {
[ "$status" -eq 0 ]
echo "$output" | grep -q "Already unlocked."
}

@test "unlock warns when required pre-commit hooks are missing" {
notes setup --yes
rm -rf "$TARGET_DIR/.git/hooks/pre-commit" \
"$TARGET_DIR/.git/hooks/pre-commit.d"

run notes unlock

[ "$status" -eq 0 ]
[[ "$output" == *"pre-commit hooks are missing or stale"* ]]
[[ "$output" == *"notes install-hooks --yes"* ]]
}

@test "unlock warns when a required pre-commit hook is stale" {
notes setup --yes
printf '\n# stale\n' >> "$TARGET_DIR/.git/hooks/pre-commit.d/obfuscation"

run notes unlock

[ "$status" -eq 0 ]
[[ "$output" == *"pre-commit hooks are missing or stale"* ]]
}
4 changes: 2 additions & 2 deletions test/obfuscate.bats
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ SH
awk_calls=$(grep -c '^awk$' "$call_log" || true)
grep_calls=$(grep -c '^grep$' "$call_log" || true)
basename_calls=$(grep -c '^basename$' "$call_log" || true)
add_calls=$(grep -c $'^git\t.* add -- notes/' "$call_log" || true)
add_calls=$(grep -c $'^git\t.* add --sparse -- notes/' "$call_log" || true)
rm_calls=$(grep -c $'^git\t.* rm --cached --quiet --ignore-unmatch -- notes/' "$call_log" || true)
[ "$status" -eq 0 ]
[[ "$output" == *"Obfuscated 535 file(s)"* ]]
Expand Down Expand Up @@ -342,7 +342,7 @@ SH

[ "$status" -eq 0 ]
[ "$(grep -c $'^git\t.* cat-file --filters :notes/.manifest$' "$call_log" || true)" -eq 1 ]
[ "$(grep -c $'^git\t.* add -- notes/' "$call_log" || true)" -eq 1 ]
[ "$(grep -c $'^git\t.* add --sparse -- notes/' "$call_log" || true)" -eq 1 ]
[ "$(grep -c $'^git\t.* rm --cached --quiet --ignore-unmatch -- notes/' "$call_log" || true)" -eq 1 ]
}

Expand Down
30 changes: 30 additions & 0 deletions test/obfuscation-hooks.bats
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,36 @@ SH
}


@test "auto hook stages a managed opaque path marked skip-worktree" {
notes obfuscate
local alpha_id
alpha_id=$(awk -F '\t' '$2 == "alpha.md" { print $1 }' \
"$NOTES_CALLER_PWD/notes/.manifest")
git -C "$NOTES_CALLER_PWD" add -A
git -C "$NOTES_CALLER_PWD" commit -q --no-verify -m "obfuscated"

notes deobfuscate
notes install-hooks --yes
git -C "$NOTES_CALLER_PWD" update-index \
--no-assume-unchanged "notes/$alpha_id"
git -C "$NOTES_CALLER_PWD" update-index \
--skip-worktree "notes/$alpha_id"

echo "skip-worktree edit" >> "$NOTES_CALLER_PWD/notes/alpha.md"
git -C "$NOTES_CALLER_PWD" add -f notes/alpha.md

run git -C "$NOTES_CALLER_PWD" commit -m "edit skipped alpha"

[ "$status" -eq 0 ]
[[ "$output" == *"Auto-obfuscating 1 file(s)"* ]]
run git -C "$NOTES_CALLER_PWD" show "HEAD:notes/$alpha_id"
[ "$status" -eq 0 ]
[[ "$output" == *"skip-worktree edit"* ]]
run git -C "$NOTES_CALLER_PWD" show --name-only --format= HEAD
[[ "$output" == *"notes/$alpha_id"* ]]
[[ "$output" != *"notes/alpha.md"* ]]
}

@test "installed hooks run notes from installer, not PATH" {
notes obfuscate
git -C "$NOTES_CALLER_PWD" add -A
Expand Down
Loading
Loading