diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63cdecf..a12c902 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -783,6 +783,47 @@ jobs: echo "==> config.yml survives upgrade and the reader separates unset / default / unknown." + - name: generate.sh and advance-state.sh run offline in a scaffold (#170) + run: | + set -uo pipefail + P=/tmp/smoke + export SPECFUSE_AUTHORING=/tmp/ci/bin/specfuse-authoring + fail() { echo "::error::$1" >&2; exit 1; } + test -x "$P/scripts/specfuse/generate.sh" || fail "generate.sh not shipped executable" + test -x "$P/scripts/specfuse/advance-state.sh" || fail "advance-state.sh not shipped executable" + + # The scaffold's project file declares no groups yet. + out=$("$P/scripts/specfuse/generate.sh" --list 2>&1) || fail "--list failed: $out" + out=$("$P/scripts/specfuse/generate.sh" all --dry-run 2>&1) && fail "an empty selection was accepted" + grep -q "No groups selected" <<<"$out" || fail "unexpected message: $out" + out=$("$P/scripts/specfuse/generate.sh" Nope 2>&1) && fail "an unknown group was accepted" + grep -q "Unknown group: 'Nope'" <<<"$out" || fail "unexpected message: $out" + + # One group writing into a sibling repo with uncommitted work: the + # dry run names the repo and flags it, and a real run refuses before + # touching the generator. + S=$(mktemp -d) || fail "mktemp failed" + cp -R "$P" "$S/specs" && git -C "$S/specs" init -q + git init -q "$S/Backend" && echo wip > "$S/Backend/wip.txt" + python3 - "$S/specs" <<'PY' + import json, pathlib, sys + p = next(pathlib.Path(sys.argv[1]).glob("*-project.json")) + c = json.loads(p.read_text()) + c["groups"] = [{"name": "Domain", "language": "C#", "basePackage": "Smoke.Domain", + "destination": "../Backend/", "artifacts": ["entity"], "cleanGenerated": True}] + p.write_text(json.dumps(c, indent=2)) + PY + out=$(cd "$S/specs" && scripts/specfuse/generate.sh --lang cs --dry-run 2>&1) || fail "dry run failed: $out" + grep -q "writes into Backend" <<<"$out" || fail "dry run did not name the destination repo: $out" + grep -q "HAS UNCOMMITTED CHANGES" <<<"$out" || fail "dry run did not flag the dirty repo: $out" + out=$(cd "$S/specs" && scripts/specfuse/generate.sh Domain 2>&1) && fail "generated into a dirty repo" + grep -q "have uncommitted changes" <<<"$out" || fail "unexpected message: $out" + + # advance-state: no destination repo is level with an origin, so it stops before `baseline`. + out=$(cd "$S/specs" && scripts/specfuse/advance-state.sh --no-fetch 2>&1) && fail "advance-state advanced without a passing repo" + grep -q "nothing to advance" <<<"$out" || fail "unexpected message: $out" + echo "==> generate.sh / advance-state.sh guards behave." + - name: Scaffolded project validates with its own scripts run: | set -euo pipefail diff --git a/templates/project-init/scripts/specfuse/README.md b/templates/project-init/scripts/specfuse/README.md index 1134842..89c5f33 100644 --- a/templates/project-init/scripts/specfuse/README.md +++ b/templates/project-init/scripts/specfuse/README.md @@ -100,6 +100,8 @@ yet exits 0. | `serve-docs.sh` | Redocly preview server for the OpenAPI docs (default port 8081). | | `serve-async-docs.sh` | Preview server for the AsyncAPI docs. | | `generate-scenario-docs.sh` | Bundle, then generate scenario + technical-reference docs. | +| `generate.sh` | Bundle once, then generate the project file's groups one at a time: by name, `--lang`, or `all`; `--list`, `--dry-run`. Refuses destination repos with uncommitted changes. | +| `advance-state.sh` | After a regen PR merges in a destination repo, advance that group's `stateDir` baseline from the merged code. | | `generate-scenario-index.sh` | Build the scenario index — called by the above. | | `build-prompt-index.sh` | Reverse index of `api/docs/implementation-prompts/`, for `/prepare-handoff`. | @@ -109,6 +111,35 @@ pinned in the kit's `generator.lock`. There is no jar to keep in this directory. (The script falls back to the deprecated flat `specfuse-authoring` command when the suite CLI is not installed, and `SPECFUSE_AUTHORING` overrides both.) +## Generating code into other repos + +`generate.sh` drives `specfuse authoring generate` for each selected group. A +group whose `destination` points outside this repo (`../Backend/`) writes into +that repo, and `cleanGenerated` deletes its generated tree first, so the script +lists every destination with its repo and branch, and stops if one has +uncommitted changes (`--allow-dirty` overrides). + +Language steps the generator does not run go in a project-owned hook, +`scripts/post-generate.sh` (outside `scripts/specfuse/`, so upgrades leave it +alone). When it exists and is executable, it runs after each group with +` `. For Dart: + +```bash +#!/bin/bash +set -euo pipefail +[ "$2" = "dart" ] || exit 0 +cd "$3/$4" +dart pub get +dart run build_runner build --delete-conflicting-outputs +``` + +A regen also rewrites the group's `stateDir` baseline (`.specfuse/state/.json`). +Don't commit that. Once the destination repo merges the regen, run +`advance-state.sh` from a clean spec repo. It wraps the generator's `baseline` +command, keeps the new baseline only for groups whose repo is clean, on its +default branch and level with origin, and restores the rest. Commit what it +leaves in a PR of its own. + ## External dependencies Not bundled — install what you use: diff --git a/templates/project-init/scripts/specfuse/advance-state.sh b/templates/project-init/scripts/specfuse/advance-state.sh new file mode 100644 index 0000000..91f5d29 --- /dev/null +++ b/templates/project-init/scripts/specfuse/advance-state.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# Advance the regen-over-regen baseline (/.json) to what the +# destination repos have actually merged. +# +# Why: each group's state file is what the next regen's CHANGELOG.md is diffed +# against, so it must describe the code that SHIPPED in the destination repo, not +# the working copy a local regen just produced. A baseline committed ahead of +# the destination's default branch adds phantom "Breaking" entries to every later +# changelog; a stale one makes the next changelog repeat old changes. Run this +# AFTER a regen PR merges in a destination repo, then open a spec-repo PR with +# only the changed state files. +# +# How: wraps the generator's `baseline` command, which re-captures the surface +# from the generated code already on disk and regenerates nothing. `baseline` +# rewrites every group's state file, so this script keeps a group's new +# baseline only when its destination repo is clean, on its default branch, and +# level with origin; every other group's state file is restored afterwards. +# +# Limit: `baseline` rewrites .json only. If .paths.json has +# drifted too, regenerate the group into the destination's default branch and +# confirm the output is byte-identical. +# +# Usage: +# ./scripts/specfuse/advance-state.sh # every destination repo that passes +# ./scripts/specfuse/advance-state.sh --repo Backend # only these repos (repeatable; dir name) +# ./scripts/specfuse/advance-state.sh --dry-run # report what would be captured, keep nothing +# ./scripts/specfuse/advance-state.sh --no-fetch # compare to the local origin ref, no fetch +# ./scripts/specfuse/advance-state.sh --branch main # default branch (default: origin/HEAD, else main) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +if [ -n "${SPECFUSE_AUTHORING:-}" ]; then + SPECFUSE_CLI=("${SPECFUSE_AUTHORING}") +elif command -v specfuse &> /dev/null; then + SPECFUSE_CLI=(specfuse authoring) +else + SPECFUSE_CLI=(specfuse-authoring) +fi +RUN_GENERATOR=("${SPECFUSE_CLI[@]}" generate) + +die() { echo "❌ $1" >&2; exit 1; } + +CONFIG="" +for candidate in "$ROOT_DIR"/*-project.json; do + if [ -f "$candidate" ]; then + CONFIG="$candidate" + break + fi +done +[ -n "$CONFIG" ] || die "Project file not found: expected -project.json in $ROOT_DIR" +command -v python3 &> /dev/null || die "python3 not found (needed to read the project file)." +command -v java &> /dev/null || die "Java not found. Install Java 21+." +command -v "${SPECFUSE_CLI[0]}" &> /dev/null || die "${SPECFUSE_CLI[*]} not found on PATH. Install the suite: pipx install specfuse" + +DRY_RUN=false +FETCH=true +BRANCH="" +SELECTED=() +while [ $# -gt 0 ]; do + case "$1" in + --dry-run|-n) DRY_RUN=true ;; + --no-fetch) FETCH=false ;; + --repo) shift; [ $# -gt 0 ] || die "--repo needs a value"; SELECTED+=("$1") ;; + --branch) shift; [ $# -gt 0 ] || die "--branch needs a value"; BRANCH="$1" ;; + -h|--help) sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "Unknown argument: $1 (see --help)" ;; + esac + shift +done + +cd "$ROOT_DIR" + +# stateDir resolves against the working directory, which is ROOT_DIR here. +STATE_DIR="$(python3 - "$CONFIG" <<'PY' +import json, sys +print(json.load(open(sys.argv[1], encoding="utf-8")).get("stateDir") or ".specfuse/state") +PY +)" +STATE_DIR="${STATE_DIR%/}"; STATE_DIR="${STATE_DIR#./}" +SELF_TOP="$(git rev-parse --show-toplevel 2>/dev/null)" || die "$ROOT_DIR is not a git repo." + +# Restoring groups uses `git checkout`, which would also discard a hand edit to +# a state file, so refuse to start on a dirty state dir (dry runs included). +if [ -d "$STATE_DIR" ] && [ -n "$(git status --porcelain -- "$STATE_DIR")" ]; then + die "$STATE_DIR has uncommitted changes. Commit or discard them first (git checkout -- $STATE_DIR && git clean -fd -- $STATE_DIR)." +fi + +# Plain parallel arrays, not `declare -A`: macOS /bin/bash is 3.2. +# GROUP_NAMES[i] is generated into the repo at GROUP_REPOS[i] ("" = this repo +# or no repo, which never advances). +GROUP_NAMES=(); GROUP_REPOS=() +while IFS=$'\t' read -r name dest; do + top="" + if [ -n "$dest" ] && [ -d "$dest" ]; then + top="$(git -C "$dest" rev-parse --show-toplevel 2>/dev/null || true)" + [ "$top" = "$SELF_TOP" ] && top="" + fi + if [ -z "$top" ]; then + echo "⏭️ $name: destination ${dest:-.} is not in a separate git repo — not advanced" + fi + GROUP_NAMES+=("$name"); GROUP_REPOS+=("$top") +done < <(python3 - "$CONFIG" <<'PY' +import json, sys +for g in json.load(open(sys.argv[1], encoding="utf-8")).get("groups", []): + print(f"{g.get('name') or ''}\t{g.get('destination') or ''}") +PY +) + +PASSING="" +is_passing() { [ -n "$1" ] && printf '%s\n' "$PASSING" | grep -qxF "$1"; } + +ok_count=0 +while IFS= read -r top; do + [ -n "$top" ] || continue + repo="$(basename "$top")" + if [ ${#SELECTED[@]} -gt 0 ] && [[ ! " ${SELECTED[*]} " == *" $repo "* ]]; then + echo "⏭️ $repo: not selected"; continue + fi + branch="$BRANCH" + if [ -z "$branch" ]; then + branch="$(git -C "$top" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null || true)" + branch="${branch#origin/}" + branch="${branch:-main}" + fi + if [ "$FETCH" = true ] && ! git -C "$top" fetch -q origin "$branch" 2>/dev/null; then + echo "⚠️ $repo: git fetch failed — comparing to the local origin/$branch, which may be stale" + fi + current="$(git -C "$top" branch --show-current)" + if [ "$current" != "$branch" ]; then + echo "⏭️ $repo: on '${current:-detached HEAD}', not $branch" + elif [ -n "$(git -C "$top" status --porcelain)" ]; then + echo "⏭️ $repo: has uncommitted changes (unmerged regen?)" + elif [ "$(git -C "$top" rev-parse HEAD)" != "$(git -C "$top" rev-parse "origin/$branch" 2>/dev/null)" ]; then + echo "⏭️ $repo: $branch is not level with origin/$branch" + else + echo "✅ $repo @ $(git -C "$top" rev-parse --short HEAD) ($branch)" + PASSING="$PASSING$top"$'\n' + ok_count=$((ok_count + 1)) + fi +done < <(printf '%s\n' ${GROUP_REPOS[@]+"${GROUP_REPOS[@]}"} | sort -u) + +[ "$ok_count" -gt 0 ] || die "No destination repo is clean and level with origin — nothing to advance." + +echo "" +args=(baseline) +[ "$DRY_RUN" = true ] && args+=(--dry-run) +"${RUN_GENERATOR[@]}" "${args[@]}" "$CONFIG" + +restore() { + local f="$1" + if git ls-files --error-unmatch "$f" &> /dev/null; then + git checkout -q -- "$f" + else + rm -f "$f" + fi +} + +if [ "$DRY_RUN" = true ]; then + # `baseline --dry-run` writes nothing on current generators; restoring + # anyway keeps an older project-pinned generator from leaving changes. + git checkout -q -- "$STATE_DIR" 2>/dev/null || true + git clean -q -f -- "$STATE_DIR" 2>/dev/null || true + echo "" + echo "Dry run: nothing kept. Only the ✅ repos' groups would advance." + exit 0 +fi + +# Put back every group whose repo did not pass, was not selected, or is this repo. +for i in "${!GROUP_NAMES[@]}"; do + is_passing "${GROUP_REPOS[$i]}" && continue + restore "$STATE_DIR/${GROUP_NAMES[$i]}.json" +done + +echo "" +if [ -z "$(git status --porcelain -- "$STATE_DIR")" ]; then + echo "State already matches the destination repos — nothing to commit." + exit 0 +fi +git status --short -- "$STATE_DIR" +echo "" +echo "Next: on a branch, commit only these state files, citing the destination" +echo "repo SHA(s) above and the regen PR(s), and open a PR. No spec changes in it." diff --git a/templates/project-init/scripts/specfuse/generate.sh b/templates/project-init/scripts/specfuse/generate.sh new file mode 100755 index 0000000..a3244ed --- /dev/null +++ b/templates/project-init/scripts/specfuse/generate.sh @@ -0,0 +1,244 @@ +#!/bin/bash +# SpecFuse Code Generation (Mac/Linux) +# +# Single entrypoint for generating the artifact groups declared in the project +# file (-project.json). The generator's `--group` takes ONE name, so this +# bundles the specs once, then runs one generator invocation per group. +# +# Usage: +# ./scripts/specfuse/generate.sh --list # groups, by language, with destinations +# ./scripts/specfuse/generate.sh [...] # one or more groups (case-insensitive) +# ./scripts/specfuse/generate.sh --lang # every group of a language (repeatable) +# ./scripts/specfuse/generate.sh all # every group (explicit opt-in) +# ./scripts/specfuse/generate.sh --dry-run # print what would run, generate nothing +# +# Other options: +# --skip-validation passed to the generator (it validates before generating by default) +# --allow-dirty generate into destination repos that have uncommitted changes +# --spec-version vN spec directory under api/specs/ (default: the newest) +# +# DESTRUCTIVE: a group whose `destination` is outside this repo (e.g. +# `../Backend/`) writes into another repo, and `cleanGenerated: true` deletes +# the group's generated tree before rewriting it. Before generating, this script +# refuses to touch a destination repo with uncommitted changes (override with +# --allow-dirty). Prefer naming groups over `all`. +# +# Post-generation hook: if the project has an executable `scripts/post-generate.sh` +# (project-owned, outside scripts/specfuse/), it runs once per generated group as +# scripts/post-generate.sh +# from the project root. Use it for language steps the generator does not run, +# e.g. Dart's `dart run build_runner build`. A non-zero exit stops the run. +# +# Markdown groups generate like any other here. `generate-scenario-docs.sh` also +# adds cross-links and the scenario index on top, so prefer it for docs. +# +# The generator is the version `specfuse authoring generate` resolves: the +# project's `.specfuse/generator.lock` when present, otherwise the kit's pin. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +if [ -n "${SPECFUSE_AUTHORING:-}" ]; then + SPECFUSE_CLI=("${SPECFUSE_AUTHORING}") +elif command -v specfuse &> /dev/null; then + SPECFUSE_CLI=(specfuse authoring) +else + SPECFUSE_CLI=(specfuse-authoring) +fi +# `specfuse authoring generate` runs the pinned generator with whatever follows +# it, so the generator's own verb is passed explicitly. Dropping it runs the +# generator with no command. +RUN_GENERATOR=("${SPECFUSE_CLI[@]}" generate) + +die() { echo "❌ $1" >&2; exit 1; } + +CONFIG="" +for candidate in "$ROOT_DIR"/*-project.json; do + if [ -f "$candidate" ]; then + CONFIG="$candidate" + break + fi +done +[ -n "$CONFIG" ] || die "Project file not found: expected -project.json in $ROOT_DIR" +command -v python3 &> /dev/null || die "python3 not found (needed to read the project file)." + +# groups_tsv — one line per group: namelanguagedestinationbasePackage. +# python3 rather than jq: no extra dependency, and no CRLF output on Windows. +groups_tsv() { + python3 - "$CONFIG" <<'PY' +import json, sys +for g in json.load(open(sys.argv[1], encoding="utf-8")).get("groups", []): + print("\t".join(str(g.get(k) or "") for k in ("name", "language", "destination", "basePackage"))) +PY +} +config_value() { + python3 - "$CONFIG" "$1" <<'PY' +import json, sys +print(json.load(open(sys.argv[1], encoding="utf-8")).get(sys.argv[2]) or "") +PY +} + +lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } + +# Language aliases, matched case-insensitively against each group's `language`. +canon_lang() { + case "$(lower "$1")" in + c#|cs|csharp|dotnet) echo "c#" ;; + py|python) echo "python" ;; + dart|flutter) echo "dart" ;; + ts|typescript) echo "typescript" ;; + md|markdown|docs) echo "markdown" ;; + *) lower "$1" ;; + esac +} + +usage() { sed -n '4,15p' "$0" | sed 's/^# \{0,1\}//'; } + +TARGETS=() +GEN_ALL=false +DRY_RUN=false +ALLOW_DIRTY=false +SPEC_VERSION="latest" +PASSTHROUGH=() + +add_target() { + local t + for t in ${TARGETS[@]+"${TARGETS[@]}"}; do + [ "$t" = "$1" ] && return 0 + done + TARGETS+=("$1") +} + +[ $# -ge 1 ] || { usage; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --list) + echo "Groups in $(basename "$CONFIG"):" + groups_tsv | sort -t$'\t' -k2,2 -s | while IFS=$'\t' read -r name lang dest _; do + printf ' [%s] %s -> %s\n' "$lang" "$name" "${dest:-.}" + done + exit 0 ;; + all) GEN_ALL=true ;; + --lang|-l) + shift; [ $# -ge 1 ] || die "--lang needs a value" + want="$(canon_lang "$1")" + found=0 + while IFS=$'\t' read -r name lang _ _; do + if [ "$(canon_lang "$lang")" = "$want" ]; then + add_target "$name"; found=$((found + 1)) + fi + done < <(groups_tsv) + [ "$found" -gt 0 ] || die "No groups for language '$1'. See --list." + ;; + --skip-validation) PASSTHROUGH+=("$1") ;; + --allow-dirty) ALLOW_DIRTY=true ;; + --dry-run|-n) DRY_RUN=true ;; + --spec-version) shift; [ $# -ge 1 ] || die "--spec-version needs a value"; SPEC_VERSION="$1" ;; + -*) die "Unknown option: '$1' (see --help)" ;; + *) + match="" + while IFS=$'\t' read -r name _ _ _; do + if [ "$(lower "$name")" = "$(lower "$1")" ]; then match="$name"; break; fi + done < <(groups_tsv) + [ -n "$match" ] || die "Unknown group: '$1'. See --list." + add_target "$match" ;; + esac + shift +done + +if [ "$GEN_ALL" = true ]; then + TARGETS=() + while IFS=$'\t' read -r name _ _ _; do add_target "$name"; done < <(groups_tsv) +fi +[ ${#TARGETS[@]} -gt 0 ] || die "No groups selected. See --list." + +# group_field — 2 language, 3 destination, 4 basePackage. +group_field() { + groups_tsv | awk -F'\t' -v n="$1" -v c="$2" '$1 == n { print $c; exit }' +} + +# Destinations that land in another git repo, checked before anything is written. +cd "$ROOT_DIR" +SELF_TOP="$(git rev-parse --show-toplevel 2>/dev/null || echo "$ROOT_DIR")" +DIRTY=() +echo "Selected ${#TARGETS[@]} group(s) from $(basename "$CONFIG"):" +for g in "${TARGETS[@]}"; do + dest="$(group_field "$g" 3)" + note="" + if [ -n "$dest" ] && [ -d "$dest" ]; then + top="$(git -C "$dest" rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$top" ] && [ "$top" != "$SELF_TOP" ]; then + note=" ⚠️ writes into $(basename "$top") ($(git -C "$top" branch --show-current 2>/dev/null || echo detached))" + if [ -n "$(git -C "$top" status --porcelain 2>/dev/null)" ]; then + note="$note — HAS UNCOMMITTED CHANGES" + DIRTY+=("$(basename "$top")") + fi + fi + elif [ -n "$dest" ]; then + note=" (destination $dest does not exist yet)" + fi + echo " - $g [$(group_field "$g" 2)] -> ${dest:-.}$note" +done + +if [ "$DRY_RUN" = true ]; then + echo "" + echo "Dry run: nothing generated." + exit 0 +fi + +if [ ${#DIRTY[@]} -gt 0 ] && [ "$ALLOW_DIRTY" = false ]; then + die "Destination repo(s) have uncommitted changes: $(printf '%s\n' "${DIRTY[@]}" | sort -u | tr '\n' ' ') + A regen with cleanGenerated deletes and rewrites files there. Commit or stash first, + or pass --allow-dirty." +fi + +command -v java &> /dev/null || die "Java not found. Install Java 21+." +command -v "${SPECFUSE_CLI[0]}" &> /dev/null || die "${SPECFUSE_CLI[*]} not found on PATH. Install the suite: pipx install specfuse" + +if [ "$SPEC_VERSION" = "latest" ]; then + SPEC_VERSION=$(ls -d "$ROOT_DIR/api/specs/v"* 2>/dev/null | sort -V | tail -n 1 | xargs basename 2>/dev/null || true) +fi +SOURCE_ROOT="$ROOT_DIR/api/specs/$SPEC_VERSION" +[ -n "$SPEC_VERSION" ] && [ -d "$SOURCE_ROOT" ] || die "Spec directory not found: $SOURCE_ROOT" + +echo "" +echo "📦 Bundling specs ($SPEC_VERSION)..." +OPENAPI_BUNDLE="$(config_value openApiSpecifications)" +"$SCRIPT_DIR/bundle-spec.sh" "$SOURCE_ROOT/openapi.yaml" "${OPENAPI_BUNDLE:-output/openapi-bundled.yaml}" > /dev/null +ASYNC_BUNDLE="$(config_value asyncSpecifications)" +if [ -n "$ASYNC_BUNDLE" ] && [ -f "$SOURCE_ROOT/asyncapi.yaml" ]; then + "$SCRIPT_DIR/bundle-async-spec.sh" "$SOURCE_ROOT/asyncapi.yaml" "$ASYNC_BUNDLE" > /dev/null +fi +echo " ✅ Bundled" + +HOOK="$ROOT_DIR/scripts/post-generate.sh" +n=0 +for g in "${TARGETS[@]}"; do + n=$((n + 1)) + echo "" + echo "── [$n/${#TARGETS[@]}] $g ──────────────────────────────" + "${RUN_GENERATOR[@]}" generate --progress --group "$g" ${PASSTHROUGH[@]+"${PASSTHROUGH[@]}"} "$CONFIG" + if [ -x "$HOOK" ]; then + echo "▶ post-generate: $g" + "$HOOK" "$g" "$(group_field "$g" 2)" "$(group_field "$g" 3)" "$(group_field "$g" 4)" + fi +done + +echo "" +echo "✅ Generated ${#TARGETS[@]} group(s)." +others=() +for g in "${TARGETS[@]}"; do + dest="$(group_field "$g" 3)" + top="$(git -C "${dest:-.}" rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$top" ] && [ "$top" != "$SELF_TOP" ]; then others+=("$(basename "$top")"); fi +done +if [ ${#others[@]} -gt 0 ]; then + echo "Next: review and PR the regen in $(printf '%s\n' "${others[@]}" | sort -u | tr '\n' ' ')" + echo " After those PRs merge, run ./scripts/specfuse/advance-state.sh." +fi +echo "Don't commit the stateDir changes this regen made (default .specfuse/state/):" +echo "only advance-state.sh, run against merged destination repos, moves the baseline."