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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .mise/tasks/audit
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import os
import subprocess
import sys
from pathlib import Path

Expand All @@ -25,6 +26,13 @@ def main() -> int:
print(f"Error: notes directory not found: {notes_dir}", file=sys.stderr)
return 1

readable = subprocess.run(
[str(repo_dir / "lib" / "readable-state.sh"), "require", str(notes_dir)],
check=False,
)
if readable.returncode != 0:
return readable.returncode

try:
top_n = parse_top(os.environ.get("usage_top", "10"))
except ValueError as exc:
Expand Down
1 change: 1 addition & 0 deletions .mise/tasks/commit
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ if [ ! -f "$manifest" ]; then
echo "No manifest found. Run 'notes obfuscate' first." >&2
exit 1
fi
require_readable_notes_state "$abs_notes_dir"

ARGS=()
if [ -n "${usage_files:-}" ]; then
Expand Down
1 change: 1 addition & 0 deletions .mise/tasks/conflicts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require_git
notes_dir="${usage_dir:-notes}"
out_arg="${usage_out:-}"
repo_root=$(git -C "$TARGET_DIR" rev-parse --show-toplevel)
require_readable_notes_state "$repo_root/$notes_dir"

if ! records=$(notes_conflict_records "$repo_root" "$notes_dir"); then
exit 1
Expand Down
1 change: 1 addition & 0 deletions .mise/tasks/diff
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pr_number="${usage_pr:-}"
out_arg="${usage_out:-}"
abs_notes_dir="$TARGET_DIR/$notes_dir"
repo_root=$(git -C "$TARGET_DIR" rev-parse --show-toplevel)
require_readable_notes_state "$abs_notes_dir"

ARGS=()
if [ -n "${usage_refs:-}" ]; then
Expand Down
2 changes: 2 additions & 0 deletions .mise/tasks/list
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
set -euo pipefail

REPO_DIR="${MISE_CONFIG_ROOT:?MISE_CONFIG_ROOT not set}"
source "$REPO_DIR/lib/readable-state.sh"
TARGET_DIR="${NOTES_CALLER_PWD:-.}"
notes_dir="$TARGET_DIR/${usage_dir:-notes}"

if [ ! -d "$notes_dir" ]; then
echo "Error: notes directory not found: $notes_dir" >&2
exit 1
fi
require_readable_notes_state "$notes_dir"

python3 - "$REPO_DIR" "$notes_dir" "${usage_json:-false}" "${usage_recent:-}" "${usage_tag:-}" "${usage_type:-}" "${usage_status:-}" <<'PY'
import json
Expand Down
3 changes: 2 additions & 1 deletion .mise/tasks/merge
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ if [ "${usage_dry_run:-false}" != "true" ]; then
exit 1
fi

args=(conflicts --dir "${usage_dir:-notes}")
notes_dir="${usage_dir:-notes}"
args=(conflicts --dir "$notes_dir")
if [ -n "${usage_out:-}" ]; then
args+=(--out "${usage_out}")
fi
Expand Down
3 changes: 3 additions & 0 deletions .mise/tasks/new
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
#USAGE flag "--dir <dir>" default="notes" help="Notes directory relative to repo root"
set -euo pipefail

REPO_DIR="${MISE_CONFIG_ROOT:?MISE_CONFIG_ROOT not set}"
source "$REPO_DIR/lib/readable-state.sh"
TARGET_DIR="${NOTES_CALLER_PWD:-.}"
notes_dir="$TARGET_DIR/${usage_dir:-notes}"
today=$(date +%Y-%m-%d)
Expand All @@ -18,6 +20,7 @@ if [ ! -d "$notes_dir" ]; then
echo "Error: notes directory not found: $notes_dir" >&2
exit 1
fi
require_readable_notes_state "$notes_dir"

file="$notes_dir/${usage_slug}.md"
if [ -f "$file" ]; then
Expand Down
8 changes: 8 additions & 0 deletions .mise/tasks/parse
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path

Expand Down Expand Up @@ -45,6 +46,13 @@ def existing_path_candidates(selector: str) -> list[Path]:


