Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/sh

# Run lint-staged for formatting and linting on staged files only
npx lint-staged
bunx lint-staged

# Everything else (tests, type checking) should run in CI
2 changes: 1 addition & 1 deletion .husky/pre-merge-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/bin/sh

# Run staged-file formatting/lint checks before creating a merge commit.
npx lint-staged
bunx lint-staged
15 changes: 12 additions & 3 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
#!/bin/sh

# Run repo-wide validation before allowing a push.
# This mirrors the main CI gate: prompts, repo-wide Prettier, type checks, ESLint, and tests.
# Run changed-file validation before allowing a push.
# Use MAESTRO_FULL_PUSH_VALIDATE=1 to run repo-wide format, type checks, ESLint, and tests.
#
# Skip validation when the push only DELETES refs (no new commits to validate).
# Git feeds ref updates on stdin as: <local ref> <local sha> <remote ref> <remote sha>
# For a deletion the local sha is all zeros, so there is nothing to lint or test.
z40=0000000000000000000000000000000000000000
only_deletes=1
saw_ref=0
ref_updates=
while read -r local_ref local_sha remote_ref remote_sha; do
[ -z "$local_ref$remote_ref" ] && continue
saw_ref=1
ref_updates="${ref_updates}${local_ref} ${local_sha} ${remote_ref} ${remote_sha}
"
case "$local_sha" in
"$z40" | "") : ;; # deletion, nothing to validate
*) only_deletes=0 ;;
Expand All @@ -23,4 +26,10 @@ if [ "$saw_ref" -eq 1 ] && [ "$only_deletes" -eq 1 ]; then
exit 0
fi

npm run validate:push
if [ "${MAESTRO_FULL_PUSH_VALIDATE:-}" = "1" ]; then
echo "[pre-push] Running full push validation."
bun run validate:push:full
else
echo "[pre-push] Running fast changed-file validation. Set MAESTRO_FULL_PUSH_VALIDATE=1 for the full gate."
printf '%s' "$ref_updates" | bun run validate:push:fast
fi
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@
"format:all": "prettier --write .",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"format:check:all": "prettier --check .",
"validate:push": "npm run format:check:all && npm run lint && npm run lint:eslint && npm run test",
"validate:push": "bun run validate:push:fast",
"validate:push:fast": "bun scripts/validate-push-fast.ts",
"validate:push:full": "bun run format:check:all && bun run lint && bun run lint:eslint && bun run test",
"test": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vitest run",
"test:watch": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vitest",
"test:coverage": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vitest run --coverage",
Expand Down
210 changes: 210 additions & 0 deletions scripts/validate-push-fast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';

const ZERO_SHA = '0000000000000000000000000000000000000000';
const MAX_ARGS_PER_BATCH = 80;
const prettierExtensions = new Set([
'.css',
'.cts',
'.html',
'.js',
'.json',
'.jsx',
'.less',
'.md',
'.mjs',
'.mts',
'.scss',
'.svg',
'.ts',
'.tsx',
'.yaml',
'.yml',
]);
const eslintExtensions = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']);

type RefUpdate = {
localRef: string;
localSha: string;
remoteRef: string;
remoteSha: string;
};

function runCapture(command: string, args: string[]): string | null {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});

if (result.status !== 0) {
return null;
}

return result.stdout.trim();
}

function runInherited(command: string, args: string[]): number {
const result = spawnSync(command, args, { stdio: 'inherit' });
return result.status ?? 1;
}

function runBunx(args: string[]): number {
const executable = process.platform === 'win32' ? 'bunx.cmd' : 'bunx';
return runInherited(executable, args);
}

function splitLines(text: string): string[] {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}

async function readStdin(): Promise<string> {
if (process.stdin.isTTY) {
return '';
}

const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}

function parseRefUpdates(stdin: string): RefUpdate[] {
return splitLines(stdin).flatMap((line) => {
const [localRef, localSha, remoteRef, remoteSha] = line.split(/\s+/);
if (!localRef || !localSha || !remoteRef || !remoteSha) {
return [];
}
return [{ localRef, localSha, remoteRef, remoteSha }];
});
}

