diff --git a/CHANGELOG.md b/CHANGELOG.md index aa72eec8c..1aba65da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,79 @@ - letting Step 5.0 read this invocation's records. - No threshold, budget, timeout, trial count or case kind changed. Size pins only went down. +## [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.`, `.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/` under that subfolder. The publication guard only looked for `/.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=

/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 `/.claude/worktrees/` 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 `/.claude/worktrees/` (or a subdirectory of it), the session counts with ``. 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 `/projects//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): `/` 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"). diff --git a/VERSION b/VERSION index 89e8820dd..6811b3c0a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.205.0 +1.91.211.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 05999fcf2..ad700de53 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.205.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.211.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, diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts index 41b95ec83..d8cf0c214 100644 --- a/autoplan/bin/phase-publication-hook.ts +++ b/autoplan/bin/phase-publication-hook.ts @@ -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: /.git/worktrees//gitdir names - * /.git, and that file names the same admin entry back. + * /.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); diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index b923f737a..2fa10b41d 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -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 diff --git a/bin/gstack-global-discover.ts b/bin/gstack-global-discover.ts index d8d8d0e9f..88cfbfdbe 100644 --- a/bin/gstack-global-discover.ts +++ b/bin/gstack-global-discover.ts @@ -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 `/.claude/worktrees/`. 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", diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 9a6db5752..8ce0a2083 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -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 (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 { 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); diff --git a/docs/capy-install.md b/docs/capy-install.md index a52e9aa20..c2d0ce97b 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -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

(m )`. A lease whose pid now belongs to another +holder as `pid

(m )`. The team-mode SessionStart hook +(`bin/gstack-session-update`) honors the same leases: it logs +`SKIP leased pid=

/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 diff --git a/docs/pregate.md b/docs/pregate.md index 004a3837c..216baf97c 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -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/` (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/` copy is honoured, so a downgrade is reviewed before it -takes effect. `gstack-pregate validate` / `init` cover the working-tree file. +Only the `origin/` 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 `/projects//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)); `` 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 `) 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= escaped= files=` 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. ## 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 diff --git a/docs/state-root.md b/docs/state-root.md index 5cd662ee5..45c246ec6 100644 --- a/docs/state-root.md +++ b/docs/state-root.md @@ -104,6 +104,9 @@ lives in `projects//`. `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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 53a2daf92..f9ca4cef2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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/`, 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 --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/`, 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 +`/projects//pregate.json` instead (outside the repository; see +docs/pregate.md for the state root and slug). +Details: docs/pregate.md. ### `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 `/projects//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). ### `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. ### `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. diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index e46d2336d..4b5cfb408 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -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 `; 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 } } }, }, }, diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index df83154fa..8d8fdf054 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -220,8 +220,8 @@ function planGap(ctx: PregateContext, plan: RunPlan, total: number): Pick 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> 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 { 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 { 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 { 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 }; diff --git a/lib/pregate/checks/strays.ts b/lib/pregate/checks/strays.ts index d46e88b39..4b5d5b572 100644 --- a/lib/pregate/checks/strays.ts +++ b/lib/pregate/checks/strays.ts @@ -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` }; } diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index cb9d82a9e..e1f2b3e20 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -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 `/` 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 + * `/projects//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; 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 / → command' }); else for (const [lane, command] of Object.entries(sel as Record)) { - 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 /' }); + if (!LANE_ID.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be /' }); 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 /' }); + 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 /` }); }); + } 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; - 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)[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: / → 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: / 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= tests= failed=) (docs/pregate.md#remote-gate)', + 'no .gstack/pregate.json on the base: /projects//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'; } diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index 4d5584284..ba9b4e532 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -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; + /** `/projects//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))); +} + +/** `/projects//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 = {}): Record { - 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 }; } /** diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index 74a553be1..d6a52462f 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -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 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 }; diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index 044e27312..529227cd5 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -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); diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts index 37512dee4..b93fbe140 100644 --- a/lib/pregate/types.ts +++ b/lib/pregate/types.ts @@ -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[]; diff --git a/lib/result-codes.ts b/lib/result-codes.ts index cd5a5f74e..d1e09b56f 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -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/ is not a resolvable commit, so the reviewed .gstack/ship-policy.json cannot be read', + summary: 'policy source unavailable: origin/ 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 --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: { diff --git a/package.json b/package.json index d41cbae09..f806401c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.205", + "version": "1.91.211", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/autoplan-publication-hook.test.ts b/test/autoplan-publication-hook.test.ts index de4eebfc9..9635e38fa 100644 --- a/test/autoplan-publication-hook.test.ts +++ b/test/autoplan-publication-hook.test.ts @@ -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([]); + }); +}); + diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index b5bc4fd10..9597362a9 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -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([]); diff --git a/test/fs-utils.test.ts b/test/fs-utils.test.ts index f15c45199..e2caa2f53 100644 --- a/test/fs-utils.test.ts +++ b/test/fs-utils.test.ts @@ -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"); diff --git a/test/global-discover.test.ts b/test/global-discover.test.ts index fd0c80631..8e56a6b42 100644 --- a/test/global-discover.test.ts +++ b/test/global-discover.test.ts @@ -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 /.claude/worktrees/ 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(); + }); + }); }); }); diff --git a/test/helpers/git-env.ts b/test/helpers/git-env.ts new file mode 100644 index 000000000..48b4a9eff --- /dev/null +++ b/test/helpers/git-env.ts @@ -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 = {}): 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; +} diff --git a/test/pregate-rust.test.ts b/test/pregate-rust.test.ts index ea1e4fad1..cec5aeab6 100644 --- a/test/pregate-rust.test.ts +++ b/test/pregate-rust.test.ts @@ -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' }); diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts new file mode 100644 index 000000000..b56a32032 --- /dev/null +++ b/test/pregate-user-policy.test.ts @@ -0,0 +1,407 @@ +/** + * The user-owned pregate policy and `local` lanes. A repo that keeps `.gstack/` + * out of git declares its policy at `/projects//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 } { + 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): { 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) => fs.writeFileSync(file, JSON.stringify({ runner: 'node --test {files}', ...policy })); + +describe('pregate.json local', () => { + // Value: protects=`local` accepts only / 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 / 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) => { + 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 ` (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 = { ...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); +}); diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index 964e4c511..84c07690b 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -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"); diff --git a/test/redact-prepush-rebase-force-push.test.ts b/test/redact-prepush-rebase-force-push.test.ts index 5b9af16d2..c6e0da0cd 100644 --- a/test/redact-prepush-rebase-force-push.test.ts +++ b/test/redact-prepush-rebase-force-push.test.ts @@ -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}`); diff --git a/test/redact-prepush-scan-range.test.ts b/test/redact-prepush-scan-range.test.ts index 316bf7787..c29003b01 100644 --- a/test/redact-prepush-scan-range.test.ts +++ b/test/redact-prepush-scan-range.test.ts @@ -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() }); diff --git a/test/redact-prepush-target.test.ts b/test/redact-prepush-target.test.ts index 4635722ee..250512d8c 100644 --- a/test/redact-prepush-target.test.ts +++ b/test/redact-prepush-target.test.ts @@ -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);