claude/lib/doc-commit.sh
Bastien Chanot ae1f218d3e feat(lib): surgical doc-commit helper + real-exec scope/exclusion tests
New lib/doc-commit.sh: surgical commit of ONLY the public-doc files doc-sync patched (passed as args), twin of memory-commit.sh with INVERSE scope. Δ1 dynamic pathspec filtered to changed paths (LRN-051); Δ2 fail-closed + LOUD scope guard rejecting .claude/** and CLAUDE.md (BDR-022) with a dedicated exit 4; Δ3 no hash anchoring (LRN-052); Δ4 `docs:` message. Hash-only on stdout for `doc_hash=$(...)` capture.

lib/tests/run-doc-commit.sh: 24 assertions, all REALLY EXECUTED on real git fixtures (no presumed behavior). T1a/b/c prove the guard CATCHES — forbidden path alone → exit 4, mixed legit+forbidden → refuse-all (nothing half-committed, offender named); T2 dynamic pathspec no-match filter; T3/T4 dangling + stale-index safety; T5/T6 idempotent + unsafe-state skip. shellcheck clean (both).

Part of the doc-sync coupled chantier (twin of BDR-034). Include + 2 orchestrator reorders follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho5EQCFTSvYamuRtVZpp2d
2026-06-27 00:38:18 +02:00

148 lines
5.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# doc-commit.sh — surgically commit ONLY the PUBLIC-DOC files doc-sync patched.
#
# Twin of memory-commit.sh, INVERSE scope: memory-commit TARGETS .claude/; this
# one commits public docs and must NEVER touch .claude/ or CLAUDE.md (BDR-022).
# Safety lives in the (dynamic) PATHSPEC + a fail-closed scope guard, never in a
# human diff review — automation removes that review, so the scope must be airtight.
#
# The scope guard is fail-CLOSED and LOUD: a forbidden path (.claude/** or
# CLAUDE.md) in the list is an UPSTREAM bug — doc-syncer must never patch those
# (BDR-022). Seeing one, ABORT THE WHOLE COMMIT and signal; do NOT silently filter
# it and commit the rest. A half-commit with no alert would MASK the violation.
# Caller passes EXACTLY the files doc-sync patched this run.
#
# Usage (CLI):
# doc-commit.sh pending <file>... # exit 0 if any passed file has changes, 1 if clean
# doc-commit.sh commit "<message>" <file>... # surgical commit
#
# Exit codes (commit): 0 ok/no-op · 2 usage · 3 unsafe git state · 4 scope violation.
# Output contract: diagnostics → stderr; on a real commit the short hash of the doc
# commit is the ONLY thing on stdout (empty on no-op/abort), so callers can capture
# it: doc_hash=$(doc-commit.sh commit "msg" README.md USAGE.md).
#
# Sourceable: docs_pending and commit_docs for the v2 hook.
set -uo pipefail
_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; }
# True (0) when the repo is in a state where we must NOT auto-commit:
# detached HEAD, or a merge/rebase/cherry-pick in progress.
_unsafe_state() {
local gitdir
gitdir="$(git rev-parse --git-dir 2>/dev/null)" || return 0
if [ -e "$gitdir/MERGE_HEAD" ] || [ -e "$gitdir/rebase-merge" ] ||
[ -e "$gitdir/rebase-apply" ] || [ -e "$gitdir/CHERRY_PICK_HEAD" ]; then
return 0
fi
git symbolic-ref -q HEAD >/dev/null 2>&1 || return 0 # detached HEAD
return 1
}
# True (0) when a path is OUT OF SCOPE for a doc commit: anything under .claude/
# (any depth) or a CLAUDE.md (root or nested). These are doc-syncer's read-only
# context, never sync targets (BDR-022) — their presence is an upstream anomaly.
_forbidden_path() {
case "$1" in
.claude | .claude/* | */.claude/* | CLAUDE.md | */CLAUDE.md) return 0 ;;
*) return 1 ;;
esac
}
# Print every forbidden path in the argument list, one per line (empty = none).
_scope_violations() {
local p
for p in "$@"; do
_forbidden_path "$p" && printf '%s\n' "$p"
done
}
# Of the passed paths, those that EXIST and have real pending changes. A clean or
# absent path is dropped (not fatal): `git commit -- <no-match>` aborts the WHOLE
# commit, while `git add` tolerates it — so scope = only paths git would accept.
_changed_paths() {
local p
for p in "$@"; do
[ -e "$p" ] || continue
[ -n "$(git status --porcelain -- "$p" 2>/dev/null)" ] && printf '%s\n' "$p"
done
}
# 0 if any passed path has pending changes, 1 if all clean / absent.
docs_pending() {
_in_git_repo || return 1
local changed
mapfile -t changed < <(_changed_paths "$@")
[ "${#changed[@]}" -gt 0 ]
}
# Surgical commit of the passed doc paths only. Returns 0 (ok/no-op), 3 (unsafe),
# 4 (scope violation). On a real commit, prints the doc-commit short hash to stdout.
commit_docs() {
local msg="${1:?commit message required}"
shift
_in_git_repo || {
echo "doc-commit: not a git repo — skip" >&2
return 3
}
if _unsafe_state; then
echo "doc-commit: detached HEAD or merge/rebase in progress — skip (no commit)" >&2
return 3
fi
# FAIL-CLOSED scope guard. A forbidden path is an upstream BDR-022 violation
# (doc-syncer must never patch .claude/ or CLAUDE.md). Abort the WHOLE commit and
# name the offenders — never filter-and-commit-the-rest (that masks the bug).
local violations
mapfile -t violations < <(_scope_violations "$@")
if [ "${#violations[@]}" -gt 0 ]; then
{
echo "doc-commit: REFUSED — out-of-scope path(s) in the doc list (upstream BDR-022 violation):"
printf ' - %s\n' "${violations[@]}"
echo "doc-commit: NOTHING committed. doc-syncer must never patch .claude/ or CLAUDE.md —" \
"investigate why these surfaced before retrying."
} >&2
return 4
fi
local changed
mapfile -t changed < <(_changed_paths "$@")
if [ "${#changed[@]}" -eq 0 ]; then
echo "doc-commit: nothing pending — no-op" >&2
return 0
fi
# Re-stage working-tree content over any stale index entry, then commit ONLY
# those paths. The pathspec on `git commit` makes it partial: other staged files
# (dangling code) are not recorded.
git add -- "${changed[@]}"
if git diff --cached --quiet -- "${changed[@]}"; then
echo "doc-commit: only ignored/no-op changes — no-op" >&2
return 0
fi
git commit -q -m "$msg" -- "${changed[@]}"
printf 'doc-commit: committed %d file(s): %s\n' "${#changed[@]}" "${changed[*]}" >&2
git rev-parse --short HEAD
}
main() {
local cmd="${1:-}"
case "$cmd" in
pending)
shift
docs_pending "$@"
;;
commit)
shift
commit_docs "$@"
;;
*)
echo "usage: doc-commit.sh {pending <file>... | commit <message> <file>...}" >&2
return 2
;;
esac
}
# Run main only when executed, not when sourced.
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
main "$@"
fi