From beaeef1f0f7d5d7f9ebee1fe5021779847659681 Mon Sep 17 00:00:00 2001 From: junior Date: Sun, 26 Jul 2026 00:32:35 -0400 Subject: [PATCH 1/8] perf: materialize only changed notes for ref diffs --- .mise/tasks/diff | 15 ++-- lib/diff.sh | 229 +++++++++++++++++++++++++++++++++-------------- test/diff.bats | 60 +++++++++++++ 3 files changed, 233 insertions(+), 71 deletions(-) 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/lib/diff.sh b/lib/diff.sh index 7575430..e8b3944 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,189 @@ _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] { base[$1] = $2; next } + { + seen[$1] = 1 + if (!($1 in base) || base[$1] != $2) print $1 + } + END { for (id in base) if (!(id in seen)) print id } + ' "$base_manifest" "$head_manifest" >> "$candidates" + + LC_ALL=C sort -u "$candidates" > "$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/test/diff.bats b/test/diff.bats index 31229ee..39011f9 100644 --- a/test/diff.bats +++ b/test/diff.bats @@ -87,9 +87,69 @@ 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 "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" From ba80ed975690357fdf9dc5ecc8d49f67ac7f4c78 Mon Sep 17 00:00:00 2001 From: junior Date: Sun, 26 Jul 2026 00:33:15 -0400 Subject: [PATCH 2/8] test: split changes suite for parallel execution --- CONTRIBUTING.md | 10 +- libexec/test | 2 +- test/changes.bats | 941 +--------------------------------- test/changes_test_helper.bash | 151 ++++++ test/commit.bats | 257 ++++++++++ test/stage.bats | 396 ++++++++++++++ test/status-suppression.bats | 153 ++++++ test/test-task.bats | 6 +- 8 files changed, 968 insertions(+), 948 deletions(-) create mode 100644 test/changes_test_helper.bash create mode 100644 test/commit.bats create mode 100644 test/stage.bats create mode 100644 test/status-suppression.bats 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/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/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 ] From 0638992bbf9110484a9964872559381e77106393 Mon Sep 17 00:00:00 2001 From: junior Date: Sun, 26 Jul 2026 00:37:52 -0400 Subject: [PATCH 3/8] docs: refresh generated test count --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7a924e7..95c31d3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 408](https://img.shields.io/badge/tests-408-brightgreen?style=flat)](test/) +[![tests: 410](https://img.shields.io/badge/tests-410-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) From 96110fb23ceef9049cb5e14193276ab0d2599458 Mon Sep 17 00:00:00 2001 From: quick Date: Sun, 26 Jul 2026 04:44:23 +0000 Subject: [PATCH 4/8] fix: preserve aliased manifest diff semantics --- README.md | 2 +- lib/diff.sh | 45 +++++++++++++++++++++++++++++++++++------ test/diff.bats | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 95c31d3..15ab63f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 410](https://img.shields.io/badge/tests-410-brightgreen?style=flat)](test/) +[![tests: 412](https://img.shields.io/badge/tests-412-brightgreen?style=flat)](test/) ![lints: 8](https://img.shields.io/badge/lints-8-blue?style=flat) [![license: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) diff --git a/lib/diff.sh b/lib/diff.sh index e8b3944..2795a8c 100644 --- a/lib/diff.sh +++ b/lib/diff.sh @@ -143,15 +143,48 @@ _changed_note_ids_between_refs() { done < "$changed_paths" awk -F '\t' ' - FILENAME == ARGV[1] { base[$1] = $2; next } + 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 + } { - seen[$1] = 1 - if (!($1 in base) || base[$1] != $2) print $1 + 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 } - END { for (id in base) if (!(id in seen)) print id } - ' "$base_manifest" "$head_manifest" >> "$candidates" + ' "$candidates" "$base_manifest" "$head_manifest" > "$out" - LC_ALL=C sort -u "$candidates" > "$out" + LC_ALL=C sort -u -o "$out" "$out" rm -f "$changed_paths" "$candidates" } diff --git a/test/diff.bats b/test/diff.bats index 39011f9..16d13cb 100644 --- a/test/diff.bats +++ b/test/diff.bats @@ -117,6 +117,61 @@ commit_readable_update() { 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 From 6239db142bd9f552f41777a84e443850c42cc53c Mon Sep 17 00:00:00 2001 From: johnson Date: Sun, 26 Jul 2026 04:58:29 +0000 Subject: [PATCH 5/8] fix: reject unreadable manifest path shapes --- README.md | 2 +- lib/diff.sh | 31 +++++++++++++++++++++++++++++++ test/diff.bats | 26 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 15ab63f..e70606e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 412](https://img.shields.io/badge/tests-412-brightgreen?style=flat)](test/) +[![tests: 413](https://img.shields.io/badge/tests-413-brightgreen?style=flat)](test/) ![lints: 8](https://img.shields.io/badge/lints-8-blue?style=flat) [![license: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) diff --git a/lib/diff.sh b/lib/diff.sh index 2795a8c..486449b 100644 --- a/lib/diff.sh +++ b/lib/diff.sh @@ -22,6 +22,32 @@ _guard_manifest_path() { esac } +_validate_manifest_readable_paths() { + local ref="$1" manifest="$2" + + awk -F '\t' -v ref="$ref" ' + { + separator = index($0, FS) + path = substr($0, separator + 1) + paths[path] = 1 + } + END { + invalid = 0 + for (path in paths) { + ancestor = path + while (sub(/\/[^/]+$/, "", ancestor)) { + if (ancestor in paths) { + printf "Error: %s readable path is both a file and directory: %s\n", ref, ancestor > "/dev/stderr" + invalid = 1 + break + } + } + } + exit invalid + } + ' "$manifest" +} + # 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" @@ -107,6 +133,11 @@ _prepare_readable_notes_ref_index() { } ' "$tree_ids" "$validated_manifest" + if ! _validate_manifest_readable_paths "$ref" "$manifest_out"; then + rm -f "$tree_paths" "$tree_ids" "$raw_manifest" "$validated_manifest" "$missing_manifest" "$unmapped_file" + return 1 + 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 diff --git a/test/diff.bats b/test/diff.bats index 16d13cb..b1920af 100644 --- a/test/diff.bats +++ b/test/diff.bats @@ -172,6 +172,32 @@ commit_readable_update() { ! grep -q "a/notes/beta.md" "$out_dir/readable.patch" } +@test "notes diff rejects readable file and directory path collisions" { + local alpha_id beta_id manifest next_manifest + 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/prefix-colliding.manifest" + + { + printf '%s\tshared\n' "$alpha_id" + printf '%s\tshared/child.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 "create readable path prefix collision" + + echo "# Beta changed" > "$NOTES_CALLER_PWD/notes/$beta_id" + git -C "$NOTES_CALLER_PWD" add "notes/$beta_id" + git -C "$NOTES_CALLER_PWD" commit -q -m "edit colliding note" + + run notes diff HEAD~1 HEAD + + [ "$status" -ne 0 ] + [[ "$output" == *"readable path is both a file and directory: shared"* ]] + [[ "$output" != *"# Beta changed"* ]] +} + @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 From dd01a3e76bbe68c50639f707b3d8802ca505a914 Mon Sep 17 00:00:00 2001 From: johnson Date: Sun, 26 Jul 2026 05:01:36 +0000 Subject: [PATCH 6/8] fix: preserve manifest collision order --- README.md | 2 +- lib/diff.sh | 30 ++++++++++++++++++++++++++++-- test/diff.bats | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 15ab63f..e70606e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 412](https://img.shields.io/badge/tests-412-brightgreen?style=flat)](test/) +[![tests: 413](https://img.shields.io/badge/tests-413-brightgreen?style=flat)](test/) ![lints: 8](https://img.shields.io/badge/lints-8-blue?style=flat) [![license: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) diff --git a/lib/diff.sh b/lib/diff.sh index 2795a8c..7b1653d 100644 --- a/lib/diff.sh +++ b/lib/diff.sh @@ -143,24 +143,50 @@ _changed_note_ids_between_refs() { done < "$changed_paths" awk -F '\t' ' + function manifest_path(row) { + return substr(row, index(row, FS) + 1) + } FILENAME == ARGV[1] { selected_id[$1] = 1; next } FILENAME == ARGV[2] { + path = manifest_path($0) base_row[$0] = 1 row_id[$0] = $1 + base_path[path] = 1 + base_order[path, ++base_count[path]] = $1 edge_id[++edge_count] = $1 - edge_path[edge_count] = $2 + edge_path[edge_count] = path next } { + path = manifest_path($0) head_row[$0] = 1 row_id[$0] = $1 + head_path[path] = 1 + head_order[path, ++head_count[path]] = $1 edge_id[++edge_count] = $1 - edge_path[edge_count] = $2 + edge_path[edge_count] = path } 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 + # Exact row sets do not capture order. When IDs collide on one readable + # path, their manifest order decides which blob materialization leaves + # behind. Select every ID on paths whose order changed. + for (path in base_path) compared_path[path] = 1 + for (path in head_path) compared_path[path] = 1 + for (path in compared_path) { + order_changed = (base_count[path] != head_count[path]) + count = base_count[path] > head_count[path] ? base_count[path] : head_count[path] + for (i = 1; !order_changed && i <= count; i++) { + if (base_order[path, i] != head_order[path, i]) order_changed = 1 + } + if (order_changed) { + for (i = 1; i <= base_count[path]; i++) selected_id[base_order[path, i]] = 1 + for (i = 1; i <= head_count[path]; i++) selected_id[head_order[path, i]] = 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. diff --git a/test/diff.bats b/test/diff.bats index 16d13cb..47982da 100644 --- a/test/diff.bats +++ b/test/diff.bats @@ -172,6 +172,38 @@ commit_readable_update() { ! grep -q "a/notes/beta.md" "$out_dir/readable.patch" } +@test "notes diff preserves changed overwrite order for colliding readable paths" { + 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/reordered.manifest" + out_dir="$BATS_TEST_TMPDIR/reordered-review" + + { + printf '%s\tshared.md\n' "$alpha_id" + printf '%s\tshared.md\n' "$beta_id" + } > "$manifest" + git -C "$NOTES_CALLER_PWD" add notes/.manifest + git -C "$NOTES_CALLER_PWD" commit -q -m "collide readable paths" + + { + printf '%s\tshared.md\n' "$beta_id" + printf '%s\tshared.md\n' "$alpha_id" + } > "$next_manifest" + mv "$next_manifest" "$manifest" + git -C "$NOTES_CALLER_PWD" add notes/.manifest + git -C "$NOTES_CALLER_PWD" commit -q -m "change collision overwrite order" + + run notes diff --out "$out_dir" HEAD~1 HEAD + + [ "$status" -eq 0 ] + grep -q "# Beta" "$out_dir/base/notes/shared.md" + grep -q "# Alpha" "$out_dir/head/notes/shared.md" + grep -q -- "-# Beta" "$out_dir/readable.patch" + grep -q -- "+# Alpha" "$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 From 664644429802c9f1decb700f8e823cd8cc963120 Mon Sep 17 00:00:00 2001 From: johnson Date: Sun, 26 Jul 2026 05:04:56 +0000 Subject: [PATCH 7/8] perf: linearize manifest component expansion --- lib/diff.sh | 51 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/lib/diff.sh b/lib/diff.sh index 2795a8c..e1f0f31 100644 --- a/lib/diff.sh +++ b/lib/diff.sh @@ -143,19 +143,41 @@ _changed_note_ids_between_refs() { done < "$changed_paths" awk -F '\t' ' + function component_root(node, current, next_node) { + if (!(node in parent)) parent[node] = node + current = node + while (parent[current] != current) current = parent[current] + while (parent[node] != node) { + next_node = parent[node] + parent[node] = current + node = next_node + } + return current + } + function connect(left, right, left_root, right_root) { + left_root = component_root(left) + right_root = component_root(right) + if (left_root == right_root) return + if (rank[left_root] < rank[right_root]) { + parent[left_root] = right_root + } else { + parent[right_root] = left_root + if (rank[left_root] == rank[right_root]) rank[left_root]++ + } + } 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 + all_id[$1] = 1 + connect("id" SUBSEP $1, "path" SUBSEP $2) next } { head_row[$0] = 1 row_id[$0] = $1 - edge_id[++edge_count] = $1 - edge_path[edge_count] = $2 + all_id[$1] = 1 + connect("id" SUBSEP $1, "path" SUBSEP $2) } END { for (row in base_row) if (!(row in head_row)) selected_id[row_id[row]] = 1 @@ -164,23 +186,10 @@ _changed_note_ids_between_refs() { # 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 + for (id in selected_id) selected_component[component_root("id" SUBSEP id)] = 1 + for (id in all_id) { + if (component_root("id" SUBSEP id) in selected_component) print id + } } ' "$candidates" "$base_manifest" "$head_manifest" > "$out" From 84449ddbba5bf9afa091b27def10487b168b65e3 Mon Sep 17 00:00:00 2001 From: johnson Date: Sun, 26 Jul 2026 05:07:19 +0000 Subject: [PATCH 8/8] fix: keep path validation portable --- lib/diff.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/diff.sh b/lib/diff.sh index 486449b..94b4f2d 100644 --- a/lib/diff.sh +++ b/lib/diff.sh @@ -35,7 +35,7 @@ _validate_manifest_readable_paths() { invalid = 0 for (path in paths) { ancestor = path - while (sub(/\/[^/]+$/, "", ancestor)) { + while (sub(/\/[^\/]+$/, "", ancestor)) { if (ancestor in paths) { printf "Error: %s readable path is both a file and directory: %s\n", ref, ancestor > "/dev/stderr" invalid = 1