From d270879c4b55d2c1946afdb259910ef9831db315 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 25 May 2026 12:56:52 +0200 Subject: [PATCH] fix(scripts): restore sparse crabbox changed gates --- CHANGELOG.md | 1 + scripts/crabbox-wrapper.mjs | 100 +++++++++++++++++++----- test/scripts/crabbox-wrapper.test.ts | 111 +++++++++++++++++++++++++-- 3 files changed, 185 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb85a779009..9479a12d582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Cron: accept leading-plus relative durations such as `+5m` for one-shot `--at` schedules. (#86341) Thanks @mushuiyu886. - Agents/media: preserve async-started media tool metadata so background generation starts no longer surface generic incomplete-turn warnings while replay stays unsafe. (#85933) Thanks @fuller-stack-dev. - Docker E2E: dedupe scheduler lane resources so npm/service package lanes are not over-counted and serialized unnecessarily. +- Crabbox: bootstrap Git metadata for sparse remote changed gates so raw synced workspaces can run `pnpm check:changed` from the intended diff. - xAI/LM Studio: avoid buffering ordinary bracketed or `final` prose until stream completion while watching for plain-text tool-call fallbacks. - Discord: suppress a bot's previous reply body and referenced media from prompt context when a user replies to that bot message, while keeping reply metadata for routing. (#86238) Thanks @fuller-stack-dev. - Docker E2E: avoid rebuilding the Control UI twice while preparing the shared OpenClaw package tarball for package-backed scenario runs. diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index f7a7479326d..881354aa12f 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -10,7 +10,10 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repoLocal = resolveCrabboxBinary(process.env, process.platform); const pathLocal = resolvePathBinary("crabbox", process.env, process.platform); const binary = - repoLocal ?? pathLocal ?? resolveGitCommonCrabboxBinary(process.env, process.platform) ?? "crabbox"; + repoLocal ?? + pathLocal ?? + resolveGitCommonCrabboxBinary(process.env, process.platform) ?? + "crabbox"; const args = process.argv.slice(2); if (args[0] === "--") { @@ -470,6 +473,21 @@ function absolutizeLocalRunPaths(commandArgs) { return normalizedArgs; } +function shellQuote(value) { + const text = `${value}`; + if (text === "") { + return "''"; + } + if (/^[A-Za-z0-9_./:=@%+-]+$/u.test(text)) { + return text; + } + return `'${text.replaceAll("'", "'\\''")}'`; +} + +function shellJoin(commandArgs) { + return commandArgs.map(shellQuote).join(" "); +} + function isLocalContainerProvider(providerName) { return ["local-container", "docker", "container", "local-docker"].includes(providerName); } @@ -511,22 +529,62 @@ function isChangedGateCommand(commandArgs) { ); } -function headInRemoteRefs() { - const refs = gitOutput([ - "for-each-ref", - "--contains", - "HEAD", - "--format=%(refname)", - "refs/remotes", - ]); - return refs.status === 0 && refs.stdout !== ""; -} - function mergeBaseForChangedGate() { const base = gitOutput(["merge-base", "origin/main", "HEAD"]); return base.status === 0 && base.stdout ? base.stdout : "origin/main"; } +function remoteGitBootstrapForChangedGate(changedGateBase) { + const quotedBase = shellQuote(changedGateBase); + return [ + "if ! git rev-parse --git-dir >/dev/null 2>&1; then", + "git init -q;", + "git remote add origin https://github.com/openclaw/openclaw.git 2>/dev/null || git remote set-url origin https://github.com/openclaw/openclaw.git;", + `git fetch -q --depth=1 origin ${quotedBase}:refs/remotes/origin/main;`, + "git reset --mixed --quiet refs/remotes/origin/main;", + "git add -A;", + "if ! git diff --cached --quiet; then git -c user.name=OpenClaw -c user.email=ci@openclaw.local commit -q --no-gpg-sign -m remote-changed-gate-tree; fi;", + "fi", + ].join(" "); +} + +function isWindowsRemoteTarget(commandArgs) { + return ( + optionValue(commandArgs, "--target") === "windows" || hasOption(commandArgs, "--windows-mode") + ); +} + +function injectRemoteChangedGateGitBootstrap(commandArgs, changedGateBase) { + if (!changedGateBase || commandArgs[0] !== "run" || isWindowsRemoteTarget(commandArgs)) { + return commandArgs; + } + + const { start, optionEnd } = runCommandBounds(commandArgs); + if (start < 0) { + return commandArgs; + } + + const normalizedArgs = [...commandArgs]; + const remoteCommand = normalizedArgs.slice(start); + const originalShellCommand = + hasOption(normalizedArgs, "--shell") && remoteCommand.length === 1 + ? remoteCommand[0] + : shellJoin(remoteCommand); + const shellCommand = `${remoteGitBootstrapForChangedGate(changedGateBase)} && ${originalShellCommand}`; + + if (!hasOption(normalizedArgs, "--shell")) { + normalizedArgs.splice(optionEnd, 0, "--shell"); + } + + const updatedBounds = runCommandBounds(normalizedArgs); + normalizedArgs.splice( + updatedBounds.start, + normalizedArgs.length - updatedBounds.start, + shellCommand, + ); + return normalizedArgs; +} + function isSparseCheckout() { const config = gitOutput(["config", "--bool", "core.sparseCheckout"]); if (config.status === 0 && config.stdout === "true") { @@ -544,10 +602,7 @@ function shouldUseFullCheckoutForCleanSparseRemoteSync(commandArgs, providerName if (commandArgs[0] !== "run" || isLocalContainerProvider(providerName)) { return false; } - if ( - hasOption(commandArgs, "--no-sync") || - hasOption(commandArgs, "--id") - ) { + if (hasOption(commandArgs, "--no-sync") || hasOption(commandArgs, "--id")) { return false; } @@ -738,13 +793,14 @@ if (provider === "blacksmith-testbox") { let childCwd = repoRoot; let cleanupChildCwd = () => {}; let cleanupDone = false; +let remoteChangedGateBase = ""; if (shouldUseFullCheckoutForCleanSparseRemoteSync(normalizedArgs, provider)) { const runWords = runCommandArgs(normalizedArgs); - const changedGateBase = - isChangedGateCommand(runWords) && !headInRemoteRefs() ? mergeBaseForChangedGate() : ""; + const changedGateBase = isChangedGateCommand(runWords) ? mergeBaseForChangedGate() : ""; const checkout = prepareFullCheckoutForSync({ changedGateBase }); childCwd = checkout.dir; cleanupChildCwd = () => checkout.cleanup(); + remoteChangedGateBase = checkout.changedGateBase; console.error( `[crabbox] sparse clean checkout detected; syncing from temporary full checkout ${checkout.dir}`, ); @@ -797,7 +853,13 @@ if ( ); } -const childArgs = childCwd === repoRoot ? normalizedArgs : absolutizeLocalRunPaths(normalizedArgs); +const childArgs = + childCwd === repoRoot + ? normalizedArgs + : injectRemoteChangedGateGitBootstrap( + absolutizeLocalRunPaths(normalizedArgs), + remoteChangedGateBase, + ); const childInvocation = spawnInvocation(binary, childArgs, childEnv, process.platform); const child = spawn(childInvocation.command, childInvocation.args, { cwd: childCwd, diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index f7ccdc75dce..0aecd8eb797 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -40,7 +40,9 @@ function writeFakeCrabbox(binDir: string, helpText: string): string { return crabboxPath; } -function makeFakeGit(responses: Record): string { +function makeFakeGit( + responses: Record, +): string { const binDir = mkdtempSync(path.join(tmpdir(), "openclaw-fake-git-")); tempDirs.push(binDir); const gitPath = path.join(binDir, "git"); @@ -91,7 +93,10 @@ function runWrapper( }); } -function parseFakeCrabboxOutput(result: ReturnType): { args: string[]; cwd: string } { +function parseFakeCrabboxOutput(result: ReturnType): { + args: string[]; + cwd: string; +} { return JSON.parse(result.stdout.trim()) as { args: string[]; cwd: string }; } @@ -261,11 +266,9 @@ describe("scripts/crabbox-wrapper", () => { const staleBinDir = mkdtempSync(path.join(tmpdir(), "openclaw-stale-crabbox-")); tempDirs.push(staleBinDir); writeFileSync(path.join(staleBinDir, "crabbox"), "not executable\n", "utf8"); - const result = runWrapper( - "provider: aws\n", - ["run", "--provider", "aws", "--", "echo ok"], - { extraPathEntries: [staleBinDir] }, - ); + const result = runWrapper("provider: aws\n", ["run", "--provider", "aws", "--", "echo ok"], { + extraPathEntries: [staleBinDir], + }); expect(result.status).toBe(0); expect(parseFakeCrabboxOutput(result).args).toContain("aws"); @@ -411,6 +414,96 @@ describe("scripts/crabbox-wrapper", () => { expect(parseFakeCrabboxOutput(result).cwd).toContain("openclaw-crabbox-sync-"); }); + it("bootstraps Git metadata for sparse changed gates on remote raw syncs", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--", "corepack", "pnpm", "check:changed"], + { + gitResponses: { + ["config\u0000--bool\u0000core.sparseCheckout"]: { stdout: "true\n" }, + ["status\u0000--porcelain=v1"]: { stdout: "" }, + ["merge-base\u0000origin/main\u0000HEAD"]: { stdout: "abc123\n" }, + }, + }, + ); + + const output = parseFakeCrabboxOutput(result); + const remoteCommand = output.args.at(-1) ?? ""; + expect(result.status).toBe(0); + expect(output.args).toContain("--shell"); + expect(remoteCommand).toContain("git init -q"); + expect(remoteCommand).toContain( + "git fetch -q --depth=1 origin abc123:refs/remotes/origin/main", + ); + expect(remoteCommand).toContain("git reset --mixed --quiet refs/remotes/origin/main"); + expect(remoteCommand).toContain("git add -A"); + expect(remoteCommand).toContain("git diff --cached --quiet"); + expect(remoteCommand).toContain("commit -q --no-gpg-sign -m remote-changed-gate-tree"); + expect(remoteCommand).toMatch(/&& corepack pnpm check:changed$/u); + }); + + it("preserves existing shell changed-gate commands after remote Git bootstrap", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--shell", "--", "env CI=1 pnpm check:changed"], + { + gitResponses: { + ["config\u0000--bool\u0000core.sparseCheckout"]: { stdout: "true\n" }, + ["status\u0000--porcelain=v1"]: { stdout: "" }, + ["merge-base\u0000origin/main\u0000HEAD"]: { stdout: "abc123\n" }, + }, + }, + ); + + const output = parseFakeCrabboxOutput(result); + const remoteCommand = output.args.at(-1) ?? ""; + expect(result.status).toBe(0); + expect(output.args.filter((arg) => arg === "--shell")).toHaveLength(1); + expect(remoteCommand).toContain( + "git fetch -q --depth=1 origin abc123:refs/remotes/origin/main", + ); + expect(remoteCommand).toMatch(/&& env CI=1 pnpm check:changed$/u); + }); + + it("does not inject the POSIX changed-gate bootstrap for Windows targets", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + [ + "run", + "--provider", + "aws", + "--target", + "windows", + "--", + "corepack", + "pnpm", + "check:changed", + ], + { + gitResponses: { + ["config\u0000--bool\u0000core.sparseCheckout"]: { stdout: "true\n" }, + ["status\u0000--porcelain=v1"]: { stdout: "" }, + ["merge-base\u0000origin/main\u0000HEAD"]: { stdout: "abc123\n" }, + }, + }, + ); + + const output = parseFakeCrabboxOutput(result); + expect(result.status).toBe(0); + expect(output.args).not.toContain("--shell"); + expect(output.args).toEqual([ + "run", + "--provider", + "aws", + "--target", + "windows", + "--", + "corepack", + "pnpm", + "check:changed", + ]); + }); + it("keeps clean sparse local-container syncs on the original checkout", () => { const result = runWrapper( "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", @@ -508,7 +601,9 @@ describe("scripts/crabbox-wrapper", () => { const output = parseFakeCrabboxOutput(result); expect(result.status).toBe(0); expect(output.cwd).toContain("openclaw-crabbox-sync-"); - expect(output.args).toContain(`--capture-stdout=${path.join(repoRoot, ".artifacts/stdout.log")}`); + expect(output.args).toContain( + `--capture-stdout=${path.join(repoRoot, ".artifacts/stdout.log")}`, + ); expect(output.args).toContain(path.join(repoRoot, ".artifacts/stderr.log")); expect(output.args).toContain(`/tmp/proof=${path.join(repoRoot, ".artifacts/proof")}`); });