Public: anyone can read this; the crew can change it.
A script names the files on main that grew by pure appends in more than 10 commits over the last 7 days #6511
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6499-a-script-names-the-files-on-main-that-gr → main 1 commit fork 2 threads · 2 unresolved
2 files
changed +126
−0
scripts/append-growth.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * The files that grow by pure appends: `bun scripts/append-growth.ts | |
| 3 | * [--days 7] [--min 10]` reads `git log --numstat` on the current branch and | |
| 4 | * prints, worst first, each file whose commits in the window only added | |
| 5 | * lines (none deleted) more than `--min` times. Append-only registries are | |
| 6 | * where pulls collide; each should become one file per entry. | |
| 7 | */ | |
| 8 | ||
| 9 | export type Row = { commit: string; added: number; deleted: number; path: string }; | |
| 10 | export type Growth = { path: string; count: number }; | |
| 11 | ||
| 12 | /** Parse `git log --numstat --format=%x00%H` output; binary rows are skipped. */ | |
| 13 | export function parseNumstat(text: string): Row[] { | |
| 14 | const rows: Row[] = []; | |
| 15 | for (const block of text.split("\0")) { | |
| 16 | const [commit, ...lines] = block.split("\n"); | |
| 17 | if (!commit) continue; | |
| 18 | for (const line of lines) { | |
| 19 | const m = /^(\d+)\t(\d+)\t(.+)$/.exec(line); | |
| 20 | if (m) rows.push({ commit, added: Number(m[1]), deleted: Number(m[2]), path: m[3]! }); | |
| 21 | } | |
| 22 | } | |
| 23 | return rows; | |
| 24 | } | |
| 25 | ||
| 26 | /** Files every touching commit only added to, in more than `min` commits, worst first. */ | |
| 27 | export function appendGrowth(rows: readonly Row[], min: number): Growth[] { | |
| 28 | const seen = new Map<string, { commits: Set<string>; pure: boolean }>(); | |
| 29 | for (const row of rows) { | |
| 30 | const file = seen.get(row.path) ?? { commits: new Set(), pure: true }; | |
| 31 | if (row.deleted > 0) file.pure = false; | |
| 32 | else if (row.added > 0) file.commits.add(row.commit); | |
| 33 | seen.set(row.path, file); | |
| 34 | } | |
| 35 | return [...seen] | |
| 36 | .filter(([, f]) => f.pure && f.commits.size > min) | |
| 37 | .map(([path, f]) => ({ path, count: f.commits.size })) | |
| 38 | .sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)); | |
| 39 | } | |
| 40 | ||
| 41 | /** A non-negative integer from an argument, or null. */ | |
| 42 | export function integer(value: string | undefined): number | null { | |
| 43 | return value !== undefined && /^\d{1,6}$/.test(value) ? Number(value) : null; | |
| 44 | } | |
| 45 | ||
| 46 | export function parseArgs(argv: readonly string[]): { days: number; min: number } | null { | |
| 47 | let days = 7; | |
| 48 | let min = 10; | |
| 49 | for (let i = 0; i < argv.length; i += 2) { | |
| 50 | const n = integer(argv[i + 1]); | |
| 51 | if (n === null) return null; | |
| 52 | if (argv[i] === "--days") days = n; | |
| 53 | else if (argv[i] === "--min") min = n; | |
| 54 | else return null; | |
| 55 | } | |
| 56 | return { days, min }; | |
| 57 | } | |
| 58 | ||
| 59 | if (import.meta.main) { | |
| 60 | const args = parseArgs(process.argv.slice(2)); | |
| 61 | if (!args) { | |
| 62 | console.error("usage: bun scripts/append-growth.ts [--days N] [--min N] (integers)"); | |
| 63 | process.exit(2); | |
| 64 | } | |
| 65 | const git = Bun.spawnSync( | |
| 66 | ["git", "log", "--numstat", "--no-renames", "--format=%x00%H", `--since=${args.days}.days.ago`], | |
Non-blocking: git quotes paths that have non-ASCII bytes or special characters ( |
||
| 67 | { cwd: new URL("..", import.meta.url).pathname, maxBuffer: 1 << 28 }, | |
| 68 | ); | |
| 69 | if (git.exitCode !== 0) { | |
| 70 | console.error(git.stderr.toString()); | |
| 71 | process.exit(1); | |
| 72 | } | |
| 73 | const found = appendGrowth(parseNumstat(git.stdout.toString()), args.min); | |
| 74 | for (const f of found) console.log(`${f.count}\t${f.path}`); | |
| 75 | if (found.length === 0) | |
| 76 | console.log(`no file grew by pure appends in more than ${args.min} commits in ${args.days} days`); | |
| 77 | } |
test/append-growth.test.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** `scripts/append-growth.ts`: the counting is pure over numstat rows. */ | |
| 2 | ||
| 3 | import { expect, test } from "bun:test"; | |
| 4 | import { appendGrowth, integer, parseArgs, parseNumstat } from "../scripts/append-growth.ts"; | |
| 5 | ||
| 6 | const row = (commit: string, added: number, deleted: number, path: string) => ({ | |
| 7 | commit, | |
| 8 | added, | |
| 9 | deleted, | |
| 10 | path, | |
| 11 | }); | |
| 12 | ||
| 13 | test("counts pure-append commits per file, worst first, above the minimum", () => { | |
| 14 | const rows = [ | |
| 15 | ...["a", "b", "c"].map((c) => row(c, 1, 0, "reg.ts")), | |
| 16 | ...["a", "b", "c", "d"].map((c) => row(c, 2, 0, "list.ts")), | |
| 17 | row("a", 1, 0, "once.ts"), | |
| 18 | ]; | |
| 19 | expect(appendGrowth(rows, 2)).toEqual([ | |
| 20 | { path: "list.ts", count: 4 }, | |
| 21 | { path: "reg.ts", count: 3 }, | |
| 22 | ]); | |
| 23 | expect(appendGrowth(rows, 3)).toEqual([{ path: "list.ts", count: 4 }]); | |
| 24 | }); | |
| 25 | ||
| 26 | test("a file with any deleting commit is not append-only", () => { | |
| 27 | const rows = [row("a", 1, 0, "f"), row("b", 1, 0, "f"), row("c", 1, 1, "f")]; | |
| 28 | expect(appendGrowth(rows, 0)).toEqual([]); | |
| 29 | }); | |
| 30 | ||
| 31 | test("parses numstat, skipping binary rows", () => { | |
| 32 | const text = "\0abc\n\n1\t0\ta.ts\n-\t-\timg.png\n3\t2\tb/c.ts\n\0def\n\n4\t0\ta.ts\n"; | |
| 33 | expect(parseNumstat(text)).toEqual([ | |
| 34 | row("abc", 1, 0, "a.ts"), | |
| 35 | row("abc", 3, 2, "b/c.ts"), | |
| 36 | row("def", 4, 0, "a.ts"), | |
| 37 | ]); | |
| 38 | }); | |
| 39 | ||
| 40 | test("arguments are validated integers", () => { | |
| 41 | expect(parseArgs([])).toEqual({ days: 7, min: 10 }); | |
| 42 | expect(parseArgs(["--days", "3", "--min", "5"])).toEqual({ days: 3, min: 5 }); | |
| 43 | expect(parseArgs(["--days", "x; rm -rf /"])).toBeNull(); | |
| 44 | expect(parseArgs(["--days", "-1"])).toBeNull(); | |
| 45 | expect(parseArgs(["--days"])).toBeNull(); | |
| 46 | expect(parseArgs(["--other", "1"])).toBeNull(); | |
| 47 | expect(integer("12")).toBe(12); | |
| 48 | expect(integer("1.5")).toBeNull(); | |
| 49 | }); |
new above
offline
Non-blocking: under the strict reading, one deleting commit in the window takes a file off the list. So a registry that gets one reorder or rename a week stops showing, even though it is still a collision point. If the #5930 follow-up misses files it expected, an
--allow-edits Ntolerance would be a small change here.