From e509e499eb03e0a7755b48ec4ae5efe15adb5fbd Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Mon, 13 Oct 2025 15:48:23 +0200 Subject: [PATCH] wip --- .../src/lib/build-cache/getBinaryPath.ts | 271 +++++++++++++++--- .../src/lib/build-cache/localBuildCache.ts | 30 +- 2 files changed, 263 insertions(+), 38 deletions(-) diff --git a/packages/tools/src/lib/build-cache/getBinaryPath.ts b/packages/tools/src/lib/build-cache/getBinaryPath.ts index 667ee07b7..045c6cf9b 100644 --- a/packages/tools/src/lib/build-cache/getBinaryPath.ts +++ b/packages/tools/src/lib/build-cache/getBinaryPath.ts @@ -3,12 +3,14 @@ import { color, colorLink } from '../color.js'; import type { RockError } from '../error.js'; import { getAllIgnorePaths } from '../fingerprint/ignorePaths.js'; import { type FingerprintSources } from '../fingerprint/index.js'; +import { isInteractive } from '../isInteractive.js'; import logger from '../logger.js'; import { getProjectRoot } from '../project.js'; +import { promptConfirm } from '../prompts.js'; import { spawn } from '../spawn.js'; -import type { RemoteBuildCache } from './common.js'; +import { formatArtifactName, type RemoteBuildCache } from './common.js'; import { fetchCachedBuild } from './fetchCachedBuild.js'; -import { getLocalBuildCacheBinaryPath } from './localBuildCache.js'; +import { getLocalBuildCacheBinaryPath, hasUsedRemoteCacheBefore } from './localBuildCache.js'; export async function getBinaryPath({ artifactName, @@ -37,50 +39,45 @@ export async function getBinaryPath({ // 3. If not, check if the remote cache is requested if (!binaryPath && !localFlag) { - try { - const cachedBuild = await fetchCachedBuild({ - artifactName, - remoteCacheProvider, - }); - if (cachedBuild) { - binaryPath = cachedBuild.binaryPath; - } - } catch (error) { - const message = (error as RockError).message; - const cause = (error as RockError).cause; - logger.warn( - `Remote Cache: Failed to fetch cached build for ${color.bold( - artifactName, - )}. -Cause: ${message}${cause ? `\n${cause.toString()}` : ''} -Read more: ${colorLink( - 'https://rockjs.dev/docs/configuration#remote-cache-configuration', - )}`, - ); - await warnIgnoredFiles(fingerprintOptions, platformName, sourceDir); - logger.debug('Remote cache failure error:', error); - logger.info('Continuing with local build'); - } + binaryPath = await tryFetchCachedBuild({ + artifactName, + remoteCacheProvider, + fingerprintOptions, + platformName, + sourceDir, + }); } return binaryPath; } -async function warnIgnoredFiles( - fingerprintOptions: FingerprintSources, - platformName: string, - sourceDir: string, -) { - // @todo unify git helpers from create-app +/** + * Checks if the current directory is a git repository + */ +async function isGitRepository(sourceDir: string): Promise { try { await spawn('git', ['rev-parse', '--is-inside-work-tree'], { stdio: 'ignore', cwd: sourceDir, }); + return true; } catch { - // Not a git repository, skip the git clean check - return; + return false; } +} + +/** + * Gets the list of files that would be removed by git clean + */ +async function getFilesToClean( + fingerprintOptions: FingerprintSources, + platformName: string, + sourceDir: string, +): Promise { + if (!(await isGitRepository(sourceDir))) { + return []; + } + const projectRoot = getProjectRoot(); const ignorePaths = [ ...(fingerprintOptions?.ignorePaths ?? []), @@ -90,6 +87,7 @@ async function warnIgnoredFiles( projectRoot, ), ]; + const { output } = await spawn('git', [ 'clean', '-fdx', @@ -97,14 +95,53 @@ async function warnIgnoredFiles( sourceDir, ...ignorePaths.flatMap((path) => ['-e', `${path}`]), ]); - const ignoredFiles = output + + return output .split('\n') .map((line) => line.replace('Would remove ', '')) .filter((line) => line !== ''); +} + +/** + * Executes git clean to remove files + */ +async function executeGitClean( + fingerprintOptions: FingerprintSources, + platformName: string, + sourceDir: string, +): Promise { + if (!(await isGitRepository(sourceDir))) { + throw new Error('Not a git repository'); + } - if (ignoredFiles.length > 0) { + const projectRoot = getProjectRoot(); + const ignorePaths = [ + ...(fingerprintOptions?.ignorePaths ?? []), + ...getAllIgnorePaths( + platformName, + path.relative(projectRoot, sourceDir), // git expects relative paths + projectRoot, + ), + ]; + + await spawn('git', [ + 'clean', + '-fdx', + sourceDir, + ...ignorePaths.flatMap((path) => ['-e', `${path}`]), + ]); +} + +async function warnIgnoredFiles( + fingerprintOptions: FingerprintSources, + platformName: string, + sourceDir: string, +) { + const filesToClean = await getFilesToClean(fingerprintOptions, platformName, sourceDir); + + if (filesToClean.length > 0) { logger.warn(`There are files that likely affect fingerprint: -${ignoredFiles.map((file) => `- ${color.bold(file)}`).join('\n')} +${filesToClean.map((file) => `- ${color.bold(file)}`).join('\n')} Consider removing them or update ${color.bold( 'fingerprint.ignorePaths', )} in ${colorLink('rock.config.mjs')}: @@ -113,3 +150,163 @@ Read more: ${colorLink( )}`); } } + +/** + * Tries to fetch cached build with optional debugging workflow + */ +async function tryFetchCachedBuild({ + artifactName, + remoteCacheProvider, + fingerprintOptions, + platformName, + sourceDir, +}: { + artifactName: string; + remoteCacheProvider: null | (() => RemoteBuildCache) | undefined; + fingerprintOptions: FingerprintSources; + platformName: string; + sourceDir: string; +}): Promise { + try { + const cachedBuild = await fetchCachedBuild({ + artifactName, + remoteCacheProvider, + }); + if (cachedBuild) { + return cachedBuild.binaryPath; + } + } catch (error) { + const message = (error as RockError).message; + const cause = (error as RockError).cause; + logger.warn( + `Remote Cache: Failed to fetch cached build for ${color.bold( + artifactName, + )}. +Cause: ${message}${cause ? `\n${cause.toString()}` : ''} +Read more: ${colorLink( + 'https://rockjs.dev/docs/configuration#remote-cache-configuration', + )}`, + ); + + // Check if user has used remote cache before and offer debugging + if (isInteractive() && hasUsedRemoteCacheBefore()) { + const cleanedAndRetried = await runCacheMissDebugging({ + fingerprintOptions, + platformName, + sourceDir, + artifactName, + remoteCacheProvider, + }); + + if (cleanedAndRetried) { + return cleanedAndRetried; + } + } + + await warnIgnoredFiles(fingerprintOptions, platformName, sourceDir); + logger.debug('Remote cache failure error:', error); + logger.info('Continuing with local build'); + } + + return undefined; +} + +/** + * Runs the cache miss debugging workflow and returns binary path if successful + */ +async function runCacheMissDebugging({ + fingerprintOptions, + platformName, + sourceDir, + artifactName, + remoteCacheProvider, +}: { + fingerprintOptions: FingerprintSources; + platformName: string; + sourceDir: string; + artifactName: string; + remoteCacheProvider: null | (() => RemoteBuildCache) | undefined; +}): Promise { + logger.info(''); // Add spacing + const shouldDebug = await promptConfirm({ + message: `Would you like to debug this remote cache miss?`, + confirmLabel: 'Yes, help me debug this', + cancelLabel: 'No, continue with local build', + }); + + if (!shouldDebug) { + return undefined; + } + + // Step 1: Check what files would be cleaned + const filesToClean = await getFilesToClean(fingerprintOptions, platformName, sourceDir); + + if (filesToClean.length === 0) { + logger.info('โœ… No files found that would affect fingerprinting.'); + // TODO: backlink to the docs here instead of a 404 + logger.info(' The cache miss might be due to other factors (CI environment, etc.)'); + return undefined; + } + + // Step 2: Show user what would be cleaned and offer to clean + logger.info(`๐Ÿ“‹ Found ${color.bold(filesToClean.length.toString())} files that affect cache fingerprint:`); + filesToClean.slice(0, 10).forEach(file => { + logger.info(` - ${color.bold(file)}`); + }); + + if (filesToClean.length > 10) { + logger.info(` ... and ${filesToClean.length - 10} more files`); + } + logger.info(''); // Add spacing + + const shouldClean = await promptConfirm({ + message: `Clean these files and retry fetching?`, + confirmLabel: 'Yes, clean files and retry', + cancelLabel: 'No, continue with local build', + }); + + if (!shouldClean) { + return undefined; + } + + // Step 3: Clean files first + logger.info('๐Ÿงน Cleaning files...'); + try { + await executeGitClean(fingerprintOptions, platformName, sourceDir); + logger.info('โœ… Files cleaned successfully'); + logger.info(''); // Add spacing + } catch (error) { + logger.error(`โŒ Failed to clean files: ${error}`); + logger.info(' Continuing with local build...'); + return undefined; + } + + // Extract platform and traits from the original artifact name to recalculate + const projectRoot = getProjectRoot(); + const nameParts = artifactName.split('-'); + const platform = nameParts[1] as 'ios' | 'android' | 'harmony'; + const traits = nameParts.slice(2, -1); // Everything except 'rock', platform, and hash + + const cleanArtifactName = await formatArtifactName({ + platform, + traits, + root: projectRoot, + fingerprintOptions, + }); + + // Step 5: Retry the fetch with the correct artifact name + logger.info('๐Ÿ”„ Retrying remote cache with clean fingerprint...'); + + const cachedBuild = await fetchCachedBuild({ + artifactName: cleanArtifactName, + remoteCacheProvider, + }); + + if (cachedBuild) { + logger.info('โœ… Successfully fetched from remote cache after cleaning!'); + return cachedBuild.binaryPath; + } else { + logger.info('โŒ Remote cache still missed after cleaning. Continuing with local build...'); + return undefined; + } +} diff --git a/packages/tools/src/lib/build-cache/localBuildCache.ts b/packages/tools/src/lib/build-cache/localBuildCache.ts index 0bdc8cf22..28fb6a1d3 100644 --- a/packages/tools/src/lib/build-cache/localBuildCache.ts +++ b/packages/tools/src/lib/build-cache/localBuildCache.ts @@ -3,7 +3,8 @@ import path from 'node:path'; import { color, colorLink } from '../color.js'; import logger from '../logger.js'; import { relativeToCwd } from '../path.js'; -import { getLocalArtifactPath, getLocalBinaryPath } from './common.js'; +import { getCacheRootPath } from '../project.js'; +import { BUILD_CACHE_DIR, getLocalArtifactPath, getLocalBinaryPath } from './common.js'; export type LocalBuild = { name: string; @@ -63,3 +64,30 @@ export function getLocalBuildCacheBinaryPath( } return undefined; } + +/** + * Checks if there are any existing remote cache artifacts, indicating previous successful remote cache usage. + */ +export function hasUsedRemoteCacheBefore(): boolean { + try { + const remoteCacheDir = path.join(getCacheRootPath(), BUILD_CACHE_DIR); + + if (!fs.existsSync(remoteCacheDir)) { + return false; + } + + const entries = fs.readdirSync(remoteCacheDir); + + // Look for any rock- directories + const rockArtifacts = entries.filter(entry => { + const entryPath = path.join(remoteCacheDir, entry); + const stats = fs.statSync(entryPath, { throwIfNoEntry: false }); + return stats?.isDirectory() && entry.startsWith('rock-'); + }); + + return rockArtifacts.length > 0; + } catch (error) { + logger.debug('Failed to check remote cache usage history:', error); + return false; + } +}