diff --git a/actions/setup/js/push_repo_memory.cjs b/actions/setup/js/push_repo_memory.cjs
index 44349024b9b..45ca12758fd 100644
--- a/actions/setup/js/push_repo_memory.cjs
+++ b/actions/setup/js/push_repo_memory.cjs
@@ -7,6 +7,7 @@ const path = require("path");
const { getErrorMessage } = require("./error_helpers.cjs");
const { globPatternToRegex } = require("./glob_pattern_helpers.cjs");
const { execGitSync, getGitAuthEnv } = require("./git_helpers.cjs");
+const { getStagedPatchAdditionsSizeBytes } = require("./repo_memory_patch_size.cjs");
const { parseAllowedRepos, validateRepo } = require("./repo_helpers.cjs");
const { pushSignedCommits } = require("./push_signed_commits.cjs");
@@ -544,13 +545,7 @@ async function main() {
// Deletions are ignored since removing content is acceptable and does not
// contribute to the size of the content being pushed.
try {
- const patchContent = execGitSync(["diff", "--cached"], { stdio: "pipe" });
- // Count only added lines (starting with '+', excluding '+++' file-header lines)
- const addedSizeBytes = patchContent
- .split("\n")
- .filter(line => line.startsWith("+") && !line.startsWith("+++"))
- .reduce((sum, line) => sum + Buffer.byteLength(line + "\n", "utf8"), 0);
- const patchSizeBytes = addedSizeBytes;
+ const patchSizeBytes = getStagedPatchAdditionsSizeBytes({ execGitSyncFn: execGitSync });
const patchSizeKb = Math.ceil(patchSizeBytes / 1024);
const maxPatchSizeKb = Math.floor(maxPatchSize / 1024);
// Allow 20% overhead to account for git diff format (headers, context lines, etc.)
diff --git a/actions/setup/js/repo_memory_patch_size.cjs b/actions/setup/js/repo_memory_patch_size.cjs
new file mode 100644
index 00000000000..b2de1919f80
--- /dev/null
+++ b/actions/setup/js/repo_memory_patch_size.cjs
@@ -0,0 +1,45 @@
+// @ts-check
+///
+
+/**
+ * Count total UTF-8 bytes for added lines in a unified diff.
+ * Added lines start with "+" and exclude file header lines ("+++").
+ * Uses hunk-state tracking so content lines that begin with "++" are
+ * counted correctly (they appear as "+++" inside a hunk but are not headers).
+ *
+ * @param {string} patchContent
+ * @returns {number}
+ */
+function getAddedPatchSizeBytesFromDiff(patchContent) {
+ let inHunk = false;
+ let total = 0;
+ for (const line of patchContent.split("\n")) {
+ if (line.startsWith("@@")) {
+ inHunk = true;
+ } else if (line.startsWith("diff ")) {
+ inHunk = false;
+ }
+ if (inHunk && line.startsWith("+")) {
+ total += Buffer.byteLength(line + "\n", "utf8");
+ }
+ }
+ return total;
+}
+
+/**
+ * Compute staged patch additions size from git diff --cached.
+ *
+ * @param {Object} options
+ * @param {(args: string[], opts?: Record) => string} options.execGitSyncFn
+ * @param {string} [options.cwd]
+ * @returns {number}
+ */
+function getStagedPatchAdditionsSizeBytes({ execGitSyncFn, cwd }) {
+ const patchContent = execGitSyncFn(["diff", "--cached"], { stdio: "pipe", cwd });
+ return getAddedPatchSizeBytesFromDiff(patchContent);
+}
+
+module.exports = {
+ getAddedPatchSizeBytesFromDiff,
+ getStagedPatchAdditionsSizeBytes,
+};
diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs
index b9a4fc8b84b..0af94ff315e 100644
--- a/actions/setup/js/safe_outputs_handlers.cjs
+++ b/actions/setup/js/safe_outputs_handlers.cjs
@@ -21,6 +21,7 @@ const { findRepoCheckout } = require("./find_repo_checkout.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { getOrGenerateTemporaryId } = require("./temporary_id.cjs");
const { parseAllowedExtensionsEnv } = require("./allowed_extensions_helpers.cjs");
+const { getStagedPatchAdditionsSizeBytes } = require("./repo_memory_patch_size.cjs");
const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs");
const { parseDeduplicateByTitle, normalizeTitleForDedup, findDuplicateByTitle } = require("./issue_title_dedup.cjs");
const { validateCreatePullRequestIntent, validatePushToPullRequestBranchIntent, validateCreateIssueIntent, validateAddCommentIntent } = require("./intent_probe.cjs");
@@ -1645,17 +1646,35 @@ function createHandlers(server, appendSafeOutput, config = {}) {
};
}
- // Check total content size. totalSize is the raw sum of all file sizes in the memory
- // directory (excluding .git). It is compared against max_patch_size × 1.2 so that
- // a full rewrite of all memory content would remain within the push gate limit in
- // push_repo_memory.cjs, which applies the same 1.2× factor to diff additions.
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
const totalSizeKb = Math.ceil(totalSize / 1024);
const effectiveMaxKb = Math.floor(effectiveMaxPatchSize / 1024);
+ const maxPatchSizeKb = Math.floor(maxPatchSize / 1024);
- core.debug(`push_repo_memory validation: ${files.length} files, total ${totalSize} bytes, effective limit ${effectiveMaxPatchSize} bytes`);
+ let patchSizeBytes;
+ try {
+ ensureSafeDirectoryTrust(memoryDir, server);
+ execGitSync(["add", "--sparse", "."], { cwd: memoryDir, stdio: "pipe" });
+ patchSizeBytes = getStagedPatchAdditionsSizeBytes({ execGitSyncFn: execGitSync, cwd: memoryDir });
+ } catch (/** @type {any} */ error) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: JSON.stringify({
+ result: "error",
+ error: `Failed to compute staged patch additions size for '${memoryDir}': ${getErrorMessage(error)}`,
+ }),
+ },
+ ],
+ isError: true,
+ };
+ }
+ const patchSizeKb = Math.ceil(patchSizeBytes / 1024);
+
+ core.debug(`push_repo_memory validation: ${files.length} files, total ${totalSize} bytes, patch additions ${patchSizeBytes} bytes, effective limit ${effectiveMaxPatchSize} bytes`);
- if (totalSize > effectiveMaxPatchSize) {
+ if (patchSizeBytes > effectiveMaxPatchSize) {
return {
content: [
{
@@ -1663,11 +1682,10 @@ function createHandlers(server, appendSafeOutput, config = {}) {
text: JSON.stringify({
result: "error",
error:
- `Total memory size (${totalSizeKb} KB) exceeds the allowed limit of ${effectiveMaxKb} KB ` +
- `(configured max-patch-size: ${Math.floor(maxPatchSize / 1024)} KB).\n\n` +
- `Please reduce the total size of files in '${memoryDir}' before the workflow completes. ` +
- `Consider: summarizing notes instead of keeping full history, removing outdated entries, or compressing data. ` +
- `Then call push_repo_memory again to verify the size is within limits.`,
+ `Patch additions size (${patchSizeKb} KB, ${patchSizeBytes} bytes) exceeds the allowed limit of ${effectiveMaxKb} KB ` +
+ `(${effectiveMaxPatchSize} bytes, configured max-patch-size: ${maxPatchSizeKb} KB / ${maxPatchSize} bytes with 20% overhead).\n\n` +
+ `Please reduce the size of staged changes in '${memoryDir}' before the workflow completes. ` +
+ `Then call push_repo_memory again to verify the patch size is within limits.`,
}),
},
],
@@ -1681,7 +1699,7 @@ function createHandlers(server, appendSafeOutput, config = {}) {
type: "text",
text: JSON.stringify({
result: "success",
- message: `Memory validation passed: ${files.length} file(s), ${totalSizeKb} KB total (limit: ${effectiveMaxKb} KB).`,
+ message: `Memory validation passed: ${files.length} file(s), ${totalSizeKb} KB total content, ` + `${patchSizeKb} KB patch additions (${patchSizeBytes} bytes) (limit: ${effectiveMaxKb} KB / ${effectiveMaxPatchSize} bytes).`,
}),
},
],
diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs
index d26430e65ae..313a1757b96 100644
--- a/actions/setup/js/safe_outputs_handlers.test.cjs
+++ b/actions/setup/js/safe_outputs_handlers.test.cjs
@@ -2512,6 +2512,12 @@ describe("safe_outputs_handlers", () => {
});
}
+ function initGitRepo(repoDir) {
+ execSync("git init", { cwd: repoDir, stdio: "pipe" });
+ execSync('git config user.email "test@example.com"', { cwd: repoDir, stdio: "pipe" });
+ execSync('git config user.name "Test User"', { cwd: repoDir, stdio: "pipe" });
+ }
+
it("should return success when no repo-memory is configured", () => {
const h = createHandlers(mockServer, mockAppendSafeOutput, {});
const result = h.pushRepoMemoryHandler({});
@@ -2543,6 +2549,7 @@ describe("safe_outputs_handlers", () => {
it("should return success for valid files within limits", () => {
const h = makeHandlersWithMemory();
fs.mkdirSync(memoryDir, { recursive: true });
+ initGitRepo(memoryDir);
fs.writeFileSync(path.join(memoryDir, "state.json"), "x".repeat(100));
const result = h.pushRepoMemoryHandler({ memory_id: "default" });
const data = JSON.parse(result.content[0].text);
@@ -2576,42 +2583,43 @@ describe("safe_outputs_handlers", () => {
expect(data.error).toContain("3 files");
});
- it("should return error when total size exceeds effective max_patch_size", () => {
- // max_patch_size = 500 bytes, effective limit = floor(500 * 1.2) = 600 bytes
- const h = makeHandlersWithMemory({ max_patch_size: 500, max_file_size: 1024 * 1024 });
+ it("should pass when total folder size is large but staged diff is tiny", () => {
+ const h = makeHandlersWithMemory({ max_patch_size: 50, max_file_size: 1024 * 1024 });
fs.mkdirSync(memoryDir, { recursive: true });
- // Write two files totaling 650 bytes (above the 600 byte effective limit)
- fs.writeFileSync(path.join(memoryDir, "a.json"), "x".repeat(350));
- fs.writeFileSync(path.join(memoryDir, "b.json"), "x".repeat(300));
+ initGitRepo(memoryDir);
+ fs.writeFileSync(path.join(memoryDir, "large.json"), `${"x\n".repeat(3000)}`);
+ execSync("git add . && git commit -m 'seed'", { cwd: memoryDir, stdio: "pipe" });
+ fs.appendFileSync(path.join(memoryDir, "large.json"), "small-diff\n");
const result = h.pushRepoMemoryHandler({ memory_id: "default" });
- expect(result.isError).toBe(true);
+ expect(result.isError).toBeUndefined();
const data = JSON.parse(result.content[0].text);
- expect(data.result).toBe("error");
- expect(data.error).toContain("exceeds the allowed limit");
- expect(data.error).toContain("push_repo_memory again");
+ expect(data.result).toBe("success");
+ expect(data.message).toContain("patch additions");
});
it("should use 'default' memory_id when memory_id is not specified", () => {
const h = makeHandlersWithMemory();
fs.mkdirSync(memoryDir, { recursive: true });
+ initGitRepo(memoryDir);
fs.writeFileSync(path.join(memoryDir, "notes.md"), "hello");
const result = h.pushRepoMemoryHandler({}); // no memory_id
const data = JSON.parse(result.content[0].text);
expect(data.result).toBe("success");
});
- it("should scan files recursively in subdirectories", () => {
+ it("should fail when staged patch additions exceed effective max_patch_size", () => {
// max_patch_size = 500 bytes, effective limit = 600 bytes
const h = makeHandlersWithMemory({ max_patch_size: 500, max_file_size: 1024 * 1024 });
+ fs.mkdirSync(memoryDir, { recursive: true });
+ initGitRepo(memoryDir);
const subDir = path.join(memoryDir, "history");
fs.mkdirSync(subDir, { recursive: true });
- // Write a nested file that pushes total above effective limit
fs.writeFileSync(path.join(subDir, "log.jsonl"), "x".repeat(700));
const result = h.pushRepoMemoryHandler({ memory_id: "default" });
expect(result.isError).toBe(true);
const data = JSON.parse(result.content[0].text);
expect(data.result).toBe("error");
- // The nested file path should appear correctly
+ expect(data.error).toContain("Patch additions size");
expect(data.error).toContain("exceeds the allowed limit");
});
@@ -2622,6 +2630,7 @@ describe("safe_outputs_handlers", () => {
// files are small but .git directory content is large — must not count toward limit.
const h = makeHandlersWithMemory({ max_patch_size: 500, max_file_size: 1024 * 1024 });
fs.mkdirSync(memoryDir, { recursive: true });
+ initGitRepo(memoryDir);
// Small memory files (well within limit)
fs.writeFileSync(path.join(memoryDir, "memory.json"), "x".repeat(100));
fs.writeFileSync(path.join(memoryDir, "state.json"), "x".repeat(100));
diff --git a/actions/setup/setup.sh b/actions/setup/setup.sh
index 92968eb518e..e9addfff9bb 100755
--- a/actions/setup/setup.sh
+++ b/actions/setup/setup.sh
@@ -364,6 +364,7 @@ SAFE_OUTPUTS_FILES=(
"markdown_code_region_balancer.cjs"
"temporary_id.cjs"
"invocation_context_helpers.cjs"
+ "repo_memory_patch_size.cjs"
)
SAFE_OUTPUTS_COUNT=0