Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions src/repair/git-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -227,7 +227,7 @@ function syncStatePublishPaths(paths: readonly string[], stateRoot: string): voi
}
}

function preserveStateOnlyAutomergeJobs({
function preserveStateOnlyFiles({
path,
source,
destination,
Expand All @@ -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 });
Expand All @@ -252,6 +252,39 @@ 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 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);
Expand Down Expand Up @@ -313,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 });
}
}

Expand Down
118 changes: 118 additions & 0 deletions test/repair/git-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -280,6 +342,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");
Expand Down