function gitDiffNames(base: string, head: string): string[] {
const output = runCapture('git', ['diff', '--name-only', '--diff-filter=ACMR', base, head]);
return output ? splitLines(output) : [];
}

function mergeBase(head: string, ref: string): string | null {
return runCapture('git', ['merge-base', head, ref]);
}

function baseForUpdate(update: RefUpdate): string | null {
if (update.remoteSha && update.remoteSha !== ZERO_SHA) {
return update.remoteSha;
}

return (
mergeBase(update.localSha, 'origin/rc') ??
mergeBase(update.localSha, 'origin/main') ??
runCapture('git', ['rev-parse', `${update.localSha}^`])
);
}

function changedFilesFromPrePush(updates: RefUpdate[]): string[] {
const files = new Set<string>();

for (const update of updates) {
if (!update.localSha || update.localSha === ZERO_SHA) {
continue;
}

const base = baseForUpdate(update);
if (!base) {
console.error(
`[validate:push:fast] Could not find a comparison base for ${update.localRef}.`
);
process.exitCode = 1;
continue;
}

for (const file of gitDiffNames(base, update.localSha)) {
files.add(file);
}
}

return [...files];
}

function changedFilesFromWorkingTree(): string[] {
const upstream = runCapture('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
const baseRange = upstream ? `${upstream}...HEAD` : 'origin/rc...HEAD';
const committed = runCapture('git', ['diff', '--name-only', '--diff-filter=ACMR', baseRange]);
const unstaged = runCapture('git', ['diff', '--name-only', '--diff-filter=ACMR']);
const staged = runCapture('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR']);
const untracked = runCapture('git', ['ls-files', '--others', '--exclude-standard']);

return [
...new Set([
...splitLines(committed ?? ''),
...splitLines(unstaged ?? ''),
...splitLines(staged ?? ''),
...splitLines(untracked ?? ''),
]),
];
}

function existingFiles(files: string[]): string[] {
const repoRoot = runCapture('git', ['rev-parse', '--show-toplevel']) ?? process.cwd();
return files.filter((file) => existsSync(path.resolve(repoRoot, file)));
}

function hasExtension(file: string, extensions: Set<string>): boolean {
return extensions.has(path.extname(file).toLowerCase());
}

function batched(files: string[]): string[][] {
const batches: string[][] = [];
for (let index = 0; index < files.length; index += MAX_ARGS_PER_BATCH) {
batches.push(files.slice(index, index + MAX_ARGS_PER_BATCH));
}
return batches;
}

function runBatches(label: string, baseArgs: string[], files: string[]): boolean {
if (files.length === 0) {
console.log(`[validate:push:fast] ${label}: no matching changed files.`);
return true;
}

console.log(`[validate:push:fast] ${label}: checking ${files.length} changed file(s).`);
for (const batch of batched(files)) {
const status = runBunx([...baseArgs, ...batch]);
if (status !== 0) {
return false;
}
}

return true;
}

const stdin = await readStdin();
const updates = parseRefUpdates(stdin);
const changedFiles = existingFiles(
updates.length > 0 ? changedFilesFromPrePush(updates) : changedFilesFromWorkingTree()
);
const prettierFiles = changedFiles.filter((file) => hasExtension(file, prettierExtensions));
const eslintFiles = changedFiles.filter(
(file) => file.startsWith('src/') && hasExtension(file, eslintExtensions)
);

console.log(`[validate:push:fast] Changed files detected: ${changedFiles.length}.`);

const prettierPassed = runBatches(
'Prettier',
['prettier', '--check', '--ignore-unknown'],
prettierFiles
);
const eslintPassed = runBatches('ESLint', ['eslint'], eslintFiles);

if (!prettierPassed || !eslintPassed || process.exitCode) {
process.exit(process.exitCode ?? 1);
}

console.log('[validate:push:fast] Fast push validation passed.');
console.log(
'[validate:push:fast] Run `bun run validate:push:full` for repo-wide format, typecheck, ESLint, and tests.'
);