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>
This commit is contained in:
RoboClaw 2026-09-16 17:58:43 -07:00 • committed by GitHub
parent 4068854ba5
commit aceff32680
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 61 additions and 44 deletions

View file

@ -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",

View file

@ -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) {

View file

@ -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: [] } },