@biffo/cli 0.236.0 → 0.238.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,208 +0,0 @@
1
- #!/usr/bin/env sh
2
- #
3
- # Is anyone already working this issue? Ask git, not a label.
4
- #
5
- # ## Why this exists
6
- #
7
- # Several agent sessions run against this estate at once, and on 2026-08-03
8
- # **four** of them collided in one morning:
9
- #
10
- # - #1165 — another session built AND MERGED a PR for it while this one was
11
- # claiming it. Three minutes, start to merge.
12
- # - #1174 — a live worktree on `fix/1174-…` existed; the issue was unlabelled.
13
- # - #621, #956 — live worktrees, no labels. Labelled on their behalf.
14
- # - #1188 — the reverse: a label with no work, while another session built it
15
- # and opened a PR.
16
- #
17
- # Three of the four were "work exists, label does not". That is the shape this
18
- # script is for.
19
- #
20
- # ## The rule it encodes
21
- #
22
- # **The `in-progress` label is a hand-maintained second copy of something git
23
- # already knows.** A branch exists. A PR exists. Those are automatic — you
24
- # cannot do the work without creating them — whereas the label is a separate
25
- # action a human or agent has to remember, in a workflow that may never have
26
- # been told to. Second copies of a decision drift; this estate says so about
27
- # `_extract_detail`, about AGENTS.md, and about the commit-msg type list.
28
- #
29
- # So this checks FOUR signals and reports all of them, rather than trusting the
30
- # one that is easiest to forget.
31
- #
32
- # ## What it cannot do
33
- #
34
- # Prevent a race. GitHub has no locking, two sessions can start in the same
35
- # second, and #1165 went from branch to merged in three minutes — no protocol
36
- # would have caught that. The goal is early, cheap detection, not exclusion.
37
- # Every collision that morning was caught before duplicate work merged; the
38
- # cost was minutes, not shipped rework.
39
- #
40
- # ## Usage
41
- #
42
- # sh scripts/claim.sh 1234 # check, and claim if free
43
- # sh scripts/claim.sh 1234 --check # report only, change nothing
44
- # sh scripts/claim.sh 1234 -R owner/repo
45
- #
46
- # 0 free — and claimed, unless --check
47
- # 1 taken, or already closed — the reason is printed
48
- # 2 cannot tell — issue unreadable, gh unauthenticated
49
- #
50
- # 2 is deliberately not 0, matching `wait-for-checks.sh` and `branch-health.sh`.
51
- # A check that cannot see its input must not report "free".
52
- #
53
- # Requires `gh`, authenticated. Uses gh's embedded jq, so no jq binary needed.
54
-
55
- set -u
56
-
57
- ISSUE=""
58
- REPO=""
59
- CHECK_ONLY=""
60
-
61
- usage() {
62
- sed -n '2,58p' "$0" | sed 's/^# \{0,1\}//'
63
- exit 2
64
- }
65
-
66
- while [ $# -gt 0 ]; do
67
- case "$1" in
68
- -R | --repo)
69
- REPO="${2:-}"
70
- shift 2
71
- ;;
72
- --check) CHECK_ONLY=1; shift ;;
73
- -h | --help) usage ;;
74
- *)
75
- ISSUE="$1"
76
- shift
77
- ;;
78
- esac
79
- done
80
-
81
- case "$ISSUE" in
82
- '' | *[!0-9]*)
83
- echo "claim: give an issue number, e.g. sh scripts/claim.sh 1234" >&2
84
- exit 2
85
- ;;
86
- esac
87
-
88
- RED=$(printf '\033[31m')
89
- GREEN=$(printf '\033[32m')
90
- YELLOW=$(printf '\033[33m')
91
- DIM=$(printf '\033[90m')
92
- OFF=$(printf '\033[0m')
93
-
94
- gh_issue() { if [ -n "$REPO" ]; then gh issue "$@" --repo "$REPO"; else gh issue "$@"; fi; }
95
- gh_pr() { if [ -n "$REPO" ]; then gh pr "$@" --repo "$REPO"; else gh pr "$@"; fi; }
96
-
97
- LABEL=in-progress
98
- TAKEN=0
99
- REASONS=""
100
-
101
- note() { REASONS="${REASONS} $1\n"; TAKEN=1; }
102
-
103
- # --- 0. Does the issue exist, and is it still open? --------------------------
104
-
105
- meta=$(gh_issue view "$ISSUE" --json state,title,labels \
106
- --jq '"\(.state)\t\(.title)\t\((.labels|map(.name)|join(",")))"' 2>/dev/null) || {
107
- echo "${RED}claim: cannot read issue #$ISSUE${OFF} — wrong repo, or gh not authenticated." >&2
108
- echo "${DIM} That is 'cannot tell', not 'free'.${OFF}" >&2
109
- exit 2
110
- }
111
-
112
- state=$(printf '%s' "$meta" | cut -f1)
113
- title=$(printf '%s' "$meta" | cut -f2)
114
- labels=$(printf '%s' "$meta" | cut -f3)
115
-
116
- echo "${DIM}#$ISSUE — $title${OFF}"
117
- echo
118
-
119
- if [ "$state" != "OPEN" ]; then
120
- echo "${RED}Already $state.${OFF} Nothing to claim."
121
- exit 1
122
- fi
123
-
124
- # --- 1. The label. Easiest to check, easiest to forget. ----------------------
125
-
126
- case ",$labels," in
127
- *",$LABEL,"*)
128
- updated=$(gh_issue view "$ISSUE" --json updatedAt --jq .updatedAt 2>/dev/null)
129
- note "${YELLOW}label${OFF} carries '$LABEL' (issue last updated $updated)"
130
- ;;
131
- esac
132
-
133
- # --- 2. An open PR that references it ----------------------------------------
134
- #
135
- # The strongest signal, because a PR cannot be opened without the work existing.
136
- # Matches the issue number in the title or body as a whole number, so #118 does
137
- # not match #1188.
138
-
139
- open_prs=$(gh_pr list --state open --limit 100 --json number,title,body,headRefName \
140
- --jq "[.[] | select(((.title + \" \" + .body) | test(\"(^|[^0-9])#$ISSUE([^0-9]|\$)\")) or (.headRefName | test(\"(^|[^0-9])$ISSUE([^0-9]|\$)\")))] | .[] | \"#\(.number) \(.headRefName)\"" 2>/dev/null)
141
-
142
- if [ -n "$open_prs" ]; then
143
- printf '%s\n' "$open_prs" | while IFS= read -r pr; do
144
- [ -n "$pr" ] && echo " ${RED}open PR${OFF} $pr"
145
- done
146
- note "${RED}open PR${OFF} see above — someone has working code"
147
- fi
148
-
149
- # --- 3. A remote branch naming it --------------------------------------------
150
- #
151
- # Catches work that has been pushed but has no PR yet. Whole-number match again.
152
-
153
- branches=$(git ls-remote --heads "${REPO:+https://github.com/$REPO.git}" 2>/dev/null |
154
- sed 's|.*refs/heads/||' |
155
- grep -E "(^|[^0-9])$ISSUE([^0-9]|$)" 2>/dev/null)
156
-
157
- if [ -n "$branches" ]; then
158
- printf '%s\n' "$branches" | while IFS= read -r b; do
159
- [ -n "$b" ] && echo " ${RED}branch${OFF} $b"
160
- done
161
- note "${RED}branch${OFF} a remote branch names this issue"
162
- fi
163
-
164
- # --- 4. A recently merged PR that already closed it --------------------------
165
- #
166
- # Not "taken" — "possibly already done". #1165 was built and merged in three
167
- # minutes; the only trace afterwards is a merged PR.
168
-
169
- merged=$(gh_pr list --state merged --limit 30 --json number,title,body,mergedAt \
170
- --jq "[.[] | select((.title + \" \" + .body) | test(\"(^|[^0-9])#$ISSUE([^0-9]|\$)\"))] | .[0] | select(. != null) | \"#\(.number) merged \(.mergedAt[0:16])\"" 2>/dev/null)
171
-
172
- if [ -n "$merged" ]; then
173
- echo " ${YELLOW}merged${OFF} $merged"
174
- echo " ${DIM} the issue is still open, but work referencing it has landed —${OFF}"
175
- echo " ${DIM} read it before rebuilding.${OFF}"
176
- fi
177
-
178
- # --- Verdict ------------------------------------------------------------------
179
-
180
- echo
181
- if [ "$TAKEN" -eq 1 ]; then
182
- printf '%b' "${RED}Taken.${OFF} Signals:\n$REASONS"
183
- echo
184
- echo "${DIM}If you believe it is abandoned, check how old the work is and say so in a${OFF}"
185
- echo "${DIM}comment before taking it. Never steal a fresh claim.${OFF}"
186
- exit 1
187
- fi
188
-
189
- if [ -n "$CHECK_ONLY" ]; then
190
- echo "${GREEN}Free.${OFF} ${DIM}(--check: nothing changed)${OFF}"
191
- exit 0
192
- fi
193
-
194
- # Claim it. Label AND comment together: the label is what other sessions filter
195
- # on, the comment is what dates it so a stale claim can be recognised later.
196
- gh_issue edit "$ISSUE" --add-label "$LABEL" >/dev/null 2>&1 || {
197
- echo "${RED}claim: could not apply the '$LABEL' label.${OFF}" >&2
198
- echo "${DIM} Not claimed. Do not start work on the assumption that it worked.${OFF}" >&2
199
- exit 2
200
- }
201
- gh_issue comment "$ISSUE" \
202
- --body "Claimed at $(date -u +%FT%TZ) by \`$(git config user.name 2>/dev/null || echo agent)\`. Release it — remove the label — on merge, or if you stop." \
203
- >/dev/null 2>&1
204
-
205
- echo "${GREEN}Claimed.${OFF}"
206
- echo "${DIM}Push your branch as soon as it exists: a claim is a reservation, the branch${OFF}"
207
- echo "${DIM}is the evidence, and the window between them is where collisions happen.${OFF}"
208
- exit 0
@@ -1,145 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # Is a git hook actually going to execute here?
4
- #
5
- # ## Why this exists
6
- #
7
- # A configured hook that does not run is worse than no hook, because it is
8
- # assumed to be protecting you. On 2026-07-29 the estate was in exactly that
9
- # state: `core.hooksPath` pointed at `.husky/_`, a **gitignored** directory
10
- # created only by `prepare: husky` on `pnpm install` — and git resolves that
11
- # relative path against *each worktree's* root. Every fresh worktree therefore
12
- # had no hooks, and git said nothing: no warning, no error, no output.
13
- #
14
- # AGENTS.md §1 mandates a fresh worktree per unit of work, so the required
15
- # workflow disarmed its own gates. 6 of 32 working trees were armed. The pre-push
16
- # pyright, the pre-commit lint-staged and commitlint had all been silently
17
- # skipped in the other 26 for as long as anyone had been using worktrees.
18
- #
19
- # Nothing detected it because nothing looked. This is the thing that looks.
20
- #
21
- # ## Verdicts
22
- #
23
- # ARMED git will execute a hook here.
24
- # DEAD core.hooksPath is set and its target is missing or carries no
25
- # hooks. Git skips silently. **This is the state that lies to you**,
26
- # and the only one that makes this script exit non-zero.
27
- # NO-HOOKS no hooks configured at all. Honest, and visible in this report.
28
- #
29
- # Usage:
30
- # sh scripts/hook-audit.sh # this repo and its worktrees
31
- # sh scripts/hook-audit.sh --estate ~/code # every repo under a directory
32
- # sh scripts/hook-audit.sh --quiet # verdict counts only
33
-
34
- set -uo pipefail
35
-
36
- ESTATE=""
37
- QUIET=""
38
- while [ $# -gt 0 ]; do
39
- case "$1" in
40
- --estate) ESTATE="$2"; shift 2 ;;
41
- --quiet) QUIET=1; shift ;;
42
- *) echo "unknown argument: $1" >&2; exit 2 ;;
43
- esac
44
- done
45
-
46
- armed=0
47
- dead=0
48
- nohooks=0
49
- dead_list=""
50
-
51
- # The three hooks this standard cares about. A hooksPath directory containing
52
- # none of them is not armed for our purposes even if it holds something else.
53
- WANTED='^(pre-commit|pre-push|commit-msg)$'
54
-
55
- report() {
56
- tree="$1"
57
- label="$2"
58
- hp=$(git -C "$tree" config core.hooksPath 2>/dev/null)
59
-
60
- if [ -z "$hp" ]; then
61
- # The default hooks directory is in the **common** git dir, which linked
62
- # worktrees share. Reading "$tree/.git/hooks" is wrong for exactly the trees
63
- # this audit exists to check: in a linked worktree `.git` is a *file*
64
- # containing a gitdir pointer, so that path does not exist and every armed
65
- # worktree was about to be reported NO-HOOKS. Ask git where it actually is.
66
- #
67
- # Git ships .sample files there that never execute, so counting the
68
- # directory as armed merely for being non-empty would be exactly the false
69
- # comfort this script exists to remove.
70
- common=$(git -C "$tree" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
71
- real=$(ls "${common:-$tree/.git}/hooks" 2>/dev/null | grep -vc '\.sample$' || true)
72
- if [ "${real:-0}" -gt 0 ]; then
73
- armed=$((armed + 1))
74
- [ -n "$QUIET" ] || printf '%-56s %-14s \033[32mARMED\033[0m %s\n' "$label" "(default)" "$real hook(s) in .git/hooks"
75
- else
76
- nohooks=$((nohooks + 1))
77
- [ -n "$QUIET" ] || printf '%-56s %-14s \033[33mNO-HOOKS\033[0m %s\n' "$label" "(default)" "no hooks configured"
78
- fi
79
- return
80
- fi
81
-
82
- # Relative hooksPath resolves against the working tree root — the whole bug.
83
- case "$hp" in
84
- /*) dir="$hp" ;;
85
- *) dir="$tree/$hp" ;;
86
- esac
87
-
88
- present=$(ls "$dir" 2>/dev/null | grep -E "$WANTED" | tr '\n' ',' || true)
89
- if [ -z "$present" ]; then
90
- dead=$((dead + 1))
91
- dead_list="$dead_list $label ($hp)
92
- "
93
- [ -n "$QUIET" ] || printf '%-56s %-14s \033[31mDEAD\033[0m %s\n' "$label" "$hp" "$hp missing or holds no hooks — git skips ALL hooks silently"
94
- else
95
- armed=$((armed + 1))
96
- [ -n "$QUIET" ] || printf '%-56s %-14s \033[32mARMED\033[0m %s\n' "$label" "$hp" "${present%,}"
97
- fi
98
- }
99
-
100
- walk_repo() {
101
- root="${1%/}"
102
- name="$2"
103
- # Every working tree, not just the primary — the primary is usually the one
104
- # that IS armed, which is how this went unnoticed for so long.
105
- git -C "$root" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | while read -r t; do
106
- [ -d "$t" ] || continue
107
- if [ "$t" = "$root" ]; then echo "$t|$name"; else echo "$t|$name${t#$root}"; fi
108
- done
109
- }
110
-
111
- [ -n "$QUIET" ] || printf '%-56s %-14s %-8s %s\n' "WORKING TREE" "hooksPath" "VERDICT" "detail"
112
-
113
- if [ -n "$ESTATE" ]; then
114
- targets=$(for d in "$ESTATE"/*/; do
115
- [ -e "$d/.git" ] || continue
116
- walk_repo "$d" "$(basename "${d%/}")"
117
- done)
118
- else
119
- root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "not a git repo" >&2; exit 2; }
120
- # From inside a worktree, --show-toplevel gives the worktree; walk from the
121
- # common repo so sibling worktrees are audited too.
122
- common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
123
- root=$(dirname "$common")
124
- targets=$(walk_repo "$root" "$(basename "$root")")
125
- fi
126
-
127
- while IFS='|' read -r tree label; do
128
- [ -n "$tree" ] || continue
129
- report "$tree" "$label"
130
- done <<EOF
131
- $targets
132
- EOF
133
-
134
- total=$((armed + dead + nohooks))
135
- printf '\n%s working trees — \033[32m%s armed\033[0m, \033[31m%s dead\033[0m, %s without hooks' "$total" "$armed" "$dead" "$nohooks"
136
- [ "$total" -gt 0 ] && printf ' (%s%% armed)' "$((100 * armed / total))"
137
- printf '\n'
138
-
139
- if [ "$dead" -gt 0 ]; then
140
- printf '\n\033[31mDEAD working trees — hooks are configured here and are NOT running:\033[0m\n%s' "$dead_list"
141
- printf 'Every commit and push made in these is unguarded, and nothing says so.\n'
142
- printf 'Fix: run `pnpm install` there, or move the repo to tracked .githooks/.\n'
143
- exit 1
144
- fi
145
- exit 0
@@ -1,275 +0,0 @@
1
- #!/usr/bin/env sh
2
- #
3
- # Give the Postgres-dependent test lane a database with a CURRENT schema, and
4
- # print its DSN.
5
- #
6
- # ## Why this exists
7
- #
8
- # `scripts/verify.sh` grew a `pg-test` check (#1089) because on 2026-08-02 nine
9
- # of thirteen locally-catchable failing CI steps across the estate were one
10
- # repo's real-Postgres lane -- a required check with no local counterpart at all.
11
- # But a gate can only run that lane against a database, and no repo documented
12
- # how to get one: no compose file, no script, no DSN written down. The container
13
- # that existed on the workstation had been created ad hoc in some earlier session
14
- # and held a scatter of scratch databases. That undocumented setup WAS the
15
- # fail-open, because a gate nobody can run is not a gate.
16
- #
17
- # ## Why freshness, not rebuild-every-time
18
- #
19
- # The expensive failure is not a slow rebuild, it is a STALE one. Measured on
20
- # tabsii-platform while writing this: a database built about an hour earlier,
21
- # before two PRs merged, produced **23 failures** in a module that had nothing to
22
- # do with the change in hand. Rebuilt from the same tree it passed 336/336, and
23
- # passed 336 again on an immediate re-run -- so the lane was genuinely
24
- # re-runnable and every one of those failures was the old schema.
25
- #
26
- # That is the worst shape a local gate can have. Twenty-three red tests that are
27
- # not your fault teach people the gate is unreliable, and an unreliable gate gets
28
- # bypassed -- which H4 pre-registered as the condition refuting the whole
29
- # local-gate programme. So the schema inputs are fingerprinted and a rebuild
30
- # happens only when they actually changed: reuse ~0.3s, rebuild ~4s.
31
- #
32
- # ## Why it is generic
33
- #
34
- # It adapts to the repo rather than being told about it, for the same reason
35
- # `verify.sh` does: forks drift, and a per-instance copy of this would drift from
36
- # the DDL layout it is meant to build. Everything instance-specific is DERIVED --
37
- # the schema directories from `db/imports/*/`, the engine image from whether the
38
- # DDL asks for PostGIS, and the did-it-build threshold from the number of
39
- # policies the DDL itself declares. Nothing here names a product.
40
- #
41
- # ## Usage
42
- #
43
- # eval "$(sh scripts/pg-test-db.sh --export)" # export BIFFO_TEST_PG_DSN
44
- # sh scripts/pg-test-db.sh # print the DSN on stdout
45
- # sh scripts/pg-test-db.sh --recreate # force a rebuild
46
- #
47
- # Only the DSN reaches stdout, so it is safe to capture; progress goes to stderr.
48
- #
49
- # Overridable: BIFFO_PG_HOST, BIFFO_PG_PORT, BIFFO_PG_USER, BIFFO_PG_PASSWORD,
50
- # BIFFO_PG_DB, BIFFO_PG_CONTAINER, BIFFO_PG_IMAGE.
51
- #
52
- # ## Concurrency: derived, not coordinated
53
- #
54
- # The port, database name, and container name below default to values DERIVED
55
- # from this checkout's own path, not fixed constants. A fixed port
56
- # (`BIFFO_PG_PORT` used to default to `55432` everywhere) meant any two
57
- # checkouts running this script at the same time -- two worktrees of one repo,
58
- # or two entirely different repos on one machine -- attached to the SAME
59
- # Postgres cluster and raced on cluster-wide catalogs like `pg_shdepend`
60
- # (#1114). A fixed database name (`biffo_test`) meant they then shared one
61
- # database on top of that (#1120).
62
- #
63
- # The fix is not a lock: a machine-wide `flock` around this script would make
64
- # concurrent runs correct by serializing them, but that is coordination, and
65
- # this estate's shared-mutable-state defects have twice been solved instead by
66
- # making the value itself unique per user rather than making users take turns.
67
- # Deriving from `$REPO_ROOT` gets that for free -- it is already different for
68
- # every worktree and every repo -- and deterministically, so repeat runs
69
- # against the SAME checkout still land on the same port/db/container and reuse
70
- # the fingerprinted schema (see step 2 below) instead of rebuilding it under a
71
- # fresh identity every time. `BIFFO_PG_PORT`, `BIFFO_PG_DB`, and
72
- # `BIFFO_PG_CONTAINER` remain explicit overrides; only the *default* changed.
73
-
74
- set -eu
75
-
76
- REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
77
-
78
- # sha256sum is already a dependency of this script (see `fingerprint` below),
79
- # so reusing it here for a deterministic, cheap per-checkout key adds nothing
80
- # new to install. 12 hex chars is ample to keep collisions between checkouts
81
- # on one machine practically impossible while staying short enough to read in
82
- # `docker ps` output and a psql prompt.
83
- _checkout_key=$(printf '%s' "$REPO_ROOT" | sha256sum | cut -c1-12)
84
- _checkout_suffix=$(printf '%s' "$_checkout_key" | cut -c1-8)
85
- # IANA's dynamic/private port range (49152-65535, 16384 ports) mapped from the
86
- # next 4 hex chars of the same hash -- an ephemeral port picked deterministically
87
- # rather than asked of the OS, because a freshly-random port on every invocation
88
- # would break the reuse this key is for (a second run against the same checkout
89
- # has to land back on the same container to find its existing database).
90
- _checkout_port=$((49152 + (0x$(printf '%s' "$_checkout_key" | cut -c9-12) % 16384)))
91
-
92
- HOST="${BIFFO_PG_HOST:-localhost}"
93
- PORT="${BIFFO_PG_PORT:-$_checkout_port}"
94
- USER_="${BIFFO_PG_USER:-postgres}"
95
- PASS="${BIFFO_PG_PASSWORD:-postgres}"
96
- DB="${BIFFO_PG_DB:-biffo_test_$_checkout_suffix}"
97
- # Keyed the same way as PORT and for the same reason: the container is where
98
- # the port mapping actually lives (`docker run -p "$PORT:5432"`), so if the
99
- # container name stayed fixed while the port became per-checkout, a second
100
- # checkout would find the first checkout's container already occupying that
101
- # name, "start" it rather than create its own, and then poll forever against a
102
- # port that container was never bound to.
103
- CONTAINER="${BIFFO_PG_CONTAINER:-biffo-pg-test-$_checkout_suffix}"
104
-
105
- RECREATE=0
106
- EXPORT=0
107
- for arg in "$@"; do
108
- case "$arg" in
109
- --recreate) RECREATE=1 ;;
110
- --export) EXPORT=1 ;;
111
- -h | --help)
112
- sed -n '2,72p' "$0" | sed 's/^#\{1,2\} \{0,1\}//'
113
- exit 0
114
- ;;
115
- *)
116
- echo "unknown argument: $arg" >&2
117
- exit 2
118
- ;;
119
- esac
120
- done
121
-
122
- say() { echo "pg-test-db: $*" >&2; }
123
-
124
- cd "$REPO_ROOT"
125
-
126
- # --- what this repo's schema is made of --------------------------------------
127
- #
128
- # `db/imports/<name>/*.sql` is the Biffo DDL-import convention that the API's own
129
- # `ddl_import.list_sql_files` reads at startup, so deriving from it means this
130
- # script and the running app agree by construction rather than by someone
131
- # remembering to update both.
132
- DDL_FILES=$(find db/imports -mindepth 2 -maxdepth 2 -name '*.sql' 2>/dev/null | LC_ALL=C sort || true)
133
- ALEMBIC_DIR=""
134
- for _d in services/api .; do
135
- [ -f "$_d/alembic.ini" ] && ALEMBIC_DIR="$_d" && break
136
- done
137
-
138
- if [ -z "$DDL_FILES" ] && [ -z "$ALEMBIC_DIR" ]; then
139
- say "no db/imports/*/ DDL and no alembic.ini - this repo has no schema to build"
140
- exit 1
141
- fi
142
-
143
- # PostGIS or plain, decided by what the DDL asks for. A plain `postgres` image
144
- # fails on the first `CREATE EXTENSION postgis`, and picking the heavier image
145
- # unconditionally would slow every repo that does not need it.
146
- if [ -n "$DDL_FILES" ] && echo "$DDL_FILES" | xargs grep -liE 'EXTENSION[[:space:]]+(IF[[:space:]]+NOT[[:space:]]+EXISTS[[:space:]]+)?postgis' >/dev/null 2>&1; then
147
- IMAGE="${BIFFO_PG_IMAGE:-postgis/postgis:16-3.4}"
148
- else
149
- IMAGE="${BIFFO_PG_IMAGE:-postgres:16}"
150
- fi
151
-
152
- export PGPASSWORD="$PASS"
153
- psql_admin() { psql -q -h "$HOST" -p "$PORT" -U "$USER_" -d postgres "$@"; }
154
- psql_db() { psql -q -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" "$@"; }
155
-
156
- # --- 1. a reachable server ---------------------------------------------------
157
- #
158
- # Started here rather than assumed, because "docker run one yourself" is exactly
159
- # the tribal knowledge this script replaces. An already-running server is reused.
160
- if ! psql_admin -c 'SELECT 1' >/dev/null 2>&1; then
161
- if ! command -v docker >/dev/null 2>&1; then
162
- say "no Postgres at $HOST:$PORT and docker is not installed."
163
- say "Start one and re-run, or set BIFFO_PG_HOST / BIFFO_PG_PORT."
164
- exit 1
165
- fi
166
- if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
167
- say "starting existing container $CONTAINER"
168
- docker start "$CONTAINER" >/dev/null
169
- else
170
- say "creating container $CONTAINER ($IMAGE) on port $PORT"
171
- docker run -d --name "$CONTAINER" \
172
- -e POSTGRES_PASSWORD="$PASS" -p "$PORT:5432" "$IMAGE" >/dev/null
173
- fi
174
- # Polled, not slept: a cold image pull and a warm restart differ by an order of
175
- # magnitude, and one fixed sleep is wrong for both.
176
- _waited=0
177
- until psql_admin -c 'SELECT 1' >/dev/null 2>&1; do
178
- _waited=$((_waited + 1))
179
- if [ "$_waited" -gt 90 ]; then
180
- say "Postgres did not become ready in 90s"
181
- exit 1
182
- fi
183
- sleep 1
184
- done
185
- say "Postgres ready after ${_waited}s"
186
- fi
187
-
188
- DSN="postgresql+asyncpg://$USER_:$PASS@$HOST:$PORT/$DB"
189
- emit() {
190
- if [ "$EXPORT" -eq 1 ]; then
191
- echo "export BIFFO_TEST_PG_DSN='$DSN'"
192
- else
193
- echo "$DSN"
194
- fi
195
- }
196
-
197
- # --- 2. is the existing schema current? --------------------------------------
198
- #
199
- # By CONTENT, not mtime: a branch switch changes content and leaves mtime
200
- # anywhere. Stored inside the database, so it cannot outlive a drop or describe
201
- # some other database.
202
- fingerprint() {
203
- {
204
- [ -n "$ALEMBIC_DIR" ] && find "$ALEMBIC_DIR" -name '*.py' -path '*alembic*' -type f 2>/dev/null |
205
- LC_ALL=C sort | xargs cat 2>/dev/null
206
- [ -n "$DDL_FILES" ] && echo "$DDL_FILES" | xargs cat 2>/dev/null
207
- } | sha256sum | cut -d' ' -f1
208
- }
209
-
210
- WANT=$(fingerprint)
211
- HAVE=""
212
- if [ "$RECREATE" -eq 0 ] &&
213
- psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname='$DB'" 2>/dev/null | grep -q 1; then
214
- HAVE=$(psql -tAq -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
215
- -c "SELECT value FROM biffo_pg_test_fingerprint LIMIT 1" 2>/dev/null || true)
216
- fi
217
-
218
- if [ -n "$HAVE" ] && [ "$HAVE" = "$WANT" ]; then
219
- say "schema is current, reusing $DB"
220
- emit
221
- exit 0
222
- fi
223
-
224
- [ -n "$HAVE" ] && say "schema inputs changed - rebuilding rather than serving a stale schema"
225
-
226
- # --- 3. rebuild the way the app and CI do ------------------------------------
227
- say "rebuilding $DB"
228
- psql_admin -c "DROP DATABASE IF EXISTS $DB WITH (FORCE)" >/dev/null
229
- psql_admin -c "CREATE DATABASE $DB" >/dev/null
230
-
231
- if [ -n "$ALEMBIC_DIR" ]; then
232
- BIFFO_DATABASE_URL="$DSN" uv run --directory "$ALEMBIC_DIR" alembic upgrade head >/dev/null
233
- say "alembic upgrade head"
234
- fi
235
-
236
- if [ -n "$DDL_FILES" ]; then
237
- # ONE psql session, sorted by filename, mirroring the API's own DDL import.
238
- # Session state an early module sets -- typically `SET search_path` in the
239
- # first file -- has to survive into later ones, so a per-file connection would
240
- # silently change the meaning of every unqualified name after it. LC_ALL=C
241
- # keeps the shell's sort byte-ordered to match Python's.
242
- # shellcheck disable=SC2046
243
- psql -q -v ON_ERROR_STOP=1 -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
244
- --single-transaction $(echo "$DDL_FILES" | sed 's/^/-f /' | tr '\n' ' ') >/dev/null
245
- say "$(echo "$DDL_FILES" | wc -l | tr -d ' ') DDL modules applied"
246
- fi
247
-
248
- # --- 4. refuse to bless a half-built schema ----------------------------------
249
- #
250
- # The threshold is derived, not guessed: count the policies the DDL declares and
251
- # require the database to hold at least half. Recording a fingerprint against a
252
- # partial schema is worse than failing, because the NEXT run would trust it and
253
- # every failure after that would look like the developer's own change.
254
- if [ -n "$DDL_FILES" ]; then
255
- _declared=$(echo "$DDL_FILES" | xargs grep -ciE '^[[:space:]]*CREATE[[:space:]]+POLICY' 2>/dev/null |
256
- awk -F: '{s+=$NF} END {print s+0}')
257
- if [ "${_declared:-0}" -gt 0 ]; then
258
- _actual=$(psql -tAq -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
259
- -c "SELECT count(*) FROM pg_policies" 2>/dev/null || echo 0)
260
- if [ "${_actual:-0}" -lt $((_declared / 2)) ]; then
261
- say "only ${_actual:-0} policies present against $_declared declared - the schema did not build."
262
- say "Not recording a fingerprint; fix the DDL and re-run."
263
- exit 1
264
- fi
265
- say "$_actual RLS policies ($_declared declared)"
266
- fi
267
- fi
268
-
269
- psql_db \
270
- -c "CREATE TABLE IF NOT EXISTS biffo_pg_test_fingerprint (value text primary key)" \
271
- -c "TRUNCATE biffo_pg_test_fingerprint" \
272
- -c "INSERT INTO biffo_pg_test_fingerprint (value) VALUES ('$WANT')" >/dev/null
273
-
274
- say "ready"
275
- emit