def main() -> int:
readable = subprocess.run(
[str(repo_dir / "lib" / "readable-state.sh"), "require", str(notes_dir)],
check=False,
)
if readable.returncode != 0:
return readable.returncode

note_path = None
for candidate in existing_path_candidates(selector):
if candidate.is_file():
Expand Down
11 changes: 8 additions & 3 deletions .mise/tasks/pull
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ abs_notes_dir="$(cd "$TARGET_DIR/$notes_dir" 2>/dev/null && pwd)" || {
exit 1
}
manifest="$abs_notes_dir/.manifest"
readable_state=$(notes_readable_state "$abs_notes_dir")
if [ "$readable_state" = "locked" ]; then
echo "Notes are locked; skipping readable manifest checks and reconciliation."
fi

# --- Check for assume-unchanged manifest ---
# git pull can fail when notes/.manifest is assume-unchanged but differs
# between local HEAD and upstream. Detect this early and repair.

if [ -f "$manifest" ]; then
if [ "$readable_state" = "readable" ] && [ -f "$manifest" ]; then
manifest_rc=0
NOTES_QUIET_MANIFEST_CHECK=1 detect_assume_unchanged_manifest "$notes_dir" || manifest_rc=$?

Expand All @@ -48,8 +52,9 @@ fi
# --- Determine obfuscation state ---
# We need to know if we're deobfuscated so we can re-apply suppression after pull.

obf_status="unknown"
if [ -f "$manifest" ]; then
obf_status="$readable_state"
if [ "$readable_state" = "readable" ] && [ -f "$manifest" ]; then
obf_status="unknown"
first_name=$(head -1 "$manifest" | cut -f2)
first_hash=$(head -1 "$manifest" | cut -f1)
if [ -n "$first_name" ] && [ -f "$abs_notes_dir/$first_name" ]; then
Expand Down
2 changes: 2 additions & 0 deletions .mise/tasks/search
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
set -euo pipefail

REPO_DIR="${MISE_CONFIG_ROOT:?MISE_CONFIG_ROOT not set}"
source "$REPO_DIR/lib/readable-state.sh"
TARGET_DIR="${NOTES_CALLER_PWD:-.}"
notes_dir="$TARGET_DIR/${usage_dir:-notes}"

if [ ! -d "$notes_dir" ]; then
echo "Error: notes directory not found: $notes_dir" >&2
exit 1
fi
require_readable_notes_state "$notes_dir"

python3 - "$REPO_DIR" "$notes_dir" "${usage_query:-}" "${usage_json:-false}" "${usage_tag:-}" "${usage_type:-}" "${usage_status:-}" "${usage_limit:-50}" <<'PY'
import json
Expand Down
2 changes: 2 additions & 0 deletions .mise/tasks/show
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
set -euo pipefail

REPO_DIR="${MISE_CONFIG_ROOT:?MISE_CONFIG_ROOT not set}"
source "$REPO_DIR/lib/readable-state.sh"
TARGET_DIR="${NOTES_CALLER_PWD:-.}"
notes_dir="$TARGET_DIR/${usage_dir:-notes}"

if [ ! -d "$notes_dir" ]; then
echo "Error: notes directory not found: $notes_dir" >&2
exit 1
fi
require_readable_notes_state "$notes_dir"

python3 - "$REPO_DIR" "$TARGET_DIR" "$notes_dir" "${usage_note:-}" "${usage_json:-false}" <<'PY'
import json
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

**Collective memory, encrypted.**

