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.
105 lines
4.6 KiB
Bash
105 lines
4.6 KiB
Bash
#!/usr/bin/env bash
|
|
# deploy-commit.sh — surgical commit for the .claude/deploy/ runbook family.
|
|
# Allowlist scope = .claude/deploy/ ONLY (inverse of doc-commit's .claude exclusion).
|
|
#
|
|
# Exit code taxonomy:
|
|
# 0 committed (short-hash on stdout), or `pending`: something changed
|
|
# 1 no-op — nothing staged/changed (`pending`: clean) — NOT a failure
|
|
# 2 usage error, or not a git repo
|
|
# 3 unsafe git state (detached HEAD / merge / rebase in progress)
|
|
# 4 a passed path is outside the .claude/deploy/ allowlist
|
|
# 5 a passed path is git-ignored and would not persist
|
|
# 6 `git commit` itself was REJECTED (pre-commit hook, protected branch,
|
|
# signing failure, …) — distinct from rc 1 (no-op): here something WAS
|
|
# staged and git refused it. Client repos may parse this by exit code,
|
|
# 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
|
|
local g; g=$(git rev-parse --git-dir 2>/dev/null) || return 0
|
|
git symbolic-ref -q HEAD >/dev/null 2>&1 || return 0 # detached HEAD
|
|
[ -e "$g/MERGE_HEAD" ] || [ -d "$g/rebase-merge" ] || \
|
|
[ -d "$g/rebase-apply" ] || [ -e "$g/CHERRY_PICK_HEAD" ] && return 0
|
|
return 1
|
|
}
|
|
|
|
_out_of_scope() { # 0 = forbidden, 1 = in scope
|
|
case "$1" in
|
|
*..*) return 0 ;; # traversal — forbidden FIRST
|
|
.claude/deploy/*) return 1 ;; # allowed
|
|
*) return 0 ;; # everything else forbidden
|
|
esac
|
|
}
|
|
|
|
_scope_violations() { local p; for p in "$@"; do _out_of_scope "$p" && printf '%s\n' "$p"; done; }
|
|
|
|
_ignored() { git check-ignore -q "$1"; } # rc 0 = ignored
|
|
|
|
_changed_only() { # echo passed files that actually have changes
|
|
local p; for p in "$@"; do
|
|
[ -n "$(git status --porcelain -- "$p" 2>/dev/null)" ] && printf '%s\n' "$p"; done
|
|
}
|
|
|
|
cmd="${1:-}"; shift || true
|
|
_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; }
|
|
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
|
|
[ -n "$(_changed_only "$@")" ] && exit 0 || exit 1 ;;
|
|
commit)
|
|
msg="${1:-}"; shift || true
|
|
[ -n "$msg" ] && [ "$#" -gt 0 ] || { echo "deploy-commit: commit needs <msg> <file>..." >&2; exit 2; }
|
|
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
|
|
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; }
|
|
changed=()
|
|
_read_lines_into changed < <(_changed_only "$@")
|
|
[ "${#changed[@]}" -gt 0 ] || exit 1
|
|
git add -- "${changed[@]}"
|
|
if git diff --cached --quiet -- "${changed[@]}"; then
|
|
echo "deploy-commit: nothing staged — no-op" >&2; exit 1
|
|
fi
|
|
git commit -q -m "$msg" -- "${changed[@]}" \
|
|
|| { echo "deploy-commit: COMMIT REJECTED — git commit exited non-zero (pre-commit hook? protected branch? signing?)." >&2; exit 6; }
|
|
git rev-parse --short HEAD ;;
|
|
*) echo "usage: deploy-commit.sh pending <file>... | commit \"<msg>\" <file>..." >&2; exit 2 ;;
|
|
esac
|