#!/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