diff --git a/tools/deno/api-diff.ts b/tools/deno/api-diff.ts index a7f4337c2..fa8f74a7f 100755 --- a/tools/deno/api-diff.ts +++ b/tools/deno/api-diff.ts @@ -7,22 +7,11 @@ * * Copyright Oxide Computer Company */ -import { exists } from 'https://deno.land/std@0.208.0/fs/mod.ts' -import { $ } from 'https://deno.land/x/dax@0.39.1/mod.ts' import { Command, ValidationError } from 'jsr:@cliffy/command@1.0.0' +import { $ } from 'jsr:@david/dax@0.41.0' +import { exists } from 'jsr:@std/fs@1.0' -// fzf picker keeps UX quick without requiring people to wire up shell helpers -async function pickPr(): Promise { - const prNum = await $`gh pr list -R oxidecomputer/omicron --limit 100 - --json number,title,updatedAt,author - --template '{{range .}}{{tablerow .number .title .author.name (timeago .updatedAt)}}{{end}}'` - .pipe($`fzf --height 25% --reverse`) - .pipe($`cut -f1 -d ' '`) - .text() - if (!/^\d+$/.test(prNum)) - throw new Error(`Error picking PR. Expected number, got '${prNum}'`) - return parseInt(prNum, 10) -} +import { isJjRepository, pickPr, resolveLocalCommit } from './common.ts' // because the schema files change, in order to specify a schema you need both a // commit and a filename @@ -40,7 +29,7 @@ const SPEC_RAW_URL = (ref: string, path: string) => `https://raw.githubusercontent.com/oxidecomputer/omicron/${ref}/${path}` async function resolveCommit(ref?: string | number): Promise { - if (ref === undefined) return resolveCommit(await pickPr()) + if (ref === undefined) return resolveCommit(await pickPr('oxidecomputer/omicron')) if (typeof ref === 'number') { console.error(`Resolving PR #${ref} to commit...`) const query = `{ @@ -133,32 +122,13 @@ const remoteSource: Source = { /** Read schemas from the git repo in the current directory (run from an omicron checkout) */ async function createLocalSource(): Promise { if (!$.commandExistsSync('git')) throw new Error('--local requires git') + const repoRoot = Deno.cwd() // jj's working copy is always a commit, so in a jj repo @ is the natural // default and reflects in-progress (even uncommitted) work. Plain git uses HEAD. - const isJj = - $.commandExistsSync('jj') && - (await $`jj root`.noThrow().stdout('null').stderr('null')).code === 0 + const isJj = await isJjRepository(repoRoot) const gitShow = (target: string) => $`git show ${target}`.text() - const resolveOne = async (ref: string): Promise => { - try { - if (isJj) { - const out = ( - await $`jj log -r ${ref} --no-graph -T commit_id`.stderr('null').text() - ).trim() - if (out.includes('\n')) throw new Error(`Revset '${ref}' matches multiple commits`) - return out - } - // pass the peel as a single arg so ^{commit} isn't brace-expanded - const rev = `${ref}^{commit}` - return (await $`git rev-parse --verify ${rev}`.stderr('null').text()).trim() - } catch (e) { - if (e instanceof Error && e.message.startsWith('Revset')) throw e - throw new Error(`Could not resolve '${ref}' in local ${isJj ? 'jj' : 'git'} repo`) - } - } - return { resolveCommit: async (ref) => { if (typeof ref === 'number') @@ -168,9 +138,9 @@ async function createLocalSource(): Promise { if (ref === undefined) { const def = isJj ? '@' : 'HEAD' console.error(`No ref given, defaulting to ${def} (comparing against its parent)`) - return resolveOne(def) + return resolveLocalCommit(repoRoot, def, isJj) } - return resolveOne(ref) + return resolveLocalCommit(repoRoot, ref, isJj) }, listSchemaNames: async (commit) => { const out = ( diff --git a/tools/deno/bump-omicron.ts b/tools/deno/bump-omicron.ts index 2e8437766..ebd4237f8 100755 --- a/tools/deno/bump-omicron.ts +++ b/tools/deno/bump-omicron.ts @@ -7,11 +7,11 @@ * * Copyright Oxide Computer Company */ -import * as path from 'https://deno.land/std@0.159.0/path/mod.ts' -import $ from 'https://deno.land/x/dax@0.39.2/mod.ts' import { Command } from 'jsr:@cliffy/command@1.0.0' import { Confirm, Input } from 'jsr:@cliffy/prompt@1.0.0' +import $ from 'jsr:@david/dax@0.41.0' import { existsSync } from 'jsr:@std/fs@1.0' +import * as path from 'jsr:@std/path@1.1.6' const OMICRON_DIR = path.resolve('../omicron') const GH_MISSING = 'GitHub CLI not found. Please install it and try again.' diff --git a/tools/deno/bundle-size-diff.ts b/tools/deno/bundle-size-diff.ts new file mode 100755 index 000000000..4b7d734f7 --- /dev/null +++ b/tools/deno/bundle-size-diff.ts @@ -0,0 +1,290 @@ +#! /usr/bin/env -S deno run --allow-run=gh,git,jj,npm,diff,delta,fzf --allow-read --allow-write --allow-env + +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { Command, ValidationError } from 'jsr:@cliffy/command@1.0.0' +import { $ } from 'jsr:@david/dax@0.41.0' +import { exists } from 'jsr:@std/fs@1.0' + +import { pickPr, resolveLocalCommit } from './common.ts' + +const REPO = 'oxidecomputer/console' +const CACHE_ROOT = '/tmp/bundle-size-diff' +const SMALL_CHANGE_KB = 0.05 + +type Pr = { + baseRefOid: string + headRefOid: string + number: number +} + +type DiffTarget = { + baseCommit: string + headCommit: string +} + +async function getPr(number: number): Promise { + const result: unknown = await $`gh pr view ${number} --repo ${REPO} + --json number,baseRefOid,headRefOid`.json() + + if ( + typeof result !== 'object' || + result === null || + !('number' in result) || + typeof result.number !== 'number' || + !('baseRefOid' in result) || + typeof result.baseRefOid !== 'string' || + !('headRefOid' in result) || + typeof result.headRefOid !== 'string' + ) { + throw new Error(`Unexpected response while resolving PR #${number}`) + } + + return { + number: result.number, + baseRefOid: result.baseRefOid, + headRefOid: result.headRefOid, + } +} + +async function hasCommit(repoRoot: string, commit: string): Promise { + const rev = `${commit}^{commit}` + return ( + (await $`git cat-file -e ${rev}`.cwd(repoRoot).noThrow().stdout('null').stderr('null')) + .code === 0 + ) +} + +async function fetchMissingCommits(repoRoot: string, pr: Pr): Promise { + if (!(await hasCommit(repoRoot, pr.baseRefOid))) { + console.error(`Fetching base ${pr.baseRefOid.slice(0, 8)}...`) + await $`git fetch --quiet origin ${pr.baseRefOid}`.cwd(repoRoot) + } + + if (!(await hasCommit(repoRoot, pr.headRefOid))) { + console.error(`Fetching head ${pr.headRefOid.slice(0, 8)}...`) + const pullRef = `refs/pull/${pr.number}/head` + await $`git fetch --quiet origin ${pullRef}`.cwd(repoRoot) + } +} + +async function resolveTarget( + repoRoot: string, + ref1?: string, + ref2?: string +): Promise { + const firstRef = ref1 ?? String(await pickPr(REPO)) + + if (ref2 === undefined) { + if (!/^\d+$/.test(firstRef)) { + throw new ValidationError( + 'A single argument must be a console PR number; pass two arguments to compare revisions' + ) + } + if (!$.commandExistsSync('gh')) throw new Error('Need gh (GitHub CLI)') + + const pr = await getPr(Number(firstRef)) + await fetchMissingCommits(repoRoot, pr) + return { baseCommit: pr.baseRefOid, headCommit: pr.headRefOid } + } + + // jj may snapshot the working copy while resolving a revision, so avoid + // running two jj processes against it concurrently. + const baseCommit = await resolveLocalCommit(repoRoot, firstRef) + const headCommit = await resolveLocalCommit(repoRoot, ref2) + return { baseCommit, headCommit } +} + +async function addWorktree(repoRoot: string, dir: string, commit: string): Promise { + await $`git worktree add --detach --quiet ${dir} ${commit}`.cwd(repoRoot) +} + +function extractBundleSizes(output: string): string { + const lines = output.split('\n') + const start = lines.findIndex((line) => line === 'computing gzip size...') + const end = lines.findIndex( + (line, index) => index > start && line.startsWith('✓ built in') + ) + + if (start === -1 || end === -1) { + throw new Error('Could not find the bundle size table in npm run build output') + } + + return ( + lines + .slice(start + 1, end) + // Content changes cascade new hashes through importing chunks. Keep a + // fixed-width placeholder so hash-only changes disappear from the diff. + .map((line) => line.replace(/-[\w-]{8}(?=\.[a-z0-9]+(?:\s|$))/i, '-HASHHASH')) + // Vite sorts by size, which makes unchanged rows look moved when a + // nearby chunk changes. Filename order is stable across builds. + .sort() + .join('\n') + .trimEnd() + '\n' + ) +} + +type BundleRow = { + file: string + line: string + sizes: number[] +} + +function parseBundleRow(line: string): BundleRow | undefined { + const file = line.match(/^\S+/)?.[0] + const sizes = [...line.matchAll(/([\d,]+\.\d+) kB/g)].map((match) => + Number(match[1].replaceAll(',', '')) + ) + return file && sizes.length > 0 ? { file, line, sizes } : undefined +} + +function suppressSmallChanges(base: string, head: string): string { + const baseRows = new Map() + for (const line of base.trimEnd().split('\n')) { + const row = parseBundleRow(line) + if (!row) continue + const rows = baseRows.get(row.file) ?? [] + rows.push(row) + baseRows.set(row.file, rows) + } + + const lines = head.trimEnd().split('\n') + return ( + lines + .map((line) => { + const headRow = parseBundleRow(line) + const baseRow = headRow && baseRows.get(headRow.file)?.shift() + const isSmallChange = + baseRow && + baseRow.sizes.length === headRow.sizes.length && + baseRow.sizes.every( + (size, index) => Math.abs(size - headRow.sizes[index]) <= SMALL_CHANGE_KB + 1e-9 + ) + return isSmallChange ? baseRow.line : line + }) + .join('\n') + '\n' + ) +} + +async function ensureBuild( + repoRoot: string, + dir: string, + commit: string, + label: string, + force: boolean +): Promise { + const cacheDir = `${CACHE_ROOT}/${commit}` + const outputPath = `${cacheDir}/build-output.txt` + if (!force && (await exists(outputPath))) { + console.error(`Using cached ${label} build...`) + return extractBundleSizes(await Deno.readTextFile(outputPath)) + } + + await addWorktree(repoRoot, dir, commit) + try { + console.error(`Installing ${label} dependencies...`) + await $`npm ci --no-audit --no-fund`.cwd(dir).env('HUSKY', '0').stdout('null') + + console.error(`Building ${label}...`) + const output = await $`npm run build`.cwd(dir).text() + const sizes = extractBundleSizes(output) + await Deno.mkdir(cacheDir, { recursive: true }) + await Deno.writeTextFile(outputPath, output) + return sizes + } finally { + console.error(`Cleaning up ${label} worktree...`) + await $`git worktree remove --force ${dir}`.cwd(repoRoot).noThrow().quiet() + } +} + +async function runDiff(base: string, head: string, baseLabel: string, headLabel: string) { + const dir = await Deno.makeTempDir({ prefix: 'bundle-size-diff-output-' }) + const basePath = `${dir}/base.txt` + const headPath = `${dir}/head.txt` + + try { + await Promise.all([ + Deno.writeTextFile(basePath, base), + Deno.writeTextFile(headPath, head), + ]) + + // Match api-diff: render through delta for interactive use and leave plain + // unified output intact when piping the result elsewhere. + const useDelta = $.commandExistsSync('delta') && Deno.stdout.isTerminal() + const diff = + $`diff -u -L ${baseLabel} -L ${headLabel} ${basePath} ${headPath}`.noThrow() + await (useDelta ? diff.pipe($`delta`) : diff) + } finally { + await Deno.remove(dir, { recursive: true }) + } +} + +await new Command() + .name('bundle-size-diff') + .description( + `Build two console revisions and display a unified diff of Vite's +bundle size table. + +Arguments: + No args Pick a console PR with fzf + Compare the base and head of a console PR + Compare two local git or jj revisions + +Dependencies: + - Deno + - GitHub CLI (gh) for PRs + - Git + - Node.js and npm + - Optional: delta diff pager https://dandavison.github.io/delta/ + - Optional: fzf for PR picker https://github.com/junegunn/fzf` + ) + .helpOption('-h, --help', 'Show help') + .option('--force', 'Rebuild even if output is cached') + .arguments('[ref1:string] [ref2:string]') + .action(async (options, ref1?: string, ref2?: string) => { + let tempRoot: string | undefined + + try { + const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim() + const target = await resolveTarget(repoRoot, ref1, ref2) + + tempRoot = await Deno.makeTempDir({ prefix: 'bundle-size-diff-' }) + const baseDir = `${tempRoot}/base` + const headDir = `${tempRoot}/head` + + const baseShort = target.baseCommit.slice(0, 8) + const headShort = target.headCommit.slice(0, 8) + const force = options.force ?? false + const base = await ensureBuild( + repoRoot, + baseDir, + target.baseCommit, + `base (${baseShort})`, + force + ) + const head = await ensureBuild( + repoRoot, + headDir, + target.headCommit, + `head (${headShort})`, + force + ) + await runDiff( + base, + suppressSmallChanges(base, head), + `a/${baseShort}/bundle-size`, + `b/${headShort}/bundle-size` + ) + } catch (e) { + console.error(`error: ${e instanceof Error ? e.message : String(e)}`) + Deno.exitCode = 1 + } finally { + if (tempRoot) await Deno.remove(tempRoot, { recursive: true }).catch(() => {}) + } + }) + .parse(Deno.args) diff --git a/tools/deno/common.ts b/tools/deno/common.ts new file mode 100644 index 000000000..56464e670 --- /dev/null +++ b/tools/deno/common.ts @@ -0,0 +1,70 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { $ } from 'jsr:@david/dax@0.41.0' + +export async function pickPr(repo: string): Promise { + if (!$.commandExistsSync('gh')) throw new Error('Need gh (GitHub CLI)') + if (!$.commandExistsSync('fzf')) throw new Error('Need fzf to pick a PR') + + const selection = await $`gh pr list --repo ${repo} --limit 100 + --json number,title,updatedAt,author + --template '{{range .}}{{tablerow .number .title .author.name (timeago .updatedAt)}}{{end}}'` + .pipe($`fzf --height 25% --reverse`) + .text() + const prNumber = selection.match(/^\d+/)?.[0] + if (!prNumber) { + throw new Error('Expected the selected row to start with a PR number') + } + return Number(prNumber) +} + +export async function isJjRepository(repoRoot: string): Promise { + return ( + $.commandExistsSync('jj') && + (await $`jj root`.cwd(repoRoot).noThrow().stdout('null').stderr('null')).code === 0 + ) +} + +export async function resolveLocalCommit( + repoRoot: string, + ref: string, + isJj?: boolean +): Promise { + const useJj = isJj ?? (await isJjRepository(repoRoot)) + if (useJj) { + try { + const template = 'commit_id ++ "\\n"' + const commits = ( + await $`jj log --revisions ${ref} --no-graph --template ${template}` + .cwd(repoRoot) + .stderr('null') + .text() + ) + .trim() + .split('\n') + .filter(Boolean) + if (commits.length !== 1) { + throw new Error(`Revision '${ref}' resolved to ${commits.length} commits`) + } + return commits[0] + } catch (e) { + if (e instanceof Error && e.message.startsWith('Revision')) throw e + // A newly fetched Git commit may not have been imported into jj yet. + } + } + + try { + // Pass the peel as a single argument so ^{commit} isn't brace-expanded. + const rev = `${ref}^{commit}` + return ( + await $`git rev-parse --verify ${rev}`.cwd(repoRoot).stderr('null').text() + ).trim() + } catch { + throw new Error(`Could not resolve revision '${ref}'`) + } +}