Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 234 additions & 37 deletions packages/tools/src/lib/build-cache/getBinaryPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<boolean> {
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<string[]> {
if (!(await isGitRepository(sourceDir))) {
return [];
}

const projectRoot = getProjectRoot();
const ignorePaths = [
...(fingerprintOptions?.ignorePaths ?? []),
Expand All @@ -90,21 +87,61 @@ async function warnIgnoredFiles(
projectRoot,
),
];

const { output } = await spawn('git', [
'clean',
'-fdx',
'--dry-run',
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<void> {
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')}:
Expand All @@ -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<string | undefined> {
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<string | undefined> {
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;
}
}
30 changes: 29 additions & 1 deletion packages/tools/src/lib/build-cache/localBuildCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Loading