#!/usr/bin/env bash # Refuse to start agent work in a state that will waste an hour. # # preflight.sh [--allow-shared] [--base main] # # Exit 0 = safe to proceed. Exit 1 = blocked, with the reason and the fix. # # Written after a real run on 2026-09-10 that burned most of a session. The # checks are in order of how much time each failure actually cost, not in # order of severity: # # 1. An executor stashed its own partial work with a descriptive message, # then found a clean tree, concluded "subagent edits are not persisting", # and re-fired subagents twice against that theory. `git stash list` # would have ended it immediately -- so this script prints the stash # stack UNCONDITIONALLY, before anything else, even when it passes. # 2. The checkout was still on a leftover branch from a merged PR, so every # edit landed there and `git pull origin main` failed against it. # 3. Work ran directly in the shared checkout, which collided with the # operator's own git commands in another terminal. set -euo pipefail BASE="main" ALLOW_SHARED=0 while [ $# -gt 0 ]; do case "$1" in --allow-shared) ALLOW_SHARED=1; shift ;; --base) BASE="${2:?--base needs a branch name}"; shift 2 ;; -h|--help) sed -n '2,6p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done git rev-parse --git-dir >/dev/null 2>&1 || { echo "preflight: not inside a git repository" >&2; exit 1; } fail() { echo "BLOCKED: $1" >&2; echo " fix: $2" >&2; exit 1; } # --- 0. The stash stack, always, pass or fail ------------------------------ # # Unconditional and first. The whole point is that an agent seeing an # unexpectedly clean tree reads this before it can invent a tooling theory. # It is also shared across every worktree of this repo on this machine, which # is why `git stash pop` is unsafe here -- another session pushing a stash # shifts every index, and pop DROPS on success, so a wrong index is # destructive. Address a stash by SHA and use `apply`. echo "--- git stash stack (shared across all worktrees) ---" if [ -z "$(git stash list)" ]; then echo "(empty)" else git stash list --format='%gd %H %gs' echo echo "NOTE: entries above are shared. If you are missing work, it may be" echo " here. Restore with: git stash apply (never pop)" fi echo # --- 1. Not the shared primary checkout ------------------------------------ # # In a linked worktree these two differ; in the primary checkout they are the # same path. Work in the primary mutates the tree the operator is using. if [ "$ALLOW_SHARED" -eq 0 ]; then if [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ]; then fail "running in the shared primary checkout, not a worktree" \ "create one (git worktree add) or pass --allow-shared if you mean it" fi fi # --- 2. On the expected base branch ---------------------------------------- branch="$(git rev-parse --abbrev-ref HEAD)" if [ "$branch" != "$BASE" ]; then # A feature branch cut from an up-to-date base is fine; a leftover branch # from a merged PR is what caused the incident. Distinguish them by asking # whether the base is an ancestor. if git merge-base --is-ancestor "origin/$BASE" HEAD 2>/dev/null; then echo "note: on '$branch', which already contains origin/$BASE. OK." else fail "on '$branch', which does NOT contain origin/$BASE" \ "git checkout $BASE && git pull origin $BASE" fi fi # --- 3. Clean working tree ------------------------------------------------- if [ -n "$(git status --porcelain)" ]; then echo "--- uncommitted changes ---" >&2 git status --short >&2 fail "working tree is dirty" \ "commit them, or stash with: git stash push -u -m ''" fi # --- 4. Base is current ---------------------------------------------------- git fetch --quiet origin "$BASE" 2>/dev/null || { echo "note: could not fetch origin/$BASE; skipping the freshness check"; } if git rev-parse --verify --quiet "origin/$BASE" >/dev/null; then behind="$(git rev-list --count "HEAD..origin/$BASE")" if [ "$behind" -gt 0 ]; then fail "$behind commit(s) behind origin/$BASE" \ "git pull origin $BASE" fi fi echo "preflight OK: $branch, clean, current with origin/$BASE"