From c0713324d0a9c0a4bb601cca61c4ed0ec39d116c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 30 Jul 2026 06:25:49 +0000
Subject: [PATCH 1/4] Initial plan
From 8e5c132a7213b3425342fc2bd70d6f0830089239 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 30 Jul 2026 07:11:57 +0000
Subject: [PATCH 2/4] test: add regression tests for validation/push file-set
parity in create_pull_request
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
...ll_request_validation_push_parity.test.cjs | 382 ++++++++++++++++++
1 file changed, 382 insertions(+)
create mode 100644 actions/setup/js/create_pull_request_validation_push_parity.test.cjs
diff --git a/actions/setup/js/create_pull_request_validation_push_parity.test.cjs b/actions/setup/js/create_pull_request_validation_push_parity.test.cjs
new file mode 100644
index 00000000000..bbe1d1ff84c
--- /dev/null
+++ b/actions/setup/js/create_pull_request_validation_push_parity.test.cjs
@@ -0,0 +1,382 @@
+/**
+ * Regression tests for create_pull_request validation/push file-set parity.
+ *
+ * Root cause (tracked in GitHub issue github/gh-aw#48999):
+ * Validation and push do not operate on the same object, so a passing patch
+ * validation does not reliably predict what files land on the remote.
+ *
+ * Two failure modes are tested:
+ *
+ * 1. Excluded-files path (non-rewrite):
+ * excluded-files patterns are applied to the patch but NOT to the bundle.
+ * The pushed commit therefore contains more files than the validated patch.
+ *
+ * 2. Merge-commit rewrite path:
+ * After applyBundleToBranch, linearizeRangeAsCommit performs a soft-reset
+ * to origin/base and re-commits ALL staged files — including excluded ones.
+ * The rewritten commit again contains more files than the validated patch.
+ *
+ * Both regression tests assert: file_set(patch) == file_set(pushed_commit).
+ *
+ * These tests are written against the CORRECT behaviour.
+ * They FAIL on pre-fix code and PASS after companion fixes land.
+ * See github/gh-aw#48999 for companion fix tracking.
+ */
+
+import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
+import { createRequire } from "module";
+import { fileURLToPath } from "url";
+import fs from "fs";
+import os from "os";
+import path from "path";
+import { spawnSync } from "child_process";
+
+const require = createRequire(import.meta.url);
+const promptsSourceDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md");
+
+global.core = {
+ debug: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ warning: vi.fn(),
+};
+
+/**
+ * `create_pull_request.cjs` reads the disclosure-header prompt template at
+ * module load time. Ensure the file is present before the first `require` so
+ * the module can be loaded successfully.
+ */
+function ensureDisclosureHeaderPrompt() {
+ const promptsDir = path.join(process.env.RUNNER_TEMP || os.tmpdir(), "gh-aw", "prompts");
+ fs.mkdirSync(promptsDir, { recursive: true });
+ fs.copyFileSync(path.join(promptsSourceDir, "safe_outputs_disclosure_header.md"), path.join(promptsDir, "safe_outputs_disclosure_header.md"));
+}
+
+// ─── git helpers ─────────────────────────────────────────────────────────────
+
+function execGit(args, options = {}) {
+ const result = spawnSync("git", args, { encoding: "utf8", ...options });
+ if (result.error) throw result.error;
+ if (result.status !== 0 && !options.allowFailure) {
+ throw new Error(`git ${args.join(" ")} failed:\n${result.stderr}`);
+ }
+ return result;
+}
+
+function createBareRepo(prefix) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+ execGit(["init", "--bare", "-b", "main"], { cwd: dir });
+ return dir;
+}
+
+function cloneRepo(remoteUrl, targetDir) {
+ execGit(["clone", remoteUrl, "."], { cwd: targetDir });
+ execGit(["config", "user.name", "Test"], { cwd: targetDir });
+ execGit(["config", "user.email", "test@example.com"], { cwd: targetDir });
+}
+
+function createExecApi(cwd) {
+ return {
+ async exec(command, args = []) {
+ if (command !== "git") throw new Error(`unexpected command: ${command}`);
+ const result = execGit(args, { cwd, allowFailure: true });
+ if (result.status !== 0) throw new Error(result.stderr || result.stdout);
+ return result.status;
+ },
+ async getExecOutput(command, args = [], options = {}) {
+ if (command !== "git") throw new Error(`unexpected command: ${command}`);
+ const result = execGit(args, { cwd, allowFailure: true });
+ if (result.status !== 0 && !options.ignoreReturnCode) {
+ throw new Error(result.stderr || result.stdout);
+ }
+ return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
+ },
+ };
+}
+
+// ─── file-set extraction helpers ─────────────────────────────────────────────
+
+/**
+ * Extract the sorted array of unique file paths touched by a patch.
+ * Parses `diff --git` headers; works for additions, modifications, and deletions.
+ * @param {string} patchContent
+ * @returns {string[]}
+ */
+function fileListFromPatch(patchContent) {
+ const { extractDiffGitHeaderEntries } = require("./patch_path_helpers.cjs");
+ const entries = extractDiffGitHeaderEntries(patchContent);
+ const files = new Set();
+ for (const entry of entries) {
+ if (entry.newPath) files.add(entry.newPath);
+ if (entry.oldPath) files.add(entry.oldPath);
+ }
+ return [...files].sort();
+}
+
+/**
+ * Return the sorted list of files changed between a base ref and HEAD via
+ * `git diff --name-only ..HEAD`.
+ * @param {string} cwd
+ * @param {string} baseRef e.g. "origin/main"
+ * @returns {string[]}
+ */
+function fileListFromPushedCommit(cwd, baseRef) {
+ const { stdout } = execGit(["diff", "--name-only", `${baseRef}..HEAD`], { cwd });
+ return stdout
+ .split("\n")
+ .map(f => f.trim())
+ .filter(Boolean)
+ .sort();
+}
+
+// ─── tests ───────────────────────────────────────────────────────────────────
+
+describe("create_pull_request – validation/push file-set parity", () => {
+ const tempDirs = [];
+ const createdArtifacts = [];
+
+ beforeAll(() => {
+ // create_pull_request.cjs reads the disclosure-header prompt at load time;
+ // set it up once before any require() calls.
+ ensureDisclosureHeaderPrompt();
+ });
+
+ afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ for (const p of createdArtifacts.splice(0)) {
+ try {
+ fs.rmSync(p, { force: true });
+ } catch {
+ // best-effort cleanup
+ }
+ }
+ vi.clearAllMocks();
+ });
+
+ /**
+ * Positive sanity check: when no files are excluded the patch file set and
+ * the pushed-commit file set are identical. This should always pass
+ * regardless of the companion fixes.
+ */
+ it("sanity – no excluded files: patch and pushed commit contain the same files", async () => {
+ const { generateGitPatch } = require("./generate_git_patch.cjs");
+ const { generateGitBundle } = require("./generate_git_bundle.cjs");
+ const { applyBundleToBranch } = require("./create_pull_request.cjs");
+
+ const branchName = "parity-sanity-no-exclusions";
+
+ const bareRemote = createBareRepo("parity-sanity-bare-");
+ const agentRepo = fs.mkdtempSync(path.join(os.tmpdir(), "parity-sanity-agent-"));
+ tempDirs.push(bareRemote, agentRepo);
+ cloneRepo(bareRemote, agentRepo);
+
+ // Initial commit on main
+ fs.writeFileSync(path.join(agentRepo, "README.md"), "base\n");
+ execGit(["add", "README.md"], { cwd: agentRepo });
+ execGit(["commit", "-m", "init"], { cwd: agentRepo });
+ execGit(["push", "-u", "origin", "main"], { cwd: agentRepo });
+
+ // Feature branch: add one file
+ execGit(["checkout", "-b", branchName], { cwd: agentRepo });
+ fs.writeFileSync(path.join(agentRepo, "main_file.txt"), "agent change\n");
+ execGit(["add", "main_file.txt"], { cwd: agentRepo });
+ execGit(["commit", "-m", "feat: add main_file"], { cwd: agentRepo });
+
+ // Generate patch (no exclusions)
+ const patchResult = await generateGitPatch(branchName, "main", { cwd: agentRepo });
+ expect(patchResult.success, `patch generation failed: ${JSON.stringify(patchResult)}`).toBe(true);
+ createdArtifacts.push(patchResult.patchPath);
+
+ // Generate bundle
+ const bundleResult = await generateGitBundle(branchName, "main", { cwd: agentRepo });
+ expect(bundleResult.success, `bundle generation failed: ${bundleResult.error}`).toBe(true);
+ createdArtifacts.push(bundleResult.bundlePath);
+
+ // Apply bundle to a fresh safe-outputs checkout
+ const safeOutputsRepo = fs.mkdtempSync(path.join(os.tmpdir(), "parity-sanity-so-"));
+ tempDirs.push(safeOutputsRepo);
+ cloneRepo(bareRemote, safeOutputsRepo);
+ execGit(["checkout", "-b", branchName], { cwd: safeOutputsRepo });
+ await applyBundleToBranch(bundleResult.bundlePath, branchName, "", createExecApi(safeOutputsRepo));
+
+ // Compare file sets
+ const patchContent = fs.readFileSync(patchResult.patchPath, "utf8");
+ const fromPatch = fileListFromPatch(patchContent);
+ const fromPush = fileListFromPushedCommit(safeOutputsRepo, "origin/main");
+
+ expect(fromPatch, "patch should contain main_file.txt").toEqual(["main_file.txt"]);
+ expect(fromPush, "pushed commit should match patch file set").toEqual(fromPatch);
+ });
+
+ /**
+ * Regression test — excluded-files / non-rewrite path.
+ *
+ * The patch is generated with `excludedFiles` so it does NOT contain
+ * `excluded_file.txt`. The bundle currently includes ALL file changes.
+ * After applying the bundle the pushed commit MUST NOT contain
+ * `excluded_file.txt`.
+ *
+ * This test FAILS on pre-fix code because the bundle always includes the
+ * excluded file. It passes after the companion fix ensures that the
+ * pushed commit matches the validated patch file set.
+ */
+ it("non-rewrite path: excluded files are absent from the pushed commit", async () => {
+ const { generateGitPatch } = require("./generate_git_patch.cjs");
+ const { generateGitBundle } = require("./generate_git_bundle.cjs");
+ const { applyBundleToBranch } = require("./create_pull_request.cjs");
+
+ const branchName = "parity-excl-no-rewrite";
+
+ const bareRemote = createBareRepo("parity-excl-bare-");
+ const agentRepo = fs.mkdtempSync(path.join(os.tmpdir(), "parity-excl-agent-"));
+ tempDirs.push(bareRemote, agentRepo);
+ cloneRepo(bareRemote, agentRepo);
+
+ // Initial commit on main
+ fs.writeFileSync(path.join(agentRepo, "README.md"), "base\n");
+ execGit(["add", "README.md"], { cwd: agentRepo });
+ execGit(["commit", "-m", "init"], { cwd: agentRepo });
+ execGit(["push", "-u", "origin", "main"], { cwd: agentRepo });
+
+ // Feature branch: modify both a regular file and an excluded file in one commit
+ execGit(["checkout", "-b", branchName], { cwd: agentRepo });
+ fs.writeFileSync(path.join(agentRepo, "main_file.txt"), "agent change\n");
+ fs.writeFileSync(path.join(agentRepo, "excluded_file.txt"), "secret content\n");
+ execGit(["add", "main_file.txt", "excluded_file.txt"], { cwd: agentRepo });
+ execGit(["commit", "-m", "feat: add both files"], { cwd: agentRepo });
+
+ // Generate patch: excluded_file.txt must be absent from the patch
+ const patchResult = await generateGitPatch(branchName, "main", {
+ cwd: agentRepo,
+ excludedFiles: ["excluded_file.txt"],
+ });
+ expect(patchResult.success, `patch generation failed: ${JSON.stringify(patchResult)}`).toBe(true);
+ createdArtifacts.push(patchResult.patchPath);
+
+ // Verify the patch indeed excludes excluded_file.txt
+ const patchContent = fs.readFileSync(patchResult.patchPath, "utf8");
+ const fromPatch = fileListFromPatch(patchContent);
+ expect(fromPatch, "patch should contain only main_file.txt").toEqual(["main_file.txt"]);
+
+ // Generate bundle (currently does NOT honour excludedFiles)
+ const bundleResult = await generateGitBundle(branchName, "main", { cwd: agentRepo });
+ expect(bundleResult.success, `bundle generation failed: ${bundleResult.error}`).toBe(true);
+ createdArtifacts.push(bundleResult.bundlePath);
+
+ // Apply bundle to a fresh safe-outputs checkout
+ const safeOutputsRepo = fs.mkdtempSync(path.join(os.tmpdir(), "parity-excl-so-"));
+ tempDirs.push(safeOutputsRepo);
+ cloneRepo(bareRemote, safeOutputsRepo);
+ execGit(["checkout", "-b", branchName], { cwd: safeOutputsRepo });
+ await applyBundleToBranch(bundleResult.bundlePath, branchName, "", createExecApi(safeOutputsRepo));
+
+ // REGRESSION ASSERTION: the pushed commit must contain the same files as the patch.
+ // On pre-fix code this fails because the bundle (and therefore the pushed commit)
+ // contains excluded_file.txt even though the patch does not.
+ const fromPush = fileListFromPushedCommit(safeOutputsRepo, "origin/main");
+ expect(fromPush, "pushed commit should match patch file set (excluded_file.txt must not be pushed)").toEqual(fromPatch);
+ });
+
+ /**
+ * Regression test — merge-commit rewrite path.
+ *
+ * After a bundle with merge commits is applied, `linearizeRangeAsCommit` is
+ * called to collapse the branch history to a single commit (this is the path
+ * taken by `rewriteBundleBranchAsSingleCommit` inside `create_pull_request`
+ * when signed push refuses merge-commit topology).
+ *
+ * The soft-reset used during linearisation stages ALL files that differ from
+ * `origin/base` — including excluded ones. The resulting commit therefore
+ * contains more files than the validated patch.
+ *
+ * This test FAILS on pre-fix code because `linearizeRangeAsCommit` does not
+ * receive the `excludedFiles` list and therefore does not un-stage them.
+ * It passes after the companion fix propagates the excluded-files list through
+ * the rewrite path.
+ */
+ it("merge-commit rewrite path: rewritten commit file set matches validated patch", async () => {
+ const { generateGitPatch } = require("./generate_git_patch.cjs");
+ const { generateGitBundle } = require("./generate_git_bundle.cjs");
+ const { applyBundleToBranch } = require("./create_pull_request.cjs");
+ const { linearizeRangeAsCommit } = require("./git_helpers.cjs");
+
+ const branchName = "parity-excl-rewrite";
+
+ const bareRemote = createBareRepo("parity-rewrite-bare-");
+ const agentRepo = fs.mkdtempSync(path.join(os.tmpdir(), "parity-rewrite-agent-"));
+ tempDirs.push(bareRemote, agentRepo);
+ cloneRepo(bareRemote, agentRepo);
+
+ // Initial commit on main
+ fs.writeFileSync(path.join(agentRepo, "README.md"), "base\n");
+ execGit(["add", "README.md"], { cwd: agentRepo });
+ execGit(["commit", "-m", "init"], { cwd: agentRepo });
+ execGit(["push", "-u", "origin", "main"], { cwd: agentRepo });
+
+ // Feature branch: modify both a regular file and an excluded file
+ execGit(["checkout", "-b", branchName], { cwd: agentRepo });
+ fs.writeFileSync(path.join(agentRepo, "main_file.txt"), "agent change\n");
+ fs.writeFileSync(path.join(agentRepo, "excluded_file.txt"), "secret content\n");
+ execGit(["add", "main_file.txt", "excluded_file.txt"], { cwd: agentRepo });
+ execGit(["commit", "-m", "feat: add files"], { cwd: agentRepo });
+
+ // Simulate base-branch drift: a collaborator pushes to main after the agent started
+ execGit(["checkout", "main"], { cwd: agentRepo });
+ fs.writeFileSync(path.join(agentRepo, "drift.txt"), "collaborator change\n");
+ execGit(["add", "drift.txt"], { cwd: agentRepo });
+ execGit(["commit", "-m", "chore: drift"], { cwd: agentRepo });
+ execGit(["push", "origin", "main"], { cwd: agentRepo });
+
+ // Agent reconciles: merges updated main into feature branch, creating a merge commit
+ execGit(["checkout", branchName], { cwd: agentRepo });
+ execGit(["merge", "--no-ff", "main", "-m", "reconcile: merge main"], { cwd: agentRepo });
+
+ // Verify the topology: the feature branch must contain at least one merge commit
+ const mergeCount = Number(execGit(["rev-list", "--count", "--merges", `main..${branchName}`], { cwd: agentRepo }).stdout.trim());
+ expect(mergeCount, "feature branch should contain at least one merge commit").toBeGreaterThanOrEqual(1);
+
+ // Generate patch: excluded_file.txt must be absent
+ // The patch covers commits between merge-base(origin/main, feature) and the branch tip.
+ // Because format-patch ignores merge commits by default, the patch only reflects
+ // the non-merge commits on the feature branch (i.e. the agent's own work).
+ const patchResult = await generateGitPatch(branchName, "main", {
+ cwd: agentRepo,
+ excludedFiles: ["excluded_file.txt"],
+ });
+ expect(patchResult.success, `patch generation failed: ${JSON.stringify(patchResult)}`).toBe(true);
+ createdArtifacts.push(patchResult.patchPath);
+
+ // Verify the patch indeed excludes excluded_file.txt
+ const patchContent = fs.readFileSync(patchResult.patchPath, "utf8");
+ const fromPatch = fileListFromPatch(patchContent);
+ expect(fromPatch, "patch should contain only main_file.txt").toEqual(["main_file.txt"]);
+
+ // Generate bundle (includes all commits and all files, no exclusion)
+ const bundleResult = await generateGitBundle(branchName, "main", { cwd: agentRepo });
+ expect(bundleResult.success, `bundle generation failed: ${bundleResult.error}`).toBe(true);
+ createdArtifacts.push(bundleResult.bundlePath);
+
+ // Apply bundle to a fresh safe-outputs checkout.
+ // The safe-outputs repo is cloned AFTER the drift commit was pushed, so
+ // origin/main already includes the collaborator's change.
+ const safeOutputsRepo = fs.mkdtempSync(path.join(os.tmpdir(), "parity-rewrite-so-"));
+ tempDirs.push(safeOutputsRepo);
+ cloneRepo(bareRemote, safeOutputsRepo);
+ execGit(["checkout", "-b", branchName], { cwd: safeOutputsRepo });
+ await applyBundleToBranch(bundleResult.bundlePath, branchName, "", createExecApi(safeOutputsRepo));
+
+ // Simulate the merge-commit rewrite path: linearise the bundle commits into a
+ // single commit on top of origin/main. In production this is triggered by
+ // pushSignedCommits refusing merge-commit topology, causing create_pull_request
+ // to call rewriteBundleBranchAsSingleCommit → linearizeRangeAsCommit.
+ await linearizeRangeAsCommit("origin/main", "apply bundled changes", createExecApi(safeOutputsRepo));
+
+ // REGRESSION ASSERTION: the rewritten commit must contain the same files as the patch.
+ // On pre-fix code this fails because linearizeRangeAsCommit stages ALL files that
+ // differ from origin/main (including excluded_file.txt) and commits them all.
+ const fromPush = fileListFromPushedCommit(safeOutputsRepo, "origin/main");
+ expect(fromPush, "rewritten commit should match patch file set (excluded_file.txt must not be committed)").toEqual(fromPatch);
+ });
+});
From a443702f3d748336fa8b757866561b3c63db5c22 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 30 Jul 2026 10:58:07 +0000
Subject: [PATCH 3/4] fix parity for filtered bundle push path
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
actions/setup/js/create_pull_request.cjs | 9 ++-
...ll_request_validation_push_parity.test.cjs | 18 ++++--
actions/setup/js/generate_git_bundle.cjs | 60 ++++++++++++++-----
actions/setup/js/git_helpers.cjs | 8 ++-
actions/setup/js/safe_outputs_handlers.cjs | 6 ++
5 files changed, 77 insertions(+), 24 deletions(-)
diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs
index b91abbb1937..b56c1ddd6e1 100644
--- a/actions/setup/js/create_pull_request.cjs
+++ b/actions/setup/js/create_pull_request.cjs
@@ -334,9 +334,10 @@ async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBran
* @param {{ exec: Function, getExecOutput: Function }} execApi
* @param {string} [bundleFilePath] - Optional path to the bundle file; used to extract the
* precise base commit the agent worked from.
+ * @param {{ excludedFiles?: string[] }} [options]
* @returns {Promise}
*/
-async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi, bundleFilePath) {
+async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi, bundleFilePath, options = {}) {
const fallbackBaseRef = `origin/${baseBranch}`;
let baseRef = fallbackBaseRef;
@@ -382,7 +383,7 @@ async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi, bundleFile
}
core.warning(`Rewriting bundled commits to a single linear commit for signed push compatibility (base: ${baseRef})`);
- const newHead = await linearizeRangeAsCommit(baseRef, commitHeadline, execApi);
+ const newHead = await linearizeRangeAsCommit(baseRef, commitHeadline, execApi, { excludedFiles: options.excludedFiles });
core.info(`Bundle rewrite completed (new HEAD: ${newHead})`);
}
@@ -1717,7 +1718,9 @@ async function main(config = {}) {
if (isSignedMergeReplayRefusal) {
core.warning("Signed push rejected merge commit topology from bundle; rewriting branch and retrying signed push");
try {
- await rewriteBundleBranchAsSingleCommit(baseBranch, exec, bundleFilePath);
+ await rewriteBundleBranchAsSingleCommit(baseBranch, exec, bundleFilePath, {
+ excludedFiles: Array.isArray(config.excluded_files) ? config.excluded_files : [],
+ });
const runRetryPush = async () =>
pushSignedCommits({
githubClient: pushGithubClient,
diff --git a/actions/setup/js/create_pull_request_validation_push_parity.test.cjs b/actions/setup/js/create_pull_request_validation_push_parity.test.cjs
index bbe1d1ff84c..63e6c63ce22 100644
--- a/actions/setup/js/create_pull_request_validation_push_parity.test.cjs
+++ b/actions/setup/js/create_pull_request_validation_push_parity.test.cjs
@@ -260,8 +260,11 @@ describe("create_pull_request – validation/push file-set parity", () => {
const fromPatch = fileListFromPatch(patchContent);
expect(fromPatch, "patch should contain only main_file.txt").toEqual(["main_file.txt"]);
- // Generate bundle (currently does NOT honour excludedFiles)
- const bundleResult = await generateGitBundle(branchName, "main", { cwd: agentRepo });
+ // Generate bundle with the same exclusions used for patch validation.
+ const bundleResult = await generateGitBundle(branchName, "main", {
+ cwd: agentRepo,
+ excludedFiles: ["excluded_file.txt"],
+ });
expect(bundleResult.success, `bundle generation failed: ${bundleResult.error}`).toBe(true);
createdArtifacts.push(bundleResult.bundlePath);
@@ -353,8 +356,11 @@ describe("create_pull_request – validation/push file-set parity", () => {
const fromPatch = fileListFromPatch(patchContent);
expect(fromPatch, "patch should contain only main_file.txt").toEqual(["main_file.txt"]);
- // Generate bundle (includes all commits and all files, no exclusion)
- const bundleResult = await generateGitBundle(branchName, "main", { cwd: agentRepo });
+ // Generate bundle with the same exclusions used for patch validation.
+ const bundleResult = await generateGitBundle(branchName, "main", {
+ cwd: agentRepo,
+ excludedFiles: ["excluded_file.txt"],
+ });
expect(bundleResult.success, `bundle generation failed: ${bundleResult.error}`).toBe(true);
createdArtifacts.push(bundleResult.bundlePath);
@@ -371,7 +377,9 @@ describe("create_pull_request – validation/push file-set parity", () => {
// single commit on top of origin/main. In production this is triggered by
// pushSignedCommits refusing merge-commit topology, causing create_pull_request
// to call rewriteBundleBranchAsSingleCommit → linearizeRangeAsCommit.
- await linearizeRangeAsCommit("origin/main", "apply bundled changes", createExecApi(safeOutputsRepo));
+ await linearizeRangeAsCommit("origin/main", "apply bundled changes", createExecApi(safeOutputsRepo), {
+ excludedFiles: ["excluded_file.txt"],
+ });
// REGRESSION ASSERTION: the rewritten commit must contain the same files as the patch.
// On pre-fix code this fails because linearizeRangeAsCommit stages ALL files that
diff --git a/actions/setup/js/generate_git_bundle.cjs b/actions/setup/js/generate_git_bundle.cjs
index ad2f0e3bc88..d707327a7e6 100644
--- a/actions/setup/js/generate_git_bundle.cjs
+++ b/actions/setup/js/generate_git_bundle.cjs
@@ -7,9 +7,11 @@
// allowlist check is required in this handler.
const fs = require("fs");
+const os = require("os");
const path = require("path");
const { getErrorMessage } = require("./error_helpers.cjs");
+const { generateGitPatch } = require("./generate_git_patch.cjs");
const { ensureOriginRemoteTrackingRef, execGitSync } = require("./git_helpers.cjs");
const { ERR_SYSTEM } = require("./error_codes.cjs");
@@ -99,6 +101,9 @@ function getBundlePathForBranchInRepo(branchName, repoSlug) {
* Required for multi-repo scenarios to prevent bundle file collisions.
* @param {string} [options.token] - GitHub token for git authentication. Falls back to GITHUB_TOKEN env var.
* Use this for cross-repo scenarios where a custom PAT with access to the target repo is needed.
+ * @param {string[]} [options.excludedFiles] - Glob patterns for files to exclude from the pushed commit set.
+ * When set, the bundle is synthesized from the same filtered patch used for validation so the pushed files
+ * match the validated patch file set.
* @returns {Promise