diff --git a/.mise/tasks/obfuscate b/.mise/tasks/obfuscate index 02b64f0..778db39 100755 --- a/.mise/tasks/obfuscate +++ b/.mise/tasks/obfuscate @@ -39,12 +39,17 @@ renamed=$(mktemp) || { echo "Error: failed to create rename result" >&2 exit 1 } -head_manifest=$(mktemp) || { +indexed_manifest=$(mktemp) || { rm -f "$plan" "$renamed" echo "Error: failed to create manifest snapshot" >&2 exit 1 } -trap 'rm -f "$plan" "$renamed" "$head_manifest"' EXIT +staged_manifest=$(mktemp) || { + rm -f "$plan" "$renamed" "$indexed_manifest" + echo "Error: failed to create staged manifest" >&2 + exit 1 +} +trap 'rm -f "$plan" "$renamed" "$indexed_manifest" "$staged_manifest"' EXIT if ! build_obfuscation_plan "$abs_notes_dir" "$plan" ${ARGS[@]+"${ARGS[@]}"}; then echo "Error: obfuscation planning failed" >&2 @@ -109,15 +114,16 @@ git -C "$TARGET_DIR" rm --cached --quiet --ignore-unmatch -- \ "${readable_paths[@]}" # Full mode always owns the complete manifest. Scoped mode stages it only when -# a renamed mapping is absent from HEAD, preventing unrelated uncommitted -# mappings from leaking into a pre-commit operation. +# a renamed mapping is absent from the index, preventing unrelated unstaged +# mappings from leaking while preserving manifest changes already selected by +# notes stage. stage_manifest=false if ! $scoped_mode; then stage_manifest=true else if ! git -C "$TARGET_DIR" cat-file --filters \ - "HEAD:$notes_dir/.manifest" > "$head_manifest" 2>/dev/null; then - : > "$head_manifest" + ":$notes_dir/.manifest" > "$indexed_manifest" 2>/dev/null; then + : > "$indexed_manifest" fi if ! awk -F '\t' -v renamed="$renamed" ' FILENAME != renamed { @@ -126,13 +132,36 @@ else } !(($2 FS $1) in entries) { missing = 1 } END { exit missing ? 1 : 0 } - ' "$head_manifest" "$renamed"; then + ' "$indexed_manifest" "$renamed"; then stage_manifest=true fi fi if $stage_manifest; then - git -C "$TARGET_DIR" add "$manifest" + if ! $scoped_mode; then + git -C "$TARGET_DIR" add "$manifest" + else + # Stage the indexed manifest plus only this operation's mappings. Keep + # unrelated working edits out of the index, and use the same canonical + # name ordering as the working manifest. + awk -F '\t' -v OFS='\t' -v renamed="$renamed" ' + FILENAME == renamed { + target_ids[$2] = 1 + target_names[$1] = 1 + rows[++count] = $2 FS $1 + next + } + !(($1 in target_ids) || ($2 in target_names)) { print } + END { + for (i = 1; i <= count; i++) print rows[i] + } + ' "$renamed" "$indexed_manifest" \ + | sort -t$'\t' -k2 > "$staged_manifest" + manifest_blob=$(git -C "$TARGET_DIR" hash-object -w \ + --path="$notes_dir/.manifest" "$staged_manifest") + git -C "$TARGET_DIR" update-index --add --cacheinfo \ + 100644 "$manifest_blob" "$notes_dir/.manifest" + fi fi echo "" diff --git a/README.md b/README.md index 2bf7ce3..8fb26a2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 419](https://img.shields.io/badge/tests-419-brightgreen?style=flat)](test/) +[![tests: 420](https://img.shields.io/badge/tests-420-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/test/obfuscate.bats b/test/obfuscate.bats index 786ebec..31ed75b 100644 --- a/test/obfuscate.bats +++ b/test/obfuscate.bats @@ -174,6 +174,34 @@ setup() { [ -f "$NOTES_CALLER_PWD/notes/$alpha_id" ] } +@test "scoped new mapping stages canonical order without unrelated manifest edits" { + # Select a later-sorting mapping in the index, then leave another mapping + # only in the working manifest. + printf '22222222\tgamma.txt\n' > "$NOTES_CALLER_PWD/notes/.manifest" + git -C "$NOTES_CALLER_PWD" add -f notes/.manifest + printf '33333333\tbeta.md\n' >> "$NOTES_CALLER_PWD/notes/.manifest" + + notes obfuscate alpha.md + + local staged_manifest staged_names working_manifest + staged_manifest=$(git -C "$NOTES_CALLER_PWD" show :notes/.manifest) + staged_names=$(printf '%s\n' "$staged_manifest" | cut -f2) + working_manifest=$(cat "$NOTES_CALLER_PWD/notes/.manifest") + [ "$staged_names" = $'alpha.md\ngamma.txt' ] + [[ "$staged_manifest" != *$'\tbeta.md'* ]] + [[ "$working_manifest" == *$'\talpha.md'* ]] + [[ "$working_manifest" == *$'33333333\tbeta.md'* ]] + + # After committing and removing the intentionally unstaged mapping, no + # order-only manifest difference remains. + git -C "$NOTES_CALLER_PWD" commit -q --no-verify -m "scoped obfuscation" + grep -v $'33333333\tbeta.md' "$NOTES_CALLER_PWD/notes/.manifest" \ + > "$NOTES_CALLER_PWD/notes/.manifest.filtered" + mv "$NOTES_CALLER_PWD/notes/.manifest.filtered" \ + "$NOTES_CALLER_PWD/notes/.manifest" + git -C "$NOTES_CALLER_PWD" diff --quiet -- notes/.manifest +} + @test "obfuscate refuses to overwrite an existing known-ID destination" { notes obfuscate local alpha_id @@ -284,7 +312,7 @@ SH [ "$rm_calls" -eq 1 ] } -@test "scoped obfuscate reads the HEAD manifest once and batches staging" { +@test "scoped obfuscate reads the indexed manifest once and batches staging" { local mock_bin command real_command call_log notes obfuscate git -C "$NOTES_CALLER_PWD" commit -q --no-verify -m "obfuscated" @@ -306,7 +334,7 @@ SH notes obfuscate alpha.md beta.md [ "$status" -eq 0 ] - [ "$(grep -c $'^git\t.* cat-file --filters HEAD:notes/.manifest$' "$call_log" || true)" -eq 1 ] + [ "$(grep -c $'^git\t.* cat-file --filters :notes/.manifest$' "$call_log" || true)" -eq 1 ] [ "$(grep -c $'^git\t.* add -- notes/' "$call_log" || true)" -eq 1 ] [ "$(grep -c $'^git\t.* rm --cached --quiet --ignore-unmatch -- notes/' "$call_log" || true)" -eq 1 ] }