cloverleaf-larry/lib/nc-revisions.sh
Bryan Johnson 5214d87a04 v0.8.27: nc-revisions — NetConfig change-history / revision diff
New tool: show how a thread/system/site changed over time by diffing
Cloverleaf NetConfig revision snapshots, annotated with who saved each and
when. Handles the non-zero-padded NetConfig<TS> revision dirs by parsing the
prologue date into a sortable key; scopes diffs to the requested thread/system
via nc-parse. Flags: <thread>[.<site>], --system, --site, --format
timeline|diff, --limit, --since. Wired as nc_revisions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:53:10 -07:00

471 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
# nc-revisions.sh — NetConfig change-history / revision-diff tool for Larry-Anywhere v3.
#
# Shows how a THREAD (or a multi-thread SYSTEM, or a whole SITE) changed over
# time by diffing Cloverleaf's own NetConfig revision SNAPSHOTS, annotated with
# WHO saved each revision and WHEN. Deterministic, pure bash+awk, NO API, NO
# network — runs identically on an API-blocked host (e.g. Gundersen).
#
# HOW CLOVERLEAF STORES REVISIONS (verified on the real integrator):
# Every save snapshots the FULL NetConfig into a per-revision directory under
# $HCIROOT/<site>/revisions/NetConfig<TS>/
# where <TS> is the save timestamp rendered as M D YYYY H M S with NO zero
# padding (so `NetConfig5212025121420` = 5/21/2025 12:14:20 and
# `NetConfig1126202513418` = 11/26/2025 1:34:18). Because the components are
# un-padded the directory NAME is NOT lexically sortable across months/hours,
# so we DO NOT sort on it. Each snapshot dir contains a full `NetConfig` file
# whose PROLOGUE carries the authoritative author + human-readable date:
# prologue
# who: <USER>
# date: <Month DD, YYYY H:MM:SS AM/PM TZ>
# type: net
# version: 3.20
# end_prologue
# (who/date are TAB-separated, leading-space-indented). We parse the prologue
# `date:` into a sortable key YYYYMMDDHHMMSS so the timeline is in true
# chronological order regardless of the un-padded dir name. The LIVE
# $HCIROOT/<site>/NetConfig is included as the most-recent ("(current)") point.
#
# WHAT EACH ROW SUMMARISES: for the requested scope we extract the relevant
# protocol-block(s) from each revision via nc-parse.sh (so the diff is SCOPED —
# we never diff the whole 10k-line NetConfig unless --site) and report, between
# consecutive revisions, the count of routing threads ADDED / REMOVED / MODIFIED
# (a thread whose block body changed). --site widens the scope to the entire
# NetConfig (protocol set + per-thread body changes across the whole file).
#
# Usage:
# nc-revisions.sh <thread> thread in $HCISITE
# nc-revisions.sh <thread>.<site> a thread in a specific site
# nc-revisions.sh <site>/<thread> v1 node form (nc-paths output feeds in)
# nc-revisions.sh --system <pattern> [--site S] a multi-thread system
# (case-insensitive name match)
# nc-revisions.sh --site <site> the WHOLE NetConfig for one site
#
# Flags:
# --format timeline (DEFAULT) who/when/summary table, one revision per block,
# plain text (label:value, aligned) for OneNote paste.
# --format diff the actual unified diff of the scoped NetConfig section
# between CONSECUTIVE revisions (oldest→newest), one block
# per transition.
# --limit N only the last N revisions (most recent N).
# --since <date> only revisions on/after <date>. Accepts YYYY-MM-DD or
# YYYYMMDD (compared against the parsed sortable key).
# --thread NAME thread to track (alternative to the positional form).
# --system PATTERN multi-thread system mode (see above).
# --site NAME the site to operate in (whole-NetConfig scope on its own;
# or scopes --thread/--system to a specific site).
# --hciroot DIR override $HCIROOT for site discovery.
# -h | --help this help.
#
# PIPE-FIRST: timeline rows and diff blocks are greppable; one revision (or one
# transition) per logical block, no decorative noise. The whole output is run
# through the shared control-byte sanitiser (lib/cygwin-safe.sh): the timeline
# is a human-readable artifact so it strips UNCONDITIONALLY; --format diff is
# tty-gated (raw bytes pass through on a pipe/redirect so a downstream consumer
# sees byte-identical diff text).
#
# Exit codes: 0 OK, 1 usage error, 2 not found.
set -u
set -o pipefail
NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
NCP="$LIB_DIR/nc-parse.sh"
# Shared sanitisers (see lib/cygwin-safe.sh). _sanitize_ctl strips C0 control
# bytes unconditionally (timeline = human-readable); _sanitize_ctl_tty strips
# only when stdout is a terminal (diff to a pipe stays byte-identical). Degrade
# safe to raw passthrough if the lib is somehow unavailable.
if [ -r "$LIB_DIR/cygwin-safe.sh" ]; then
# shellcheck disable=SC1090,SC1091
. "$LIB_DIR/cygwin-safe.sh"
else
_sanitize_ctl() { cat; }
_sanitize_ctl_tty() { cat; }
fi
die() { printf 'nc-revisions: %s\n' "$*" >&2; exit 1; }
# ─────────────────────────────────────────────────────────────────────────────
# Arg parsing
# ─────────────────────────────────────────────────────────────────────────────
THREAD=""
SYSTEM=""
SITE_ARG=""
HCIROOT_OVERRIDE=""
FORMAT="timeline"
LIMIT=0
SINCE=""
WHOLE_SITE=0
POSITIONAL=()
while [ $# -gt 0 ]; do
case "$1" in
--thread) shift; THREAD="${1:-}" ;;
--system) shift; SYSTEM="${1:-}" ;;
--site) shift; SITE_ARG="${1:-}"; WHOLE_SITE=1 ;;
--hciroot) shift; HCIROOT_OVERRIDE="${1:-}" ;;
--format) shift; FORMAT="${1:-timeline}" ;;
--limit) shift; LIMIT="${1:-0}" ;;
--since) shift; SINCE="${1:-}" ;;
-h|--help) sed -n '2,67p' "$NC_SELF" | sed 's/^# \{0,1\}//'; exit 0 ;;
--*) die "unknown flag: $1" ;;
*) POSITIONAL+=("$1") ;;
esac
shift
done
case "$FORMAT" in timeline|diff) ;; *) die "bad --format: $FORMAT (timeline|diff)" ;; esac
# coerce LIMIT to a clean int (CR-safe)
LIMIT=$(coerce_int "$LIMIT" 0 2>/dev/null || printf '%s' "$LIMIT")
case "$LIMIT" in ''|*[!0-9]*) LIMIT=0 ;; esac
# Positional shapes for a single thread:
# <thread> bare thread (site from --site / $HCISITE)
# <thread>.<site> thread.site (cross-site) — split on the LAST dot
# <site>/<thread> v1 node form (nc-paths output feeds back in)
# An explicit --thread / --system wins; a positional is only consumed when neither
# was given.
if [ -z "$THREAD" ] && [ -z "$SYSTEM" ] && [ "$WHOLE_SITE" = "0" ] && [ "${#POSITIONAL[@]}" -ge 1 ]; then
THREAD="${POSITIONAL[0]}"
fi
[ "${#POSITIONAL[@]}" -gt 1 ] && die "too many positional args: ${POSITIONAL[*]}"
# Resolve a site embedded in the thread token.
if [ -n "$THREAD" ]; then
case "$THREAD" in
*/*) # v1 node form site/thread (split on FIRST slash)
_s="${THREAD%%/*}"; _t="${THREAD#*/}"
if [ -n "$_s" ] && [ -n "$_t" ]; then THREAD="$_t"; [ -z "$SITE_ARG" ] && SITE_ARG="$_s"; fi ;;
*.*) # thread.site form (split on LAST dot)
_t="${THREAD%.*}"; _s="${THREAD##*.}"
if [ -n "$_t" ] && [ -n "$_s" ]; then THREAD="$_t"; [ -z "$SITE_ARG" ] && SITE_ARG="$_s"; fi ;;
esac
fi
# Default the site to $HCISITE when nothing else named it.
if [ -z "$SITE_ARG" ]; then SITE_ARG="${HCISITE:-}"; fi
# Exactly one scope must be determinable.
if [ "$WHOLE_SITE" = "1" ] && [ -z "$THREAD" ] && [ -z "$SYSTEM" ]; then
: # whole-site scope; SITE_ARG holds the site
elif [ -n "$SYSTEM" ]; then
:
elif [ -n "$THREAD" ]; then
:
else
die "no scope given. Try: nc-revisions.sh <thread> | nc-revisions.sh --system <pat> [--site S] | nc-revisions.sh --site <site>"
fi
[ -n "$SITE_ARG" ] || die "no site resolvable (set \$HCISITE, pass <thread>.<site>, or --site <site>)"
ROOT="${HCIROOT_OVERRIDE:-${HCIROOT:-}}"
[ -n "$ROOT" ] || die "no \$HCIROOT and no --hciroot; pass one or set the env var"
[ -d "$ROOT" ] || die "hciroot not a directory: $ROOT"
SITE_DIR="$ROOT/$SITE_ARG"
[ -d "$SITE_DIR" ] || die "site dir not found: $SITE_DIR"
REV_DIR="$SITE_DIR/revisions"
LIVE_NC="$SITE_DIR/NetConfig"
US=$'\037' # field separator for the discovered-revisions table
# ─────────────────────────────────────────────────────────────────────────────
# PROLOGUE PARSE — extract who + date from a NetConfig's prologue block, and
# render a sortable key YYYYMMDDHHMMSS from the human-readable date string.
# The prologue (lines 1..end_prologue) carries TAB-separated `who:` / `date:`
# fields. Emits exactly: SORTKEY<US>WHO<US>HUMANDATE (one line), or nothing.
# ─────────────────────────────────────────────────────────────────────────────
_prologue_facts() { # netconfig_file
local nc="$1"
[ -f "$nc" ] || return 0
awk -v US="$US" '
BEGIN {
m["January"]="01"; m["February"]="02"; m["March"]="03"; m["April"]="04"
m["May"]="05"; m["June"]="06"; m["July"]="07"; m["August"]="08"
m["September"]="09"; m["October"]="10"; m["November"]="11"; m["December"]="12"
who=""; dh=""
}
NR==1 && $0 !~ /^prologue/ { exit } # not a prologue-led NetConfig
/^[[:space:]]*who:/ { line=$0; sub(/^[[:space:]]*who:[[:space:]]*/,"",line); who=line }
/^[[:space:]]*date:/ { line=$0; sub(/^[[:space:]]*date:[[:space:]]*/,"",line); dh=line }
/^end_prologue/ { exit }
END {
# dh looks like: November 26, 2025 1:04:18 PM PST
key="00000000000000"
if (dh != "") {
d=dh
gsub(/,/," ",d)
n=split(d, f, /[ \t]+/)
# f: [1]=Month [2]=Day [3]=Year [4]=H:MM:SS [5]=AM/PM [6]=TZ
mon = (f[1] in m) ? m[f[1]] : "00"
day = f[2]+0; if (day<10) day="0" day
yr = f[3]+0
split(f[4], t, ":")
hh=t[1]+0; mm=t[2]+0; ss=t[3]+0
ap = toupper(f[5])
if (ap=="PM" && hh<12) hh+=12
if (ap=="AM" && hh==12) hh=0
if (hh<10) hh="0" hh
if (mm<10) mm="0" mm
if (ss<10) ss="0" ss
if (yr>0 && mon!="00") key = sprintf("%04d%s%s%s%s%s", yr, mon, day, hh, mm, ss)
}
printf "%s%s%s%s%s\n", key, US, who, US, dh
}
' "$nc"
}
# ─────────────────────────────────────────────────────────────────────────────
# REVISION DISCOVERY — build a chronologically-ordered list of (sortkey, who,
# humandate, label, netconfig-path) records, one per revision PLUS the live
# NetConfig. Written to $REVTBL, US-delimited, sorted ascending by sortkey.
# Honors --since and --limit (limit keeps the most-recent N).
#
# TIE-BREAK: the prologue `date:` is the LAST-EDITOR stamp, so two snapshots (and
# the live NetConfig copied from the newest snapshot) can share the SAME prologue
# date. To keep the order deterministic we carry a SECONDARY key, written as a
# leading sort-only column (stripped before $REVTBL):
# - snapshots → the dir-name digit string (the actual save timestamp), so two
# same-prologue snapshots still order by when they were saved.
# - live NetConfig → the sentinel `~~~~~~~~~~~~~~` (C-locale: `~` > any digit),
# so the CURRENT state always sorts LAST on a tie.
# ─────────────────────────────────────────────────────────────────────────────
REVTBL=$(mktemp)
trap 'rm -f "$REVTBL"' EXIT
_discover_revisions() {
local raw; raw=$(mktemp)
local d nc facts key who dh rest sec
# snapshot revisions
if [ -d "$REV_DIR" ]; then
for d in "$REV_DIR"/NetConfig*; do
[ -d "$d" ] || continue
nc="$d/NetConfig"
[ -f "$nc" ] || continue
facts=$(_prologue_facts "$nc")
[ -n "$facts" ] || continue
key="${facts%%$US*}"; rest="${facts#*$US}"
who="${rest%%$US*}"; dh="${rest#*$US}"
# secondary key = the dir-name digit run, zero-padded so a numeric save
# timestamp orders correctly as a string.
sec=$(basename "$d" | tr -cd '0-9'); sec=$(printf '%014d' "${sec:-0}" 2>/dev/null || printf '%s' "$sec")
printf '%s%s%s%s%s%s%s%s%s%s%s\n' \
"$key" "$US" "$sec" "$US" "$who" "$US" "$dh" "$US" "$(basename "$d")" "$US" "$nc" >> "$raw"
done
fi
# the live (current) NetConfig as the newest point — its secondary key is the
# `~` sentinel so it always sorts AFTER a same-prologue-date snapshot.
if [ -f "$LIVE_NC" ]; then
facts=$(_prologue_facts "$LIVE_NC")
if [ -n "$facts" ]; then
key="${facts%%$US*}"; rest="${facts#*$US}"
who="${rest%%$US*}"; dh="${rest#*$US}"
printf '%s%s%s%s%s%s%s%s%s%s%s\n' \
"$key" "$US" "~~~~~~~~~~~~~~" "$US" "$who" "$US" "$dh" "$US" "(current)" "$US" "$LIVE_NC" >> "$raw"
fi
fi
# --since filter: normalise YYYY-MM-DD / YYYYMMDD to an 8-digit prefix and
# compare against the sortkey's leading 8 digits (the date portion).
local since8=""
if [ -n "$SINCE" ]; then
since8=$(printf '%s' "$SINCE" | tr -cd '0-9')
since8="${since8:0:8}"
fi
# sort ascending by (sortkey, secondary), apply --since, then drop the secondary
# column so $REVTBL is the clean 5-field record: key|who|dh|label|nc.
LC_ALL=C sort -t"$US" -k1,1 -k2,2 "$raw" \
| awk -F"$US" -v US="$US" -v since8="$since8" '
{ if (since8=="" || substr($1,1,8) >= since8)
print $1 US $3 US $4 US $5 US $6 }
' > "$raw.sorted"
rm -f "$raw"
if [ "$LIMIT" -gt 0 ]; then
# keep the most-recent LIMIT (tail), preserving ascending order
tail -n "$LIMIT" "$raw.sorted" > "$REVTBL"
else
cp "$raw.sorted" "$REVTBL"
fi
rm -f "$raw.sorted"
}
# ─────────────────────────────────────────────────────────────────────────────
# SCOPED EXTRACTION — emit the relevant NetConfig text for ONE revision so the
# diff/summary is scoped to the requested thread/system (NOT the whole file)
# unless --site. The extraction is deterministic via nc-parse.sh.
# _scoped_text <netconfig> → the protocol-block(s) in scope (or whole file).
# For --system we concatenate every matching protocol's block (sorted by name)
# so a thread added/removed between revisions shows up as a block appearing/
# disappearing in the scoped text.
# ─────────────────────────────────────────────────────────────────────────────
_scope_threads() { # netconfig → the in-scope thread names, sorted unique
local nc="$1"
if [ "$WHOLE_SITE" = "1" ] && [ -z "$THREAD" ] && [ -z "$SYSTEM" ]; then
"$NCP" list-protocols "$nc" 2>/dev/null | LC_ALL=C sort -u
elif [ -n "$SYSTEM" ]; then
"$NCP" list-protocols "$nc" 2>/dev/null | grep -iE -- "$SYSTEM" 2>/dev/null | LC_ALL=C sort -u
else
# single thread (only if present in this revision)
"$NCP" list-protocols "$nc" 2>/dev/null | grep -xF -- "$THREAD"
fi
}
_scoped_text() { # netconfig → scoped NetConfig text
local nc="$1"
if [ "$WHOLE_SITE" = "1" ] && [ -z "$THREAD" ] && [ -z "$SYSTEM" ]; then
cat "$nc"
return 0
fi
local t
while IFS= read -r t; do
[ -z "$t" ] && continue
printf 'protocol %s {\n' "$t"
"$NCP" protocol-block "$nc" "$t" 2>/dev/null | sed '1d' # block already starts with the header; re-emit a stable header
done < <(_scope_threads "$nc")
}
# Summarise the change between two revisions' in-scope thread SETS + bodies.
# Emits a compact phrase: "+N added, -N removed, ~N modified (names…)".
# Pure: compares the thread name sets and per-thread block bodies.
_summary_between() { # nc_old nc_new
local a="$1" b="$2"
local ta tb
ta=$(mktemp); tb=$(mktemp)
_scope_threads "$a" > "$ta"
_scope_threads "$b" > "$tb"
local added removed common t
added=$(LC_ALL=C comm -13 "$ta" "$tb")
removed=$(LC_ALL=C comm -23 "$ta" "$tb")
common=$(LC_ALL=C comm -12 "$ta" "$tb")
rm -f "$ta" "$tb"
local n_add n_rem n_mod=0 mod_names=""
n_add=$(printf '%s\n' "$added" | grep -c . 2>/dev/null); [ -z "$n_add" ] && n_add=0
n_rem=$(printf '%s\n' "$removed" | grep -c . 2>/dev/null); [ -z "$n_rem" ] && n_rem=0
[ -z "$added" ] && n_add=0
[ -z "$removed" ] && n_rem=0
# modified = common thread whose block body differs
while IFS= read -r t; do
[ -z "$t" ] && continue
if ! diff -q <("$NCP" protocol-block "$a" "$t" 2>/dev/null) \
<("$NCP" protocol-block "$b" "$t" 2>/dev/null) >/dev/null 2>&1; then
n_mod=$((n_mod+1))
mod_names="${mod_names:+$mod_names }$t"
fi
done <<< "$common"
# detail names (capped so the row stays one block)
local add_names rem_names
add_names=$(printf '%s\n' "$added" | grep . | head -6 | tr '\n' ' ' | sed 's/ $//')
rem_names=$(printf '%s\n' "$removed" | grep . | head -6 | tr '\n' ' ' | sed 's/ $//')
local mn; mn=$(printf '%s' "$mod_names" | tr ' ' '\n' | grep . | head -6 | tr '\n' ' ' | sed 's/ $//')
local parts=""
[ "$n_add" -gt 0 ] && parts="${parts:+$parts, }+${n_add} added${add_names:+ ($add_names)}"
[ "$n_rem" -gt 0 ] && parts="${parts:+$parts, }-${n_rem} removed${rem_names:+ ($rem_names)}"
[ "$n_mod" -gt 0 ] && parts="${parts:+$parts, }~${n_mod} modified${mn:+ ($mn)}"
[ -z "$parts" ] && parts="no change in scope"
printf '%s' "$parts"
}
# ─────────────────────────────────────────────────────────────────────────────
# Scope label for the header (human-readable description of what we're tracking).
# ─────────────────────────────────────────────────────────────────────────────
_scope_label() {
if [ "$WHOLE_SITE" = "1" ] && [ -z "$THREAD" ] && [ -z "$SYSTEM" ]; then
printf 'whole site %s (entire NetConfig)' "$SITE_ARG"
elif [ -n "$SYSTEM" ]; then
printf 'system "%s" in site %s' "$SYSTEM" "$SITE_ARG"
else
printf 'thread %s in site %s' "$THREAD" "$SITE_ARG"
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# RENDER: timeline
# ─────────────────────────────────────────────────────────────────────────────
render_timeline() {
local n; n=$(grep -c . "$REVTBL" 2>/dev/null); [ -z "$n" ] && n=0
printf 'NetConfig revision history — %s\n' "$(_scope_label)"
printf '%s revision(s) found%s\n\n' "$n" "$( [ "$LIMIT" -gt 0 ] && printf ' (last %s)' "$LIMIT" )"
if [ "$n" = "0" ]; then
printf '(no revisions with a parseable prologue under %s)\n' "$REV_DIR" >&2
return 0
fi
# iterate ascending; for each revision (after the first) summarise vs the prior.
local prev_nc="" idx=0
local key who dh label nc
while IFS="$US" read -r key who dh label nc; do
[ -z "$nc" ] && continue
idx=$((idx+1))
printf 'revision %s\n' "$label"
printf 'date %s\n' "${dh:-(unknown)}"
printf 'who %s\n' "${who:-(unknown)}"
if [ -z "$prev_nc" ]; then
printf 'changed (baseline — earliest revision in range)\n'
else
printf 'changed %s\n' "$(_summary_between "$prev_nc" "$nc")"
fi
printf '\n'
prev_nc="$nc"
done < "$REVTBL"
}
# ─────────────────────────────────────────────────────────────────────────────
# RENDER: diff (unified diff of the scoped section between CONSECUTIVE revisions)
# ─────────────────────────────────────────────────────────────────────────────
render_diff() {
local n; n=$(grep -c . "$REVTBL" 2>/dev/null); [ -z "$n" ] && n=0
printf 'NetConfig revision diff — %s\n' "$(_scope_label)"
printf '%s revision(s) found%s\n\n' "$n" "$( [ "$LIMIT" -gt 0 ] && printf ' (last %s)' "$LIMIT" )"
if [ "$n" -lt 2 ]; then
printf '(need at least 2 revisions to diff; found %s under %s)\n' "$n" "$REV_DIR" >&2
return 0
fi
local prev_nc="" prev_lbl="" prev_dh=""
local key who dh label nc
while IFS="$US" read -r key who dh label nc; do
[ -z "$nc" ] && continue
if [ -n "$prev_nc" ]; then
printf '=== %s (%s) -> %s (%s, by %s) ===\n' \
"$prev_lbl" "${prev_dh:-?}" "$label" "${dh:-?}" "${who:-?}"
local a b
a=$(mktemp); b=$(mktemp)
_scoped_text "$prev_nc" > "$a"
_scoped_text "$nc" > "$b"
# unified diff of the scoped text; label the headers with the revision name.
diff -u --label "$prev_lbl" --label "$label" "$a" "$b" 2>/dev/null
local rc=$?
[ "$rc" = "0" ] && printf '(no change in scope)\n'
rm -f "$a" "$b"
printf '\n'
fi
prev_nc="$nc"; prev_lbl="$label"; prev_dh="$dh"
done < "$REVTBL"
}
# ─────────────────────────────────────────────────────────────────────────────
# Drive
# ─────────────────────────────────────────────────────────────────────────────
_discover_revisions
case "$FORMAT" in
timeline)
# Human-readable artifact → strip control bytes UNCONDITIONALLY.
{ render_timeline; } | _sanitize_ctl
exit "${PIPESTATUS[0]}"
;;
diff)
# Diff text may feed a downstream consumer → tty-gated strip (raw on a pipe).
{ render_diff; } | _sanitize_ctl_tty
exit "${PIPESTATUS[0]}"
;;
esac