
Workflow Preflight
- 50 installs
- 49 repo stars
- Updated August 4, 2026
- laurigates/claude-plugins
Helps with automation & workflows tasks.
About
workflow-preflight is a Claude Code skill for automation & workflows. It helps solo builders move faster with AI-assisted development.
- workflow-preflight
- Automation & Workflows
- AI-coding skill
Workflow Preflight by the numbers
- 50 all-time installs (skills.sh)
- Ranked #1,082 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/laurigates/claude-plugins --skill workflow-preflightAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 50 |
|---|---|
| repo stars | ★ 49 |
| Last updated | August 4, 2026 |
| Repository | laurigates/claude-plugins ↗ |
What it does
Helps with automation & workflows tasks.
Files
/workflow:preflight
Pre-work validation to prevent wasted effort from stale state, redundant work, or branch conflicts.
When to Use This Skill
| Use this skill when... | Skip when... |
|---|---|
| Starting work on a new issue or feature | Quick single-file edit |
| Resuming work after a break | Already verified state this session |
| Before spawning parallel agents | Working in an isolated worktree |
| Before creating a branch for a PR | Branch already created and verified |
Context
- Repo: !
git remote -v - Current branch: !
git branch --show-current - Remote tracking: !
git branch -vv --format='%(refname:short) %(upstream:short) %(upstream:track)' - Uncommitted changes: !
git status --porcelain - Stash count: !
git stash list
Execution
Run the preflight check, then apply judgment to its results.
Step 1: Gather preflight state
Invoke the gatherer. Pass --issue <n> or --branch <name> when the argument is a GitHub issue number or branch:
bash "${CLAUDE_SKILL_DIR}/scripts/preflight.sh" --project-dir "$(pwd)" [--issue N] [--branch NAME]The script fetches origin --prune, resolves the base ref (origin/main → origin/master → main → master), and emits a structured KEY=VALUE block: AHEAD / BEHIND divergence, UNCOMMITTED / STASH_COUNT, CONFLICTS (+ CONFLICT_FILES), existing-work lookups (ISSUE_STATE, EXISTING_PRS, BRANCH_MATCHES), and a fixed RECOMMENDATION. It degrades gracefully when gh is unavailable (GH_AVAILABLE=false) and never fails on network errors. Pass --base <ref> to override the comparison ref, --no-fetch to skip the network round-trip.
Step 2: Act on existing work (judgment)
Read EXISTING_PRS (#N:STATE:headRef entries) and RECOMMENDATION:
- `RECOMMENDATION=already-addressed` (a
:MERGED:PR exists): report that
the issue is already addressed and stop — do not duplicate the work.
- `RECOMMENDATION=existing-pr` (an
:OPEN:PR exists): report the PR and
ask the user whether to continue on that branch or start fresh (AskUserQuestion). Do not pick for them.
- Otherwise continue to the summary.
Step 3: Summary report
Translate the KEY=VALUE block into a summary the user can act on:
| Check | Source key | Detail |
|---|---|---|
| Remote state | FETCH | ok / skipped / no-remote |
| Existing PRs | EXISTING_PRS | PR numbers + state, if any |
| Branch state | AHEAD / BEHIND / UNCOMMITTED | ahead/behind counts, dirty tree |
| Conflicts | CONFLICTS / CONFLICT_FILES | conflicting files |
| Stash | STASH_COUNT | number of stash entries |
Lead with the headline RECOMMENDATION, then surface every relevant note — the recommendation is a single first-match headline, but multiple conditions can be worth mentioning:
RECOMMENDATION | Tell the user |
|---|---|
resolve-conflicts | Resolve conflicts with the base ref before proceeding |
commit-or-stash | Commit or stash changes before branching |
rebase | Rebase on the base ref before starting work |
existing-pr | A PR already addresses this — review before duplicating |
already-addressed | A merged PR already addresses this — stop |
ready | Ready to proceed |
Agentic Optimizations
| Context | Command |
|---|---|
| Full preflight (default) | bash "${CLAUDE_SKILL_DIR}/scripts/preflight.sh" --project-dir "$(pwd)" |
| Preflight for an issue | bash "${CLAUDE_SKILL_DIR}/scripts/preflight.sh" --issue N |
| Offline / no network | bash "${CLAUDE_SKILL_DIR}/scripts/preflight.sh" --no-fetch |
| Override base ref | bash "${CLAUDE_SKILL_DIR}/scripts/preflight.sh" --base origin/develop |
The script wraps git fetch --prune, git rev-list --left-right --count, git merge-tree --write-tree, git stash list, and the gh existing-work lookups behind one structured-output call. See `scripts/preflight.sh`.
Quick Reference
| Flag | Description |
|---|---|
git fetch --prune | Fetch and remove stale remote refs |
git status --porcelain=v2 | Machine-parseable status |
gh pr list --search | Search PRs by content |
gh issue view --json | Structured issue data |
git merge-tree | Dry-run merge conflict detection |
git log A..B | Commits in B but not A |
#!/usr/bin/env bash
# Preflight state gatherer for /workflow:preflight (ADR-0016 extraction, #1558).
#
# Collects the deterministic git/gh state the skill used to gather inline —
# remote freshness, ahead/behind counts, uncommitted + stash state, merge
# conflict detection, existing-PR/issue/branch lookup — and emits it as a
# structured KEY=VALUE block (see .claude/rules/structured-script-output.md).
#
# The skill keeps every judgment step: deciding whether an open PR means
# "continue here or start fresh" (an AskUserQuestion handoff), and authoring
# the final summary. This script only reports facts and a fixed
# recommendation derived from those facts.
#
# Usage:
# bash preflight.sh [--project-dir <path>] [--base <ref>] [--issue <n>]
# [--branch <name>] [--no-fetch]
#
# Exit codes: 0 when state was gathered (STATUS=OK or WARN), 1 on ERROR
# (not a git repository). Network/gh failures degrade gracefully — they never
# fail the run.
set -uo pipefail
project_dir=""
base_ref=""
issue_num=""
branch_name=""
do_fetch=true
while [ $# -gt 0 ]; do
case "$1" in
--project-dir) project_dir="$2"; shift 2 ;;
--base) base_ref="$2"; shift 2 ;;
--issue) issue_num="$2"; shift 2 ;;
--branch) branch_name="$2"; shift 2 ;;
--no-fetch) do_fetch=false; shift ;;
*) shift ;;
esac
done
: "${project_dir:=$(pwd)}"
git_in() { git -C "$project_dir" "$@"; }
echo "=== PREFLIGHT ==="
# --- Repository guard -------------------------------------------------------
repo_root=$(git_in rev-parse --show-toplevel 2>/dev/null || true)
if [ -z "$repo_root" ]; then
echo "STATUS=ERROR"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=ERROR TYPE=not_a_repo MSG=${project_dir} is not inside a git repository"
echo "=== END PREFLIGHT ==="
exit 1
fi
echo "REPO_ROOT=${repo_root}"
current_branch=$(git_in branch --show-current 2>/dev/null || true)
echo "CURRENT_BRANCH=${current_branch:-DETACHED}"
# --- Step 1: fetch latest remote state --------------------------------------
fetch_state="skipped"
if [ "$do_fetch" = true ]; then
if git_in remote get-url origin >/dev/null 2>&1; then
if git_in fetch origin --prune >/dev/null 2>&1; then
fetch_state="ok"
else
fetch_state="failed"
fi
else
fetch_state="no-remote"
fi
fi
echo "FETCH=${fetch_state}"
# --- Resolve the base ref to compare against --------------------------------
if [ -z "$base_ref" ]; then
for candidate in origin/main origin/master main master; do
if git_in rev-parse --verify --quiet "$candidate" >/dev/null 2>&1; then
base_ref="$candidate"
break
fi
done
fi
base_resolved=false
if [ -n "$base_ref" ] && git_in rev-parse --verify --quiet "$base_ref" >/dev/null 2>&1; then
base_resolved=true
fi
echo "BASE_REF=${base_ref:-none}"
echo "BASE_RESOLVED=${base_resolved}"
# --- Step 3: branch divergence (ahead / behind) -----------------------------
ahead=0
behind=0
if [ "$base_resolved" = true ]; then
# left = behind (in base, not HEAD); right = ahead (in HEAD, not base)
counts=$(git_in rev-list --left-right --count "${base_ref}...HEAD" 2>/dev/null || echo "0 0")
behind=$(printf '%s\n' "$counts" | awk '{print $1+0}')
ahead=$(printf '%s\n' "$counts" | awk '{print $2+0}')
fi
echo "AHEAD=${ahead}"
echo "BEHIND=${behind}"
# --- Uncommitted + stash state ----------------------------------------------
uncommitted=$(git_in status --porcelain 2>/dev/null | grep -c . || true)
uncommitted=${uncommitted:-0}
echo "UNCOMMITTED=${uncommitted}"
stash_count=$(git_in stash list 2>/dev/null | grep -c . || true)
stash_count=${stash_count:-0}
echo "STASH_COUNT=${stash_count}"
# --- Step 4: dry-run merge conflict detection -------------------------------
conflicts="none"
conflict_files=""
if [ "$base_resolved" = true ]; then
# git 2.38+ : --write-tree exits non-zero on conflict; --name-only lists the
# conflicted paths after the tree-OID line. Fall back to the legacy 3-arg
# form (parsing conflict markers) when --write-tree is unavailable.
mt_out=$(git_in merge-tree --write-tree --name-only "$base_ref" HEAD 2>/dev/null)
mt_rc=$?
if [ "$mt_rc" -gt 1 ]; then
# --write-tree unsupported (usage error rc=128) — legacy fallback
merge_base=$(git_in merge-base HEAD "$base_ref" 2>/dev/null || true)
if [ -n "$merge_base" ] && git_in merge-tree "$merge_base" HEAD "$base_ref" 2>/dev/null | grep -q '^<<<<<<<'; then
conflicts="detected"
fi
elif [ "$mt_rc" -eq 1 ]; then
conflicts="detected"
# Drop the first line (the written tree OID); the rest are conflicted paths.
conflict_files=$(printf '%s\n' "$mt_out" | tail -n +2 | paste -sd, - 2>/dev/null || true)
fi
fi
echo "CONFLICTS=${conflicts}"
[ -n "$conflict_files" ] && echo "CONFLICT_FILES=${conflict_files}"
# --- Step 2: existing work lookup (gh + git), graceful degradation -----------
gh_available=false
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
gh_available=true
fi
echo "GH_AVAILABLE=${gh_available}"
issue_state="none"
existing_prs="none"
branch_matches="none"
if [ -n "$issue_num" ]; then
echo "ISSUE=${issue_num}"
if [ "$gh_available" = true ]; then
issue_state=$(gh issue view "$issue_num" --json state --jq '.state' 2>/dev/null || echo "unknown")
: "${issue_state:=unknown}"
# PRs that reference the issue via closing keywords.
prs=$(gh pr list --state all \
--search "fixes #${issue_num} OR closes #${issue_num} OR resolves #${issue_num}" \
--json number,state,headRefName \
--jq '[.[] | "#\(.number):\(.state):\(.headRefName)"] | join(";")' 2>/dev/null || true)
[ -n "$prs" ] && existing_prs="$prs"
else
issue_state="unknown"
fi
# Local + remote branches referencing the issue (no network needed).
matches=$(git_in branch -a --list "*issue-${issue_num}*" "*issue/${issue_num}*" \
"*fix/${issue_num}*" "*fix/${issue_num}-*" "*feat/${issue_num}*" "*feat/${issue_num}-*" 2>/dev/null \
| sed 's/^[* ]*//' | grep -v '^$' | paste -sd, - 2>/dev/null || true)
[ -n "$matches" ] && branch_matches="$matches"
else
echo "ISSUE=none"
fi
echo "ISSUE_STATE=${issue_state}"
echo "EXISTING_PRS=${existing_prs}"
echo "BRANCH_MATCHES=${branch_matches}"
[ -n "$branch_name" ] && echo "TARGET_BRANCH=${branch_name}"
# --- Step 5: fixed recommendation decision tree -----------------------------
# First match wins, ordered by what blocks starting work most decisively.
# The skill still surfaces every relevant note; this is the headline.
has_merged_pr=false
has_open_pr=false
if [ "$existing_prs" != "none" ]; then
printf '%s\n' "$existing_prs" | grep -q ':MERGED:' && has_merged_pr=true
printf '%s\n' "$existing_prs" | grep -q ':OPEN:' && has_open_pr=true
fi
issue_count=0
recommendation="ready"
if [ "$has_merged_pr" = true ]; then
recommendation="already-addressed"
issue_count=$((issue_count + 1))
elif [ "$has_open_pr" = true ]; then
recommendation="existing-pr"
issue_count=$((issue_count + 1))
elif [ "$conflicts" = "detected" ]; then
recommendation="resolve-conflicts"
issue_count=$((issue_count + 1))
elif [ "$uncommitted" -gt 0 ]; then
recommendation="commit-or-stash"
issue_count=$((issue_count + 1))
elif [ "$behind" -gt 0 ]; then
recommendation="rebase"
issue_count=$((issue_count + 1))
fi
echo "RECOMMENDATION=${recommendation}"
if [ "$issue_count" -gt 0 ]; then
echo "STATUS=WARN"
else
echo "STATUS=OK"
fi
echo "ISSUE_COUNT=${issue_count}"
echo "=== END PREFLIGHT ==="
exit 0
#!/usr/bin/env bash
# Regression test for preflight.sh (ADR-0016 extraction, #1558).
#
# Encodes the script's structured-output contract (the semantic invariant the
# skill depends on): the =/STATUS/ISSUE_COUNT envelope, the ahead/behind +
# uncommitted + stash + conflict facts, and the fixed recommendation decision
# tree (resolve-conflicts > commit-or-stash > rebase > ready). All cases run
# against a local fixture repo with --no-fetch --base main, so the test is
# hermetic (no remote, no gh).
#
# Exit 0 on success, non-zero on first failure.
set -uo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
preflight="${script_dir}/../preflight.sh"
fail() { echo "FAIL: $1" >&2; exit 1; }
pass() { echo "PASS: $1"; }
[ -f "$preflight" ] || fail "preflight.sh not found at $preflight"
# Fresh fixture repo on branch main with a configured identity.
make_repo() {
local dir
dir="$(mktemp -d)" || { echo "mktemp -d failed" >&2; exit 1; }
git -C "$dir" init -q -b main
git -C "$dir" config user.email "test@example.com"
git -C "$dir" config user.name "Test"
git -C "$dir" config commit.gpgsign false
printf 'line\n' > "$dir/file.txt"
git -C "$dir" add file.txt
git -C "$dir" commit -q -m "initial"
printf '%s' "$dir"
}
run() { bash "$preflight" --no-fetch --base main --project-dir "$1"; }
# -----------------------------------------------------------------------------
# Case 0: not a git repository -> STATUS=ERROR, exit 1
# -----------------------------------------------------------------------------
notrepo="$(mktemp -d)" || { echo "mktemp -d failed" >&2; exit 1; }
out0="$(bash "$preflight" --no-fetch --project-dir "$notrepo")"; rc0=$?
[ "$rc0" -eq 1 ] || fail "expected exit 1 outside a repo, got $rc0"
echo "$out0" | grep -q "^STATUS=ERROR$" || fail "expected STATUS=ERROR outside a repo:\n$out0"
echo "$out0" | grep -q "TYPE=not_a_repo" || fail "expected not_a_repo issue type:\n$out0"
rm -rf "$notrepo"
pass "non-repo emits STATUS=ERROR and exits 1"
# -----------------------------------------------------------------------------
# Case 1: clean, HEAD == base -> ready / OK, no divergence
# -----------------------------------------------------------------------------
r1="$(make_repo)"
out1="$(run "$r1")"; rc1=$?
[ "$rc1" -eq 0 ] || fail "clean repo should exit 0, got $rc1"
echo "$out1" | grep -q "^=== PREFLIGHT ===$" || fail "missing section header:\n$out1"
echo "$out1" | grep -q "^=== END PREFLIGHT ===$" || fail "missing section footer:\n$out1"
echo "$out1" | grep -q "^FETCH=skipped$" || fail "expected FETCH=skipped with --no-fetch:\n$out1"
echo "$out1" | grep -q "^BASE_RESOLVED=true$" || fail "expected BASE_RESOLVED=true:\n$out1"
echo "$out1" | grep -q "^AHEAD=0$" || fail "expected AHEAD=0:\n$out1"
echo "$out1" | grep -q "^BEHIND=0$" || fail "expected BEHIND=0:\n$out1"
echo "$out1" | grep -q "^CONFLICTS=none$" || fail "expected CONFLICTS=none:\n$out1"
echo "$out1" | grep -q "^RECOMMENDATION=ready$" || fail "expected RECOMMENDATION=ready:\n$out1"
echo "$out1" | grep -q "^STATUS=OK$" || fail "expected STATUS=OK:\n$out1"
echo "$out1" | grep -q "^ISSUE_COUNT=0$" || fail "expected ISSUE_COUNT=0:\n$out1"
rm -rf "$r1"
pass "clean HEAD==base -> ready / OK"
# -----------------------------------------------------------------------------
# Case 2: behind base, no conflict -> rebase
# -----------------------------------------------------------------------------
r2="$(make_repo)"
git -C "$r2" checkout -q -b feature
git -C "$r2" checkout -q main
printf 'extra\n' > "$r2/other.txt"
git -C "$r2" add other.txt
git -C "$r2" commit -q -m "advance main"
git -C "$r2" checkout -q feature
out2="$(run "$r2")"
echo "$out2" | grep -q "^BEHIND=1$" || fail "expected BEHIND=1:\n$out2"
echo "$out2" | grep -q "^AHEAD=0$" || fail "expected AHEAD=0:\n$out2"
echo "$out2" | grep -q "^CONFLICTS=none$" || fail "expected no conflict for disjoint change:\n$out2"
echo "$out2" | grep -q "^RECOMMENDATION=rebase$" || fail "expected RECOMMENDATION=rebase:\n$out2"
echo "$out2" | grep -q "^STATUS=WARN$" || fail "expected STATUS=WARN when behind:\n$out2"
rm -rf "$r2"
pass "behind base, no conflict -> rebase / WARN"
# -----------------------------------------------------------------------------
# Case 3: uncommitted changes (not behind, no conflict) -> commit-or-stash
# -----------------------------------------------------------------------------
r3="$(make_repo)"
printf 'dirty\n' >> "$r3/file.txt"
out3="$(run "$r3")"
echo "$out3" | grep -q "^UNCOMMITTED=1$" || fail "expected UNCOMMITTED=1:\n$out3"
echo "$out3" | grep -q "^BEHIND=0$" || fail "expected BEHIND=0:\n$out3"
echo "$out3" | grep -q "^RECOMMENDATION=commit-or-stash$" || fail "expected commit-or-stash:\n$out3"
rm -rf "$r3"
pass "uncommitted changes -> commit-or-stash"
# -----------------------------------------------------------------------------
# Case 4: conflicting divergence -> resolve-conflicts (precedence over behind)
# -----------------------------------------------------------------------------
r4="$(make_repo)"
git -C "$r4" checkout -q -b feature
printf 'feature-line\n' > "$r4/file.txt"
git -C "$r4" commit -q -am "feature edit"
git -C "$r4" checkout -q main
printf 'main-line\n' > "$r4/file.txt"
git -C "$r4" commit -q -am "main edit"
git -C "$r4" checkout -q feature
out4="$(run "$r4")"
echo "$out4" | grep -q "^CONFLICTS=detected$" || fail "expected CONFLICTS=detected:\n$out4"
echo "$out4" | grep -q "^CONFLICT_FILES=.*file.txt" || fail "expected file.txt in CONFLICT_FILES:\n$out4"
echo "$out4" | grep -q "^RECOMMENDATION=resolve-conflicts$" || fail "expected resolve-conflicts (precedence):\n$out4"
echo "$out4" | grep -q "^STATUS=WARN$" || fail "expected STATUS=WARN on conflict:\n$out4"
rm -rf "$r4"
pass "conflicting divergence -> resolve-conflicts (beats behind)"
# -----------------------------------------------------------------------------
# Case 5: stash state is reported
# -----------------------------------------------------------------------------
r5="$(make_repo)"
printf 'wip\n' >> "$r5/file.txt"
git -C "$r5" stash -q
out5="$(run "$r5")"
echo "$out5" | grep -q "^STASH_COUNT=1$" || fail "expected STASH_COUNT=1:\n$out5"
rm -rf "$r5"
pass "stash state reported"
# -----------------------------------------------------------------------------
# Case 6: no --issue -> existing-work lookups stay inert (hermetic, no gh)
# -----------------------------------------------------------------------------
r6="$(make_repo)"
out6="$(run "$r6")"
echo "$out6" | grep -q "^ISSUE=none$" || fail "expected ISSUE=none without --issue:\n$out6"
echo "$out6" | grep -q "^EXISTING_PRS=none$" || fail "expected EXISTING_PRS=none without --issue:\n$out6"
echo "$out6" | grep -q "^BRANCH_MATCHES=none$" || fail "expected BRANCH_MATCHES=none without --issue:\n$out6"
rm -rf "$r6"
pass "no --issue keeps PR/issue lookup inert"
echo "ALL PASS"
#!/usr/bin/env bash
# Regression test for workflow-preflight.sh (issue #1558).
#
# Semantic invariant under test: the deterministic recommendation decision tree
# returns the correct RECOMMENDATION for each combination of booleans, computed
# offline against a planted git repo + canned gh JSON via the fixture seam
# (WORKFLOW_PREFLIGHT_FIXTURE + WORKFLOW_PREFLIGHT_NO_FETCH). No network, no gh.
#
# Cases:
# (a) existing OPEN PR + clean tree -> "continue ... or start fresh"
# (b) merge conflicts detected -> "Resolve conflicts"
# (c) uncommitted + stash present -> "Commit or stash"
# (d) fresh branch, no PR, clean -> "Ready to proceed"
#
# Exit 0 on success ("ALL TESTS PASSED"), non-zero on any failure.
set -uo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
preflight_script="${script_dir}/../workflow-preflight.sh"
fail() { echo "FAIL: $1" >&2; exit 1; }
pass() { echo "PASS: $1"; }
if ! command -v jq >/dev/null 2>&1; then
echo "SKIP: jq not installed; cannot run workflow-preflight tests"
exit 0
fi
[ -f "$preflight_script" ] || fail "workflow-preflight.sh not found at $preflight_script"
home_dir="$(mktemp -d)" || { echo "mktemp -d failed" >&2; exit 1; }
trap 'rm -rf "$home_dir"' EXIT
# Build a planted git repo with origin/main, a feature branch one commit ahead,
# and return its path. Isolated env so global git config can't interfere.
make_repo() {
local root remote work
root="$(mktemp -d)" || { echo "mktemp -d failed" >&2; exit 1; }
remote="${root}/remote.git"
work="${root}/work"
git init --quiet --bare "$remote"
git init --quiet "$work"
git -C "$work" config user.email "test@example.com"
git -C "$work" config user.name "Test"
git -C "$work" config commit.gpgsign false
git -C "$work" checkout -q -b main
printf 'base\n' > "${work}/file.txt"
git -C "$work" add file.txt
git -C "$work" commit --quiet -m "base commit"
git -C "$work" remote add origin "$remote"
git -C "$work" push --quiet -u origin main
# Feature branch one commit ahead of main.
git -C "$work" checkout -q -b feature/issue-42
printf 'base\nfeature line\n' > "${work}/file.txt"
git -C "$work" add file.txt
git -C "$work" commit --quiet -m "feature work"
# Refresh remote-tracking refs without hitting the network at run time.
git -C "$work" fetch --quiet origin
echo "$root"
}
# ---------------------------------------------------------------------------
# Case (a): existing OPEN PR + clean tree -> continue-or-start-fresh
# ---------------------------------------------------------------------------
root_a="$(make_repo)"
work_a="${root_a}/work"
fix_a="$(mktemp -d)" || { echo "mktemp -d failed" >&2; exit 1; }
cat > "${fix_a}/issue.json" <<'JSON'
{"number":42,"title":"Fix the thing","state":"OPEN","labels":[]}
JSON
cat > "${fix_a}/pr-search.json" <<'JSON'
[{"number":99,"title":"fix: the thing","state":"OPEN","headRefName":"feature/issue-42"}]
JSON
out_a="$(WORKFLOW_PREFLIGHT_NO_FETCH=1 WORKFLOW_PREFLIGHT_FIXTURE="$fix_a" \
bash "$preflight_script" --home-dir "$home_dir" --project-dir "$work_a" --issue 42)"
echo "$out_a" | grep -q "^EXISTING_PR_STATE=OPEN$" \
|| fail "(a) expected EXISTING_PR_STATE=OPEN, got:\n$out_a"
echo "$out_a" | grep -q "^RECOMMENDATION=Open PR #99 exists - continue on that branch or start fresh" \
|| fail "(a) expected continue-or-start-fresh recommendation, got:\n$out_a"
pass "(a) open PR + clean tree -> continue-or-start-fresh"
rm -rf "$root_a" "$fix_a"
# ---------------------------------------------------------------------------
# Case (b): merge conflicts detected -> "Resolve conflicts"
# ---------------------------------------------------------------------------
root_b="$(make_repo)"
work_b="${root_b}/work"
# Diverge origin/main on the SAME line the feature branch changed, so a
# merge-tree dry-run reports a real conflict.
git -C "$work_b" checkout -q main
printf 'base\nmain divergent line\n' > "${work_b}/file.txt"
git -C "$work_b" add file.txt
git -C "$work_b" commit --quiet -m "main diverges"
git -C "$work_b" push --quiet origin main
git -C "$work_b" fetch --quiet origin
git -C "$work_b" checkout -q feature/issue-42
out_b="$(WORKFLOW_PREFLIGHT_NO_FETCH=1 \
bash "$preflight_script" --home-dir "$home_dir" --project-dir "$work_b")"
echo "$out_b" | grep -q "^CONFLICTS_DETECTED=true$" \
|| fail "(b) expected CONFLICTS_DETECTED=true, got:\n$out_b"
echo "$out_b" | grep -q "^RECOMMENDATION=Resolve conflicts with origin/main" \
|| fail "(b) expected resolve-conflicts recommendation, got:\n$out_b"
pass "(b) conflicts detected -> resolve-conflicts"
rm -rf "$root_b"
# ---------------------------------------------------------------------------
# Case (c): uncommitted changes + stash present -> "Commit or stash"
# ---------------------------------------------------------------------------
root_c="$(make_repo)"
work_c="${root_c}/work"
# Create a stash, then leave an uncommitted change in the tree.
printf 'base\nfeature line\nstashed change\n' > "${work_c}/file.txt"
git -C "$work_c" stash --quiet
printf 'base\nfeature line\nuncommitted change\n' > "${work_c}/file.txt"
out_c="$(WORKFLOW_PREFLIGHT_NO_FETCH=1 \
bash "$preflight_script" --home-dir "$home_dir" --project-dir "$work_c")"
echo "$out_c" | grep -q "^UNCOMMITTED_CHANGES=true$" \
|| fail "(c) expected UNCOMMITTED_CHANGES=true, got:\n$out_c"
echo "$out_c" | grep -qE "^STASH_COUNT=[1-9]" \
|| fail "(c) expected STASH_COUNT>=1, got:\n$out_c"
echo "$out_c" | grep -q "^RECOMMENDATION=Commit or stash uncommitted changes before branching$" \
|| fail "(c) expected commit-or-stash recommendation, got:\n$out_c"
pass "(c) uncommitted + stash present -> commit-or-stash"
rm -rf "$root_c"
# ---------------------------------------------------------------------------
# Case (d): fresh branch, no PR, clean, up to date -> "Ready to proceed"
# ---------------------------------------------------------------------------
root_d="$(make_repo)"
work_d="${root_d}/work"
# Sit on main itself: clean, no divergence, no target issue.
git -C "$work_d" checkout -q main
out_d="$(WORKFLOW_PREFLIGHT_NO_FETCH=1 \
bash "$preflight_script" --home-dir "$home_dir" --project-dir "$work_d")"
echo "$out_d" | grep -q "^EXISTING_PR_STATE=NONE$" \
|| fail "(d) expected EXISTING_PR_STATE=NONE, got:\n$out_d"
echo "$out_d" | grep -q "^UNCOMMITTED_CHANGES=false$" \
|| fail "(d) expected clean tree, got:\n$out_d"
echo "$out_d" | grep -q "^CONFLICTS_DETECTED=false$" \
|| fail "(d) expected no conflicts, got:\n$out_d"
echo "$out_d" | grep -q "^RECOMMENDATION=Ready to proceed$" \
|| fail "(d) expected ready-to-proceed recommendation, got:\n$out_d"
echo "$out_d" | grep -q "^STATUS=OK$" \
|| fail "(d) expected STATUS=OK on a clean fresh tree, got:\n$out_d"
pass "(d) fresh branch, no PR, clean -> ready-to-proceed"
rm -rf "$root_d"
echo "ALL TESTS PASSED"
#!/usr/bin/env bash
# Workflow Preflight
# Deterministic pre-work validation: fetch remote, compute ahead/behind counts,
# look up an existing PR / issue / branch for the target, detect merge conflicts
# via merge-tree dry-run, inspect uncommitted + stash state, and apply the fixed
# recommendation decision tree over those booleans.
#
# The judgment-bearing step ("Continue on existing PR or start fresh?" — an
# AskUserQuestion handoff) stays in the skill; this script only emits the
# deterministic state the skill needs to decide.
#
# Usage: bash workflow-preflight.sh --home-dir <path> --project-dir <path> \
# [--issue <number>]
#
# Fixture seam (offline testing): every network/gh probe is injectable so the
# test suite runs against a planted git repo + canned gh JSON with no network.
# WORKFLOW_PREFLIGHT_NO_FETCH=1 skip `git fetch` (no network)
# WORKFLOW_PREFLIGHT_FIXTURE=DIR read canned gh JSON from DIR instead of gh:
# DIR/issue.json <- gh issue view ...
# DIR/pr-search.json <- gh pr list --search ...
# Absence of a file == empty result for that probe.
set -uo pipefail
home_dir=""
project_dir=""
preflight_issue=""
while [ $# -gt 0 ]; do
case "$1" in
--home-dir) home_dir="$2"; shift 2 ;;
--project-dir) project_dir="$2"; shift 2 ;;
--issue) preflight_issue="$2"; shift 2 ;;
*) shift ;;
esac
done
: "${home_dir:=$HOME}"
: "${project_dir:=$(pwd)}"
preflight_fixture="${WORKFLOW_PREFLIGHT_FIXTURE:-}"
preflight_no_fetch="${WORKFLOW_PREFLIGHT_NO_FETCH:-}"
echo "=== WORKFLOW PREFLIGHT ==="
issue_count=0
check_status="OK"
issues_list=""
add_issue() {
# $1 severity, $2 type, $3 message
issues_list="${issues_list} - SEVERITY=${1} TYPE=${2} MSG=${3}\n"
issue_count=$((issue_count + 1))
if [ "$1" = "ERROR" ]; then
check_status="ERROR"
elif [ "$1" = "WARN" ] && [ "$check_status" = "OK" ]; then
check_status="WARN"
fi
}
# jq drives the gh JSON parsing; without it the existing-work lookup is unsafe.
if ! command -v jq >/dev/null 2>&1; then
echo "JQ_AVAILABLE=false"
echo "STATUS=ERROR"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=ERROR TYPE=missing_tool MSG=jq is required but not installed"
echo "=== END WORKFLOW PREFLIGHT ==="
exit 1
fi
echo "JQ_AVAILABLE=true"
# Resolve the git working tree from the project dir. Everything downstream runs
# through `git -C "$project_dir"` so a moved cwd cannot leak operations.
git_dir="$(git -C "$project_dir" rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$git_dir" ]; then
echo "GIT_REPO=false"
echo "STATUS=ERROR"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=ERROR TYPE=not_a_repo MSG=${project_dir} is not inside a git repository"
echo "=== END WORKFLOW PREFLIGHT ==="
exit 1
fi
echo "GIT_REPO=true"
git_cmd() { git -C "$git_dir" "$@"; }
current_branch="$(git_cmd branch --show-current 2>/dev/null || true)"
echo "CURRENT_BRANCH=${current_branch:-DETACHED}"
# --- Step 1: Fetch latest remote state (injectable seam) ---------------------
if [ -n "$preflight_no_fetch" ]; then
echo "FETCH=SKIPPED"
else
if git_cmd fetch origin --prune >/dev/null 2>&1; then
echo "FETCH=OK"
else
echo "FETCH=FAILED"
add_issue WARN fetch_failed "git fetch origin failed; ahead/behind may be stale"
fi
fi
# --- Resolve the upstream base branch (main or master) -----------------------
base_ref=""
for candidate in origin/main origin/master; do
if git_cmd rev-parse --verify --quiet "$candidate" >/dev/null 2>&1; then
base_ref="$candidate"
break
fi
done
echo "BASE_REF=${base_ref:-NONE}"
# --- Step 3: ahead/behind counts vs base ------------------------------------
ahead=0
behind=0
if [ -n "$base_ref" ]; then
counts="$(git_cmd rev-list --left-right --count "${base_ref}...HEAD" 2>/dev/null || true)"
if [ -n "$counts" ]; then
behind="$(printf '%s\n' "$counts" | awk '{print $1}')"
ahead="$(printf '%s\n' "$counts" | awk '{print $2}')"
fi
fi
echo "COMMITS_AHEAD=${ahead:-0}"
echo "COMMITS_BEHIND=${behind:-0}"
if [ "${behind:-0}" -gt 0 ] 2>/dev/null; then
add_issue WARN behind_remote "HEAD is ${behind} commits behind ${base_ref}; rebase recommended"
fi
# --- Uncommitted + stash state ----------------------------------------------
porcelain="$(git_cmd status --porcelain 2>/dev/null || true)"
if [ -n "$porcelain" ]; then
uncommitted_count="$(printf '%s\n' "$porcelain" | grep -c .)"
echo "UNCOMMITTED_CHANGES=true"
echo "UNCOMMITTED_COUNT=${uncommitted_count}"
add_issue WARN dirty_tree "${uncommitted_count} uncommitted changes; commit or stash before branching"
else
echo "UNCOMMITTED_CHANGES=false"
echo "UNCOMMITTED_COUNT=0"
fi
stash_list="$(git_cmd stash list 2>/dev/null || true)"
if [ -n "$stash_list" ]; then
stash_count="$(printf '%s\n' "$stash_list" | grep -c .)"
else
stash_count=0
fi
echo "STASH_COUNT=${stash_count}"
# --- Step 4: merge-tree dry-run conflict detection --------------------------
conflicts_detected=false
if [ -n "$base_ref" ]; then
merge_base="$(git_cmd merge-base HEAD "$base_ref" 2>/dev/null || true)"
if [ -n "$merge_base" ]; then
merge_out="$(git_cmd merge-tree "$merge_base" HEAD "$base_ref" 2>/dev/null || true)"
# merge-tree (the trivial three-arg form) emits "changed in both" + "<<<<<<<"
# conflict markers when a path conflicts.
if printf '%s\n' "$merge_out" | grep -q '^<<<<<<<\|changed in both'; then
conflicts_detected=true
fi
fi
fi
echo "CONFLICTS_DETECTED=${conflicts_detected}"
if [ "$conflicts_detected" = true ]; then
add_issue WARN conflicts "Merge conflicts with ${base_ref} detected; resolve before proceeding"
fi
# --- Step 2: existing-work lookup (issue + PR + branch) ---------------------
existing_pr_state="NONE"
existing_pr_number=""
existing_pr_branch=""
issue_state="NONE"
matching_branches=""
# gh probes go through the fixture seam: when WORKFLOW_PREFLIGHT_FIXTURE is set,
# read canned JSON from files; otherwise call gh live.
fixture_or_gh() {
# $1 = fixture filename, rest = gh argv
local fname="$1"; shift
if [ -n "$preflight_fixture" ]; then
if [ -f "${preflight_fixture}/${fname}" ]; then
cat "${preflight_fixture}/${fname}"
else
printf ''
fi
return 0
fi
command -v gh >/dev/null 2>&1 || { printf ''; return 0; }
gh "$@" 2>/dev/null || printf ''
}
if [ -n "$preflight_issue" ]; then
echo "TARGET_ISSUE=${preflight_issue}"
issue_json="$(fixture_or_gh issue.json issue view "$preflight_issue" --json number,title,state,labels)"
if [ -n "$issue_json" ]; then
issue_state="$(printf '%s' "$issue_json" | jq -r '.state // "NONE"' 2>/dev/null || echo NONE)"
fi
echo "ISSUE_STATE=${issue_state}"
pr_json="$(fixture_or_gh pr-search.json pr list --search "fixes #${preflight_issue} OR closes #${preflight_issue} OR resolves #${preflight_issue}" --json number,title,state,headRefName)"
if [ -n "$pr_json" ]; then
# First MERGED match wins; otherwise first OPEN match.
existing_pr_state="$(printf '%s' "$pr_json" | jq -r '
([.[] | select(.state == "MERGED")] | first) as $m
| ([.[] | select(.state == "OPEN")] | first) as $o
| if $m then "MERGED" elif $o then "OPEN" else "NONE" end' 2>/dev/null || echo NONE)"
existing_pr_number="$(printf '%s' "$pr_json" | jq -r '
([.[] | select(.state == "MERGED")] | first) as $m
| ([.[] | select(.state == "OPEN")] | first) as $o
| ($m // $o // {}) | .number // ""' 2>/dev/null || echo "")"
existing_pr_branch="$(printf '%s' "$pr_json" | jq -r '
([.[] | select(.state == "MERGED")] | first) as $m
| ([.[] | select(.state == "OPEN")] | first) as $o
| ($m // $o // {}) | .headRefName // ""' 2>/dev/null || echo "")"
fi
matching_branches="$(git_cmd branch -a --list "*issue-${preflight_issue}*" --list "*fix/${preflight_issue}*" --list "*feat/${preflight_issue}*" 2>/dev/null | sed 's/^[* ] *//' | grep -c . || echo 0)"
else
echo "TARGET_ISSUE=NONE"
echo "ISSUE_STATE=NONE"
matching_branches=0
fi
echo "EXISTING_PR_STATE=${existing_pr_state}"
echo "EXISTING_PR_NUMBER=${existing_pr_number}"
echo "EXISTING_PR_BRANCH=${existing_pr_branch}"
echo "MATCHING_BRANCHES=${matching_branches}"
if [ "$existing_pr_state" = "MERGED" ]; then
add_issue WARN already_addressed "Issue already addressed by merged PR #${existing_pr_number}; stop before duplicating"
elif [ "$existing_pr_state" = "OPEN" ]; then
add_issue WARN existing_pr "Open PR #${existing_pr_number} (${existing_pr_branch}) already addresses this; review before duplicating"
fi
# --- Fixed recommendation decision tree over the booleans -------------------
# Precedence: merged PR > conflicts > open PR > behind/dirty > clean.
recommendation=""
if [ "$existing_pr_state" = "MERGED" ]; then
recommendation="STOP: merged PR #${existing_pr_number} already addresses this issue"
elif [ "$conflicts_detected" = true ]; then
recommendation="Resolve conflicts with ${base_ref} before proceeding"
elif [ "$existing_pr_state" = "OPEN" ]; then
recommendation="Open PR #${existing_pr_number} exists - continue on that branch or start fresh (ask user)"
elif [ "${behind:-0}" -gt 0 ] 2>/dev/null; then
recommendation="Rebase onto ${base_ref} before starting work (${behind} behind)"
elif [ -n "$porcelain" ]; then
recommendation="Commit or stash uncommitted changes before branching"
else
recommendation="Ready to proceed"
fi
echo "RECOMMENDATION=${recommendation}"
echo "STATUS=${check_status}"
echo "ISSUE_COUNT=${issue_count}"
if [ -n "$issues_list" ]; then
echo "ISSUES:"
echo -e "$issues_list" | sed '/^$/d'
fi
echo "=== END WORKFLOW PREFLIGHT ==="
[ "$check_status" = "ERROR" ] && exit 1
exit 0