Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 12 additions & 0 deletions .changeset/patch-push-pr-incremental-size-check.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 38 additions & 1 deletion actions/setup/js/generate_git_patch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,23 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
patchLines: 0,
};
}

// In incremental mode, the patch must be measured relative to the existing
// PR branch head (origin/<branch>), never relative to the default branch.
// If Strategy 1 did not produce a patch (e.g. format-patch yielded empty
// output for an unusual commit shape), do NOT fall through to Strategy 2
// or Strategy 3 — those use GITHUB_SHA..HEAD or merge-base with a remote
// ref and would produce a checkout-base diff (which can be many MB on a
// long-running branch). Returning an explicit error preserves the
// "incremental" contract that the patch reflects only the new commits.
if (!patchGenerated && mode === "incremental") {
debugLog(`Strategy 1 (incremental): No patch generated from ${baseRef}..${branchName}, refusing to fall through to checkout-base strategies`);
return {
success: false,
error: `Cannot generate incremental patch: no incremental commits found between ${baseRef} and ${branchName}.`,

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In incremental mode, this error path triggers when commitCount > 0 but git format-patch produced empty output (since commitCount === 0 returns earlier). The message "no incremental commits found" is misleading in that case and will send users down the wrong debugging path. Consider wording that reflects the actual failure (e.g., format-patch produced no output / could not generate patch content) and include commitCount/range in the error to aid diagnosis.

Suggested change
error: `Cannot generate incremental patch: no incremental commits found between ${baseRef} and ${branchName}.`,
error: `Cannot generate incremental patch: git format-patch produced no output for ${baseRef}..${branchName} despite ${commitCount} incremental commit(s).`,

Copilot uses AI. Check for mistakes.
patchPath: patchPath,
};
}
} catch (branchError) {
// Branch does not exist locally
debugLog(`Strategy 1: Branch '${branchName}' does not exist locally - ${getErrorMessage(branchError)}`);
Expand Down Expand Up @@ -450,12 +467,32 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
};
}

debugLog(`Final: SUCCESS - patchSize=${patchSize} bytes, patchLines=${patchLines}, baseCommit=${baseCommitSha || "(unknown)"}`);
// In incremental mode, also compute the net diff size between baseRef and the
// branch tip. The format-patch file size (patchSize) is the sum of every
// commit's individual diff plus per-commit metadata headers, which can be
// significantly larger than the actual net change. Consumers (e.g.
// push_to_pull_request_branch) should validate `max_patch_size` against the
// incremental net diff so the limit reflects how much the branch will
// actually change, not the cumulative size of the commit history. See:
// https://github.com/github/gh-aw/issues for the long-running branch case.
let diffSize = null;
if (mode === "incremental" && baseCommitSha && branchName) {
try {
const diffOutput = execGitSync(["diff", "--binary", `${baseCommitSha}..${branchName}`, ...excludeArgs()], { cwd });
diffSize = Buffer.byteLength(diffOutput, "utf8");
debugLog(`Final: Computed incremental net diffSize=${diffSize} bytes (baseRef=${baseCommitSha}..${branchName})`);
} catch (diffErr) {
debugLog(`Final: Failed to compute incremental net diffSize - ${getErrorMessage(diffErr)} (will fall back to patchSize)`);
}

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

execGitSync(["diff", "--binary", ...]) captures the entire diff output into memory as a UTF-8 string just to measure its byte length. For larger incremental diffs this can be slow and memory-heavy (and may hit the 100MB maxBuffer, making diffSize null and falling back). Consider measuring diff size without buffering the full output (e.g., write diff to a temp file via git diff --output <file> and stat it, or stream to a byte counter) so size measurement is O(1) memory.

Copilot uses AI. Check for mistakes.
}

debugLog(`Final: SUCCESS - patchSize=${patchSize} bytes, patchLines=${patchLines}, diffSize=${diffSize ?? "(n/a)"} bytes, baseCommit=${baseCommitSha || "(unknown)"}`);
return {
success: true,
patchPath: patchPath,
patchSize: patchSize,
patchLines: patchLines,
diffSize: diffSize,
baseCommit: baseCommitSha,
};
}
Expand Down
46 changes: 46 additions & 0 deletions actions/setup/js/git_patch_integration.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,52 @@ describe("git patch integration tests", () => {
}
});