[![tests: 459](https://img.shields.io/badge/tests-459-brightgreen?style=flat)](test/)
[![tests: 467](https://img.shields.io/badge/tests-467-brightgreen?style=flat)](test/)
![lints: 8](https://img.shields.io/badge/lints-8-blue?style=flat)
[![license: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)

Expand Down
1 change: 1 addition & 0 deletions lib/changes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ detect_changes() {
resolve_notes_dir "$abs_notes_dir" || return
local repo_root="$RESOLVED_REPO_ROOT"
local notes_dir="$RESOLVED_NOTES_DIR"
require_readable_notes_state "$abs_notes_dir" || return
local workspace rc=0
workspace=$(mktemp -d) || return
: > "$workspace/detected"
Expand Down
1 change: 1 addition & 0 deletions lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ TARGET_DIR="${NOTES_CALLER_PWD:-.}"
NOTES_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NOTES_REPO_DIR="$(cd "$NOTES_LIB_DIR/.." && pwd)"
HOOKS_DIR="$NOTES_REPO_DIR/hooks"
source "$NOTES_LIB_DIR/readable-state.sh"

# ── Require checks ────────────────────────────────────────────

Expand Down
3 changes: 3 additions & 0 deletions lib/obfuscate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ build_obfuscation_plan() {
local manifest="$notes_dir/.manifest"
local workspace candidates manifest_input rc=0

require_readable_notes_state "$notes_dir" || return
workspace=$(mktemp -d) || {
echo "Error: failed to create obfuscation workspace" >&2
return 1
Expand Down Expand Up @@ -158,6 +159,7 @@ apply_obfuscation_plan() {
local manifest="$notes_dir/.manifest"
local new_entries kind id relpath new_count=0

require_readable_notes_state "$notes_dir" || return
[ -s "$plan_file" ] || return 2

new_entries=$(mktemp) || {
Expand Down Expand Up @@ -511,6 +513,7 @@ rename_to_readable() {
local manifest="$notes_dir/.manifest"
local count=0 dirty_count=0

require_readable_notes_state "$notes_dir" || return
[ ! -f "$manifest" ] && return 1

# _rename_one_to_readable returns: 0=renamed, 2=skipped,
Expand Down
100 changes: 100 additions & 0 deletions lib/readable-state.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# readable-state.sh — classify whether managed note content is readable

_GIT_CRYPT_HEADER_HEX="00474954435259505400"

# Print "readable" or "locked" for a notes directory.
# Missing/non-Git/plaintext note directories remain readable so their owning
# commands can preserve their existing validation and initialization behavior.
notes_readable_state() {
local notes_dir="${1:?usage: notes_readable_state <notes_dir>}"
local abs_notes_dir repo_root rel_notes_dir manifest attr filter header

if [ ! -d "$notes_dir" ]; then
printf '%s\n' "readable"
return 0
fi
if ! abs_notes_dir=$(cd "$notes_dir" 2>/dev/null && pwd -P); then
echo "Error: failed to resolve notes directory: $notes_dir" >&2
return 2
fi
if ! repo_root=$(git -C "$abs_notes_dir" rev-parse --show-toplevel 2>/dev/null); then
printf '%s\n' "readable"
return 0
fi

case "$abs_notes_dir" in
"$repo_root"/*) rel_notes_dir=${abs_notes_dir#"$repo_root"/} ;;
*)
echo "Error: notes directory is outside its Git repository: $abs_notes_dir" >&2
return 2
;;
esac

manifest="$abs_notes_dir/.manifest"
if [ ! -f "$manifest" ]; then
printf '%s\n' "readable"
return 0
fi

if ! attr=$(git -C "$repo_root" check-attr filter -- "$rel_notes_dir/.manifest"); then
echo "Error: failed to inspect encryption attributes for $rel_notes_dir/.manifest" >&2
return 2
fi
filter=${attr##*: }
if [ "$filter" != "git-crypt" ]; then
printf '%s\n' "readable"
return 0
fi

if ! header=$(LC_ALL=C od -An -tx1 -N10 "$manifest" 2>/dev/null); then
echo "Error: failed to inspect encrypted manifest header: $manifest" >&2
return 2
fi
header=$(printf '%s' "$header" | tr -d '[:space:]')

if [ "$header" = "$_GIT_CRYPT_HEADER_HEX" ]; then
printf '%s\n' "locked"
else
printf '%s\n' "readable"
fi
}

require_readable_notes_state() {
local notes_dir="${1:?usage: require_readable_notes_state <notes_dir>}"
local state status=0

state=$(notes_readable_state "$notes_dir") || status=$?
if [ "$status" -ne 0 ]; then
return "$status"
fi
if [ "$state" = "locked" ]; then
echo "Error: git-crypt is locked. Run 'notes unlock' first." >&2
return 1
fi
}

_readable_state_main() {
local command="${1:-}"
local notes_dir="${2:-}"

case "$command" in
probe)
[ -n "$notes_dir" ] || { echo "usage: readable-state.sh probe <notes-dir>" >&2; return 64; }
notes_readable_state "$notes_dir"
;;
require)
[ -n "$notes_dir" ] || { echo "usage: readable-state.sh require <notes-dir>" >&2; return 64; }
require_readable_notes_state "$notes_dir"
;;
*)
echo "usage: readable-state.sh <probe|require> <notes-dir>" >&2
return 64
;;
esac
}

if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -uo pipefail
_readable_state_main "$@"
fi
4 changes: 4 additions & 0 deletions lib/suppress.sh
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ set_status_suppression() {
local scoped_ids=("$@")
local manifest="$abs_notes_dir/.manifest"
[ ! -f "$manifest" ] && return
require_readable_notes_state "$abs_notes_dir" || return

resolve_notes_dir "$abs_notes_dir" || return
local repo_root="$RESOLVED_REPO_ROOT"
Expand Down Expand Up @@ -265,6 +266,7 @@ clear_status_suppression() {
local scoped_ids=("$@")
local manifest="$abs_notes_dir/.manifest"
[ ! -f "$manifest" ] && return
require_readable_notes_state "$abs_notes_dir" || return

resolve_notes_dir "$abs_notes_dir" || return
local repo_root="$RESOLVED_REPO_ROOT"
Expand Down Expand Up @@ -346,6 +348,7 @@ detect_stale_readable_notes() {
local abs_notes_dir="${1:?usage: detect_stale_readable_notes <abs_notes_dir>}"
local manifest="$abs_notes_dir/.manifest"
[ -f "$manifest" ] || return 0
require_readable_notes_state "$abs_notes_dir" || return

resolve_notes_dir "$abs_notes_dir" || return
local repo_root="$RESOLVED_REPO_ROOT"
Expand Down Expand Up @@ -495,6 +498,7 @@ rebuild_status_suppression() {
local abs_notes_dir="${1:?usage: rebuild_status_suppression <abs_notes_dir>}"
local manifest="$abs_notes_dir/.manifest"
[ -f "$manifest" ] || return 0
require_readable_notes_state "$abs_notes_dir" || return

resolve_notes_dir "$abs_notes_dir" || return
local repo_root="$RESOLVED_REPO_ROOT"
Expand Down
28 changes: 28 additions & 0 deletions test/changes.bats
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,34 @@ SH

# ── detect_changes ────────────────────────────────────────────

@test "notes changes refuses locked encrypted content before classifying paths" {
printf 'notes/** filter=git-crypt diff=git-crypt\n' > "$NOTES_CALLER_PWD/.gitattributes"
printf '\0GITCRYPT\0encrypted manifest bytes\n' > "$MANIFEST"
printf '\0GITCRYPT\0encrypted note bytes\n' > "$NOTES_CALLER_PWD/notes/aaaaaaaa"

run notes changes --summary
[ "$status" -ne 0 ]
[[ "$output" == *"git-crypt is locked"* ]]
[[ "$output" == *"notes unlock"* ]]
[[ "$output" != *"aaaaaaaa"* ]]

# A keyless clone can report initialized=false even though the encrypted
# manifest is present and unreadable.
run notes changes
[ "$status" -ne 0 ]
[[ "$output" == *"git-crypt is locked"* ]]
[[ "$output" != *"aaaaaaaa"* ]]
}

@test "notes changes preserves clean output when encryption is unlocked" {
printf 'notes/** filter=git-crypt diff=git-crypt\n' > "$NOTES_CALLER_PWD/.gitattributes"

run notes changes --summary

[ "$status" -eq 0 ]
[ "$output" = "No changes." ]
}

@test "detect_changes: no changes when files match HEAD" {
run detect_changes "$NOTES_CALLER_PWD/notes"
[ "$status" -eq 0 ]
Expand Down
Loading
Loading