|
1 | | -#!/bin/sh |
| 1 | +#!/bin/bash |
2 | 2 |
|
3 | 3 | # Check our project: formatting, linting, testing, building, etc. |
4 | 4 | # Good to call this from .git/hooks/pre-commit |
| 5 | +# Runs checks in parallel for speed. |
5 | 6 |
|
6 | 7 | # Important: run with `uv run` to setup the environment |
7 | 8 |
|
8 | | -set -e |
| 9 | +set -euo pipefail |
9 | 10 |
|
10 | 11 | # Parse command line arguments |
11 | | -# --staged-only is useful to only run checks on the types of files that are staged for commit, speeding up pre-commit hooks |
| 12 | +# --staged-only: only run checks on the types of files that are staged for commit |
| 13 | +# --agent-mode: only print output for failed checks (token-friendly for AI agents) |
12 | 14 | staged_only=false |
13 | | -for arg in "$@"; do |
14 | | - case $arg in |
| 15 | +agent_mode=false |
| 16 | +while [[ $# -gt 0 ]]; do |
| 17 | + case $1 in |
15 | 18 | --staged-only) |
16 | 19 | staged_only=true |
17 | | - shift |
| 20 | + ;; |
| 21 | + --agent-mode) |
| 22 | + agent_mode=true |
18 | 23 | ;; |
19 | 24 | *) |
20 | | - echo "Unknown option: $arg" |
21 | | - echo "Usage: $0 [--staged-only]" |
| 25 | + echo "Unknown option: $1" |
| 26 | + echo "Usage: $0 [--staged-only] [--agent-mode]" |
22 | 27 | exit 1 |
23 | 28 | ;; |
24 | 29 | esac |
| 30 | + shift |
25 | 31 | done |
26 | 32 |
|
27 | 33 | # work from the root of the repo |
28 | 34 | cd "$(dirname "$0")" |
29 | | -echo $PWD |
30 | 35 |
|
31 | | -headerStart="\n\033[4;34m=== " |
32 | | -headerEnd=" ===\033[0m\n" |
| 36 | +# Create temp dir for check outputs, clean up on exit |
| 37 | +tmp_dir=$(mktemp -d) |
| 38 | +trap 'rm -rf "$tmp_dir"' EXIT |
| 39 | + |
| 40 | +# ── Parallel check runner ──────────────────────────────────────────── |
| 41 | + |
| 42 | +declare -a check_names=() |
| 43 | +declare -a check_pids=() |
| 44 | +declare -a failed_names=() |
| 45 | + |
| 46 | +# Start a named check running in the background. |
| 47 | +# Usage: start_check "name" command arg1 arg2 ... |
| 48 | +start_check() { |
| 49 | + local name="$1"; shift |
| 50 | + check_names+=("$name") |
| 51 | + "$@" > "$tmp_dir/$name.out" 2>&1 & |
| 52 | + check_pids+=($!) |
| 53 | +} |
| 54 | + |
| 55 | +# Wait for all checks and report results. |
| 56 | +# Shows a spinner with live status while checks are running (skipped in agent mode). |
| 57 | +# Returns 0 if all passed, 1 if any failed. |
| 58 | +wait_for_checks() { |
| 59 | + local any_failed=0 |
| 60 | + local green="\033[32m" red="\033[31m" dim="\033[2m" reset="\033[0m" |
| 61 | + local total=${#check_pids[@]} |
| 62 | + local spin_chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' |
| 63 | + local interactive=false |
| 64 | + if [ "$agent_mode" = false ] && [ -t 1 ]; then |
| 65 | + interactive=true |
| 66 | + fi |
| 67 | + |
| 68 | + # Track per-check status: empty=running, 0=pass, non-zero=fail |
| 69 | + declare -a check_status=() |
| 70 | + for i in "${!check_pids[@]}"; do |
| 71 | + check_status[$i]="" |
| 72 | + done |
| 73 | + |
| 74 | + # Print a result line for a completed check. In interactive mode, clears |
| 75 | + # the spinner line first so the result is on its own permanent line, |
| 76 | + # then redraws the spinner below it. |
| 77 | + print_result() { |
| 78 | + local name="$1" status="$2" |
| 79 | + if [ "$status" -ne 0 ]; then |
| 80 | + any_failed=1 |
| 81 | + failed_names+=("$name") |
| 82 | + if [ "$interactive" = true ]; then |
| 83 | + printf "\r\033[K" |
| 84 | + fi |
| 85 | + echo -e "${red}✗ FAIL: ${name}${reset}" |
| 86 | + cat "$tmp_dir/$name.out" |
| 87 | + echo "" |
| 88 | + else |
| 89 | + if [ "$interactive" = true ]; then |
| 90 | + printf "\r\033[K" |
| 91 | + echo -e "${green}✓ PASS: ${name}${reset}" |
| 92 | + fi |
| 93 | + fi |
| 94 | + } |
| 95 | + |
| 96 | + if [ "$agent_mode" = false ] && [ -t 1 ]; then |
| 97 | + # Poll loop: print results as they arrive, spinner on last line |
| 98 | + local spin_idx=0 |
| 99 | + local done_count=0 |
| 100 | + while true; do |
| 101 | + for i in "${!check_pids[@]}"; do |
| 102 | + if [ -z "${check_status[$i]}" ]; then |
| 103 | + if ! kill -0 "${check_pids[$i]}" 2>/dev/null; then |
| 104 | + wait "${check_pids[$i]}" 2>/dev/null && check_status[$i]=0 || check_status[$i]=$? |
| 105 | + done_count=$((done_count + 1)) |
| 106 | + print_result "${check_names[$i]}" "${check_status[$i]}" |
| 107 | + fi |
| 108 | + fi |
| 109 | + done |
| 110 | + |
| 111 | + [ "$done_count" -eq "$total" ] && break |
| 112 | + |
| 113 | + local spinner="${spin_chars:$spin_idx:1}" |
| 114 | + spin_idx=$(( (spin_idx + 1) % ${#spin_chars} )) |
| 115 | + local remaining=$((total - done_count)) |
| 116 | + printf "\r\033[K%s ${dim}running — %d remaining...${reset}" \ |
| 117 | + "$spinner" "$remaining" |
| 118 | + sleep 0.1 |
| 119 | + done |
| 120 | + else |
| 121 | + # Agent mode: wait quietly, print only failures |
| 122 | + for i in "${!check_pids[@]}"; do |
| 123 | + wait "${check_pids[$i]}" 2>/dev/null && check_status[$i]=0 || check_status[$i]=$? |
| 124 | + print_result "${check_names[$i]}" "${check_status[$i]}" |
| 125 | + done |
| 126 | + fi |
33 | 127 |
|
34 | | -echo "${headerStart}Checking Python: uv run ruff check ${headerEnd}" |
35 | | -uv run ruff check |
| 128 | + # Reset for next batch |
| 129 | + check_names=() |
| 130 | + check_pids=() |
36 | 131 |
|
37 | | -echo "${headerStart}Checking Python: uv run ruff format --check ${headerEnd}" |
38 | | -uv run ruff format --check . |
| 132 | + return $any_failed |
| 133 | +} |
39 | 134 |
|
40 | | -echo "${headerStart}Checking Python Types: uv run ty check${headerEnd}" |
41 | | -uv run ty check |
| 135 | +# ── Kick off all checks ────────────────────────────────────────────── |
42 | 136 |
|
43 | | -echo "${headerStart}Checking for Misspellings${headerEnd}" |
| 137 | +changed_files="" |
| 138 | +if [ "$staged_only" = true ]; then |
| 139 | + changed_files=$(git diff --name-only --staged) |
| 140 | +fi |
| 141 | + |
| 142 | +# Python checks (always run) |
| 143 | +start_check "ruff check" uv run ruff check |
| 144 | +start_check "ruff format" uv run ruff format --check . |
| 145 | +start_check "ty check" uv run ty check |
| 146 | + |
| 147 | +# Misspelling check |
44 | 148 | if command -v misspell >/dev/null 2>&1; then |
45 | | - find . -type f | grep -v "/node_modules/" | grep -v "/\." | grep -v "/dist/" | grep -v "/desktop/build/" | grep -v "/app/web_ui/build/" | xargs misspell -error |
46 | | - echo "No misspellings found" |
| 149 | + start_check "misspell" bash -c 'find . -type f -not -path "*/node_modules/*" -not -path "*/\.*" -not -path "*/dist/*" -not -path "*/desktop/build/*" -not -path "*/app/web_ui/build/*" -print0 | xargs -0 misspell -error' |
47 | 150 | else |
48 | | - echo "\033[31mWarning: misspell command not found. Skipping misspelling check.\033[0m" |
49 | | - echo "\033[31mTo install follow the instructions at https://github.com/golangci/misspell \033[0m" |
| 151 | + echo -e "\033[33mWarning: misspell not found, skipping. Install: https://github.com/golangci/misspell\033[0m" |
50 | 152 | fi |
51 | 153 |
|
52 | | -echo "${headerStart}OpenAPI Schema Check${headerEnd}" |
53 | | -cd app/web_ui/src/lib/ |
54 | | -./check_schema.sh --allow-skip |
55 | | -cd ../../../.. |
| 154 | +# OpenAPI schema check |
| 155 | +start_check "openapi schema" bash -c 'cd app/web_ui/src/lib/ && ./check_schema.sh --allow-skip' |
56 | 156 |
|
57 | | -echo "${headerStart}Web UI: format, lint, check${headerEnd}" |
58 | | -changed_files=$(git diff --name-only --staged) |
| 157 | +# Web UI checks |
59 | 158 | if [ "$staged_only" = false ] || [[ "$changed_files" == *"app/web_ui/"* ]]; then |
60 | | - echo "${headerStart}Checking Web UI: format, lint, check${headerEnd}" |
61 | | - cd app/web_ui |
62 | | - npm run format_check |
63 | | - npm run lint |
64 | | - npm run check |
65 | | - npm run test_run |
66 | | - echo "Running vite build" |
67 | | - npm run build > /dev/null |
68 | | - cd ../.. |
69 | | -else |
70 | | - echo "Skipping Web UI: no files changed" |
| 159 | + start_check "web format" bash -c 'cd app/web_ui && npm run format_check' |
| 160 | + start_check "web lint" bash -c 'cd app/web_ui && npm run lint' |
| 161 | + start_check "web check" bash -c 'cd app/web_ui && npm run check' |
| 162 | + start_check "web test" bash -c 'cd app/web_ui && npm run test_run' |
| 163 | + start_check "web build" bash -c 'cd app/web_ui && npm run build' |
71 | 164 | fi |
72 | 165 |
|
73 | | -# Check if python files were changed, and run tests if so |
| 166 | +# Python tests |
74 | 167 | if [ "$staged_only" = false ] || echo "$changed_files" | grep -q "\.py$"; then |
75 | | - echo "${headerStart}Running Python Tests${headerEnd}" |
76 | | - python3 -m pytest --benchmark-quiet -q -n auto . |
77 | | -else |
78 | | - echo "${headerStart}Python Checks${headerEnd}" |
79 | | - echo "Skipping Python tests/typecheck: no .py files changed" |
| 168 | + start_check "python tests" python3 -m pytest --benchmark-quiet -q -n auto . |
| 169 | +fi |
| 170 | + |
| 171 | +# ── Wait and summarize ──────────────────────────────────────────────── |
| 172 | + |
| 173 | +if ! wait_for_checks; then |
| 174 | + failed_list=$(IFS=','; echo "${failed_names[*]}" | sed 's/,/, /g') |
| 175 | + echo -e "\n\033[31mSome checks failed: ${failed_list}\033[0m" |
| 176 | + exit 1 |
| 177 | +fi |
| 178 | + |
| 179 | +if [ "$agent_mode" = false ] && [ -t 1 ]; then |
| 180 | + echo -e "\n\033[32mAll checks passed.\033[0m" |
80 | 181 | fi |
0 commit comments