mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
Merge origin/main into #3409 and stamp v1.91.215.0 (train slot 4)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
7866caac97
40 files changed
+1582
-156
No files matched your search
@@ -90,8 +90,13 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL https://cli.github.
|
||||
# and "gnupg" was renamed to "gpg" on Ubuntu 24.04 anyway, so NodeSource's script
|
||||
# fails before it can add its own repo. Direct tarball download is network-simpler
|
||||
# (one host: nodejs.org) and doesn't touch apt at all.
|
||||
ENV NODE_VERSION=22.20.0
|
||||
# The hash comes from the release's signed SHASUMS256.txt, and the archive is
|
||||
# checked before it is unpacked. scripts/ubicloud/setup-free-suite.sh pins the
|
||||
# same version and hash (test/ci-image-cli-pin.test.ts keeps them equal).
|
||||
ENV NODE_VERSION=22.23.4
|
||||
ARG NODE_SHA256=1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce
|
||||
RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz \
|
||||
&& echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \
|
||||
&& tar -xJ -C /usr/local --strip-components=1 --no-same-owner -f /tmp/node.tar.xz \
|
||||
&& rm -f /tmp/node.tar.xz \
|
||||
&& node --version \
|
||||
|
||||
+117
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
## [1.91.215.0] - 2026-10-25
|
||||
|
||||
**An unattended `/ship` leaves its receipt in a file the parent can read, and the parent is told to read that file (GSTA-88).** Before, Step 18's handoff section wrote the ship receipt only to stdout, and `docs/unattended.md` told the parent to read it from the PR body with `gstack-ship-receipt read --pr <n> --json`. An unattended run must not push, so there is no PR: the parent got a usage error or `RECEIPT_MISSING`, or, if an older PR existed, read that PR's receipt with exit 0 and a stale `head`. Now the handoff writes `.gstack/tmp/ship-receipt.md`, the parent reads it with `read --body` in the child's checkout, and it compares the receipt's `head` to the branch SHA either way.
|
||||
|
||||
@@ -13,6 +13,122 @@
|
||||
- `test/gstack-ship-receipt.test.ts` runs the handoff template's write command as written in a fixture repository, then reads the file back with `read --body`: `head` is HEAD, `session_kind` is `unattended` and there is no `pr`. It also checks that the template and the generated section carry `--out` and that `docs/unattended.md` names the same file with `read --body`.
|
||||
- `test/gstack-skill-start.test.ts` checks that the `UNATTENDED_SHIP` line appears exactly once for an unattended `/ship`, alongside only the `unattended-session` block, and never for `/review`, `/qa`, `/autoplan` or for `/ship` in an interactive, spawned or headless session.
|
||||
|
||||
## [1.91.214.0] - 2026-10-25
|
||||
|
||||
**`gstack-brain-sync --publish-removals --yes` no longer prints `list: unbound variable` when it finishes, and it now removes its temp list and releases the sync lock.** The command's EXIT trap named the function's local `$list`. When a publish succeeded, the function returned before the script exited, so the trap ran after `list` had gone out of scope. Under `set -u` it stopped at the unbound variable and never reached the cleanup, which left `brain-sync-removals.*` in `$TMPDIR` and `.brain-sync.lock.d` in `~/.gstack` until a later run cleared the lock as stale (found by GSTA-44).
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-brain-sync`**: `--publish-removals` writes the temp file paths into its EXIT/INT/TERM trap when it installs the trap (quoted with `printf %q`), so the cleanup and lock release work however the function ends. The drain's trap in `--once` also names locals, but every path in that function ends with `exit` while they are still in scope, so it's unchanged. The other trap sites (the drain's lock-only trap and `gstack-brain-restore`'s, which uses a global) don't have the bug.
|
||||
|
||||
### Tests
|
||||
- `test/brain-sync-removals.test.ts`: a new case runs `--publish-removals`, then `--publish-removals --yes` twice (publishing, then nothing to publish) with a private `TMPDIR`. After each run, stderr has no `unbound variable`, the exit status is 0, `TMPDIR` is empty and the lock directory is gone. It failed before the fix.
|
||||
|
||||
## [1.91.213.0] - 2026-10-25
|
||||
|
||||
**The CI image and the Ubicloud free-suite VMs move off Node.js 22.20.0 to 22.23.4, and both now check the Node archive's SHA-256 before unpacking it (#3354, port of #3355 by @michael-sumner).** 22.20.0 is affected by the October 21, 2026 Node.js security releases, including the Brotli decoder out-of-bounds write (CVE-2026-111497, high) and the TLS hostname bypass for leading-zero IPv4 hosts (CVE-2026-106069, high). 22.23.4 is the patched 22.x release. #3355 bumped `.github/docker/Dockerfile.ci`, but `scripts/ubicloud/setup-free-suite.sh` pins Node separately and would have kept installing 22.20.0 on every remote free-suite run.
|
||||
|
||||
### Fixed
|
||||
- **`.github/docker/Dockerfile.ci`**: `NODE_VERSION` is 22.23.4 (Michael Sumner's commit). The archive is checked against `NODE_SHA256`, the linux-x64 hash from the release's SHASUMS256.txt, whose signature verifies against the Node.js release keyring, before it is unpacked.
|
||||
- **`scripts/ubicloud/setup-free-suite.sh`**: pins the same 22.23.4 and hash, and downloads the archive to a temporary file and verifies it instead of piping `curl` straight into `sudo tar`.
|
||||
|
||||
### Tests
|
||||
- `test/ci-image-cli-pin.test.ts` gains four cases: Dockerfile.ci pins an exact Node version and a SHA-256, both files verify before they unpack, and `setup-free-suite.sh` pins the same version and hash as the image. On #3355's head alone, all four fail; on main, three fail.
|
||||
- The Bun ordering check in `setup-free-suite.sh` now looks for `sha256sum -c` after the Bun download instead of anywhere in the file, because the Node check now comes first.
|
||||
- The test names `Dockerfile.ci` as `join(__dirname, '..', '.github', …)`, the form pregate's literal scan resolves, so a change to the image now selects it (before, pregate refused a Dockerfile.ci-only change with `PREGATE_NO_LANE`).
|
||||
|
||||
### Gate integrity
|
||||
- One accepted-input change to review: the Bun ordering check searches for its `sha256sum -c` from the Bun download onward. It now accepts one input it used to reject: a file with some other `sha256sum -c` before the Bun download and the Bun check after it, which is this file. The property it guards, Bun verified after download and before unpack, is unchanged. A file whose only `sha256sum -c` sits before the download still fails, and so does removing the Bun hash or moving it after `unzip` (negative controls run). No threshold, budget, timeout, trial count or case kind changed.
|
||||
|
||||
## [1.91.212.0] - 2026-10-25
|
||||
|
||||
**/careful no longer asks about words inside a commit message (#1060; ported from #3221 by @Bhumika-1432006).** The careful hook matched its MEDIUM patterns against the whole command, so `git commit -m "fix: prevent drop table in migration"` asked for approval as an SQL DROP. The hook now masks git commit's own literal message before those checks, including the `-m "$(cat <<'EOF' … EOF
|
||||
)"` form agents use most. Our commits on top of the contributor's scanner fix four problems found in review: the inline awk program made the whole hook a bash syntax error on bash 3.2 (macOS `/bin/bash`), so every Bash call failed the hook there; a newline after the message was dropped, so `-m x⏎truncate …` stopped asking; any `-m '…'` after a commit was masked, including another command's argument; and the heredoc form was never masked.
|
||||
|
||||
### Fixed
|
||||
- **`careful/bin/mask-commit-message.awk`** (new, from the contributor's scanner): reads the command as one string and keeps its newlines. It splits at unquoted `;` `&` `|` `(` `)` and newlines (a redirection's `&` does not split) and masks `-m`, `-am`, `--message` and `--message=` only in a segment whose first word is `git` with a `commit` word after it. Single-quoted messages, double-quoted messages without `$`, backtick or backslash, and a double-quoted `$(cat <<'DELIM' … DELIM )` with a quoted delimiter are masked. Quotes, backslashes and comments are parsed as bash does. The command is left unmasked when it contains any other command, parameter or process substitution, a backtick, `$'…'`, a heredoc, an unterminated quote, or a pipe after a masked message (git prints the message on stdout).
|
||||
- **`careful/bin/check-careful.sh`**: runs the program with `awk -f` from the hook's own directory, so bash 3.2 parses the hook again. A missing program or an awk error leaves the command unmasked. The HIGH deny tier, the PowerShell table and the obfuscation tripwire still read the unmasked command.
|
||||
|
||||
### Tests
|
||||
- `test/hook-scripts.test.ts` keeps the contributor's 10 cases and adds 31. Masked (no prompt): every flag form, `git -C repo commit`, `--amend`, multi-line messages and four heredoc spellings (`'EOF'`, `"MSG"`, `<<-` with a tab, a quoted word in the body). Still asking: a command on the next line, a quote that spans a line break, another command's `-m`, `echo git commit -m '…'`, an escaped quote, a backslash-newline, a heredoc with an unquoted delimiter, a substitution that closes the message early, a backtick, `git commit -F - <<'EOF'`, `$'…'`, a pipe after the commit (direct, after `2>&1`, or from a subshell), a process substitution and a parameter expansion. A partial install without the awk file still asks.
|
||||
- The same cases pass with mawk and the one-true-awk (`original-awk`, the awk macOS ships). In a `bash:3.2` container the hook as submitted in #3221 exits 2 with `unexpected EOF while looking for matching '"'` on every command; with this change it answers normally.
|
||||
|
||||
### Gate integrity
|
||||
- One accepted-input change to review, in a safety hook: MEDIUM checks (and custom `careful-patterns.txt` rules) no longer see the masked text of git commit's own literal message. That is a weakening by design, limited to text that bash passes to git as a literal argument. The negative controls listed under Tests still ask. Commands that combine a commit with anything that could execute the message, or that the scanner cannot read with certainty, are checked unmasked, exactly as on main. No threshold, budget, timeout, trial count or case kind changed.
|
||||
- Known limit, still a false positive as on main: `git commit -F - <<'EOF'` messages and commits after an environment assignment or `sudo` are not masked.
|
||||
|
||||
## [1.91.211.0] - 2026-10-25
|
||||
|
||||
**`gstack-diff-scope` sees Python tests and nested test directories (#3335).** A branch that changed `components/orch/services/shared/tests/test_native_store_custody.py` reported `SCOPE_TESTS=false`, because the Tests rule only knew `*.test.*`-style suffixes and `test/`, `tests/`, `spec/` and `__tests__/` at the repository root. /ship and /review use that flag to route the testing specialist, so a backend change whose tests follow pytest's `test_*.py` convention skipped it. The test-bootstrap resolver already counted these files, so the two detectors disagreed.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-diff-scope`**: a changed path is a test when it matches `TEST_PATH_RE`, the same regex the test-bootstrap resolver uses for its TESTFILES count (`scripts/resolvers/testing.ts`): test dirs at any depth, `test_*.py`, `tests.py`, `_test.<ext>`, `.test.`/`.spec.` JS and TS, `_spec.rb` and `*Test.java`/`.kt`. `conftest.py` counts too. The existing globs stay.
|
||||
|
||||
### Tests
|
||||
- `test/diff-scope.test.ts` adds the issue's three paths, `pkg/test_store.py`, Django's `tests.py`, `conftest.py`, and nested `__tests__/` and `spec/` dirs; all eight fail on main. Controls that stay `SCOPE_TESTS=false`: `latest.py`, `contest_rules.py`, `attest_signer.py` and a `testing/` dir. A new test checks that `TEST_PATH_RE` is byte-identical to the resolver's regex, so the two can't drift again.
|
||||
|
||||
## [1.91.210.0] - 2026-10-25
|
||||
|
||||
**/autoplan works when Claude Code's project directory is a subfolder of the repository (#3296).** When the git root sits above the project (for example the root is `$HOME` and the session starts in `$HOME/projects/app`), a worktree session's journal lives in `.claude/worktrees/<name>` under that subfolder. The publication guard only looked for `<project dir>/.git/worktrees`, found nothing, and denied every phase Read with `foreign_cwd`, so the reporter had to restart the session from the repository root.
|
||||
|
||||
### Fixed
|
||||
- **`autoplan/bin/phase-publication-hook.ts`**: `linkedWorktrees` walks up from the project directory to the first `.git` and reads that repository's worktree registry. A `.git` file (the project is itself a linked worktree or a submodule) stops the walk with no owners, as before. Git's two-way link is still required: the registry entry must name the worktree's `.git` file and that file must name the same entry back. No `git` process is started inside the hook.
|
||||
|
||||
### Tests
|
||||
- `test/autoplan-publication-hook.test.ts` builds the reporter's layout with real `git worktree add`: a subfolder project directory (and a deeper one) owns the worktree under it, and the root still does. It fails on main. Controls that still get no owners: a nested independent repository (its own `.git` stops the walk), a project directory inside a linked worktree, a registry entry whose worktree names another repository, and a directory with no `.git` above it.
|
||||
|
||||
### Gate integrity
|
||||
- One accepted-input change to review: from a project directory below the repository root, the guard now accepts the journal of a linked worktree of that same repository, which it already accepted when the project directory was the root. The seven existing `foreign_cwd` refusals in `test/autoplan-publication-guard.test.ts` (no link, one-sided links, another repository, a submodule gitdir, a project that is itself a worktree, a bare repository) still deny. No threshold, budget, timeout, trial count or case kind changed.
|
||||
|
||||
## [1.91.209.0] - 2026-10-25
|
||||
|
||||
**The free test suite no longer rewrites a contributor's global git pre-push hook (#3353).** On a machine with a global `core.hooksPath`, `test/redact-prepush-target.test.ts` and `test/fs-utils.test.ts` ran `gstack-redact install-prepush-hook` with the parent environment. Git resolved the hooks directory to the global one, so the install moved the developer's own `pre-push` to `pre-push.local` and left a managed wrapper pointing into the test checkout, which breaks every later push once that checkout is gone. The same config also failed 4 tests in the target file, 5 in `test/redact-prepush-hook.test.ts` and 1 in `fs-utils`. CI has no global hooks path, so it stayed green.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/git-env.ts`** (new): `gitEnv()` is the one fixture environment for git and hook installs: `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_COUNT=0` and no `GIT_CONFIG_PARAMETERS`. Config written inside the fixture repo still applies.
|
||||
- `test/redact-prepush-target.test.ts`, `test/redact-prepush-hook.test.ts` and `test/fs-utils.test.ts` pass it to every fixture git, install and hook spawn. `test/redact-prepush-scan-range.test.ts` and `test/redact-prepush-rebase-force-push.test.ts` drop their private copies (#2967) and import it.
|
||||
|
||||
### Tests
|
||||
- A new case in `test/redact-prepush-target.test.ts` sets a global, system and environment `core.hooksPath` to a hook that fails, builds the fixture, and checks the developer's hooks directory is untouched, the managed hook landed in the fixture repo, and a push never runs the developer's hook. It fails without the shared environment.
|
||||
- Rerun of the issue's repro with `HOME` pointing at a gitconfig with a global hooks dir: on main, two files rewrote it and three files had failures; with this change all six `redact-prepush-*` and `fs-utils` files pass and the directory is unchanged.
|
||||
|
||||
## [1.91.208.0] - 2026-10-25
|
||||
|
||||
**The Capy install guide says that the team-mode session-start updater also waits for live workflow leases.** With #3385 (#3363), `bin/gstack-session-update` skips its background fast-forward while another session holds a live workflow lease, the same leases `bin/gstack-upgrade` already honors. `docs/capy-install.md` described only the upgrade side.
|
||||
|
||||
### Documentation
|
||||
- **`docs/capy-install.md`**: the workflow-lease paragraph now says the team-mode SessionStart hook (`bin/gstack-session-update`) honors the same leases. It logs `SKIP leased pid=<p>/<age>m` to `analytics/session-update.log` and tries again at the next session start, without writing its hourly throttle stamp.
|
||||
|
||||
## [1.91.207.0] - 2026-10-25
|
||||
|
||||
**Per-repository session counts include sessions from removed Claude Code worktrees (ported from #3334 by @Shweta-Mishra-ai).** Claude Code creates worktrees at `<repo>/.claude/worktrees/<name>` and removes them once their branch merges, but their session history stays. `gstack-global-discover` resolved each session's recorded directory with git, so a removed worktree resolved to nothing: its sessions counted in the tool total and in no repository. Reproduced on main: 2 sessions in a repo plus 3 in a removed worktree gave `total_sessions 5` but 2 for the repo.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-global-discover.ts`**: when a session's directory no longer exists and sits inside `<repo>/.claude/worktrees/<name>` (or a subdirectory of it), the session counts with `<repo>`. Any other missing directory, and a worktree whose repository is gone too, still count in no repository. A live worktree keeps its own path, as before.
|
||||
|
||||
### Tests
|
||||
- `test/global-discover.test.ts` gains six end-to-end cases built with real `git worktree add` and `remove` (removed worktree, a subdirectory of one, a repo with no remote, plus the live-worktree, gone-repo and plain-missing-directory controls) and table tests for the path matcher. The three positive cases and the matcher tests fail on main.
|
||||
|
||||
## [1.91.206.0] - 2026-10-25
|
||||
|
||||
**A repo that keeps `.gstack/` out of git can give pregate a policy, and name the CI lanes a local run satisfies (#3286).** On a shared repo where gstack is one contributor's tool, pregate fell back to defaults, so every platform lane the machine could not run (an `ubuntu-latest` suite on a Mac, say) became a `requires-remote` obligation that never cleared, even when that suite runs the same here. Ported from #3286 by arlophoenix, whose 13 commits carry the feature (merged with their history kept); our commits on top close two findings from a security-focused review of the port.
|
||||
|
||||
### Added
|
||||
- **`lib/pregate/context.ts`, `lib/pregate/config.ts`** (arlophoenix): when the base branch carries no `.gstack/pregate.json`, `gstack-pregate run` reads `<state root>/projects/<slug>/pregate.json`. Never under `--policy-from head`, never a relative path or one inside the checkout, and never chosen by a checkout's `.env` (the bin ignores it, and a bare `bun gstack-pregate` beside one refuses). An unreadable file, a dangling link on its path, `sealed` or `price_file` refuses with `PREGATE_CONFIG_INVALID`; a base copy git cannot read refuses with `POLICY_SOURCE_UNAVAILABLE` unless `git ls-tree` confirms it is absent.
|
||||
- **`local`** (arlophoenix): `<workflow.yml>/<job>` lanes a local run satisfies run here instead of becoming `requires-remote`. Under 8 cores with a `remote_gate` or remote `test_backend`, the small-machine obligation still stands.
|
||||
- **`lib/pregate/index.ts`** (arlophoenix): the record names the user file and its digest, and `verify` goes `PREGATE_STALE` when it is edited, deleted, or appears after a run under the defaults. Fix texts for missing lanes, strays, stalls and `remote_gate` name the active policy file.
|
||||
|
||||
### Fixed
|
||||
- **`lib/pregate/context.ts`**: a user policy whose path reaches the checkout through any symlink is ignored. Before, only the lexical path and the final target were checked, so a state root linked into the checkout, holding a link back out to another existing policy, let a branch choose a more permissive file without changing its bytes.
|
||||
- **`lib/pregate/index.ts`**: `verify` goes `PREGATE_STALE` when a link on the way to the absent user path starts pointing nowhere. A fresh run refuses that state, but verify looked only at the leaf, so an old defaults pass stayed publishable. The loader and verify now share one absence check.
|
||||
|
||||
### Tests
|
||||
- `test/pregate-user-policy.test.ts` (new, arlophoenix) covers the user file, `local`, the refusals, `.env` isolation and stale receipts. New cases: a link chain through the checkout is refused for both a linked state root and a single in-repo hop, while a direct outside link is still read; and verify goes stale on a dangling parent, with the plain-absent path as the control.
|
||||
- `test/pregate-rust.test.ts`: the two cargo-lane cases skipped on Windows carry a `TODO(#3380)` naming the gap.
|
||||
|
||||
### Gate integrity
|
||||
- No threshold, budget, timeout, trial count, case kind or skip changed. The user file can relax lanes only where the base has no policy at all, and only from outside the repository; the two fixes make that containment stricter.
|
||||
- No new dependency, no network call, no workflow change.
|
||||
|
||||
## [1.91.205.0] - 2026-10-25
|
||||
|
||||
**/ship's Step 9 can re-review just the fixes after a fixing pass, and a narrowed pass can never be the review that ships (#3339).** Before, every fix cycle reran every specialist and the Red Team over the whole branch. On a large branch each full pass tends to find something new in code earlier passes already read, so the loop often ran to the three-cycle stop without converging. Now the pass after a fixing pass can review only what changed since that pass. One full pass still has to come last: a fix-range record is never CURRENT, and Step 11.5 can't bind it. Garry approved this on 2026-10-24 ("only with a mandatory final full pass").
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.205.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.215.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -627,12 +627,21 @@ function phaseDecision(input: PublicationHookInput, root: string, before: Event[
|
||||
* A worktree session gets the repository root as CLAUDE_PROJECT_DIR while its
|
||||
* journal is rooted in the linked worktree. Only git's own two-way link makes
|
||||
* a directory that worktree: <root>/.git/worktrees/<name>/gitdir names
|
||||
* <worktree>/.git, and that file names the same admin entry back.
|
||||
* <worktree>/.git, and that file names the same admin entry back. A project
|
||||
* directory below the root (#3296) uses the first .git found walking up; a
|
||||
* .git file there means a linked worktree or submodule, which has no registry.
|
||||
*/
|
||||
export function linkedWorktrees(projectDir: string): string[] {
|
||||
let entries: fs.Dirent[];
|
||||
const admin = path.join(projectDir, '.git', 'worktrees');
|
||||
try { entries = fs.readdirSync(admin, { withFileTypes: true }); } catch { return []; }
|
||||
let entries: fs.Dirent[], admin: string | undefined;
|
||||
try {
|
||||
for (let dir = path.resolve(projectDir); ; dir = path.dirname(dir)) {
|
||||
const dotGit = fs.statSync(path.join(dir, '.git'), { throwIfNoEntry: false });
|
||||
if (dotGit) { if (dotGit.isDirectory()) admin = path.join(dir, '.git', 'worktrees'); break; }
|
||||
if (path.dirname(dir) === dir) break;
|
||||
}
|
||||
if (!admin) return [];
|
||||
entries = fs.readdirSync(admin, { withFileTypes: true });
|
||||
} catch { return []; }
|
||||
return entries.filter(entry => entry.isDirectory()).slice(0, 256).flatMap(entry => {
|
||||
try {
|
||||
const link = path.join(admin, entry.name);
|
||||
|
||||
@@ -1068,7 +1068,7 @@ subcmd_publish_removals() {
|
||||
sync_active || { echo "BRAIN_SYNC: artifacts sync is not active; nothing to publish" >&2; exit 1; }
|
||||
gstack_brain_lock_acquire "$GSTACK_HOME" try || { echo "BRAIN_SYNC: another sync is running; try again" >&2; exit 1; }
|
||||
list=$(mktemp "${TMPDIR:-/tmp}/brain-sync-removals.XXXXXX") || exit 1
|
||||
trap 'rm -f "$list" "$list.gone"; gstack_brain_lock_release' EXIT INT TERM
|
||||
trap "rm -f $(printf '%q ' "$list" "$list.gone"); gstack_brain_lock_release" EXIT INT TERM
|
||||
removal_candidates "$list" || { echo "BRAIN_SYNC: could not list removed files" >&2; exit 1; }
|
||||
n=$(grep -c . "$list" 2>/dev/null || true)
|
||||
if [ "$n" -eq 0 ]; then
|
||||
|
||||
@@ -42,6 +42,12 @@ MIGRATIONS=false
|
||||
API=false
|
||||
AUTH=false
|
||||
|
||||
# Test-file paths, the same rule the test-bootstrap resolver counts as
|
||||
# TESTFILES (scripts/resolvers/testing.ts; test/diff-scope.test.ts keeps the two
|
||||
# identical). Nested test dirs and Python's test_*.py / tests.py were missed by
|
||||
# the root-anchored globs alone (#3335).
|
||||
TEST_PATH_RE='(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$'
|
||||
|
||||
_print_flags() {
|
||||
echo "SCOPE_FRONTEND=$FRONTEND"
|
||||
echo "SCOPE_BACKEND=$BACKEND"
|
||||
@@ -126,7 +132,9 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
|
||||
case "$f" in
|
||||
*.test.*|*.spec.*|*_test.*|*_spec.*) m_tests=true ;;
|
||||
test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) m_tests=true ;;
|
||||
conftest.py|*/conftest.py) m_tests=true ;;
|
||||
esac
|
||||
if [[ "$f" =~ $TEST_PATH_RE ]]; then m_tests=true; fi
|
||||
|
||||
# Docs
|
||||
case "$f" in
|
||||
|
||||
@@ -186,11 +186,26 @@ function gitProject(cwd: string): GitProject | null {
|
||||
const up = prefix.split("/").filter(Boolean).map(() => "..");
|
||||
project = { root: resolve(cwd, ...up), main: basename(common) === ".git" ? dirname(common) : common };
|
||||
}
|
||||
} else {
|
||||
// A merged branch's worktree is removed but its session history stays:
|
||||
// count those sessions with the repository the worktree was created from.
|
||||
const parent = parentRepoOfClaudeWorktree(cwd);
|
||||
if (parent) project = gitProject(parent);
|
||||
}
|
||||
PROJECT_MEMO.set(cwd, project);
|
||||
return project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code creates worktrees at `<repo>/.claude/worktrees/<name>`. Given a
|
||||
* cwd inside one (the worktree root or any subdirectory), the repository root
|
||||
* it was created from; null for any other path.
|
||||
*/
|
||||
export function parentRepoOfClaudeWorktree(cwd: string): string | null {
|
||||
const m = cwd.match(/^(.+?)[\\/]\.claude[\\/]worktrees[\\/][^\\/]+(?:[\\/].*)?$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function getGitRemote(root: string): string | null {
|
||||
const r = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], {
|
||||
encoding: "utf-8",
|
||||
|
||||
+6
-3
@@ -29,7 +29,7 @@ import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
|
||||
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
|
||||
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
|
||||
import { buildContext, describeRefusal } from '../lib/pregate/context';
|
||||
import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
|
||||
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
|
||||
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
|
||||
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
|
||||
@@ -140,13 +140,14 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
|
||||
if (stallTimeoutMs !== undefined && (!Number.isFinite(stallTimeoutMs) || stallTimeoutMs < 0)) { process.stderr.write('gstack-pregate: --stall-timeout needs a number of seconds (0 disables)\n'); return EXIT.usage; }
|
||||
let remote: { spendCapUsd: number } | null = null;
|
||||
if (rest.includes('--remote')) {
|
||||
if (!allow || policyFrom === 'head') { process.stderr.write('gstack-pregate: --remote runs the base branch\'s remote_gate and needs --allow-repo-commands with --policy-from base\n'); return EXIT.refused; }
|
||||
if (!allow || policyFrom === 'head') { process.stderr.write('gstack-pregate: --remote runs the active policy\'s remote_gate (the base branch\'s copy, or the user-owned file when the base carries none) and needs --allow-repo-commands with --policy-from base\n'); return EXIT.refused; }
|
||||
const cap = Number(flag(rest, '--spend-cap') ?? process.env.GSTACK_SPEND_CAP_USD ?? NaN);
|
||||
if (!Number.isFinite(cap) || cap <= 0) { process.stderr.write('gstack-pregate: --remote spends money; pass --spend-cap <usd> (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; }
|
||||
remote = { spendCapUsd: cap };
|
||||
} else if (flag(rest, '--spend-cap') !== undefined) { process.stderr.write('gstack-pregate: --spend-cap applies only with --remote\n'); return EXIT.usage; }
|
||||
const base = resolveBaseBranch(root, flag(rest, '--base'));
|
||||
const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2] });
|
||||
const userConfigPath = defaultUserConfigPath(root);
|
||||
const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2], userConfigPath });
|
||||
if ('refused' in loaded) {
|
||||
const refusal = describeRefusal(loaded.refused, base);
|
||||
process.stderr.write(refusal.line + '\n');
|
||||
@@ -281,6 +282,8 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (bad) { process.stderr.write(`gstack-pregate: ${bad}; see gstack-pregate --help\n`); return EXIT.usage; }
|
||||
const root = repoRoot();
|
||||
if (!root) { process.stderr.write('gstack-pregate: not inside a git repository\n'); return EXIT.usage; }
|
||||
const dotenv = loadedDotenvFiles(process.cwd(), process.execArgv);
|
||||
if (dotenv.length) { process.stderr.write(resultLine('gstack-pregate', 'POLICY_SOURCE_UNAVAILABLE', `bun loaded ${dotenv.join(', ')} from the working directory, and a checkout's .env must not choose the state root or slug behind the policy; run gstack-pregate directly (its shebang passes --no-env-file) or as bun --no-env-file`) + '\n'); return EXIT.refused; }
|
||||
if (cmd === 'run') return await cmdRun(root, rest, json);
|
||||
if (cmd === 'verify') return cmdVerify(root, rest, json);
|
||||
if (cmd === 'clear') return cmdClear(root, rest);
|
||||
|
||||
@@ -224,6 +224,23 @@ esac
|
||||
WARN=""
|
||||
PATTERN=""
|
||||
|
||||
# Mask quoted -m/-am/--message argument content in git commit commands so that
|
||||
# descriptive commit messages do not trigger false positives (#1060). The
|
||||
# quote-aware scanner lives in mask-commit-message.awk beside this hook: as a
|
||||
# heredoc inside a command substitution, its quote characters are mis-parsed by
|
||||
# bash 3.2 (macOS /bin/bash), which made this whole hook a syntax error there.
|
||||
# A missing program or an awk failure leaves the command unmasked.
|
||||
CMD_MEDIUM="$CMD"
|
||||
CMD_MEDIUM_LOWER="$CMD_LOWER"
|
||||
_MASK_AWK="$_HOOK_DIR/mask-commit-message.awk"
|
||||
case "$CMD" in *git*commit*) _MAYBE_COMMIT=1 ;; *) _MAYBE_COMMIT=0 ;; esac
|
||||
if [ "$_MAYBE_COMMIT" -eq 1 ] && [ -f "$_MASK_AWK" ]; then
|
||||
if _cmd_masked=$(printf '%s' "$CMD" | awk -f "$_MASK_AWK" 2>/dev/null); then
|
||||
CMD_MEDIUM="$_cmd_masked"
|
||||
CMD_MEDIUM_LOWER=$(printf '%s' "$CMD_MEDIUM" | tr '[:upper:]' '[:lower:]')
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- PowerShell and cmd table (#3067) ---
|
||||
# Runs on the PowerShell tool's command, and on the part of any command from a
|
||||
# nested `powershell`/`pwsh`/`cmd` launcher onward (`pwsh -c "..."`,
|
||||
@@ -303,50 +320,50 @@ if [ -n "$_WIN_SCAN" ]; then
|
||||
fi
|
||||
|
||||
# rm -rf / rm -r / rm -R / rm --recursive (capital -R is BSD/macOS recursive)
|
||||
if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD" 2>/dev/null; then
|
||||
if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD_MEDIUM" 2>/dev/null; then
|
||||
WARN="Destructive: recursive delete (rm -r). This permanently removes files."
|
||||
PATTERN="rm_recursive"
|
||||
fi
|
||||
|
||||
# DROP TABLE / DROP DATABASE
|
||||
if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_LOWER" 2>/dev/null; then
|
||||
if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then
|
||||
WARN="Destructive: SQL DROP detected. This permanently deletes database objects."
|
||||
PATTERN="drop_table"
|
||||
fi
|
||||
|
||||
# TRUNCATE
|
||||
if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_LOWER" 2>/dev/null; then
|
||||
if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then
|
||||
WARN="Destructive: SQL TRUNCATE detected. This deletes all rows from a table."
|
||||
PATTERN="truncate"
|
||||
fi
|
||||
|
||||
# git push --force / git push -f / plus-refspec force (git push origin +ref)
|
||||
if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD" 2>/dev/null \
|
||||
&& grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD" 2>/dev/null; then
|
||||
if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD_MEDIUM" 2>/dev/null \
|
||||
&& grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD_MEDIUM" 2>/dev/null; then
|
||||
WARN="Destructive: git force-push rewrites remote history. Other contributors may lose work."
|
||||
PATTERN="git_force_push"
|
||||
fi
|
||||
|
||||
# git reset --hard
|
||||
if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD" 2>/dev/null; then
|
||||
if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD_MEDIUM" 2>/dev/null; then
|
||||
WARN="Destructive: git reset --hard discards all uncommitted changes."
|
||||
PATTERN="git_reset_hard"
|
||||
fi
|
||||
|
||||
# git checkout . / git restore .
|
||||
if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD" 2>/dev/null; then
|
||||
if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD_MEDIUM" 2>/dev/null; then
|
||||
WARN="Destructive: discards all uncommitted changes in the working tree."
|
||||
PATTERN="git_discard"
|
||||
fi
|
||||
|
||||
# kubectl delete
|
||||
if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD" 2>/dev/null; then
|
||||
if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD_MEDIUM" 2>/dev/null; then
|
||||
WARN="Destructive: kubectl delete removes Kubernetes resources. May impact production."
|
||||
PATTERN="kubectl_delete"
|
||||
fi
|
||||
|
||||
# docker rm -f / docker system prune
|
||||
if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD" 2>/dev/null; then
|
||||
if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD_MEDIUM" 2>/dev/null; then
|
||||
WARN="Destructive: Docker force-remove or prune. May delete running containers or cached images."
|
||||
PATTERN="docker_destructive"
|
||||
fi
|
||||
@@ -386,7 +403,7 @@ $_GSTACK_HOME_DIR/projects/$SLUG/careful-patterns.txt"
|
||||
_PAT_RC=0
|
||||
printf '' | grep -qE -- "$_PAT" 2>/dev/null || _PAT_RC=$?
|
||||
[ "$_PAT_RC" -eq 2 ] && continue # invalid ERE — skip the line
|
||||
if grep -qE -- "$_PAT" <<< "$CMD" 2>/dev/null; then
|
||||
if grep -qE -- "$_PAT" <<< "$CMD_MEDIUM" 2>/dev/null; then
|
||||
WARN="Project rule matched: $_PAT"
|
||||
PATTERN="project_rule"
|
||||
break
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# mask-commit-message.awk — read by careful/bin/check-careful.sh (#1060).
|
||||
# Prints the command with the quoted message argument of git commit
|
||||
# (-m, -am, --message) replaced by an empty quoted string. The command is
|
||||
# scanned as one string: newlines are kept, since bash runs each line as its
|
||||
# own command, and quote state carries across lines.
|
||||
#
|
||||
# Only git commit's own arguments are masked: the scanner splits the command
|
||||
# at unquoted ; & | ( ) and newlines, and masks a message flag only in a
|
||||
# segment whose first word is git and that has a commit word after it. The &
|
||||
# of a redirection (2>&1, &>) does not split. Any pipe after a masked message
|
||||
# prints the command unchanged, because git prints the message on stdout.
|
||||
# Quotes, backslashes and comments follow bash: a backslash escapes the next
|
||||
# character outside single quotes, and nothing inside single quotes; an
|
||||
# unquoted # starting a word runs to the end of its line. An unterminated
|
||||
# quote or escape prints the command unchanged.
|
||||
#
|
||||
# A message written as "$(cat <<'EOF' ... EOF )" (a quoted delimiter, so the
|
||||
# body is never expanded) is masked whole. Any other command, parameter or
|
||||
# process substitution, backtick, $'...' string or heredoc changes how bash
|
||||
# reads the quotes after it, so the command is printed unchanged.
|
||||
# heredoc_msg S N I — S[I] opens a double-quoted "$(cat <<'DELIM' ... DELIM )"
|
||||
# message: the index after its closing quote, else 0.
|
||||
function heredoc_msg(s, n, i, p, rest, strip, delim, e, line) {
|
||||
rest = substr(s, i + 1)
|
||||
if (!match(rest, /^\$\(cat[ \t]+<<-?[ \t]*/)) return 0
|
||||
strip = substr(rest, 1, RLENGTH) ~ /<<-/
|
||||
p = i + 1 + RLENGTH
|
||||
rest = substr(s, p)
|
||||
if (match(rest, /^'[A-Za-z_][A-Za-z0-9_]*'/) || match(rest, /^"[A-Za-z_][A-Za-z0-9_]*"/)) delim = substr(rest, 2, RLENGTH - 2)
|
||||
else if (match(rest, /^\\[A-Za-z_][A-Za-z0-9_]*/)) delim = substr(rest, 2, RLENGTH - 1)
|
||||
else return 0
|
||||
p += RLENGTH
|
||||
while (p <= n && substr(s, p, 1) ~ /[ \t]/) p++
|
||||
if (substr(s, p, 1) != "\n") return 0
|
||||
p++
|
||||
for (;;) {
|
||||
if (p > n) return 0
|
||||
e = index(substr(s, p), "\n")
|
||||
if (!e) return 0
|
||||
line = substr(s, p, e - 1)
|
||||
if (strip) sub(/^\t+/, "", line)
|
||||
p += e
|
||||
if (line == delim) break
|
||||
}
|
||||
while (p <= n && substr(s, p, 1) ~ /[ \t\n]/) p++
|
||||
if (substr(s, p, 2) != ")\"") return 0
|
||||
return p + 2
|
||||
}
|
||||
function handle_msg(s, n, i, j, c, safe) {
|
||||
if (i > n) return i
|
||||
if (substr(s, i, 1) == "'") {
|
||||
j = i + 1
|
||||
while (j <= n && substr(s, j, 1) != "'") j++
|
||||
if (j > n) return i
|
||||
result = result "''"
|
||||
return j + 1
|
||||
}
|
||||
if (substr(s, i, 1) == "\"") {
|
||||
if ((j = heredoc_msg(s, n, i)) > 0) { result = result "\"\""; return j }
|
||||
j = i + 1; safe = 1
|
||||
while (j <= n && (c = substr(s, j, 1)) != "\"") {
|
||||
if (c == "$" || c == "`" || c == "\\") safe = 0
|
||||
j++
|
||||
}
|
||||
if (!safe || j > n) return i
|
||||
result = result "\"\""
|
||||
return j + 1
|
||||
}
|
||||
return i
|
||||
}
|
||||
# flag S I — length of the message flag (with its trailing blanks) at S[I], or 0
|
||||
function flag(s, i, rest, m) {
|
||||
rest = substr(s, i)
|
||||
if (rest ~ /^--message=/) return 10
|
||||
if (match(rest, /^(--message|-am|-m)[ \t]+/)) return RLENGTH
|
||||
return 0
|
||||
}
|
||||
{ cmd = (NR > 1) ? cmd "\n" $0 : $0 }
|
||||
END {
|
||||
s = cmd
|
||||
n = length(s); sq = 0; dq = 0; bs = 0; i = 1
|
||||
atword = 1; words = 0; git = 0; commit = 0; masked = 0; piped = 0; unsure = 0
|
||||
while (i <= n) {
|
||||
c = substr(s, i, 1)
|
||||
if (bs) { result = result c; bs = 0; i++; continue }
|
||||
if (sq) { if (c == "'") sq = 0; result = result c; i++; continue }
|
||||
two = substr(s, i, 2)
|
||||
if (c == "`" || two == "$(" || two == "${") unsure = 1
|
||||
if (dq) {
|
||||
if (c == "\\") bs = 1
|
||||
else if (c == "\"") dq = 0
|
||||
result = result c; i++; continue
|
||||
}
|
||||
if (two == "<<" || two == "$'" || two == "<(" || two == ">(") unsure = 1
|
||||
if (atword && c == "#") {
|
||||
while (i <= n && substr(s, i, 1) != "\n") { result = result substr(s, i, 1); i++ }
|
||||
continue
|
||||
}
|
||||
if (c == "&" && (substr(s, i - 1, 1) ~ /[<>]/ || substr(s, i + 1, 1) == ">")) { atword = 0; result = result c; i++; continue }
|
||||
if (c ~ /[;&|()\n]/) {
|
||||
if (c == "|" && masked && substr(s, i + 1, 1) != "|" && substr(s, i - 1, 1) != "|") piped = 1
|
||||
atword = 1; words = 0; git = 0; commit = 0
|
||||
result = result c; i++; continue
|
||||
}
|
||||
if (c == " " || c == "\t") { atword = 1; result = result c; i++; continue }
|
||||
if (atword) {
|
||||
atword = 0; words++
|
||||
rest = substr(s, i)
|
||||
if (words == 1 && rest ~ /^git([ \t\n;&|()]|$)/) git = 1
|
||||
else if (git && !commit && rest ~ /^commit([ \t\n;&|()]|$)/) commit = 1
|
||||
else if (commit && (len = flag(s, i)) > 0) {
|
||||
result = result substr(s, i, len); i += len
|
||||
j = handle_msg(s, n, i)
|
||||
if (j > i) masked = 1
|
||||
atword = (j == i); i = j
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (c == "\\") bs = 1
|
||||
else if (c == "'") sq = 1
|
||||
else if (c == "\"") dq = 1
|
||||
result = result c; i++
|
||||
}
|
||||
printf "%s", (sq || dq || bs || piped || unsure) ? s : result
|
||||
}
|
||||
@@ -109,7 +109,11 @@ and `/proc` start time; elsewhere `ps -o lstart`), because a pid is reused after
|
||||
a reboot or a machine wake. `bin/gstack-upgrade`, and so the session-start
|
||||
self-upgrade, refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy)
|
||||
while a lease held by a live process outside its own chain exists, naming each
|
||||
holder as `pid <p> (<age>m <command>)`. A lease whose pid now belongs to another
|
||||
holder as `pid <p> (<age>m <command>)`. The team-mode SessionStart hook
|
||||
(`bin/gstack-session-update`) honors the same leases: it logs
|
||||
`SKIP leased pid=<p>/<age>m` to `analytics/session-update.log` and tries again at
|
||||
the next session start, without writing its hourly throttle stamp. A lease whose
|
||||
pid now belongs to another
|
||||
process, or an identity-less lease from an older gstack past 120 minutes, is
|
||||
stale: `bin/gstack-lease live` reclaims it (`gstack-lease: reclaimed stale
|
||||
lease …`). Stale needs proof: two well-formed identities from the same backend
|
||||
|
||||
+41
-9
@@ -19,7 +19,8 @@ Three bins, all over `lib/`:
|
||||
|
||||
A pin file names commands. Its presence never authorizes execution: both tools
|
||||
read the committed copy on `origin/<base>` (a PR cannot smuggle a command into
|
||||
its own gate) and run commands only with `--allow-repo-commands`, which `/ship`
|
||||
its own gate; `gstack-pregate run` alone also reads a user-owned file outside
|
||||
the repository when the base has none, see [pregate v1](#pregate-v1)) and run commands only with `--allow-repo-commands`, which `/ship`
|
||||
passes. Standalone, `gstack-regen check` lists the commands and exits 3
|
||||
(`REPO_COMMANDS_NOT_ALLOWED`); `gstack-pregate` marks `regen` and `lanes`
|
||||
`incomplete`, never `pass`. `--policy-from head` (a PR that introduces or
|
||||
@@ -65,6 +66,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
|
||||
| `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) |
|
||||
| `warn` | check ids downgraded to warn; `secrets` and required lanes never |
|
||||
| `strays.allow` | intentional root files or new top-level directories |
|
||||
| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no workflow lane |
|
||||
| `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) |
|
||||
| `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) |
|
||||
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
|
||||
@@ -72,8 +74,36 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
|
||||
| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides (see [supervision](#supervision)) |
|
||||
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
|
||||
|
||||
Only the `origin/<base>` copy is honoured, so a downgrade is reviewed before it
|
||||
takes effect. `gstack-pregate validate` / `init` cover the working-tree file.
|
||||
Only the `origin/<base>` copy or the user-owned file below is honoured, so a
|
||||
downgrade is reviewed (or is the user's own) before it takes effect. A repo that
|
||||
keeps `.gstack/` out of git (a shared repo where gstack is one contributor's
|
||||
tool) can hold the same file at `<state root>/projects/<slug>/pregate.json`. The
|
||||
state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`,
|
||||
else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `<slug>` is what
|
||||
`~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository
|
||||
(`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none
|
||||
(never under `--policy-from head`), it lives outside the repository so a PR still
|
||||
cannot reach it, and the record names it (`policy user <path>`) and fingerprints
|
||||
its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the
|
||||
ship receipt treat an edited or deleted user file, or one that appears (or a
|
||||
link on its path that starts pointing nowhere) after a run under the defaults,
|
||||
as `PREGATE_STALE`. A path that is relative, resolves inside the repository, or
|
||||
reaches it through any symlink on the way (a state root linked into the
|
||||
checkout, or a link hop inside it, even when the last hop points back outside)
|
||||
is ignored, since a PR could commit it or choose the file; `--explain` says
|
||||
so, or says `(absent)` when no file is there. The slug and state root come from
|
||||
the caller's environment, never the checkout's `.env`: the bin's shebang passes
|
||||
`--no-env-file`, and a bare `bun gstack-pregate` launched beside any `.env` file
|
||||
refuses (`POLICY_SOURCE_UNAVAILABLE`). `verify` reads both paths from the
|
||||
record, not from its own environment.
|
||||
It covers `gstack-pregate run` only: `sealed` and `price_file` are read from the
|
||||
base branch's copy, so the user file refuses them (`PREGATE_CONFIG_INVALID`)
|
||||
rather than holding a policy nothing reads. A user file that exists but cannot
|
||||
be read, or a symlink on its path that points nowhere, refuses the same way
|
||||
instead of falling back to defaults. A base copy git cannot read (and
|
||||
`git ls-tree` cannot confirm is absent) refuses with `POLICY_SOURCE_UNAVAILABLE`
|
||||
instead of handing the run to the user file.
|
||||
`gstack-pregate validate` / `init` cover the working-tree file.
|
||||
|
||||
`gstack-pregate init` writes a draft built from the repo, not a fixed example.
|
||||
It prints the languages it found and the runner each language's tests would
|
||||
@@ -115,13 +145,15 @@ Two stages with separate clocks, both printed:
|
||||
`TIMING_ASSERTIONS: found=<n> escaped=<n> files=<n>` summary. It warns
|
||||
(`PREGATE_TIMING_ASSERTION`); it does not block. See [timing](#timing).
|
||||
|
||||
- `guards`: each guard in `.gstack/pregate.json` whose `when` globs match a
|
||||
- `guards`: each guard in the active policy (`.gstack/pregate.json`, or the
|
||||
user-owned file) whose `when` globs match a
|
||||
touched file (renames included) runs its `command` in its own process, under
|
||||
the verified launch Bun, with its own `timeout_s` (default `--timeout`). A
|
||||
non-zero exit fails pregate naming the guard and its `fix`
|
||||
(`PREGATE_GUARD_FAILED`); a timeout is `incomplete`; without
|
||||
`--allow-repo-commands` the selected guards are listed and the check is
|
||||
`incomplete`. Guards are read from the base branch's copy and cannot be
|
||||
`incomplete`. Guards are read from the base branch's copy (or the
|
||||
user-owned file when the base carries none) and cannot be
|
||||
downgraded to warn. See [guards](#guards).
|
||||
|
||||
**Stage 2, test execution:**
|
||||
@@ -181,7 +213,7 @@ Two stages with separate clocks, both printed:
|
||||
failure, a `| tail`-hidden exit is still an exit; cargo's `test result:` lines, one per
|
||||
test binary and doc-tests included, are summed); a platform lane this
|
||||
machine cannot run becomes a `requires-remote` obligation naming
|
||||
`workflow.yml/job`. `--explain` prints each test's provenance and every
|
||||
`workflow.yml/job`, unless `local` names that lane. `--explain` prints each test's provenance and every
|
||||
uncertain mapping (a planner-computed matrix without a `selection` manifest
|
||||
selects conservatively). Test files matching `serial_patterns` (default
|
||||
`**/*.serial.test.*`) run one process per file after the unit files. The
|
||||
@@ -194,7 +226,7 @@ Two stages with separate clocks, both printed:
|
||||
runner process (the unit-file group, each serial file, each guard without
|
||||
its own `timeout_s`), not their total; the stall timeout catches a hung or
|
||||
silent one sooner.
|
||||
- Under 8 cores, a base-branch `remote_gate` or a remote `test_backend`
|
||||
- Under 8 cores, a `remote_gate` in the active policy or a remote `test_backend`
|
||||
(`gstack-config set test_backend ubicloud`) records the local test tier as a
|
||||
`requires-remote` obligation instead of running it: see [remote gate](#remote-gate).
|
||||
|
||||
@@ -260,8 +292,8 @@ misses a descendant that was reparented or started detached.
|
||||
<a id="remote-gate"></a>
|
||||
## Small machines and the remote gate
|
||||
|
||||
Under 8 cores (`os.availableParallelism()`), when the base branch's
|
||||
`.gstack/pregate.json` declares `remote_gate` or `gstack-config get
|
||||
Under 8 cores (`os.availableParallelism()`), when the active policy (the
|
||||
base branch's `.gstack/pregate.json`, or the user-owned file) declares `remote_gate` or `gstack-config get
|
||||
test_backend` is not `local`, the lanes check runs nothing locally: it records
|
||||
the selected local files as a `requires-remote` obligation with the command to
|
||||
run, `pregate.json/remote_gate` (the declared command) or
|
||||
|
||||
@@ -104,6 +104,9 @@ lives in `projects/<slug>/`. `bin/gstack-slug` picks the slug from the git
|
||||
and the ssh, https and ssh-with-port spellings of one repository share one.
|
||||
- Without a remote, the project root's directory name is used.
|
||||
|
||||
A bucket can also hold a user-owned `pregate.json` policy, for a repo that keeps
|
||||
`.gstack/` out of git (see [pregate v1](pregate.md#pregate-v1)).
|
||||
|
||||
The first slug gstack resolves in a directory sticks (it is cached under
|
||||
`slug-cache/`), so a project keeps its bucket when it adds a remote later.
|
||||
|
||||
|
||||
+28
-8
@@ -2474,7 +2474,13 @@ file is described in [docs/ship-policy.md](ship-policy.md); the queue flow in
|
||||
**Meaning.** The policy is read from the committed copy on `origin/<base>`,
|
||||
and that ref does not resolve here (a shallow or detached gate checkout, or a
|
||||
fetch that never happened). The tool exits 3 and never falls back to the
|
||||
working-tree file silently.
|
||||
working-tree file silently. For `gstack-pregate` the same refusal covers a base
|
||||
`.gstack/pregate.json` git cannot read (a timeout, an oversized blob, a missing
|
||||
or corrupt object) that `git ls-tree` cannot confirm is absent; it never falls
|
||||
through to the user-owned file. It also refuses when Bun loaded a `.env` file
|
||||
from the working directory (a bare `bun gstack-pregate`), since a branch could
|
||||
then pick the user policy: run `gstack-pregate` directly or as
|
||||
`bun --no-env-file`.
|
||||
|
||||
**Fix.** `git fetch origin <base> --depth=1`, then rerun. To run against the
|
||||
branch's own file deliberately, pass `--policy-from head --allow-repo-commands`;
|
||||
@@ -2877,7 +2883,11 @@ or `REALITY: surface-check finding bun upgrade --version absent on 1.3.14 bin/gs
|
||||
|
||||
Both tools read their pin file (`.gstack/generated.json`, `.gstack/pregate.json`)
|
||||
from `origin/<base>`, never the working tree, and run repo-declared commands only
|
||||
under `--allow-repo-commands` or inside `/ship`. Details: docs/pregate.md.
|
||||
under `--allow-repo-commands` or inside `/ship`. When the base carries no
|
||||
`.gstack/pregate.json`, `gstack-pregate run` reads
|
||||
`<state root>/projects/<slug>/pregate.json` instead (outside the repository; see
|
||||
docs/pregate.md for the state root and slug).
|
||||
Details: docs/pregate.md.
|
||||
|
||||
<a id="regen-registry-invalid"></a>
|
||||
### `REGEN_REGISTRY_INVALID`
|
||||
@@ -2932,10 +2942,16 @@ repository's lockfile), then rerun `gstack-regen check` or the pre-gate.
|
||||
|
||||
**Meaning.** `.gstack/pregate.json` has an unknown key, a wrong type, a glob
|
||||
that escapes the repository, a runner without `{files}`, or `secrets` in `warn`.
|
||||
The user-owned `<state root>/projects/<slug>/pregate.json` also refuses when it
|
||||
sets `sealed` or `price_file` (both are read only from the base branch's copy)
|
||||
or exists but cannot be read (a dangling symlink on its path counts).
|
||||
|
||||
**Fix.** `gstack-pregate validate`. `secrets` and required-lane obligations
|
||||
cannot be downgraded; every other check id in `warn` is honoured only from the
|
||||
base branch's copy.
|
||||
base branch's copy or the user-owned file. For the user-owned file, edit it
|
||||
directly at the path the message names. A user file inside the repository, or
|
||||
reached through a symlink into it, is ignored rather than read (`--explain`
|
||||
names it).
|
||||
|
||||
<a id="pregate-secrets"></a>
|
||||
### `PREGATE_SECRETS`
|
||||
@@ -2980,7 +2996,7 @@ allowlisted tool config no test names, are the assets-and-tool-config empty lane
|
||||
not this error.
|
||||
|
||||
**Fix.** Add a test that names the file, or declare its lane (an explicit `[]`
|
||||
for files with no behaviour) under `dependencies` in `.gstack/pregate.json`.
|
||||
for files with no behaviour) under `dependencies` in `.gstack/pregate.json` (or the user-owned file the message names).
|
||||
Other non-Markdown documentation goes in `empty_lanes` on the base branch.
|
||||
Dart files under `integration_test/` or `test_driver/` need a device, so pregate
|
||||
never runs them: declare `"**/integration_test/**": []` (and
|
||||
@@ -3019,7 +3035,7 @@ declare `runner`), raise `--timeout`, or keep the tree still, then rerun.
|
||||
|
||||
**Meaning.** A test command the lanes check (or a guard, or the `--remote`
|
||||
gate) launched wrote nothing to its log for the stall timeout (default 300 s,
|
||||
`stall_timeout_s` in the base branch's `.gstack/pregate.json`, or
|
||||
`stall_timeout_s` in the base branch's `.gstack/pregate.json` or the user-owned file, or
|
||||
`--stall-timeout`). pregate killed its process group and names the last test
|
||||
file started. The check is incomplete, never pass.
|
||||
|
||||
@@ -3047,7 +3063,10 @@ file with `--receipt`, or let `gstack-pregate --stage tests --remote --spend-cap
|
||||
### `PREGATE_STALE`
|
||||
|
||||
**Meaning.** `pregate.json` was recorded against a different working-tree
|
||||
fingerprint than the one being published (the final stamp, a regen, any edit).
|
||||
fingerprint than the one being published (the final stamp, a regen, any edit),
|
||||
or the user-owned policy it ran under was edited or deleted (or one appeared,
|
||||
or a symlink on its path started pointing nowhere, after a run under the
|
||||
defaults).
|
||||
|
||||
**Fix.** Rerun `gstack-pregate` after the final generation and stamp; Step 18
|
||||
refuses to write the ship receipt otherwise.
|
||||
@@ -3276,8 +3295,9 @@ next `./setup` prints no CRLF warning.
|
||||
<a id="pregate-guard-failed"></a>
|
||||
### `PREGATE_GUARD_FAILED`
|
||||
|
||||
**Meaning.** A guard from the base branch's `.gstack/pregate.json` (a repo
|
||||
command such as a serial-test manifest check or a CLI golden test) matched a
|
||||
**Meaning.** A guard (a repo command such as a serial-test manifest check or a
|
||||
CLI golden test) from the base branch's `.gstack/pregate.json`, or from the
|
||||
user-owned file when the base carries none, matched a
|
||||
file the branch touched and exited non-zero. Guards cover what the import graph
|
||||
cannot see: manifests, subprocess goldens, registries. They cannot be
|
||||
downgraded to warn.
|
||||
|
||||
@@ -73,6 +73,10 @@ export interface PregateFile {
|
||||
schema_version: 1; tree: string; checks: PregateCheck[]; run?: string; stage1_ms?: number; stage2_ms?: number;
|
||||
// C-pregate (C8): head/base identity, the policy sources, persisted requires-remote obligations and the one-line verdict.
|
||||
head?: string; base?: string; generated_at?: string; policy?: string; registry?: string; requires_remote?: PregateRemote[]; verdict?: string;
|
||||
/** sha256 of the user-owned policy when `policy` is `user <path>`; verify re-checks it. */
|
||||
policy_digest?: string;
|
||||
/** Under the defaults, the user-owned policy path that was looked for and absent; verify checks it is still absent. */
|
||||
policy_user_path?: string;
|
||||
}
|
||||
export interface ShipReceipt {
|
||||
schema_version: 1; head: string; base: string; pr?: number; version?: string; gated_tree?: string;
|
||||
@@ -189,7 +193,7 @@ export const ARTIFACT_SCHEMAS = {
|
||||
properties: {
|
||||
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
|
||||
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList } } },
|
||||
head: str, base: str, generated_at: str, policy: str, registry: str, verdict: str,
|
||||
head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str,
|
||||
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str } } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -220,8 +220,8 @@ function planGap(ctx: PregateContext, plan: RunPlan, total: number): Pick<CheckR
|
||||
const { unclaimed, missing, noPubspec } = plan;
|
||||
const detail = `no test runner detected for ${unclaimed.length} of ${total} selected file(s): ${unclaimed.slice(0, 8).join(' ')}${unclaimed.length > 8 ? ' …' : ''}${missing.length ? `; not on PATH: ${missing.join(', ')}` : ''}${noPubspec.length ? `; no pubspec.yaml above ${noPubspec.length} .dart file(s)` : ''}`;
|
||||
const fix = missing.length ? `install ${missing.join(' and ')}${fvmPinnedFor(ctx.repoRoot, unclaimed) ? ' (or fvm, which this repo pins)' : ''} and rerun`
|
||||
: noPubspec.length ? 'commit the Dart package\'s pubspec.yaml, or declare `runner` (with {files}) in .gstack/pregate.json'
|
||||
: 'declare `runner` (with {files}) in .gstack/pregate.json';
|
||||
: noPubspec.length ? `commit the Dart package's pubspec.yaml, or declare \`runner\` (with {files}) in ${ctx.configFile}`
|
||||
: `declare \`runner\` (with {files}) in ${ctx.configFile}`;
|
||||
return { code: 'PREGATE_INCOMPLETE', detail, fix };
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
|
||||
const timedOut = r.runs.filter(x => x.outcome === 'timeout');
|
||||
const rest = r.failed ? `; ${r.failed} failed` : '';
|
||||
if (stalled.length) {
|
||||
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in .gstack/pregate.json; 0 disables) for a test that is quiet for longer` };
|
||||
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that is quiet for longer` };
|
||||
}
|
||||
if (!timedOut.length) return { code: 'PREGATE_INCOMPLETE', detail: `the runner could not be launched (${total} files${rest})`, fix: 'read the lane log, fix the runner command, then rerun gstack-pregate --stage tests' };
|
||||
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
|
||||
@@ -379,7 +379,7 @@ export function remoteReportProblem(report: RemoteReport, tree: string): string
|
||||
*/
|
||||
async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult): Promise<CheckResult> {
|
||||
const lines = base.lines ?? [];
|
||||
if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: 'declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in .gstack/pregate.json on the base branch, or run the tests locally without --remote' };
|
||||
if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: `declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in ${ctx.configFile === PREGATE_CONFIG_PATH ? `${PREGATE_CONFIG_PATH} on the base branch` : ctx.configFile}, or run the tests locally without --remote` };
|
||||
const obligation = remoteObligation(ctx, sel);
|
||||
const cap = ctx.remote!.spendCapUsd;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-remote-'));
|
||||
@@ -411,7 +411,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
const lines = describe(sel);
|
||||
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines, remote: sel.remote };
|
||||
if (sel.noLane.length) {
|
||||
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(sel.noLane) };
|
||||
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) };
|
||||
}
|
||||
if (sel.tests.length === 0) {
|
||||
const inert = sel.inert.length ? `${sel.inert.length} asset or tool-config file(s)` : '';
|
||||
@@ -449,9 +449,9 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
return { ...withRun, status: sel.remote.length ? 'requires-remote' : 'pass', detail };
|
||||
}
|
||||
|
||||
function noLaneFix(noLane: readonly string[]): string {
|
||||
function noLaneFix(ctx: PregateContext, noLane: readonly string[]): string {
|
||||
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) and let CI run them` : '';
|
||||
return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${PREGATE_CONFIG_PATH}${device}`;
|
||||
return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}${device}`;
|
||||
}
|
||||
|
||||
export const lanesCheck: CheckDef = { id: 'lanes', stage: 'tests', tier: 1, downgradable: true, needsRepoCommands: true, run };
|
||||
@@ -31,8 +31,8 @@ function run(ctx: PregateContext): CheckResult {
|
||||
if (!baseDirs.has(top)) warnings.push(`${file}: new top-level directory ${top}/ (not on ${ctx.baseRef})`);
|
||||
}
|
||||
ctx.explain.push(`strays: base directories ${[...baseDirs].join(',')}; allow ${allow.join(',') || '(none)'}`);
|
||||
if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: 'delete or move the file; an intentional root file goes in .gstack/pregate.json strays.allow' };
|
||||
if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: 'confirm the directory is intended, or allow it in .gstack/pregate.json strays.allow' };
|
||||
if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: `delete or move the file; an intentional root file goes in ${ctx.configFile} strays.allow` };
|
||||
if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: `confirm the directory is intended, or allow it in ${ctx.configFile} strays.allow` };
|
||||
return { id: 'strays', stage: 'preflight', status: 'pass', inputs, detail: `${ctx.touched.filter(t => t.status === 'A').length} added file(s), none stray` };
|
||||
}
|
||||
|
||||
|
||||
+61
-7
@@ -16,6 +16,9 @@
|
||||
* warn check ids downgraded to warn (never `secrets`; `lanes` keeps
|
||||
* its required-lane and requires-remote verdicts)
|
||||
* strays.allow root files or new top-level directories that are intentional
|
||||
* local `<workflow.yml>/<job>` lanes a local run satisfies (a
|
||||
* platform-agnostic toolchain, or a repo whose CI is off):
|
||||
* their tests run here instead of becoming requires-remote
|
||||
* guards `{ id, when: [globs], command, fix?, timeout_s? }`: a repo
|
||||
* command that must pass when a touched file matches `when`
|
||||
* (manifest checks, CLI goldens); never downgradable
|
||||
@@ -30,10 +33,16 @@
|
||||
* remote_gate the repo's remote test gate command: under 8 cores the test
|
||||
* tier is a requires-remote obligation naming it, and
|
||||
* `--remote` runs it (it prints `PREGATE_REMOTE: tree=… tests=… failed=…`)
|
||||
*
|
||||
* A repo that keeps `.gstack/` out of git can hold the same file at
|
||||
* `<state root>/projects/<slug>/pregate.json` (lib/state-root.ts), honoured
|
||||
* only when the base carries none. It must sit outside the repo, so a PR
|
||||
* cannot reach it; a path inside the checkout is ignored (pregate/context).
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ResultCodeName } from '../result-codes';
|
||||
import { sealedErrors } from '../sealed-guard';
|
||||
import { containedRelative, globToRegExp } from '../ship-policy';
|
||||
@@ -52,6 +61,7 @@ export interface PregateConfig {
|
||||
selection: Record<string, string>;
|
||||
warn: string[];
|
||||
strays: { allow: string[] };
|
||||
local: string[];
|
||||
guards: Guard[];
|
||||
serial_patterns: string[];
|
||||
price_file: string | null;
|
||||
@@ -59,12 +69,13 @@ export interface PregateConfig {
|
||||
remote_gate: string | null;
|
||||
}
|
||||
export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number }
|
||||
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null };
|
||||
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null };
|
||||
export interface ConfigError { code: ResultCodeName; path: string; message: string }
|
||||
|
||||
export const MAX_LIST = 256;
|
||||
const MAX_COMMAND = 512;
|
||||
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
|
||||
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
|
||||
const LANE_ID = /^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/;
|
||||
const MAX_GUARDS = 32;
|
||||
const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s'];
|
||||
|
||||
@@ -153,7 +164,7 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
|
||||
const sel = obj.selection;
|
||||
if (!sel || typeof sel !== 'object' || Array.isArray(sel)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'selection', message: 'must be an object of <workflow.yml>/<job> → command' });
|
||||
else for (const [lane, command] of Object.entries(sel as Record<string, unknown>)) {
|
||||
if (!/^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be <workflow file>/<job id>' });
|
||||
if (!LANE_ID.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be <workflow file>/<job id>' });
|
||||
const why = commandError(command);
|
||||
if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: why });
|
||||
}
|
||||
@@ -176,6 +187,10 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
if ('local' in obj) {
|
||||
if (!Array.isArray(obj.local) || obj.local.some(l => typeof l !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'local', message: 'must be an array of <workflow file>/<job id>' });
|
||||
else (obj.local as string[]).forEach((l, i) => { if (!LANE_ID.test(l)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `local[${i}]`, message: `${JSON.stringify(l)} must be <workflow file>/<job id>` }); });
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -186,14 +201,15 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []):
|
||||
const errors = validateConfig(raw, knownChecks);
|
||||
if (errors.length) return { config: DEFAULT_CONFIG, errors };
|
||||
const o = raw as Partial<PregateConfig>;
|
||||
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] };
|
||||
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] };
|
||||
}
|
||||
|
||||
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'none' } | { unavailable: true; base: string; detail: string };
|
||||
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string } | { unavailable: true; base: string; detail: string };
|
||||
|
||||
function git(cwd: string, args: string[]): { ok: boolean; out: string } {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
|
||||
return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
|
||||
// r.error covers ENOBUFS, where stdout is cut at the buffer limit even with status 0.
|
||||
return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim() };
|
||||
}
|
||||
|
||||
export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: readonly string[] = []): ConfigLoad {
|
||||
@@ -201,7 +217,12 @@ export function loadConfigFromBase(repoRoot: string, base: string, knownChecks:
|
||||
const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
||||
if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` };
|
||||
const show = git(repoRoot, ['show', `${sha.out}:${PREGATE_CONFIG_PATH}`]);
|
||||
if (!show.ok) return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' };
|
||||
if (!show.ok) {
|
||||
// Absent only when the tree says so: a timeout, an oversized blob or a missing/corrupt object must refuse, not hand the run to the user file.
|
||||
const entry = git(repoRoot, ['ls-tree', '--name-only', sha.out, '--', PREGATE_CONFIG_PATH]);
|
||||
if (!entry.ok || entry.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref}:${PREGATE_CONFIG_PATH} could not be read and git cannot confirm it is absent` };
|
||||
return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' };
|
||||
}
|
||||
return { ...parseConfig(show.out, knownChecks), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' };
|
||||
}
|
||||
|
||||
@@ -211,6 +232,37 @@ export function loadConfigFromHead(repoRoot: string, knownChecks: readonly strin
|
||||
return { ...parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' };
|
||||
}
|
||||
|
||||
/** A symlink to nothing on the way to `file` (a dotfiles link not yet cloned): a policy the user meant to have, not an absent one. */
|
||||
function danglingLink(file: string): boolean {
|
||||
for (let p = file; path.dirname(p) !== p; p = path.dirname(p)) {
|
||||
try { if (fs.lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() && !fs.existsSync(p)) return true; } catch { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Nothing at `file` and no dangling link on the way to it: the only state that means "no user policy", for the loader and verify alike. */
|
||||
export function userPolicyAbsent(file: string): boolean {
|
||||
try { fs.statSync(file); return false; } catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT' && !danglingLink(file); }
|
||||
}
|
||||
|
||||
/** The user-owned copy for a repo that keeps `.gstack/` out of git; the caller consults it only when the base has none. */
|
||||
export function loadConfigFromUser(file: string, knownChecks: readonly string[] = []): ConfigLoad | null {
|
||||
let text: string;
|
||||
try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT' && userPolicyAbsent(file)) return null;
|
||||
// An unreadable policy must refuse, never fall back to defaults (its guards would silently vanish).
|
||||
return { ...parseConfig('{}', knownChecks), errors: [{ code: 'PREGATE_CONFIG_INVALID', path: file, message: `cannot read the user-owned policy: ${(e as Error).message}` }], present: true, label: `user ${file}`, kind: 'user' };
|
||||
}
|
||||
const load = parseConfig(text, knownChecks);
|
||||
// `sealed` (the pre-push guard) and `price_file` (gstack-eval-plan) read only the git copies, so a non-null value in the user file would look active and do nothing.
|
||||
let raw: unknown = null;
|
||||
try { raw = JSON.parse(text); } catch { /* parseConfig reported it */ }
|
||||
const ignored = raw && typeof raw === 'object' && !Array.isArray(raw) ? ['sealed', 'price_file'].filter(k => (raw as Record<string, unknown>)[k] != null) : [];
|
||||
// parseConfig names the repo copy for a JSON error; this file is not it.
|
||||
const errors = [...load.errors.map(e => e.path === PREGATE_CONFIG_PATH ? { ...e, path: file } : e), ...ignored.map(k => ({ code: 'PREGATE_CONFIG_INVALID' as const, path: k, message: `the user-owned policy covers gstack-pregate run only; ${k} is read from ${PREGATE_CONFIG_PATH} on the base branch` }))];
|
||||
return { ...load, errors, present: true, label: `user ${file}`, kind: 'user', digest: createHash('sha256').update(text).digest('hex') };
|
||||
}
|
||||
|
||||
export function isConfigUnavailable(load: ConfigLoad): load is { unavailable: true; base: string; detail: string } {
|
||||
return (load as any).unavailable === true;
|
||||
}
|
||||
@@ -223,11 +275,13 @@ export function explainConfig(): string {
|
||||
'selection: <workflow.yml>/<job> → the runner-owned command that lists that lane’s test files when its matrix is planner-computed',
|
||||
`warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`,
|
||||
'strays.allow: root files or new top-level directories that are intentional',
|
||||
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
|
||||
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
|
||||
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
|
||||
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)',
|
||||
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
|
||||
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
|
||||
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
|
||||
'no .gstack/pregate.json on the base: <state root>/projects/<slug>/pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin data dir, else ~/.gstack, per docs/state-root.md; run --explain prints the resolved path; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)',
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
+79
-7
@@ -9,13 +9,16 @@
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { slugFromEnvironment } from '../bin-context';
|
||||
import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type Registry } from '../regen';
|
||||
import { resolveStateRoot } from '../state-root';
|
||||
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
|
||||
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
|
||||
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
|
||||
import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, type ConfigError, type PregateConfig } from './config';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
|
||||
import type { CheckResult, PregateContext, TouchedFile } from './types';
|
||||
import { discoverLanes } from './workflows';
|
||||
import type { RegistryError } from '../regen';
|
||||
@@ -27,6 +30,8 @@ export interface BuildContextOptions {
|
||||
stallTimeoutMs?: number; progress?: (line: string) => void; cores?: number; testBackend?: string; remote?: { spendCapUsd: number } | null;
|
||||
/** How gstack's own bash bins launch (gstack-wtree, gstack-config); tests inject a Windows probe. */
|
||||
invocation?: InvocationOptions;
|
||||
/** `<state root>/projects/<slug>/pregate.json`: read only when the base carries no pregate.json, and never from a relative path or one inside the repository. */
|
||||
userConfigPath?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_STALL_TIMEOUT_S = 300;
|
||||
@@ -123,6 +128,61 @@ export function touchedFiles(repoRoot: string, mergeBase: string): TouchedFile[]
|
||||
return [...out.values()].filter(t => t.path !== 'node_modules' && !t.path.startsWith('node_modules/')).sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
/** The deepest existing ancestor realpathed (on-disk casing), the rest re-joined: a symlinked state root inside the checkout still counts as inside. */
|
||||
function realish(p: string): string {
|
||||
let head = path.resolve(p); const tail: string[] = [];
|
||||
while (!fs.existsSync(head) && path.dirname(head) !== head) { tail.unshift(path.basename(head)); head = path.dirname(head); }
|
||||
try { return path.join(fs.realpathSync.native(head), ...tail); } catch { return path.resolve(p); }
|
||||
}
|
||||
/**
|
||||
* Every location the path to `p` passes through, each symlink followed one hop at a time: a link inside the checkout picks
|
||||
* the file even when the last hop points back outside it. A missing component ends the walk with the rest joined lexically.
|
||||
*/
|
||||
function resolutionChain(p: string): string[] {
|
||||
const chain: string[] = [];
|
||||
let cur = path.parse(path.resolve(p)).root;
|
||||
let rest = path.relative(cur, path.resolve(p)).split(path.sep).filter(Boolean);
|
||||
for (let hops = 0; rest.length && hops <= 40;) {
|
||||
const next = path.join(cur, rest.shift()!);
|
||||
let st: fs.Stats | undefined;
|
||||
try { st = fs.lstatSync(next, { throwIfNoEntry: false }); } catch { st = undefined; }
|
||||
chain.push(st ? next : path.join(next, ...rest));
|
||||
if (!st) break;
|
||||
if (!st.isSymbolicLink()) { cur = next; continue; }
|
||||
hops++;
|
||||
const target = path.resolve(cur, fs.readlinkSync(next));
|
||||
cur = path.parse(target).root;
|
||||
rest = [...path.relative(cur, target).split(path.sep).filter(Boolean), ...rest];
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
const within = (root: string, p: string) => { const rel = path.relative(root, p); return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); };
|
||||
|
||||
/** Dotenv files Bun loads from the launch directory unless started with --no-env-file. */
|
||||
const DOTENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local', '.env.production', '.env.production.local', '.env.test', '.env.test.local'];
|
||||
|
||||
/**
|
||||
* The dotenv files in `cwd` that Bun loaded (no --no-env-file in `execArgv`). Any of them could set the slug or state
|
||||
* root that picks the user policy, and Bun's dotenv syntax is looser than a key scan can follow, so any one refuses.
|
||||
*/
|
||||
export function loadedDotenvFiles(cwd: string, execArgv: readonly string[]): string[] {
|
||||
if (execArgv.includes('--no-env-file')) return [];
|
||||
return DOTENV_FILES.filter(f => fs.existsSync(path.join(cwd, f)));
|
||||
}
|
||||
|
||||
/** `<state root>/projects/<slug>/pregate.json` for the repository at `root`, from the caller's environment. */
|
||||
export function defaultUserConfigPath(root: string): string {
|
||||
return path.join(resolveStateRoot(), 'projects', slugFromEnvironment(undefined, root), 'pregate.json');
|
||||
}
|
||||
|
||||
/** Why a user policy path must not be read, or null: a relative path (no HOME) or one a PR could commit is never trusted. */
|
||||
export function userConfigRejection(repoRoot: string, file: string): string | null {
|
||||
if (!path.isAbsolute(file)) return 'not an absolute path';
|
||||
const repo = path.resolve(repoRoot), real = realish(repoRoot);
|
||||
if (within(repo, path.resolve(file)) || [file, ...resolutionChain(file)].some(p => within(real, realish(p)))) return 'inside the repository; a PR could supply it';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
const base = o.base.replace(/^origin\//, '');
|
||||
const baseRef = `origin/${base}`;
|
||||
@@ -130,9 +190,18 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
|
||||
const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
|
||||
const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out;
|
||||
const configLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
|
||||
let configLoad: ConfigLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
|
||||
const explain: string[] = [];
|
||||
let absentUserPolicy: string | undefined;
|
||||
if (o.policyFrom === 'base' && o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') {
|
||||
const rejected = userConfigRejection(o.repoRoot, o.userConfigPath);
|
||||
const user = rejected ? null : loadConfigFromUser(o.userConfigPath, o.knownChecks);
|
||||
if (user) configLoad = user;
|
||||
else explain.push(`user policy: ${o.userConfigPath} ${rejected ? `ignored (${rejected})` : '(absent)'}`);
|
||||
if (!user && !rejected) absentUserPolicy = o.userConfigPath;
|
||||
}
|
||||
if (isConfigUnavailable(configLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: configLoad.detail } };
|
||||
if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors } };
|
||||
if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors, ...(configLoad.kind === 'user' ? { fix: `edit ${o.userConfigPath} directly at each path the message names, then rerun gstack-pregate; it lives outside the repository, so a base-branch change does not reach it` } : {}) } };
|
||||
const registryLoad = o.policyFrom === 'head' ? loadRegistryFromHead(o.repoRoot) : loadRegistryFromBase(o.repoRoot, base);
|
||||
if (isRegistryUnavailable(registryLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: registryLoad.detail } };
|
||||
if (registryLoad.errors.length) return { refused: { code: 'REGEN_REGISTRY_INVALID', message: `${registryLoad.source.label}: ${registryLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: registryLoad.errors } };
|
||||
@@ -142,15 +211,18 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
const wtree = fp.tree;
|
||||
const platform = o.platform ?? process.platform;
|
||||
const config = configLoad.config as PregateConfig;
|
||||
const lanes = discoverLanes(o.repoRoot, platform);
|
||||
// A lane that exists but is not platform-tier on this host already runs here, so only an unknown id is worth a line.
|
||||
for (const id of config.local) if (!lanes.some(l => l.id === id)) explain.push(`local: ${id} matches no workflow lane; ignored`);
|
||||
return {
|
||||
ctx: {
|
||||
repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase),
|
||||
config, configLabel: configLoad.label, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
lanes: discoverLanes(o.repoRoot, platform), platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000,
|
||||
config, configLabel: configLoad.label, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.kind === 'user' && configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000,
|
||||
stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000,
|
||||
progress: o.progress ?? (line => process.stderr.write(`${line}\n`)),
|
||||
cores: o.cores ?? os.availableParallelism(), testBackend: o.testBackend ?? readTestBackend(env, o.invocation), policyFrom: o.policyFrom, remote: o.remote ?? null,
|
||||
env, explain: [],
|
||||
env, explain,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -159,7 +231,7 @@ export function sha256(text: string): string { return createHash('sha256').updat
|
||||
|
||||
/** The common input identity every check records; checks add their own keys. */
|
||||
export function baseInputs(ctx: PregateContext, extra: Record<string, string> = {}): Record<string, string> {
|
||||
return { tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, touched: sha256(ctx.touched.map(t => `${t.status} ${t.path}`).join('\n')), ...extra };
|
||||
return { tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, touched: sha256(ctx.touched.map(t => `${t.status} ${t.path}`).join('\n')), ...(ctx.configDigest ? { policy: ctx.configDigest } : {}), ...extra };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+32
-6
@@ -22,8 +22,8 @@ import { regenCheck } from './checks/regen';
|
||||
import { secretsCheck } from './checks/secrets';
|
||||
import { straysCheck } from './checks/strays';
|
||||
import { timingCheck } from './checks/timing';
|
||||
import { NON_DOWNGRADABLE } from './config';
|
||||
import { withFingerprint } from './context';
|
||||
import { NON_DOWNGRADABLE, userPolicyAbsent } from './config';
|
||||
import { sha256, withFingerprint } from './context';
|
||||
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
|
||||
|
||||
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
|
||||
@@ -57,7 +57,7 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<Pr
|
||||
const remote: PregateRemote[] = checks.flatMap(c => c.remote ?? []).map(r => ({ ...r }));
|
||||
const file: PregateFile = {
|
||||
schema_version: 1, tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, generated_at: new Date().toISOString(),
|
||||
policy: ctx.configLabel, registry: ctx.registryLabel, run: o.run,
|
||||
policy: ctx.configLabel, ...(ctx.configDigest ? { policy_digest: ctx.configDigest } : {}), ...(ctx.absentUserPolicy ? { policy_user_path: ctx.absentUserPolicy } : {}), registry: ctx.registryLabel, run: o.run,
|
||||
stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' ? undefined : clocks.tests,
|
||||
checks: checks.map(c => { const { remote: _r, ...rest } = c; return rest; }),
|
||||
requires_remote: remote,
|
||||
@@ -122,12 +122,17 @@ export interface Verification { ok: boolean; code?: 'PREGATE_STALE' | 'PREGATE_R
|
||||
/**
|
||||
* Publication check: the recorded tree must equal the tree now (results
|
||||
* current after final generation and stamping), no check may be fail or
|
||||
* incomplete, and every requires-remote obligation needs its receipt.
|
||||
* incomplete, and every requires-remote obligation needs its receipt. The
|
||||
* user-owned policy behind the run (or its absence, under the defaults) must
|
||||
* still hold, since it lives outside the tree.
|
||||
*/
|
||||
export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: WtreeResult = wtreeFingerprint({ cwd: repoRoot })): Verification {
|
||||
const uncleared = (file.requires_remote ?? []).filter(r => !r.receipt);
|
||||
const current = 'tree' in treeNow && treeNow.tree === file.tree;
|
||||
if (!current) return { ok: false, code: 'PREGATE_STALE', message: `pregate.json was recorded against tree ${file.tree.slice(0, 12)}; ${'tree' in treeNow ? `the tree is now ${treeNow.tree.slice(0, 12)}` : `the tree cannot be fingerprinted now (${treeNow.error})`}`, current, uncleared };
|
||||
const treeCurrent = 'tree' in treeNow && treeNow.tree === file.tree;
|
||||
if (!treeCurrent) return { ok: false, code: 'PREGATE_STALE', message: `pregate.json was recorded against tree ${file.tree.slice(0, 12)}; ${'tree' in treeNow ? `the tree is now ${treeNow.tree.slice(0, 12)}` : `the tree cannot be fingerprinted now (${treeNow.error})`}`, current: false, uncleared };
|
||||
const policyChange = userPolicyChange(file);
|
||||
if (policyChange) return { ok: false, code: 'PREGATE_STALE', message: policyChange, current: false, uncleared };
|
||||
const current = true;
|
||||
const failed = file.checks.filter(c => c.status === 'fail');
|
||||
if (failed.length) return { ok: false, code: 'PREGATE_LANE_FAILED', message: `${failed.length} check(s) failed: ${failed.map(c => c.id).join(', ')}`, current, uncleared };
|
||||
const incomplete = file.checks.filter(c => c.status === 'incomplete' || c.status === 'not_installed');
|
||||
@@ -136,6 +141,27 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre
|
||||
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}`, current, uncleared };
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the user-owned policy a run recorded no longer holds, or null. Both paths come from the record, never the verifier's
|
||||
* environment: the policy file and its digest, or under the defaults the path that was looked for and absent.
|
||||
*/
|
||||
function userPolicyChange(file: PregateFile): string | null {
|
||||
const recorded = file.policy?.startsWith('user ') ? file.policy.slice('user '.length) : null;
|
||||
if (recorded) {
|
||||
if (!file.policy_digest) return `pregate.json names the user-owned policy ${recorded} without its digest; rerun gstack-pregate`;
|
||||
let text: string;
|
||||
try { text = fs.readFileSync(recorded, 'utf8'); } catch { return `the user-owned policy ${recorded} that pregate.json was recorded under is gone or unreadable; rerun gstack-pregate`; }
|
||||
return sha256(text) === file.policy_digest ? null : `the user-owned policy ${recorded} changed after pregate.json was recorded; rerun gstack-pregate`;
|
||||
}
|
||||
const absent = file.policy_user_path;
|
||||
if (!absent) return null;
|
||||
if (userPolicyAbsent(absent)) return null;
|
||||
let leaf: fs.Stats | undefined;
|
||||
try { leaf = fs.lstatSync(absent, { throwIfNoEntry: false }); } catch { leaf = undefined; }
|
||||
if (!leaf) return `cannot confirm ${absent} is still absent (a link on the way to it is dangling or unreadable); rerun gstack-pregate`;
|
||||
return `${absent} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`;
|
||||
}
|
||||
|
||||
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string }
|
||||
export type ClearDecision = { receipt: string; attested: boolean } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string };
|
||||
|
||||
|
||||
@@ -130,12 +130,13 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
const set = laneFileSet(ctx, lane);
|
||||
if (set !== 'all') laneSets[lane.id] = set;
|
||||
const inLane = set === 'all' ? selected : set === '?' ? selected : selected.filter(t => set.includes(t));
|
||||
if (lane.tier === 'platform') {
|
||||
if (lane.tier === 'platform' && ctx.config.local.includes(lane.id)) ctx.explain.push(`lane ${lane.id}: ${lane.platform} lane satisfied locally (pregate.json local)`);
|
||||
else if (lane.tier === 'platform') {
|
||||
if (inLane.length) remote.push({ lane: lane.id, platform: lane.platform, workflow: lane.workflow, job: lane.job, tests: inLane, receipt: null });
|
||||
continue;
|
||||
}
|
||||
if (set === 'all') anyLocalAll = true;
|
||||
if (inLane.length) { tiers.add(lane.tier); for (const t of inLane) local.add(t); }
|
||||
if (inLane.length) { tiers.add(lane.tier === 'platform' ? 'unit' : lane.tier); for (const t of inLane) local.add(t); }
|
||||
}
|
||||
if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t);
|
||||
for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t);
|
||||
|
||||
@@ -33,6 +33,12 @@ export interface PregateContext {
|
||||
touched: TouchedFile[];
|
||||
config: PregateConfig;
|
||||
configLabel: string;
|
||||
/** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */
|
||||
configFile: string;
|
||||
/** sha256 of the user-owned policy's bytes when it is active; recorded as `inputs.policy` (a base copy is pinned by `inputs.base`). */
|
||||
configDigest?: string;
|
||||
/** The user-owned policy path looked for and absent under the defaults; recorded so verify can tell when one appears. */
|
||||
absentUserPolicy?: string;
|
||||
registry: Registry;
|
||||
registryLabel: string;
|
||||
lanes: Lane[];
|
||||
|
||||
+2
-2
@@ -166,7 +166,7 @@ export const RESULT_CODES = {
|
||||
// C-stamp (plan C1, C2, C3, C5, C7): ship policy, restamp, tree receipt, ship receipt.
|
||||
POLICY_SOURCE_UNAVAILABLE: {
|
||||
anchor: 'policy-source-unavailable',
|
||||
summary: 'policy source unavailable: origin/<base> is not a resolvable commit, so the reviewed .gstack/ship-policy.json cannot be read',
|
||||
summary: 'policy source unavailable: origin/<base> is not a resolvable commit, or a pin file committed on it cannot be read, so the reviewed .gstack/ship-policy.json cannot be read',
|
||||
fix: 'git fetch origin <base> --depth=1 (the working-tree copy is never used silently)',
|
||||
},
|
||||
POLICY_INVALID: {
|
||||
@@ -364,7 +364,7 @@ export const RESULT_CODES = {
|
||||
},
|
||||
PREGATE_CONFIG_INVALID: {
|
||||
anchor: 'pregate-config-invalid',
|
||||
summary: '.gstack/pregate.json has an unknown key, a wrong type, a non-downgradable check in `warn`, or an uncontained glob',
|
||||
summary: 'the pregate policy (.gstack/pregate.json, or the user-owned copy) has an unknown key, a wrong type, a non-downgradable check in `warn`, or an uncontained glob',
|
||||
fix: 'run `gstack-pregate validate` and fix the file on the base branch (secrets and required lanes cannot be downgraded)',
|
||||
},
|
||||
PREGATE_SECRETS: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.205",
|
||||
"version": "1.91.215",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
BUN_VERSION=1.4.2
|
||||
NODE_VERSION=22.20.0
|
||||
NODE_VERSION=22.23.4
|
||||
NODE_SHA256=1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
sudo -E apt-get update -qq
|
||||
@@ -17,9 +18,13 @@ sudo -E apt-get install -y -qq --no-install-recommends \
|
||||
# needs; GitHub-hosted runners ship with this relaxed.
|
||||
sudo sysctl -qw kernel.apparmor_restrict_unprivileged_userns=0
|
||||
|
||||
# The same verified release archive as .github/docker/Dockerfile.ci.
|
||||
if ! command -v node >/dev/null; then
|
||||
curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
|
||||
| sudo tar -xJ -C /usr/local --strip-components=1
|
||||
node_tmp=$(mktemp -d)
|
||||
curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" -o "$node_tmp/node.tar.xz"
|
||||
echo "$NODE_SHA256 $node_tmp/node.tar.xz" | sha256sum -c - >/dev/null
|
||||
sudo tar -xJ -C /usr/local --strip-components=1 -f "$node_tmp/node.tar.xz"
|
||||
rm -rf "$node_tmp"
|
||||
fi
|
||||
|
||||
# The exact release archive, verified by SHA-256 before it is unpacked, as in
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts';
|
||||
import { linkedWorktrees, runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts';
|
||||
import { OWNED_READ_APPROVAL } from '../autoplan/bin/owned-read';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
@@ -192,3 +192,63 @@ describe('Autoplan hook journal-root verdicts', () => {
|
||||
for (const detail of ['unrecognized_shape:cwd_spelling', 'analytics/autoplan-guard.jsonl']) expect(guide).toContain(detail);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkedWorktrees from a project directory below the repository root (#3296)', () => {
|
||||
const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0' };
|
||||
const git = (cwd: string, ...args: string[]) => {
|
||||
const child = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000, env: gitEnv });
|
||||
expect(child.status).toBe(0);
|
||||
};
|
||||
function repo(): string {
|
||||
const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(dir);
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed');
|
||||
return dir;
|
||||
}
|
||||
const real = (dir: string) => fs.realpathSync(dir);
|
||||
|
||||
test.skipIf(process.platform === 'win32')('the reporter layout: a subfolder project directory owns its worktree under that subfolder', () => {
|
||||
const root = repo(), sub = path.join(root, 'sub'); fs.mkdirSync(path.join(sub, '.claude', 'worktrees'), { recursive: true });
|
||||
const worktree = path.join(sub, '.claude', 'worktrees', 'w');
|
||||
git(root, 'worktree', 'add', '-q', worktree);
|
||||
expect(linkedWorktrees(sub)).toEqual([real(worktree)]);
|
||||
expect(linkedWorktrees(path.join(sub, 'deeper', 'still'))).toEqual([real(worktree)]);
|
||||
expect(linkedWorktrees(root)).toEqual([real(worktree)]);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('a nested independent repository stops the walk at its own .git', () => {
|
||||
const outer = repo(), inner = path.join(outer, 'vendor', 'lib');
|
||||
fs.mkdirSync(inner, { recursive: true });
|
||||
git(inner, 'init', '-q', '-b', 'main');
|
||||
git(inner, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed');
|
||||
git(outer, 'worktree', 'add', '-q', path.join(outer, 'outer-wt'));
|
||||
expect(linkedWorktrees(inner)).toEqual([]);
|
||||
expect(linkedWorktrees(path.join(inner, 'src'))).toEqual([]);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('a project directory inside a linked worktree has no registry of its own', () => {
|
||||
const root = repo(), worktree = path.join(root, 'wt');
|
||||
git(root, 'worktree', 'add', '-q', worktree);
|
||||
fs.mkdirSync(path.join(worktree, 'pkg'));
|
||||
expect(linkedWorktrees(worktree)).toEqual([]);
|
||||
expect(linkedWorktrees(path.join(worktree, 'pkg'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('a registry entry whose worktree names another repository is still refused from a subfolder', () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(root);
|
||||
const other = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-other-'))); dirs.push(other);
|
||||
const worktree = path.join(root, 'sub', 'wt'); fs.mkdirSync(worktree, { recursive: true });
|
||||
const entry = path.join(root, '.git', 'worktrees', 'wt'); fs.mkdirSync(entry, { recursive: true });
|
||||
const foreign = path.join(other, '.git', 'worktrees', 'wt'); fs.mkdirSync(foreign, { recursive: true });
|
||||
fs.writeFileSync(path.join(entry, 'gitdir'), path.join(worktree, '.git') + '\n');
|
||||
fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${foreign}\n`);
|
||||
expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([]);
|
||||
fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${entry}\n`);
|
||||
expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([worktree]);
|
||||
});
|
||||
|
||||
test('a directory with no .git anywhere above it has no worktrees', () => {
|
||||
expect(linkedWorktrees(path.parse(tmpdir()).root)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,9 +16,9 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
let home: string;
|
||||
let remote: string;
|
||||
|
||||
function run(argv: string[]) {
|
||||
function run(argv: string[], extraEnv: Record<string, string> = {}) {
|
||||
const r = spawnSync(path.join(ROOT, 'bin', argv[0]), argv.slice(1), {
|
||||
env: { ...process.env, HOME: home, GSTACK_HOME: home },
|
||||
env: { ...process.env, HOME: home, GSTACK_HOME: home, ...extraEnv },
|
||||
encoding: 'utf-8',
|
||||
timeout: 60_000,
|
||||
});
|
||||
@@ -145,6 +145,24 @@ describe('artifacts_sync_removals', () => {
|
||||
expect(run(['gstack-brain-sync', '--publish-removals', '--yes']).stdout).toContain('no removals to publish');
|
||||
});
|
||||
|
||||
test('--publish-removals cleans up its temp files and lock on every exit path', () => {
|
||||
syncAll(['projects/p/designs/a.md', 'projects/p/designs/b.md']);
|
||||
fs.rmSync(path.join(home, 'projects/p/designs/a.md'));
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-removals-tmp-'));
|
||||
try {
|
||||
for (const argv of [['--publish-removals'], ['--publish-removals', '--yes'], ['--publish-removals', '--yes']]) {
|
||||
const r = run(['gstack-brain-sync', ...argv], { TMPDIR: tmp });
|
||||
expect(r.stderr).not.toContain('unbound variable');
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readdirSync(tmp)).toEqual([]);
|
||||
expect(fs.existsSync(path.join(home, '.brain-sync.lock.d'))).toBe(false);
|
||||
}
|
||||
expect(remoteFiles()).toEqual(['projects/p/designs/b.md']);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('config rejects values other than on and off', () => {
|
||||
const r = run(['gstack-config', 'set', 'artifacts_sync_removals', 'yes']);
|
||||
expect(r.stderr).toContain("artifacts_sync_removals 'yes' not recognized");
|
||||
|
||||
@@ -24,7 +24,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const DOCKERFILE = path.join(ROOT, '.github', 'docker', 'Dockerfile.ci');
|
||||
const DOCKERFILE = path.join(__dirname, '..', '.github', 'docker', 'Dockerfile.ci');
|
||||
|
||||
/** Package specs from every `npm i -g` / `npm install -g` in the Dockerfile. */
|
||||
export function globalNpmInstallSpecs(source: string): string[] {
|
||||
@@ -193,7 +193,7 @@ describe('other CI Bun installs match the image (#1713, #1706)', () => {
|
||||
test(`${rel} downloads the verified linux-x64 archive instead of piping an installer into a shell`, () => {
|
||||
expect(curlPipedToShell(source)).toEqual([]);
|
||||
const download = source.indexOf('/releases/download/bun-v$BUN_VERSION/bun-linux-x64.zip');
|
||||
const verify = source.indexOf('sha256sum -c');
|
||||
const verify = source.indexOf('sha256sum -c', download);
|
||||
const unpack = source.indexOf('unzip -q');
|
||||
expect(download).toBeGreaterThan(-1);
|
||||
expect(verify).toBeGreaterThan(download);
|
||||
@@ -216,3 +216,43 @@ describe('other CI Bun installs match the image (#1713, #1706)', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('CI Node.js installs match the image and are verified (#3354)', () => {
|
||||
const docker = fs.readFileSync(DOCKERFILE, 'utf-8');
|
||||
const imageVersion = docker.match(/^ENV NODE_VERSION=(\S+)$/m)?.[1];
|
||||
const imageSha = docker.match(/^ARG NODE_SHA256=(\S+)$/m)?.[1];
|
||||
const nodeRun = logicalLines(docker).find((line) => line.includes('nodejs.org/dist/v${NODE_VERSION}/')) ?? '';
|
||||
|
||||
test('Dockerfile.ci pins an exact Node.js version and the SHA-256 of its linux-x64 archive', () => {
|
||||
expect(imageVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
expect(imageSha).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('Dockerfile.ci checks the Node.js archive before it is unpacked', () => {
|
||||
expect(nodeRun, 'Node.js must come from the nodejs.org release archive').not.toBe('');
|
||||
const download = nodeRun.indexOf('-o /tmp/node.tar.xz');
|
||||
const verify = nodeRun.indexOf('echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c -');
|
||||
const unpack = nodeRun.indexOf('tar -xJ');
|
||||
expect(download).toBeGreaterThan(-1);
|
||||
expect(verify).toBeGreaterThan(download);
|
||||
expect(unpack).toBeGreaterThan(verify);
|
||||
});
|
||||
|
||||
const rel = 'scripts/ubicloud/setup-free-suite.sh';
|
||||
const source = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||
|
||||
test(`${rel} pins the same Node.js version and SHA-256 as Dockerfile.ci`, () => {
|
||||
expect(imageSha).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(source.match(/^NODE_VERSION=(\S+)$/m)?.[1]).toBe(imageVersion!);
|
||||
expect(source.match(/^NODE_SHA256=(\S+)$/m)?.[1]).toBe(imageSha!);
|
||||
});
|
||||
|
||||
test(`${rel} checks the Node.js archive before it is unpacked`, () => {
|
||||
const download = source.indexOf('/node-v$NODE_VERSION-linux-x64.tar.xz" -o "$node_tmp/node.tar.xz"');
|
||||
const verify = source.indexOf('echo "$NODE_SHA256 $node_tmp/node.tar.xz" | sha256sum -c -');
|
||||
const unpack = source.indexOf('tar -xJ');
|
||||
expect(download).toBeGreaterThan(-1);
|
||||
expect(verify).toBeGreaterThan(download);
|
||||
expect(unpack).toBeGreaterThan(verify);
|
||||
});
|
||||
});
|
||||
+27
-1
@@ -5,7 +5,7 @@
|
||||
* the correct SCOPE_* variables are output.
|
||||
*/
|
||||
import { describe, test, expect, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
@@ -245,6 +245,20 @@ describe('glob classes (table-driven, #2526 + #2455)', () => {
|
||||
{ name: 'plain backend', file: 'server.go', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_FRONTEND'] },
|
||||
{ name: 'docs', file: 'docs/guide.md', expects: ['SCOPE_DOCS'] },
|
||||
{ name: 'prompts', file: 'app/services/prompt_builder.rb', expects: ['SCOPE_PROMPTS', 'SCOPE_BACKEND'] },
|
||||
// Python tests and nested test dirs (#3335): the issue's three paths.
|
||||
{ name: 'nested tests/test_*.py (#3335)', file: 'components/orch/services/shared/tests/test_native_store_custody.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
|
||||
{ name: 'second nested tests/test_*.py (#3335)', file: 'components/orch/services/shared/tests/test_infra_promoted_first_apply.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
|
||||
{ name: 'hyphenated service tests/test_*.py (#3335)', file: 'components/orch/services/api-gateway/tests/test_native_chat_checkpoint_store.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
|
||||
{ name: 'test_*.py outside a tests dir', file: 'pkg/test_store.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
|
||||
{ name: 'Django tests.py', file: 'shop/tests.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
|
||||
{ name: 'pytest conftest.py', file: 'services/api/conftest.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
|
||||
{ name: 'nested __tests__ dir', file: 'packages/ui/__tests__/render.ts', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
|
||||
{ name: 'nested spec dir', file: 'engines/billing/spec/invoice_checker.rb', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
|
||||
// Controls: a name that only contains "test" is not a test file.
|
||||
{ name: 'latest.py is not a test', file: 'pkg/latest.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
|
||||
{ name: 'contest_rules.py is not a test', file: 'pkg/contest_rules.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
|
||||
{ name: 'testing/ dir is not a test dir', file: 'pkg/testing/fixtures_loader.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
|
||||
{ name: 'attest_*.py is not a test', file: 'pkg/attest_signer.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
|
||||
];
|
||||
|
||||
for (const row of TABLE) {
|
||||
@@ -262,6 +276,18 @@ describe('glob classes (table-driven, #2526 + #2455)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('one test-path rule (#3335)', () => {
|
||||
test('TEST_PATH_RE is byte-identical to the test-bootstrap TESTFILES regex', () => {
|
||||
const script = readFileSync(SCRIPT, 'utf8');
|
||||
const scriptRe = script.match(/^TEST_PATH_RE='([^']+)'$/m)?.[1];
|
||||
const resolver = readFileSync(join(import.meta.dir, '..', 'scripts', 'resolvers', 'testing.ts'), 'utf8');
|
||||
const resolverRe = resolver.match(/git ls-files \| grep -cE '([^']+)'/)?.[1]?.replace(/\\\\/g, '\\');
|
||||
expect(scriptRe).toBeDefined();
|
||||
expect(resolverRe).toBeDefined();
|
||||
expect(scriptRe).toBe(resolverRe);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exit-code contract (#2526)', () => {
|
||||
test('clean tree, no changes → all false, exit 0, no SCOPE_ERROR', () => {
|
||||
const dir = createRepo([]);
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { gitEnv } from "./helpers/git-env";
|
||||
import { mkdirpSync } from "../lib/fs-utils";
|
||||
|
||||
const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
|
||||
@@ -93,7 +94,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", (
|
||||
const base = tmpdir();
|
||||
try {
|
||||
const repo = path.join(base, "repo");
|
||||
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
||||
spawnSync("git", ["init", "-q", repo], { timeout: 30_000, env: gitEnv() });
|
||||
const hookDir = path.join(repo, ".git", "hooks");
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
const hookPath = path.join(hookDir, "pre-push");
|
||||
@@ -106,6 +107,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", (
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
env: gitEnv(),
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr ?? "").not.toContain("EEXIST");
|
||||
|
||||
@@ -644,5 +644,104 @@ describe("gstack-global-discover", () => {
|
||||
expect(json.repos).toHaveLength(1);
|
||||
expect(json.repos[0].sessions.claude_code).toBe(1);
|
||||
});
|
||||
|
||||
// Claude Code worktrees live at <repo>/.claude/worktrees/<name> and are removed once
|
||||
// their branch merges; the session history under ~/.claude/projects stays behind.
|
||||
function removedWorktree(main: string, name = "feat-x"): string {
|
||||
const wt = join(main, ".claude", "worktrees", name);
|
||||
git(main, "worktree", "add", "-q", wt);
|
||||
return wt;
|
||||
}
|
||||
|
||||
test("sessions of a removed Claude worktree count with the repository it was created from", () => {
|
||||
const main = repo("work/ai-blogger-lab", "https://github.com/acme/ai-blogger-lab.git");
|
||||
const wt = removedWorktree(main);
|
||||
session(main, "main-1");
|
||||
session(main, "main-2");
|
||||
session(wt, "wt-1");
|
||||
session(wt, "wt-2");
|
||||
session(wt, "wt-3");
|
||||
git(main, "worktree", "remove", "--force", wt);
|
||||
expect(existsSync(wt)).toBe(false);
|
||||
const json = discover();
|
||||
expect(json.tools.claude_code.total_sessions).toBe(5);
|
||||
expect(json.repos).toHaveLength(1);
|
||||
expect(json.repos[0].sessions.claude_code).toBe(5);
|
||||
expect(json.repos[0].paths).toEqual([main]);
|
||||
});
|
||||
|
||||
test("a session started in a subdirectory of a removed Claude worktree counts too", () => {
|
||||
const main = repo("work/app", "git@github.com:acme/app.git");
|
||||
const wt = removedWorktree(main);
|
||||
mkdirSync(join(wt, "packages", "web"), { recursive: true });
|
||||
session(join(wt, "packages", "web"), "sub-1");
|
||||
git(main, "worktree", "remove", "--force", wt);
|
||||
const r = discover().repos.find((x) => x.remote === "https://github.com/acme/app")!;
|
||||
expect(r.sessions.claude_code).toBe(1);
|
||||
expect(r.paths).toEqual([main]);
|
||||
});
|
||||
|
||||
test("a removed Claude worktree of a repository with no remote counts with its repository", () => {
|
||||
const main = repo("work/local-only");
|
||||
const wt = removedWorktree(main);
|
||||
session(main, "l-1");
|
||||
session(wt, "l-2");
|
||||
git(main, "worktree", "remove", "--force", wt);
|
||||
const local = discover().repos.filter((x) => x.remote.startsWith("local:"));
|
||||
expect(local).toHaveLength(1);
|
||||
expect(local[0].sessions.claude_code).toBe(2);
|
||||
});
|
||||
|
||||
test("a live Claude worktree keeps its own path next to its repository's", () => {
|
||||
const main = repo("work/live", "https://github.com/acme/live.git");
|
||||
const wt = removedWorktree(main);
|
||||
session(main, "m-1");
|
||||
session(wt, "w-1");
|
||||
const r = discover().repos.find((x) => x.remote === "https://github.com/acme/live")!;
|
||||
expect(r.sessions.claude_code).toBe(2);
|
||||
expect(r.paths.sort()).toEqual([main, wt].sort());
|
||||
});
|
||||
|
||||
test("a removed Claude worktree whose repository is also gone is not counted as a repository", () => {
|
||||
const gone = join(home, "work", "gone-repo");
|
||||
session(join(gone, ".claude", "worktrees", "feat-x"), "g-1");
|
||||
const json = discover();
|
||||
expect(json.tools.claude_code.total_sessions).toBe(1);
|
||||
expect(json.repos).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a removed directory that is not a Claude worktree is not counted as a repository", () => {
|
||||
const main = repo("work/real", "https://github.com/acme/real.git");
|
||||
session(join(main, "some", "deleted-dir"), "d-1");
|
||||
session(join(main, ".claude", "worktrees"), "d-2");
|
||||
session(main, "r-1");
|
||||
const json = discover();
|
||||
expect(json.tools.claude_code.total_sessions).toBe(3);
|
||||
expect(json.repos).toHaveLength(1);
|
||||
expect(json.repos[0].sessions.claude_code).toBe(1);
|
||||
});
|
||||
|
||||
describe("parentRepoOfClaudeWorktree", () => {
|
||||
let parentRepoOfClaudeWorktree: (cwd: string) => string | null;
|
||||
beforeEach(async () => {
|
||||
parentRepoOfClaudeWorktree = (await import("../bin/gstack-global-discover.ts")).parentRepoOfClaudeWorktree;
|
||||
});
|
||||
|
||||
test("returns the repository root for a worktree path, with or without a subdirectory", () => {
|
||||
expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x")).toBe("/Users/me/code/app");
|
||||
expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x/src/lib")).toBe("/Users/me/code/app");
|
||||
expect(parentRepoOfClaudeWorktree("C:\\code\\app\\.claude\\worktrees\\feat-x")).toBe("C:\\code\\app");
|
||||
});
|
||||
|
||||
test("returns null for any other path", () => {
|
||||
for (const p of [
|
||||
"/Users/me/code/app",
|
||||
"/Users/me/code/app/.claude/worktrees",
|
||||
"/Users/me/code/app/.claude/worktrees/",
|
||||
"/Users/me/code/not.claude/worktrees/x",
|
||||
"/.claude/worktrees/x",
|
||||
]) expect(parentRepoOfClaudeWorktree(p)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Environment for fixture git commands and `gstack-redact install-prepush-hook`.
|
||||
*
|
||||
* With the parent environment, a developer's global or system core.hooksPath
|
||||
* (or the same key via GIT_CONFIG_COUNT / GIT_CONFIG_PARAMETERS) reaches the
|
||||
* fixture: `git rev-parse --git-path hooks` resolves to that directory, so the
|
||||
* install rewrites the developer's own pre-push (#3353), and their hook runs
|
||||
* on fixture pushes and rejects the local remote (#2967). /dev/null drops the
|
||||
* global file on every platform: Git for Windows rejects NUL (see
|
||||
* EMPTY_GLOBAL_GIT_CONFIG in lib/cso/process.ts). Config written inside the
|
||||
* fixture repo still applies.
|
||||
*/
|
||||
export function gitEnv(extra: Record<string, string | undefined> = {}): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, ...extra };
|
||||
env.GIT_CONFIG_GLOBAL = "/dev/null";
|
||||
env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
env.GIT_CONFIG_COUNT = "0";
|
||||
delete env.GIT_CONFIG_PARAMETERS;
|
||||
return env;
|
||||
}
|
||||
@@ -342,6 +342,215 @@ describe('check-careful.sh', () => {
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
|
||||
});
|
||||
|
||||
// --- Commit message false positives (#1060) ---
|
||||
|
||||
describe('commit message false positives (#1060)', () => {
|
||||
// Quoted -m content describes destructive patterns but does not run them.
|
||||
test.each([
|
||||
'git commit -m "fix: prevent accidental drop table in migration"',
|
||||
'git commit -m "docs: explain truncate vs delete semantics"',
|
||||
'git commit -m "feat: add guard for recursive delete patterns"',
|
||||
'git add . && git commit -m "fix: handle drop database edge case"',
|
||||
])('does not warn on descriptive text in commit message: %s', (command) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
|
||||
// Real destructive commands chained with a commit still warn.
|
||||
test('warns when a real DROP TABLE follows git commit in a chain', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
CAREFUL_SCRIPT,
|
||||
carefulInput('git commit -m "release" && psql -c "DROP TABLE staging"'),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL DROP');
|
||||
});
|
||||
|
||||
test('warns when a real TRUNCATE follows git commit in a chain', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
CAREFUL_SCRIPT,
|
||||
carefulInput('git commit -m "cleanup" && psql -c "TRUNCATE audit_log"'),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL TRUNCATE');
|
||||
});
|
||||
|
||||
// Quote-aware awk scanner: a single-quoted message whose content looks like
|
||||
// a flag boundary must not swallow real commands that follow via &&.
|
||||
test('warns when rm follows a single-quoted message that embeds a double-quote', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
CAREFUL_SCRIPT,
|
||||
carefulInput(`git commit -m 'note -m "' && rm -rf build && echo '"'`),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
|
||||
});
|
||||
|
||||
// A double-quoted message containing $(...) is not masked — the substitution
|
||||
// content remains visible to the MEDIUM checks.
|
||||
test('warns when a commit message contains a command substitution with rm', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
CAREFUL_SCRIPT,
|
||||
carefulInput('git commit -m "$(rm -rf build)"'),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
|
||||
});
|
||||
|
||||
// A force-push chained after a commit must still warn even after the message is masked.
|
||||
test('warns on force-push chained after git commit', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
CAREFUL_SCRIPT,
|
||||
carefulInput('git commit -m "x" && git push --force'),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push');
|
||||
});
|
||||
|
||||
// -am shorthand: the message is masked, so "drop table" in the message text
|
||||
// does not trigger the SQL DROP warning.
|
||||
test('does not warn on drop-table text in -am commit message', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
CAREFUL_SCRIPT,
|
||||
carefulInput('git commit -am "drop table notes"'),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
|
||||
// bash 3.2 (macOS /bin/bash) mis-parses quote characters in a heredoc
|
||||
// inside $( ), so the awk program inline made the whole hook a syntax error
|
||||
// there. It is a file beside the hook, read with awk -f.
|
||||
test('the masking program is a file beside the hook, not a heredoc inside $( )', () => {
|
||||
const source = fs.readFileSync(CAREFUL_SCRIPT, 'utf8');
|
||||
expect(source).not.toMatch(/\$\(cat <</);
|
||||
expect(source).toContain('awk -f "$_MASK_AWK"');
|
||||
expect(fs.existsSync(path.join(path.dirname(CAREFUL_SCRIPT), 'mask-commit-message.awk'))).toBe(true);
|
||||
});
|
||||
|
||||
// Newlines separate commands, and quote state carries across them. The
|
||||
// per-line scanner joined lines (`-m xtruncate`) and restarted quote
|
||||
// state on every line, so a quoted line break could hide a real command.
|
||||
test.each([
|
||||
['git commit -m x\ntruncate -s 0 f', 'SQL TRUNCATE'],
|
||||
['git commit -m wip\nrm -rf build', 'recursive delete'],
|
||||
["git commit -m \"a\n-m 'x\" && rm -rf build && echo '", 'recursive delete'],
|
||||
])('a command on the next line still asks: %j', (command, reason) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
|
||||
});
|
||||
|
||||
test('a multi-line quoted message is masked as one message', () => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git commit -m "fix: guard migrations\n\nNo more drop table in the seed script."'));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
|
||||
// Only git commit's own message is masked: a -m argument of another
|
||||
// command in the chain, a commit whose output is piped into a shell, and
|
||||
// an escaped quote inside the message all stay visible.
|
||||
test.each([
|
||||
["git commit -m a && ls | parallel -m 'rm -rf build'", 'recursive delete'],
|
||||
["git commit -m 'rm -rf build' | sh", 'recursive delete'],
|
||||
["echo git commit -m 'drop table users'", 'SQL DROP'],
|
||||
['git commit -m "a\\"b" && rm -rf build', 'recursive delete'],
|
||||
["git commit -m wip \\\n&& rm -rf build", 'recursive delete'],
|
||||
])('text outside commit\'s own message still asks: %j', (command, reason) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'git -C repo commit --message="docs: truncate the changelog intro"',
|
||||
"git commit --message 'chore: drop database fixtures from the seed'",
|
||||
"git commit --amend -m 'fix: git reset --hard in the docs'",
|
||||
])('commit\'s own message is masked in every flag form: %s', (command) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
|
||||
// The form agents use most: a quoted-delimiter heredoc inside "$( )". Its
|
||||
// body is never expanded, so it is masked whole. Any other substitution,
|
||||
// backtick, $'...' or heredoc leaves the command unmasked.
|
||||
const heredocMessage = (body: string, delimiter = "'EOF'", end = 'EOF') =>
|
||||
`git commit -m "$(cat <<${delimiter}\n${body}\n${end}\n)"`;
|
||||
test.each([
|
||||
heredocMessage('fix: guard the migration that could drop table users\n\nCo-Authored-By: A <a@example.com>'),
|
||||
heredocMessage('docs: why we never truncate "audit" rows (see #12)'),
|
||||
heredocMessage('chore: git reset --hard is documented', '"MSG"', 'MSG'),
|
||||
heredocMessage('chore: rm -rf build in the clean script', "-'EOF'", '\tEOF'),
|
||||
])('a quoted-delimiter heredoc message is masked: %j', (command) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[heredocMessage('docs: drop table notes') + ' && git push --force', 'force-push'],
|
||||
[heredocMessage('drop table $(rm -rf build)', 'EOF'), 'recursive delete'],
|
||||
[`git commit -m "$(echo " -m 'x" ; rm -rf build ; echo ')"`, 'recursive delete'],
|
||||
['git commit -m "\`rm -rf build\`"', 'recursive delete'],
|
||||
["git commit -F - <<'EOF'\ndocs: drop table notes\nEOF", 'SQL DROP'],
|
||||
["git commit -m $'drop table notes\\n'", 'SQL DROP'],
|
||||
])('other substitutions and heredocs stay unmasked: %j', (command, reason) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
|
||||
});
|
||||
|
||||
// git prints the message on stdout, so any pipe after a masked message,
|
||||
// a process substitution or a parameter expansion leaves it unmasked; a
|
||||
// redirection's & is not a separator, and a comment's quote is not a quote.
|
||||
test.each([
|
||||
"git commit -m 'rm -rf build' 2>&1 | sh",
|
||||
"(git commit -m 'rm -rf build') | sh",
|
||||
"git commit -m 'rm -rf build' > >(sh)",
|
||||
'git commit -m "${MSG:-rm -rf build}"',
|
||||
])('a message that can reach a shell stays unmasked: %j', (command) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
|
||||
});
|
||||
|
||||
test.each([
|
||||
"git commit -m 'drop table notes' 2>&1 && git log -1 --oneline",
|
||||
"git commit -m 'drop table notes' || echo failed",
|
||||
"git commit -m 'drop table notes' # it's the docs commit",
|
||||
])('redirections, || and comments keep the message masked: %j', (command) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a partial install without the masking program still asks on the message text', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-nomask-'));
|
||||
try {
|
||||
for (const file of ['check-careful.sh', 'hook-extract.sh']) {
|
||||
fs.copyFileSync(path.join(path.dirname(CAREFUL_SCRIPT), file), path.join(dir, file));
|
||||
}
|
||||
const { exitCode, output } = runHook(path.join(dir, 'check-careful.sh'), carefulInput('git commit -m "drop table notes"'));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL DROP');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Shell obfuscation ---
|
||||
|
||||
describe('shell obfuscation', () => {
|
||||
|
||||
@@ -301,6 +301,7 @@ describe('gstack-pregate on a Rust workspace', () => {
|
||||
// Value: protects=the reported case end to end: a touched src file of a workspace crate is not PREGATE_NO_LANE, its crate and the
|
||||
// dependent crates run with `cargo test -p` from the workspace root, and the summed count is reported; fails_when=any link of
|
||||
// graph, plan or summary regresses; why_new=no Rust run existed; seam=none; no_claim=a real cargo (PATH shim reports cwd and argv)
|
||||
// TODO(#3380): Windows gap. This case is skipped on win32 until the cargo stand-in runs under the lane's Git Bash there; https://github.com/garrytan/gstack/pull/3380
|
||||
posixShim('a touched src file runs its crate and the crates depending on it from the workspace root', () => {
|
||||
const repo = rustRepo();
|
||||
commit(repo, { 'engine/crates/alpha/src/util.rs': 'pub fn two() -> u32 { 1 + 1 }\n' });
|
||||
@@ -315,6 +316,7 @@ describe('gstack-pregate on a Rust workspace', () => {
|
||||
// Value: protects=a crate whose test binaries all report 0 tests is ZERO-RUN (exit 0 is not a pass), and a failing cargo run is
|
||||
// PREGATE_LANE_FAILED with its counts; fails_when=cargo's summary is unparsed (count unavailable passes) or the exit is ignored;
|
||||
// why_new=the pure parser test does not run the lane; seam=none; no_claim=a real cargo (PATH shim)
|
||||
// TODO(#3380): Windows gap. This case is skipped on win32 until the cargo stand-in runs under the lane's Git Bash there; https://github.com/garrytan/gstack/pull/3380
|
||||
posixShim('zero tests is ZERO-RUN; a failing cargo run is PREGATE_LANE_FAILED', () => {
|
||||
const repo = rustRepo();
|
||||
commit(repo, { 'engine/crates/gamma/src/lib.rs': 'pub fn g() { }\n' });
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* The user-owned pregate policy and `local` lanes. A repo that keeps `.gstack/`
|
||||
* out of git declares its policy at `<GSTACK_HOME>/projects/<slug>/pregate.json`;
|
||||
* the base branch's copy always wins, and `--policy-from head` never reads the
|
||||
* user file. A platform lane named in `local` runs here instead of becoming
|
||||
* requires-remote. Every assertion holds on any host platform: each lane the
|
||||
* fixture selects is either named in `local` or asserted only by label.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { loadConfigFromUser, validateConfig } from '../lib/pregate/config';
|
||||
import { buildContext, sha256, userConfigRejection } from '../lib/pregate/context';
|
||||
import { runPregate, verifyPregate } from '../lib/pregate/index';
|
||||
import { BASE_PREGATE, BIN, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
const SLUG = 'userpolicy';
|
||||
const LOCAL_ALL = ['free-tests.yml/unit', 'windows.yml/windows-shard'];
|
||||
|
||||
/** A fixture whose base carries no `.gstack/pregate.json`, a feature commit touching lib/util.js, and the user-file path for its slug. */
|
||||
function setup(): { repo: FixtureRepo; userPolicy: string; run: (extra?: string[]) => ReturnType<typeof runBin> } {
|
||||
const repo = makeFixtureRepo({ pregate: null });
|
||||
repos.push(repo);
|
||||
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
|
||||
const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json');
|
||||
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
|
||||
const run = (extra: string[] = []) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...extra], { GSTACK_PROJECT_SLUG: SLUG });
|
||||
return { repo, userPolicy, run };
|
||||
}
|
||||
/** setup() with a base that commits `.gstack/pregate.json` (raw text, or a policy object). */
|
||||
function setupWithBase(base: string | Record<string, unknown>): { repo: FixtureRepo; userPolicy: string } {
|
||||
const repo = makeFixtureRepo({ pregate: null, baseFiles: { '.gstack/pregate.json': typeof base === 'string' ? base : JSON.stringify(base) } });
|
||||
repos.push(repo);
|
||||
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
|
||||
const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json');
|
||||
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
|
||||
return { repo, userPolicy };
|
||||
}
|
||||
const writePolicy = (file: string, policy: Record<string, unknown>) => fs.writeFileSync(file, JSON.stringify({ runner: 'node --test {files}', ...policy }));
|
||||
|
||||
describe('pregate.json local', () => {
|
||||
// Value: protects=`local` accepts only <workflow file>/<job id> lane ids, shared with selection keys; fails_when=a bare job name or a
|
||||
// string is accepted and silently matches no lane; why_new=the key is new; seam=none; no_claim=none beyond protects
|
||||
test('takes <workflow>/<job> ids only', () => {
|
||||
expect(validateConfig({ local: ['test.yml/unit'] })).toEqual([]);
|
||||
expect(validateConfig({ local: ['unit'] }).map(e => e.path)).toEqual(['local[0]']);
|
||||
expect(validateConfig({ local: 'test.yml/unit' }).map(e => e.path)).toEqual(['local']);
|
||||
expect(validateConfig({ local: [7] }).map(e => e.path)).toEqual(['local']);
|
||||
expect(validateConfig({ selection: { unit: 'true' } }).map(e => e.path)).toEqual(['selection["unit"]']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfigFromUser', () => {
|
||||
// Value: protects=a missing user file falls back (null) while any other read error refuses at the file's path, never defaults;
|
||||
// fails_when=every read error is treated as absent and the user's guards silently vanish; why_new=the loader is new; seam=none;
|
||||
// no_claim=EACCES specifically (a directory, and a dangling symlink, stand in for an unreadable file)
|
||||
test('ENOENT is null; an unreadable file refuses at its path', () => {
|
||||
const { userPolicy } = setup();
|
||||
expect(loadConfigFromUser(userPolicy)).toBeNull();
|
||||
fs.mkdirSync(userPolicy);
|
||||
const load = loadConfigFromUser(userPolicy);
|
||||
expect(load && 'errors' in load ? load.errors.map(e => [e.code, e.path]) : null).toEqual([['PREGATE_CONFIG_INVALID', userPolicy]]);
|
||||
expect(load && 'kind' in load ? [load.kind, load.label] : null).toEqual(['user', `user ${userPolicy}`]);
|
||||
fs.rmSync(userPolicy, { recursive: true });
|
||||
fs.symlinkSync(path.join(path.dirname(userPolicy), 'not-yet-cloned.json'), userPolicy);
|
||||
const dangling = loadConfigFromUser(userPolicy);
|
||||
expect(dangling && 'errors' in dangling ? dangling.errors.map(e => [e.code, e.path]) : null).toEqual([['PREGATE_CONFIG_INVALID', userPolicy]]);
|
||||
fs.unlinkSync(userPolicy);
|
||||
// A dangling link one level up (the project directory) is the same intent.
|
||||
const dir = path.dirname(userPolicy);
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
fs.symlinkSync(path.join(path.dirname(dir), 'not-yet-cloned'), dir);
|
||||
expect(loadConfigFromUser(userPolicy)).not.toBeNull();
|
||||
fs.unlinkSync(dir);
|
||||
fs.mkdirSync(dir);
|
||||
// Malformed JSON reaches the sealed/price_file probe too; it must still come back as a refusal, never a throw.
|
||||
fs.writeFileSync(userPolicy, '{"local": [');
|
||||
const bad = loadConfigFromUser(userPolicy);
|
||||
expect(bad && 'errors' in bad ? bad.errors.map(e => e.message.startsWith('not valid JSON')) : null).toEqual([true]);
|
||||
// The JSON error names this file, not the repo copy parseConfig assumes.
|
||||
expect(bad && 'errors' in bad ? bad.errors.map(e => e.path) : null).toEqual([userPolicy]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-pregate with a user-owned policy', () => {
|
||||
// Value: protects=with no base copy the user file is the policy and its `local` lanes run here, while an unnamed platform lane stays
|
||||
// requires-remote; fails_when=the user fallback or the `local` branch in selectLanes is dropped; why_new=both are new;
|
||||
// seam=none; no_claim=a real CI host (lanes are judged by the fixture's workflows)
|
||||
test('the user file is read when the base has none, and a local lane runs here instead of requires-remote', () => {
|
||||
const { repo, userPolicy, run } = setup();
|
||||
expect(run().out).toContain('(no .gstack/pregate.json)');
|
||||
|
||||
// The lane left off `local` must be foreign to this host: on Windows the windows lane is native and runs anyway.
|
||||
const [named, foreign] = process.platform === 'win32'
|
||||
? [['windows.yml/windows-shard', 'windows'], ['free-tests.yml/unit', 'linux']]
|
||||
: [['free-tests.yml/unit', 'linux'], ['windows.yml/windows-shard', 'windows']];
|
||||
writePolicy(userPolicy, { local: [named[0]] });
|
||||
const partial = run();
|
||||
expect(partial.out).toContain(`policy user ${userPolicy}`);
|
||||
expect(partial.out).toContain(`requires-remote ${foreign[0]} (${foreign[1]})`);
|
||||
expect(partial.out).not.toContain(`requires-remote ${named[0]}`);
|
||||
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
const all = run(['--explain']);
|
||||
expect(all.status).toBe(EXIT.ok);
|
||||
// The record pins the user file's bytes, since no commit does.
|
||||
const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes');
|
||||
expect(lanes.inputs.policy).toBe(sha256(fs.readFileSync(userPolicy, 'utf8')));
|
||||
expect(all.out).toContain('Tiers ran: unit; requires-remote: none');
|
||||
expect(all.out).toContain(`lane ${foreign[0]}: ${foreign[1]} lane satisfied locally (pregate.json local)`);
|
||||
expect(all.out).toMatch(/^tests {6}lanes {5}pass /m);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a fix that says where to declare policy names the user file when it is the active policy, since an edit to the
|
||||
// repo's .gstack/pregate.json is never read on a base that carries none; fails_when=the fix hardcodes .gstack/pregate.json;
|
||||
// why_new=the user source is new; seam=none; no_claim=every fix string (strays, runner, remote_gate share ctx.configFile)
|
||||
test('the no-lane fix names the user file when it is the active policy', () => {
|
||||
const { repo, userPolicy, run } = setup();
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
commit(repo, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' });
|
||||
expect(run().status).not.toBe(EXIT.ok);
|
||||
const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes');
|
||||
expect(lanes.code).toBe('PREGATE_NO_LANE');
|
||||
expect(lanes.fix).toContain(`under dependencies — or empty_lanes for other documentation — in ${userPolicy}`);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=an invalid, sealed-bearing or unreadable user file refuses with PREGATE_CONFIG_INVALID naming the file, with a fix
|
||||
// that points at the file rather than the base branch; fails_when=user-file errors run on defaults, or sealed/price_file are accepted
|
||||
// and look active while nothing reads them; why_new=the user source is new; seam=none; no_claim=the sealed guard itself
|
||||
test('an invalid, sealed or unreadable user file refuses and names the file', () => {
|
||||
const { userPolicy, run } = setup();
|
||||
writePolicy(userPolicy, { local: ['not-a-lane'] });
|
||||
const invalid = run();
|
||||
expect(invalid.status).toBe(EXIT.refused);
|
||||
expect(invalid.err).toContain('PREGATE_CONFIG_INVALID');
|
||||
expect(invalid.err).toContain(`user ${userPolicy}`);
|
||||
expect(invalid.err).toContain(`fix: edit ${userPolicy} directly`);
|
||||
expect(invalid.err).not.toContain('.gstack/pregate.json has');
|
||||
|
||||
for (const [key, value] of [['sealed', { paths: ['evals/**'] }], ['price_file', 'evals/calls.json']] as const) {
|
||||
writePolicy(userPolicy, { [key]: value });
|
||||
const r = run();
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain(`covers gstack-pregate run only; ${key}`);
|
||||
}
|
||||
// A null price_file is the documented default, not a policy, so it is accepted.
|
||||
writePolicy(userPolicy, { price_file: null, local: LOCAL_ALL });
|
||||
expect(run().err).not.toContain('PREGATE_CONFIG_INVALID');
|
||||
|
||||
fs.rmSync(userPolicy);
|
||||
fs.mkdirSync(userPolicy);
|
||||
const unreadable = run();
|
||||
expect(unreadable.status).toBe(EXIT.refused);
|
||||
expect(unreadable.err).toContain('cannot read the user-owned policy');
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the user file is consulted only for --policy-from base with no base copy: head mode and a committed base copy never
|
||||
// read it; fails_when=either guard on the fallback is dropped and an unreviewed user file overrides reviewed policy; why_new=the
|
||||
// fallback is new; seam=none; no_claim=none beyond protects
|
||||
test('--policy-from head and a base copy never read the user file', () => {
|
||||
const { repo, userPolicy, run } = setup();
|
||||
writePolicy(userPolicy, { local: ['not-a-lane'] });
|
||||
|
||||
const head = run(['--policy-from', 'head']);
|
||||
expect(head.err).not.toContain('PREGATE_CONFIG_INVALID');
|
||||
expect(head.out).not.toContain('policy user');
|
||||
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify(BASE_PREGATE) + '\n' });
|
||||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' });
|
||||
const based = run();
|
||||
expect(based.err).not.toContain('PREGATE_CONFIG_INVALID');
|
||||
expect(based.out).toContain('origin/main@');
|
||||
expect(based.out).not.toContain('policy user');
|
||||
// A base copy is pinned by inputs.base; no user digest is recorded.
|
||||
const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes');
|
||||
expect(lanes.inputs.base).toContain('origin/main@');
|
||||
expect('policy' in lanes.inputs).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the --remote refusal for a policy with no remote_gate tells the user where to declare it: the repo copy "on the
|
||||
// base branch", the user file by its path (never "on the base branch", which the user file is not on); fails_when=the
|
||||
// configFile ternary is dropped or inverted and the user is sent to edit a file nothing reads; why_new=no test reaches the
|
||||
// no-remote_gate branch and the user source is new; seam=none (userConfigPath and remote are existing buildContext options);
|
||||
// no_claim=the remote gate run itself
|
||||
test('--remote with no remote_gate names the file to declare it in', async () => {
|
||||
const lanesFix = async (repo: FixtureRepo, userConfigPath?: string) => {
|
||||
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', remote: { spendCapUsd: 1 }, userConfigPath });
|
||||
if (!('ctx' in loaded)) throw new Error('fixture context refused');
|
||||
const lanes = (await runPregate(loaded.ctx, { stage: 'tests' })).checks.find(c => c.id === 'lanes')!;
|
||||
expect([lanes.status, lanes.code]).toEqual(['incomplete', 'PREGATE_INCOMPLETE']);
|
||||
return lanes.fix!;
|
||||
};
|
||||
const { repo, userPolicy } = setup();
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
const user = await lanesFix(repo, userPolicy);
|
||||
expect(user).toContain(`in ${userPolicy}, or run the tests locally`);
|
||||
expect(user).not.toContain('on the base branch');
|
||||
|
||||
const based = makeFixtureRepo({ pregate: { ...BASE_PREGATE, local: LOCAL_ALL } });
|
||||
repos.push(based);
|
||||
commit(based, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
|
||||
expect(await lanesFix(based)).toContain('in .gstack/pregate.json on the base branch, or run the tests locally');
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the strays fail fix, the strays warn fix and the PREGATE_STALLED fix each name the user file when it is the active
|
||||
// policy, so the user edits the file that is read; fails_when=any of the three reverts to a hardcoded .gstack/pregate.json, which a
|
||||
// base carrying none never reads; why_new=existing strays and stall tests assert detail and lines, never the fix, and only with a
|
||||
// repo copy; seam=none; no_claim=the stall timing itself (pregate-supervise covers it)
|
||||
test('strays (fail and warn) and stall fixes name the user file', () => {
|
||||
const { repo, userPolicy, run } = setup();
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
const check = (id: string) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === id);
|
||||
const strays = (files: Record<string, string | null>) => {
|
||||
commit(repo, files);
|
||||
runBin(repo, 'gstack-pregate', ['--base', 'main', '--stage', 'preflight', '--only', 'strays'], { GSTACK_PROJECT_SLUG: SLUG });
|
||||
return check('strays');
|
||||
};
|
||||
|
||||
const fail = strays({ 'notes.txt': 'x\n', 'newtop/thing.js': 'module.exports = 1;\n' });
|
||||
expect([fail.status, fail.code]).toEqual(['fail', 'PREGATE_STRAYS']);
|
||||
expect(fail.fix).toContain(`goes in ${userPolicy} strays.allow`);
|
||||
|
||||
const warn = strays({ 'notes.txt': null });
|
||||
expect([warn.status, warn.code]).toEqual(['warn', 'PREGATE_STRAYS']);
|
||||
expect(warn.fix).toContain(`allow it in ${userPolicy} strays.allow`);
|
||||
|
||||
commit(repo, { 'newtop/thing.js': null, 'test/hang.test.js': 'setInterval(() => {}, 1000);\n' });
|
||||
run(['--stall-timeout', '2']);
|
||||
const stalled = check('lanes');
|
||||
expect(stalled.code).toBe('PREGATE_STALLED');
|
||||
expect(stalled.fix).toContain(`stall_timeout_s in ${userPolicy}; 0 disables`);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a user path inside the checkout (GSTACK_HOME under the repo, or a relative state root when HOME is unset) is
|
||||
// never read, so a PR cannot commit its own "user" policy; fails_when=userConfigRejection is dropped and the in-repo file
|
||||
// becomes the policy; why_new=the trust check is new; seam=none (userConfigPath is an existing buildContext option);
|
||||
// no_claim=a symlinked state root (the next test)
|
||||
test('a user path inside the repository or relative is ignored, with an explain line', () => {
|
||||
const { repo } = setup();
|
||||
const inside = path.join(repo.work, '.gstack', 'projects', 'x', 'pregate.json');
|
||||
fs.mkdirSync(path.dirname(inside), { recursive: true });
|
||||
fs.writeFileSync(inside, JSON.stringify({ local: ['not-a-lane'] }));
|
||||
for (const [file, why] of [[inside, 'inside the repository'], [path.join('.gstack', 'projects', 'x', 'pregate.json'), 'not an absolute path']] as const) {
|
||||
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: file });
|
||||
if (!('ctx' in loaded)) throw new Error(`refused: ${loaded.refused.message}`);
|
||||
expect(loaded.ctx.configLabel).not.toStartWith('user ');
|
||||
expect(loaded.ctx.explain).toContain(`user policy: ${file} ignored (${why}${why === 'inside the repository' ? '; a PR could supply it' : ''})`);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a base copy that exists but git show cannot read (over spawnSync's 1 MB buffer, or its blob missing)
|
||||
// refuses instead of letting the user file replace it; fails_when=a git show failure is read as "no base copy" and the
|
||||
// unreviewed user file governs a repo that commits its policy; why_new=the user-file fallback is new; seam=none;
|
||||
// no_claim=timeouts (they take the same failure branch)
|
||||
test('a base copy git show cannot read refuses rather than reading the user file', () => {
|
||||
const { repo, userPolicy } = setupWithBase(`{"runner": "node --test {files}"${' '.repeat(1_200_000)}}\n`);
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: userPolicy });
|
||||
expect('refused' in loaded ? [loaded.refused.code, loaded.refused.message] : null).toEqual(['POLICY_SOURCE_UNAVAILABLE', 'origin/main:.gstack/pregate.json could not be read and git cannot confirm it is absent']);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=absence is decided from the tree, not the blob; fails_when=cat-file -e (a blob check) stands in for absence
|
||||
// and a missing or corrupt blob lets the user file govern; why_new=the fallback is new; seam=none; no_claim=partial clones
|
||||
test('a base copy whose blob is missing refuses rather than reading the user file', () => {
|
||||
const { repo, userPolicy } = setupWithBase({ runner: 'node --test {files}' });
|
||||
const blob = repo.git('rev-parse', 'origin/main:.gstack/pregate.json');
|
||||
fs.rmSync(path.join(repo.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2)));
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: userPolicy });
|
||||
expect('refused' in loaded ? loaded.refused.code : null).toBe('POLICY_SOURCE_UNAVAILABLE');
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the containment check sees through a symlinked state root and does not mistake an in-repo `..name`
|
||||
// directory for a parent; fails_when=the realpath half or the `..`-segment test regresses and a file a PR committed is read
|
||||
// as the user policy; why_new=both edges are new; seam=none; no_claim=case-only path differences (realpathSync.native)
|
||||
test('a state root symlinked into the checkout, or under an in-repo ..name directory, is inside', () => {
|
||||
const { repo } = setup();
|
||||
fs.mkdirSync(path.join(repo.work, '.st'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-sr-'));
|
||||
try {
|
||||
const link = path.join(outside, 'root');
|
||||
fs.symlinkSync(path.join(repo.work, '.st'), link);
|
||||
expect(userConfigRejection(repo.work, path.join(link, 'projects', 'x', 'pregate.json'))).toContain('inside the repository');
|
||||
expect(userConfigRejection(repo.work, path.join(repo.work, '..st', 'projects', 'x', 'pregate.json'))).toContain('inside the repository');
|
||||
expect(userConfigRejection(repo.work, path.join(outside, 'projects', 'x', 'pregate.json'))).toBeNull();
|
||||
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// Value: protects=a policy reached through the checkout is refused even when the last link points back outside it, so a
|
||||
// branch cannot steer which existing out-of-repo policy governs it; fails_when=containment checks only the lexical path and
|
||||
// the final target, and a state root or a link hop inside the checkout picks the file; why_new=security review of #3286;
|
||||
// seam=none; no_claim=case-only path differences
|
||||
test('a policy whose link chain passes through the checkout is inside, even when its final target is outside', () => {
|
||||
const { repo } = setup();
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-chain-'));
|
||||
try {
|
||||
const permissive = path.join(outside, 'permissive.json');
|
||||
fs.writeFileSync(permissive, '{}');
|
||||
fs.mkdirSync(path.join(repo.work, '.st', 'projects', 'x'), { recursive: true });
|
||||
fs.symlinkSync(permissive, path.join(repo.work, '.st', 'projects', 'x', 'pregate.json'));
|
||||
fs.symlinkSync(path.join(repo.work, '.st'), path.join(outside, 'root'));
|
||||
expect(userConfigRejection(repo.work, path.join(outside, 'root', 'projects', 'x', 'pregate.json'))).toContain('inside the repository');
|
||||
fs.symlinkSync(permissive, path.join(repo.work, '.st', 'hop.json'));
|
||||
fs.symlinkSync(path.join(repo.work, '.st', 'hop.json'), path.join(outside, 'leaf.json'));
|
||||
expect(userConfigRejection(repo.work, path.join(outside, 'leaf.json'))).toContain('inside the repository');
|
||||
fs.symlinkSync(permissive, path.join(outside, 'direct.json'));
|
||||
expect(userConfigRejection(repo.work, path.join(outside, 'direct.json'))).toBeNull();
|
||||
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// Value: protects=a defaults pass stops verifying once a link on the way to the absent user path dangles, the state a fresh
|
||||
// run refuses; fails_when=verify checks only the leaf and reads the broken parent as continued absence; why_new=security
|
||||
// review of #3286; seam=verifyPregate with the recorded tree passed in, so only the policy check differs; no_claim=the
|
||||
// bin's verify wiring (the stale-policy test covers it)
|
||||
test('verify goes stale when a parent of the absent user path becomes a dangling link', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-dangle-'));
|
||||
try {
|
||||
const userPolicy = path.join(dir, 'projects', 's', 'pregate.json');
|
||||
const record = { schema_version: 1, tree: 'T', checks: [], policy: 'defaults', policy_user_path: userPolicy, verdict: 'pass' } as any;
|
||||
expect(verifyPregate(dir, record, { tree: 'T' }).ok).toBe(true);
|
||||
fs.symlinkSync(path.join(dir, 'not-yet-cloned'), path.join(dir, 'projects'));
|
||||
expect((loadConfigFromUser(userPolicy) as { errors: { code: string }[] } | null)?.errors.map(e => e.code)).toEqual(['PREGATE_CONFIG_INVALID']);
|
||||
const v = verifyPregate(dir, record, { tree: 'T' });
|
||||
expect([v.ok, v.code]).toEqual([false, 'PREGATE_STALE']);
|
||||
expect(v.message).toContain(`cannot confirm ${userPolicy} is still absent`);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// Value: protects=--explain says when the user file was looked for and absent, and names a `local` id that matches no workflow lane;
|
||||
// fails_when=either line is dropped and a typo'd slug or lane id silently does nothing; why_new=both lines are new; seam=none;
|
||||
// no_claim=the line wording beyond its path and id
|
||||
test('--explain names an absent user file and a local id that matches no workflow lane', () => {
|
||||
const { userPolicy, run } = setup();
|
||||
expect(run(['--explain']).out).toContain(`user policy: ${userPolicy} (absent)`);
|
||||
writePolicy(userPolicy, { local: [...LOCAL_ALL, 'ci.yml/nope'] });
|
||||
const r = run(['--explain']);
|
||||
expect(r.out).toContain('local: ci.yml/nope matches no workflow lane; ignored');
|
||||
expect(r.out).not.toContain('local: free-tests.yml/unit matches');
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=with no GSTACK_PROJECT_SLUG the bin finds the user file under the slug gstack-slug resolves for the repo;
|
||||
// fails_when=the bin computes the slug some other way (a different cwd or state root) and the documented path is never read;
|
||||
// why_new=every other test pins GSTACK_PROJECT_SLUG; seam=none; no_claim=slug rules themselves (state-root tests cover them)
|
||||
test('the default slug path is the one gstack-slug prints', () => {
|
||||
const { repo } = setup();
|
||||
const noSlug = { GSTACK_PROJECT_SLUG: '' };
|
||||
const slug = /^SLUG=(.+)$/m.exec(runBin(repo, 'gstack-slug', [], noSlug).out)?.[1];
|
||||
expect(slug).toBeTruthy();
|
||||
const userPolicy = path.join(repo.home, 'projects', slug!, 'pregate.json');
|
||||
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests'], noSlug).out).toContain(`policy user ${userPolicy}`);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a branch's committed .env cannot choose the slug or state root behind the user policy, whether pregate is
|
||||
// launched through scriptInvocation, by its shebang, or as a bare `bun <bin>` (which refuses on any loaded dotenv file,
|
||||
// since Bun's syntax admits `KEY: value` and bare-CR lines a key scan misses); fails_when=Bun's dotenv
|
||||
// loading reaches slugFromEnvironment and a branch picks which pregate.json governs it; why_new=maintainer review of #3286;
|
||||
// seam=none; no_claim=a .env in a parent of the working directory (Bun reads the launch directory only)
|
||||
test('a committed .env cannot redirect the user policy', () => {
|
||||
const { repo } = setup();
|
||||
const env: Record<string, string | undefined> = { ...process.env, ...repo.env };
|
||||
delete env.GSTACK_PROJECT_SLUG;
|
||||
const slug = /^SLUG=(.+)$/m.exec(runScriptSync(BIN('gstack-slug'), [], { cwd: repo.work, encoding: 'utf8', env }).stdout)?.[1];
|
||||
expect(slug).toBeTruthy();
|
||||
const userPolicy = path.join(repo.home, 'projects', slug!, 'pregate.json');
|
||||
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
commit(repo, { '.env': 'GSTACK_PROJECT_SLUG=elsewhere\n' });
|
||||
const args = ['--base', 'main', '--allow-repo-commands', '--stage', 'tests'];
|
||||
expect(runScriptSync(BIN('gstack-pregate'), args, { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 }).stdout).toContain(`policy user ${userPolicy}`);
|
||||
if (process.platform !== 'win32') expect(spawnSync(BIN('gstack-pregate'), args, { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 }).stdout).toContain(`policy user ${userPolicy}`);
|
||||
const bare = spawnSync(process.execPath, [BIN('gstack-pregate'), ...args], { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 });
|
||||
expect(bare.status).toBe(EXIT.refused);
|
||||
expect(bare.stderr).toContain('POLICY_SOURCE_UNAVAILABLE');
|
||||
expect(bare.stderr).toContain('bun loaded .env from the working directory');
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a recorded pass stops verifying once the user-owned policy behind it is edited or deleted, or once a user
|
||||
// file appears where the run used the defaults; fails_when=verify checks only the tree and a weakened or removed policy
|
||||
// keeps an old pass publishable; why_new=maintainer review of #3286; seam=none; no_claim=a base-branch copy that changes
|
||||
// (pinned by the recorded base, not re-read); both paths come from the record, so the verifier's environment is not read
|
||||
test('verify goes stale when the user-owned policy changes, disappears, or appears after a defaults run', () => {
|
||||
const { repo, userPolicy, run } = setup();
|
||||
const verify = () => JSON.parse(runBin(repo, 'gstack-pregate', ['verify', '--json'], { GSTACK_PROJECT_SLUG: SLUG }).out);
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
expect(run().status).toBe(EXIT.ok);
|
||||
expect(verify().ok).toBe(true);
|
||||
writePolicy(userPolicy, { local: ['free-tests.yml/unit'] });
|
||||
expect([verify().code, verify().message]).toEqual(['PREGATE_STALE', `the user-owned policy ${userPolicy} changed after pregate.json was recorded; rerun gstack-pregate`]);
|
||||
fs.rmSync(userPolicy);
|
||||
expect(verify().message).toContain('is gone or unreadable');
|
||||
run();
|
||||
expect(verify().code).toBe('PREGATE_REMOTE_UNCLEARED');
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
expect([verify().code, verify().message]).toEqual(['PREGATE_STALE', `${userPolicy} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`]);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { gitEnv } from "./helpers/git-env";
|
||||
|
||||
const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush");
|
||||
const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
|
||||
@@ -21,7 +22,7 @@ let repo: string;
|
||||
let stateHome: string;
|
||||
|
||||
function git(args: string[], cwd = repo): string {
|
||||
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 });
|
||||
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
return r.stdout?.trim() ?? "";
|
||||
}
|
||||
|
||||
@@ -40,7 +41,7 @@ function runHook(
|
||||
cwd: repo,
|
||||
input: Buffer.from(stdinLines),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
env: gitEnv(env),
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { code: r.status ?? 0, stderr: r.stderr ?? "" };
|
||||
@@ -439,7 +440,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => {
|
||||
const r = spawnSync("bash", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"], // stdin not a TTY
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
env: gitEnv({ GSTACK_HOME: home }),
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
@@ -467,7 +468,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => {
|
||||
const r = spawnSync("bash", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
env: gitEnv({ GSTACK_HOME: home }),
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
@@ -502,7 +503,7 @@ describe("install / chaining", () => {
|
||||
const existing = path.join(hookDir, "pre-push");
|
||||
fs.writeFileSync(existing, "#!/usr/bin/env bash\necho mine\n", { mode: 0o755 });
|
||||
|
||||
const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(r.status).toBe(0);
|
||||
const installed = fs.readFileSync(existing, "utf8");
|
||||
expect(installed).toContain("gstack-redact pre-push (managed)");
|
||||
@@ -516,7 +517,7 @@ describe("install / chaining", () => {
|
||||
test("chained pre-push.local receives the final ref line (trailing newline preserved)", () => {
|
||||
const hookDir = path.join(repo, ".git", "hooks");
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
|
||||
|
||||
const seen = path.join(repo, "seen.txt");
|
||||
fs.writeFileSync(
|
||||
@@ -531,7 +532,7 @@ describe("install / chaining", () => {
|
||||
cwd: repo,
|
||||
input: Buffer.from(line),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome },
|
||||
env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }),
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
@@ -545,7 +546,7 @@ describe("install / chaining", () => {
|
||||
test("a blocking pre-push.local still short-circuits the push", () => {
|
||||
const hookDir = path.join(repo, ".git", "hooks");
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
|
||||
fs.writeFileSync(
|
||||
path.join(hookDir, "pre-push.local"),
|
||||
"#!/usr/bin/env bash\nwhile read -r _a _b _c _d || [ -n \"${_a:-}\" ]; do exit 1; done\nexit 0\n",
|
||||
@@ -555,7 +556,7 @@ describe("install / chaining", () => {
|
||||
cwd: repo,
|
||||
input: Buffer.from(`refs/heads/main ${"b".repeat(40)} refs/heads/main ${ZERO}\n`),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome },
|
||||
env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }),
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(r.status).toBe(1);
|
||||
@@ -603,6 +604,7 @@ describe("install / chaining", () => {
|
||||
timeout: 30_000,
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
env: gitEnv(),
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain("refreshed stale managed pre-push hook");
|
||||
@@ -618,7 +620,7 @@ describe("install / chaining", () => {
|
||||
cwd: repo,
|
||||
input: Buffer.from(`refs/heads/main ${sha} refs/heads/main ${ZERO}\n`),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome },
|
||||
env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }),
|
||||
});
|
||||
expect(run.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(stateHome, "security", "prepush-skip.jsonl"), "utf8")).toContain("env-skip");
|
||||
@@ -630,13 +632,14 @@ describe("install / chaining", () => {
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
const hook = path.join(hookDir, "pre-push");
|
||||
|
||||
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo });
|
||||
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, env: gitEnv() });
|
||||
const first = fs.readFileSync(hook, "utf8");
|
||||
const stamp = fs.statSync(hook).mtimeMs;
|
||||
|
||||
const again = spawnSync("bun", [REDACT, "install-prepush-hook"], {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
env: gitEnv(),
|
||||
});
|
||||
expect(again.status).toBe(0);
|
||||
expect(again.stdout).toContain("already installed");
|
||||
@@ -651,8 +654,8 @@ describe("install / chaining", () => {
|
||||
fs.writeFileSync(path.join(hookDir, "pre-push"), "#!/usr/bin/env bash\necho mine\n", {
|
||||
mode: 0o755,
|
||||
});
|
||||
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
|
||||
spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
|
||||
const restored = fs.readFileSync(path.join(hookDir, "pre-push"), "utf8");
|
||||
expect(restored).toContain("echo mine");
|
||||
expect(restored).not.toContain("managed");
|
||||
@@ -671,7 +674,7 @@ describe("base resolution when the default branch is neither main nor master", (
|
||||
// blocks the push having scanned NOTHING — the "scans more, never less"
|
||||
// fallback inverting into "scans nothing".
|
||||
const bare = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-remote-"));
|
||||
spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare], { timeout: 30_000 });
|
||||
spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare], { timeout: 30_000, env: gitEnv() });
|
||||
|
||||
git(["branch", "-M", "trunk"]);
|
||||
const old = commit("legacy.txt", FAKE_AWS_KEY + "\n", "secret already on the remote");
|
||||
|
||||
@@ -31,23 +31,13 @@ import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { gitEnv } from "./helpers/git-env";
|
||||
|
||||
const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush");
|
||||
|
||||
let repo: string;
|
||||
let remote: string;
|
||||
|
||||
// Same blanking as test/redact-prepush-scan-range.test.ts (#2967). A
|
||||
// hooksPath written into the fixture repo still runs.
|
||||
function gitEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
env.GIT_CONFIG_GLOBAL = "/dev/null";
|
||||
env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
env.GIT_CONFIG_COUNT = "0";
|
||||
delete env.GIT_CONFIG_PARAMETERS;
|
||||
return env;
|
||||
}
|
||||
|
||||
function git(args: string[], cwd = repo): string {
|
||||
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(" ")}\n${r.stderr}`);
|
||||
|
||||
@@ -17,25 +17,11 @@
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { spawnSync } from "child_process";
|
||||
import { gitEnv } from "./helpers/git-env";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
// Fixture git was spawned with the parent environment. A global or system
|
||||
// core.hooksPath, or the same key via GIT_CONFIG_COUNT /
|
||||
// GIT_CONFIG_PARAMETERS, runs on these pushes and rejects the local remote
|
||||
// (#2967). /dev/null drops the global file on every platform: Git for
|
||||
// Windows rejects NUL (see EMPTY_GLOBAL_GIT_CONFIG in lib/cso/process.ts).
|
||||
// Config written inside the fixture repo still applies.
|
||||
function gitEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
env.GIT_CONFIG_GLOBAL = "/dev/null";
|
||||
env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
env.GIT_CONFIG_COUNT = "0";
|
||||
delete env.GIT_CONFIG_PARAMETERS;
|
||||
return env;
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
const run = (args: string[], cwd = dir): string => {
|
||||
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
|
||||
@@ -4,13 +4,14 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { scan } from "../lib/redact-engine";
|
||||
import { gitEnv } from "./helpers/git-env";
|
||||
|
||||
const REDACT = path.resolve(import.meta.dir, "../bin/gstack-redact");
|
||||
const roots: string[] = [];
|
||||
const key = ["AKIA", "1234567890ABCDEF"].join("");
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 });
|
||||
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
@@ -35,7 +36,7 @@ function fixture(initialSecret = true, sha256 = false): { repo: string; origin:
|
||||
git(repo, "remote", "add", "origin", origin);
|
||||
git(repo, "push", "-q", "-u", "origin", "main");
|
||||
git(repo, "remote", "add", "publish", publish);
|
||||
const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(install.status).toBe(0);
|
||||
return { repo, origin, publish, head };
|
||||
}
|
||||
@@ -58,11 +59,49 @@ afterEach(() => {
|
||||
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("fixture installs ignore the developer's hook config (#3353)", () => {
|
||||
test("a global, system or environment core.hooksPath is neither rewritten nor run", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-target-global-"));
|
||||
roots.push(root);
|
||||
const hooks = path.join(root, "global-hooks");
|
||||
fs.mkdirSync(hooks);
|
||||
const original = "#!/bin/sh\necho developer-hook >&2\nexit 1\n";
|
||||
fs.writeFileSync(path.join(hooks, "pre-push"), original, { mode: 0o755 });
|
||||
const config = path.join(root, "gitconfig");
|
||||
fs.writeFileSync(config, `[core]\n\thooksPath = ${JSON.stringify(hooks)}\n`);
|
||||
const keys = ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_COUNT",
|
||||
"GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0", "GIT_CONFIG_PARAMETERS"] as const;
|
||||
const prev = Object.fromEntries(keys.map(key => [key, process.env[key]]));
|
||||
try {
|
||||
process.env.GIT_CONFIG_GLOBAL = config;
|
||||
process.env.GIT_CONFIG_SYSTEM = config;
|
||||
delete process.env.GIT_CONFIG_NOSYSTEM;
|
||||
process.env.GIT_CONFIG_COUNT = "1";
|
||||
process.env.GIT_CONFIG_KEY_0 = "core.hooksPath";
|
||||
process.env.GIT_CONFIG_VALUE_0 = hooks;
|
||||
process.env.GIT_CONFIG_PARAMETERS = `'core.hooksPath=${hooks}'`;
|
||||
const { repo } = fixture(false);
|
||||
expect(fs.readdirSync(hooks)).toEqual(["pre-push"]);
|
||||
expect(fs.readFileSync(path.join(hooks, "pre-push"), "utf8")).toBe(original);
|
||||
const installed = git(repo, "rev-parse", "--path-format=absolute", "--git-path", "hooks/pre-push");
|
||||
expect(fs.readFileSync(installed, "utf8")).toContain("gstack-redact pre-push (managed)");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status, push.stderr).toBe(0);
|
||||
expect(push.stderr).not.toContain("developer-hook");
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(prev)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("installed pre-push guard uses the actual destination", () => {
|
||||
test("origin tracking at HEAD cannot excuse the same credential on first push to publish", () => {
|
||||
const { repo, publish, head } = fixture();
|
||||
expect(git(repo, "rev-parse", "origin/main")).toBe(head);
|
||||
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("aws.access_key");
|
||||
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
|
||||
@@ -71,7 +110,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
test("a configured remote's different push URL cannot reuse its fetch tracking tip", () => {
|
||||
const { repo, publish } = fixture();
|
||||
git(repo, "remote", "set-url", "--push", "origin", publish);
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("aws.access_key");
|
||||
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
|
||||
@@ -92,7 +131,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
git(repo, "update-ref", "refs/heads/publish/main", tip);
|
||||
expect(git(repo, "rev-parse", "publish/main")).toBe(tip);
|
||||
expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(base);
|
||||
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("aws.access_key");
|
||||
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
|
||||
@@ -108,7 +147,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(git(repo, "rev-parse", "HEAD"));
|
||||
git(repo, "remote", "set-url", "--add", "publish", second);
|
||||
expect(git(repo, "remote", "get-url", "publish")).toBe(publish);
|
||||
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("aws.access_key");
|
||||
expect(git(second, "for-each-ref", "--format=%(refname)")).toBe("");
|
||||
@@ -116,7 +155,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
|
||||
test("direct URL push cannot borrow origin's tracking history", () => {
|
||||
const { repo, publish } = fixture();
|
||||
const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("aws.access_key");
|
||||
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
|
||||
@@ -125,8 +164,8 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
test("an advertised remote tip absent from local objects cannot justify a guessed base", () => {
|
||||
const { repo, publish } = fixture();
|
||||
const remoteTip = seedIndependentRemote(repo, publish);
|
||||
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0);
|
||||
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000, env: gitEnv() }).status).not.toBe(0);
|
||||
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("aws.access_key");
|
||||
expect(git(publish, "rev-parse", "refs/heads/main")).toBe(remoteTip);
|
||||
@@ -135,21 +174,21 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
test("an absent advertised tip permits a clean forced update after conservative scanning", () => {
|
||||
const { repo, publish, head } = fixture(false);
|
||||
const remoteTip = seedIndependentRemote(repo, publish);
|
||||
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0);
|
||||
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000, env: gitEnv() }).status).not.toBe(0);
|
||||
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(0);
|
||||
expect(git(publish, "rev-parse", "refs/heads/main")).toBe(head);
|
||||
});
|
||||
|
||||
test("new SHA-256 branch scans the correct empty tree and allows a clean push", () => {
|
||||
const { repo, publish } = fixture(false, true);
|
||||
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(0);
|
||||
expect(git(publish, "rev-parse", "refs/heads/main")).toHaveLength(64);
|
||||
fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`);
|
||||
git(repo, "add", "leak.txt");
|
||||
git(repo, "commit", "-qm", "new secret");
|
||||
const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(blocked.status).toBe(1);
|
||||
expect(blocked.stderr).toContain("aws.access_key");
|
||||
});
|
||||
@@ -164,7 +203,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "clean.txt"), "safe update\n");
|
||||
git(repo, "add", "clean.txt");
|
||||
git(repo, "commit", "-qm", "clean branch");
|
||||
const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("aws.access_key");
|
||||
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
|
||||
@@ -183,7 +222,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
git(repo, "add", "clean.txt");
|
||||
git(repo, "commit", "-qm", "clean update");
|
||||
const head = git(repo, "rev-parse", "HEAD");
|
||||
const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(0);
|
||||
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
|
||||
expect(git(origin, "for-each-ref", "--format=%(refname)", "refs/heads/disposable")).toBe("");
|
||||
@@ -193,7 +232,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
const { repo, origin, head } = fixture(false);
|
||||
const hook = git(repo, "rev-parse", "--git-path", "hooks/pre-push");
|
||||
const result = spawnSync("bash", [hook, "origin", origin], {
|
||||
cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000,
|
||||
cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000, env: gitEnv(),
|
||||
});
|
||||
expect(result.status, result.stderr || result.error?.message).toBe(1);
|
||||
expect(result.stderr).toContain("could not parse a pre-push ref line");
|
||||
@@ -208,7 +247,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
|
||||
git(repo, "add", "payload.txt");
|
||||
git(repo, "commit", "-qm", "near seam");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("aws.secret_key");
|
||||
});
|
||||
@@ -221,7 +260,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
|
||||
git(repo, "add", "payload.txt");
|
||||
git(repo, "commit", "-qm", "unlabeled seam");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(0);
|
||||
expect(push.stderr).not.toContain("aws.secret_key");
|
||||
});
|
||||
@@ -232,7 +271,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
|
||||
git(repo, "add", "payload.txt");
|
||||
git(repo, "commit", "-qm", "one finding at seam");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1);
|
||||
});
|
||||
@@ -247,7 +286,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
|
||||
git(repo, "add", "payload.txt");
|
||||
git(repo, "commit", "-qm", "supplementary normalization seam");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1);
|
||||
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
|
||||
@@ -264,7 +303,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
|
||||
git(repo, "add", "payload.txt");
|
||||
git(repo, "commit", "-qm", "mid-line control");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(0);
|
||||
expect(push.stderr).not.toContain("MEDIUM finding");
|
||||
});
|
||||
@@ -277,7 +316,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
|
||||
git(repo, "add", "payload.txt");
|
||||
git(repo, "commit", "-qm", "embedded key control");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(0);
|
||||
expect(push.stderr).not.toContain("aws.access_key");
|
||||
expect(git(origin, "rev-parse", "refs/heads/main")).not.toBe(head);
|
||||
@@ -294,7 +333,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
git(repo, "add", "payload.txt");
|
||||
git(repo, "commit", "-qm", "long bearer advisory");
|
||||
const tip = git(repo, "rev-parse", "HEAD");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(0);
|
||||
expect(push.stderr).toContain("MEDIUM finding");
|
||||
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);
|
||||
@@ -308,7 +347,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
|
||||
git(repo, "add", "payload.txt");
|
||||
git(repo, "commit", "-qm", "invisible seam");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("aws.secret_key");
|
||||
});
|
||||
@@ -320,7 +359,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
|
||||
git(repo, "add", "payload.txt");
|
||||
git(repo, "commit", "-qm", "unscannable context");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("engine.input_too_large");
|
||||
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
|
||||
@@ -339,7 +378,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
|
||||
git(repo, "add", "payload.txt");
|
||||
git(repo, "commit", "-qm", "normalization seam");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(expected ? 1 : 0);
|
||||
expect(push.stderr.includes("aws.secret_key")).toBe(expected);
|
||||
});
|
||||
@@ -350,19 +389,19 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "large.txt"), "ordinary content\n".repeat(160_000));
|
||||
git(repo, "add", "large.txt");
|
||||
git(repo, "commit", "-qm", "large clean diff");
|
||||
let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
|
||||
let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() });
|
||||
expect(push.status).toBe(0);
|
||||
fs.writeFileSync(path.join(repo, "long.txt"), "x".repeat(900_000) + "\n");
|
||||
git(repo, "add", "long.txt");
|
||||
git(repo, "commit", "-qm", "long clean line");
|
||||
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
|
||||
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() });
|
||||
expect(push.status).toBe(0);
|
||||
const priorTip = git(origin, "rev-parse", "refs/heads/main");
|
||||
expect(priorTip).not.toBe(head);
|
||||
fs.writeFileSync(path.join(repo, "too-long.txt"), "x".repeat(1_100_000) + "\n");
|
||||
git(repo, "add", "too-long.txt");
|
||||
git(repo, "commit", "-qm", "over cap line");
|
||||
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
|
||||
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("engine.input_too_large");
|
||||
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(priorTip);
|
||||
@@ -373,7 +412,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
fs.writeFileSync(path.join(repo, "long.txt"), `${"x".repeat(900_000)} key ${key}\n`);
|
||||
git(repo, "add", "long.txt");
|
||||
git(repo, "commit", "-qm", "long line with key");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(1);
|
||||
expect(push.stderr).toContain("aws.access_key");
|
||||
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
|
||||
@@ -385,7 +424,7 @@ describe("installed pre-push guard uses the actual destination", () => {
|
||||
git(repo, "add", "long.txt");
|
||||
git(repo, "commit", "-qm", "neighboring long lines");
|
||||
const tip = git(repo, "rev-parse", "HEAD");
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
|
||||
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
|
||||
expect(push.status).toBe(0);
|
||||
expect(push.stderr).not.toContain("engine.input_too_large");
|
||||
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);
|
||||
|
||||
Reference in new issue
Block a user