it("should report diffSize as the net diff between origin/branch and HEAD in incremental mode", async () => {
// Reproduces the long-running branch scenario from the issue:
// - origin/<branch> already has accumulated history (e.g. many KB)
// - the agent makes a small new commit on top
// - the format-patch file size only reflects the *new* commit (because
// baseRef = origin/<branch>), but the returned diffSize must also be
// small and must NOT reflect the divergence from main.

// Create the long-running branch with a "large" accumulated payload.
execGit(["checkout", "-b", "long-running-branch"], { cwd: workingRepo });
const accumulated = "accumulated content line\n".repeat(2000); // ~50 KB
fs.writeFileSync(path.join(workingRepo, "accumulated.txt"), accumulated);
execGit(["add", "accumulated.txt"], { cwd: workingRepo });
execGit(["commit", "-m", "Accumulated work from previous iterations"], { cwd: workingRepo });
execGit(["push", "-u", "origin", "long-running-branch"], { cwd: workingRepo });

// Now the agent's "new iteration": a tiny incremental change.
fs.writeFileSync(path.join(workingRepo, "tiny.txt"), "tiny change\n");
execGit(["add", "tiny.txt"], { cwd: workingRepo });
execGit(["commit", "-m", "Tiny new iteration"], { cwd: workingRepo });

const origWorkspace = process.env.GITHUB_WORKSPACE;
const origDefaultBranch = process.env.DEFAULT_BRANCH;
process.env.GITHUB_WORKSPACE = workingRepo;
process.env.DEFAULT_BRANCH = "main";

try {
const result = await generateGitPatch("long-running-branch", "main", { mode: "incremental" });

expect(result.success).toBe(true);
expect(typeof result.diffSize).toBe("number");

// The incremental net diff is just the tiny.txt addition (well under 1 KB).
expect(result.diffSize).toBeGreaterThan(0);
expect(result.diffSize).toBeLessThan(1024);

// And the diffSize must NOT include the accumulated 50 KB payload that
// already exists on origin/long-running-branch — that is the entire
// point of the fix.
expect(result.diffSize).toBeLessThan(2000);
} finally {
process.env.GITHUB_WORKSPACE = origWorkspace;
process.env.DEFAULT_BRANCH = origDefaultBranch;
}
});

