-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathlefthook.yml
More file actions
318 lines (292 loc) · 14.7 KB
/
Copy pathlefthook.yml
File metadata and controls
318 lines (292 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# Git hooks. Replaces husky (hook runner) + lint-staged (staged-file filtering).
#
# Installed by `pnpm install` via the `prepare` script, which calls
# `scripts/git/install-hooks.mjs`. That wrapper exists because lefthook refuses
# to install while `core.hooksPath` is set -- it prints advice and exits 0, so a
# leftover husky `core.hooksPath` silently leaves you with no hooks at all.
#
# Lefthook writes hooks into the *common* git dir, so a single install covers
# every worktree of a bare checkout, matching the previous husky behaviour.
#
# Every npm-provided tool is invoked through `./node_modules/.bin/<tool>`.
# Lefthook adds nothing to `PATH`, so a bare `oxlint` resolves from whatever the
# caller happens to have -- which works in an interactive shell and fails in a
# GUI client, an IDE commit dialog, or any minimal environment. It also silently
# picked up `/opt/homebrew/bin/shellcheck` instead of the pinned devDependency.
# Relative paths are safe because no job sets `root:`, so every job runs from the
# repository root; a job that later sets `root:` needs an absolute path instead.
# `rustfmt` and `cargo` are the exception -- they come from the Rust toolchain,
# not from node_modules, and are resolved from `PATH` by necessity.
# A dev with a stale global lefthook on $PATH would otherwise shadow the npm
# binary and silently skip config it cannot parse.
min_version: 2.1.10
# Turns "hooks were never installed" from silence into an error. Paired with
# install-hooks.mjs this is what guarantees the checks actually ran.
assert_lefthook_installed: true
prepare-commit-msg:
jobs:
# Must be a script file, not an inline `run:`. Lefthook registers `{N}` only
# for arguments git actually passed; anything beyond that count survives into
# the shell as a literal. A bare `git commit` hands prepare-commit-msg only
# `$1`, so `{2}` and `{3}` -- which the commitizen guard needs for
# COMMIT_SOURCE and SHA -- would expand to nothing usable. Scripts under
# source_dir receive the full, unmodified git hook argv, so the guard works
# as-is regardless of how many arguments git supplied.
- name: commitizen
# Opens /dev/tty as stdin so the commitizen prompt is usable. Ignored when
# `no_tty` is set or LEFTHOOK=0, which is why CI can never block on it.
interactive: true
script: commitizen.sh
runner: sh
commit-msg:
jobs:
- name: commitlint
run: ./node_modules/.bin/commitlint --edit {1}
pre-commit:
# Deliberate deviation from the husky setup, which ran unconditionally.
# `stage_fixed` below rewrites files in place; doing that while a rebase
# replays commits mutates history mid-flight. The old prepare-commit-msg hook
# already opted out of merge/squash, so this extends an existing intent.
skip:
- merge
- rebase
# Independent file groups run concurrently, mirroring how lint-staged ran the
# task lists of different patterns in parallel. Ordering *within* a group is
# expressed by `piped: true` -- see each group's comment for why its order is
# load-bearing.
parallel: true
jobs:
# No glob: a conflicted index is a property of the commit, not of any file
# extension, and this is the one job whose cost does not scale with the
# commit -- `git diff --check --cached` is a single call.
- name: merge-conflicts
run: bash ./scripts/git/no-merge-conflicts.sh staged
# Glob-gated on the files that can actually invalidate it. It used to be the
# only job without a glob, so it swept every workspace manifest on commits
# that touched no manifest at all. Both entries are required for the same
# whole-path reason the `manifests` job documents below.
- name: version-mismatch
glob:
- 'package.json'
- '**/package.json'
- 'pnpm-lock.yaml'
run: ./scripts/git/version-mismatch-check.sh
# Lint first so that autofixes are then formatted, not the reverse.
- name: js-ts
glob: '*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'
group:
piped: true
jobs:
- run: ./node_modules/.bin/oxlint --fix --no-error-on-unmatched-pattern {staged_files}
- run: ./node_modules/.bin/oxfmt --no-error-on-unmatched-pattern {staged_files}
stage_fixed: true
# `package.json` is excluded here and handled by its own job below. Under
# lint-staged the manifest also matched this group, so `oxfmt` here raced
# `syncpack format` on the same file; the exclusion is what serialises the
# two. Keeping them in separate concurrent jobs would reintroduce the race,
# which is why the manifest gets a dedicated job rather than an extra entry.
- name: data-files
glob: '*.{json,jsonc,yml,yaml,css,html,vue}'
exclude:
- '**/package.json'
- 'package.json'
run: ./node_modules/.bin/oxfmt --no-error-on-unmatched-pattern {staged_files}
stage_fixed: true
# Markdown is split out of the group above for the same reason `package.json`
# is: two tasks touching one file have to be sequenced. The alert check reads
# what oxfmt just wrote, so running it concurrently could read a half-written
# file.
#
# The checker takes the staged paths so the hook stays proportional to the
# commit; `pnpm lint:markdown` passes none and sweeps the whole tree.
- name: markdown
glob: '*.{md,mdx}'
group:
piped: true
jobs:
- run: ./node_modules/.bin/oxfmt --no-error-on-unmatched-pattern {staged_files}
stage_fixed: true
- run: node scripts/deps/check-markdown.mjs {staged_files}
# Syncpack owns manifest ordering, which is why `sortPackageJson` is
# disabled in `.oxfmtrc.json`. The script sequences it against oxfmt and
# documents why it has to build syncpack's arguments by hand.
#
# Invoked via `run:` rather than `script:` because pre-commit receives no
# argv from git, and `script:` jobs are handed the hook argv -- only a
# command template expands `{staged_files}` into arguments.
#
# Both glob entries are required. Unlike lint-staged's micromatch, which
# matched a bare `package.json` by basename at any depth, lefthook matches
# the whole path -- so `package.json` alone catches only the root manifest
# and every workspace manifest silently skips this job *and* is excluded
# from `data-files`, leaving it unformatted.
- name: manifests
glob:
- 'package.json'
- '**/package.json'
run: sh .lefthook/pre-commit/manifests.sh {staged_files}
stage_fixed: true
- name: shell
glob: '*.sh'
run: ./node_modules/.bin/shellcheck -x {staged_files}
# `rustfmt`, never `cargo fmt`. `cargo fmt` has no per-file mode -- it
# rewrites the whole workspace, so combined with `stage_fixed` a one-line
# change could stage unrelated files that merely happened to be
# unformatted. What this protects is the commit: nothing you did not stage
# can be added to it.
#
# Note the weaker guarantee on the *working tree*. rustfmt follows `mod`
# declarations, so staging a `lib.rs` or `mod.rs` also reformats its
# submodules on disk even though they are not in `{staged_files}`. Those
# rewrites are not staged -- `stage_fixed` only re-adds the paths lefthook
# passed in -- so they show up as ordinary unstaged changes. `--skip-children`
# would suppress it but is nightly-only.
#
# The edition comes from `rustfmt.toml`, which rustfmt finds by walking up
# from each input file. It matters that it is set there: invoked directly,
# rustfmt never reads `Cargo.toml`, so with no config it would fall back to
# edition 2015 and reject valid 2024 code as a parse error. Deliberately not
# passed as `--edition` here -- a CLI flag overrides the config file, so an
# edition bump would silently keep formatting against the stale value.
#
# `cargo fmt --all -- --check` stays in CI and in pre-push as the backstop
# for anything the per-file path misses; it reads the same `rustfmt.toml`,
# so the two cannot disagree.
- name: rust
glob: '*.rs'
run: rustfmt {staged_files}
stage_fixed: true
# `@taplo/cli` is a root devDependency purely so this job has a binary to
# call; the crates that run `format:toml` through turbo depend on it
# individually.
- name: toml
glob: '*.toml'
run: ./node_modules/.bin/taplo format {staged_files}
stage_fixed: true
# The local gate between commit and CI. Budget is 10s; everything unconditional
# here is measured well under it.
#
# This is a convenience gate, never a guarantee. Lefthook skips *every* job in
# `pre-commit` and `pre-push` when the resolved file list is empty, keyed on the
# hook name rather than on whether a job uses a file template or has a glob, and
# there is no config-level opt-out (`--force` / `--all-files` / `--file` are CLI
# only). A push whose commits change no file therefore runs nothing. The first
# push of a new branch is unaffected: `@{push}` does not resolve, so lefthook
# runs the jobs.
pre-push:
# Unbounded -- lefthook spawns one goroutine per job with a plain WaitGroup,
# with no CPU cap and no knob to throttle it. Fine at this size; worth
# remembering before the list grows.
parallel: true
jobs:
# Reads the ref updates git writes to stdin, so it checks exactly the
# commits being pushed rather than the working tree. `use_stdin` is what
# forwards them; without it the loop reads nothing and passes vacuously.
- name: merge-conflicts
run: bash ./scripts/git/no-merge-conflicts.sh pushed
use_stdin: true
# `cargo fmt` has no per-file mode, so unlike its pre-commit counterpart
# this one is whole-workspace by necessity: the glob decides *whether* it
# runs, not what it looks at. Safe here in a way it is not in pre-commit --
# nothing is staged, so a wider scope cannot pull unrelated files into a
# commit.
- name: rust-fmt
glob: '*.rs'
# A push to `develop` or `master` is about to be re-checked by CI, which
# runs `format:check`, `lint-dead-exports` and `ci-script-tests` as their
# own legs -- so every job carrying this anchor has a CI counterpart and
# nothing is lost by skipping it locally. `merge-conflicts` deliberately
# does not: it is a single `git diff --check`, and it is the one check
# here with no CI equivalent on a direct push.
#
# `ref` matches the branch you are *on*, not the refspec, which is the
# same thing in every workflow this repository uses.
skip: &on_shared_branches
- ref: develop
- ref: master
run: cargo fmt --all -- --check
# `{push_files}` rather than a bare sweep, so the job stays proportional to
# the push the same way the pre-commit `markdown` job stays proportional to
# the commit. That is also the right scope for what this backstops: the
# commits that reached here without pre-commit running, via `--no-verify` or
# a merge/rebase skip.
- name: markdown
glob: '*.{md,mdx}'
skip: *on_shared_branches
run: node scripts/deps/check-markdown.mjs {push_files}
# Dead exports are worth hearing about before the PR, not in review. Scoped
# to `exports,types` deliberately: knip's other categories are noise here
# (a bare run reports ~950 "unused files", nearly all of them demo apps and
# config), and `deps:check` already owns the dependency category.
#
# Whole-graph by necessity, like `rust-fmt`: an export is dead only relative
# to every importer, so there is nothing to narrow to `{push_files}`.
- name: dead-exports
glob: '*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'
skip: *on_shared_branches
run: ./node_modules/.bin/knip --include exports,types
# The suites that cover this repository's own scripts, including these
# hooks. Glob-gated rather than unconditional: at ~15s it is over the budget
# above, and it can only regress when something under these paths changes.
# CI runs it on every PR as `ci-script-tests` regardless.
- name: script-tests
glob:
- 'scripts/**/*'
- '.github/scripts/**/*'
- '.lefthook/**/*'
- 'lefthook.yml'
skip: *on_shared_branches
run: pnpm run test:scripts
# Opt-in only: `STYLEX_SLOW=1 git push`, or force it any time with
# `lefthook run pre-push --job clippy`. Measured at 0.56s fully cached but
# 42.86s on a cold populate, and any touched crate pushes it back toward the
# cold figure -- a minute of blocked terminal per push is what trains people
# into `--no-verify`. CI runs clippy on every PR regardless.
#
# The condition has to be `run:` with a shell test. `skip`/`only` accept
# only `ref:` and `run:`; an `env:` key passes `lefthook validate` (the
# schema types them as bare arrays) and is a silent no-op. Tags cannot
# express this either -- `LEFTHOOK_EXCLUDE` only appends to `exclude_tags`
# and there is no `LEFTHOOK_INCLUDE`.
- name: clippy
only:
- run: test -n "$STYLEX_SLOW"
run: cargo clippy --all-targets --all-features --workspace -- -D warnings
# A compiler's dependency tree is a supply-chain surface, and nothing else
# here looks at it. Same opt-in as clippy: cargo-audit refreshes the RustSec
# advisory database over the network, which is not something to put in the
# default path of every push. Policy lives in `deny.toml`; the script skips
# whichever tool is not installed rather than failing.
- name: rust-audit
only:
- run: test -n "$STYLEX_SLOW"
run: ./scripts/git/audit-rust.sh
# Ergonomics, not gates: these keep the two dependency graphs in step with the
# ref you just moved to. They may mutate the working tree, but git ignores their
# exit status, so they can never fail the operation that triggered them.
#
# `interactive: true` because `pnpm install` can prompt (e.g. before purging
# node_modules when the store location changed) and without a TTY the prompt
# hides behind lefthook's spinner and the hook appears to hang. `interactive`
# opens /dev/tty directly and ignores `no_tty`, and is honoured only when stdin
# is already a real terminal.
#
# Set `STYLEX_SKIP_INSTALL=1` to opt out.
post-checkout:
jobs:
# git passes `$3 = 1` for a branch checkout and `0` for a file checkout;
# only the former can change a lockfile. post-checkout receives three
# arguments, so `{3}` is genuinely substituted here.
- name: install-changed-deps
run: if [ "{3}" = "1" ]; then node scripts/git/install-changed-deps.mjs; fi
interactive: true
post-merge:
jobs:
- name: install-changed-deps
run: node scripts/git/install-changed-deps.mjs
interactive: true
post-rewrite:
jobs:
- name: install-changed-deps
run: node scripts/git/install-changed-deps.mjs
interactive: true