diff --git a/lib/deploy-commit.sh b/lib/deploy-commit.sh index bdee296..d237385 100644 --- a/lib/deploy-commit.sh +++ b/lib/deploy-commit.sh @@ -1,6 +1,18 @@ #!/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 _in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; } @@ -67,7 +79,8 @@ case "$cmd" in 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: git commit failed" >&2; exit 1; } + 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 ... | commit \"\" ..." >&2; exit 2 ;; esac diff --git a/lib/tests/deploy-commit.test.sh b/lib/tests/deploy-commit.test.sh index f100509..3a9b82e 100644 --- a/lib/tests/deploy-commit.test.sh +++ b/lib/tests/deploy-commit.test.sh @@ -50,4 +50,13 @@ printf 'run\n' >"$d/.claude/deploy/PROCEDURE.md" ( cd "$d" && bash "$H" commit "docs(deploy): t" .claude/deploy/PROCEDURE.md ) >/dev/null 2>&1 check T9-ignored-rc "$?" 5 +d=$(mkrepo); printf '#!/bin/sh\nexit 1\n' >"$d/.git/hooks/pre-commit"; chmod +x "$d/.git/hooks/pre-commit" +BEFORE=$(git -C "$d" rev-parse --short HEAD) +printf 'run\n' >"$d/.claude/deploy/PROCEDURE.md" +OUT=$( ( cd "$d" && bash "$H" commit "docs(deploy): t" .claude/deploy/PROCEDURE.md ) 2>/dev/null ); RC=$? +AFTER=$(git -C "$d" rev-parse --short HEAD) +check T10-rejected-rc "$RC" 6 +check T10-rejected-no-hash "$([ -z "$OUT" ] && echo empty || echo "$OUT")" empty +check T10-rejected-head-unmoved "$BEFORE" "$AFTER" + printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ] diff --git a/skills/deploy/SKILL.md b/skills/deploy/SKILL.md index d6b2515..30c85dc 100644 --- a/skills/deploy/SKILL.md +++ b/skills/deploy/SKILL.md @@ -250,6 +250,7 @@ Present the full draft `PROCEDURE.md`. Return codes: **0** committed · **1** no-op (investigate — both files should be new) · **3** unsafe git state (STOP, tell user) · **4** out-of-scope path · **5** a passed path is git-ignored (won't persist) — STOP, fix the target's `.gitignore` · + **6** commit rejected — pre-commit hook/protected branch/signing (STOP, investigate) · **2** usage error OR not a git repo. **On rc=0: continue to STEP 1.** `STATE.json` absent → first deploy → @@ -358,8 +359,9 @@ Return codes: **0** committed (short-hash on stdout) · **1** nothing staged — wrote neither file · **3** unsafe git state (detached/merge/rebase — STOP, tell the user) · **4** out-of-scope path (you passed a non-`.claude/deploy/` path — fix the call) · **5** a passed path is git-ignored (won't persist) — STOP, fix the -target's `.gitignore` · **2** usage error OR not a git repo. The helper commits -whatever subset actually changed; +target's `.gitignore` · **6** commit rejected — pre-commit hook/protected branch/ +signing (STOP, investigate) · **2** usage error OR not a git repo. The helper +commits whatever subset actually changed; patch+incident coupling is **Claude-discipline, not helper-enforced**. **This commit IS the resolution** — the commit that introduces `DEP-NNN` is its