#!/usr/bin/env bash # refresh-fork-upstream-map.sh — Update column 3 (the upstream release tag) # of local/fork-upstream-map.toml in place, preserving every other byte. # # Policy (release-bump-pipeline.md, Deliverable C): # - APPEND-ONLY over column 3 (the upstream release tag). # - Preserve columns 1 (fork), 2 (url), 4 (mode) and ALL comments / header # text byte-for-byte. The curated header comments and the `base` row are # never dropped. # - SKIP ls-remote for rows with mode `diverged` — their rebase is operator # manual (Phase 2.4+); the map tag documents the CURRENT base, not the # latest upstream. # - SKIP ls-remote for rows whose tag is `main` (the `base` row, which has # no upstream releases). # - For forks present in local/sources/*/ with an upstream remote but # MISSING from the map: APPEND a new row # ` # TODO: classify mode` # at the end of the file. # # Usage: # ./local/scripts/refresh-fork-upstream-map.sh # Update col 3 in place # ./local/scripts/refresh-fork-upstream-map.sh --check # Read-only report, exit 1 on drift # ./local/scripts/refresh-fork-upstream-map.sh --no-fetch # Use map's current tags (no network) # ./local/scripts/refresh-fork-upstream-map.sh --help # This header # # Acceptance: # - `git diff local/fork-upstream-map.toml` shows only column-3 token changes # (if any) plus any newly appended rows. # - `grep -c '^base\b' local/fork-upstream-map.toml` = 1. # - `grep -c diverged local/fork-upstream-map.toml` unchanged vs before. set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" MAP="$ROOT/local/fork-upstream-map.toml" CHECK_ONLY=0 NO_FETCH=0 for arg in "$@"; do case "$arg" in --check) CHECK_ONLY=1 ;; --no-fetch) NO_FETCH=1 ;; -h|--help) sed -n '2,35p' "$0"; exit 0 ;; *) echo "Unknown option: $arg" >&2; exit 1 ;; esac done if [[ ! -f "$MAP" ]]; then echo "ERROR: map file not found: $MAP" >&2 exit 1 fi # Anchored semver regex (release-bump-pipeline.md design constraint #2). SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+$' # ---- latest_tag_for_url [fork] [base] -------------------------------- # Resolve the newest GENUINE upgrade tag for a remote via `git ls-remote`. # Output: "|" — tag empty when nothing qualifies. # Two filters: (a) tags already reachable from the fork's HEAD are history, # not newer releases (relibc's 0.6.0/2020 outranks its newer 0.2.x API line # lexically); (b) tags not version-greater than the current base are not # newer either (the base tag itself is usually an ancestor of HEAD). latest_tag_for_url() { local url="$1" fork="${2:-}" base="${3:-}" local tag forkdir="" raw=0 [[ -n "$fork" && -d "$ROOT/local/sources/$fork/.git" ]] \ && forkdir="$ROOT/local/sources/$fork" while IFS= read -r tag; do [[ -z "$tag" ]] && continue raw=$((raw + 1)) if [[ -n "$forkdir" ]] \ && git -C "$forkdir" rev-parse --verify --quiet "refs/tags/$tag" >/dev/null 2>&1 \ && git -C "$forkdir" merge-base --is-ancestor "refs/tags/$tag" HEAD 2>/dev/null; then continue fi if [[ -n "$base" ]] && ! version_greater "$tag" "$base"; then continue fi echo "${tag}|${raw}" return 0 done < <(git ls-remote --tags --sort=-v:refname "$url" 2>/dev/null \ | awk '{print $2}' \ | sed 's|refs/tags/||' \ | sed 's|\^{}$||' \ | grep -E "$SEMVER_RE" || true) echo "|${raw}" return 0 } # version_greater → true iff a is version-greater than b (sort -V). version_greater() { [[ "$1" != "$2" ]] \ && [[ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | tail -1)" == "$1" ]] } # ---- parse existing map into associative arrays ---------------------------- # Sets MAP_FORK_URL[fork]=url, MAP_FORK_TAG[fork]=tag, MAP_FORK_MODE[fork]=mode. declare -A MAP_FORK_URL=() declare -A MAP_FORK_TAG=() declare -A MAP_FORK_MODE=() # Match a data row: first non-whitespace, non-# token = fork; followed by # whitespace-separated url, tag, optional mode + trailing comment. # We use awk to emit TAB-separated columns; bash reads them in. while IFS=$'\t' read -r fork url tag mode; do [[ -z "$fork" ]] && continue MAP_FORK_URL["$fork"]="$url" MAP_FORK_TAG["$fork"]="$tag" MAP_FORK_MODE["$fork"]="$mode" done < <(awk ' /^[^#[:space:]]/ { fork = $1 url = $2 tag = $3 mode = $4 sub(/[ \t]*#.*/, "", mode) print fork "\t" url "\t" tag "\t" mode } ' "$MAP") # ---- collect on-disk forks with upstream remote ---------------------------- declare -A DISK_FORK_URL=() for d in "$ROOT"/local/sources/*/; do [[ -d "$d/.git" ]] || continue name=$(basename "$d") url=$(git -C "$d" remote get-url upstream 2>/dev/null || true) [[ -n "$url" ]] && DISK_FORK_URL["$name"]="$url" done # ---- plan column-3 updates + appends --------------------------------------- declare -A NEW_TAG_FOR=() declare -a APPEND_ROWS=() DRIFT_COUNT=0 UPDATE_COUNT=0 echo "=== refresh-fork-upstream-map ===" if [[ "$NO_FETCH" -eq 1 ]]; then echo "(--no-fetch: skipping git ls-remote; using current map tags)" fi echo "" # Iterate in map-order (preserve stable output for --check reports). MAP_ORDER=() while IFS= read -r line; do if [[ "$line" =~ ^[^#[:space:]] ]]; then fork=$(awk '{print $1}' <<<"$line") MAP_ORDER+=("$fork") fi done < <(awk 1 "$MAP") for fork in "${MAP_ORDER[@]}"; do url="${MAP_FORK_URL[$fork]:-}" current="${MAP_FORK_TAG[$fork]:-}" mode="${MAP_FORK_MODE[$fork]:-}" # Skip the base row (tag=main). if [[ "$current" == "main" ]]; then echo "fork=$fork mode=$mode current=$current latest=(skip) action=skip-main" continue fi # Skip diverged forks: tag documents CURRENT base. if [[ "$mode" == "diverged" ]]; then echo "fork=$fork mode=diverged current=$current latest=(skip) action=skip-diverged" continue fi latest="" if [[ "$NO_FETCH" -eq 0 ]]; then result=$(latest_tag_for_url "$url" "$fork" "$current") latest="${result%%|*}" raw="${result##*|}" # Tags existed upstream but none qualified as newer → already current. [[ -z "$latest" && "$raw" -gt 0 ]] && latest="$current" fi if [[ -z "$latest" ]]; then echo "fork=$fork mode=$mode current=$current latest=(unknown) action=skip-no-tag" continue fi if [[ "$latest" != "$current" ]]; then NEW_TAG_FOR["$fork"]="$latest" if [[ $CHECK_ONLY -eq 1 ]]; then echo "fork=$fork mode=$mode current=$current latest=$latest action=DRIFT" DRIFT_COUNT=$((DRIFT_COUNT + 1)) else echo "fork=$fork mode=$mode current=$current latest=$latest action=UPDATE" UPDATE_COUNT=$((UPDATE_COUNT + 1)) fi else echo "fork=$fork mode=$mode current=$current latest=$latest action=ok" fi done # Forks on disk but missing from the map → append. # Iterate in sorted order for stable output. for fork in $(printf '%s\n' "${!DISK_FORK_URL[@]}" | sort); do if [[ -z "${MAP_FORK_URL[$fork]:-}" ]]; then url="${DISK_FORK_URL[$fork]}" latest="" if [[ "$NO_FETCH" -eq 0 ]]; then result=$(latest_tag_for_url "$url" "$fork") latest="${result%%|*}" fi latest="${latest:-unknown}" row="$fork $url $latest # TODO: classify mode" if [[ $CHECK_ONLY -eq 1 ]]; then echo "fork=$fork mode=(missing) current=(none) latest=$latest action=DRIFT-MISSING" DRIFT_COUNT=$((DRIFT_COUNT + 1)) else echo "fork=$fork mode=(missing) current=(none) latest=$latest action=APPEND" APPEND_ROWS+=("$row") fi fi done # ---- --check exit ---------------------------------------------------------- if [[ $CHECK_ONLY -eq 1 ]]; then echo "" echo "Summary: drift=$DRIFT_COUNT" if [[ $DRIFT_COUNT -gt 0 ]]; then echo "FAIL: $DRIFT_COUNT drift(s) detected" >&2 exit 1 fi echo "OK: no drift" exit 0 fi # ---- apply column-3 rewrites in place (byte-exact awk) --------------------- if [[ ${#NEW_TAG_FOR[@]} -eq 0 && ${#APPEND_ROWS[@]} -eq 0 ]]; then echo "" echo "No changes needed ($MAP already current)" exit 0 fi TMP="${MAP}.tmp.$$" APPEND_FILE="" trap 'rm -f "$TMP" "$APPEND_FILE"' EXIT # Sidecar file with append rows (avoids awk quoting issues). if [[ ${#APPEND_ROWS[@]} -gt 0 ]]; then APPEND_FILE="${TMP}.append" : > "$APPEND_FILE" for row in "${APPEND_ROWS[@]}"; do printf '%s\n' "$row" >> "$APPEND_FILE" done fi # Two-pass awk: # pass 1 (control): UPDATES[fork] = newtag # pass 2 (map): rewrite column 3 in matching rows; everything else # is printed verbatim. Append sidecar rows at END. # # The column-3 rewrite uses match() to locate the prefix # `^forkurl` and the next non-whitespace run (the tag), so all # leading whitespace, the URL, and everything after the tag (mode, # comments, EOL) are preserved byte-for-byte. # # A sentinel record guarantees the control stream always has at least one # line. Without it, when CONTROL is empty (only appends, no col-3 # rewrites), awk's `NR == FNR` test would misidentify every map line as a # control line and corrupt the output. CONTROL=$'#__rbum_sentinel__\t0.0.0\n' for fork in "${!NEW_TAG_FOR[@]}"; do CONTROL+="${fork}"$'\t'"${NEW_TAG_FOR[$fork]}"$'\n' done awk -v APPEND_FILE="$APPEND_FILE" ' NR == FNR { UPDATES[$1] = $2 next } { if ($0 ~ /^[^#[:space:]]/) { fork = $1 if (fork in UPDATES) { prefix_re = fork "[ \t]+[^ \t]+[ \t]+" if (match($0, prefix_re)) { prefix = substr($0, 1, RLENGTH) rest = substr($0, RLENGTH + 1) if (match(rest, /^[^ \t]+/)) { suffix = substr(rest, RLENGTH + 1) $0 = prefix UPDATES[fork] suffix } } } } print } END { if (APPEND_FILE != "") { while ((getline line < APPEND_FILE) > 0) print line close(APPEND_FILE) } } ' <(printf '%s' "$CONTROL") "$MAP" > "$TMP" mv "$TMP" "$MAP" echo "" echo "Updated $MAP ($UPDATE_COUNT col-3 rewrite(s), ${#APPEND_ROWS[@]} append(s))"