fix(git): push-guard.README.md — required repo config (#975) + heredoc-in-substitution warning
ci/woodpecker/pr/ci Pipeline is pending
ci/woodpecker/pr/ci Pipeline is pending
This commit is contained in:
@@ -19,8 +19,9 @@ Each is an assertion satisfied by the null case. `push-guard.sh` asserts the **p
|
||||
| `check-staged` | index has no unmerged paths | `2` |
|
||||
| | no conflict markers in staged content | `2` |
|
||||
| | the conflict scan actually *completed* | `2` |
|
||||
| | staged JSON under `--json-path` parses | `3` |
|
||||
| | staged JSON under the nominated paths parses | `3` |
|
||||
| | **something is actually staged** | `5` |
|
||||
| | **an explicit JSON decision exists** | `6` |
|
||||
| `push` | HEAD advanced past `--since-head` | `5` |
|
||||
| | HEAD is a non-empty commit | `5` |
|
||||
| | remote **moved**, and now equals local HEAD | `4` |
|
||||
@@ -61,14 +62,36 @@ The review also correctly identified one **vacuous control**: the positive `--si
|
||||
|
||||
Two reported findings did **not** reproduce and were not acted on: `:(glob)` does still match a bare directory pathspec, and my first rename repro failed only because the edit dropped similarity below the detection threshold — a badly built test, not an absent bug. Re-testing properly is what confirmed it.
|
||||
|
||||
## The JSON decision is mandatory (`.push-guard.json`)
|
||||
|
||||
The first release announced `JSON check NOT REQUESTED` and continued. That was *visible* rather than silent, which is better — but it is still an **absence**, and an absence is not reviewable. Nobody reads a line that has printed correctly ten thousand times. A repo that needed the check and never wired it up stayed unprotected forever, and nothing ever failed.
|
||||
|
||||
The decision is now required, from one of exactly two places:
|
||||
|
||||
```jsonc
|
||||
// nominate generated JSON for parse-checking
|
||||
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
|
||||
|
||||
// or opt out — the reason is REQUIRED and is printed on every run
|
||||
{"json_check": "none", "reason": "no generated JSON in this repo"}
|
||||
```
|
||||
|
||||
`--json-path` on the command line also satisfies it. Saying nothing is refused (exit `6`).
|
||||
|
||||
**The asymmetry is deliberate.** Turning the check *on* is safe from anywhere, so a CLI flag suffices. Turning it *off* is confined to a committed file, because that is the only form a human can review: you can read a reason in a diff, and you cannot review the fact that nobody typed a flag. An opt-out living in an ad-hoc command line is the old fail-open with extra steps.
|
||||
|
||||
A **malformed** config is refused outright rather than treated as absent — that fallback would mean a typo silently disables the check the file was written to enable. A config that parses but *states nothing* (`{}`) is refused too: valid JSON that says nothing is the original defect wearing a config file as a disguise.
|
||||
|
||||
**Migration is a hard cutover, on purpose.** The tempting path is "warn for one release, then enforce" — but that warning phase *is* the degrade-to-a-warning this tool forbids, and it leaves the fail-open open for exactly as long as the warning is ignored, which is indefinitely. Consumers add a config or the guard refuses. It breaks loudly, once. Two pre-existing cases in this repo's own harness were the first to pay that cost, which is the correct place to feel it.
|
||||
|
||||
## Known limitations — stated, not hidden
|
||||
|
||||
- **The JSON check is unprotected until a repo wires up `--json-path`.** This is a real fail-open. The mitigation is that the guard prints `JSON check NOT REQUESTED` on *every* run, so the unprotected state is visible in every log rather than silent. That printed line is the only thing standing between "not configured" and "not noticed".
|
||||
- `check-staged` inspects the index. Content added to the working tree *after* it runs is not covered — it belongs in a `pre-commit` hook, where the window is smallest.
|
||||
- `push` hardcodes `HEAD:refs/heads/$branch`, so it cannot verify a commit built via plumbing on a different base. A `--sha` option would close this; it is deliberately not added without a needle.
|
||||
|
||||
## Test harness
|
||||
|
||||
`test-push-guard.sh` — 19 cases, each check in **both polarities**:
|
||||
`test-push-guard.sh` — 32 cases, each check in **both polarities**:
|
||||
|
||||
- **NEEDLE** — a deliberately broken fixture that must trip the guard.
|
||||
- **CONTROL** — a clean fixture that must pass.
|
||||
@@ -87,12 +110,20 @@ Controls are not decoration. A guard that failed unconditionally would satisfy e
|
||||
| revert `--no-renames` | 1/19 fail — the rename needle |
|
||||
| revert `-a` to `-I` | 1/19 fail — the binary-attribute needle |
|
||||
| delete the `--since-head` block | 2/19 fail — incl. the control that *was* vacuous |
|
||||
| unmodified | 19/19 pass |
|
||||
| revert refuse-on-missing-config | 1/32 fail — the closed fail-open |
|
||||
| malformed config degrades to "absent" | 4/32 fail — **all four by `--out` alone**, exit code was correct every time |
|
||||
| drop the mandatory `reason` | 1/32 fail — the unexplained-opt-out needle |
|
||||
| make the guard emit a stray warning | 1/32 fail — the stray-output control |
|
||||
| unmodified | 32/32 pass |
|
||||
|
||||
A guard nobody has watched fail is not a guard. The same applies to the harness: mutant C would have passed silently without the output assertions, so the assertions are load-bearing rather than ornamental.
|
||||
|
||||
**A blanket `|| true` on the scan was a fail-open in the guard's own error handling.** `git grep` exits `1` for "no match" but `>=2` for a real failure. `|| true` collapsed the two, so a malformed pathspec or unreadable index would have been reported as "ok, no conflict markers" — a clean pass from a scan that never ran. The status is now discriminated, and a PATH-shim fault-injection needle proves the refusal.
|
||||
|
||||
**A `<<'PY'` heredoc inside `$( )` made bash print a warning on every run — and 30 needles plus a clean linter all missed it.** The config parser started life as an inline heredoc inside a command substitution, so every invocation emitted `warning: command substitution: 1 unterminated here-document` to stderr. It executed correctly, every needle stayed green, and shellcheck reported nothing. The harness missed it because `expect` asserts *substrings it was told to look for* — and nobody tells you to look for output you did not know existed. It surfaced only when the guard was run against a real repository.
|
||||
|
||||
The fix moves the parser to a top-level constant. The lesson is encoded as case `z1`, which asserts the **absence of a whole output class** rather than the presence of an expected string, and is paired with a needle proving the detector can fire. A tool built to refuse quiet failures was quietly polluting stderr for its entire existence.
|
||||
|
||||
**The `-E` flag on `git grep` is load-bearing and was caught by a needle, not by review.** `git grep` defaults to *basic* regex, in which `(`, `|` and `{7}` are literal characters. Without `-E` the patterns match nothing and the check reports a clean pass over a file full of conflict markers — the exact defect this tool exists to prevent, shipped inside the tool itself.
|
||||
|
||||
## Proposed framework path
|
||||
|
||||
Reference in New Issue
Block a user