diff options
Diffstat (limited to 'claude-templates/bin')
| -rwxr-xr-x | claude-templates/bin/ai | 40 | ||||
| -rwxr-xr-x | claude-templates/bin/git-worktree-gate | 185 |
2 files changed, 216 insertions, 9 deletions
diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai index 55c0e0d..65d0ab7 100755 --- a/claude-templates/bin/ai +++ b/claude-templates/bin/ai @@ -231,12 +231,28 @@ fetch_candidates() { wait } -# True (exit 0) when the worktree has staged, unstaged, or untracked changes. +# Resolve the shared state gate installed beside this launcher. Keeping the +# policy in one executable prevents startup, the picker, and wrap-up from +# developing different meanings of "safe to sync." +_git_gate_path() { + local gate="${GIT_WORKTREE_GATE:-}" + [ -n "$gate" ] || gate="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/git-worktree-gate" + [ -x "$gate" ] && printf '%s\n' "$gate" +} + +# True (exit 0) when strict wrap would reject the worktree. _git_is_dirty() { - local dir="$1" - ! git -C "$dir" diff --quiet 2>/dev/null || - ! git -C "$dir" diff --cached --quiet 2>/dev/null || - [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ] + local dir="$1" gate + gate="$(_git_gate_path)" || return 0 + ! "$gate" strict "$dir" >/dev/null 2>&1 +} + +# True (exit 0) when startup sync must stop. Untracked inbox deliveries are +# safe queue input; every tracked, staged, or other untracked change blocks. +_git_blocks_sync() { + local dir="$1" gate + gate="$(_git_gate_path)" || return 0 + ! "$gate" sync-safe "$dir" >/dev/null 2>&1 } # Return " (↑N ↓N dirty)" or " (✓)" if clean. @@ -254,7 +270,13 @@ git_status_indicator() { parts+=("no upstream") fi - _git_is_dirty "$dir" && parts+=("dirty") + if _git_is_dirty "$dir"; then + if _git_blocks_sync "$dir"; then + parts+=("dirty") + else + parts+=("inbox") + fi + fi if [ ${#parts[@]} -gt 0 ]; then local IFS=' ' @@ -275,11 +297,11 @@ annotate_candidates() { candidates=("${annotated[@]}") } -# Pull if clean, behind, not ahead. No-op otherwise. +# Pull if sync-safe, behind, not ahead. Inbox-only queue input is sync-safe. auto_pull_if_clean() { local dir="$1" upstream ahead behind [ -d "$dir/.git" ] || return 0 - _git_is_dirty "$dir" && return 0 + _git_blocks_sync "$dir" && return 0 upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true) [ -z "$upstream" ] && return 0 @@ -356,7 +378,7 @@ prep_git_single() { ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) - _git_is_dirty "$dir" && dirty=1 + _git_blocks_sync "$dir" && dirty=1 case "$(_git_prep_action 1 "$dirty" "${ahead:-0}" "${behind:-0}")" in pull) 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 |
