mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix: preserve unchanged line endings during managed publication (#147618)
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
This commit is contained in:
parent
fc0360dab2
commit
5576f256da
4 changed files with 105 additions and 8 deletions
|
|
@ -61,6 +61,8 @@ OpenClaw `worker-turn` cloud workers receive the effective shared identity per t
|
|||
|
||||
OpenClaw sandboxes, ordinary node-host exec, and Codex `remote-exec` placements still do not receive the Gateway's managed GitHub credentials. The `github_publish` tool remains available for remote-exec sessions: it records a bounded publication request without credentials or repository authority. After the exact workspace result is reconciled and accepted, the Gateway commits remaining changes as the verified effective GitHub user, pushes the authoritative session branch through a one-shot HTTPS credential helper, and creates or reuses a draft pull request.
|
||||
|
||||
Publication stages workspace changes with ordinary Git attribute conversion. It preserves unchanged committed file bytes, including existing CRLF line endings, rather than renormalizing unrelated tracked files.
|
||||
|
||||
Local session-owned worktrees can use the same **Publish PR** action in the Control UI. The Gateway derives the managed worktree, repository, branch, base, and head from current session ownership. It never accepts those authority facts from the browser or model. Publication retries use a durable request ID, an exact commit marker, remote branch observation, and pull-request lookup by head branch so a Gateway restart or lost response does not create duplicate commits, pushes, or pull requests.
|
||||
|
||||
Verification proves which account answered the GitHub API request. Status reports the credential kind, access expiry, refresh availability, OAuth scopes, and Git author while distinguishing missing credentials, unverified transport failures, and GitHub rate limiting without returning `gh` diagnostics. Repository-specific grants remain unknown until an exact repository operation succeeds; `/user` does not prove write access.
|
||||
|
|
|
|||
|
|
@ -237,11 +237,14 @@ export async function captureGitHubPublicationWorkspaceSnapshot(params: {
|
|||
GIT_INDEX_FILE: path.join(tempDir, "index"),
|
||||
};
|
||||
// Preserve staged path inventory, and keep write-tree cache updates off the real index.
|
||||
const indexStat = await step(() => fs.stat(index, { bigint: true }));
|
||||
await step(() => fs.copyFile(index, env.GIT_INDEX_FILE));
|
||||
// A newer copy timestamp hides racy-clean edits. Round down so lost precision only adds reads.
|
||||
const indexTimestamp = Number(indexStat.mtimeNs / 1_000_000_000n);
|
||||
await step(() => fs.utimes(env.GIT_INDEX_FILE, indexTimestamp, indexTimestamp));
|
||||
const sourceIndexTree = await git(["write-tree"], env);
|
||||
// Ordinary staging preserves unchanged blobs; renormalization would rewrite unrelated CRLF files.
|
||||
await git(["-c", `core.attributesFile=${os.devNull}`, "add", "-A"], env);
|
||||
// Normalize after removals, retaining intent-to-add paths and ignoring copied stat caches.
|
||||
await git(["-c", `core.attributesFile=${os.devNull}`, "add", "--renormalize", "-u"], env);
|
||||
const workspaceTree = await git(["write-tree"], env);
|
||||
await step(() =>
|
||||
assertGitHubPublicationTreeHasNoFilters(params.cwd, workspaceTree, runPublicationCommand),
|
||||
|
|
|
|||
|
|
@ -51,16 +51,105 @@ async function fixture(linked: false | "linked" | "linked-config" = false) {
|
|||
cwd = checkout;
|
||||
}
|
||||
const output = path.join(root, "snapshot");
|
||||
const capture = () =>
|
||||
const capture = (baseCommit = base) =>
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
["-e", REMOTE_GITHUB_PUBLICATION_SNAPSHOT_JS, cwd, base, output],
|
||||
["-e", REMOTE_GITHUB_PUBLICATION_SNAPSHOT_JS, cwd, baseCommit, output],
|
||||
{ env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
|
||||
).trim();
|
||||
return { cwd, root, git, base, output, capture, env };
|
||||
}
|
||||
|
||||
describe("repository publication checkpoint capture", () => {
|
||||
it.each(["full", "split", "linked", "linked-config"] as const)(
|
||||
"preserves committed CRLF bytes when capturing clean and edited workspaces (%s index)",
|
||||
async (format) => {
|
||||
const f = await fixture(format === "linked" || format === "linked-config" ? format : false);
|
||||
const file = "gradlew.bat";
|
||||
const content = Buffer.from("@echo off\r\necho unchanged\r\n");
|
||||
await fs.writeFile(path.join(f.cwd, file), content);
|
||||
await fs.writeFile(path.join(f.cwd, ".gitattributes"), "* text=auto eol=lf\n");
|
||||
f.git("add", ".gitattributes");
|
||||
// Model an existing CRLF blob: ordinary add would normalize a new file first.
|
||||
const blob = execFileSync("git", ["hash-object", "-w", "--no-filters", "--stdin"], {
|
||||
cwd: f.cwd,
|
||||
env: f.env,
|
||||
input: content,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
f.git("update-index", "--add", "--cacheinfo", `100644,${blob},${file}`);
|
||||
f.git("commit", "--quiet", "-m", "existing CRLF blob");
|
||||
const head = f.git("rev-parse", "HEAD");
|
||||
const tree = f.git("rev-parse", "HEAD^{tree}");
|
||||
if (format === "split") {
|
||||
f.git("update-index", "--split-index");
|
||||
}
|
||||
expect(f.git("status", "--porcelain")).toBe("");
|
||||
const indexPath = path.resolve(f.cwd, f.git("rev-parse", "--git-path", "index"));
|
||||
const index = await fs.readFile(indexPath);
|
||||
for (const edited of [false, true]) {
|
||||
if (edited) {
|
||||
await fs.writeFile(path.join(f.cwd, "counter.txt"), "changed\r\n");
|
||||
await fs.rm(f.output, { recursive: true });
|
||||
}
|
||||
const local = await captureGitHubPublicationWorkspaceSnapshot({ cwd: f.cwd });
|
||||
const digest = f.capture(head);
|
||||
const { snapshot } = await readGitHubRepositoryPublicationMetadata(f.output, digest);
|
||||
expect(local.sourceHeadCommit).toBe(head);
|
||||
expect(local.sourceIndexTree).toBe(tree);
|
||||
expect(local.workspaceTree).toBe(snapshot.workspaceTree);
|
||||
expect(f.git("rev-parse", `${local.workspaceTree}:${file}`)).toBe(blob);
|
||||
if (edited) {
|
||||
expect(snapshot.entries).toEqual([
|
||||
{ path: "counter.txt", mode: "100644", sha: expect.any(String) },
|
||||
]);
|
||||
expect(
|
||||
await readGitHubRepositoryPublicationBlob(f.output, snapshot.entries[0]!.sha!),
|
||||
).toEqual(Buffer.from("changed\n"));
|
||||
} else {
|
||||
expect(local.workspaceTree).toBe(tree);
|
||||
expect(snapshot.entries).toEqual([]);
|
||||
}
|
||||
expect(await fs.readFile(indexPath)).toEqual(index);
|
||||
expect(await fs.readFile(path.join(f.cwd, file))).toEqual(content);
|
||||
expect(f.git("rev-parse", "HEAD")).toBe(head);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["full", "split", "linked", "linked-config"] as const)(
|
||||
"captures same-size unstaged edits with matching cached timestamps (%s index)",
|
||||
async (format) => {
|
||||
const f = await fixture(format === "linked" || format === "linked-config" ? format : false);
|
||||
f.git("config", "core.trustctime", "false");
|
||||
f.git("config", "core.checkStat", "minimal");
|
||||
const file = path.join(f.cwd, "counter.txt");
|
||||
const timestamp = Math.floor(Date.now() / 1000) - 60;
|
||||
await fs.writeFile(file, "staged\n");
|
||||
await fs.utimes(file, timestamp, timestamp);
|
||||
f.git("add", "counter.txt");
|
||||
if (format === "split") {
|
||||
f.git("update-index", "--split-index");
|
||||
}
|
||||
const stagedTree = f.git("write-tree");
|
||||
const indexPath = path.resolve(f.cwd, f.git("rev-parse", "--git-path", "index"));
|
||||
// Reproduce a coarse-timestamp filesystem without relying on execution timing.
|
||||
await fs.utimes(indexPath, timestamp, timestamp);
|
||||
await fs.writeFile(file, "latest\n");
|
||||
await fs.utimes(file, timestamp, timestamp);
|
||||
const index = await fs.readFile(indexPath);
|
||||
const local = await captureGitHubPublicationWorkspaceSnapshot({ cwd: f.cwd });
|
||||
const digest = f.capture();
|
||||
const { snapshot } = await readGitHubRepositoryPublicationMetadata(f.output, digest);
|
||||
expect(local.sourceIndexTree).toBe(stagedTree);
|
||||
expect(local.workspaceTree).toBe(snapshot.workspaceTree);
|
||||
expect(f.git("show", `${local.workspaceTree}:counter.txt`)).toBe("latest");
|
||||
expect(await fs.readFile(indexPath)).toEqual(index);
|
||||
expect(await fs.readFile(file, "utf8")).toBe("latest\n");
|
||||
expect(f.git("rev-parse", "HEAD")).toBe(f.base);
|
||||
},
|
||||
);
|
||||
|
||||
it("captures a cumulative Git-normalized sparse tree, binary bytes, modes and deletions without changing the worker index", async () => {
|
||||
const f = await fixture();
|
||||
await fs.writeFile(path.join(f.cwd, "counter.txt"), "first\r\n");
|
||||
|
|
@ -105,7 +194,7 @@ describe("repository publication checkpoint capture", () => {
|
|||
});
|
||||
|
||||
it.each(["full", "split", "linked", "linked-config"] as const)(
|
||||
"preserves staged path inventory and index bytes while normalizing the full worktree (%s index)",
|
||||
"preserves staged path inventory and index bytes while staging workspace changes (%s index)",
|
||||
async (format) => {
|
||||
const f = await fixture(format === "linked" || format === "linked-config" ? format : false);
|
||||
await fs.writeFile(path.join(f.cwd, "ignored-added.txt"), "staged\r\n");
|
||||
|
|
|
|||
|
|
@ -77,13 +77,16 @@ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-publication-in
|
|||
try {
|
||||
const index = path.resolve(cwd, text(["rev-parse", "--git-path", "index"]));
|
||||
env.GIT_INDEX_FILE = path.join(temporary, "index");
|
||||
// Keep explicitly staged ignored paths and cached removals; normalize only the copy.
|
||||
// Keep explicitly staged ignored paths and cached removals; stage changes only in the copy.
|
||||
const indexStat = fs.statSync(index, { bigint: true });
|
||||
fs.copyFileSync(index, env.GIT_INDEX_FILE);
|
||||
// A newer copy timestamp hides racy-clean edits. Round down so lost precision only adds reads.
|
||||
const indexTimestamp = Number(indexStat.mtimeNs / 1_000_000_000n);
|
||||
fs.utimesSync(env.GIT_INDEX_FILE, indexTimestamp, indexTimestamp);
|
||||
// Unresolved merge stages cannot define an accepted tree.
|
||||
git(["write-tree"]);
|
||||
// Ordinary staging preserves unchanged blobs; renormalization would rewrite unrelated CRLF files.
|
||||
git(["add", "-A"]);
|
||||
// Normalize after removals, retaining intent-to-add paths and ignoring copied stat caches.
|
||||
git(["add", "--renormalize", "-u"]);
|
||||
const workspaceTree = text(["write-tree"]);
|
||||
const baseTree = text(["rev-parse", baseCommit + "^{tree}"]);
|
||||
const attributes = git(["ls-tree", "-r", "-z", "--full-tree", workspaceTree]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue