Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 457](https://img.shields.io/badge/tests-457-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
67 changes: 60 additions & 7 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 Down Expand Up @@ -74,6 +76,57 @@ install_double_tracking_hook() {
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="$TARGET_DIR/.git/hooks"
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
[ -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 Down
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
48 changes: 48 additions & 0 deletions test/stage.bats
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
load test_helper
load changes_test_helper

setup() {
setup_changes_fixture
export TARGET_DIR="$NOTES_CALLER_PWD"
install_encryption_hook
install_obfuscation_hook
install_double_tracking_hook
}

# ── stage via git add -f ─────────────────────────────────────

@test "git add -f works despite exclude" {
Expand Down Expand Up @@ -69,6 +77,46 @@ load changes_test_helper
[[ "$output" == *"notes/gamma.md"* ]]
}

@test "notes stage refuses missing required hooks before index mutation" {
rm -rf "$NOTES_CALLER_PWD/.git/hooks/pre-commit" \
"$NOTES_CALLER_PWD/.git/hooks/pre-commit.d"
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"* ]]
[[ "$output" == *"notes install-hooks --yes"* ]]
run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
[ -z "$output" ]
}

@test "notes stage refuses stale required hooks before index mutation" {
printf '\n# stale\n' >> \
"$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/encryption"
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" ]
}

@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"
echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"

run notes stage alpha.md --dry-run

[ "$status" -eq 0 ]
[[ "$output" == *"Would stage:"* ]]
run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
[ -z "$output" ]
}

@test "notes stage: explicit unknown path fails instead of silently selecting nothing" {
echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"

Expand Down
17 changes: 17 additions & 0 deletions test/status-suppression.bats
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ load changes_test_helper
grep -q "notes/beta.md" "$exclude"
}

@test "clear_status_suppression clears skip-worktree on managed opaque paths" {
local alpha_id
alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
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"

run git -C "$NOTES_CALLER_PWD" ls-files -v "notes/$alpha_id"
[[ "$output" == S* ]]

clear_status_suppression "$NOTES_CALLER_PWD/notes" "$alpha_id"

run git -C "$NOTES_CALLER_PWD" ls-files -v "notes/$alpha_id"
[[ "$output" != S* ]]
}

@test "status suppression helpers tolerate empty scope under nounset" {
run bash -c '
set -euo pipefail
Expand Down
Loading