66 * found in the LICENSE file at https://angular.dev/license
77 */
88
9+ import fs from 'fs' ;
10+ import path from 'path' ;
911import { setOutput } from '@actions/core' ;
1012import { GitClient , Log , bold , green , yellow } from '@angular/ng-dev' ;
1113import { select } from '@inquirer/prompts' ;
@@ -17,7 +19,7 @@ import {
1719 getTestlogPath ,
1820 resolveTarget ,
1921} from './targets.mts' ;
20- import { exec } from './utils.mts' ;
22+ import { exec , projectDir } from './utils.mts' ;
2123
2224const benchmarkTestFlags = [
2325 '--cache_test_results=no' ,
@@ -27,6 +29,9 @@ const benchmarkTestFlags = [
2729 // reduce fluctuation. Output streamed ensures that deps can build with RBE, but
2830 // tests run locally while also providing useful output for debugging.
2931 '--test_output=streamed' ,
32+ // In the comparison run, we create a hybrid workspace (main files + PR scripts/lockfiles).
33+ // This causes a lockfile mismatch, so we must allow Bazel to update the lockfile in memory.
34+ '--lockfile_mode=update' ,
3035] ;
3136
3237await yargs ( process . argv . slice ( 2 ) )
@@ -98,11 +103,11 @@ async function prepareForGitHubAction(commentBody: string): Promise<void> {
98103 // Attempt to find the compare SHA. The commit may be either part of the
99104 // pull request, or might be a commit unrelated to the PR- but part of the
100105 // upstream repository. We attempt to fetch/resolve the SHA in both remotes.
101- const compareRefResolve = git . runGraceful ( [ 'rev-parse' , compareRefRaw ] ) ;
106+ const compareRefResolve = git . runGraceful ( [ 'rev-parse' , '--' , compareRefRaw ] ) ;
102107 let compareRefSha = compareRefResolve . stdout . trim ( ) ;
103108 if ( compareRefSha === '' || compareRefResolve . status !== 0 ) {
104109 git . run ( [ 'fetch' , '--depth=1' , git . getRepoGitUrl ( ) , compareRefRaw ] ) ;
105- compareRefSha = git . run ( [ 'rev-parse' , 'FETCH_HEAD' ] ) . stdout . trim ( ) ;
110+ compareRefSha = git . run ( [ 'rev-parse' , '--' , ' FETCH_HEAD'] ) . stdout . trim ( ) ;
106111 }
107112
108113 setOutput ( 'compareSha' , compareRefSha ) ;
@@ -126,8 +131,8 @@ async function runBenchmarkCmd(bazelTargetRaw: string | undefined): Promise<void
126131}
127132
128133/** Runs a benchmark Bazel target. */
129- async function runBenchmarkTarget ( bazelTarget : ResolvedTarget ) : Promise < void > {
130- await exec ( 'bazel ' , [ 'test' , bazelTarget , ...benchmarkTestFlags ] ) ;
134+ async function runBenchmarkTarget ( bazelTarget : ResolvedTarget , cwd ?: string ) : Promise < void > {
135+ await exec ( 'pnpm ' , [ 'bazel' , ' test', bazelTarget , ...benchmarkTestFlags ] , cwd ) ;
131136}
132137
133138/**
@@ -138,13 +143,6 @@ async function runCompare(bazelTargetRaw: string | undefined, compareRef: string
138143 const git = await GitClient . get ( ) ;
139144 const currentRef = git . getCurrentBranchOrRevision ( ) ;
140145
141- if ( git . hasUncommittedChanges ( ) ) {
142- Log . warn ( bold ( 'You have uncommitted changes.' ) ) ;
143- Log . warn ( 'The script will stash your changes and re-apply them so that' ) ;
144- Log . warn ( 'the comparison ref can be checked out.' ) ;
145- Log . warn ( '' ) ;
146- }
147-
148146 if ( bazelTargetRaw === undefined ) {
149147 bazelTargetRaw = await promptForBenchmarkTarget ( ) ;
150148 }
@@ -159,29 +157,92 @@ async function runCompare(bazelTargetRaw: string | undefined, compareRef: string
159157
160158 const workingDirResults = await collectBenchmarkResults ( testlogPath ) ;
161159
162- // Stash working directory as we might be in the middle of developing
163- // and we wouldn't want to discard changes when checking out the compare SHA.
164- git . run ( [ 'stash' ] ) ;
160+ // Define isolated temporary workspace inside `dist/` so it is ignored by git.
161+ const tempDir = path . join ( projectDir , 'dist/benchmark- compare-temp' ) ;
162+ let comparisonResults : any = null ;
165163
166164 try {
167- Log . log ( green ( 'Fetching comparison revision.' ) ) ;
168- // Note: Not using a shallow fetch here as that would convert the local
169- // user repository into an incomplete repository.
170- git . run ( [ 'fetch' , git . getRepoGitUrl ( ) , compareRef ] ) ;
171- Log . log ( green ( 'Checking out comparison revision.' ) ) ;
172- git . run ( [ 'checkout' , 'FETCH_HEAD' ] ) ;
173-
174- await exec ( 'pnpm' , [ 'install' , '--frozen-lockfile' ] ) ;
175- await runBenchmarkTarget ( bazelTarget ) ;
165+ Log . log ( green ( `Creating isolated workspace in ${ tempDir } ` ) ) ;
166+ try {
167+ git . run ( [ 'worktree' , 'remove' , '--force' , tempDir ] ) ;
168+ } catch ( e ) {
169+ if ( fs . existsSync ( tempDir ) ) {
170+ fs . rmSync ( tempDir , { recursive : true , force : true } ) ;
171+ }
172+ try {
173+ git . run ( [ 'worktree' , 'prune' ] ) ;
174+ } catch ( pruneError ) {
175+ // Ignore prune errors
176+ }
177+ }
178+
179+ // Ensure the comparison ref is fetched on the main repository if not already present.
180+ const hasCommit = git . runGraceful ( [ 'cat-file' , '-e' , `${ compareRef } ^{commit}` ] ) . status === 0 ;
181+ if ( ! hasCommit ) {
182+ Log . log ( green ( `Fetching comparison revision ${ compareRef } ...` ) ) ;
183+ git . run ( [ 'fetch' , git . getRepoGitUrl ( ) , compareRef ] ) ;
184+ } else {
185+ Log . log (
186+ green ( `Comparison revision ${ compareRef } is already available locally. Skipping fetch.` ) ,
187+ ) ;
188+ }
189+
190+ // Create isolated workspace instantly using native git worktree.
191+ Log . log ( green ( `Creating isolated worktree for ${ compareRef } in ${ tempDir } ` ) ) ;
192+ git . run ( [ 'worktree' , 'add' , '--detach' , tempDir , compareRef ] ) ;
193+
194+ // Copy the current PR's benchmark scripts and packages into the isolated workspace.
195+ // Explicitly exclude node_modules to avoid copying broken relative symlinks.
196+ Log . log ( green ( 'Copying PR benchmark scripts and packages into isolated workspace...' ) ) ;
197+ const dirsToCopy = [ 'scripts/benchmarks' , 'packages/benchpress' ] ;
198+ for ( const relDir of dirsToCopy ) {
199+ const src = path . join ( projectDir , relDir ) ;
200+ const dest = path . join ( tempDir , relDir ) ;
201+ fs . rmSync ( dest , { recursive : true , force : true } ) ;
202+ fs . cpSync ( src , dest , {
203+ recursive : true ,
204+ filter : ( srcPath ) => ! srcPath . split ( path . sep ) . includes ( 'node_modules' ) ,
205+ } ) ;
206+ }
207+
208+ // Copy `.bazelrc.user` if it exists, otherwise create it.
209+ const bazelrcUser = path . join ( projectDir , '.bazelrc.user' ) ;
210+ const tempBazelrcUser = path . join ( tempDir , '.bazelrc.user' ) ;
211+ if ( fs . existsSync ( bazelrcUser ) ) {
212+ fs . copyFileSync ( bazelrcUser , tempBazelrcUser ) ;
213+ } else {
214+ fs . writeFileSync ( tempBazelrcUser , '' ) ;
215+ }
216+
217+ // Run pnpm install inside the isolated workspace.
218+ Log . log ( green ( 'Installing dependencies in isolated workspace...' ) ) ;
219+ await exec ( 'pnpm' , [ 'install' , '--no-frozen-lockfile' , '--prefer-offline' ] , tempDir ) ;
220+
221+ // Run the benchmark on the comparison workspace.
222+ Log . log ( green ( 'Running benchmark in isolated workspace...' ) ) ;
223+ await runBenchmarkTarget ( bazelTarget , tempDir ) ;
224+
225+ // Resolve testlog path and collect results from the isolated workspace.
226+ Log . log ( green ( 'Collecting comparison results...' ) ) ;
227+ const tempTestlogPath = await getTestlogPath ( bazelTarget , tempDir ) ;
228+ comparisonResults = await collectBenchmarkResults ( tempTestlogPath ) ;
176229 } finally {
177- restoreWorkingStage ( git , currentRef ) ;
230+ Log . log ( green ( 'Cleaning up isolated workspace...' ) ) ;
231+ try {
232+ git . run ( [ 'worktree' , 'remove' , '--force' , tempDir ] ) ;
233+ } catch ( e ) {
234+ Log . warn ( `Failed to clean up isolated worktree: ${ e } ` ) ;
235+ if ( fs . existsSync ( tempDir ) ) {
236+ fs . rmSync ( tempDir , { recursive : true , force : true } ) ;
237+ }
238+ try {
239+ git . run ( [ 'worktree' , 'prune' ] ) ;
240+ } catch ( pruneError ) {
241+ // Ignore prune errors
242+ }
243+ }
178244 }
179245
180- // Re-install dependencies for `HEAD`.
181- await exec ( 'pnpm' , [ 'install' , '--frozen-lockfile' ] ) ;
182-
183- const comparisonResults = await collectBenchmarkResults ( testlogPath ) ;
184-
185246 // If we are running in a GitHub action, expose the benchmark text
186247 // results as outputs. Useful if those are exposed as a GitHub comment then.
187248 if ( process . env . GITHUB_ACTION !== undefined ) {
@@ -198,11 +259,3 @@ async function runCompare(bazelTargetRaw: string | undefined, compareRef: string
198259 Log . info ( bold ( yellow ( `Working stage (${ currentRef } ) results:` ) ) , '\n' ) ;
199260 Log . info ( workingDirResults . summaryConsoleText ) ;
200261}
201-
202- function restoreWorkingStage ( git : GitClient , initialRef : string ) {
203- Log . log ( green ( 'Restoring working stage' ) ) ;
204- git . run ( [ 'checkout' , '-f' , initialRef ] ) ;
205-
206- // Stash apply could fail if there were not changes in the working stage.
207- git . runGraceful ( [ 'stash' , 'apply' ] ) ;
208- }
0 commit comments