From d1f74ea57bfe3ce4298a01b46fe256fefe80cd44 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 19 May 2026 20:50:22 -0500 Subject: [PATCH 1/2] fix(publish): preserve PR egg assets --- src/repair/git-publish.ts | 16 +++++++--- test/repair/git-publish.test.ts | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/repair/git-publish.ts b/src/repair/git-publish.ts index 297ee65e3b..a90eabea03 100644 --- a/src/repair/git-publish.ts +++ b/src/repair/git-publish.ts @@ -213,7 +213,7 @@ function syncStatePublishPaths(paths: readonly string[], stateRoot: string): voi if (!destination.startsWith(`${stateRoot}/`) && destination !== stateRoot) { throw new Error(`Refusing to publish outside state root: ${path}`); } - const preserved = preserveStateOnlyAutomergeJobs({ path, source, destination }); + const preserved = preserveStateOnlyFiles({ path, source, destination }); try { rmSync(destination, { force: true, recursive: true }); if (existsSync(source)) { @@ -227,7 +227,7 @@ function syncStatePublishPaths(paths: readonly string[], stateRoot: string): voi } } -function preserveStateOnlyAutomergeJobs({ +function preserveStateOnlyFiles({ path, source, destination, @@ -237,12 +237,12 @@ function preserveStateOnlyAutomergeJobs({ destination: string; }): { root: string; files: string[] } { const root = mkdtempSync(join(tmpdir(), "clawsweeper-state-preserve-")); - if (path !== "jobs" || !existsSync(destination)) return { root, files: [] }; + if (!existsSync(destination)) return { root, files: [] }; const files: string[] = []; for (const file of listFiles(destination)) { const rel = relative(destination, file); - if (!/^[^/]+\/inbox\/automerge-.+\.md$/.test(rel)) continue; + if (!shouldPreserveStateOnlyFile(path, rel)) continue; if (existsSync(resolve(source, rel))) continue; const target = resolve(root, rel); mkdirSync(dirname(target), { recursive: true }); @@ -252,6 +252,14 @@ function preserveStateOnlyAutomergeJobs({ return { root, files }; } +function shouldPreserveStateOnlyFile(path: string, rel: string): boolean { + if (path === "jobs") return /^[^/]+\/inbox\/automerge-.+\.md$/.test(rel); + if (path === "assets/pr-eggs" || path === "assets/pr-eggs/") { + return /^[^/]+\/\d+\.png$/.test(rel); + } + return false; +} + function restorePreservedFiles(preserved: { root: string; files: string[] }, destination: string) { for (const rel of preserved.files) { const source = resolve(preserved.root, rel); diff --git a/test/repair/git-publish.test.ts b/test/repair/git-publish.test.ts index c4713f3c6a..3905748e05 100644 --- a/test/repair/git-publish.test.ts +++ b/test/repair/git-publish.test.ts @@ -280,6 +280,62 @@ test("publishMainCommit preserves state-only automerge jobs on broad jobs publis ); }); +test("publishMainCommit preserves state-only PR egg images on PR egg asset publishes", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-publish-")); + const origin = path.join(root, "origin.git"); + const work = path.join(root, "work"); + const state = path.join(root, "state"); + run("git", ["init", "--bare", origin], root); + run("git", ["clone", origin, state], root); + configureUser(state); + write(path.join(state, "assets/pr-eggs/openclaw-openclaw/84269.png"), "old egg\n"); + write(path.join(state, "assets/pr-eggs/openclaw-openclaw/readme.txt"), "not preserved\n"); + run("git", ["add", "."], state); + run("git", ["commit", "-m", "initial state"], state); + run("git", ["push", "origin", "HEAD:state"], state); + run("git", ["--git-dir", origin, "symbolic-ref", "HEAD", "refs/heads/state"], root); + run("git", ["checkout", "-B", "state", "origin/state"], state); + + fs.mkdirSync(work); + write(path.join(work, "assets/pr-eggs/openclaw-openclaw/84374.png"), "new egg\n"); + + const result = withEnv({ CLAWSWEEPER_STATE_DIR: state }, () => + withCwd(work, () => + publishMainCommit({ + message: "chore: publish PR egg assets", + paths: ["assets/pr-eggs"], + maxAttempts: 1, + pushAttempts: 1, + }), + ), + ); + + assert.equal(result, "committed"); + assert.equal( + run( + "git", + ["--git-dir", origin, "show", "state:assets/pr-eggs/openclaw-openclaw/84269.png"], + root, + ), + "old egg\n", + ); + assert.equal( + run( + "git", + ["--git-dir", origin, "show", "state:assets/pr-eggs/openclaw-openclaw/84374.png"], + root, + ), + "new egg\n", + ); + assert.throws(() => + run( + "git", + ["--git-dir", origin, "show", "state:assets/pr-eggs/openclaw-openclaw/readme.txt"], + root, + ), + ); +}); + test("publish-main CLI accepts package-manager double dash separators", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-publish-")); const origin = path.join(root, "origin.git"); From 7b181b1da470d2ed9fac05db29f3375738d3af2a Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 19 May 2026 20:58:05 -0500 Subject: [PATCH 2/2] fix(publish): preserve egg assets during rebuild --- src/repair/git-publish.ts | 37 ++++++++++++++++++-- test/repair/git-publish.test.ts | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/repair/git-publish.ts b/src/repair/git-publish.ts index a90eabea03..cc0aace4ec 100644 --- a/src/repair/git-publish.ts +++ b/src/repair/git-publish.ts @@ -260,6 +260,31 @@ function shouldPreserveStateOnlyFile(path: string, rel: string): boolean { return false; } +function preserveStateOnlyCommitFiles({ + path, + sourceCommit, +}: { + path: string; + sourceCommit: string; +}): { root: string; files: string[] } { + const root = mkdtempSync(join(tmpdir(), "clawsweeper-state-preserve-")); + const source = resolve(path); + if (!existsSync(source)) return { root, files: [] }; + + const files: string[] = []; + const commitPathPrefix = path.replace(/\/+$/, ""); + for (const file of listFiles(source)) { + const rel = relative(source, file); + if (!shouldPreserveStateOnlyFile(path, rel)) continue; + if (commitHasPath(sourceCommit, `${commitPathPrefix}/${rel}`)) continue; + const target = resolve(root, rel); + mkdirSync(dirname(target), { recursive: true }); + cpSync(file, target); + files.push(rel); + } + return { root, files }; +} + function restorePreservedFiles(preserved: { root: string; files: string[] }, destination: string) { for (const rel of preserved.files) { const source = resolve(preserved.root, rel); @@ -321,9 +346,15 @@ function rebuildPublishCommit(options: { runGit(["reset", "--hard", `${options.remote}/${options.branch}`]); for (const path of uniqueNonEmpty(options.paths)) { - runGit(["rm", "-r", "--ignore-unmatch", "--", path], { allowFailure: true }); - if (commitHasPath(options.sourceCommit, path)) { - runGit(["checkout", options.sourceCommit, "--", path]); + const preserved = preserveStateOnlyCommitFiles({ path, sourceCommit: options.sourceCommit }); + try { + runGit(["rm", "-r", "--ignore-unmatch", "--", path], { allowFailure: true }); + if (commitHasPath(options.sourceCommit, path)) { + runGit(["checkout", options.sourceCommit, "--", path]); + } + restorePreservedFiles(preserved, resolve(path)); + } finally { + rmSync(preserved.root, { force: true, recursive: true }); } } diff --git a/test/repair/git-publish.test.ts b/test/repair/git-publish.test.ts index 3905748e05..c9444413a6 100644 --- a/test/repair/git-publish.test.ts +++ b/test/repair/git-publish.test.ts @@ -187,6 +187,68 @@ test("publishMainCommit rebuilds generated state commits after rebase conflicts" assert.equal(run("git", ["--git-dir", origin, "show", "main:keep.txt"], root), "keep remote\n"); }); +test("publishMainCommit preserves remote-only PR egg images during publish rebuilds", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-publish-")); + const origin = path.join(root, "origin.git"); + const work = path.join(root, "work"); + const other = path.join(root, "other"); + run("git", ["init", "--bare", origin], root); + run("git", ["clone", origin, work], root); + configureUser(work); + write(path.join(work, "results/sweep-status/openclaw-openclaw.json"), '{"state":"base"}\n'); + run("git", ["add", "."], work); + run("git", ["commit", "-m", "initial"], work); + run("git", ["push", "origin", "HEAD:main"], work); + run("git", ["--git-dir", origin, "symbolic-ref", "HEAD", "refs/heads/main"], root); + run("git", ["checkout", "-B", "main", "origin/main"], work); + + run("git", ["clone", origin, other], root); + configureUser(other); + write(path.join(other, "results/sweep-status/openclaw-openclaw.json"), '{"state":"remote"}\n'); + write(path.join(other, "assets/pr-eggs/openclaw-openclaw/84269.png"), "remote egg\n"); + run("git", ["add", "."], other); + run("git", ["commit", "-m", "remote generated state update"], other); + run("git", ["push", "origin", "HEAD:main"], other); + + write(path.join(work, "results/sweep-status/openclaw-openclaw.json"), '{"state":"local"}\n'); + write(path.join(work, "assets/pr-eggs/openclaw-openclaw/84374.png"), "local egg\n"); + + const result = withCwd(work, () => + publishMainCommit({ + message: "chore: update sweep records", + paths: ["results/sweep-status", "assets/pr-eggs"], + maxAttempts: 1, + pushAttempts: 1, + }), + ); + + assert.equal(result, "committed"); + assert.equal( + run( + "git", + ["--git-dir", origin, "show", "main:results/sweep-status/openclaw-openclaw.json"], + root, + ), + '{"state":"local"}\n', + ); + assert.equal( + run( + "git", + ["--git-dir", origin, "show", "main:assets/pr-eggs/openclaw-openclaw/84269.png"], + root, + ), + "remote egg\n", + ); + assert.equal( + run( + "git", + ["--git-dir", origin, "show", "main:assets/pr-eggs/openclaw-openclaw/84374.png"], + root, + ), + "local egg\n", + ); +}); + test("publishMainCommit publishes generated paths to state branch when state root is configured", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-publish-")); const origin = path.join(root, "origin.git");