/**
* Sets GITHUB_WORKSPACE, DEFAULT_BRANCH, GITHUB_TOKEN, and GITHUB_SERVER_URL for
* a test, then restores the original values (or deletes them if they were unset).
Expand Down
24 changes: 20 additions & 4 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,26 @@ async function main(config = {}) {
const patchSizeBytes = Buffer.byteLength(patchContent, "utf8");
const patchSizeKb = Math.ceil(patchSizeBytes / 1024);

core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`);

if (patchSizeKb > maxSizeKb) {
const msg = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
// Prefer the incremental net diff size (computed by the MCP server when
// the patch was generated in incremental mode) over the format-patch file
// size for `max_patch_size` validation. The format-patch file accumulates
// per-commit metadata and per-commit diffs, which can be much larger than
// the actual net change relative to the existing PR branch head — and on
// a long-running branch (e.g. autoloop iteration branches) this drift
// grows monotonically even when each iteration only changes a few KB.
// The diff size, in contrast, is the size of `git diff origin/<branch>..HEAD`
// and is what the user actually expects `max-patch-size` to cap.
const diffSizeBytesRaw = message.diff_size;
const haveDiffSize = typeof diffSizeBytesRaw === "number" && diffSizeBytesRaw >= 0;
const sizeForCheckBytes = haveDiffSize ? diffSizeBytesRaw : patchSizeBytes;
const sizeForCheckKb = Math.ceil(sizeForCheckBytes / 1024);
const sizeLabel = haveDiffSize ? "Incremental patch size" : "Patch size";

core.info(`Patch file size: ${patchSizeKb} KB`);
core.info(`${sizeLabel}: ${sizeForCheckKb} KB (maximum allowed: ${maxSizeKb} KB)`);

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

max_patch_size validation still only runs for patch transport (!hasBundleFile). If bundle transport is configured, large bundles can bypass the size limit entirely. If max_patch_size is intended to cap push size regardless of transport (as implied by the PR description mentioning bundle backward-compat), consider enforcing the limit against bundle_path file size (or explicitly document that the limit is patch-transport-only).

Copilot uses AI. Check for mistakes.
if (sizeForCheckKb > maxSizeKb) {
const msg = `Patch size (${sizeForCheckKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When message.diff_size is used, the rejection error still says "Patch size (...)". This can be confusing because the value being enforced is the incremental net diff, not the transport file size. Consider tailoring the error message to indicate whether the limit was exceeded by the incremental diff size vs. the patch file size (and possibly include both values, since you already log them).

Suggested change
const msg = `Patch size (${sizeForCheckKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
const msg = haveDiffSize
? `Incremental diff size (${sizeForCheckKb} KB) exceeds maximum allowed size (${maxSizeKb} KB). Patch file size: ${patchSizeKb} KB.`
: `Patch file size (${sizeForCheckKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;

Copilot uses AI. Check for mistakes.
return { success: false, error: msg };
}

Expand Down
50 changes: 50 additions & 0 deletions actions/setup/js/push_to_pull_request_branch.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,56 @@ index 0000000..abc1234
expect(result.success).toBe(true);
expect(mockCore.info).toHaveBeenCalledWith("Patch size validation passed");
});

it("should prefer message.diff_size (incremental net diff) over patch file size", async () => {
// Simulate the long-running branch case: a large format-patch file
// (e.g. 2 MB of cumulative commit metadata + per-commit diffs) but a
// tiny incremental net diff (e.g. 5 KB of actual changes since
// origin/<branch>). The size check must use diff_size and accept the push.
const largePatch = "x".repeat(2 * 1024 * 1024); // 2 MB format-patch file
const patchPath = createPatchFile(largePatch);

mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" });

const module = await loadModule();
const handler = await module.main({ max_patch_size: 1024 }); // 1 MB max
const result = await handler({ patch_path: patchPath, diff_size: 5 * 1024 }, {});

expect(result.success).toBe(true);
expect(mockCore.info).toHaveBeenCalledWith("Patch size validation passed");
// Verify the size check used the incremental (diff_size) value, not the
// 2 MB file size.
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Incremental patch size: 5 KB"));
});

it("should reject when message.diff_size exceeds max size even if file size is small", async () => {
// Inverse case: small file (defensive — shouldn't happen in practice)
// but a recorded large diff_size should still cause rejection. This
// proves diff_size is the source of truth for the size check.
const patchPath = createPatchFile(); // small valid patch

const module = await loadModule();
const handler = await module.main({ max_patch_size: 1024 }); // 1 MB max
const result = await handler({ patch_path: patchPath, diff_size: 2 * 1024 * 1024 }, {});

expect(result.success).toBe(false);
expect(result.error).toContain("exceeds maximum");
});

it("should fall back to patch file size when message.diff_size is not provided", async () => {
// Backward-compat: older MCP servers (or non-incremental code paths)
// do not set diff_size. The check must continue to work using the patch
// file size as the measurement.
const largePatch = "x".repeat(2 * 1024 * 1024); // 2 MB
const patchPath = createPatchFile(largePatch);

const module = await loadModule();
const handler = await module.main({ max_patch_size: 1024 }); // 1 MB max
const result = await handler({ patch_path: patchPath }, {});

expect(result.success).toBe(false);
expect(result.error).toContain("exceeds maximum");
});
});

// ──────────────────────────────────────────────────────
Expand Down
12 changes: 11 additions & 1 deletion actions/setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ function createHandlers(server, appendSafeOutput, config = {}) {
}

// prettier-ignore
server.debug(`Patch generated successfully: ${patchResult.patchPath} (${patchResult.patchSize} bytes, ${patchResult.patchLines} lines)`);
server.debug(`Patch generated successfully: ${patchResult.patchPath} (${patchResult.patchSize} bytes, ${patchResult.patchLines} lines, diffSize=${patchResult.diffSize ?? "(n/a)"} bytes)`);

// Store the patch path in the entry so consumers know which file to use
entry.patch_path = patchResult.patchPath;
Expand All @@ -638,6 +638,16 @@ function createHandlers(server, appendSafeOutput, config = {}) {
entry.base_commit = patchResult.baseCommit;
}

// Store the incremental net diff size so push_to_pull_request_branch can
// validate `max_patch_size` against the actual incremental change relative
// to the existing PR branch head, not the (potentially much larger) size of
// the format-patch transport file. This is critical for the long-running
// branch pattern (e.g. autoloop) where the format-patch can include many
// commits but each iteration only changes a few KB.
if (typeof patchResult.diffSize === "number" && patchResult.diffSize >= 0) {
entry.diff_size = patchResult.diffSize;
}

appendSafeOutput(entry);
return {
content: [
Expand Down
Loading