From 0cd805206f48a0272c89f068b8ff238fa11d3d4b Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 2 Sep 2026 11:14:46 +0200 Subject: [PATCH 1/4] feat(project): Add project-scoped build cache deletion Add BuildCacheStorage#hasProjectRecords and #dropProjectRecords to delete a single project's entries across the four project-keyed tables (index_cache, stage_metadata, task_metadata, result_metadata) in one transaction. The content-addressed store is shared across projects and left untouched; orphaned blobs are reused on the next build and reclaimed by a full cache clean. Expose these via CacheManager.getProjectCacheInfo and cleanProject, mirroring getCacheInfo/cleanCache and reusing the #withStorage helper. --- .../lib/build/cache/BuildCacheStorage.js | 58 +++++++++++++++++++ .../project/lib/build/cache/CacheManager.js | 42 ++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index 13fb50c491a..464070f2118 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -12,6 +12,12 @@ const CONTENT_COMPRESSION_THRESHOLD = 128; /** All live data table names */ const DATA_TABLES = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; +/** + * Data tables keyed by project_id. The content table is content-addressed and shared + * across projects, so it is not part of a project-scoped delete. + */ +const PROJECT_TABLES = ["index_cache", "stage_metadata", "task_metadata", "result_metadata"]; + /** * Unified SQLite-backed storage for the build cache * @@ -145,6 +151,11 @@ export default class BuildCacheStorage { `INSERT OR REPLACE INTO result_metadata (project_id, build_signature, stage_signature, data) VALUES (?, ?, ?, ?)` ), + + // Project-scoped deletion (one DELETE per project-keyed table) + deleteProjectRecords: Object.fromEntries(PROJECT_TABLES.map((table) => [ + table, this.#db.prepare(`DELETE FROM ${table} WHERE project_id = ?`) + ])), }; } @@ -535,6 +546,53 @@ export default class BuildCacheStorage { return false; } + /** + * Checks whether any project-keyed table holds records for the given project. + * + * The content table is content-addressed and shared across projects, so it is not + * considered here. + * + * @param {string} projectId Project identifier + * @returns {boolean} True if any project-keyed table has a row for the project + */ + hasProjectRecords(projectId) { + for (const table of PROJECT_TABLES) { + const {is_populated: isPopulated} = this.#db.prepare( + `SELECT EXISTS(SELECT 1 FROM ${table} WHERE project_id = ? LIMIT 1) as is_populated` + ).get(projectId); + if (isPopulated) { + return true; + } + } + return false; + } + + /** + * Deletes all cache entries for a single project across the project-keyed tables + * (index_cache, stage_metadata, task_metadata, result_metadata) in one transaction. + * + * The content table is left untouched: it is content-addressed and shared across + * projects. Orphaned blobs are reused on the next build and reclaimed by a full + * {@link dropAllRecords}. No VACUUM is run; freed pages return to the freelist. + * + * @param {string} projectId Project identifier + * @returns {number} Number of deleted rows across all project-keyed tables + */ + dropProjectRecords(projectId) { + this.#db.exec("BEGIN"); + try { + let deletedEntries = 0; + for (const table of PROJECT_TABLES) { + deletedEntries += this.#stmts.deleteProjectRecords[table].run(projectId).changes; + } + this.#db.exec("COMMIT"); + return deletedEntries; + } catch (err) { + this.#db.exec("ROLLBACK"); + throw err; + } + } + /** * Atomically drops all live tables and recreates fresh empty ones in a single * transaction. The operation completes in milliseconds regardless of data volume — diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index d1baec61eb0..c39173f6304 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -414,6 +414,48 @@ export default class CacheManager { }); } + /** + * Get build cache info for a single project in the current cache version. + * + * @public + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @param {string} projectId Project identifier (root package name) + * @returns {Promise<{path: string, projectId: string}|null>} Build cache info or null + */ + static getProjectCacheInfo(ui5DataDir, projectId) { + return CacheManager.#withStorage(ui5DataDir, null, (storage) => { + if (!storage.hasProjectRecords(projectId)) { + return null; + } + return {path: `buildCache/${CACHE_VERSION}`, projectId}; + }); + } + + /** + * Deletes all build cache entries for a single project across the project-keyed + * tables. The shared content-addressed store is left untouched; run + * {@link cleanCache} to reclaim orphaned content. + * + * @public + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @param {string} projectId Project identifier (root package name) + * @returns {Promise<{path: string, projectId: string, deletedEntries: number}|null>} Removal result or null + */ + static cleanProject(ui5DataDir, projectId) { + return CacheManager.#withStorage(ui5DataDir, null, (storage) => { + if (!storage.hasProjectRecords(projectId)) { + return null; + } + return { + path: `buildCache/${CACHE_VERSION}`, + projectId, + deletedEntries: storage.dropProjectRecords(projectId), + }; + }); + } + /** * Runs VACUUM to reclaim disk space from a previous {@link cleanCache} call. * Only runs if the database has freelist pages (i.e. cleanup was deferred). From a2aaa1d8d0596370722310a1e9bb0b63d5707573 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 2 Sep 2026 11:18:08 +0200 Subject: [PATCH 2/4] feat(cli): Add 'cache clean --project' to delete a project's build cache Add a --project/-p flag to 'ui5 cache clean' that removes only the build cache of the project in the current directory, leaving other projects' build cache and the downloaded framework packages intact. The root project id (the build cache key) is resolved from the project graph the same way 'ui5 build' does, honoring --config, --workspace and --dependency-definition. Deletion goes through the new CacheManager.cleanProject; the framework cache is not touched in this mode. Reuses the existing confirmation prompt, --force flag and verbose output gating. --- packages/cli/lib/cli/commands/cache.js | 109 +++++++++- .../lib/cli/commands/helpers/cacheOutput.js | 39 ++++ packages/cli/test/lib/cli/commands/cache.js | 191 +++++++++++++++++- 3 files changed, 335 insertions(+), 4 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index c580f835e01..bab23cf76e6 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -3,12 +3,15 @@ import path from "node:path"; import process from "node:process"; import {isLogLevelEnabled} from "@ui5/logger"; import baseMiddleware from "../middlewares/base.js"; +import {applyProjectConfigOptions, applyWorkspaceOptions} from "../options.js"; import {getUi5DataDirOrDefault, formatPath} from "../../dataDir.js"; import { CACHE_CLEAN_HELP_USAGE, displayCacheCleanWarning, displayCacheInfo, displayCleanupResult, + displayProjectCacheInfo, + displayProjectCleanupResult, } from "./helpers/cacheOutput.js"; const cacheCommand = { @@ -24,6 +27,8 @@ cacheCommand.builder = function(cli) { .command("clean", "Remove all cached UI5 data", { handler: handleCache, builder: function(yargs) { + applyProjectConfigOptions(yargs); + applyWorkspaceOptions(yargs); return yargs .usage(CACHE_CLEAN_HELP_USAGE) .option("force", { @@ -32,10 +37,22 @@ cacheCommand.builder = function(cli) { default: false, type: "boolean", }) + .option("project", { + alias: "p", + describe: "Remove only the build cache of a single project, leaving other " + + "projects' build cache and the downloaded framework packages intact. " + + "Without a value, targets the project in the current directory. Pass a " + + "project id (e.g. --project sap.ui.core) to target a specific project", + type: "string", + }) .example("$0 cache clean", "Remove all cached UI5 data after confirmation") .example("$0 cache clean --force", "Remove all cached UI5 data without confirmation (e.g. in CI scenarios)") + .example("$0 cache clean --project", + "Remove only the build cache of the project in the current directory") + .example("$0 cache clean --project sap.ui.core", + "Remove only the build cache of the project 'sap.ui.core'") .example("UI5_DATA_DIR=/custom/path $0 cache clean", "Remove cached data from a non-default UI5 data directory"); }, @@ -46,16 +63,17 @@ cacheCommand.builder = function(cli) { * Prompt the user for confirmation before proceeding with cache cleanup. * * @param {Yargs.Arguments} argv + * @param {string} [question] Confirmation prompt text * @returns {Promise} Confirmation result */ -async function getConfirmation(argv) { +async function getConfirmation(argv, question = "Proceed with cache cleanup? (y/N)") { if (argv.force) { return true; } displayCacheCleanWarning(); const {default: yesno} = await import("yesno"); return yesno({ - question: "Proceed with cache cleanup? (y/N)", + question, defaultValue: false }); } @@ -74,6 +92,9 @@ function getAbsPath(ui5DataDir, cacheEntry) { } async function handleCache(argv) { + if (argv.project !== undefined) { + return handleProjectCache(argv); + } // Lazy loading to prevent unnecessary imports when the command is not executed const [{default: FrameworkCache}, {default: CacheManager}] = await Promise.all([ import("@ui5/project/internal/ui5Framework/cache"), @@ -168,4 +189,88 @@ async function handleCache(argv) { } } +/** + * Resolves the project graph for the current directory and returns the root project's id, + * which is the key used for its entries in the build cache. + * + * Framework dependencies are not resolved: the root project id is its package name and does + * not depend on the framework version, so resolving the framework would only add network + * access and a failure surface to a command meant to leave framework packages untouched. + * + * @param {Yargs.Arguments} argv + * @returns {Promise} Root project id + */ +async function getRootProjectId(argv) { + const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph"); + let graph; + if (argv.dependencyDefinition) { + graph = await graphFromStaticFile({ + filePath: argv.dependencyDefinition, + rootConfigPath: argv.config, + resolveFrameworkDependencies: false, + }); + } else { + graph = await graphFromPackageDependencies({ + rootConfigPath: argv.config, + workspaceConfigPath: argv.workspaceConfig, + workspaceName: argv.workspace === false ? null : argv.workspace, + resolveFrameworkDependencies: false, + }); + } + return graph.getRoot().getId(); +} + +/** + * Removes the build cache of a single project, leaving other projects' build cache and the + * framework cache intact. + * + * A project id passed via --project is used directly. Without one, the root project id of the + * current directory is resolved from the project graph the same way 'ui5 build' does. + * + * @param {Yargs.Arguments} argv + */ +async function handleProjectCache(argv) { + const {default: CacheManager} = await import("@ui5/project/internal/build/cache/CacheManager"); + + const projectId = argv.project || await getRootProjectId(argv); + const ui5DataDir = await getUi5DataDirOrDefault({cwd: process.cwd()}); + const isVerbose = isLogLevelEnabled("verbose"); + + if (isVerbose) { + process.stderr.write( + `Checking build cache for project ${chalk.bold(projectId)} at ${chalk.bold(formatPath(ui5DataDir))} …\n` + ); + } + + const info = await CacheManager.getProjectCacheInfo(ui5DataDir, projectId); + if (!info) { + if (isVerbose) { + process.stderr.write(`${chalk.italic("Nothing to clean")}\n`); + } + return; + } + + if (isVerbose) { + displayProjectCacheInfo({projectId, absPath: getAbsPath(ui5DataDir, info)}); + } + + const confirmed = await getConfirmation(argv, `Delete build cache for '${projectId}'? (y/N)`); + if (!confirmed) { + if (isVerbose) { + process.stderr.write(`${chalk.italic("Cancelled")}\n`); + } + return; + } + + const result = await CacheManager.cleanProject(ui5DataDir, projectId); + + if (isVerbose) { + displayProjectCleanupResult({ + projectId, + absPath: getAbsPath(ui5DataDir, result), + deletedEntries: result?.deletedEntries ?? 0, + }); + } +} + export default cacheCommand; diff --git a/packages/cli/lib/cli/commands/helpers/cacheOutput.js b/packages/cli/lib/cli/commands/helpers/cacheOutput.js index 6295a0437ed..a9bb1febfaa 100644 --- a/packages/cli/lib/cli/commands/helpers/cacheOutput.js +++ b/packages/cli/lib/cli/commands/helpers/cacheOutput.js @@ -82,6 +82,45 @@ export function displayCacheCleanWarning() { process.stderr.write(`${chalk.italic(CACHE_CLEAN_WARNING_IMPACT)}\n\n`); } +function formatEntries(count) { + return `${count.toLocaleString("en-US")} ${count === 1 ? "entry" : "entries"}`; +} + +/** + * Display information about the build cache entries of a single project that will be removed. + * + * @param {object} data + * @param {string} data.projectId + * @param {string} data.absPath + */ +export function displayProjectCacheInfo({projectId, absPath}) { + process.stderr.write( + `\n${chalk.bold(`The build cache of project ${chalk.cyan(projectId)} will be removed:`)}\n\n` + ); + writePreviewItem(absPath); + process.stderr.write("\n"); +} + +/** + * Display the result of a project-scoped build cache cleanup. + * + * @param {object} data + * @param {string} data.projectId + * @param {string|null} data.absPath + * @param {number} data.deletedEntries + */ +export function displayProjectCleanupResult({projectId, absPath, deletedEntries}) { + if (!absPath) { + process.stderr.write(`${chalk.italic(PARALLEL_CLEANUP_NOTICE)}\n`); + return; + } + process.stderr.write(`\n${chalk.bold("Cleanup result:")}\n\n`); + writeCleanupItem(absPath, formatEntries(deletedEntries)); + process.stderr.write( + `\n${chalk.green("Success:")} Removed the build cache of project ${chalk.cyan(projectId)}\n` + ); +} + function createFrameworkItems(entries) { const items = []; for (const entry of entries) { diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index c5ac660e9c8..c226624e72c 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -57,9 +57,15 @@ test.beforeEach(async (t) => { t.context.buildCacheCleanCache = sinon.stub(); t.context.buildCacheCleanAdditional = sinon.stub().resolves([]); t.context.buildCacheGetAdditionalCacheInfo = sinon.stub().resolves([]); + t.context.buildCacheGetProjectCacheInfo = sinon.stub(); + t.context.buildCacheCleanProject = sinon.stub(); t.context.yesnoStub = sinon.stub(); + t.context.getRootStub = sinon.stub().returns({getId: () => "my.project"}); + t.context.graphFromPackageDependencies = sinon.stub().resolves({getRoot: t.context.getRootStub}); + t.context.graphFromStaticFile = sinon.stub().resolves({getRoot: t.context.getRootStub}); + t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/internal/ui5Framework/cache": { default: class { @@ -75,8 +81,14 @@ test.beforeEach(async (t) => { static cleanCache = t.context.buildCacheCleanCache; static cleanAdditional = t.context.buildCacheCleanAdditional; static getAdditionalCacheInfo = t.context.buildCacheGetAdditionalCacheInfo; + static getProjectCacheInfo = t.context.buildCacheGetProjectCacheInfo; + static cleanProject = t.context.buildCacheCleanProject; } }, + "@ui5/project/graph": { + graphFromPackageDependencies: t.context.graphFromPackageDependencies, + graphFromStaticFile: t.context.graphFromStaticFile, + }, "yesno": { default: t.context.yesnoStub, }, @@ -106,6 +118,7 @@ test("Command builder", async (t) => { const yargsStub = { usage: sinon.stub().returnsThis(), option: sinon.stub().returnsThis(), + coerce: sinon.stub().returnsThis(), example: sinon.stub().returnsThis(), }; const cliStub = { @@ -125,8 +138,9 @@ test("Command builder", async (t) => { t.is(yargsStub.usage.callCount, 1, "usage called once for warning help banner"); t.true(yargsStub.usage.firstCall.args[0].startsWith("WARNING:"), "usage banner starts with warning"); - t.is(yargsStub.option.callCount, 1, "option called for --force flag"); - t.is(yargsStub.example.callCount, 3, "example called 3 times"); + // config, dependency-definition, workspace-config, workspace, force, project + t.is(yargsStub.option.callCount, 6, "option called for all clean options"); + t.is(yargsStub.example.callCount, 5, "example called 5 times"); }); test.serial("Command definition is correct", (t) => { @@ -869,3 +883,176 @@ test.serial("ui5 cache clean: pre-clean summary shows both groups when active an t.true(allOutput.includes(ACTIVE_CACHE_HEADER), "Shows Active Cache group header"); t.true(allOutput.includes(STALE_CACHE_HEADER), "Shows Stale Cache group header"); }); + +// ─── Project-scoped clean (ui5 cache clean --project) ──────────────────────── + +test.serial("ui5 cache clean --project: nothing to clean", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetProjectCacheInfo, + buildCacheCleanProject, frameworkCacheCleanCache, yesnoStub} = t.context; + + buildCacheGetProjectCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(buildCacheGetProjectCacheInfo.callCount, 1, "Looks up project cache info"); + t.is(buildCacheGetProjectCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, "Uses resolved ui5DataDir"); + t.is(buildCacheGetProjectCacheInfo.firstCall.args[1], "my.project", "Uses resolved root project id"); + t.is(yesnoStub.callCount, 0, "Does not prompt when nothing to clean"); + t.is(buildCacheCleanProject.callCount, 0, "Does not clean when nothing to clean"); + t.is(frameworkCacheCleanCache.callCount, 0, "Never touches framework cache in project mode"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Nothing to clean"), "Prints nothing to clean"); +}); + +test.serial("ui5 cache clean --project: removes project build cache with --force", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetProjectCacheInfo, + buildCacheCleanProject, frameworkCacheCleanCache, buildCacheCleanCache, yesnoStub} = t.context; + + buildCacheGetProjectCacheInfo.resolves({path: BUILD_CACHE_PATH, projectId: "my.project"}); + buildCacheCleanProject.resolves({path: BUILD_CACHE_PATH, projectId: "my.project", deletedEntries: 42}); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + argv["force"] = true; + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(yesnoStub.callCount, 0, "Does not prompt with --force"); + t.is(buildCacheCleanProject.callCount, 1, "Cleans project build cache"); + t.is(buildCacheCleanProject.firstCall.args[1], "my.project", "Cleans the resolved root project id"); + t.is(frameworkCacheCleanCache.callCount, 0, "Does not touch framework cache"); + t.is(buildCacheCleanCache.callCount, 0, "Does not run full build cache clean"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("my.project"), "Names the project"); + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, BUILD_CACHE_PATH)), "Shows absolute build cache path"); + t.true(allOutput.includes("42 entries"), "Reports number of removed entries"); + t.true(allOutput.includes("Success:"), "Shows success summary"); +}); + +test.serial("ui5 cache clean --project: user cancels", async (t) => { + const {cache, argv, buildCacheGetProjectCacheInfo, buildCacheCleanProject, yesnoStub} = t.context; + + buildCacheGetProjectCacheInfo.resolves({path: BUILD_CACHE_PATH, projectId: "my.project"}); + yesnoStub.resolves(false); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + await cache.handler(argv); + + t.is(yesnoStub.callCount, 1, "Prompts for confirmation"); + t.is(buildCacheCleanProject.callCount, 0, "Does not clean when user cancels"); +}); + +test.serial("ui5 cache clean --project: reports parallel cleanup when result is empty", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetProjectCacheInfo, + buildCacheCleanProject, yesnoStub} = t.context; + + buildCacheGetProjectCacheInfo.resolves({path: BUILD_CACHE_PATH, projectId: "my.project"}); + buildCacheCleanProject.resolves(null); + yesnoStub.resolves(true); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(buildCacheCleanProject.callCount, 1, "Attempts cleanup"); + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(PARALLEL_CLEANUP_NOTICE), "Reports parallel cleanup for empty result"); + t.false(allOutput.includes("Success:"), "Does not claim success for empty result"); +}); + +test.serial("ui5 cache clean --project: non-verbose --force stays quiet", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetProjectCacheInfo, buildCacheCleanProject} = t.context; + + buildCacheGetProjectCacheInfo.resolves({path: BUILD_CACHE_PATH, projectId: "my.project"}); + buildCacheCleanProject.resolves({path: BUILD_CACHE_PATH, projectId: "my.project", deletedEntries: 3}); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + argv["force"] = true; + await cache.handler(argv); + + t.is(buildCacheCleanProject.callCount, 1, "Cleans project build cache"); + t.is(stderrWriteStub.callCount, 0, "Writes nothing in non-verbose --force mode"); +}); + +test.serial("ui5 cache clean --project: resolves graph via package dependencies by default", async (t) => { + const {cache, argv, graphFromPackageDependencies, graphFromStaticFile, + buildCacheGetProjectCacheInfo} = t.context; + + buildCacheGetProjectCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + await cache.handler(argv); + + t.is(graphFromPackageDependencies.callCount, 1, "Resolves graph from package dependencies"); + t.is(graphFromStaticFile.callCount, 0, "Does not use static file resolution"); +}); + +test.serial("ui5 cache clean --project: uses static file when dependency-definition is given", async (t) => { + const {cache, argv, graphFromPackageDependencies, graphFromStaticFile, + buildCacheGetProjectCacheInfo} = t.context; + + buildCacheGetProjectCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + argv["dependencyDefinition"] = "/path/to/deps.yaml"; + await cache.handler(argv); + + t.is(graphFromStaticFile.callCount, 1, "Resolves graph from static file"); + t.is(graphFromStaticFile.firstCall.args[0].filePath, "/path/to/deps.yaml", "Passes dependency definition path"); + t.is(graphFromPackageDependencies.callCount, 0, "Does not use package dependency resolution"); +}); + +test.serial("ui5 cache clean --project : uses the given id without resolving the graph", async (t) => { + const {cache, argv, stderrWriteStub, graphFromPackageDependencies, graphFromStaticFile, + buildCacheGetProjectCacheInfo, buildCacheCleanProject, frameworkCacheCleanCache} = t.context; + + buildCacheGetProjectCacheInfo.resolves({path: BUILD_CACHE_PATH, projectId: "sap.ui.core"}); + buildCacheCleanProject.resolves({path: BUILD_CACHE_PATH, projectId: "sap.ui.core", deletedEntries: 7}); + + argv["_"] = ["cache", "clean"]; + argv["project"] = "sap.ui.core"; + argv["force"] = true; + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(graphFromPackageDependencies.callCount, 0, "Does not resolve the project graph for an explicit id"); + t.is(graphFromStaticFile.callCount, 0, "Does not resolve the project graph for an explicit id"); + t.is(buildCacheGetProjectCacheInfo.firstCall.args[1], "sap.ui.core", "Looks up the given project id"); + t.is(buildCacheCleanProject.firstCall.args[1], "sap.ui.core", "Cleans the given project id"); + t.is(frameworkCacheCleanCache.callCount, 0, "Never touches framework cache in project mode"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("sap.ui.core"), "Names the given project"); + t.true(allOutput.includes("7 entries"), "Reports number of removed entries"); + t.true(allOutput.includes("Success:"), "Shows success summary"); +}); + +test.serial("ui5 cache clean --project : nothing to clean for an unknown id", async (t) => { + const {cache, argv, stderrWriteStub, graphFromPackageDependencies, + buildCacheGetProjectCacheInfo, buildCacheCleanProject, yesnoStub} = t.context; + + buildCacheGetProjectCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + argv["project"] = "does.not.exist"; + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(graphFromPackageDependencies.callCount, 0, "Does not resolve the project graph for an explicit id"); + t.is(buildCacheGetProjectCacheInfo.firstCall.args[1], "does.not.exist", "Looks up the given project id"); + t.is(yesnoStub.callCount, 0, "Does not prompt when nothing to clean"); + t.is(buildCacheCleanProject.callCount, 0, "Does not clean when nothing to clean"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Nothing to clean"), "Prints nothing to clean"); +}); From 307e2f92dabb1f0b357d8b02412fe52625018e60 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 2 Sep 2026 11:19:41 +0200 Subject: [PATCH 3/4] test(project): Cover project-scoped build cache deletion Add BuildCacheStorage tests for hasProjectRecords and dropProjectRecords (only the target project's project-keyed rows are removed; other projects and shared content stay intact; unknown project is a no-op) and CacheManager tests for getProjectCacheInfo and cleanProject. --- .../test/lib/build/cache/BuildCacheStorage.js | 55 ++++++++++++++ .../test/lib/build/cache/CacheManager.js | 71 +++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/packages/project/test/lib/build/cache/BuildCacheStorage.js b/packages/project/test/lib/build/cache/BuildCacheStorage.js index 7a3447ff52f..505ec243130 100644 --- a/packages/project/test/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/test/lib/build/cache/BuildCacheStorage.js @@ -449,6 +449,61 @@ test("hasRecords: Returns true when result metadata table has records", (t) => { t.true(t.context.storage.hasRecords()); }); +// ===== hasProjectRecords / dropProjectRecords ===== + +function seedProjectRecords(storage, projectId) { + storage.writeIndexCache(projectId, "build-sig", "source", {v: 1}); + storage.writeStageCache(projectId, "build-sig", "task/minify", "stage-sig", {v: 2}); + storage.writeTaskMetadata(projectId, "build-sig", "minify", "project", {v: 3}); + storage.writeResultMetadata(projectId, "build-sig", "result-sig", {v: 4}); +} + +test("hasProjectRecords: Returns false for a project without records", (t) => { + t.context.storage.writeIndexCache("project-a", "build-sig", "source", {v: 1}); + t.false(t.context.storage.hasProjectRecords("project-b")); +}); + +test("hasProjectRecords: Returns true when any project-keyed table has a row", (t) => { + t.context.storage.writeResultMetadata("project-a", "build-sig", "sig-a", {v: 1}); + t.true(t.context.storage.hasProjectRecords("project-a")); +}); + +test("hasProjectRecords: Ignores content table (not project-keyed)", (t) => { + t.context.storage.putContent("sha256-content", Buffer.from("data")); + t.false(t.context.storage.hasProjectRecords("project-a"), + "Shared content does not count as project records"); +}); + +test("dropProjectRecords: Removes all project-keyed rows and returns the count", (t) => { + seedProjectRecords(t.context.storage, "project-a"); + + const deleted = t.context.storage.dropProjectRecords("project-a"); + + t.is(deleted, 4, "Reports one deleted row per project-keyed table"); + t.false(t.context.storage.hasProjectRecords("project-a"), "Project has no records left"); +}); + +test("dropProjectRecords: Leaves other projects and shared content intact", (t) => { + seedProjectRecords(t.context.storage, "project-a"); + seedProjectRecords(t.context.storage, "project-b"); + t.context.storage.putContent("sha256-shared", Buffer.from("data")); + + t.context.storage.dropProjectRecords("project-a"); + + t.false(t.context.storage.hasProjectRecords("project-a"), "Target project cleared"); + t.true(t.context.storage.hasProjectRecords("project-b"), "Other project untouched"); + t.true(t.context.storage.hasContent("sha256-shared"), "Shared content untouched"); +}); + +test("dropProjectRecords: No-op returns 0 for an unknown project", (t) => { + seedProjectRecords(t.context.storage, "project-a"); + + const deleted = t.context.storage.dropProjectRecords("project-unknown"); + + t.is(deleted, 0, "Nothing deleted for an unknown project"); + t.true(t.context.storage.hasProjectRecords("project-a"), "Existing project untouched"); +}); + test("getDatabaseSize: Returns positive database size", (t) => { const size = t.context.storage.getDatabaseSize(); t.true(Number.isInteger(size)); diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index dd89c030446..0ea19e097ab 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -276,6 +276,77 @@ test.serial("cleanCache: returns null when db does not exist", async (t) => { t.is(result, null); }); +test.serial("getProjectCacheInfo: Returns null when db does not exist", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + + const info = await CacheManager.getProjectCacheInfo(testDir, "project-x"); + t.is(info, null); +}); + +test.serial("getProjectCacheInfo: Returns null when project has no records", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("project-a", "build-sig", "source", {value: 1}); + cm.close(); + + const info = await CacheManager.getProjectCacheInfo(testDir, "project-b"); + t.is(info, null); +}); + +test.serial("getProjectCacheInfo: Returns info when project has records", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("project-x", "build-sig", "source", {value: 1}); + cm.close(); + + const info = await CacheManager.getProjectCacheInfo(testDir, "project-x"); + t.truthy(info); + t.is(info.projectId, "project-x"); + t.regex(info.path, /^buildCache\//); +}); + +test.serial("cleanProject: Removes only the project's records and returns the result", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("project-x", "build-sig", "source", {value: 1}); + cm.writeResultMetadata("project-x", "build-sig", "result-sig", {value: 2}); + cm.writeIndexCache("project-y", "build-sig", "source", {value: 3}); + cm.close(); + + const result = await CacheManager.cleanProject(testDir, "project-x"); + t.truthy(result); + t.is(result.projectId, "project-x"); + t.is(result.deletedEntries, 2); + + t.is(await CacheManager.getProjectCacheInfo(testDir, "project-x"), null, + "Target project has no records left"); + t.truthy(await CacheManager.getProjectCacheInfo(testDir, "project-y"), + "Other project is untouched"); +}); + +test.serial("cleanProject: Returns null when project has no records", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("project-a", "build-sig", "source", {value: 1}); + cm.close(); + + const result = await CacheManager.cleanProject(testDir, "project-b"); + t.is(result, null); +}); + +test.serial("cleanProject: Returns null when db does not exist", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + + const result = await CacheManager.cleanProject(testDir, "project-x"); + t.is(result, null); +}); + test.serial("getAdditionalCacheInfo: returns empty array when no stale tables", async (t) => { const testDir = getUniqueTestDir(); const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; From ae466eb1c0d41dc871c16a2053ccde86d6af59ce Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 4 Sep 2026 09:37:56 +0200 Subject: [PATCH 4/4] docs(cli): Fix parameter example Co-authored-by: Yavor Ivanov --- packages/cli/lib/cli/commands/cache.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index bab23cf76e6..47a1258ff35 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -51,7 +51,7 @@ cacheCommand.builder = function(cli) { "Remove all cached UI5 data without confirmation (e.g. in CI scenarios)") .example("$0 cache clean --project", "Remove only the build cache of the project in the current directory") - .example("$0 cache clean --project sap.ui.core", + .example("$0 cache clean --project @openui5/sap.ui.core", "Remove only the build cache of the project 'sap.ui.core'") .example("UI5_DATA_DIR=/custom/path $0 cache clean", "Remove cached data from a non-default UI5 data directory");