diff --git a/.vault/scripts/lib-blame.sh b/.vault/scripts/lib-blame.sh new file mode 100644 index 0000000..ee6dce2 --- /dev/null +++ b/.vault/scripts/lib-blame.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# ============================================================================== +# LIB-BLAME — Page history correlation for vault-tools +# ============================================================================== +# +# Contains the blame command: +# cmd_blame() — Show the git change history of a vault file and correlate +# each commit with the matching wiki/log.md entry, answering +# "why does this page say X?" +# +# Uses `git log --follow` so history is tracked across renames. Log entries +# are matched by date against the SR-005 heading format: +# ## [YYYY-MM-DD] operation | Title +# and refined by file path: entries whose block does not mention the target +# path are labeled "(date match only)". +# +# This file is sourced by vault-tools.sh and depends on functions and +# variables from lib-utils.sh and the entry point configuration. +# +# ============================================================================== + +# This file is sourced by vault-tools.sh — do not execute directly +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && { echo "Source this file, don't execute it directly."; exit 1; } + +# ============================================================================== +# COMMAND: blame +# ============================================================================== +# Usage: vault-tools.sh blame +# +# Behavior: +# - Prints one row per commit: DATE | SHA | AUTHOR | SUMMARY +# - Beneath each row, lists wiki/log.md entries whose heading date matches +# the commit date ("log: | "), or a note when none +# does. Entries that do not mention the file path in their block are +# suffixed "(date match only)". +# - Exit 2 on missing/invalid argument, exit 1 when not in a git repo. + +cmd_blame() { + header "Vault Blame" + + local target="${1:-}" + if [[ -z "$target" ]]; then + error "Usage: vault-tools.sh blame <file-path>" + return 2 + fi + + # Resolve the target: as given (relative to cwd) or relative to VAULT_ROOT + local abs_path + if [[ -f "$target" ]]; then + abs_path="$(cd "$(dirname "$target")" && pwd)/$(basename "$target")" + elif [[ -f "${VAULT_ROOT}/${target}" ]]; then + abs_path="${VAULT_ROOT}/${target}" + else + error "File not found: ${target}" + error "Usage: vault-tools.sh blame <file-path>" + return 2 + fi + local rel_path="${abs_path#"${VAULT_ROOT}"/}" + if [[ "$rel_path" == /* ]]; then + error "File is outside the vault: ${target}" + return 2 + fi + + if ! git -C "${VAULT_ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + warning "Not inside a git repository — blame requires git history" + return 1 + fi + + # %x1f (unit separator) cannot appear in names/summaries, unlike '|' + local history + history="$(git -C "${VAULT_ROOT}" log --follow \ + --format="%h%x1f%ad%x1f%an%x1f%s" --date=short -- "$rel_path")" + if [[ -z "$history" ]]; then + warning "No git history found for ${rel_path} (not committed yet?)" + return 0 + fi + + subheader "History for ${rel_path}" + echo "" + printf " %-10s | %-9s | %-20s | %s\n" "DATE" "SHA" "AUTHOR" "SUMMARY" + printf " %-10s-+-%-9s-+-%-20s-+-%s\n" "----------" "---------" \ + "--------------------" "----------------------------------------" + + local sha commit_date author summary log_matches log_line + while IFS=$'\x1f' read -r sha commit_date author summary; do + printf " %-10s | %-9s | %-20s | %s\n" \ + "$commit_date" "$sha" "$author" "$summary" + + # Correlate with wiki/log.md entries (SR-005): match headings by + # date, then check each entry's block for the file path (from the + # "Files modified" list). Date-only matches are flagged as such. + # Path detection is a substring test: a block mentioning a longer + # path that contains this one (e.g. page.md.bak) also counts. + # The kind is emitted BEFORE the heading so a tab inside a heading + # title cannot corrupt the field split below. + log_matches="" + if [[ -f "${LOG_FILE}" ]]; then + log_matches="$(awk -v tag="## [${commit_date}]" -v p="$rel_path" ' + /^## \[/ { + if (inblk) print (hit ? "path" : "date") "\t" head + inblk = (index($0, tag) == 1); head = $0; hit = 0; next + } + inblk && index($0, p) { hit = 1 } + END { if (inblk) print (hit ? "path" : "date") "\t" head } + ' "${LOG_FILE}" || true)" + fi + if [[ -n "$log_matches" ]]; then + local match_kind suffix + while IFS=$'\t' read -r match_kind log_line; do + suffix="" + [[ "$match_kind" == "date" ]] && suffix=" (date match only)" + printf " %-10s | log: %s%s\n" "" \ + "${log_line#\#\# \[${commit_date}\] }" "$suffix" + done <<< "$log_matches" + else + printf " %-10s | log: (no matching entry)\n" "" + fi + done <<< "$history" + echo "" +} diff --git a/.vault/scripts/tests/test-blame.sh b/.vault/scripts/tests/test-blame.sh new file mode 100755 index 0000000..8517fde --- /dev/null +++ b/.vault/scripts/tests/test-blame.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Test for the blame command (issue #14). +# +# Builds a disposable vault inside a fresh git repo, commits a wiki page, +# appends a matching wiki/log.md entry dated today, and asserts that +# `vault-tools.sh blame` correlates the commit with the log entry: +# - exit 0 and output containing the short SHA and the log entry title +# - exit 2 with usage on a nonexistent file +# +# Run: bash .vault/scripts/tests/test-blame.sh +# Exit: 0 on PASS, non-zero on any FAIL. + +set -euo pipefail + +VAULT_ROOT="$(git rev-parse --show-toplevel)" +FIXTURE_DIR="$(mktemp -d)" +trap 'rm -rf "$FIXTURE_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +TODAY="$(date +%Y-%m-%d)" + +# ------------------------------------------------------------------ +# Build a minimal vault clone in a tempdir with its own git repo. +# Hooks are bypassed via core.hooksPath=/dev/null so the fixture +# commits don't need to satisfy the full pre-commit rule set. +# ------------------------------------------------------------------ +mkdir -p "$FIXTURE_DIR/wiki/concepts" "$FIXTURE_DIR/memory" +cp -r "$VAULT_ROOT/.vault" "$FIXTURE_DIR/.vault" + +git -C "$FIXTURE_DIR" init -q +git -C "$FIXTURE_DIR" config user.name "Blame Tester" +git -C "$FIXTURE_DIR" config user.email "blame-tester@example.com" + +cat > "$FIXTURE_DIR/wiki/log.md" <<EOF +--- +title: "Operations Log" +type: index +created: ${TODAY} +updated: ${TODAY} +status: active +tags: + - type/index +owner: agent +confidence: high +--- + +# Operations Log +EOF + +cat > "$FIXTURE_DIR/wiki/concepts/concept-blame.md" <<EOF +--- +title: "Blame Test Concept" +type: concept +created: ${TODAY} +updated: ${TODAY} +status: draft +sources: [] +related: [] +tags: + - domain/engineering + - type/concept +owner: agent +confidence: high +--- + +# Blame Test Concept +EOF + +git -C "$FIXTURE_DIR" add -A +git -C "$FIXTURE_DIR" -c core.hooksPath=/dev/null commit -q -m "[ingest] Added blame test concept" + +# Append two same-day log entries: one whose block lists the target page +# in "Files modified" (definite match) and one that does not (date-only). +cat >> "$FIXTURE_DIR/wiki/log.md" <<EOF + +## [${TODAY}] ingest | Blame Test Concept Ingested + +- **Agent**: test +- **Files modified**: wiki/concepts/concept-blame.md +- **Summary**: fixture entry that mentions the target path + +## [${TODAY}] query | Unrelated Same-Day Operation + +- **Agent**: test +- **Files modified**: wiki/index.md +- **Summary**: fixture entry that does not mention the target path +EOF + +git -C "$FIXTURE_DIR" add -A +git -C "$FIXTURE_DIR" -c core.hooksPath=/dev/null commit -q -m "[log] Log entry for blame test" + +short_sha="$(git -C "$FIXTURE_DIR" log --format="%h" -1 -- wiki/concepts/concept-blame.md)" +[[ -n "$short_sha" ]] || fail "could not resolve the fixture commit SHA" + +# ------------------------------------------------------------------ +# Assert 1: blame on the committed page exits 0 and correlates the +# commit with the log entry appended above. +# ------------------------------------------------------------------ +output="$(cd "$FIXTURE_DIR" && bash .vault/scripts/vault-tools.sh blame wiki/concepts/concept-blame.md)" \ + || fail "blame exited non-zero on a committed wiki page" + +echo "$output" | grep -q "$short_sha" \ + || fail "blame output missing the short SHA ${short_sha}" +echo "$output" | grep -q "log: ingest | Blame Test Concept Ingested" \ + || fail "blame output missing the correlated log entry title" + +# Path-aware correlation (issue #14: match by date AND file path): the +# entry naming the page must NOT be date-only; the unrelated one must be. +echo "$output" | grep "log: ingest | Blame Test Concept Ingested" \ + | grep -q "(date match only)" \ + && fail "path-matched log entry wrongly labeled as date match only" +echo "$output" | grep "log: query | Unrelated Same-Day Operation" \ + | grep -q "(date match only)" \ + || fail "date-only log entry not labeled '(date match only)'" + +# ------------------------------------------------------------------ +# Assert: a file outside the vault is rejected with exit 2. +# Explicit /tmp template: must land outside the fixture vault even when +# the caller's environment exports TMPDIR. +# ------------------------------------------------------------------ +outside_file="$(mktemp /tmp/blame-outside.XXXXXX)" +rc=0 +output="$(cd "$FIXTURE_DIR" && bash .vault/scripts/vault-tools.sh blame "$outside_file" 2>&1)" || rc=$? +rm -f "$outside_file" +[[ "$rc" -eq 2 ]] || fail "blame on an outside-vault file exited ${rc}, expected 2" +echo "$output" | grep -q "outside the vault" \ + || fail "blame error output missing outside-the-vault message" + +# ------------------------------------------------------------------ +# Assert 2: blame on a nonexistent file exits 2 with usage. +# ------------------------------------------------------------------ +rc=0 +output="$(cd "$FIXTURE_DIR" && bash .vault/scripts/vault-tools.sh blame wiki/no-such-page.md 2>&1)" || rc=$? +[[ "$rc" -eq 2 ]] || fail "blame on a nonexistent file exited ${rc}, expected 2" +echo "$output" | grep -q "Usage: vault-tools.sh blame" \ + || fail "blame error output missing usage line" + +echo "PASS: blame correlates commits with wiki/log.md entries" diff --git a/.vault/scripts/vault-tools.sh b/.vault/scripts/vault-tools.sh index c80d291..eaddc94 100644 --- a/.vault/scripts/vault-tools.sh +++ b/.vault/scripts/vault-tools.sh @@ -22,6 +22,7 @@ # ./vault-tools.sh skill-manifest <dir> Generate/refresh skill-manifest.json # ./vault-tools.sh content-audit Audit content integrity # ./vault-tools.sh verify-sources Verify sources: citations resolve to raw/ files +# ./vault-tools.sh blame <file> Show file history correlated with log.md # ./vault-tools.sh stats Show vault statistics # ./vault-tools.sh init-hooks Install git hooks # ./vault-tools.sh doctor Full diagnostic check @@ -93,6 +94,7 @@ source "${SCRIPT_DIR}/lib-lint.sh" source "${SCRIPT_DIR}/lib-index.sh" source "${SCRIPT_DIR}/lib-manage.sh" source "${SCRIPT_DIR}/lib-skills.sh" +source "${SCRIPT_DIR}/lib-blame.sh" # ============================================================================== # HELP @@ -118,6 +120,7 @@ cmd_help() { echo " verify-sources Verify sources: citations resolve to raw/ files" echo "" echo "Management:" + echo " blame <file> Show file history correlated with wiki/log.md" echo " status Show vault status" echo " stats Show detailed vault statistics" echo " index-rebuild Rebuild wiki/index.md (destructive full rewrite)" @@ -149,6 +152,7 @@ main() { skill-manifest) cmd_skill_manifest "$@" ;; content-audit) cmd_content_audit "$@" ;; verify-sources) cmd_verify_sources "$@" ;; + blame) cmd_blame "$@" ;; index-rebuild) cmd_index_rebuild "$@" ;; index-update) cmd_index_update "$@" ;; index-split) cmd_index_split "$@" ;; diff --git a/CHANGELOG.md b/CHANGELOG.md index b8419bf..bf38730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 page as verified or dangling with the specific missing paths, exits 1 when any citation dangles, and runs inside `doctor` as an advisory check that does not block an otherwise healthy vault (#7). +- `vault-tools.sh blame <file>` — shows a file's git change history + (`git log --follow`, so renames are tracked) as a DATE | SHA | AUTHOR | + SUMMARY table and correlates each commit with the `wiki/log.md` entries + dated the same day, answering "why does this page say X?" (#14). ### Changed