From aceff326809c1a7799636f2a8a550e2aa4d51c43 Mon Sep 17 00:00:00 2001 From: RoboClaw Date: Wed, 16 Sep 2026 17:58:43 -0700 Subject: [PATCH] fix(build): avoid duplicate asset copies during merge conflict resolution (#148907) Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> --- scripts/lib/bundled-plugin-source-utils.mts | 3 +- scripts/lib/static-extension-assets.mts | 49 +++---------------- test/scripts/runtime-postbuild.test.ts | 53 +++++++++++++++++++++ 3 files changed, 61 insertions(+), 44 deletions(-) diff --git a/scripts/lib/bundled-plugin-source-utils.mts b/scripts/lib/bundled-plugin-source-utils.mts index 8553fb7de0eb..c3f6e1b411fa 100644 --- a/scripts/lib/bundled-plugin-source-utils.mts +++ b/scripts/lib/bundled-plugin-source-utils.mts @@ -23,7 +23,8 @@ function readJsonFile(filePath: string): unknown { return JSON.parse(fs.readFileSync(filePath, "utf8")); } -function collectTrackedBundledPluginSourceCandidates(repoRoot: string) { +/** Collect tracked metadata paths without reading plugin JSON or scanning directories. */ +export function collectTrackedBundledPluginSourceCandidates(repoRoot: string) { const pathspecs = [ ":(glob)extensions/*/openclaw.plugin.json", ":(glob)extensions/*/package.json", diff --git a/scripts/lib/static-extension-assets.mts b/scripts/lib/static-extension-assets.mts index bb76cb331a3d..d2389fef677d 100644 --- a/scripts/lib/static-extension-assets.mts +++ b/scripts/lib/static-extension-assets.mts @@ -1,8 +1,8 @@ // Discovers and copies static assets declared by bundled extension packages. -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { parseDockerSelectedPluginBuildIdFilter } from "./bundled-plugin-build-entries.mjs"; +import { collectTrackedBundledPluginSourceCandidates } from "./bundled-plugin-source-utils.mts"; import { isRecord } from "./record-shared.mjs"; type StaticExtensionAsset = { @@ -61,49 +61,12 @@ function listTrackedExtensionPackageDirs(rootDir: string, fsImpl: typeof fs) { if (fsImpl !== fs) { return null; } - const result = spawnSync("git", ["ls-files", "--", ":(glob)extensions/*/package.json"], { - cwd: rootDir, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - if (result.status !== 0) { - return null; - } - const deletedResult = spawnSync( - "git", - ["ls-files", "--deleted", "--", ":(glob)extensions/*/package.json"], - { - cwd: rootDir, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }, + return collectTrackedBundledPluginSourceCandidates(rootDir)?.flatMap( + ({ dirName, pluginDir, packageJsonPath }) => + packageJsonPath + ? [{ dirName, hasPackageJson: true, packageDir: pluginDir, packageJsonPath }] + : [], ); - if (deletedResult.status !== 0) { - return null; - } - const deletedPaths = new Set( - deletedResult.stdout.split("\n").map((line) => toPosixPath(line.trim())), - ); - return result.stdout - .split("\n") - .map((line) => toPosixPath(line.trim())) - .filter((line) => line.length > 0 && !deletedPaths.has(line)) - .flatMap((line) => { - const match = /^extensions\/([^/]+)\/package\.json$/u.exec(line); - if (!match?.[1]) { - return []; - } - const packageDir = path.join(rootDir, "extensions", match[1]); - return [ - { - dirName: match[1], - hasPackageJson: true, - packageDir, - packageJsonPath: path.join(packageDir, "package.json"), - }, - ]; - }) - .toSorted((left, right) => left.dirName.localeCompare(right.dirName)); } function listFilesystemExtensionPackageDirs(rootDir: string, fsImpl: typeof fs) { diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index 8a898cdd2431..d146770fc935 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -176,6 +176,59 @@ describe("runtime postbuild static assets", () => { ]); }); + it("copies each package asset once with multiple Git index stages", async () => { + const rootDir = createTempDir("openclaw-static-assets-index-"); + const git = (args: string[], input?: string) => + childProcess.execFileSync("git", args, { cwd: rootDir, encoding: "utf8", input }); + const pluginIds = ["conflicted", "package-only", "with-manifest"]; + for (const id of pluginIds) { + const pluginDir = path.join(rootDir, "extensions", id); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(path.join(pluginDir, "asset.txt"), `${id} bytes\n`); + await fs.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ + openclaw: { build: { staticAssets: [{ source: "asset.txt", output: "asset.txt" }] } }, + }), + ); + } + for (const id of ["with-manifest", "manifest-only"]) { + const pluginDir = path.join(rootDir, "extensions", id); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(path.join(pluginDir, "openclaw.plugin.json"), "not valid JSON"); + } + git(["init", "-q"]); + git(["add", "extensions"]); + const packagePath = "extensions/conflicted/package.json"; + const blob = git(["hash-object", "-w", "--stdin"], "{}").trim(); + // Keep resolved working-tree metadata while the index still holds all merge stages. + git( + ["update-index", "--index-info"], + [ + `0 ${"0".repeat(blob.length)}\t${packagePath}`, + ...[1, 2, 3].map((stage) => `100644 ${blob} ${stage}\t${packagePath}`), + "", + ].join("\n"), + ); + expect(git(["ls-files", "--", packagePath]).trim().split("\n")).toHaveLength(3); + + const copy = vi.spyOn(fsSync, "copyFileSync"); + try { + copyStaticExtensionAssets({ rootDir }); + expect(copy).toHaveBeenCalledTimes(3); + expect(discoverStaticExtensionAssets({ rootDir }).map(({ pluginDir }) => pluginDir)).toEqual( + pluginIds, + ); + for (const id of pluginIds) { + await expect( + fs.readFile(path.join(rootDir, "dist", "extensions", id, "asset.txt"), "utf8"), + ).resolves.toBe(`${id} bytes\n`); + } + } finally { + copy.mockRestore(); + } + }); + it.each([ { name: "top-level array", packageJson: [] }, { name: "array openclaw section", packageJson: { openclaw: [] } },