diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-25 15:35:14 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-25 15:35:14 -0500 |
| commit | f2609d9f9ad33486bef43211d753ba53e1e24181 (patch) | |
| tree | 561f0514866570d47c446816daa131ce13064639 /claude-templates/bin/git-worktree-gate | |
| parent | 3203bd803b6a05d10781634e7e18742879c1046a (diff) | |
| download | rulesets-main.tar.gz rulesets-main.zip | |
Centralize repository-state checks, bind teardown to a certified clean HEAD, and allow inbox-only refreshes. Guard installed symlinks from cross-project writes and add regression coverage.
Diffstat (limited to 'claude-templates/bin/git-worktree-gate')
| -rwxr-xr-x | claude-templates/bin/git-worktree-gate | 185 |
1 files changed, 185 insertions, 0 deletions
diff --git a/claude-templates/bin/git-worktree-gate b/claude-templates/bin/git-worktree-gate new file mode 100755 index 0000000..e453fd1 --- /dev/null +++ b/claude-templates/bin/git-worktree-gate @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# git-worktree-gate — one definition of safe Git state for startup and wrap. +# +# Modes: +# strict [DIR] Require an entirely empty worktree. +# sync-safe [DIR] Permit untracked inbox/ deliveries, but nothing else. +# certify [DIR] Strict-check, then record the verified HEAD in the git dir. +# verify [DIR] Strict-check and require the recorded HEAD to still match. +# +# Ignored files are deliberately outside Git's clean-worktree contract. + +set -u + +mode="${1:-}" +repo="${2:-.}" + +usage() { + echo "usage: git-worktree-gate {strict|sync-safe|certify|verify} [DIR]" >&2 + exit 2 +} + +case "$mode" in + strict|sync-safe|certify|verify) ;; + *) usage ;; +esac + +root="$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null)" || { + echo "git-worktree-gate: $repo is not inside a Git worktree" >&2 + exit 2 +} +gitdir="$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null)" || { + echo "git-worktree-gate: cannot resolve the Git directory for $root" >&2 + exit 2 +} +certificate="$gitdir/ai-wrap-clean" + +quote_path() { + printf '%q' "$1" +} + +operation_in_progress() { + local marker + for marker in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do + [ -e "$gitdir/$marker" ] && { + printf '%s' "$marker" + return 0 + } + done + for marker in rebase-merge rebase-apply sequencer; do + [ -d "$gitdir/$marker" ] && { + printf '%s' "$marker" + return 0 + } + done + return 1 +} + +describe_entry() { + local xy="$1" path="$2" original="${3:-}" + local index="${xy:0:1}" worktree="${xy:1:1}" label="" + + if [ "$xy" = "??" ]; then + label="untracked; add and commit it, move it outside the repository, or remove it if unwanted" + elif [ "$xy" = "!!" ]; then + label="ignored" + elif [[ "$xy" = *U* || "$xy" = "AA" || "$xy" = "DD" ]]; then + label="unmerged; resolve the conflict and commit the result" + elif [ "$index" != " " ] && [ "$worktree" != " " ]; then + label="staged and unstaged changes; review both layers, then commit or restore them" + elif [ "$index" != " " ]; then + label="staged change; commit it or unstage and restore it" + else + label="unstaged tracked change; commit it or restore it" + fi + + printf ' %s ' "$xy" + quote_path "$path" + if [ -n "$original" ]; then + printf ' (from ' + quote_path "$original" + printf ')' + fi + printf ' — %s\n' "$label" +} + +check_state() { + local policy="$1" xy path original="" blocked=0 op="" + local status_tmp="" status_err="" status_detail="" + local -a report=() + + if op="$(operation_in_progress)"; then + report+=(" Git operation in progress: $op — finish or abort it") + blocked=1 + fi + + status_tmp="$(mktemp "$gitdir/ai-worktree-status.tmp.XXXXXX")" || { + echo "wrap blocked: cannot allocate a Git-state check file" >&2 + return 1 + } + status_err="$(mktemp "$gitdir/ai-worktree-status.err.XXXXXX")" || { + rm -f "$status_tmp" + echo "wrap blocked: cannot allocate a Git-state error file" >&2 + return 1 + } + + if ! git -C "$root" status --porcelain=v1 -z \ + --untracked-files=all --ignore-submodules=none \ + >"$status_tmp" 2>"$status_err"; then + status_detail="$(head -1 "$status_err")" + [ -n "$status_detail" ] || status_detail="unknown Git error" + report+=(" git status failed — $status_detail") + blocked=1 + else + while IFS= read -r -d '' entry; do + xy="${entry:0:2}" + path="${entry:3}" + original="" + if [[ "${xy:0:1}" = "R" || "${xy:0:1}" = "C" ]]; then + IFS= read -r -d '' original || true + fi + + if [ "$policy" = "sync-safe" ] \ + && [ "$xy" = "??" ] \ + && [[ "$path" = inbox/* ]]; then + continue + fi + + report+=("$(describe_entry "$xy" "$path" "$original")") + blocked=1 + done <"$status_tmp" + fi + rm -f "$status_tmp" "$status_err" + + if [ "$blocked" -ne 0 ]; then + if [ "$policy" = "sync-safe" ]; then + echo "sync blocked: rulesets has changes other than untracked inbox deliveries" >&2 + else + echo "wrap blocked: Git worktree is not completely clean" >&2 + fi + printf '%s\n' "${report[@]}" >&2 + return 1 + fi + return 0 +} + +case "$mode" in + strict) + check_state strict + ;; + sync-safe) + check_state sync-safe + ;; + certify) + check_state strict || exit 1 + head="$(git -C "$root" rev-parse HEAD 2>/dev/null)" || { + echo "wrap blocked: cannot resolve HEAD" >&2 + exit 1 + } + tmp="$(mktemp "$gitdir/ai-wrap-clean.tmp.XXXXXX")" || exit 1 + chmod 600 "$tmp" + { + printf 'head=%s\n' "$head" + printf 'root=%s\n' "$root" + } >"$tmp" + mv "$tmp" "$certificate" + ;; + verify) + check_state strict || exit 1 + [ -f "$certificate" ] || { + echo "wrap blocked: no clean-tree certificate exists; rerun the final wrap verification" >&2 + exit 1 + } + certified_head="$(sed -n 's/^head=//p' "$certificate" | head -1)" + certified_root="$(sed -n 's/^root=//p' "$certificate" | head -1)" + current_head="$(git -C "$root" rev-parse HEAD 2>/dev/null)" || exit 1 + [ "$certified_root" = "$root" ] || { + echo "wrap blocked: clean-tree certificate belongs to a different worktree" >&2 + exit 1 + } + [ -n "$certified_head" ] && [ "$certified_head" = "$current_head" ] || { + echo "wrap blocked: HEAD changed after clean-tree certification; rerun the final wrap verification" >&2 + exit 1 + } + ;; +esac |
