aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/bin/git-worktree-gate
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-25 15:35:14 -0500
committerCraig Jennings <c@cjennings.net>2026-07-25 15:35:14 -0500
commitf2609d9f9ad33486bef43211d753ba53e1e24181 (patch)
tree561f0514866570d47c446816daa131ce13064639 /claude-templates/bin/git-worktree-gate
parent3203bd803b6a05d10781634e7e18742879c1046a (diff)
downloadrulesets-main.tar.gz
rulesets-main.zip
feat: enforce clean wraps and inbox-safe syncHEADmain
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-xclaude-templates/bin/git-worktree-gate185
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