diff --git a/build-tools/packages/build-cli/src/commands/check/bundleSize.ts b/build-tools/packages/build-cli/src/commands/check/bundleSize.ts index ff3b1d6a6a23..b6c0e349c25b 100644 --- a/build-tools/packages/build-cli/src/commands/check/bundleSize.ts +++ b/build-tools/packages/build-cli/src/commands/check/bundleSize.ts @@ -3,36 +3,73 @@ * Licensed under the MIT License. */ +import { execFileSync } from "node:child_process"; import { Flags } from "@oclif/core"; + +import { getArtifactForCommit } from "../../library/azureDevops/getArtifactForCommit.js"; import { getAzureDevopsApi } from "../../library/azureDevops/getAzureDevopsApi.js"; import { - ADOSizeComparator, - type BundleComparison, - bundlesContainNoChanges, - pickFreshestCanonicalRemote, + checkLocalBundleAnalysisExists, + compareJsonReportsByPackage, + extractAnalyzerJsonsFromArtifact, + type PackageComparison, + readAnalyzerJsonsFromFileSystem, } from "../../library/bundleSize/index.js"; - import { BaseCommand } from "../../library/commands/base.js"; +import { pickFreshestRemote } from "../../library/git/pickFreshestRemote.js"; -// Must match the "public" project + build-bundle-size-artifacts.yml (definitionId 48). -const adoConstants = { - orgUrl: "https://dev.azure.com/fluidframework", - projectName: "public", - ciBuildDefinitionId: 48, - artifactName: "bundleAnalyzerJson", -} as const; - -// Where `flub generate bundleStats` (via `npm run bundle-analysis:collect`) writes. +// Where `flub generate bundleStats` (via `pnpm bundle-analysis:collect`) writes. const defaultLocalReportPath = "./artifacts/bundleAnalyzerJson"; /** * Result serialized to stdout by `--json`. Default invocations print a * human-readable summary instead. */ -type CheckBundleSizeResult = - | { kind: "no-changes"; baselineCommit: string } - | { kind: "changes"; baselineCommit: string; comparison: BundleComparison[] } - | { kind: "error"; baselineCommit: string | undefined; error: string }; +interface CheckBundleSizeResult { + baselineCommit: string; + comparison: PackageComparison; +} + +/** + * Render a {@link PackageComparison} as a flat list of human-readable lines. + * Skips packages whose bundles all have zero deltas. + * + * @returns The rendered lines, or an empty array when nothing changed across + * the whole comparison. + */ +function formatComparison(comparison: PackageComparison): string[] { + const fmt = (before: number, after: number): string => { + const delta = after - before; + const sign = delta > 0 ? "+" : ""; + return `${before} -> ${after} (${sign}${delta})`; + }; + + const lines: string[] = []; + for (const [sourcePackage, bundles] of Object.entries(comparison)) { + const bundleLines: string[] = []; + for (const [bundleName, { base, compare }] of Object.entries(bundles)) { + if (base === undefined && compare !== undefined) { + bundleLines.push( + ` ${bundleName}: added (parsed ${compare.parsedSize}, gzip ${compare.gzipSize})`, + ); + } else if (compare === undefined && base !== undefined) { + bundleLines.push( + ` ${bundleName}: removed (was parsed ${base.parsedSize}, gzip ${base.gzipSize})`, + ); + } else if (base !== undefined && compare !== undefined) { + const parsedChanged = base.parsedSize !== compare.parsedSize; + const gzipChanged = base.gzipSize !== compare.gzipSize; + if (!parsedChanged && !gzipChanged) continue; + bundleLines.push( + ` ${bundleName}: parsed ${fmt(base.parsedSize, compare.parsedSize)}, gzip ${fmt(base.gzipSize, compare.gzipSize)}`, + ); + } + } + if (bundleLines.length === 0) continue; + lines.push(` ${sourcePackage}:`, ...bundleLines); + } + return lines; +} export default class CheckBundleSize extends BaseCommand { static readonly description = @@ -59,76 +96,86 @@ export default class CheckBundleSize extends BaseCommand // Auto-detect targets `main` on the canonical remote; `--target ` overrides. const branch = "main"; + const canonicalUrl = /(^|[/:])microsoft\/fluidframework(\.git)?$/i; let targetRef: string; if (target !== undefined) { targetRef = target; this.log(`Using explicit target ref ${target}.`); } else { - const remote = pickFreshestCanonicalRemote(branch) ?? "origin"; + const remote = pickFreshestRemote(branch, (url) => canonicalUrl.test(url)); + if (remote === undefined) { + this.error( + "Could not auto-detect a canonical remote. Add a remote pointing at microsoft/FluidFramework, or pass --target to override.", + ); + } targetRef = `${remote}/${branch}`; this.log(`Using target ref ${targetRef}. Pass --target to override.`); } - // Anonymous reads work for the public ADO project at this command's scale; - // automated consumers authenticate at the library layer. - const adoApi = getAzureDevopsApi(undefined, adoConstants.orgUrl); - const sizeComparator = new ADOSizeComparator( - adoConstants, + let baselineCommit: string; + try { + baselineCommit = execFileSync("git", ["merge-base", targetRef, "HEAD"], { + stdio: ["ignore", "pipe", "pipe"], + }) + .toString() + .trim(); + } catch (e) { + const stderr = (e as { stderr?: Buffer }).stderr?.toString().trim(); + this.error( + `Could not determine merge-base for ref "${targetRef}". Ensure it is fetched locally, or pass --target to override.${ + stderr ? `\n${stderr}` : "" + }`, + ); + } + this.log(`Baseline commit: ${baselineCommit}`); + + // Public ADO project — anonymous reads are fine at this command's scale. + const adoApi = getAzureDevopsApi(undefined, "https://dev.azure.com/fluidframework"); + const artifactContents = await getArtifactForCommit({ adoApi, - localReportPath, - targetRef, - ); - const comparisonResult = await sizeComparator.getSizeComparison(); - - if (comparisonResult.kind === "error") { - this.warning(comparisonResult.error); - return { - kind: "error", - baselineCommit: comparisonResult.baselineCommit, - error: comparisonResult.error, - }; + // Published by the `Build - Client bundle size artifacts` pipeline. + artifactName: "bundleAnalyzerJson", + commit: baselineCommit, + // `Build - Client bundle size artifacts` in the `public` project. + // Source-of-truth: tools/pipelines/build-bundle-size-artifacts.yml. + definitionId: 48, + project: "public", + }); + + const baselineJsons = extractAnalyzerJsonsFromArtifact(artifactContents); + if (baselineJsons.size === 0) { + this.error( + `Baseline artifact contains no analyzer.json files for commit ${baselineCommit}.`, + ); } - if (comparisonResult.comparison.length === 0) { - const message = - "No bundles to compare — baseline artifact or local bundle reports are empty."; - this.warning(message); - return { - kind: "error", - baselineCommit: comparisonResult.baselineCommit, - error: message, - }; + const localCheck = checkLocalBundleAnalysisExists(localReportPath); + // Append the `pnpm bundle-analysis:collect` hint only on the default + // path — overrides are populated from some source we don't know about. + const hint = + localReportPath === defaultLocalReportPath + ? " Run `pnpm bundle-analysis:collect` to populate it." + : ""; + if (localCheck === "missing") { + this.error(`Local bundle report directory not found at "${localReportPath}".${hint}`); + } + if (localCheck === "noAnalyzerJson") { + this.error( + `Local bundle report directory "${localReportPath}" contains no analyzer.json files.${hint}`, + ); } + const compareJsons = await readAnalyzerJsonsFromFileSystem(localReportPath); - const { baselineCommit, comparison } = comparisonResult; + const comparison = compareJsonReportsByPackage(baselineJsons, compareJsons); + const changeLines = formatComparison(comparison); - if (bundlesContainNoChanges(comparison)) { + if (changeLines.length === 0) { this.log(`No bundle size changes vs baseline commit ${baselineCommit}.`); - return { kind: "no-changes", baselineCommit }; - } - - const fmt = (before: number, after: number, delta: number): string => { - const sign = delta > 0 ? "+" : ""; - return `${before} -> ${after} (${sign}${delta})`; - }; - - this.log(`Bundle size changes vs baseline commit ${baselineCommit}:`); - for (const bundle of comparison) { - const changedMetrics = Object.entries(bundle.commonBundleMetrics).flatMap( - ([metricName, { baseline, compare }]) => { - const parsedDelta = compare.parsedSize - baseline.parsedSize; - const gzipDelta = compare.gzipSize - baseline.gzipSize; - if (parsedDelta === 0 && gzipDelta === 0) return []; - return [ - ` ${metricName}: parsed ${fmt(baseline.parsedSize, compare.parsedSize, parsedDelta)}, gzip ${fmt(baseline.gzipSize, compare.gzipSize, gzipDelta)}`, - ]; - }, - ); - if (changedMetrics.length === 0) continue; - this.log(` ${bundle.bundleName}:`); - for (const line of changedMetrics) this.log(line); + } else { + this.log(`Bundle size changes vs baseline commit ${baselineCommit}:`); + for (const line of changeLines) this.log(line); } - return { kind: "changes", baselineCommit, comparison }; + return { baselineCommit, comparison }; } } diff --git a/build-tools/packages/build-cli/src/library/azureDevops/downloadArtifact.ts b/build-tools/packages/build-cli/src/library/azureDevops/downloadArtifact.ts index 13e48e178d79..92dd9b9b3632 100644 --- a/build-tools/packages/build-cli/src/library/azureDevops/downloadArtifact.ts +++ b/build-tools/packages/build-cli/src/library/azureDevops/downloadArtifact.ts @@ -4,6 +4,7 @@ */ import { strict as assert } from "node:assert"; +import type { IncomingMessage } from "node:http"; import type { WebApi } from "azure-devops-node-api"; import { unzipSync } from "fflate"; @@ -44,6 +45,18 @@ export async function downloadArtifact( buildApi.createAcceptHeader = originalCreateAcceptHeader; } + // The declared `ReadableStream` return type hides it, but the SDK actually + // returns an `http.IncomingMessage` and doesn't throw on non-2xx responses + // for this endpoint — a missing artifact comes back as a 404 with a + // non-zip body that would otherwise blow up inside `unzipSync` with a + // useless "invalid zip data" error. + const statusCode = (artifactStream as Partial).statusCode; + if (statusCode !== undefined && (statusCode < 200 || statusCode >= 300)) { + throw new Error( + `Failed to download artifact "${artifactName}" from build ${buildId} (HTTP ${statusCode})`, + ); + } + const unzipped = unzipSync(await readStreamAsUint8Array(artifactStream)); // ADO wraps the artifact contents in a top-level folder named after the artifact. diff --git a/build-tools/packages/build-cli/src/library/azureDevops/getArtifactForCommit.ts b/build-tools/packages/build-cli/src/library/azureDevops/getArtifactForCommit.ts new file mode 100644 index 000000000000..6483f75b70f2 --- /dev/null +++ b/build-tools/packages/build-cli/src/library/azureDevops/getArtifactForCommit.ts @@ -0,0 +1,134 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { WebApi } from "azure-devops-node-api"; +import { + type Build, + BuildResult, + BuildStatus, +} from "azure-devops-node-api/interfaces/BuildInterfaces.js"; + +import { type ArtifactContents, downloadArtifact } from "./downloadArtifact.js"; + +// Upper bound on builds fetched when searching for one matching a target commit. +// ADO has no API to query builds by commit SHA, so this window size determines +// how stale a target commit can be relative to the pipeline's recent activity +// and still be findable. +const recentBuildsToFetch = 100; + +/** + * Wrapper around the unwieldy positional signature of ADO's `getBuilds`. + */ +async function getRecentBuilds( + adoApi: WebApi, + project: string, + definitionId: number, +): Promise { + const buildApi = await adoApi.getBuildApi(); + return buildApi.getBuilds( + project, + [definitionId], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + recentBuildsToFetch, + ); +} + +/** + * Find a usable build for `commit` in `builds` — one with an id, status + * Completed, and result Succeeded. A commit can have more than one ADO build + * (manual re-run, partial-success retry, …), so scan all matches rather than + * locking onto the first one ADO returned. + * + * @returns The build id. Throws with a human-readable message when no usable + * build is found, prioritizing "not yet completed" over "did not succeed" + * since retrying later might help. + */ +function findBuildIdForCommit(builds: Build[], commit: string): number { + const candidates = builds.filter((b) => b.sourceVersion === commit); + + if (candidates.length === 0) { + throw new Error(`No build found for commit ${commit}`); + } + + const usable = candidates.find( + (b): b is Build & { id: number } => + b.id !== undefined && + b.status === BuildStatus.Completed && + b.result === BuildResult.Succeeded, + ); + if (usable !== undefined) { + return usable.id; + } + + // No usable found — report the most actionable state across the candidates. + // "Actively running" gets priority since the user might just need to wait; + // Cancelling is *not* in that bucket because it's heading toward Canceled. + const isActivelyRunning = (b: Build): boolean => + b.status === BuildStatus.NotStarted || + b.status === BuildStatus.InProgress || + b.status === BuildStatus.Postponed; + if (candidates.some(isActivelyRunning)) { + throw new Error( + `Found an in-progress build for commit ${commit}; none have succeeded yet.`, + ); + } + if (candidates.every((b) => b.result !== BuildResult.Succeeded)) { + throw new Error(`All builds for commit ${commit} have completed but none succeeded.`); + } + // Reaching here means at least one candidate Succeeded but is missing an + // `id` (possibly alongside other failed candidates) — an ADO state anomaly + // that shouldn't happen in practice, but the `id` field is typed + // `number | undefined` so we surface it explicitly. + throw new Error(`No build for commit ${commit} has a usable build id.`); +} + +export interface GetArtifactForCommitArgs { + /** A connection to the ADO API. */ + adoApi: WebApi; + /** Name of the pipeline artifact to fetch. */ + artifactName: string; + /** Commit whose build to look up. */ + commit: string; + /** ID of the ADO pipeline whose builds to search. */ + definitionId: number; + /** The ADO project name. */ + project: string; +} + +/** + * Look up the build for `commit` on the given ADO pipeline and return the + * contents of one of its artifacts. + * + * @returns The artifact's {@link ArtifactContents}. Throws with a + * human-readable message when no usable build is found (missing, incomplete, + * failed) or the artifact can't be downloaded. + */ +export async function getArtifactForCommit( + args: GetArtifactForCommitArgs, +): Promise { + const { adoApi, artifactName, commit, definitionId, project } = args; + + const builds = await getRecentBuilds(adoApi, project, definitionId); + const buildId = findBuildIdForCommit(builds, commit); + + try { + return await downloadArtifact(adoApi, project, buildId, artifactName); + } catch (e) { + throw new Error(`Could not download artifact "${artifactName}" for commit ${commit}`, { + cause: e, + }); + } +} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/ADO/AdoArtifactFileProvider.ts b/build-tools/packages/build-cli/src/library/bundleSize/ADO/AdoArtifactFileProvider.ts deleted file mode 100644 index be8a19593970..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/ADO/AdoArtifactFileProvider.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import { strict as assert } from "node:assert"; -import type { BundleAnalyzerPlugin } from "webpack-bundle-analyzer"; - -import type { ArtifactContents } from "../../azureDevops/downloadArtifact.js"; - -/** - * Retrieves and parses an analyzer.json file (webpack-bundle-analyzer's - * `analyzerMode: "json"` output) from the decompressed artifact contents. - * @param contents - Artifact contents keyed by file path relative to the artifact root. - * @param relativePath - The relative path to the file that will be retrieved. - */ -export function getAnalyzerJsonFromContents( - contents: ArtifactContents, - relativePath: string, -): BundleAnalyzerPlugin.JsonReport { - const bytes = contents[relativePath]; - assert(bytes, `getAnalyzerJsonFromContents could not find file ${relativePath}`); - - const text = Buffer.from(bytes).toString("utf8"); - return JSON.parse(text) as BundleAnalyzerPlugin.JsonReport; -} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/ADO/AdoSizeComparator.ts b/build-tools/packages/build-cli/src/library/bundleSize/ADO/AdoSizeComparator.ts deleted file mode 100644 index 8ce0f4910feb..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/ADO/AdoSizeComparator.ts +++ /dev/null @@ -1,186 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import { join } from "node:path"; -import type { WebApi } from "azure-devops-node-api"; -import { BuildResult, BuildStatus } from "azure-devops-node-api/interfaces/BuildInterfaces.js"; - -import { - type ArtifactContents, - downloadArtifact, -} from "../../azureDevops/downloadArtifact.js"; -import { compareBundles } from "../compareBundles.js"; -import type { BundleComparison } from "../types.js"; -import { getBuilds, getMergeBaseWithHead } from "../utilities/index.js"; -import { getAnalyzerJsonFromContents } from "./AdoArtifactFileProvider.js"; -import type { IADOConstants } from "./Constants.js"; -import { - getAnalyzerJsonFromFileSystem, - getAnalyzerPathsFromFileSystem, -} from "./FileSystemBundleFileProvider.js"; -import { getAnalyzerFilePathsFromFolder } from "./getBundleFilePathsFromFolder.js"; -import { getBundleSummariesFromAnalyzer } from "./getBundleSummaries.js"; - -/** - * Result of a size comparison against a baseline build, discriminated by `kind`. - * - * On `"success"`, `comparison` holds the bundle diff against `baselineCommit`. - * On `"error"`, the comparison could not be produced and `error` holds the reason; - * `baselineCommit` reflects the last commit that was attempted and may be `undefined` - * if the search never found a candidate. - */ -export type SizeComparison = - | { kind: "success"; baselineCommit: string; comparison: BundleComparison[] } - | { kind: "error"; baselineCommit: string | undefined; error: string }; - -export class ADOSizeComparator { - /** - * The default number of most recent builds on the ADO pipeline to search when - * looking for a build matching a baseline commit. The most recent builds may not - * necessarily match the chain of commits, but typically will when the pipeline - * only builds commits to main. - */ - private static readonly defaultBuildsToSearch = 100; - - constructor( - /** - * ADO constants identifying where to fetch baseline bundle info - */ - private readonly adoConstants: IADOConstants, - /** - * The ADO connection to use to fetch baseline bundle info - */ - private readonly adoApi: WebApi, - /** - * Path to existing local bundle size reports - */ - private readonly localReportPath: string, - /** - * Target ref — the ref a PR would be opened against. The baseline commit - * is `git merge-base HEAD`, so this accepts any argument - * `git merge-base` does. - */ - private readonly targetRef: string, - ) {} - - /** - * Run the bundle size comparison against the baseline build. - * - * @returns A {@link SizeComparison} tagged with `kind: "success"` or `kind: "error"`. - * Never throws: unexpected exceptions from underlying `git` shell-outs, ADO API - * calls, or stats-file parsing are caught and reported via the `error` variant so - * callers can rely on the return shape. - */ - public async getSizeComparison(): Promise { - // Declared outside the try block so the catch can still report the last-known - // commit value in the synthesized error variant. - let baselineCommit: string | undefined; - try { - baselineCommit = getMergeBaseWithHead(this.targetRef); - console.log(`Baseline commit: ${baselineCommit}`); - - const recentBuilds = await getBuilds(this.adoApi, { - project: this.adoConstants.projectName, - definitions: [this.adoConstants.ciBuildDefinitionId], - maxBuildsPerDefinition: - this.adoConstants.buildsToSearch ?? ADOSizeComparator.defaultBuildsToSearch, - }); - - const baselineBuild = recentBuilds.find( - (build) => build.sourceVersion === baselineCommit, - ); - - if (baselineBuild === undefined) { - return { - kind: "error", - baselineCommit, - error: `No CI build found for baseline commit ${baselineCommit}`, - }; - } - - if (baselineBuild.id === undefined) { - return { - kind: "error", - baselineCommit, - error: `Baseline build does not have a build id`, - }; - } - - if (baselineBuild.status !== BuildStatus.Completed) { - return { - kind: "error", - baselineCommit, - error: "Baseline build for this commit has not yet completed.", - }; - } - - if (baselineBuild.result !== BuildResult.Succeeded) { - return { - kind: "error", - baselineCommit, - error: "Baseline CI build failed, cannot generate bundle analysis at this time", - }; - } - - console.log(`Found baseline build with id: ${baselineBuild.id}`); - - const baselineContents = await downloadArtifact( - this.adoApi, - this.adoConstants.projectName, - baselineBuild.id, - this.adoConstants.artifactName, - ).catch((error) => { - // Preserve the underlying error/stack: it's useful diagnostic - // info that doesn't reach the synthesized error variant below. - console.log(`Error downloading artifact: ${error.message}`); - console.log(`Error stack: ${error.stack}`); - return undefined; - }); - - if (baselineContents === undefined) { - return { - kind: "error", - baselineCommit, - error: "Baseline build did not publish bundle artifacts", - }; - } - - const comparison: BundleComparison[] = - await this.createComparisonFromContents(baselineContents); - - return { kind: "success", baselineCommit, comparison }; - } catch (e) { - return { - kind: "error", - baselineCommit, - error: `Unexpected failure during size comparison: ${ - e instanceof Error ? e.message : String(e) - }`, - }; - } - } - - private async createComparisonFromContents( - baselineContents: ArtifactContents, - ): Promise { - const baselineBundlePaths = getAnalyzerFilePathsFromFolder(Object.keys(baselineContents)); - - const prBundleFileSystemPaths = await getAnalyzerPathsFromFileSystem(this.localReportPath); - - const baselineSummaries = await getBundleSummariesFromAnalyzer({ - bundlePaths: baselineBundlePaths, - getAnalyzerJson: async (relativePath) => - getAnalyzerJsonFromContents(baselineContents, relativePath), - }); - - const prSummaries = await getBundleSummariesFromAnalyzer({ - bundlePaths: prBundleFileSystemPaths, - getAnalyzerJson: async (relativePath) => - getAnalyzerJsonFromFileSystem(join(this.localReportPath, relativePath)), - }); - - return compareBundles(baselineSummaries, prSummaries); - } -} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/ADO/Constants.ts b/build-tools/packages/build-cli/src/library/bundleSize/ADO/Constants.ts deleted file mode 100644 index 91a851a93722..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/ADO/Constants.ts +++ /dev/null @@ -1,23 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -export interface IADOConstants { - // URL for the ADO org - orgUrl: string; - - // The ADO project that contains the repo - projectName: string; - - // The ID for the build that runs against main when PRs are merged - ciBuildDefinitionId: number; - - // The name of the build artifact that contains the bundle size data - artifactName: string; - - // The number of most recent ADO builds to pull when searching for one associated - // with a specific commit, default 20. Pulling more builds takes longer, but may - // be useful when there are a high volume of commits/builds. - buildsToSearch?: number; -} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/ADO/FileSystemBundleFileProvider.ts b/build-tools/packages/build-cli/src/library/bundleSize/ADO/FileSystemBundleFileProvider.ts deleted file mode 100644 index 64ad9da0c280..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/ADO/FileSystemBundleFileProvider.ts +++ /dev/null @@ -1,38 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import { promises as fsPromises } from "fs"; -import type { BundleAnalyzerPlugin } from "webpack-bundle-analyzer"; - -import { getAllFilesInDirectory } from "../utilities/index.js"; -import { - type BundleFileData, - getAnalyzerFilePathsFromFolder, -} from "./getBundleFilePathsFromFolder.js"; - -/** - * Returns a list of `analyzer.json` paths from the given folder (one per source package). - * @param bundleReportPath - The path to the folder containing the bundle report - */ -export async function getAnalyzerPathsFromFileSystem( - bundleReportPath: string, -): Promise { - const filePaths = await getAllFilesInDirectory(bundleReportPath); - - return getAnalyzerFilePathsFromFolder(filePaths); -} - -/** - * Reads and parses an analyzer.json file (webpack-bundle-analyzer's - * `analyzerMode: "json"` output) from the filesystem. - * @param path - the full path to the file in the filesystem - */ -export async function getAnalyzerJsonFromFileSystem( - path: string, -): Promise { - const text = await fsPromises.readFile(path, "utf8"); - - return JSON.parse(text) as BundleAnalyzerPlugin.JsonReport; -} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/ADO/getBundleFilePathsFromFolder.ts b/build-tools/packages/build-cli/src/library/bundleSize/ADO/getBundleFilePathsFromFolder.ts deleted file mode 100644 index 3e6489f3a0ef..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/ADO/getBundleFilePathsFromFolder.ts +++ /dev/null @@ -1,46 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -export interface BundleFileData { - bundleName: string; - - relativePathToStatsFile: string; - - relativePathToConfigFile: string | undefined; -} - -function getBundleNameFromPath(relativePath: string): string { - // Our artifacts are stored in the the format //[/]/. - // We want to use the npm scope + package name as the bundle name. - // The regex here normalized the slashes in the path names. - const pathParts = relativePath.replace(/\\/g, "/").split("/"); - - if (pathParts.length < 3) { - throw Error(`Could not derive a bundle name from this path: ${relativePath}`); - } - pathParts.pop(); // Remove the filename - - return pathParts.join("/"); -} - -/** - * Filters the given paths down to `analyzer.json` files (one per source package), - * pairing each with its derived bundle name. - */ -export function getAnalyzerFilePathsFromFolder( - relativePathsInFolder: string[], -): BundleFileData[] { - return relativePathsInFolder - .filter((relativePath) => { - // Normalize backslashes so the same logic handles both Windows and POSIX path separators. - const fileName = relativePath.replace(/\\/g, "/").split("/").pop(); - return fileName === "analyzer.json"; - }) - .map((relativePath) => ({ - bundleName: getBundleNameFromPath(relativePath), - relativePathToStatsFile: relativePath, - relativePathToConfigFile: undefined, - })); -} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/ADO/getBundleSummaries.ts b/build-tools/packages/build-cli/src/library/bundleSize/ADO/getBundleSummaries.ts deleted file mode 100644 index 13cd07ea0a81..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/ADO/getBundleSummaries.ts +++ /dev/null @@ -1,47 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import type { BundleAnalyzerPlugin } from "webpack-bundle-analyzer"; - -import type { BundleMetricSet, BundleSummaries } from "../types.js"; -import type { BundleFileData } from "./getBundleFilePathsFromFolder.js"; - -export interface GetBundleSummariesFromAnalyzerArgs { - bundlePaths: BundleFileData[]; - - getAnalyzerJson: (relativePath: string) => Promise; -} - -/** - * Builds a {@link BundleSummaries} from analyzer.json (webpack-bundle-analyzer's - * `analyzerMode: "json"` output). The data is already pre-summarized per asset, so - * no stats processors are needed — each asset entry's `parsedSize` becomes a - * `BundleMetric` keyed by the asset's `label`. - */ -export async function getBundleSummariesFromAnalyzer( - args: GetBundleSummariesFromAnalyzerArgs, -): Promise { - const result: BundleSummaries = new Map(); - - const pendingAsyncWork = args.bundlePaths.map(async (bundle) => { - const entries = await args.getAnalyzerJson(bundle.relativePathToStatsFile); - - const metrics: BundleMetricSet = new Map(); - for (const entry of entries) { - if (entry.isAsset) { - metrics.set(entry.label, { - parsedSize: entry.parsedSize, - gzipSize: entry.gzipSize, - }); - } - } - - result.set(bundle.bundleName, metrics); - }); - - await Promise.all(pendingAsyncWork); - - return result; -} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/ADO/index.ts b/build-tools/packages/build-cli/src/library/bundleSize/ADO/index.ts deleted file mode 100644 index b12d06bf265c..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/ADO/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -export { getAnalyzerJsonFromContents } from "./AdoArtifactFileProvider.js"; -export { ADOSizeComparator, SizeComparison } from "./AdoSizeComparator.js"; -export { IADOConstants } from "./Constants.js"; -export { - getAnalyzerJsonFromFileSystem, - getAnalyzerPathsFromFileSystem, -} from "./FileSystemBundleFileProvider.js"; -export { - BundleFileData, - getAnalyzerFilePathsFromFolder, -} from "./getBundleFilePathsFromFolder.js"; -export { - GetBundleSummariesFromAnalyzerArgs, - getBundleSummariesFromAnalyzer, -} from "./getBundleSummaries.js"; diff --git a/build-tools/packages/build-cli/src/library/bundleSize/compareBundles.ts b/build-tools/packages/build-cli/src/library/bundleSize/compareBundles.ts deleted file mode 100644 index d57d71fc6758..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/compareBundles.ts +++ /dev/null @@ -1,67 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import type { BundleComparison, BundleSummaries } from "./types.js"; - -/** - * Compares all the bundle summaries for a "baseline" and a "compare" bundle. - */ -export function compareBundles( - baseline: BundleSummaries, - compare: BundleSummaries, -): BundleComparison[] { - const results: BundleComparison[] = []; - - baseline.forEach((baselineBundle, bundleName) => { - const compareBundle = compare.get(bundleName); - - if (!compareBundle) { - console.log( - `Baseline has bundle '${bundleName}' that does not appear in the comparison bundle `, - ); - } else { - const bundleComparison: BundleComparison = { bundleName, commonBundleMetrics: {} }; - - baselineBundle.forEach((baselineMetric, metricName) => { - const compareMetric = compareBundle.get(metricName); - - if (!compareMetric) { - console.log( - `Baseline has metric '${metricName}' in bundle '${bundleName}' that does not exist in the comparison bundle'`, - ); - } else { - bundleComparison.commonBundleMetrics[metricName] = { - baseline: baselineMetric, - compare: compareMetric, - }; - } - }); - - results.push(bundleComparison); - } - }); - - return results; -} - -/** - * Checks if a bundle comparison contains no size changes - * @param comparisons - bundle comparison - */ -export function bundlesContainNoChanges(comparisons: BundleComparison[]): boolean { - for (const { commonBundleMetrics } of comparisons) { - const metrics = Object.values(commonBundleMetrics); - for (const { baseline, compare } of metrics) { - if ( - baseline.parsedSize !== compare.parsedSize || - baseline.gzipSize !== compare.gzipSize - ) { - return false; - } - } - } - - return true; -} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/compareJsonReports.ts b/build-tools/packages/build-cli/src/library/bundleSize/compareJsonReports.ts new file mode 100644 index 000000000000..e2fd37e387f1 --- /dev/null +++ b/build-tools/packages/build-cli/src/library/bundleSize/compareJsonReports.ts @@ -0,0 +1,84 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { BundleAnalyzerPlugin } from "webpack-bundle-analyzer"; + +import type { + AnalyzerJsonByPackage, + BundleData, + BundlesComparison, + PackageComparison, +} from "./types.js"; + +/** + * Filter `report` to its asset entries and key their size data by asset name. + * `undefined` is treated as an empty report, so callers can pass an absent + * side directly. + */ +function jsonReportToBundleSizes( + report: BundleAnalyzerPlugin.JsonReport | undefined, +): Map { + const sizes = new Map(); + if (report === undefined) return sizes; + for (const entry of report) { + if (!entry.isAsset) continue; + sizes.set(entry.label, { + statSize: entry.statSize, + parsedSize: entry.parsedSize, + gzipSize: entry.gzipSize, + }); + } + return sizes; +} + +/** + * Compare the asset entries from two `JsonReport`s (one webpack-bundle-analyzer + * output each side) and produce the per-bundle comparison map. Either side may + * be `undefined` to represent a package that only exists on the other side. + * Bundles present only in one side encode added/removed via field presence + * (see {@link BundlesComparison}). + */ +function compareJsonReports( + base: BundleAnalyzerPlugin.JsonReport | undefined, + compare: BundleAnalyzerPlugin.JsonReport | undefined, +): BundlesComparison { + const baseSizes = jsonReportToBundleSizes(base); + const compareSizes = jsonReportToBundleSizes(compare); + + const allBundleNames = new Set([...baseSizes.keys(), ...compareSizes.keys()]); + + const bundles: BundlesComparison = {}; + for (const bundleName of allBundleNames) { + const baseBundle = baseSizes.get(bundleName); + const compareBundle = compareSizes.get(bundleName); + bundles[bundleName] = { + ...(baseBundle && { base: baseBundle }), + ...(compareBundle && { compare: compareBundle }), + }; + } + + return bundles; +} + +/** + * Compare per-package `JsonReport`s for two snapshots and produce a + * {@link PackageComparison}. Iterates the union of source packages so packages + * present only on one side are represented (their `compareJsonReports` call + * treats the absent side as empty). + */ +export function compareJsonReportsByPackage( + base: AnalyzerJsonByPackage, + compare: AnalyzerJsonByPackage, +): PackageComparison { + const allPackages = new Set([...base.keys(), ...compare.keys()]); + const result: PackageComparison = {}; + for (const sourcePackage of allPackages) { + result[sourcePackage] = compareJsonReports( + base.get(sourcePackage), + compare.get(sourcePackage), + ); + } + return result; +} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/extractAnalyzerJsonsFromArtifact.ts b/build-tools/packages/build-cli/src/library/bundleSize/extractAnalyzerJsonsFromArtifact.ts new file mode 100644 index 000000000000..a5e19633a3a6 --- /dev/null +++ b/build-tools/packages/build-cli/src/library/bundleSize/extractAnalyzerJsonsFromArtifact.ts @@ -0,0 +1,27 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { BundleAnalyzerPlugin } from "webpack-bundle-analyzer"; + +import type { ArtifactContents } from "../azureDevops/downloadArtifact.js"; +import { sourcePackageFromAnalyzerPath } from "./sourcePackageFromAnalyzerPath.js"; +import type { AnalyzerJsonByPackage } from "./types.js"; + +/** + * Walks a downloaded artifact's contents, finds every `analyzer.json`, parses + * it, and keys the results by source package. + */ +export function extractAnalyzerJsonsFromArtifact( + contents: ArtifactContents, +): AnalyzerJsonByPackage { + const result: AnalyzerJsonByPackage = new Map(); + for (const [relativePath, bytes] of Object.entries(contents)) { + const sourcePackage = sourcePackageFromAnalyzerPath(relativePath); + if (sourcePackage === undefined) continue; + const text = Buffer.from(bytes).toString("utf8"); + result.set(sourcePackage, JSON.parse(text) as BundleAnalyzerPlugin.JsonReport); + } + return result; +} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/index.ts b/build-tools/packages/build-cli/src/library/bundleSize/index.ts index e30e861a9366..a00bb8ba49ba 100644 --- a/build-tools/packages/build-cli/src/library/bundleSize/index.ts +++ b/build-tools/packages/build-cli/src/library/bundleSize/index.ts @@ -3,28 +3,16 @@ * Licensed under the MIT License. */ +export { compareJsonReportsByPackage } from "./compareJsonReports.js"; +export { extractAnalyzerJsonsFromArtifact } from "./extractAnalyzerJsonsFromArtifact.js"; export { - ADOSizeComparator, - BundleFileData, - GetBundleSummariesFromAnalyzerArgs, - getAnalyzerFilePathsFromFolder, - getAnalyzerJsonFromContents, - getAnalyzerJsonFromFileSystem, - getAnalyzerPathsFromFileSystem, - getBundleSummariesFromAnalyzer, - IADOConstants, - SizeComparison, -} from "./ADO/index.js"; -export { bundlesContainNoChanges, compareBundles } from "./compareBundles.js"; + checkLocalBundleAnalysisExists, + readAnalyzerJsonsFromFileSystem, +} from "./readAnalyzerJsonsFromFileSystem.js"; +export { sourcePackageFromAnalyzerPath } from "./sourcePackageFromAnalyzerPath.js"; export { - BundleComparison, - BundleMetric, - BundleMetricSet, - BundleSummaries, + AnalyzerJsonByPackage, + BundleData, + BundlesComparison, + PackageComparison, } from "./types.js"; -export { - GetBuildOptions, - getAllFilesInDirectory, - getBuilds, - pickFreshestCanonicalRemote, -} from "./utilities/index.js"; diff --git a/build-tools/packages/build-cli/src/library/bundleSize/readAnalyzerJsonsFromFileSystem.ts b/build-tools/packages/build-cli/src/library/bundleSize/readAnalyzerJsonsFromFileSystem.ts new file mode 100644 index 000000000000..1925efacbb9a --- /dev/null +++ b/build-tools/packages/build-cli/src/library/bundleSize/readAnalyzerJsonsFromFileSystem.ts @@ -0,0 +1,49 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { globSync, statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { BundleAnalyzerPlugin } from "webpack-bundle-analyzer"; + +import { sourcePackageFromAnalyzerPath } from "./sourcePackageFromAnalyzerPath.js"; +import type { AnalyzerJsonByPackage } from "./types.js"; + +const analyzerJsonGlob = "**/analyzer.json"; + +/** + * Check whether `rootPath` exists and contains any `analyzer.json` file. + * + * @returns `"ok"`, `"missing"` (rootPath doesn't exist), or `"noAnalyzerJson"` + * (rootPath exists but the tree has none). Real failures (permission errors, + * broken symlinks, …) propagate as-is rather than collapsing to a kind. + */ +export function checkLocalBundleAnalysisExists( + rootPath: string, +): "ok" | "missing" | "noAnalyzerJson" { + if (statSync(rootPath, { throwIfNoEntry: false }) === undefined) { + return "missing"; + } + return globSync(analyzerJsonGlob, { cwd: rootPath }).length > 0 ? "ok" : "noAnalyzerJson"; +} + +/** + * Walks `rootPath`, finds every `analyzer.json` file, parses it, and keys the + * results by source package. + */ +export async function readAnalyzerJsonsFromFileSystem( + rootPath: string, +): Promise { + const result: AnalyzerJsonByPackage = new Map(); + await Promise.all( + globSync(analyzerJsonGlob, { cwd: rootPath }).map(async (relativePath) => { + const sourcePackage = sourcePackageFromAnalyzerPath(relativePath); + if (sourcePackage === undefined) return; + const text = await readFile(join(rootPath, relativePath), "utf8"); + result.set(sourcePackage, JSON.parse(text) as BundleAnalyzerPlugin.JsonReport); + }), + ); + return result; +} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/sourcePackageFromAnalyzerPath.ts b/build-tools/packages/build-cli/src/library/bundleSize/sourcePackageFromAnalyzerPath.ts new file mode 100644 index 000000000000..be7167dc22b6 --- /dev/null +++ b/build-tools/packages/build-cli/src/library/bundleSize/sourcePackageFromAnalyzerPath.ts @@ -0,0 +1,21 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +const analyzerJsonSuffix = "/analyzer.json"; + +/** + * If `relativePath` looks like `/analyzer.json` (nested layout — + * e.g. `@fluid-example/bundle-size-tests/analyzer.json`), returns the source + * package name. Returns `undefined` for paths that don't match. + * + * Slashes are normalized first so the same logic handles both Windows and + * POSIX path separators. + */ +export function sourcePackageFromAnalyzerPath(relativePath: string): string | undefined { + const normalized = relativePath.replace(/\\/g, "/"); + if (!normalized.endsWith(analyzerJsonSuffix)) return undefined; + const sourcePackage = normalized.slice(0, -analyzerJsonSuffix.length); + return sourcePackage.length === 0 ? undefined : sourcePackage; +} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/types.ts b/build-tools/packages/build-cli/src/library/bundleSize/types.ts index 94a5901a82bc..1e4b759021c9 100644 --- a/build-tools/packages/build-cli/src/library/bundleSize/types.ts +++ b/build-tools/packages/build-cli/src/library/bundleSize/types.ts @@ -3,30 +3,54 @@ * Licensed under the MIT License. */ -/** - * A map of bundles friendly names to their relevant metrics - */ -export type BundleSummaries = Map; +import type { BundleAnalyzerPlugin } from "webpack-bundle-analyzer"; /** - * A collection of all the relevant size metrics for a given bundle. A bundle can have one or more named metrics that - * could map to a single chunk or a collection chunks. + * Map from source package name to that package's parsed analyzer.json + * (webpack-bundle-analyzer's `analyzerMode: "json"` output). */ -export type BundleMetricSet = Map; +export type AnalyzerJsonByPackage = Map; /** - * A description of the size of a particular part of a bundle + * Data for a single bundle (webpack entrypoint), sourced from + * webpack-bundle-analyzer's chart data. */ -export interface BundleMetric { +export interface BundleData { + /** + * Sum of source-module sizes before tree-shaking and minification. + */ + statSize: number; + /** + * Post-minification on-disk size — what's actually emitted to the bundle output. + */ parsedSize: number; + /** + * Estimated size after gzip compression — closest proxy for what users download. + */ gzipSize: number; } /** - * A comparison of two bundles + * Per-bundle comparison for one source package, keyed by bundle name (webpack + * entrypoint). Field presence on each entry encodes three states: + * - **pre-existing** (existed in both): both `base` and `compare` present + * - **added** (only in PR): only `compare` present + * - **removed** (only in baseline): only `base` present */ -export interface BundleComparison { - bundleName: string; +export type BundlesComparison = { + [bundleName: string]: { + base?: BundleData; + compare?: BundleData; + }; +}; - commonBundleMetrics: { [key: string]: { baseline: BundleMetric; compare: BundleMetric } }; -} +/** + * Full comparison keyed by source package name. Packages present only on one + * side appear with that side's bundles only. + * + * The producer is deliberately unopinionated: it emits raw sizes only. Consumers + * compute deltas, percentages, and apply their own thresholds / regression rules. + */ +export type PackageComparison = { + [sourcePackage: string]: BundlesComparison; +}; diff --git a/build-tools/packages/build-cli/src/library/bundleSize/utilities/getAllFilesInDirectory.ts b/build-tools/packages/build-cli/src/library/bundleSize/utilities/getAllFilesInDirectory.ts deleted file mode 100644 index b448229311bc..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/utilities/getAllFilesInDirectory.ts +++ /dev/null @@ -1,33 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import { promises as fsPromises } from "fs"; -import { join } from "path"; - -/** - * Gets the relative path of all files in this directory - * @param sourceFolder - The path of the directory to scan - * @param partialPathPrefix - The partial path built up as we recurse through directories. External callers probably don't want to set this. - */ -export async function getAllFilesInDirectory( - sourceFolder: string, - partialPathPrefix: string = "", -): Promise { - const result: string[] = []; - for (const file of await fsPromises.readdir(sourceFolder)) { - const fullPath = join(sourceFolder, file); - if ((await fsPromises.stat(fullPath)).isFile()) { - result.push(join(partialPathPrefix, file)); - } else { - result.push( - ...(await getAllFilesInDirectory( - join(sourceFolder, file), - join(partialPathPrefix, file), - )), - ); - } - } - return result; -} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/utilities/getBuilds.ts b/build-tools/packages/build-cli/src/library/bundleSize/utilities/getBuilds.ts deleted file mode 100644 index cd06507d3e1c..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/utilities/getBuilds.ts +++ /dev/null @@ -1,46 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import type { WebApi } from "azure-devops-node-api"; -import type { Build } from "azure-devops-node-api/interfaces/BuildInterfaces.js"; - -export interface GetBuildOptions { - // The ADO project name - project: string; - - // An array of ADO definitions that should be considered for this query - definitions: number[]; - - // An optional set of tags that should be on the returned builds - tagFilters?: string[]; - - // An upper limit on the number of queries to return. Can be used to improve performance - maxBuildsPerDefinition?: number; -} - -/** - * A wrapper around the terrible API signature for ADO getBuilds - */ -export async function getBuilds(adoApi: WebApi, options: GetBuildOptions): Promise { - const buildApi = await adoApi.getBuildApi(); - - return buildApi.getBuilds( - options.project, - options.definitions, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - options.tagFilters, - undefined, - undefined, - undefined, - options.maxBuildsPerDefinition, - ); -} diff --git a/build-tools/packages/build-cli/src/library/bundleSize/utilities/index.ts b/build-tools/packages/build-cli/src/library/bundleSize/utilities/index.ts deleted file mode 100644 index d8d920b7fe66..000000000000 --- a/build-tools/packages/build-cli/src/library/bundleSize/utilities/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -export { getAllFilesInDirectory } from "./getAllFilesInDirectory.js"; -export { GetBuildOptions, getBuilds } from "./getBuilds.js"; -export { getMergeBaseWithHead, pickFreshestCanonicalRemote } from "./gitCommands.js"; diff --git a/build-tools/packages/build-cli/src/library/bundleSize/utilities/gitCommands.ts b/build-tools/packages/build-cli/src/library/git/pickFreshestRemote.ts similarity index 71% rename from build-tools/packages/build-cli/src/library/bundleSize/utilities/gitCommands.ts rename to build-tools/packages/build-cli/src/library/git/pickFreshestRemote.ts index 852b05bea82b..24d9639d05cf 100644 --- a/build-tools/packages/build-cli/src/library/bundleSize/utilities/gitCommands.ts +++ b/build-tools/packages/build-cli/src/library/git/pickFreshestRemote.ts @@ -6,42 +6,27 @@ import { execFileSync } from "node:child_process"; /** - * Compute the merge-base of `HEAD` and the given ref. The ref may be any - * argument `git merge-base` accepts (remote branch, local branch, SHA, tag, …). - * - * @returns The merge-base commit SHA. - */ -export function getMergeBaseWithHead(targetRef: string): string { - return execFileSync("git", ["merge-base", targetRef, "HEAD"]).toString().trim(); -} - -/** - * A canonical-remote ref paired with its locally-resolved tip commit. + * A remote ref paired with its locally-resolved tip commit. */ -interface CanonicalCandidate { +interface RemoteCandidate { name: string; ref: string; tip: string; } /** - * List remotes that point at the canonical `microsoft/FluidFramework` - * repository. - * - * Match is case-insensitive and tolerant of a trailing `.git`, covering both - * HTTPS (`https://github.com/microsoft/FluidFramework[.git]`) and SSH - * (`git@github.com:microsoft/FluidFramework[.git]`) remote URL forms. + * List every remote configured in the local git repo. * - * @returns The matching remotes in `.git/config` order, or an empty array if - * none match. + * @returns The configured remotes in `.git/config` order, or an empty array if + * none are configured. */ -function findCanonicalRemotes(): { name: string; url: string }[] { +function listRemotes(): { name: string; url: string }[] { // Read every `remote..url` config entry. `--all` returns every match // (otherwise `--regexp` returns only the first); `--show-names` includes // the key so the remote name can be extracted. // Exit codes from `git config get --regexp`: // 0 = at least one match - // 1 = no matches (e.g. clone has no canonical remote configured) + // 1 = no matches (e.g. clone has no remotes configured) // any other = the subcommand itself failed — most likely git < 2.46 // (`get` is not a recognized subcommand on older versions). // Treat status 1 as a clean "no matches" and reserve the targeted "upgrade @@ -60,22 +45,19 @@ function findCanonicalRemotes(): { name: string; url: string }[] { const detail = error instanceof Error ? error.message : String(error); throw new Error( `Failed to read remote URLs via \`git config get --regexp\` (introduced in git 2.46). ` + - `Upgrade git, or pass --target to skip remote auto-detection.\n` + + `Upgrade git to enable remote auto-detection.\n` + `Underlying error: ${detail}`, ); } const line = /^remote\.(.+)\.url\s+(.+)$/; - const canonical = /(^|[/:])microsoft\/fluidframework(\.git)?$/i; - const matches: { name: string; url: string }[] = []; + const remotes: { name: string; url: string }[] = []; for (const raw of output.split("\n")) { const match = line.exec(raw); if (match === null) continue; const [, name, url] = match; - if (canonical.test(url)) { - matches.push({ name, url }); - } + remotes.push({ name, url }); } - return matches; + return remotes; } /** @@ -135,8 +117,8 @@ function isAncestor(ancestor: string, descendant: string): boolean { * one winner; equal tips don't dominate each other, and truly divergent * histories (rare for `main`) produce multiple winners. */ -function pickFreshest(candidates: CanonicalCandidate[]): CanonicalCandidate[] { - function hasStrictlyNewerPeer(candidate: CanonicalCandidate): boolean { +function pickFreshest(candidates: RemoteCandidate[]): RemoteCandidate[] { + function hasStrictlyNewerPeer(candidate: RemoteCandidate): boolean { return candidates.some((other) => { if (other === candidate) return false; if (other.tip === candidate.tip) return false; // ties don't dominate @@ -149,27 +131,29 @@ function pickFreshest(candidates: CanonicalCandidate[]): CanonicalCandidate[] { } /** - * Pick the canonical remote (one pointing at `microsoft/FluidFramework`) whose - * `/` is freshest locally. + * From the remotes configured in the local repo whose URL matches `filter`, + * pick the one whose `/` is freshest locally. * * Remotes whose `/` doesn't resolve locally are dropped. Among * the rest, pick the tip that isn't a strict ancestor of any other's; ties * (identical or divergent tips) resolve to the first candidate in config order. * - * @returns The selected remote's name, or `undefined` if no canonical remote is - * configured or none have a locally-resolvable `/`. + * @returns The selected remote's name, or `undefined` if no remote matches + * `filter` or none have a locally-resolvable `/`. */ -export function pickFreshestCanonicalRemote(branch: string): string | undefined { - const canonicals = findCanonicalRemotes(); +export function pickFreshestRemote( + branch: string, + filter: (url: string) => boolean, +): string | undefined { + const eligible = listRemotes().filter((r) => filter(r.url)); - if (canonicals.length === 0) { - console.log(`No remote found pointing at microsoft/FluidFramework.`); + if (eligible.length === 0) { return undefined; } - const candidates: CanonicalCandidate[] = []; + const candidates: RemoteCandidate[] = []; const skipped: string[] = []; - for (const remote of canonicals) { + for (const remote of eligible) { const ref = `${remote.name}/${branch}`; let tip: string | undefined; try { @@ -191,15 +175,11 @@ export function pickFreshestCanonicalRemote(branch: string): string | undefined } if (candidates.length === 0) { - console.log( - `Found remote(s) pointing at microsoft/FluidFramework but none of [${skipped.join( - ", ", - )}] are fetched locally.`, - ); + console.log(`No eligible remote has [${skipped.join(", ")}] fetched locally.`); return undefined; } - let freshest: CanonicalCandidate[]; + let freshest: RemoteCandidate[]; try { freshest = pickFreshest(candidates); } catch (error) { @@ -214,7 +194,7 @@ export function pickFreshestCanonicalRemote(branch: string): string | undefined } const selected = freshest[0]; - console.log(`Remotes pointing at microsoft/FluidFramework:`); + console.log(`Eligible remotes:`); for (const ref of skipped) { console.log(` ${ref} — not fetched locally; skipped`); }