diff --git a/.mise/tasks/diff b/.mise/tasks/diff
index cdcc2f8..f087871 100755
--- a/.mise/tasks/diff
+++ b/.mise/tasks/diff
@@ -2,7 +2,7 @@
#MISE description="Show readable note diffs for the working tree, refs, or a PR"
#USAGE flag "--dir
" default="notes" help="Notes directory relative to repo root (default: notes)"
#USAGE flag "--pr " default="" help="Diff a GitHub PR from the current repo's origin remote"
-#USAGE flag "--out " default="" help="Write readable base/head trees and readable.patch to this directory"
+#USAGE flag "--out " default="" help="Write changed-note base/head artifacts and readable.patch to this directory"
#USAGE arg "[refs]" var=#true default="" help="Optional ref/range: BASE HEAD, BASE..HEAD, or BASE...HEAD. Omit for working-tree diff against HEAD."
set -euo pipefail
@@ -146,8 +146,13 @@ cleanup_out_dir() {
trap 'cleanup_temp_refs; cleanup_out_dir' EXIT
_prepare_diff_workspace "$out_dir"
-materialize_readable_notes_ref "$repo_root" "$notes_dir" "$base_ref" "$out_dir/base"
-materialize_readable_notes_ref "$repo_root" "$notes_dir" "$head_ref" "$out_dir/head"
+materialize_changed_readable_notes_refs \
+ "$repo_root" \
+ "$notes_dir" \
+ "$base_ref" \
+ "$head_ref" \
+ "$out_dir/base" \
+ "$out_dir/head"
generate_readable_notes_patch "$out_dir/base" "$out_dir/head" "$out_dir/readable.patch"
if [ -s "$out_dir/readable.patch" ]; then
@@ -157,7 +162,7 @@ else
fi
if [ -n "$out_arg" ]; then
- echo "Wrote readable base: $out_dir/base" >&2
- echo "Wrote readable head: $out_dir/head" >&2
+ echo "Wrote changed-note base artifacts: $out_dir/base" >&2
+ echo "Wrote changed-note head artifacts: $out_dir/head" >&2
echo "Wrote readable patch: $out_dir/readable.patch" >&2
fi
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b03d5d0..001bff8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -67,11 +67,11 @@ classification and execution, while `test/setup_suite.bash` establishes the
repo-local tool environment and deterministic fixture Git identity once per
BATS invocation.
-Notes intentionally uses eight BATS workers, including within-file
-parallelism. Several suites contain many independent Git fixtures, so the
-starter template's four-job files-only default is materially slower here.
-Keep mutable fixtures under `$BATS_TEST_TMPDIR`; do not introduce shared test
-repositories, fixed temporary paths, or ambient signing dependencies.
+Notes uses eight BATS workers across test files. BATS does not parallelize the
+tests within one file, so split large suites along coherent fixture and behavior
+boundaries when that exposes useful independent work. Keep mutable fixtures
+under `$BATS_TEST_TMPDIR`; do not introduce shared test repositories, fixed
+temporary paths, or ambient signing dependencies.
## Encryption and Git safety
diff --git a/README.md b/README.md
index 7a924e7..15ab63f 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
**Collective memory, encrypted.**
-[](test/)
+[](test/)

[](LICENSE)
diff --git a/lib/diff.sh b/lib/diff.sh
index 7575430..2795a8c 100644
--- a/lib/diff.sh
+++ b/lib/diff.sh
@@ -1,11 +1,6 @@
#!/usr/bin/env bash
# diff.sh — Materialize readable note trees from refs and diff them.
-_ref_has_path() {
- local repo_root="$1" ref="$2" path="$3"
- git -C "$repo_root" cat-file -e "$ref:$path" 2>/dev/null
-}
-
_guard_manifest_path() {
local kind="$1" value="$2"
@@ -27,87 +22,222 @@ _guard_manifest_path() {
esac
}
-_materialize_manifest_for_ref() {
- local repo_root="$1" notes_dir="$2" ref="$3" out="$4"
- : > "$out"
-
- if ! _ref_has_path "$repo_root" "$ref" "$notes_dir/.manifest"; then
- return 0
- fi
-
- git -C "$repo_root" cat-file --filters "$ref:$notes_dir/.manifest" > "$out"
-}
-
-# Materialize a ref's obfuscated notes into readable filenames under dest.
-# Usage: materialize_readable_notes_ref [
-materialize_readable_notes_ref() {
- local repo_root="${1:?usage: materialize_readable_notes_ref ][ }"
- local notes_dir="${2:?usage: materialize_readable_notes_ref ][ }"
- local ref="${3:?usage: materialize_readable_notes_ref ][ }"
- local dest="${4:?usage: materialize_readable_notes_ref ][ }"
- local manifest
- manifest=$(mktemp) || return 1
+# Build and validate one ref's readable-name index without materializing note blobs.
+_prepare_readable_notes_ref_index() {
+ local repo_root="$1" notes_dir="$2" ref="$3" manifest_out="$4"
+ local tree_paths tree_ids raw_manifest validated_manifest missing_manifest unmapped_file
+ tree_paths=$(mktemp) || return 1
+ tree_ids=$(mktemp) || { rm -f "$tree_paths"; return 1; }
+ raw_manifest=$(mktemp) || { rm -f "$tree_paths" "$tree_ids"; return 1; }
+ validated_manifest=$(mktemp) || { rm -f "$tree_paths" "$tree_ids" "$raw_manifest"; return 1; }
+ missing_manifest=$(mktemp) || { rm -f "$tree_paths" "$tree_ids" "$raw_manifest" "$validated_manifest"; return 1; }
+ unmapped_file=$(mktemp) || {
+ rm -f "$tree_paths" "$tree_ids" "$raw_manifest" "$validated_manifest" "$missing_manifest"
+ return 1
+ }
+ : > "$manifest_out"
if ! git -C "$repo_root" cat-file -e "$ref^{tree}" 2>/dev/null; then
echo "Error: not a tree-ish ref: $ref" >&2
- rm -f "$manifest"
+ rm -f "$tree_paths" "$tree_ids" "$raw_manifest" "$validated_manifest" "$missing_manifest" "$unmapped_file"
+ return 1
+ fi
+ if ! git -C "$repo_root" ls-tree -r -z --name-only "$ref" -- "$notes_dir" > "$tree_paths"; then
+ rm -f "$tree_paths" "$tree_ids" "$raw_manifest" "$validated_manifest" "$missing_manifest" "$unmapped_file"
return 1
fi
- mkdir -p "$dest/$notes_dir"
- local has_manifest=false
- if _ref_has_path "$repo_root" "$ref" "$notes_dir/.manifest"; then
- has_manifest=true
+ local tree_path relpath has_manifest=false
+ while IFS= read -r -d '' tree_path; do
+ relpath="${tree_path#"$notes_dir/"}"
+ if [ "$relpath" = ".manifest" ]; then
+ has_manifest=true
+ else
+ printf '%s\n' "$relpath" >> "$tree_ids"
+ fi
+ done < "$tree_paths"
+
+ if ! $has_manifest; then
+ local tree_count=0
+ while IFS= read -r relpath; do
+ [ -z "$relpath" ] && continue
+ tree_count=$((tree_count + 1))
+ done < "$tree_ids"
+ rm -f "$tree_paths" "$tree_ids" "$raw_manifest" "$validated_manifest" "$missing_manifest" "$unmapped_file"
+ if [ "$tree_count" -gt 0 ]; then
+ echo "Error: $ref has $tree_count note file(s) but no $notes_dir/.manifest" >&2
+ return 1
+ fi
+ return 0
fi
- if ! _materialize_manifest_for_ref "$repo_root" "$notes_dir" "$ref" "$manifest"; then
- rm -f "$manifest"
+
+ if ! git -C "$repo_root" cat-file --filters "$ref:$notes_dir/.manifest" > "$raw_manifest"; then
+ rm -f "$tree_paths" "$tree_ids" "$raw_manifest" "$validated_manifest" "$missing_manifest" "$unmapped_file"
return 1
fi
- local manifest_ids
- manifest_ids=$(mktemp) || { rm -f "$manifest"; return 1; }
- # Failure paths may unlink these temp files before returning; open readers stay valid.
- # shellcheck disable=SC2094
+ local id manifest_valid=true
while IFS=$'\t' read -r id relpath; do
[ -z "$id" ] && continue
- _guard_manifest_path "id" "$id" || { rm -f "$manifest" "$manifest_ids"; return 1; }
- _guard_manifest_path "path" "$relpath" || { rm -f "$manifest" "$manifest_ids"; return 1; }
- printf '%s\n' "$id" >> "$manifest_ids"
-
- if ! _ref_has_path "$repo_root" "$ref" "$notes_dir/$id"; then
- echo "Warning: $ref manifest maps $id to $relpath, but $notes_dir/$id is missing" >&2
- continue
+ if ! _guard_manifest_path "id" "$id" || ! _guard_manifest_path "path" "$relpath"; then
+ manifest_valid=false
+ break
fi
+ printf '%s\t%s\n' "$id" "$relpath" >> "$validated_manifest"
+ done < "$raw_manifest"
+ if ! $manifest_valid; then
+ rm -f "$tree_paths" "$tree_ids" "$raw_manifest" "$validated_manifest" "$missing_manifest" "$unmapped_file"
+ return 1
+ fi
- mkdir -p "$(dirname "$dest/$notes_dir/$relpath")"
- if ! git -C "$repo_root" cat-file --filters "$ref:$notes_dir/$id" > "$dest/$notes_dir/$relpath"; then
- rm -f "$manifest" "$manifest_ids"
- return 1
- fi
- done < "$manifest"
+ awk -F '\t' \
+ -v valid="$manifest_out" \
+ -v missing="$missing_manifest" \
+ -v unmapped="$unmapped_file" '
+ FILENAME == ARGV[1] { tree[$0] = 1; next }
+ {
+ mapped[$1] = 1
+ if ($1 in tree) print $0 > valid
+ else print $0 > missing
+ }
+ END {
+ count = 0
+ for (id in tree) if (!(id in mapped)) count++
+ print count > unmapped
+ }
+ ' "$tree_ids" "$validated_manifest"
- local tree_path relpath unmapped_count=0
- while IFS= read -r -d '' tree_path; do
- relpath="${tree_path#"$notes_dir/"}"
- [ "$relpath" = ".manifest" ] && continue
- if ! $has_manifest || ! grep -Fxq -- "$relpath" "$manifest_ids"; then
- unmapped_count=$((unmapped_count + 1))
- fi
- done < <(
- if ! git -C "$repo_root" ls-tree -r -z --name-only "$ref" -- "$notes_dir" 2>/dev/null; then
- :
- fi
- )
+ while IFS=$'\t' read -r id relpath; do
+ [ -z "$id" ] && continue
+ echo "Warning: $ref manifest maps $id to $relpath, but $notes_dir/$id is missing" >&2
+ done < "$missing_manifest"
- rm -f "$manifest" "$manifest_ids"
+ local unmapped_count=0
+ IFS= read -r unmapped_count < "$unmapped_file" || unmapped_count=0
+ rm -f "$tree_paths" "$tree_ids" "$raw_manifest" "$validated_manifest" "$missing_manifest" "$unmapped_file"
if [ "$unmapped_count" -gt 0 ]; then
- if $has_manifest; then
- echo "Error: $ref has $unmapped_count note file(s) not listed in $notes_dir/.manifest" >&2
- else
- echo "Error: $ref has $unmapped_count note file(s) but no $notes_dir/.manifest" >&2
+ echo "Error: $ref has $unmapped_count note file(s) not listed in $notes_dir/.manifest" >&2
+ return 1
+ fi
+}
+
+_changed_note_ids_between_refs() {
+ local repo_root="$1" notes_dir="$2" base_ref="$3" head_ref="$4"
+ local base_manifest="$5" head_manifest="$6" out="$7"
+ local changed_paths candidates
+ changed_paths=$(mktemp) || return 1
+ candidates=$(mktemp) || { rm -f "$changed_paths"; return 1; }
+
+ if ! git -C "$repo_root" diff --name-only -z "$base_ref" "$head_ref" -- "$notes_dir" > "$changed_paths"; then
+ rm -f "$changed_paths" "$candidates"
+ return 1
+ fi
+
+ local path id
+ while IFS= read -r -d '' path; do
+ id="${path#"$notes_dir/"}"
+ case "$id" in
+ .manifest|*/*) continue ;;
+ esac
+ [ -n "$id" ] && printf '%s\n' "$id" >> "$candidates"
+ done < "$changed_paths"
+
+ awk -F '\t' '
+ FILENAME == ARGV[1] { selected_id[$1] = 1; next }
+ FILENAME == ARGV[2] {
+ base_row[$0] = 1
+ row_id[$0] = $1
+ edge_id[++edge_count] = $1
+ edge_path[edge_count] = $2
+ next
+ }
+ {
+ head_row[$0] = 1
+ row_id[$0] = $1
+ edge_id[++edge_count] = $1
+ edge_path[edge_count] = $2
+ }
+ END {
+ for (row in base_row) if (!(row in head_row)) selected_id[row_id[row]] = 1
+ for (row in head_row) if (!(row in base_row)) selected_id[row_id[row]] = 1
+
+ # A malformed or merge-produced manifest can alias one ID to multiple
+ # paths, or multiple IDs to one path. Pull in the entire affected
+ # ID/path component so selection preserves full-tree diff semantics.
+ do {
+ expanded = 0
+ for (i = 1; i <= edge_count; i++) {
+ if ((edge_id[i] in selected_id) || (edge_path[i] in selected_path)) {
+ if (!(edge_id[i] in selected_id)) {
+ selected_id[edge_id[i]] = 1
+ expanded = 1
+ }
+ if (!(edge_path[i] in selected_path)) {
+ selected_path[edge_path[i]] = 1
+ expanded = 1
+ }
+ }
+ }
+ } while (expanded)
+
+ for (id in selected_id) print id
+ }
+ ' "$candidates" "$base_manifest" "$head_manifest" > "$out"
+
+ LC_ALL=C sort -u -o "$out" "$out"
+ rm -f "$changed_paths" "$candidates"
+}
+
+_materialize_selected_notes_ref() {
+ local repo_root="$1" notes_dir="$2" ref="$3" manifest="$4" ids="$5" dest="$6"
+ local selected
+ selected=$(mktemp) || return 1
+ mkdir -p "$dest/$notes_dir"
+
+ awk -F '\t' '
+ FILENAME == ARGV[1] { wanted[$1] = 1; next }
+ $1 in wanted { print }
+ ' "$ids" "$manifest" > "$selected"
+
+ local id relpath output_path output_parent materialize_ok=true
+ while IFS=$'\t' read -r id relpath; do
+ [ -z "$id" ] && continue
+ output_path="$dest/$notes_dir/$relpath"
+ output_parent="${output_path%/*}"
+ mkdir -p "$output_parent"
+ if ! git -C "$repo_root" cat-file --filters "$ref:$notes_dir/$id" > "$output_path"; then
+ materialize_ok=false
+ break
fi
+ done < "$selected"
+
+ rm -f "$selected"
+ $materialize_ok
+}
+
+# Validate both refs and materialize only notes that affect their readable diff.
+materialize_changed_readable_notes_refs() {
+ local repo_root="${1:?usage: materialize_changed_readable_notes_refs }"
+ local notes_dir="${2:?usage: materialize_changed_readable_notes_refs }"
+ local base_ref="${3:?usage: materialize_changed_readable_notes_refs }"
+ local head_ref="${4:?usage: materialize_changed_readable_notes_refs }"
+ local base_dest="${5:?usage: materialize_changed_readable_notes_refs }"
+ local head_dest="${6:?usage: materialize_changed_readable_notes_refs }"
+ local base_manifest head_manifest changed_ids
+ base_manifest=$(mktemp) || return 1
+ head_manifest=$(mktemp) || { rm -f "$base_manifest"; return 1; }
+ changed_ids=$(mktemp) || { rm -f "$base_manifest" "$head_manifest"; return 1; }
+
+ if ! _prepare_readable_notes_ref_index "$repo_root" "$notes_dir" "$base_ref" "$base_manifest" ||
+ ! _prepare_readable_notes_ref_index "$repo_root" "$notes_dir" "$head_ref" "$head_manifest" ||
+ ! _changed_note_ids_between_refs "$repo_root" "$notes_dir" "$base_ref" "$head_ref" "$base_manifest" "$head_manifest" "$changed_ids" ||
+ ! _materialize_selected_notes_ref "$repo_root" "$notes_dir" "$base_ref" "$base_manifest" "$changed_ids" "$base_dest" ||
+ ! _materialize_selected_notes_ref "$repo_root" "$notes_dir" "$head_ref" "$head_manifest" "$changed_ids" "$head_dest"; then
+ rm -f "$base_manifest" "$head_manifest" "$changed_ids"
return 1
fi
+
+ rm -f "$base_manifest" "$head_manifest" "$changed_ids"
}
_copy_tree_contents() {
diff --git a/libexec/test b/libexec/test
index 03c3b71..2e99830 100755
--- a/libexec/test
+++ b/libexec/test
@@ -183,7 +183,7 @@ run_bats() {
if [ "$effective_jobs" -gt 1 ]; then
call_arguments+=(--parallel-binary-name "$effective_parallel_command")
- printf 'BATS parallelism: %s jobs within files via %s\n' \
+ printf 'BATS parallelism: %s jobs across files via %s\n' \
"$effective_jobs" "$effective_parallel_command"
else
printf 'BATS parallelism: serial\n'
diff --git a/test/changes.bats b/test/changes.bats
index 897b4df..c5383ef 100644
--- a/test/changes.bats
+++ b/test/changes.bats
@@ -1,158 +1,9 @@
#!/usr/bin/env bats
-# Tests for notes changes detection and the changes/stage commands.
+# Change detection tests.
load test_helper
-
-setup() {
- export NOTES_CALLER_PWD="$BATS_TEST_TMPDIR"
- source "$REPO_DIR/lib/common.sh"
- source "$REPO_DIR/lib/obfuscate.sh"
- source "$REPO_DIR/lib/suppress.sh"
- source "$REPO_DIR/lib/changes.sh"
-
- # Create a git repo with obfuscated notes
- git -C "$NOTES_CALLER_PWD" init -q
- git -C "$NOTES_CALLER_PWD" config user.name "Test"
- git -C "$NOTES_CALLER_PWD" config user.email "test@test.com"
-
- mkdir -p "$NOTES_CALLER_PWD/notes"
- echo "# Alpha" > "$NOTES_CALLER_PWD/notes/alpha.md"
- echo "# Beta" > "$NOTES_CALLER_PWD/notes/beta.md"
-
- MANIFEST="$NOTES_CALLER_PWD/notes/.manifest"
-
- # Obfuscate, commit, then deobfuscate (simulates normal state)
- rename_to_obfuscated "$NOTES_CALLER_PWD/notes" > /dev/null
- git -C "$NOTES_CALLER_PWD" add -A
- git -C "$NOTES_CALLER_PWD" commit -q -m "initial"
- rename_to_readable "$NOTES_CALLER_PWD/notes" > /dev/null
- set_status_suppression "$NOTES_CALLER_PWD/notes"
-}
-
-add_clean_numbered_notes() {
- local count="$1"
- local i=1 name
- while [ "$i" -le "$count" ]; do
- name=$(printf 'note-%02d.md' "$i")
- printf '# Note %02d\n' "$i" > "$NOTES_CALLER_PWD/notes/$name"
- i=$((i + 1))
- done
-
- rename_to_obfuscated "$NOTES_CALLER_PWD/notes" > /dev/null
- git -C "$NOTES_CALLER_PWD" add -A
- git -C "$NOTES_CALLER_PWD" commit -q -m "add many notes"
- rename_to_readable "$NOTES_CALLER_PWD/notes" > /dev/null
- set_status_suppression "$NOTES_CALLER_PWD/notes"
-}
-
-install_process_counter() {
- local command="$1"
- local real_command
- real_command=$(command -v "$command")
- cat > "$PROCESS_COUNTER_BIN/$command" <> "\${NOTES_PROCESS_COUNTER_DIR:?}/$command.calls"
-exec '$real_command' "\$@"
-SH
- chmod +x "$PROCESS_COUNTER_BIN/$command"
-}
-
-setup_membership_process_counters() {
- PROCESS_COUNTER_BIN="$BATS_TEST_TMPDIR/process-counter-bin"
- NOTES_PROCESS_COUNTER_DIR="$BATS_TEST_TMPDIR/process-counter-results"
- mkdir -p "$PROCESS_COUNTER_BIN" "$NOTES_PROCESS_COUNTER_DIR"
- export NOTES_PROCESS_COUNTER_DIR
- install_process_counter grep
- install_process_counter basename
-}
-
-run_with_process_counters() {
- local function_name="$1"
- shift
- PATH="$PROCESS_COUNTER_BIN:$PATH"
- "$function_name" "$@"
-}
-
-setup_git_argument_counter() {
- local real_git
- PROCESS_COUNTER_BIN="$BATS_TEST_TMPDIR/git-counter-bin"
- NOTES_PROCESS_COUNTER_DIR="$BATS_TEST_TMPDIR/git-counter-results"
- real_git=$(command -v git)
- mkdir -p "$PROCESS_COUNTER_BIN" "$NOTES_PROCESS_COUNTER_DIR"
- export NOTES_PROCESS_COUNTER_DIR
- cat > "$PROCESS_COUNTER_BIN/git" <> "\${NOTES_PROCESS_COUNTER_DIR:?}/git.args"
-exec '$real_git' "\$@"
-SH
- chmod +x "$PROCESS_COUNTER_BIN/git"
-}
-
-setup_clean_filter_counter() {
- CLEAN_FILTER_CALLS="$BATS_TEST_TMPDIR/clean-filter.calls"
- CLEAN_FILTER="$BATS_TEST_TMPDIR/counting-clean-filter"
- export CLEAN_FILTER_CALLS
- cat > "$CLEAN_FILTER" <<'BASH'
-#!/usr/bin/env bash
-printf '1\n' >> "${CLEAN_FILTER_CALLS:?}"
-cat
-BASH
- chmod +x "$CLEAN_FILTER"
- git -C "$NOTES_CALLER_PWD" config filter.notes-test.clean "$CLEAN_FILTER"
- git -C "$NOTES_CALLER_PWD" config filter.notes-test.smudge cat
- git -C "$NOTES_CALLER_PWD" config filter.notes-test.required true
- printf 'notes/** filter=notes-test\n' > "$NOTES_CALLER_PWD/.gitattributes"
- git -C "$NOTES_CALLER_PWD" add .gitattributes
- git -C "$NOTES_CALLER_PWD" commit -q -m "add counting clean filter"
-}
-
-clean_filter_call_count() {
- if [ -f "$CLEAN_FILTER_CALLS" ]; then
- wc -l < "$CLEAN_FILTER_CALLS" | tr -d ' '
- else
- printf '0\n'
- fi
-}
-
-record_deobfuscation_state_for_manifest() {
- local ids=()
- while IFS=$'\t' read -r id relpath; do
- [ -z "$id" ] && continue
- ids+=("$id")
- done < "$MANIFEST"
- _record_deobfuscation_base_hashes "$NOTES_CALLER_PWD/notes" "${ids[@]}"
-}
-
-delete_manifest_entry_from_head() {
- local relpath="$1"
- local id
- id=$(manifest_id_for_name "$MANIFEST" "$relpath")
- [ -n "$id" ]
-
- git -C "$NOTES_CALLER_PWD" update-index --no-assume-unchanged "notes/$id" 2>/dev/null || true
- git -C "$NOTES_CALLER_PWD" rm -q --cached "notes/$id"
- awk -F '\t' -v path="$relpath" '$2 != path { print }' "$MANIFEST" > "$MANIFEST.tmp"
- mv "$MANIFEST.tmp" "$MANIFEST"
- git -C "$NOTES_CALLER_PWD" add notes/.manifest
- git -C "$NOTES_CALLER_PWD" commit -q -m "delete $relpath"
-
- printf '%s' "$id"
-}
-
-rename_manifest_entry_in_head() {
- local old_relpath="$1" new_relpath="$2"
- local id
- id=$(manifest_id_for_name "$MANIFEST" "$old_relpath")
- [ -n "$id" ]
-
- awk -F '\t' -v old="$old_relpath" -v new="$new_relpath" 'BEGIN { OFS="\t" } $2 == old { $2 = new } { print }' "$MANIFEST" > "$MANIFEST.tmp"
- mv "$MANIFEST.tmp" "$MANIFEST"
- git -C "$NOTES_CALLER_PWD" add notes/.manifest
- git -C "$NOTES_CALLER_PWD" commit -q -m "rename $old_relpath"
-
- printf '%s' "$id"
-}
+load changes_test_helper
# ── detect_changes ────────────────────────────────────────────
@@ -380,791 +231,3 @@ SH
[ "$status" -eq 0 ]
[[ "$output" == *"modified"*"alpha.md"* ]]
}
-
-# ── exclude management ────────────────────────────────────────
-
-@test "set_status_suppression adds exclude entries" {
- local repo_root
- repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
- local exclude="$repo_root/.git/info/exclude"
-
- # Suppression was already set in setup
- [ -f "$exclude" ]
- grep -q "notes/alpha.md" "$exclude"
- grep -q "notes/beta.md" "$exclude"
- grep -q "# BEGIN notes-obfuscation" "$exclude"
- grep -q "# END notes-obfuscation" "$exclude"
-}
-
-@test "set_status_suppression gives clean git status" {
- # After setup, git status should be clean
- run git -C "$NOTES_CALLER_PWD" status --porcelain
- [ -z "$output" ]
-}
-
-@test "clear_status_suppression removes exclude entries" {
- clear_status_suppression "$NOTES_CALLER_PWD/notes"
-
- local repo_root
- repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
- local exclude="$repo_root/.git/info/exclude"
-
- # Managed block should be gone
- if [ -f "$exclude" ]; then
- ! grep -q "notes/alpha.md" "$exclude"
- ! grep -q "# BEGIN notes-obfuscation" "$exclude"
- fi
-}
-
-@test "exclude preserves non-managed content" {
- local repo_root
- repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
- local exclude="$repo_root/.git/info/exclude"
-
- # Add custom content before the managed block
- local tmp
- tmp=$(mktemp)
- echo "# My custom excludes" > "$tmp"
- echo "build/" >> "$tmp"
- if [ -f "$exclude" ]; then
- cat "$exclude" >> "$tmp"
- fi
- mv "$tmp" "$exclude"
-
- # Re-run suppression (should preserve custom content)
- clear_status_suppression "$NOTES_CALLER_PWD/notes"
- set_status_suppression "$NOTES_CALLER_PWD/notes"
-
- grep -q "# My custom excludes" "$exclude"
- grep -q "build/" "$exclude"
- grep -q "notes/alpha.md" "$exclude"
-}
-
-@test "scoped set_status_suppression adds only specified entries" {
- # Clear all first
- clear_status_suppression "$NOTES_CALLER_PWD/notes"
-
- local alpha_id
- alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
-
- # Set suppression for just alpha
- set_status_suppression "$NOTES_CALLER_PWD/notes" "$alpha_id"
-
- local repo_root
- repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
- local exclude="$repo_root/.git/info/exclude"
-
- grep -q "notes/alpha.md" "$exclude"
- ! grep -q "notes/beta.md" "$exclude"
-}
-
-@test "scoped clear_status_suppression removes only specified entries" {
- local alpha_id
- alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
-
- # Clear just alpha
- clear_status_suppression "$NOTES_CALLER_PWD/notes" "$alpha_id"
-
- local repo_root
- repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
- local exclude="$repo_root/.git/info/exclude"
-
- ! grep -q "notes/alpha.md" "$exclude"
- grep -q "notes/beta.md" "$exclude"
-}
-
-@test "status suppression helpers tolerate empty scope under nounset" {
- run bash -c '
- set -euo pipefail
- source "$1/lib/common.sh"
- source "$1/lib/suppress.sh"
- set_status_suppression "$2/notes"
- clear_status_suppression "$2/notes"
- ' _ "$REPO_DIR" "$NOTES_CALLER_PWD"
-
- [ "$status" -eq 0 ]
-}
-
-@test "notes suppress-refresh rebuilds stale exclude entries" {
- local repo_root exclude
- repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
- exclude="$repo_root/.git/info/exclude"
-
- cat > "$exclude" < "$NOTES_CALLER_PWD/notes/deadbeef"
- git -C "$NOTES_CALLER_PWD" add notes/deadbeef
- git -C "$NOTES_CALLER_PWD" commit -q -m "add stale old id"
- git -C "$NOTES_CALLER_PWD" update-index --assume-unchanged notes/deadbeef
- printf 'deadbeef\told.md\t012345\n' > "$NOTES_CALLER_PWD/.git/info/notes-obfuscation-state"
-
- run git -C "$NOTES_CALLER_PWD" ls-files -v notes/deadbeef
- [ "$status" -eq 0 ]
- [[ "$output" == h* ]]
-
- run notes suppress-refresh
- [ "$status" -eq 0 ]
-
- run git -C "$NOTES_CALLER_PWD" ls-files -v notes/deadbeef
- [ "$status" -eq 0 ]
- [[ "$output" == H* ]]
-}
-
-# ── stage via git add -f ─────────────────────────────────────
-
-@test "git add -f works despite exclude" {
- echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- # Normal git add should fail (file is excluded)
- git -C "$NOTES_CALLER_PWD" add "$NOTES_CALLER_PWD/notes/alpha.md" 2>/dev/null || true
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [[ "$output" != *"alpha.md"* ]]
-
- # Force add should work
- git -C "$NOTES_CALLER_PWD" add -f "$NOTES_CALLER_PWD/notes/alpha.md"
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [[ "$output" == *"alpha.md"* ]]
-}
-
-@test "notes stage: no args requires explicit scope" {
- echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
- echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"
-
- run notes stage
- [ "$status" -ne 0 ]
- [[ "$output" == *"provide note paths or --all"* ]]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-@test "notes stage: no args ignores inherited usage_files and still requires scope" {
- echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- usage_files="gamma.md" run notes stage
- [ "$status" -ne 0 ]
- [[ "$output" == *"provide note paths or --all"* ]]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-@test "notes stage --all stages modified and new notes" {
- echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
- echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"
-
- run notes stage --all
- [ "$status" -eq 0 ]
- [[ "$output" == *"staged: alpha.md"* ]]
- [[ "$output" == *"staged: gamma.md"* ]]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [[ "$output" == *"notes/alpha.md"* ]]
- [[ "$output" == *"notes/gamma.md"* ]]
-}
-
-@test "notes stage: explicit file stages a new note" {
- echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"
-
- run notes stage gamma.md
- [ "$status" -eq 0 ]
- [[ "$output" == *"staged: gamma.md"* ]]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [[ "$output" == *"notes/gamma.md"* ]]
-}
-
-@test "notes stage: explicit unknown path fails instead of silently selecting nothing" {
- echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes stage alhpa.md
- [ "$status" -ne 0 ]
- [[ "$output" == *"requested note path"* ]]
- [[ "$output" == *"alhpa.md"* ]]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-@test "notes stage: path traversal argument fails instead of selecting nothing" {
- echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
- echo "readme" > "$NOTES_CALLER_PWD/README.md"
-
- run notes stage ../README.md
- [ "$status" -ne 0 ]
- [[ "$output" == *"requested note path"* ]]
- [[ "$output" == *"../README.md"* ]]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-@test "notes stage --dry-run: deleted note leaves manifest and index untouched" {
- local manifest_before
- manifest_before=$(cat "$MANIFEST")
- rm "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes stage --all --dry-run
- [ "$status" -eq 0 ]
- [[ "$output" == *"Would stage:"* ]]
- [[ "$output" == *"alpha.md"* ]]
- [ "$(cat "$MANIFEST")" = "$manifest_before" ]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-@test "notes stage: deleted note does not mutate manifest when index removal fails" {
- local manifest_before
- manifest_before=$(cat "$MANIFEST")
- rm "$NOTES_CALLER_PWD/notes/alpha.md"
- touch "$NOTES_CALLER_PWD/.git/index.lock"
-
- run notes stage --all
- rm -f "$NOTES_CALLER_PWD/.git/index.lock"
-
- [ "$status" -ne 0 ]
- [ "$(cat "$MANIFEST")" = "$manifest_before" ]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-@test "notes stage: deleted note rolls back manifest when manifest staging fails" {
- local alpha_id manifest_before real_git
- alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
- manifest_before=$(cat "$MANIFEST")
- real_git=$(command -v git)
- rm "$NOTES_CALLER_PWD/notes/alpha.md"
-
- mkdir -p "$BATS_TEST_TMPDIR/bin"
- cat > "$BATS_TEST_TMPDIR/bin/git" <&2
- exit 99
-fi
-exec "$real_git" "\$@"
-SH
- chmod +x "$BATS_TEST_TMPDIR/bin/git"
-
- PATH="$BATS_TEST_TMPDIR/bin:$PATH" run notes stage --all
- [ "$status" -ne 0 ]
- [ "$(cat "$MANIFEST")" = "$manifest_before" ]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-status
- [[ "$output" == *$'D\tnotes/'"$alpha_id"* ]]
- [[ "$output" != *$'M\tnotes/.manifest'* ]]
-
- run notes stage --all
- [ "$status" -eq 0 ]
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-status
- [[ "$output" == *$'D\tnotes/'"$alpha_id"* ]]
- [[ "$output" == *$'M\tnotes/.manifest'* ]]
-}
-
-@test "notes stage: deleted note stages manifest update in same commit" {
- source "$REPO_DIR/lib/hooks.sh"
- install_obfuscation_hook
- install_deobfuscation_hook
-
- local alpha_id
- alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
- rm "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes stage --all
- [ "$status" -eq 0 ]
- [[ "$output" == *"staged (delete): alpha.md"* ]]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-status
- [[ "$output" == *$'D\tnotes/'"$alpha_id"* ]]
- [[ "$output" == *$'M\tnotes/.manifest'* ]]
-
- git -C "$NOTES_CALLER_PWD" commit -q -m "delete alpha"
-
- run git -C "$NOTES_CALLER_PWD" status --porcelain
- [ -z "$output" ]
-
- run git -C "$NOTES_CALLER_PWD" cat-file --filters HEAD:notes/.manifest
- [[ "$output" != *"alpha.md"* ]]
- [[ "$output" == *"beta.md"* ]]
-}
-
-@test "notes stage: refuses dual-present differing readable and obfuscated pair" {
- local alpha_id
- alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
-
- echo "# Alpha local edit" > "$NOTES_CALLER_PWD/notes/alpha.md"
- echo "# Alpha incoming upstream" > "$NOTES_CALLER_PWD/notes/$alpha_id"
-
- run notes stage alpha.md
- [ "$status" -ne 0 ]
- [[ "$output" == *"incomplete deobfuscation"* ]]
- [[ "$output" == *"alpha.md"* ]]
- [[ "$output" == *"notes deobfuscate"* ]]
- [[ "$output" == *"notes changes alpha.md"* ]]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [[ "$output" != *"notes/alpha.md"* ]]
-}
-
-@test "notes stage: refuses double-tracked notes (readable + obfuscated both in index)" {
- local alpha_id
- alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
-
- # Simulate the double-tracking bug from notes#51: both readable and hex tracked.
- # Use identical content so the dual-present conflict check does not fire first.
- echo "# Alpha obfuscated" > "$NOTES_CALLER_PWD/notes/alpha.md"
- echo "# Alpha obfuscated" > "$NOTES_CALLER_PWD/notes/$alpha_id"
- git -C "$NOTES_CALLER_PWD" add -f "notes/alpha.md"
-
- run notes stage alpha.md
- [ "$status" -ne 0 ]
- [[ "$output" == *"double-tracked"* ]]
- [[ "$output" == *"alpha.md"* ]]
- [[ "$output" == *"notes#51"* ]]
-}
-
-@test "notes stage: does not refuse when readable is only on disk, not tracked" {
- local alpha_id
- alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
-
- # Normal deobfuscated state: readable on disk, hex tracked in index
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes stage alpha.md
- [ "$status" -eq 0 ]
- [[ "$output" == *"staged: alpha.md"* ]]
-}
-
-@test "notes stage --all refuses stale readable files left from another branch" {
- local repo="$BATS_TEST_TMPDIR/branch-repo"
- mkdir -p "$repo/notes"
- git -C "$repo" init -q -b main
- git -C "$repo" config user.name "Test"
- git -C "$repo" config user.email "test@test.com"
-
- echo "# Alpha" > "$repo/notes/alpha.md"
- rename_to_obfuscated "$repo/notes" > /dev/null
- git -C "$repo" add -A
- git -C "$repo" commit -q -m "add alpha"
- rename_to_readable "$repo/notes" > /dev/null
- set_status_suppression "$repo/notes"
-
- git -C "$repo" branch feature
-
- echo "# Beta" > "$repo/notes/beta.md"
- rename_to_obfuscated "$repo/notes" > /dev/null
- git -C "$repo" add -A
- git -C "$repo" commit -q -m "add beta on main"
- rename_to_readable "$repo/notes" > /dev/null
- set_status_suppression "$repo/notes"
-
- git -C "$repo" checkout -q feature
- [ -f "$repo/notes/beta.md" ]
- echo "alpha edit" >> "$repo/notes/alpha.md"
-
- NOTES_CALLER_PWD="$repo" run notes changes --summary
- [ "$status" -eq 0 ]
- [[ "$output" == *"stale-readable: beta.md"* ]]
- [[ "$output" != *"new: beta.md"* ]]
-
- NOTES_CALLER_PWD="$repo" run notes stage --all
- [ "$status" -ne 0 ]
- [[ "$output" == *"stale readable note"* ]]
- [[ "$output" == *"beta.md"* ]]
-
- run git -C "$repo" diff --cached --name-only
- [[ "$output" != *"notes/alpha.md"* ]]
- [[ "$output" != *"notes/beta.md"* ]]
-}
-
-@test "notes changes: stale readable is not reported as a new note" {
- record_deobfuscation_state_for_manifest
- delete_manifest_entry_from_head "beta.md" > /dev/null
-
- run notes changes --summary
- [ "$status" -eq 0 ]
- [[ "$output" == *"stale-readable: beta.md"* ]]
- [[ "$output" != *"new: beta.md"* ]]
-}
-
-@test "notes stage: refuses explicit stale readable note" {
- record_deobfuscation_state_for_manifest
- delete_manifest_entry_from_head "beta.md" > /dev/null
-
- run notes stage beta.md
- [ "$status" -ne 0 ]
- [[ "$output" == *"stale readable note"* ]]
- [[ "$output" == *"beta.md"* ]]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [[ "$output" != *"notes/beta.md"* ]]
-}
-
-@test "deobfuscate removes clean stale readable after manifest deletion" {
- record_deobfuscation_state_for_manifest
- delete_manifest_entry_from_head "beta.md" > /dev/null
-
- run notes deobfuscate
- [ "$status" -eq 0 ]
- [[ "$output" == *"removed stale readable: beta.md"* ]]
- [ ! -f "$NOTES_CALLER_PWD/notes/beta.md" ]
- ! grep -q "notes/beta.md" "$NOTES_CALLER_PWD/.git/info/exclude"
-
- run notes changes --summary
- [ "$status" -eq 0 ]
- [[ "$output" == *"No changes."* ]]
-}
-
-@test "deobfuscate removes clean stale readable with legacy id-hash state" {
- local beta_id beta_hash state
- beta_id=$(manifest_id_for_name "$MANIFEST" "beta.md")
- beta_hash=$(git -C "$NOTES_CALLER_PWD" hash-object -- "$NOTES_CALLER_PWD/notes/beta.md")
- state="$NOTES_CALLER_PWD/.git/info/notes-obfuscation-state"
- mkdir -p "$(dirname "$state")"
- printf '%s\t%s\n' "$beta_id" "$beta_hash" > "$state"
-
- delete_manifest_entry_from_head "beta.md" > /dev/null
-
- run notes deobfuscate
- [ "$status" -eq 0 ]
- [[ "$output" == *"removed stale readable: beta.md"* ]]
- [ ! -f "$NOTES_CALLER_PWD/notes/beta.md" ]
-
- run notes changes --summary
- [ "$status" -eq 0 ]
- [[ "$output" == *"No changes."* ]]
-}
-
-@test "deobfuscate quarantines dirty stale readable after manifest deletion" {
- record_deobfuscation_state_for_manifest
- echo "local edit" >> "$NOTES_CALLER_PWD/notes/beta.md"
- delete_manifest_entry_from_head "beta.md" > /dev/null
-
- run notes deobfuscate
- [ "$status" -eq 0 ]
- [[ "$output" == *"quarantined stale readable note: beta.md"* ]]
- [ ! -f "$NOTES_CALLER_PWD/notes/beta.md" ]
- [ -f "$NOTES_CALLER_PWD/.git/info/notes-stale-readable/beta.md" ]
- [[ "$(cat "$NOTES_CALLER_PWD/.git/info/notes-stale-readable/beta.md")" == *"local edit"* ]]
- ! grep -q "notes/beta.md" "$NOTES_CALLER_PWD/.git/info/exclude"
-
- run notes changes --summary
- [ "$status" -eq 0 ]
- [[ "$output" == *"No changes."* ]]
-}
-
-@test "deobfuscate reconciles stale old path when manifest renames a note" {
- record_deobfuscation_state_for_manifest
- local beta_id
- beta_id=$(rename_manifest_entry_in_head "beta.md" "renamed-beta.md")
- git -C "$NOTES_CALLER_PWD" update-index --no-assume-unchanged "notes/$beta_id" 2>/dev/null || true
- git -C "$NOTES_CALLER_PWD" checkout -- "notes/$beta_id"
-
- run notes deobfuscate
- [ "$status" -eq 0 ]
- [[ "$output" == *"removed stale readable: beta.md"* ]]
- [ ! -f "$NOTES_CALLER_PWD/notes/beta.md" ]
- [ -f "$NOTES_CALLER_PWD/notes/renamed-beta.md" ]
- [[ "$(cat "$NOTES_CALLER_PWD/notes/renamed-beta.md")" == *"# Beta"* ]]
- ! grep -q "notes/beta.md" "$NOTES_CALLER_PWD/.git/info/exclude"
- grep -q "notes/renamed-beta.md" "$NOTES_CALLER_PWD/.git/info/exclude"
-
- run notes changes --summary
- [ "$status" -eq 0 ]
- [[ "$output" == *"No changes."* ]]
-}
-
-@test "notes stage: path-limited stage does not leak unselected new manifest entry through pre-commit hook" {
- source "$REPO_DIR/lib/hooks.sh"
- install_obfuscation_hook
- install_deobfuscation_hook
-
- echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"
- printf 'cccccccc\tgamma.md\n' >> "$MANIFEST"
- echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes stage alpha.md
- [ "$status" -eq 0 ]
- [[ "$output" == *"staged: alpha.md"* ]]
- [[ "$output" != *"gamma.md"* ]]
-
- git -C "$NOTES_CALLER_PWD" commit -q -m "update alpha"
-
- run git -C "$NOTES_CALLER_PWD" cat-file --filters HEAD:notes/.manifest
- [[ "$output" == *"alpha.md"* ]]
- [[ "$output" != *"gamma.md"* ]]
-
- run git -C "$NOTES_CALLER_PWD" show --name-only --format= HEAD
- [[ "$output" != *"gamma"* ]]
-}
-
-# ── commit wrapper ───────────────────────────────────────────
-
-@test "notes commit post-check batches readable-path inspection" {
- add_clean_numbered_notes 40
- notes install-hooks --yes >/dev/null
- printf '# changed\n' >> "$NOTES_CALLER_PWD/notes/alpha.md"
- setup_git_argument_counter
-
- run run_with_process_counters notes commit -m "update alpha" alpha.md
-
- [ "$status" -eq 0 ]
- [ "$(grep -c ' ls-files ' "$NOTES_PROCESS_COUNTER_DIR/git.args" || true)" -le 3 ]
-}
-
-@test "notes commit: explicit file commits modified note and leaves clean readable tree" {
- notes install-hooks --yes
-
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes commit -m "update alpha" alpha.md
- [ "$status" -eq 0 ]
- [[ "$output" == *"Committed note changes"* ]]
- [[ "$output" == *"Notes changes: clean"* ]]
-
- [ "$(git -C "$NOTES_CALLER_PWD" log -1 --format=%s)" = "update alpha" ]
- [ -f "$NOTES_CALLER_PWD/notes/alpha.md" ]
- [[ "$(cat "$NOTES_CALLER_PWD/notes/alpha.md")" == *"Alpha v2"* ]]
-
- local committed_files
- committed_files=$(git -C "$NOTES_CALLER_PWD" show --name-only --format='' HEAD -- notes/)
- ! echo "$committed_files" | grep -q "alpha.md"
-
- run detect_changes "$NOTES_CALLER_PWD/notes"
- [ -z "$output" ]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-
- run git -C "$NOTES_CALLER_PWD" ls-files notes/alpha.md
- [ -z "$output" ]
-}
-
-@test "notes commit --all commits modified new and deleted notes" {
- notes install-hooks --yes
-
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
- echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"
- rm "$NOTES_CALLER_PWD/notes/beta.md"
-
- run notes commit --all -m "update all notes"
- [ "$status" -eq 0 ]
- [[ "$output" == *"staged: alpha.md"* ]]
- [[ "$output" == *"staged: gamma.md"* ]]
- [[ "$output" == *"staged (delete): beta.md"* ]]
- [[ "$output" == *"Notes changes: clean"* ]]
-
- [ -f "$NOTES_CALLER_PWD/notes/alpha.md" ]
- [ -f "$NOTES_CALLER_PWD/notes/gamma.md" ]
- [ ! -f "$NOTES_CALLER_PWD/notes/beta.md" ]
- ! grep -q "beta.md" "$MANIFEST"
- grep -q "gamma.md" "$MANIFEST"
-
- run detect_changes "$NOTES_CALLER_PWD/notes"
- [ -z "$output" ]
-}
-
-@test "notes commit: path-limited commit leaves unrelated dirty notes uncommitted" {
- notes install-hooks --yes
-
- local alpha_id beta_id
- alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
- beta_id=$(manifest_id_for_name "$MANIFEST" "beta.md")
-
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
- echo "# Beta v2" > "$NOTES_CALLER_PWD/notes/beta.md"
-
- run notes commit -m "update alpha only" alpha.md
- [ "$status" -eq 0 ]
- [[ "$output" == *"Remaining note changes"* ]]
- [[ "$output" == *"modified: beta.md"* ]]
- [[ "$output" != *"modified: alpha.md"* ]]
-
- git -C "$NOTES_CALLER_PWD" cat-file --filters "HEAD:notes/$alpha_id" | grep -q "Alpha v2"
- git -C "$NOTES_CALLER_PWD" cat-file --filters "HEAD:notes/$beta_id" | grep -q "# Beta"
- ! git -C "$NOTES_CALLER_PWD" cat-file --filters "HEAD:notes/$beta_id" | grep -q "Beta v2"
-
- run detect_changes "$NOTES_CALLER_PWD/notes"
- [[ "$output" == *"modified"*"beta.md"* ]]
- [[ "$output" != *"alpha.md"* ]]
-}
-
-@test "notes commit --dry-run shows staged plan without staging or committing" {
- local before
- before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes commit --dry-run -m "update alpha" alpha.md
- [ "$status" -eq 0 ]
- [[ "$output" == *"Would stage:"* ]]
- [[ "$output" == *"alpha.md"* ]]
- [[ "$output" == *"Would commit with message: update alpha"* ]]
- [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-@test "notes commit: no args requires explicit scope" {
- notes install-hooks --yes
- local before
- before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes commit -m "missing scope"
- [ "$status" -ne 0 ]
- [[ "$output" == *"provide note paths or --all"* ]]
- [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-@test "notes commit: explicit unknown path fails instead of silently committing nothing" {
- notes install-hooks --yes
- local before
- before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes commit -m "typo" alhpa.md
- [ "$status" -ne 0 ]
- [[ "$output" == *"requested note path"* ]]
- [[ "$output" == *"alhpa.md"* ]]
- [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-@test "notes commit: path traversal argument fails instead of silently committing nothing" {
- notes install-hooks --yes
- local before
- before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
- echo "readme" > "$NOTES_CALLER_PWD/README.md"
-
- run notes commit -m "traversal" ../README.md
- [ "$status" -ne 0 ]
- [[ "$output" == *"requested note path"* ]]
- [[ "$output" == *"../README.md"* ]]
- [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-@test "notes commit: refuses pre-staged changes before staging notes" {
- notes install-hooks --yes
- local before
- before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
- echo "readme" > "$NOTES_CALLER_PWD/README.md"
- git -C "$NOTES_CALLER_PWD" add README.md
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes commit --all -m "should refuse"
- [ "$status" -ne 0 ]
- [[ "$output" == *"staged changes already exist"* ]]
- [[ "$output" == *"README.md"* ]]
- [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ "$output" = "README.md" ]
-}
-
-@test "notes commit: detects non-note paths added by another pre-commit hook" {
- notes install-hooks --yes
- mkdir -p "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d"
- cat > "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/zz-stage-generated" <<'HOOK'
-#!/usr/bin/env bash
-set -euo pipefail
-printf 'generated by hook\n' > hook-generated.txt
-git add hook-generated.txt
-HOOK
- chmod +x "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/zz-stage-generated"
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes commit -m "update alpha" alpha.md
- [ "$status" -ne 0 ]
- [[ "$output" == *"included non-note path"* ]]
- [[ "$output" == *"hook-generated.txt"* ]]
-
- run git -C "$NOTES_CALLER_PWD" show --name-only --format= HEAD
- [[ "$output" == *"hook-generated.txt"* ]]
-}
-
-@test "notes commit: refuses missing hooks before staging or committing" {
- local before
- before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- run notes commit -m "update alpha" alpha.md
- [ "$status" -ne 0 ]
- [[ "$output" == *"requires installed obfuscation/deobfuscation hooks"* ]]
- [[ "$output" == *"notes install-hooks"* ]]
- [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
-
- run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
- [ -z "$output" ]
-}
-
-# ── full lifecycle ────────────────────────────────────────────
-
-@test "full cycle: edit → stage → commit → clean status" {
- # Install hooks so post-commit deobfuscates
- source "$REPO_DIR/lib/hooks.sh"
- install_obfuscation_hook
- install_deobfuscation_hook
-
- # Verify clean status before edit
- run git -C "$NOTES_CALLER_PWD" status --porcelain
- [ -z "$output" ]
-
- # Edit a note
- echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
-
- # git status should still be clean (exclude hides the change)
- run git -C "$NOTES_CALLER_PWD" status --porcelain
- [ -z "$output" ]
-
- # But detect_changes should see it
- run detect_changes "$NOTES_CALLER_PWD/notes"
- [[ "$output" == *"modified"*"alpha.md"* ]]
-
- # Stage via notes stage
- notes stage alpha.md
-
- # Commit — hooks handle obfuscation + deobfuscation
- git -C "$NOTES_CALLER_PWD" commit -q -m "update alpha"
-
- # After commit, files should be deobfuscated
- [ -f "$NOTES_CALLER_PWD/notes/alpha.md" ]
- [[ "$(cat "$NOTES_CALLER_PWD/notes/alpha.md")" == *"Alpha v2"* ]]
-
- # Status should be clean again
- run git -C "$NOTES_CALLER_PWD" status --porcelain
- [ -z "$output" ]
-
- # No changes detected
- run detect_changes "$NOTES_CALLER_PWD/notes"
- [ -z "$output" ]
-}
diff --git a/test/changes_test_helper.bash b/test/changes_test_helper.bash
new file mode 100644
index 0000000..5cfea8b
--- /dev/null
+++ b/test/changes_test_helper.bash
@@ -0,0 +1,151 @@
+setup() {
+ export NOTES_CALLER_PWD="$BATS_TEST_TMPDIR"
+ source "$REPO_DIR/lib/common.sh"
+ source "$REPO_DIR/lib/obfuscate.sh"
+ source "$REPO_DIR/lib/suppress.sh"
+ source "$REPO_DIR/lib/changes.sh"
+
+ # Create a git repo with obfuscated notes
+ git -C "$NOTES_CALLER_PWD" init -q
+ git -C "$NOTES_CALLER_PWD" config user.name "Test"
+ git -C "$NOTES_CALLER_PWD" config user.email "test@test.com"
+
+ mkdir -p "$NOTES_CALLER_PWD/notes"
+ echo "# Alpha" > "$NOTES_CALLER_PWD/notes/alpha.md"
+ echo "# Beta" > "$NOTES_CALLER_PWD/notes/beta.md"
+
+ MANIFEST="$NOTES_CALLER_PWD/notes/.manifest"
+
+ # Obfuscate, commit, then deobfuscate (simulates normal state)
+ rename_to_obfuscated "$NOTES_CALLER_PWD/notes" > /dev/null
+ git -C "$NOTES_CALLER_PWD" add -A
+ git -C "$NOTES_CALLER_PWD" commit -q -m "initial"
+ rename_to_readable "$NOTES_CALLER_PWD/notes" > /dev/null
+ set_status_suppression "$NOTES_CALLER_PWD/notes"
+}
+
+add_clean_numbered_notes() {
+ local count="$1"
+ local i=1 name
+ while [ "$i" -le "$count" ]; do
+ name=$(printf 'note-%02d.md' "$i")
+ printf '# Note %02d\n' "$i" > "$NOTES_CALLER_PWD/notes/$name"
+ i=$((i + 1))
+ done
+
+ rename_to_obfuscated "$NOTES_CALLER_PWD/notes" > /dev/null
+ git -C "$NOTES_CALLER_PWD" add -A
+ git -C "$NOTES_CALLER_PWD" commit -q -m "add many notes"
+ rename_to_readable "$NOTES_CALLER_PWD/notes" > /dev/null
+ set_status_suppression "$NOTES_CALLER_PWD/notes"
+}
+
+install_process_counter() {
+ local command="$1"
+ local real_command
+ real_command=$(command -v "$command")
+ cat > "$PROCESS_COUNTER_BIN/$command" <> "\${NOTES_PROCESS_COUNTER_DIR:?}/$command.calls"
+exec '$real_command' "\$@"
+SH
+ chmod +x "$PROCESS_COUNTER_BIN/$command"
+}
+
+setup_membership_process_counters() {
+ PROCESS_COUNTER_BIN="$BATS_TEST_TMPDIR/process-counter-bin"
+ NOTES_PROCESS_COUNTER_DIR="$BATS_TEST_TMPDIR/process-counter-results"
+ mkdir -p "$PROCESS_COUNTER_BIN" "$NOTES_PROCESS_COUNTER_DIR"
+ export NOTES_PROCESS_COUNTER_DIR
+ install_process_counter grep
+ install_process_counter basename
+}
+
+run_with_process_counters() {
+ local function_name="$1"
+ shift
+ PATH="$PROCESS_COUNTER_BIN:$PATH"
+ "$function_name" "$@"
+}
+
+setup_git_argument_counter() {
+ local real_git
+ PROCESS_COUNTER_BIN="$BATS_TEST_TMPDIR/git-counter-bin"
+ NOTES_PROCESS_COUNTER_DIR="$BATS_TEST_TMPDIR/git-counter-results"
+ real_git=$(command -v git)
+ mkdir -p "$PROCESS_COUNTER_BIN" "$NOTES_PROCESS_COUNTER_DIR"
+ export NOTES_PROCESS_COUNTER_DIR
+ cat > "$PROCESS_COUNTER_BIN/git" <> "\${NOTES_PROCESS_COUNTER_DIR:?}/git.args"
+exec '$real_git' "\$@"
+SH
+ chmod +x "$PROCESS_COUNTER_BIN/git"
+}
+
+setup_clean_filter_counter() {
+ CLEAN_FILTER_CALLS="$BATS_TEST_TMPDIR/clean-filter.calls"
+ CLEAN_FILTER="$BATS_TEST_TMPDIR/counting-clean-filter"
+ export CLEAN_FILTER_CALLS
+ cat > "$CLEAN_FILTER" <<'BASH'
+#!/usr/bin/env bash
+printf '1\n' >> "${CLEAN_FILTER_CALLS:?}"
+cat
+BASH
+ chmod +x "$CLEAN_FILTER"
+ git -C "$NOTES_CALLER_PWD" config filter.notes-test.clean "$CLEAN_FILTER"
+ git -C "$NOTES_CALLER_PWD" config filter.notes-test.smudge cat
+ git -C "$NOTES_CALLER_PWD" config filter.notes-test.required true
+ printf 'notes/** filter=notes-test\n' > "$NOTES_CALLER_PWD/.gitattributes"
+ git -C "$NOTES_CALLER_PWD" add .gitattributes
+ git -C "$NOTES_CALLER_PWD" commit -q -m "add counting clean filter"
+}
+
+clean_filter_call_count() {
+ if [ -f "$CLEAN_FILTER_CALLS" ]; then
+ wc -l < "$CLEAN_FILTER_CALLS" | tr -d ' '
+ else
+ printf '0\n'
+ fi
+}
+
+record_deobfuscation_state_for_manifest() {
+ local ids=()
+ while IFS=$'\t' read -r id relpath; do
+ [ -z "$id" ] && continue
+ ids+=("$id")
+ done < "$MANIFEST"
+ _record_deobfuscation_base_hashes "$NOTES_CALLER_PWD/notes" "${ids[@]}"
+}
+
+delete_manifest_entry_from_head() {
+ local relpath="$1"
+ local id
+ id=$(manifest_id_for_name "$MANIFEST" "$relpath")
+ [ -n "$id" ]
+
+ if ! git -C "$NOTES_CALLER_PWD" update-index --no-assume-unchanged "notes/$id" 2>/dev/null; then
+ : # The fixture ID may not currently be marked assume-unchanged.
+ fi
+ git -C "$NOTES_CALLER_PWD" rm -q --cached "notes/$id"
+ awk -F '\t' -v path="$relpath" '$2 != path { print }' "$MANIFEST" > "$MANIFEST.tmp"
+ mv "$MANIFEST.tmp" "$MANIFEST"
+ git -C "$NOTES_CALLER_PWD" add notes/.manifest
+ git -C "$NOTES_CALLER_PWD" commit -q -m "delete $relpath"
+
+ printf '%s' "$id"
+}
+
+rename_manifest_entry_in_head() {
+ local old_relpath="$1" new_relpath="$2"
+ local id
+ id=$(manifest_id_for_name "$MANIFEST" "$old_relpath")
+ [ -n "$id" ]
+
+ awk -F '\t' -v old="$old_relpath" -v new="$new_relpath" 'BEGIN { OFS="\t" } $2 == old { $2 = new } { print }' "$MANIFEST" > "$MANIFEST.tmp"
+ mv "$MANIFEST.tmp" "$MANIFEST"
+ git -C "$NOTES_CALLER_PWD" add notes/.manifest
+ git -C "$NOTES_CALLER_PWD" commit -q -m "rename $old_relpath"
+
+ printf '%s' "$id"
+}
diff --git a/test/commit.bats b/test/commit.bats
new file mode 100644
index 0000000..cb05474
--- /dev/null
+++ b/test/commit.bats
@@ -0,0 +1,257 @@
+#!/usr/bin/env bats
+
+# Readable-note commit and full-lifecycle tests.
+
+load test_helper
+load changes_test_helper
+
+# ── commit wrapper ───────────────────────────────────────────
+
+@test "notes commit post-check batches readable-path inspection" {
+ add_clean_numbered_notes 40
+ notes install-hooks --yes >/dev/null
+ printf '# changed\n' >> "$NOTES_CALLER_PWD/notes/alpha.md"
+ setup_git_argument_counter
+
+ run run_with_process_counters notes commit -m "update alpha" alpha.md
+
+ [ "$status" -eq 0 ]
+ [ "$(grep -c ' ls-files ' "$NOTES_PROCESS_COUNTER_DIR/git.args" || true)" -le 3 ]
+}
+
+@test "notes commit: explicit file commits modified note and leaves clean readable tree" {
+ notes install-hooks --yes
+
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes commit -m "update alpha" alpha.md
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Committed note changes"* ]]
+ [[ "$output" == *"Notes changes: clean"* ]]
+
+ [ "$(git -C "$NOTES_CALLER_PWD" log -1 --format=%s)" = "update alpha" ]
+ [ -f "$NOTES_CALLER_PWD/notes/alpha.md" ]
+ [[ "$(cat "$NOTES_CALLER_PWD/notes/alpha.md")" == *"Alpha v2"* ]]
+
+ local committed_files
+ committed_files=$(git -C "$NOTES_CALLER_PWD" show --name-only --format='' HEAD -- notes/)
+ ! echo "$committed_files" | grep -q "alpha.md"
+
+ run detect_changes "$NOTES_CALLER_PWD/notes"
+ [ -z "$output" ]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+
+ run git -C "$NOTES_CALLER_PWD" ls-files notes/alpha.md
+ [ -z "$output" ]
+}
+
+@test "notes commit --all commits modified new and deleted notes" {
+ notes install-hooks --yes
+
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+ echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"
+ rm "$NOTES_CALLER_PWD/notes/beta.md"
+
+ run notes commit --all -m "update all notes"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"staged: alpha.md"* ]]
+ [[ "$output" == *"staged: gamma.md"* ]]
+ [[ "$output" == *"staged (delete): beta.md"* ]]
+ [[ "$output" == *"Notes changes: clean"* ]]
+
+ [ -f "$NOTES_CALLER_PWD/notes/alpha.md" ]
+ [ -f "$NOTES_CALLER_PWD/notes/gamma.md" ]
+ [ ! -f "$NOTES_CALLER_PWD/notes/beta.md" ]
+ ! grep -q "beta.md" "$MANIFEST"
+ grep -q "gamma.md" "$MANIFEST"
+
+ run detect_changes "$NOTES_CALLER_PWD/notes"
+ [ -z "$output" ]
+}
+
+@test "notes commit: path-limited commit leaves unrelated dirty notes uncommitted" {
+ notes install-hooks --yes
+
+ local alpha_id beta_id
+ alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
+ beta_id=$(manifest_id_for_name "$MANIFEST" "beta.md")
+
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+ echo "# Beta v2" > "$NOTES_CALLER_PWD/notes/beta.md"
+
+ run notes commit -m "update alpha only" alpha.md
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Remaining note changes"* ]]
+ [[ "$output" == *"modified: beta.md"* ]]
+ [[ "$output" != *"modified: alpha.md"* ]]
+
+ git -C "$NOTES_CALLER_PWD" cat-file --filters "HEAD:notes/$alpha_id" | grep -q "Alpha v2"
+ git -C "$NOTES_CALLER_PWD" cat-file --filters "HEAD:notes/$beta_id" | grep -q "# Beta"
+ ! git -C "$NOTES_CALLER_PWD" cat-file --filters "HEAD:notes/$beta_id" | grep -q "Beta v2"
+
+ run detect_changes "$NOTES_CALLER_PWD/notes"
+ [[ "$output" == *"modified"*"beta.md"* ]]
+ [[ "$output" != *"alpha.md"* ]]
+}
+
+@test "notes commit --dry-run shows staged plan without staging or committing" {
+ local before
+ before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes commit --dry-run -m "update alpha" alpha.md
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Would stage:"* ]]
+ [[ "$output" == *"alpha.md"* ]]
+ [[ "$output" == *"Would commit with message: update alpha"* ]]
+ [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+@test "notes commit: no args requires explicit scope" {
+ notes install-hooks --yes
+ local before
+ before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes commit -m "missing scope"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"provide note paths or --all"* ]]
+ [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+@test "notes commit: explicit unknown path fails instead of silently committing nothing" {
+ notes install-hooks --yes
+ local before
+ before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes commit -m "typo" alhpa.md
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"requested note path"* ]]
+ [[ "$output" == *"alhpa.md"* ]]
+ [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+@test "notes commit: path traversal argument fails instead of silently committing nothing" {
+ notes install-hooks --yes
+ local before
+ before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+ echo "readme" > "$NOTES_CALLER_PWD/README.md"
+
+ run notes commit -m "traversal" ../README.md
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"requested note path"* ]]
+ [[ "$output" == *"../README.md"* ]]
+ [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+@test "notes commit: refuses pre-staged changes before staging notes" {
+ notes install-hooks --yes
+ local before
+ before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
+ echo "readme" > "$NOTES_CALLER_PWD/README.md"
+ git -C "$NOTES_CALLER_PWD" add README.md
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes commit --all -m "should refuse"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"staged changes already exist"* ]]
+ [[ "$output" == *"README.md"* ]]
+ [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ "$output" = "README.md" ]
+}
+
+@test "notes commit: detects non-note paths added by another pre-commit hook" {
+ notes install-hooks --yes
+ mkdir -p "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d"
+ cat > "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/zz-stage-generated" <<'HOOK'
+#!/usr/bin/env bash
+set -euo pipefail
+printf 'generated by hook\n' > hook-generated.txt
+git add hook-generated.txt
+HOOK
+ chmod +x "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/zz-stage-generated"
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes commit -m "update alpha" alpha.md
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"included non-note path"* ]]
+ [[ "$output" == *"hook-generated.txt"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" show --name-only --format= HEAD
+ [[ "$output" == *"hook-generated.txt"* ]]
+}
+
+@test "notes commit: refuses missing hooks before staging or committing" {
+ local before
+ before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes commit -m "update alpha" alpha.md
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"requires installed obfuscation/deobfuscation hooks"* ]]
+ [[ "$output" == *"notes install-hooks"* ]]
+ [ "$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD)" = "$before" ]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+# ── full lifecycle ────────────────────────────────────────────
+
+@test "full cycle: edit → stage → commit → clean status" {
+ # Install hooks so post-commit deobfuscates
+ source "$REPO_DIR/lib/hooks.sh"
+ install_obfuscation_hook
+ install_deobfuscation_hook
+
+ # Verify clean status before edit
+ run git -C "$NOTES_CALLER_PWD" status --porcelain
+ [ -z "$output" ]
+
+ # Edit a note
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ # git status should still be clean (exclude hides the change)
+ run git -C "$NOTES_CALLER_PWD" status --porcelain
+ [ -z "$output" ]
+
+ # But detect_changes should see it
+ run detect_changes "$NOTES_CALLER_PWD/notes"
+ [[ "$output" == *"modified"*"alpha.md"* ]]
+
+ # Stage via notes stage
+ notes stage alpha.md
+
+ # Commit — hooks handle obfuscation + deobfuscation
+ git -C "$NOTES_CALLER_PWD" commit -q -m "update alpha"
+
+ # After commit, files should be deobfuscated
+ [ -f "$NOTES_CALLER_PWD/notes/alpha.md" ]
+ [[ "$(cat "$NOTES_CALLER_PWD/notes/alpha.md")" == *"Alpha v2"* ]]
+
+ # Status should be clean again
+ run git -C "$NOTES_CALLER_PWD" status --porcelain
+ [ -z "$output" ]
+
+ # No changes detected
+ run detect_changes "$NOTES_CALLER_PWD/notes"
+ [ -z "$output" ]
+}
diff --git a/test/diff.bats b/test/diff.bats
index 31229ee..16d13cb 100644
--- a/test/diff.bats
+++ b/test/diff.bats
@@ -87,9 +87,124 @@ commit_readable_update() {
grep -q "# Alpha" "$out_dir/base/notes/alpha.md"
grep -q "# Alpha v2" "$out_dir/head/notes/alpha.md"
grep -q "diff --git a/notes/alpha.md b/notes/alpha.md" "$out_dir/readable.patch"
+ [ ! -e "$out_dir/base/notes/beta.md" ]
+ [ ! -e "$out_dir/head/notes/beta.md" ]
+ [[ "$output" == *"Wrote changed-note base artifacts: $out_dir/base"* ]]
+ [[ "$output" == *"Wrote changed-note head artifacts: $out_dir/head"* ]]
[[ "$output" == *"Wrote readable patch: $out_dir/readable.patch"* ]]
}
+@test "notes diff materializes manifest-only readable renames" {
+ local manifest next_manifest out_dir
+ manifest="$NOTES_CALLER_PWD/notes/.manifest"
+ next_manifest="$BATS_TEST_TMPDIR/renamed.manifest"
+ out_dir="$BATS_TEST_TMPDIR/renamed-review"
+
+ awk -F '\t' 'BEGIN { OFS = "\t" } $2 == "alpha.md" { $2 = "renamed/alpha.md" } { print }' \
+ "$manifest" > "$next_manifest"
+ mv "$next_manifest" "$manifest"
+ git -C "$NOTES_CALLER_PWD" add notes/.manifest
+ git -C "$NOTES_CALLER_PWD" commit -q -m "rename alpha in manifest"
+
+ run notes diff --out "$out_dir" HEAD~1 HEAD
+
+ [ "$status" -eq 0 ]
+ [ -f "$out_dir/base/notes/alpha.md" ]
+ [ -f "$out_dir/head/notes/renamed/alpha.md" ]
+ grep -q "# Alpha" "$out_dir/base/notes/alpha.md"
+ grep -q "# Alpha" "$out_dir/head/notes/renamed/alpha.md"
+ grep -q "notes/alpha.md" "$out_dir/readable.patch"
+ grep -q "notes/renamed/alpha.md" "$out_dir/readable.patch"
+}
+
+@test "notes diff preserves manifest aliases when one readable path is removed" {
+ local alpha_id manifest next_manifest out_dir
+ manifest="$NOTES_CALLER_PWD/notes/.manifest"
+ alpha_id=$(awk -F '\t' '$2 == "alpha.md" { print $1 }' "$manifest")
+ next_manifest="$BATS_TEST_TMPDIR/aliased.manifest"
+ out_dir="$BATS_TEST_TMPDIR/alias-review"
+
+ printf '%s\talias-alpha.md\n' "$alpha_id" >> "$manifest"
+ git -C "$NOTES_CALLER_PWD" add notes/.manifest
+ git -C "$NOTES_CALLER_PWD" commit -q -m "alias alpha in manifest"
+
+ awk -F '\t' '$2 != "alpha.md" { print }' "$manifest" > "$next_manifest"
+ mv "$next_manifest" "$manifest"
+ git -C "$NOTES_CALLER_PWD" add notes/.manifest
+ git -C "$NOTES_CALLER_PWD" commit -q -m "remove original alpha path"
+
+ run notes diff --out "$out_dir" HEAD~1 HEAD
+
+ [ "$status" -eq 0 ]
+ [ -f "$out_dir/base/notes/alpha.md" ]
+ [ -f "$out_dir/base/notes/alias-alpha.md" ]
+ [ ! -e "$out_dir/head/notes/alpha.md" ]
+ [ -f "$out_dir/head/notes/alias-alpha.md" ]
+ grep -q "deleted file mode" "$out_dir/readable.patch"
+ grep -q "a/notes/alpha.md" "$out_dir/readable.patch"
+ ! grep -q "a/notes/alias-alpha.md" "$out_dir/readable.patch"
+}
+
+@test "notes diff includes unchanged IDs that collide on a changed readable path" {
+ local alpha_id beta_id manifest next_manifest out_dir
+ manifest="$NOTES_CALLER_PWD/notes/.manifest"
+ alpha_id=$(awk -F '\t' '$2 == "alpha.md" { print $1 }' "$manifest")
+ beta_id=$(awk -F '\t' '$2 == "beta.md" { print $1 }' "$manifest")
+ next_manifest="$BATS_TEST_TMPDIR/colliding.manifest"
+ out_dir="$BATS_TEST_TMPDIR/collision-review"
+
+ {
+ printf '%s\tbeta.md\n' "$alpha_id"
+ printf '%s\tbeta.md\n' "$beta_id"
+ } > "$next_manifest"
+ mv "$next_manifest" "$manifest"
+ git -C "$NOTES_CALLER_PWD" add notes/.manifest
+ git -C "$NOTES_CALLER_PWD" commit -q -m "collide readable paths"
+
+ run notes diff --out "$out_dir" HEAD~1 HEAD
+
+ [ "$status" -eq 0 ]
+ [ -f "$out_dir/base/notes/alpha.md" ]
+ grep -q "# Beta" "$out_dir/base/notes/beta.md"
+ [ ! -e "$out_dir/head/notes/alpha.md" ]
+ grep -q "# Beta" "$out_dir/head/notes/beta.md"
+ grep -q "a/notes/alpha.md" "$out_dir/readable.patch"
+ ! grep -q "a/notes/beta.md" "$out_dir/readable.patch"
+}
+
+@test "ref diff Git process count does not grow with unchanged notes" {
+ local i real_git counter_bin calls git_call_count
+ rename_to_readable "$NOTES_CALLER_PWD/notes" > /dev/null
+ i=1
+ while [ "$i" -le 50 ]; do
+ printf '# Unchanged %02d\n' "$i" > "$NOTES_CALLER_PWD/notes/unchanged-$i.md"
+ i=$((i + 1))
+ done
+ commit_readable_update "add unchanged scale"
+
+ rename_to_readable "$NOTES_CALLER_PWD/notes" > /dev/null
+ echo "# Alpha changed" > "$NOTES_CALLER_PWD/notes/alpha.md"
+ commit_readable_update "edit one note"
+
+ real_git=$(command -v git)
+ counter_bin="$BATS_TEST_TMPDIR/counter-bin"
+ calls="$BATS_TEST_TMPDIR/git.calls"
+ mkdir -p "$counter_bin"
+ cat > "$counter_bin/git" <> '$calls'
+exec '$real_git' "\$@"
+SH
+ chmod +x "$counter_bin/git"
+
+ PATH="$counter_bin:$PATH" run notes diff HEAD~1 HEAD
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"diff --git a/notes/alpha.md b/notes/alpha.md"* ]]
+ git_call_count=$(wc -l < "$calls" | tr -d ' ')
+ [ "$git_call_count" -le 30 ]
+}
+
@test "notes diff --out refuses symlink destinations" {
local out_target out_link
out_target="$BATS_TEST_TMPDIR/out-target"
diff --git a/test/stage.bats b/test/stage.bats
new file mode 100644
index 0000000..cafcc7f
--- /dev/null
+++ b/test/stage.bats
@@ -0,0 +1,396 @@
+#!/usr/bin/env bats
+
+# Readable-note staging and stale-path reconciliation tests.
+
+load test_helper
+load changes_test_helper
+
+# ── stage via git add -f ─────────────────────────────────────
+
+@test "git add -f works despite exclude" {
+ echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ # Normal git add should fail (file is excluded)
+ git -C "$NOTES_CALLER_PWD" add "$NOTES_CALLER_PWD/notes/alpha.md" 2>/dev/null || true
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [[ "$output" != *"alpha.md"* ]]
+
+ # Force add should work
+ git -C "$NOTES_CALLER_PWD" add -f "$NOTES_CALLER_PWD/notes/alpha.md"
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [[ "$output" == *"alpha.md"* ]]
+}
+
+@test "notes stage: no args requires explicit scope" {
+ echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
+ echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"
+
+ run notes stage
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"provide note paths or --all"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+@test "notes stage: no args ignores inherited usage_files and still requires scope" {
+ echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ usage_files="gamma.md" run notes stage
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"provide note paths or --all"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+@test "notes stage --all stages modified and new notes" {
+ echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
+ echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"
+
+ run notes stage --all
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"staged: alpha.md"* ]]
+ [[ "$output" == *"staged: gamma.md"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [[ "$output" == *"notes/alpha.md"* ]]
+ [[ "$output" == *"notes/gamma.md"* ]]
+}
+
+@test "notes stage: explicit file stages a new note" {
+ echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"
+
+ run notes stage gamma.md
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"staged: gamma.md"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [[ "$output" == *"notes/gamma.md"* ]]
+}
+
+@test "notes stage: explicit unknown path fails instead of silently selecting nothing" {
+ echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes stage alhpa.md
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"requested note path"* ]]
+ [[ "$output" == *"alhpa.md"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+@test "notes stage: path traversal argument fails instead of selecting nothing" {
+ echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
+ echo "readme" > "$NOTES_CALLER_PWD/README.md"
+
+ run notes stage ../README.md
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"requested note path"* ]]
+ [[ "$output" == *"../README.md"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+@test "notes stage --dry-run: deleted note leaves manifest and index untouched" {
+ local manifest_before
+ manifest_before=$(cat "$MANIFEST")
+ rm "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes stage --all --dry-run
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Would stage:"* ]]
+ [[ "$output" == *"alpha.md"* ]]
+ [ "$(cat "$MANIFEST")" = "$manifest_before" ]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+@test "notes stage: deleted note does not mutate manifest when index removal fails" {
+ local manifest_before
+ manifest_before=$(cat "$MANIFEST")
+ rm "$NOTES_CALLER_PWD/notes/alpha.md"
+ touch "$NOTES_CALLER_PWD/.git/index.lock"
+
+ run notes stage --all
+ rm -f "$NOTES_CALLER_PWD/.git/index.lock"
+
+ [ "$status" -ne 0 ]
+ [ "$(cat "$MANIFEST")" = "$manifest_before" ]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [ -z "$output" ]
+}
+
+@test "notes stage: deleted note rolls back manifest when manifest staging fails" {
+ local alpha_id manifest_before real_git
+ alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
+ manifest_before=$(cat "$MANIFEST")
+ real_git=$(command -v git)
+ rm "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ mkdir -p "$BATS_TEST_TMPDIR/bin"
+ cat > "$BATS_TEST_TMPDIR/bin/git" <&2
+ exit 99
+fi
+exec "$real_git" "\$@"
+SH
+ chmod +x "$BATS_TEST_TMPDIR/bin/git"
+
+ PATH="$BATS_TEST_TMPDIR/bin:$PATH" run notes stage --all
+ [ "$status" -ne 0 ]
+ [ "$(cat "$MANIFEST")" = "$manifest_before" ]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-status
+ [[ "$output" == *$'D\tnotes/'"$alpha_id"* ]]
+ [[ "$output" != *$'M\tnotes/.manifest'* ]]
+
+ run notes stage --all
+ [ "$status" -eq 0 ]
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-status
+ [[ "$output" == *$'D\tnotes/'"$alpha_id"* ]]
+ [[ "$output" == *$'M\tnotes/.manifest'* ]]
+}
+
+@test "notes stage: deleted note stages manifest update in same commit" {
+ source "$REPO_DIR/lib/hooks.sh"
+ install_obfuscation_hook
+ install_deobfuscation_hook
+
+ local alpha_id
+ alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
+ rm "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes stage --all
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"staged (delete): alpha.md"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-status
+ [[ "$output" == *$'D\tnotes/'"$alpha_id"* ]]
+ [[ "$output" == *$'M\tnotes/.manifest'* ]]
+
+ git -C "$NOTES_CALLER_PWD" commit -q -m "delete alpha"
+
+ run git -C "$NOTES_CALLER_PWD" status --porcelain
+ [ -z "$output" ]
+
+ run git -C "$NOTES_CALLER_PWD" cat-file --filters HEAD:notes/.manifest
+ [[ "$output" != *"alpha.md"* ]]
+ [[ "$output" == *"beta.md"* ]]
+}
+
+@test "notes stage: refuses dual-present differing readable and obfuscated pair" {
+ local alpha_id
+ alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
+
+ echo "# Alpha local edit" > "$NOTES_CALLER_PWD/notes/alpha.md"
+ echo "# Alpha incoming upstream" > "$NOTES_CALLER_PWD/notes/$alpha_id"
+
+ run notes stage alpha.md
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"incomplete deobfuscation"* ]]
+ [[ "$output" == *"alpha.md"* ]]
+ [[ "$output" == *"notes deobfuscate"* ]]
+ [[ "$output" == *"notes changes alpha.md"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [[ "$output" != *"notes/alpha.md"* ]]
+}
+
+@test "notes stage: refuses double-tracked notes (readable + obfuscated both in index)" {
+ local alpha_id
+ alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
+
+ # Simulate the double-tracking bug from notes#51: both readable and hex tracked.
+ # Use identical content so the dual-present conflict check does not fire first.
+ echo "# Alpha obfuscated" > "$NOTES_CALLER_PWD/notes/alpha.md"
+ echo "# Alpha obfuscated" > "$NOTES_CALLER_PWD/notes/$alpha_id"
+ git -C "$NOTES_CALLER_PWD" add -f "notes/alpha.md"
+
+ run notes stage alpha.md
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"double-tracked"* ]]
+ [[ "$output" == *"alpha.md"* ]]
+ [[ "$output" == *"notes#51"* ]]
+}
+
+@test "notes stage: does not refuse when readable is only on disk, not tracked" {
+ local alpha_id
+ alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
+
+ # Normal deobfuscated state: readable on disk, hex tracked in index
+ echo "# Alpha v2" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes stage alpha.md
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"staged: alpha.md"* ]]
+}
+
+@test "notes stage --all refuses stale readable files left from another branch" {
+ local repo="$BATS_TEST_TMPDIR/branch-repo"
+ mkdir -p "$repo/notes"
+ git -C "$repo" init -q -b main
+ git -C "$repo" config user.name "Test"
+ git -C "$repo" config user.email "test@test.com"
+
+ echo "# Alpha" > "$repo/notes/alpha.md"
+ rename_to_obfuscated "$repo/notes" > /dev/null
+ git -C "$repo" add -A
+ git -C "$repo" commit -q -m "add alpha"
+ rename_to_readable "$repo/notes" > /dev/null
+ set_status_suppression "$repo/notes"
+
+ git -C "$repo" branch feature
+
+ echo "# Beta" > "$repo/notes/beta.md"
+ rename_to_obfuscated "$repo/notes" > /dev/null
+ git -C "$repo" add -A
+ git -C "$repo" commit -q -m "add beta on main"
+ rename_to_readable "$repo/notes" > /dev/null
+ set_status_suppression "$repo/notes"
+
+ git -C "$repo" checkout -q feature
+ [ -f "$repo/notes/beta.md" ]
+ echo "alpha edit" >> "$repo/notes/alpha.md"
+
+ NOTES_CALLER_PWD="$repo" run notes changes --summary
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"stale-readable: beta.md"* ]]
+ [[ "$output" != *"new: beta.md"* ]]
+
+ NOTES_CALLER_PWD="$repo" run notes stage --all
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"stale readable note"* ]]
+ [[ "$output" == *"beta.md"* ]]
+
+ run git -C "$repo" diff --cached --name-only
+ [[ "$output" != *"notes/alpha.md"* ]]
+ [[ "$output" != *"notes/beta.md"* ]]
+}
+
+@test "notes changes: stale readable is not reported as a new note" {
+ record_deobfuscation_state_for_manifest
+ delete_manifest_entry_from_head "beta.md" > /dev/null
+
+ run notes changes --summary
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"stale-readable: beta.md"* ]]
+ [[ "$output" != *"new: beta.md"* ]]
+}
+
+@test "notes stage: refuses explicit stale readable note" {
+ record_deobfuscation_state_for_manifest
+ delete_manifest_entry_from_head "beta.md" > /dev/null
+
+ run notes stage beta.md
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"stale readable note"* ]]
+ [[ "$output" == *"beta.md"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" diff --cached --name-only
+ [[ "$output" != *"notes/beta.md"* ]]
+}
+
+@test "deobfuscate removes clean stale readable after manifest deletion" {
+ record_deobfuscation_state_for_manifest
+ delete_manifest_entry_from_head "beta.md" > /dev/null
+
+ run notes deobfuscate
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"removed stale readable: beta.md"* ]]
+ [ ! -f "$NOTES_CALLER_PWD/notes/beta.md" ]
+ ! grep -q "notes/beta.md" "$NOTES_CALLER_PWD/.git/info/exclude"
+
+ run notes changes --summary
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"No changes."* ]]
+}
+
+@test "deobfuscate removes clean stale readable with legacy id-hash state" {
+ local beta_id beta_hash state
+ beta_id=$(manifest_id_for_name "$MANIFEST" "beta.md")
+ beta_hash=$(git -C "$NOTES_CALLER_PWD" hash-object -- "$NOTES_CALLER_PWD/notes/beta.md")
+ state="$NOTES_CALLER_PWD/.git/info/notes-obfuscation-state"
+ mkdir -p "$(dirname "$state")"
+ printf '%s\t%s\n' "$beta_id" "$beta_hash" > "$state"
+
+ delete_manifest_entry_from_head "beta.md" > /dev/null
+
+ run notes deobfuscate
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"removed stale readable: beta.md"* ]]
+ [ ! -f "$NOTES_CALLER_PWD/notes/beta.md" ]
+
+ run notes changes --summary
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"No changes."* ]]
+}
+
+@test "deobfuscate quarantines dirty stale readable after manifest deletion" {
+ record_deobfuscation_state_for_manifest
+ echo "local edit" >> "$NOTES_CALLER_PWD/notes/beta.md"
+ delete_manifest_entry_from_head "beta.md" > /dev/null
+
+ run notes deobfuscate
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"quarantined stale readable note: beta.md"* ]]
+ [ ! -f "$NOTES_CALLER_PWD/notes/beta.md" ]
+ [ -f "$NOTES_CALLER_PWD/.git/info/notes-stale-readable/beta.md" ]
+ [[ "$(cat "$NOTES_CALLER_PWD/.git/info/notes-stale-readable/beta.md")" == *"local edit"* ]]
+ ! grep -q "notes/beta.md" "$NOTES_CALLER_PWD/.git/info/exclude"
+
+ run notes changes --summary
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"No changes."* ]]
+}
+
+@test "deobfuscate reconciles stale old path when manifest renames a note" {
+ record_deobfuscation_state_for_manifest
+ local beta_id
+ beta_id=$(rename_manifest_entry_in_head "beta.md" "renamed-beta.md")
+ git -C "$NOTES_CALLER_PWD" update-index --no-assume-unchanged "notes/$beta_id" 2>/dev/null || true
+ git -C "$NOTES_CALLER_PWD" checkout -- "notes/$beta_id"
+
+ run notes deobfuscate
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"removed stale readable: beta.md"* ]]
+ [ ! -f "$NOTES_CALLER_PWD/notes/beta.md" ]
+ [ -f "$NOTES_CALLER_PWD/notes/renamed-beta.md" ]
+ [[ "$(cat "$NOTES_CALLER_PWD/notes/renamed-beta.md")" == *"# Beta"* ]]
+ ! grep -q "notes/beta.md" "$NOTES_CALLER_PWD/.git/info/exclude"
+ grep -q "notes/renamed-beta.md" "$NOTES_CALLER_PWD/.git/info/exclude"
+
+ run notes changes --summary
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"No changes."* ]]
+}
+
+@test "notes stage: path-limited stage does not leak unselected new manifest entry through pre-commit hook" {
+ source "$REPO_DIR/lib/hooks.sh"
+ install_obfuscation_hook
+ install_deobfuscation_hook
+
+ echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"
+ printf 'cccccccc\tgamma.md\n' >> "$MANIFEST"
+ echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md"
+
+ run notes stage alpha.md
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"staged: alpha.md"* ]]
+ [[ "$output" != *"gamma.md"* ]]
+
+ git -C "$NOTES_CALLER_PWD" commit -q -m "update alpha"
+
+ run git -C "$NOTES_CALLER_PWD" cat-file --filters HEAD:notes/.manifest
+ [[ "$output" == *"alpha.md"* ]]
+ [[ "$output" != *"gamma.md"* ]]
+
+ run git -C "$NOTES_CALLER_PWD" show --name-only --format= HEAD
+ [[ "$output" != *"gamma"* ]]
+}
diff --git a/test/status-suppression.bats b/test/status-suppression.bats
new file mode 100644
index 0000000..be447b3
--- /dev/null
+++ b/test/status-suppression.bats
@@ -0,0 +1,153 @@
+#!/usr/bin/env bats
+
+# Readable-note Git status suppression tests.
+
+load test_helper
+load changes_test_helper
+
+# ── exclude management ────────────────────────────────────────
+
+@test "set_status_suppression adds exclude entries" {
+ local repo_root
+ repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
+ local exclude="$repo_root/.git/info/exclude"
+
+ # Suppression was already set in setup
+ [ -f "$exclude" ]
+ grep -q "notes/alpha.md" "$exclude"
+ grep -q "notes/beta.md" "$exclude"
+ grep -q "# BEGIN notes-obfuscation" "$exclude"
+ grep -q "# END notes-obfuscation" "$exclude"
+}
+
+@test "set_status_suppression gives clean git status" {
+ # After setup, git status should be clean
+ run git -C "$NOTES_CALLER_PWD" status --porcelain
+ [ -z "$output" ]
+}
+
+@test "clear_status_suppression removes exclude entries" {
+ clear_status_suppression "$NOTES_CALLER_PWD/notes"
+
+ local repo_root
+ repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
+ local exclude="$repo_root/.git/info/exclude"
+
+ # Managed block should be gone
+ if [ -f "$exclude" ]; then
+ ! grep -q "notes/alpha.md" "$exclude"
+ ! grep -q "# BEGIN notes-obfuscation" "$exclude"
+ fi
+}
+
+@test "exclude preserves non-managed content" {
+ local repo_root
+ repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
+ local exclude="$repo_root/.git/info/exclude"
+
+ # Add custom content before the managed block
+ local tmp
+ tmp=$(mktemp)
+ echo "# My custom excludes" > "$tmp"
+ echo "build/" >> "$tmp"
+ if [ -f "$exclude" ]; then
+ cat "$exclude" >> "$tmp"
+ fi
+ mv "$tmp" "$exclude"
+
+ # Re-run suppression (should preserve custom content)
+ clear_status_suppression "$NOTES_CALLER_PWD/notes"
+ set_status_suppression "$NOTES_CALLER_PWD/notes"
+
+ grep -q "# My custom excludes" "$exclude"
+ grep -q "build/" "$exclude"
+ grep -q "notes/alpha.md" "$exclude"
+}
+
+@test "scoped set_status_suppression adds only specified entries" {
+ # Clear all first
+ clear_status_suppression "$NOTES_CALLER_PWD/notes"
+
+ local alpha_id
+ alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
+
+ # Set suppression for just alpha
+ set_status_suppression "$NOTES_CALLER_PWD/notes" "$alpha_id"
+
+ local repo_root
+ repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
+ local exclude="$repo_root/.git/info/exclude"
+
+ grep -q "notes/alpha.md" "$exclude"
+ ! grep -q "notes/beta.md" "$exclude"
+}
+
+@test "scoped clear_status_suppression removes only specified entries" {
+ local alpha_id
+ alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md")
+
+ # Clear just alpha
+ clear_status_suppression "$NOTES_CALLER_PWD/notes" "$alpha_id"
+
+ local repo_root
+ repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
+ local exclude="$repo_root/.git/info/exclude"
+
+ ! grep -q "notes/alpha.md" "$exclude"
+ grep -q "notes/beta.md" "$exclude"
+}
+
+@test "status suppression helpers tolerate empty scope under nounset" {
+ run bash -c '
+ set -euo pipefail
+ source "$1/lib/common.sh"
+ source "$1/lib/suppress.sh"
+ set_status_suppression "$2/notes"
+ clear_status_suppression "$2/notes"
+ ' _ "$REPO_DIR" "$NOTES_CALLER_PWD"
+
+ [ "$status" -eq 0 ]
+}
+
+@test "notes suppress-refresh rebuilds stale exclude entries" {
+ local repo_root exclude
+ repo_root=$(git -C "$NOTES_CALLER_PWD" rev-parse --show-toplevel)
+ exclude="$repo_root/.git/info/exclude"
+
+ cat > "$exclude" < "$NOTES_CALLER_PWD/notes/deadbeef"
+ git -C "$NOTES_CALLER_PWD" add notes/deadbeef
+ git -C "$NOTES_CALLER_PWD" commit -q -m "add stale old id"
+ git -C "$NOTES_CALLER_PWD" update-index --assume-unchanged notes/deadbeef
+ printf 'deadbeef\told.md\t012345\n' > "$NOTES_CALLER_PWD/.git/info/notes-obfuscation-state"
+
+ run git -C "$NOTES_CALLER_PWD" ls-files -v notes/deadbeef
+ [ "$status" -eq 0 ]
+ [[ "$output" == h* ]]
+
+ run notes suppress-refresh
+ [ "$status" -eq 0 ]
+
+ run git -C "$NOTES_CALLER_PWD" ls-files -v notes/deadbeef
+ [ "$status" -eq 0 ]
+ [[ "$output" == H* ]]
+}
diff --git a/test/test-task.bats b/test/test-task.bats
index 3923645..c4c7300 100644
--- a/test/test-task.bats
+++ b/test/test-task.bats
@@ -51,11 +51,11 @@ logged_arguments() {
sed -n 's/^arg=//p' "$1"
}
-@test "test task preserves Notes' eight-worker within-file default" {
+@test "test task preserves Notes' eight-worker across-file default" {
run notes test changes
[ "$status" -eq 0 ]
- [[ "$output" == *"8 jobs within files"* ]]
+ [[ "$output" == *"8 jobs across files"* ]]
[ "$(arg_count "$bats_log" --jobs)" -eq 1 ]
[ "$(arg_count "$bats_log" 8)" -eq 1 ]
[ "$(arg_count "$bats_log" --parallel-binary-name)" -eq 1 ]
@@ -117,7 +117,7 @@ logged_arguments() {
run notes test --jobs 3 changes
[ "$status" -eq 0 ]
- [[ "$output" == *"3 jobs within files"* ]]
+ [[ "$output" == *"3 jobs across files"* ]]
[ "$(arg_count "$bats_log" --jobs)" -eq 1 ]
[ "$(arg_count "$bats_log" 3)" -eq 1 ]
[ "$(arg_count "$bats_log" 8)" -eq 0 ]
]