From 3967ea978778ae041ecb83d3285676410524ae93 Mon Sep 17 00:00:00 2001 From: "Carlos D. Escobar-Valbuena" Date: Fri, 29 May 2026 16:13:29 -0500 Subject: [PATCH] feat(0.23.0): gitignore-aware + public-repo-aware + non-destructive bootstrap (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the safety gaps found dogfooding v0.22.0 on existing repos (stimulus, broomva.tech). Bootstrap was built for fresh workspaces; on repos with their own hooks/CI/gitignore it clobbered a tracked pre-commit and lacked gitignore awareness. Dep-Chain (P14): - upstream: install-l3-stability.sh pre-commit logic; bootstrap.sh Phase 2; arcs.v1 committable substrate; gh CLI (graceful-optional). - downstream: onboard.sh + repair.sh (call the same installer — inherit the fix); every workspace that runs `bstack bootstrap` on an existing repo. Fixed: - install-l3-stability.sh: never clobber a git-TRACKED .githooks/pre-commit (skip+warn; --force still moves aside). Untracked hooks still preserved as .local. Added: - bootstrap.sh Phase 2.6: gitignore reconciliation (auto-ignore .control/audit/*.jsonl; warn when committable substrate is ignored) + public-repo advisory (gh visibility). - tests/gitignore-aware-bootstrap.test.sh (5/5). Verified: new test 5/5; canary 14/14; shellcheck clean. VERSION 0.22.0→0.23.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 24 ++++++ VERSION | 2 +- scripts/bootstrap.sh | 56 ++++++++++++++ scripts/install-l3-stability.sh | 43 +++++++---- tests/gitignore-aware-bootstrap.test.sh | 99 +++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 15 deletions(-) create mode 100644 tests/gitignore-aware-bootstrap.test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 45c8ea0..694200d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.23.0 — 2026-05-29 + +### fix+feat: gitignore-aware, public-repo-aware, non-destructive bootstrap (issue #67) + +Found dogfooding v0.22.0 on existing real repos (stimulus, broomva.tech): bootstrap is built for *fresh* workspaces and did two unsafe things + lacked one needed capability on repos with their own hooks/CI/gitignore. + +### Fixed + +- **`install-l3-stability.sh` no longer clobbers a TRACKED `.githooks/pre-commit`.** On a repo with a committed pre-commit (e.g. broomva.tech's multimedia-asset validator), bootstrap overwrote it with the bstack L3 rate-gate hook — moving the original to `.pre-commit.local` — **even when `core.hooksPath` ≠ `.githooks`**, so the bstack hook never even fired. Now: if the hook is git-tracked, **skip + warn** (the committed hook is authoritative; `--force` still allows the move-aside). An *untracked* hook is still preserved as `.pre-commit.local` as before. + +### Added + +- **`bootstrap.sh` Phase 2.6 — gitignore reconciliation + public-repo advisory.** Reconciles `.gitignore` against the committable-vs-machine-local manifest: + - machine-local telemetry (`.control/audit/*.jsonl`) → auto-added to `.gitignore` if missing. + - committable substrate (`.control/arcs.yaml`, `rcs-parameters.toml`, `policy.yaml`) → **warns** if gitignored (a coverage gap — the loop won't survive a fresh clone), never auto-un-ignores (publishing a maybe-private file is the human's call). + - **public-repo advisory** (via `gh repo view --json visibility`, graceful if absent): on a PUBLIC remote, surfaces that scaffolded governance is committable-and-public so the operator reviews for secrets before pushing. +- `tests/gitignore-aware-bootstrap.test.sh` — 5 assertions covering both fixes (tracked-hook preserved, untracked-hook sidecar still works, audit-glob added, committable-ignored warning). 5/5. + +### Notes + +- Additive + non-blocking: Phase 2.6 skips gracefully on a non-git workspace; canary 14/14 unaffected. +- The manual handling on stimulus (#1811) + broomva.tech (broomva.tech#211) is the spec this automates. +- `VERSION` 0.22.0 → 0.23.0. + ## 0.22.0 — 2026-05-28 ### feat: wire the RCS control loop on `bstack bootstrap` (close the split-brain) diff --git a/VERSION b/VERSION index 2157409..ca222b7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.0 +0.23.0 diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index fc4c809..31cb3f2 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -154,6 +154,62 @@ scaffold_governance_file ".control/arcs.yaml" "arcs.yaml.template" echo " scaffolded: $scaffolded | preserved: $preserved" +# ─── Phase 2.6: gitignore reconciliation + public-repo advisory ──────────── +# The control loop splits into two file classes (the committable-vs-machine-local +# manifest). This phase reconciles .gitignore against it: +# - machine-local (paths/telemetry) → MUST be ignored; add if missing. +# - committable (team-wide, no secrets) → must NOT be ignored; WARN (don't +# auto-un-ignore — un-ignoring a deliberately-private file is the human's call). +# Plus a public-repo advisory: on a public remote, committable governance becomes +# public — surface that, don't decide it silently. Never blocks. +echo "" +echo "=== bstack gitignore reconciliation ===" +GITIGNORE="$WORKSPACE_DIR/.gitignore" +# Machine-local globs that should always be ignored (absolute paths / runtime telemetry). +LOCAL_GLOBS=(".control/audit/*.jsonl") +# Committable substrate the loop needs — warn if a repo ignores these. +COMMITTABLE_FILES=(".control/arcs.yaml" ".control/rcs-parameters.toml" ".control/policy.yaml") + +if command -v git >/dev/null 2>&1 && git -C "$WORKSPACE_DIR" rev-parse --git-dir >/dev/null 2>&1; then + # Ensure machine-local globs are ignored (append the missing ones). + _added_ignores=() + for glob in "${LOCAL_GLOBS[@]}"; do + if ! git -C "$WORKSPACE_DIR" check-ignore -q "${glob/\*/x}" 2>/dev/null; then + _added_ignores+=("$glob") + fi + done + if [ ${#_added_ignores[@]} -gt 0 ]; then + { + echo "" + echo "# bstack RCS control-loop machine-local telemetry (not committed)" + for g in "${_added_ignores[@]}"; do echo "$g"; done + } >> "$GITIGNORE" + echo " [ignore] added ${#_added_ignores[@]} machine-local glob(s) to .gitignore: ${_added_ignores[*]}" + else + echo " [ok] machine-local telemetry already ignored" + fi + + # Warn if committable substrate is ignored (coverage gap — the loop needs these committed). + for f in "${COMMITTABLE_FILES[@]}"; do + if git -C "$WORKSPACE_DIR" check-ignore -q "$f" 2>/dev/null; then + echo " [warn] $f is gitignored but the loop needs it committed." + echo " → un-ignore it (after confirming it carries no secrets) so the control loop survives a fresh clone." + fi + done + + # Public-repo advisory (graceful if gh missing or not a GitHub remote). + if command -v gh >/dev/null 2>&1; then + _vis=$(gh repo view --json visibility --jq .visibility 2>/dev/null || true) + if [ "$_vis" = "PUBLIC" ]; then + echo " [advisory] PUBLIC repo — scaffolded governance (CLAUDE.md/AGENTS.md/METALAYER.md/.control/*)" + echo " is committable and will be PUBLIC. Review for secrets before pushing; keep" + echo " machine-local files (.claude/settings.json, .control/audit/*.jsonl) ignored." + fi + fi +else + echo " [skip] not a git repo — gitignore reconciliation skipped" +fi + # ─── Phase 3: wire missing hooks into .claude/settings.json ──────────────── # Idempotent: never overwrites existing hook entries. Only adds missing ones. echo "" diff --git a/scripts/install-l3-stability.sh b/scripts/install-l3-stability.sh index 866c92b..52ffe51 100755 --- a/scripts/install-l3-stability.sh +++ b/scripts/install-l3-stability.sh @@ -83,22 +83,37 @@ do_install "$BSTACK_REPO/assets/templates/rcs-parameters.toml.template" \ # ── 2. Deploy git pre-commit hook ─────────────────────────────────────────── echo "2. Git pre-commit hook (.githooks/pre-commit)" -# If a pre-commit hook exists and is NOT ours, preserve it as .local sidecar PRE_COMMIT="$WORKSPACE/.githooks/pre-commit" -if [ -f "$PRE_COMMIT" ] && [ "$FORCE" = "0" ]; then - if grep -q "L3 rate gate" "$PRE_COMMIT" 2>/dev/null; then - echo " [skip] .githooks/pre-commit (already bstack L3 hook)" - SKIPPED=$((SKIPPED + 1)) - else - # Preserve existing as .local - echo " [info] existing .githooks/pre-commit found — preserving as .pre-commit.local" - if [ "$DRY_RUN" = "0" ]; then - mv "$PRE_COMMIT" "$WORKSPACE/.githooks/pre-commit.local" - chmod +x "$WORKSPACE/.githooks/pre-commit.local" - fi - do_install "$BSTACK_REPO/assets/templates/githook-pre-commit-l3-rate.sh.template" \ - "$PRE_COMMIT" "755" +if [ -f "$PRE_COMMIT" ] && grep -q "L3 rate gate" "$PRE_COMMIT" 2>/dev/null; then + # Already our hook — idempotent no-op (even under --force; reinstalling + # identical content is pointless). + echo " [skip] .githooks/pre-commit (already bstack L3 hook)" + SKIPPED=$((SKIPPED + 1)) +elif [ -f "$PRE_COMMIT" ] \ + && (cd "$WORKSPACE" && git ls-files --error-unmatch .githooks/pre-commit >/dev/null 2>&1) \ + && [ "$FORCE" = "0" ]; then + # TRACKED hook + no --force → NEVER clobber. The committed pre-commit is + # authoritative; overwriting it destroys a tracked file. Skip + warn. + # (Bug found dogfooding on a repo with a tracked .githooks/pre-commit: the + # repo's own hook got replaced even though core.hooksPath ≠ .githooks, so + # the L3 hook never fired.) --force routes to the preserve-then-install + # branch below, which DOES create the .pre-commit.local sidecar. + echo " [skip] .githooks/pre-commit is git-tracked — preserving the repo's committed hook" + echo " → to add the L3 rate gate: chain it into the existing hook by hand," + echo " or re-run with --force (which preserves the current hook as .pre-commit.local)." + SKIPPED=$((SKIPPED + 1)) +elif [ -f "$PRE_COMMIT" ]; then + # An existing hook we are about to replace: either an UNTRACKED local hook, + # or a tracked hook under --force. Preserve it as .pre-commit.local first, + # then install ours — so the sidecar recovery path the warning promises + # actually exists in both cases. + echo " [info] existing .githooks/pre-commit found — preserving as .pre-commit.local" + if [ "$DRY_RUN" = "0" ]; then + mv "$PRE_COMMIT" "$WORKSPACE/.githooks/pre-commit.local" + chmod +x "$WORKSPACE/.githooks/pre-commit.local" fi + do_install "$BSTACK_REPO/assets/templates/githook-pre-commit-l3-rate.sh.template" \ + "$PRE_COMMIT" "755" else do_install "$BSTACK_REPO/assets/templates/githook-pre-commit-l3-rate.sh.template" \ "$PRE_COMMIT" "755" diff --git a/tests/gitignore-aware-bootstrap.test.sh b/tests/gitignore-aware-bootstrap.test.sh new file mode 100644 index 0000000..e42c13e --- /dev/null +++ b/tests/gitignore-aware-bootstrap.test.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# gitignore-aware-bootstrap — covers the v0.23.0 safety fixes (issue #67): +# 1. install-l3-stability.sh never clobbers a TRACKED .githooks/pre-commit +# 2. install-l3-stability.sh still preserves an UNTRACKED pre-commit as .local +# 3. bootstrap.sh adds machine-local audit-log glob to .gitignore (real git repo) +# 4. bootstrap.sh warns when committable substrate is gitignored (coverage gap) + +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PASS=0; FAIL=0; FAILED=() +pass() { echo " [ok] $1"; PASS=$((PASS + 1)); } +fail() { echo " [FAIL] $1"; FAIL=$((FAIL + 1)); FAILED+=("$1"); } + +echo "── gitignore-aware-bootstrap ──────────────────────────" + +# ── 1. TRACKED pre-commit is preserved (not clobbered) ────────────────────── +TW=$(mktemp -d) +( + cd "$TW" && git init -q + mkdir -p .githooks + printf '#!/bin/bash\n# repo multimedia validator\n' > .githooks/pre-commit + chmod +x .githooks/pre-commit + git add .githooks/pre-commit && git -c user.email=t@t -c user.name=t commit -q -m "hook" +) +BROOMVA_WORKSPACE="$TW" bash "$REPO/scripts/install-l3-stability.sh" >/dev/null 2>&1 +if grep -q "multimedia validator" "$TW/.githooks/pre-commit"; then + pass "tracked .githooks/pre-commit preserved (not clobbered)" +else + fail "tracked .githooks/pre-commit was clobbered" +fi +if [ ! -f "$TW/.githooks/pre-commit.local" ]; then + pass "no .pre-commit.local created for tracked hook (default)" +else + fail ".pre-commit.local was created for a tracked hook (default)" +fi + +# ── 1b. --force on a TRACKED hook DOES preserve it as .pre-commit.local ────── +BROOMVA_WORKSPACE="$TW" bash "$REPO/scripts/install-l3-stability.sh" --force >/dev/null 2>&1 +if grep -q "L3 rate gate" "$TW/.githooks/pre-commit" 2>/dev/null \ + && grep -q "multimedia validator" "$TW/.githooks/pre-commit.local" 2>/dev/null; then + pass "--force preserves tracked hook as .pre-commit.local, then installs L3 hook" +else + fail "--force did NOT create the promised .pre-commit.local sidecar" +fi +rm -rf "$TW" + +# ── 2. UNTRACKED pre-commit IS preserved as sidecar, ours installed ───────── +TW=$(mktemp -d) +( + cd "$TW" && git init -q + mkdir -p .githooks + printf '#!/bin/bash\n# untracked local hook\n' > .githooks/pre-commit + chmod +x .githooks/pre-commit +) +BROOMVA_WORKSPACE="$TW" bash "$REPO/scripts/install-l3-stability.sh" >/dev/null 2>&1 +if grep -q "L3 rate gate" "$TW/.githooks/pre-commit" 2>/dev/null \ + && grep -q "untracked local hook" "$TW/.githooks/pre-commit.local" 2>/dev/null; then + pass "untracked hook moved to .pre-commit.local, L3 hook installed" +else + fail "untracked-hook preservation regressed" +fi +rm -rf "$TW" + +# ── 3 + 4. bootstrap gitignore reconciliation on a real git repo ──────────── +TW=$(mktemp -d); TH=$(mktemp -d) +( cd "$TW" && git init -q ) +HOME="$TH" BROOMVA_WORKSPACE="$TW" BSTACK_SKIP_SKILLS=1 BSTACK_SKIP_RCS=1 \ + BROOMVA_STATE_DIR="$TH/.cfg" bash "$REPO/scripts/bootstrap.sh" >/dev/null 2>&1 +if grep -q "control/audit/\*.jsonl" "$TW/.gitignore" 2>/dev/null; then + pass "bootstrap added machine-local audit-log glob to .gitignore" +else + fail "bootstrap did not add audit-log glob to .gitignore" +fi +# committable-ignored warning +echo ".control/arcs.yaml" >> "$TW/.gitignore" +out=$(HOME="$TH" BROOMVA_WORKSPACE="$TW" BSTACK_SKIP_SKILLS=1 BSTACK_SKIP_RCS=1 \ + BROOMVA_STATE_DIR="$TH/.cfg" bash "$REPO/scripts/bootstrap.sh" 2>&1) +if echo "$out" | grep -q "arcs.yaml is gitignored but the loop needs it"; then + pass "bootstrap warns when committable substrate is gitignored" +else + fail "no warning for gitignored committable substrate" +fi +# idempotency: the audit-log glob appears exactly once after repeated runs +n=$(grep -c "^\.control/audit/\*\.jsonl$" "$TW/.gitignore" 2>/dev/null || echo 0) +if [ "$n" -eq 1 ]; then + pass "audit-log glob is idempotent (single .gitignore entry after 2 runs)" +else + fail "audit-log glob duplicated in .gitignore ($n entries)" +fi +rm -rf "$TW" "$TH" + +echo "─────────────────────────────────────" +echo " Passed: $PASS" +echo " Failed: $FAIL" +if [ "$FAIL" -gt 0 ]; then + for t in "${FAILED[@]}"; do echo " - $t"; done + exit 1 +fi +echo " gitignore-aware-bootstrap passed."