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
9 changes: 2 additions & 7 deletions actions/setup/js/push_repo_memory.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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.)
Expand Down
45 changes: 45 additions & 0 deletions actions/setup/js/repo_memory_patch_size.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* 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, any>) => 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,
};
42 changes: 30 additions & 12 deletions actions/setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -1645,29 +1646,46 @@ 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: [
{
type: "text",
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.`,
}),
},
],
Expand All @@ -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).`,
}),
},
],
Expand Down
35 changes: 22 additions & 13 deletions actions/setup/js/safe_outputs_handlers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
});

Expand All @@ -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));
Expand Down
1 change: 1 addition & 0 deletions actions/setup/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading