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>
471 lines
22 KiB
Bash
Executable File
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
|