#!/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//revisions/NetConfig/ # where 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: # date: # 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//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 in $HCISITE # nc-revisions.sh . a thread in a specific site # nc-revisions.sh / v1 node form (nc-paths output feeds in) # nc-revisions.sh --system [--site S] a multi-thread system # (case-insensitive name match) # nc-revisions.sh --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 only revisions on/after . 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: # bare thread (site from --site / $HCISITE) # . thread.site (cross-site) — split on the LAST dot # / 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 | nc-revisions.sh --system [--site S] | nc-revisions.sh --site " fi [ -n "$SITE_ARG" ] || die "no site resolvable (set \$HCISITE, pass ., or --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: SORTKEYWHOHUMANDATE (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 → 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