Skip to content

Commit d44615b

Browse files
authored
tools: bundle size diff CLI (#3350)
No big deal, just something to check the effect of a given PR on the bundled output. Uses `fzf` for the PR picker and [`delta`](https://dandavison.github.io/delta/) for a nice diff. Like `api-diff`, you can specify either a PR number (in this case a console PR rather than an omicron PR) or a base and a head rev. There are some heuristics in place to reduce diff noise: we have to replace the hashes in the filenames with placeholders and we ignore file size changes under 0.05 KB. We also sort the files by name rather than size (which Vite does by default) to avoid spurious changes in order due to files changing size slightly. <img width="575" height="458" alt="image" src="https://github.com/user-attachments/assets/fdb28f39-26e0-490c-ab01-7c5eb0937108" /> <img width="864" height="637" alt="image" src="https://github.com/user-attachments/assets/35e0fad5-2941-4633-adc9-b9cd0878ad59" /> https://github.com/user-attachments/assets/2aa44d75-3ab5-4442-b9a3-3972a62c7cc3
1 parent 4f92755 commit d44615b

4 files changed

Lines changed: 370 additions & 40 deletions

File tree

tools/deno/api-diff.ts

Lines changed: 8 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,11 @@
77
*
88
* Copyright Oxide Computer Company
99
*/
10-
import { exists } from 'https://deno.land/std@0.208.0/fs/mod.ts'
11-
import { $ } from 'https://deno.land/x/dax@0.39.1/mod.ts'
1210
import { Command, ValidationError } from 'jsr:@cliffy/command@1.0.0'
11+
import { $ } from 'jsr:@david/dax@0.41.0'
12+
import { exists } from 'jsr:@std/fs@1.0'
1313

14-
// fzf picker keeps UX quick without requiring people to wire up shell helpers
15-
async function pickPr(): Promise<number> {
16-
const prNum = await $`gh pr list -R oxidecomputer/omicron --limit 100
17-
--json number,title,updatedAt,author
18-
--template '{{range .}}{{tablerow .number .title .author.name (timeago .updatedAt)}}{{end}}'`
19-
.pipe($`fzf --height 25% --reverse`)
20-
.pipe($`cut -f1 -d ' '`)
21-
.text()
22-
if (!/^\d+$/.test(prNum))
23-
throw new Error(`Error picking PR. Expected number, got '${prNum}'`)
24-
return parseInt(prNum, 10)
25-
}
14+
import { isJjRepository, pickPr, resolveLocalCommit } from './common.ts'
2615

2716
// because the schema files change, in order to specify a schema you need both a
2817
// commit and a filename
@@ -40,7 +29,7 @@ const SPEC_RAW_URL = (ref: string, path: string) =>
4029
`https://raw.githubusercontent.com/oxidecomputer/omicron/${ref}/${path}`
4130

4231
async function resolveCommit(ref?: string | number): Promise<string> {
43-
if (ref === undefined) return resolveCommit(await pickPr())
32+
if (ref === undefined) return resolveCommit(await pickPr('oxidecomputer/omicron'))
4433
if (typeof ref === 'number') {
4534
console.error(`Resolving PR #${ref} to commit...`)
4635
const query = `{
@@ -133,32 +122,13 @@ const remoteSource: Source = {
133122
/** Read schemas from the git repo in the current directory (run from an omicron checkout) */
134123
async function createLocalSource(): Promise<Source> {
135124
if (!$.commandExistsSync('git')) throw new Error('--local requires git')
125+
const repoRoot = Deno.cwd()
136126
// jj's working copy is always a commit, so in a jj repo @ is the natural
137127
// default and reflects in-progress (even uncommitted) work. Plain git uses HEAD.
138-
const isJj =
139-
$.commandExistsSync('jj') &&
140-
(await $`jj root`.noThrow().stdout('null').stderr('null')).code === 0
128+
const isJj = await isJjRepository(repoRoot)
141129

142130
const gitShow = (target: string) => $`git show ${target}`.text()
143131

144-
const resolveOne = async (ref: string): Promise<string> => {
145-
try {
146-
if (isJj) {
147-
const out = (
148-
await $`jj log -r ${ref} --no-graph -T commit_id`.stderr('null').text()
149-
).trim()
150-
if (out.includes('\n')) throw new Error(`Revset '${ref}' matches multiple commits`)
151-
return out
152-
}
153-
// pass the peel as a single arg so ^{commit} isn't brace-expanded
154-
const rev = `${ref}^{commit}`
155-
return (await $`git rev-parse --verify ${rev}`.stderr('null').text()).trim()
156-
} catch (e) {
157-
if (e instanceof Error && e.message.startsWith('Revset')) throw e
158-
throw new Error(`Could not resolve '${ref}' in local ${isJj ? 'jj' : 'git'} repo`)
159-
}
160-
}
161-
162132
return {
163133
resolveCommit: async (ref) => {
164134
if (typeof ref === 'number')
@@ -168,9 +138,9 @@ async function createLocalSource(): Promise<Source> {
168138
if (ref === undefined) {
169139
const def = isJj ? '@' : 'HEAD'
170140
console.error(`No ref given, defaulting to ${def} (comparing against its parent)`)
171-
return resolveOne(def)
141+
return resolveLocalCommit(repoRoot, def, isJj)
172142
}
173-
return resolveOne(ref)
143+
return resolveLocalCommit(repoRoot, ref, isJj)
174144
},
175145
listSchemaNames: async (commit) => {
176146
const out = (

tools/deno/bump-omicron.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
*
88
* Copyright Oxide Computer Company
99
*/
10-
import * as path from 'https://deno.land/std@0.159.0/path/mod.ts'
11-
import $ from 'https://deno.land/x/dax@0.39.2/mod.ts'
1210
import { Command } from 'jsr:@cliffy/command@1.0.0'
1311
import { Confirm, Input } from 'jsr:@cliffy/prompt@1.0.0'
12+
import $ from 'jsr:@david/dax@0.41.0'
1413
import { existsSync } from 'jsr:@std/fs@1.0'
14+
import * as path from 'jsr:@std/path@1.1.6'
1515

1616
const OMICRON_DIR = path.resolve('../omicron')
1717
const GH_MISSING = 'GitHub CLI not found. Please install it and try again.'

tools/deno/bundle-size-diff.ts

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
#! /usr/bin/env -S deno run --allow-run=gh,git,jj,npm,diff,delta,fzf --allow-read --allow-write --allow-env
2+
3+
/*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* Copyright Oxide Computer Company
9+
*/
10+
import { Command, ValidationError } from 'jsr:@cliffy/command@1.0.0'
11+
import { $ } from 'jsr:@david/dax@0.41.0'
12+
import { exists } from 'jsr:@std/fs@1.0'
13+
14+
import { pickPr, resolveLocalCommit } from './common.ts'
15+
16+
const REPO = 'oxidecomputer/console'
17+
const CACHE_ROOT = '/tmp/bundle-size-diff'
18+
const SMALL_CHANGE_KB = 0.05
19+
20+
type Pr = {
21+
baseRefOid: string
22+
headRefOid: string
23+
number: number
24+
}
25+
26+
type DiffTarget = {
27+
baseCommit: string
28+
headCommit: string
29+
}
30+
31+
async function getPr(number: number): Promise<Pr> {
32+
const result: unknown = await $`gh pr view ${number} --repo ${REPO}
33+
--json number,baseRefOid,headRefOid`.json()
34+
35+
if (
36+
typeof result !== 'object' ||
37+
result === null ||
38+
!('number' in result) ||
39+
typeof result.number !== 'number' ||
40+
!('baseRefOid' in result) ||
41+
typeof result.baseRefOid !== 'string' ||
42+
!('headRefOid' in result) ||
43+
typeof result.headRefOid !== 'string'
44+
) {
45+
throw new Error(`Unexpected response while resolving PR #${number}`)
46+
}
47+
48+
return {
49+
number: result.number,
50+
baseRefOid: result.baseRefOid,
51+
headRefOid: result.headRefOid,
52+
}
53+
}
54+
55+
async function hasCommit(repoRoot: string, commit: string): Promise<boolean> {
56+
const rev = `${commit}^{commit}`
57+
return (
58+
(await $`git cat-file -e ${rev}`.cwd(repoRoot).noThrow().stdout('null').stderr('null'))
59+
.code === 0
60+
)
61+
}
62+
63+
async function fetchMissingCommits(repoRoot: string, pr: Pr): Promise<void> {
64+
if (!(await hasCommit(repoRoot, pr.baseRefOid))) {
65+
console.error(`Fetching base ${pr.baseRefOid.slice(0, 8)}...`)
66+
await $`git fetch --quiet origin ${pr.baseRefOid}`.cwd(repoRoot)
67+
}
68+
69+
if (!(await hasCommit(repoRoot, pr.headRefOid))) {
70+
console.error(`Fetching head ${pr.headRefOid.slice(0, 8)}...`)
71+
const pullRef = `refs/pull/${pr.number}/head`
72+
await $`git fetch --quiet origin ${pullRef}`.cwd(repoRoot)
73+
}
74+
}
75+
76+
async function resolveTarget(
77+
repoRoot: string,
78+
ref1?: string,
79+
ref2?: string
80+
): Promise<DiffTarget> {
81+
const firstRef = ref1 ?? String(await pickPr(REPO))
82+
83+
if (ref2 === undefined) {
84+
if (!/^\d+$/.test(firstRef)) {
85+
throw new ValidationError(
86+
'A single argument must be a console PR number; pass two arguments to compare revisions'
87+
)
88+
}
89+
if (!$.commandExistsSync('gh')) throw new Error('Need gh (GitHub CLI)')
90+
91+
const pr = await getPr(Number(firstRef))
92+
await fetchMissingCommits(repoRoot, pr)
93+
return { baseCommit: pr.baseRefOid, headCommit: pr.headRefOid }
94+
}
95+
96+
// jj may snapshot the working copy while resolving a revision, so avoid
97+
// running two jj processes against it concurrently.
98+
const baseCommit = await resolveLocalCommit(repoRoot, firstRef)
99+
const headCommit = await resolveLocalCommit(repoRoot, ref2)
100+
return { baseCommit, headCommit }
101+
}
102+
103+
async function addWorktree(repoRoot: string, dir: string, commit: string): Promise<void> {
104+
await $`git worktree add --detach --quiet ${dir} ${commit}`.cwd(repoRoot)
105+
}
106+
107+
function extractBundleSizes(output: string): string {
108+
const lines = output.split('\n')
109+
const start = lines.findIndex((line) => line === 'computing gzip size...')
110+
const end = lines.findIndex(
111+
(line, index) => index > start && line.startsWith('✓ built in')
112+
)
113+
114+
if (start === -1 || end === -1) {
115+
throw new Error('Could not find the bundle size table in npm run build output')
116+
}
117+
118+
return (
119+
lines
120+
.slice(start + 1, end)
121+
// Content changes cascade new hashes through importing chunks. Keep a
122+
// fixed-width placeholder so hash-only changes disappear from the diff.
123+
.map((line) => line.replace(/-[\w-]{8}(?=\.[a-z0-9]+(?:\s|$))/i, '-HASHHASH'))
124+
// Vite sorts by size, which makes unchanged rows look moved when a
125+
// nearby chunk changes. Filename order is stable across builds.
126+
.sort()
127+
.join('\n')
128+
.trimEnd() + '\n'
129+
)
130+
}
131+
132+
type BundleRow = {
133+
file: string
134+
line: string
135+
sizes: number[]
136+
}
137+
138+
function parseBundleRow(line: string): BundleRow | undefined {
139+
const file = line.match(/^\S+/)?.[0]
140+
const sizes = [...line.matchAll(/([\d,]+\.\d+) kB/g)].map((match) =>
141+
Number(match[1].replaceAll(',', ''))
142+
)
143+
return file && sizes.length > 0 ? { file, line, sizes } : undefined
144+
}
145+
146+
function suppressSmallChanges(base: string, head: string): string {
147+
const baseRows = new Map<string, BundleRow[]>()
148+
for (const line of base.trimEnd().split('\n')) {
149+
const row = parseBundleRow(line)
150+
if (!row) continue
151+
const rows = baseRows.get(row.file) ?? []
152+
rows.push(row)
153+
baseRows.set(row.file, rows)
154+
}
155+
156+
const lines = head.trimEnd().split('\n')
157+
return (
158+
lines
159+
.map((line) => {
160+
const headRow = parseBundleRow(line)
161+
const baseRow = headRow && baseRows.get(headRow.file)?.shift()
162+
const isSmallChange =
163+
baseRow &&
164+
baseRow.sizes.length === headRow.sizes.length &&
165+
baseRow.sizes.every(
166+
(size, index) => Math.abs(size - headRow.sizes[index]) <= SMALL_CHANGE_KB + 1e-9
167+
)
168+
return isSmallChange ? baseRow.line : line
169+
})
170+
.join('\n') + '\n'
171+
)
172+
}
173+
174+
async function ensureBuild(
175+
repoRoot: string,
176+
dir: string,
177+
commit: string,
178+
label: string,
179+
force: boolean
180+
): Promise<string> {
181+
const cacheDir = `${CACHE_ROOT}/${commit}`
182+
const outputPath = `${cacheDir}/build-output.txt`
183+
if (!force && (await exists(outputPath))) {
184+
console.error(`Using cached ${label} build...`)
185+
return extractBundleSizes(await Deno.readTextFile(outputPath))
186+
}
187+
188+
await addWorktree(repoRoot, dir, commit)
189+
try {
190+
console.error(`Installing ${label} dependencies...`)
191+
await $`npm ci --no-audit --no-fund`.cwd(dir).env('HUSKY', '0').stdout('null')
192+
193+
console.error(`Building ${label}...`)
194+
const output = await $`npm run build`.cwd(dir).text()
195+
const sizes = extractBundleSizes(output)
196+
await Deno.mkdir(cacheDir, { recursive: true })
197+
await Deno.writeTextFile(outputPath, output)
198+
return sizes
199+
} finally {
200+
console.error(`Cleaning up ${label} worktree...`)
201+
await $`git worktree remove --force ${dir}`.cwd(repoRoot).noThrow().quiet()
202+
}
203+
}
204+
205+
async function runDiff(base: string, head: string, baseLabel: string, headLabel: string) {
206+
const dir = await Deno.makeTempDir({ prefix: 'bundle-size-diff-output-' })
207+
const basePath = `${dir}/base.txt`
208+
const headPath = `${dir}/head.txt`
209+
210+
try {
211+
await Promise.all([
212+
Deno.writeTextFile(basePath, base),
213+
Deno.writeTextFile(headPath, head),
214+
])
215+
216+
// Match api-diff: render through delta for interactive use and leave plain
217+
// unified output intact when piping the result elsewhere.
218+
const useDelta = $.commandExistsSync('delta') && Deno.stdout.isTerminal()
219+
const diff =
220+
$`diff -u -L ${baseLabel} -L ${headLabel} ${basePath} ${headPath}`.noThrow()
221+
await (useDelta ? diff.pipe($`delta`) : diff)
222+
} finally {
223+
await Deno.remove(dir, { recursive: true })
224+
}
225+
}
226+
227+
await new Command()
228+
.name('bundle-size-diff')
229+
.description(
230+
`Build two console revisions and display a unified diff of Vite's
231+
bundle size table.
232+
233+
Arguments:
234+
No args Pick a console PR with fzf
235+
<pr> Compare the base and head of a console PR
236+
<base> <head> Compare two local git or jj revisions
237+
238+
Dependencies:
239+
- Deno
240+
- GitHub CLI (gh) for PRs
241+
- Git
242+
- Node.js and npm
243+
- Optional: delta diff pager https://dandavison.github.io/delta/
244+
- Optional: fzf for PR picker https://github.com/junegunn/fzf`
245+
)
246+
.helpOption('-h, --help', 'Show help')
247+
.option('--force', 'Rebuild even if output is cached')
248+
.arguments('[ref1:string] [ref2:string]')
249+
.action(async (options, ref1?: string, ref2?: string) => {
250+
let tempRoot: string | undefined
251+
252+
try {
253+
const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim()
254+
const target = await resolveTarget(repoRoot, ref1, ref2)
255+
256+
tempRoot = await Deno.makeTempDir({ prefix: 'bundle-size-diff-' })
257+
const baseDir = `${tempRoot}/base`
258+
const headDir = `${tempRoot}/head`
259+
260+
const baseShort = target.baseCommit.slice(0, 8)
261+
const headShort = target.headCommit.slice(0, 8)
262+
const force = options.force ?? false
263+
const base = await ensureBuild(
264+
repoRoot,
265+
baseDir,
266+
target.baseCommit,
267+
`base (${baseShort})`,
268+
force
269+
)
270+
const head = await ensureBuild(
271+
repoRoot,
272+
headDir,
273+
target.headCommit,
274+
`head (${headShort})`,
275+
force
276+
)
277+
await runDiff(
278+
base,
279+
suppressSmallChanges(base, head),
280+
`a/${baseShort}/bundle-size`,
281+
`b/${headShort}/bundle-size`
282+
)
283+
} catch (e) {
284+
console.error(`error: ${e instanceof Error ? e.message : String(e)}`)
285+
Deno.exitCode = 1
286+
} finally {
287+
if (tempRoot) await Deno.remove(tempRoot, { recursive: true }).catch(() => {})
288+
}
289+
})
290+
.parse(Deno.args)

0 commit comments

Comments
 (0)