fix(portability): replace bash 4 builtins absent from macOS bash 3.2

macOS ships bash 3.2 as /bin/bash, which `#!/usr/bin/env bash` resolves to
when no newer bash is on PATH. Two builtins the repo relies on do not exist
there, and both failed SILENTLY:

- `mapfile` in the three surgical-commit helpers left every array empty, so
  the scope guards passed on nothing (fail-OPEN) and the commits degraded to
  "nothing pending — no-op" while reporting success. deploy-commit.test.sh
  went 4/16; memory and doc commits simply never happened.
- `declare -A` in the session-start hook errored on every session and left
  each plugin cost at 0, so the passive-budget warning could never fire.

`_read_lines_into` is the portable equivalent of `mapfile`, space-safe and
resetting its target first — expanding a never-assigned array trips `set -u`
on bash < 4.4, which is how the empty arrays surfaced as "unbound variable".
Plugin costs move to a `case`.

source-scope.sh's header prescribed `mapfile` to its callers; it now shows
the read loop, and its own test plus run-reconcile.sh stop using the builtin.

deploy-commit 16/16, source-scope 34/34, run-reconcile 25 GREEN / 0 RED,
session-start stderr empty.
This commit is contained in:
2026-09-13 17:20:57 -04:00
parent a53a5a26a8
commit f3919b6ace
7 changed files with 82 additions and 24 deletions
+22 -4
View File
@@ -15,6 +15,19 @@
# not just stderr, so it can't share rc 1's "nothing to do" (J4-22).
set -uo pipefail
# bash 3.2 (macOS /bin/bash) predates the `mapfile` builtin; this is the portable
# equivalent. Reads stdin's lines into the array named by $1, space-safe
# (IFS= read -r). The array is reset first, so empty input yields an empty array
# rather than a stale or unset one — `set -u` on bash < 4.4 trips on expanding
# an array that was never assigned.
_read_lines_into() {
local _name="$1" _line
eval "$_name=()"
while IFS= read -r _line; do
eval "$_name+=(\"\$_line\")"
done
}
_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; }
_unsafe_state() { # 0 = unsafe
@@ -48,7 +61,8 @@ _in_git_repo || { echo "deploy-commit: not a git repo" >&2; exit 2; }
case "$cmd" in
pending)
[ "$#" -gt 0 ] || { echo "deploy-commit: pending needs file args" >&2; exit 2; }
mapfile -t violations < <(_scope_violations "$@")
violations=()
_read_lines_into violations < <(_scope_violations "$@")
if [ "${#violations[@]}" -gt 0 ]; then
{ echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:";
printf ' - %s\n' "${violations[@]}";
@@ -59,21 +73,25 @@ case "$cmd" in
commit)
msg="${1:-}"; shift || true
[ -n "$msg" ] && [ "$#" -gt 0 ] || { echo "deploy-commit: commit needs <msg> <file>..." >&2; exit 2; }
mapfile -t violations < <(_scope_violations "$@")
violations=()
_read_lines_into violations < <(_scope_violations "$@")
if [ "${#violations[@]}" -gt 0 ]; then
{ echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:";
printf ' - %s\n' "${violations[@]}";
echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2
exit 4
fi
mapfile -t ignored_paths < <(for p in "$@"; do _ignored "$p" && printf '%s\n' "$p"; done)
ignored_paths=()
_read_lines_into ignored_paths \
< <(for p in "$@"; do _ignored "$p" && printf '%s\n' "$p"; done)
if [ "${#ignored_paths[@]}" -gt 0 ]; then
{ echo "deploy-commit: REFUSED — path(s) are git-ignored and will NOT persist; \`.claude/deploy/\` must be committable in this project:";
printf ' - %s\n' "${ignored_paths[@]}"; } >&2
exit 5
fi
_unsafe_state && { echo "deploy-commit: unsafe git state (detached/merge/rebase) — not committing" >&2; exit 3; }
mapfile -t changed < <(_changed_only "$@")
changed=()
_read_lines_into changed < <(_changed_only "$@")
[ "${#changed[@]}" -gt 0 ] || exit 1
git add -- "${changed[@]}"
if git diff --cached --quiet -- "${changed[@]}"; then