From 81d8a03d2e5b8e3bc2207472ccacd374a7320c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 26 Sep 2026 07:36:06 +0000 Subject: [PATCH] fix(repo): keep one fallow audit base cache per checkout instead of one per main commit (#4525) --- lefthook.yml | 3 +- package.json | 2 +- scripts/prune-fallow-audit-caches.mjs | 39 +++++++ scripts/prune-fallow-audit-caches.test.mjs | 122 +++++++++++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 scripts/prune-fallow-audit-caches.mjs create mode 100644 scripts/prune-fallow-audit-caches.test.mjs diff --git a/lefthook.yml b/lefthook.yml index 8d42077b37..23d99d917c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -30,7 +30,8 @@ pre-commit: # Unset git worktree env vars: fallow creates a temp worktree internally and # GIT_DIR/GIT_INDEX_FILE (set by git in worktree hook context) break that. # env -u is safe in non-worktree contexts (no-op when var is unset). - run: env -u GIT_DIR -u GIT_INDEX_FILE -u GIT_WORK_TREE bunx fallow audit --base origin/main --fail-on-issues + # The prune keeps one audit base cache per checkout; the commit gets the audit's own status. + run: env -u GIT_DIR -u GIT_INDEX_FILE -u GIT_WORK_TREE sh -c 'bunx fallow audit --base origin/main --fail-on-issues; status=$?; bun scripts/prune-fallow-audit-caches.mjs origin/main; exit $status' largefiles: # Reject large binaries committed straight into the git pack instead of # LFS. The script reads the staged set itself (via `git diff --cached`) diff --git a/package.json b/package.json index baaae21297..b4a4cea62f 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/prune-fallow-audit-caches.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "node --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/scripts/prune-fallow-audit-caches.mjs b/scripts/prune-fallow-audit-caches.mjs new file mode 100644 index 0000000000..f147698007 --- /dev/null +++ b/scripts/prune-fallow-audit-caches.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env bun +// fallow keeps one base-snapshot worktree per checkout and base commit in the temp dir +// (`fallow-audit-base-cache--`); 2.75 never removes +// the one for an old base. Run after `fallow audit`: keeps only the current base's. +import { spawnSync } from "node:child_process"; +import { readdirSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const PREFIX = "fallow-audit-base-cache-"; + +/** The entries in `names` that are this checkout's caches for a base other than `keepSha`. */ +export function staleAuditCaches(names, repoHash, keepSha) { + const own = `${PREFIX}${repoHash}-`; + const keep = `${own}${keepSha.slice(0, 16)}`; + return names.filter((name) => name.startsWith(own) && !name.includes(".") && name !== keep); +} + +function git(root, args) { + return spawnSync("git", args, { cwd: root, encoding: "utf8" }); +} + +if (import.meta.main) { + const base = process.argv[2] ?? "origin/main"; + const root = realpathSync(git(process.cwd(), ["rev-parse", "--show-toplevel"]).stdout.trim()); + const keepSha = git(root, ["rev-parse", base]).stdout.trim(); + if (!keepSha) { + console.error(`prune-fallow-audit-caches: cannot resolve ${base}; nothing removed`); + process.exit(0); + } + const repoHash = Bun.hash.xxHash3(root).toString(16).padStart(16, "0"); + const dir = tmpdir(); + const stale = staleAuditCaches(readdirSync(dir), repoHash, keepSha); + for (const name of stale) { + // A registered cache must leave git's worktree list too, or `git worktree add` refuses the path later. + git(root, ["worktree", "remove", "--force", join(dir, name)]); + rmSync(join(dir, name), { recursive: true, force: true }); + } +} diff --git a/scripts/prune-fallow-audit-caches.test.mjs b/scripts/prune-fallow-audit-caches.test.mjs new file mode 100644 index 0000000000..56dd3a3130 --- /dev/null +++ b/scripts/prune-fallow-audit-caches.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { staleAuditCaches } from "./prune-fallow-audit-caches.mjs"; + +const SCRIPT = join(import.meta.dirname, "prune-fallow-audit-caches.mjs"); +const FALLOW = join(import.meta.dirname, "../node_modules/.bin/fallow"); +const CAN_AUDIT = spawnSync("bun", ["--version"]).status === 0 && existsSync(FALLOW); + +// Run from inside a git hook, these would point the temp repo's git at the outer checkout. +const ENV = { ...process.env }; +for (const name of ["GIT_DIR", "GIT_INDEX_FILE", "GIT_WORK_TREE"]) delete ENV[name]; + +function git(cwd, ...args) { + const result = spawnSync("git", args, { cwd, env: ENV, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); +} + +function commit(repo, message) { + git( + repo, + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "--no-verify", + "-am", + message, + ); +} + +describe("staleAuditCaches", () => { + it("picks this checkout's caches for other bases, nothing else", () => { + const names = [ + "fallow-audit-base-cache-aaaaaaaaaaaaaaaa-1111111111111111", + "fallow-audit-base-cache-aaaaaaaaaaaaaaaa-2222222222222222", + "fallow-audit-base-cache-aaaaaaaaaaaaaaaa-1111111111111111.lock", + "fallow-audit-base-cache-bbbbbbbbbbbbbbbb-1111111111111111", + "fallow-audit-base-4242-99", + "unrelated", + ]; + + assert.deepEqual(staleAuditCaches(names, "aaaaaaaaaaaaaaaa", "2222222222222222ffff"), [ + "fallow-audit-base-cache-aaaaaaaaaaaaaaaa-1111111111111111", + ]); + }); +}); + +describe( + "prune-fallow-audit-caches", + { skip: !CAN_AUDIT && "bun or fallow is not installed" }, + () => { + it("leaves one fallow cache for the checkout after audits against two bases", () => { + const work = mkdtempSync(join(tmpdir(), "hf-fallow-prune-")); + try { + const repo = join(work, "repo"); + const temp = join(work, "tmp"); + mkdirSync(join(repo, "src"), { recursive: true }); + mkdirSync(temp); + writeFileSync( + join(repo, "package.json"), + '{"name":"x","type":"module","main":"src/index.js"}', + ); + writeFileSync(join(repo, "src/index.js"), "export const a = 1;\n"); + git(repo, "init", "-q"); + git(repo, "add", "-A"); + commit(repo, "a"); + const oldBase = git(repo, "rev-parse", "HEAD"); + appendFileSync(join(repo, "src/index.js"), "export const b = 2;\n"); + commit(repo, "b"); + const newBase = git(repo, "rev-parse", "HEAD"); + appendFileSync(join(repo, "src/index.js"), "export const c = 3;\n"); + const env = { ...ENV, TMPDIR: temp }; + for (const base of [oldBase, newBase]) { + const audit = spawnSync(FALLOW, ["audit", "--base", base], { + cwd: repo, + env, + encoding: "utf8", + }); + assert.equal(audit.status, 0, audit.stderr); + } + const caches = () => + readdirSync(temp).filter((name) => + /^fallow-audit-base-cache-[0-9a-f]+-[0-9a-f]+$/.test(name), + ); + assert.equal(caches().length, 2, "fallow left one cache per base"); + const foreign = join( + temp, + `fallow-audit-base-cache-0000000000000000-${oldBase.slice(0, 16)}`, + ); + mkdirSync(foreign); + + const run = spawnSync("bun", [SCRIPT, newBase], { cwd: repo, env, encoding: "utf8" }); + + assert.equal(run.status, 0, run.stderr); + const left = caches().filter((name) => !foreign.endsWith(name)); + assert.equal(left.length, 1); + assert.ok(left[0].endsWith(`-${newBase.slice(0, 16)}`), `kept ${left[0]}`); + assert.ok(existsSync(foreign), "another checkout's cache is not touched"); + assert.doesNotMatch(git(repo, "worktree", "list"), new RegExp(oldBase.slice(0, 16))); + } finally { + rmSync(work, { recursive: true, force: true }); + } + }); + }, +);