From 2851773ef0579fc4e237b3c591bbd6dc7040339e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 06:14:17 +0000 Subject: [PATCH 1/8] Initial plan From 6440bd8d03560e9bb1613479129b01d8465fd911 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 06:24:28 +0000 Subject: [PATCH 2/8] refactor: share diff header parsing across safe-output patch checks Agent-Logs-Url: https://github.com/github/gh-aw/sessions/983e8824-ff4e-496f-a356-853ef69b87fb Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 106 ++---------------- actions/setup/js/manifest_file_helpers.cjs | 19 +++- .../setup/js/manifest_file_helpers.test.cjs | 19 ++++ actions/setup/js/patch_path_helpers.cjs | 95 ++++++++++++++++ 4 files changed, 137 insertions(+), 102 deletions(-) create mode 100644 actions/setup/js/patch_path_helpers.cjs diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 37a41428e9a..cbd0146cb1f 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -35,6 +35,7 @@ const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); const { findAgent, getIssueDetails, assignAgentToIssue } = require("./assign_agent_helpers.cjs"); const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits } = require("./git_helpers.cjs"); +const { parseDiffGitHeader: parseDiffGitHeaderPaths, extractDiffGitHeaderEntries } = require("./patch_path_helpers.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -417,93 +418,14 @@ async function createFallbackIssue(githubClient, repoParts, title, body, labels, const MAX_FILES = 100; /** - * Parses a single `diff --git` header line and returns the post-image (`b/`) - * path, the pre-image (`a/`) path, or `null` if the header could not be - * parsed. Handles both unquoted paths and C-style quoted paths emitted by - * git when filenames contain unusual characters (e.g. backslash-escaped - * quotes, control characters, or non-ASCII bytes when `core.quotepath=true`). + * Parses one `diff --git` header line and returns the preferred file path key. * - * Examples of supported forms: - * diff --git a/foo.txt b/foo.txt - * diff --git a/dir/with space/x b/dir/with space/x - * diff --git "a/foo\"bar" "b/foo\"bar" - * diff --git "a/foo\\bar" "b/foo\\bar" - * - * @param {string} headerLine - The full header line (must start with `diff --git `) - * @returns {string|null} The extracted file path, or null if parsing failed. + * @param {string} headerLine + * @returns {string|null} */ function parseDiffGitHeader(headerLine) { - // Strip the `diff --git ` prefix. - const rest = headerLine.replace(/^diff --git /, ""); - if (rest === headerLine) { - return null; - } - - // Walk the string and pull out the two pathspecs. Each is either: - // - A quoted C-style string ("..."), where backslash escapes any character - // including embedded quotes and backslashes. - // - An unquoted run of non-space characters. - // We don't actually need to unescape the contents; the raw token is fine - // for use as a Set key (uniqueness is preserved). All we need is to - // correctly delimit the two path tokens. - /** @type {string[]} */ - const tokens = []; - let i = 0; - while (i < rest.length && tokens.length < 2) { - // Skip leading whitespace between tokens. - while (i < rest.length && rest[i] === " ") { - i++; - } - if (i >= rest.length) { - break; - } - let token = ""; - if (rest[i] === '"') { - // Quoted form: consume until the matching unescaped quote. - token += rest[i++]; - while (i < rest.length) { - const ch = rest[i++]; - token += ch; - if (ch === "\\" && i < rest.length) { - // Escaped char: consume the next character verbatim. - token += rest[i++]; - } else if (ch === '"') { - break; - } - } - } else { - // Unquoted form: consume up to the next space. - while (i < rest.length && rest[i] !== " ") { - token += rest[i++]; - } - } - tokens.push(token); - } - - if (tokens.length < 2) { - return null; - } - - // Prefer the "b/" (post-image) token, falling back to "a/" if needed. - // The leading "a/" or "b/" prefix is preserved in the returned key so - // that quoted vs. unquoted forms of the same path don't collide - // accidentally with unrelated files; uniqueness is the only invariant - // that matters here. - const stripPrefix = tok => { - if (tok.startsWith('"a/') || tok.startsWith('"b/')) { - return tok.slice(3, tok.endsWith('"') ? -1 : undefined); - } - if (tok.startsWith("a/") || tok.startsWith("b/")) { - return tok.slice(2); - } - return tok; - }; - const bPath = stripPrefix(tokens[1]); - if (bPath) { - return bPath; - } - const aPath = stripPrefix(tokens[0]); - return aPath || null; + const parsed = parseDiffGitHeaderPaths(headerLine); + return parsed.newPath || parsed.oldPath || null; } /** @@ -527,22 +449,14 @@ function countUniquePatchFiles(patchContent) { return 0; } const files = new Set(); - // Find all `diff --git` headers (start of line). Each header corresponds - // to one file diff; we try to extract its path and fall back to a unique - // synthetic key per unparseable header so the file is still counted in - // the limit. This is a conservative choice: it never undercounts, so a - // single malformed header cannot bypass the safety limit. - const headerRe = /^diff --git .*$/gm; - let match; + const entries = extractDiffGitHeaderEntries(patchContent); let unparseableIdx = 0; - while ((match = headerRe.exec(patchContent)) !== null) { - const path = parseDiffGitHeader(match[0]); + for (const entry of entries) { + const path = entry.newPath || entry.oldPath; if (path) { files.add(path); } else { - // Use the byte offset of the header to ensure uniqueness across - // multiple unparseable headers, so each is counted exactly once. - files.add(`__unparseable_header_${match.index}_${unparseableIdx++}`); + files.add(`__unparseable_header_${entry.headerIndex}_${unparseableIdx++}`); } } return files.size; diff --git a/actions/setup/js/manifest_file_helpers.cjs b/actions/setup/js/manifest_file_helpers.cjs index 045fd6a117b..81d2beb4f73 100644 --- a/actions/setup/js/manifest_file_helpers.cjs +++ b/actions/setup/js/manifest_file_helpers.cjs @@ -1,6 +1,7 @@ // @ts-check /** @typedef {import('./types/handler-factory').HandlerConfig} HandlerConfig */ +const { extractDiffGitHeaderEntries } = require("./patch_path_helpers.cjs"); /** * Extracts the unique set of file basenames (filename without directory path) changed in a git patch. @@ -17,9 +18,12 @@ function extractFilenamesFromPatch(patchContent) { return []; } const fileSet = new Set(); - const matches = patchContent.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm); - for (const match of matches) { - for (const filePath of [match[1], match[2]]) { + const entries = extractDiffGitHeaderEntries(patchContent); + for (const entry of entries) { + if (!entry.parseable) { + continue; + } + for (const filePath of [entry.oldPath, entry.newPath]) { // "dev/null" is the sentinel used when a file is created or deleted; skip it if (filePath && filePath !== "dev/null") { const parts = filePath.split("/"); @@ -51,9 +55,12 @@ function extractPathsFromPatch(patchContent) { return []; } const pathSet = new Set(); - const matches = patchContent.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm); - for (const match of matches) { - for (const filePath of [match[1], match[2]]) { + const entries = extractDiffGitHeaderEntries(patchContent); + for (const entry of entries) { + if (!entry.parseable) { + continue; + } + for (const filePath of [entry.oldPath, entry.newPath]) { if (filePath && filePath !== "dev/null") { pathSet.add(filePath); } diff --git a/actions/setup/js/manifest_file_helpers.test.cjs b/actions/setup/js/manifest_file_helpers.test.cjs index b3cc503c7bd..66faa540809 100644 --- a/actions/setup/js/manifest_file_helpers.test.cjs +++ b/actions/setup/js/manifest_file_helpers.test.cjs @@ -79,6 +79,18 @@ rename to package.json.bak expect(result).toContain("package.json.bak"); }); + it("should parse quoted headers with spaces and escapes", () => { + const patch = `diff --git "a/dir/with space/package.json" "b/dir/with space/package-lock.json" +index abc..def 100644 +diff --git "a/foo\\\\bar/config.json" "b/foo\\\\bar/config.json" +index abc..def 100644 +`; + const result = extractFilenamesFromPatch(patch); + expect(result).toContain("package.json"); + expect(result).toContain("package-lock.json"); + expect(result).toContain("config.json"); + }); + it("should ignore dev/null sentinel in new-file diffs", () => { const patch = `diff --git a/dev/null b/src/new-file.js new file mode 100644 @@ -236,6 +248,13 @@ index 0000000..abc expect(result).toContain(".github/workflows/new.yml"); expect(result).not.toContain("dev/null"); }); + + it("should parse quoted headers and ignore malformed headers", () => { + const patch = [`diff --git "a/.github/workflows/ci file.yml" "b/.github/workflows/ci file.yml"`, "index abc..def 100644", "diff --git ", "index abc..def 100644"].join("\n"); + const result = extractPathsFromPatch(patch); + expect(result).toContain(".github/workflows/ci file.yml"); + expect(result).toHaveLength(1); + }); }); describe("checkForProtectedPaths", () => { diff --git a/actions/setup/js/patch_path_helpers.cjs b/actions/setup/js/patch_path_helpers.cjs new file mode 100644 index 00000000000..4c8faee6ea6 --- /dev/null +++ b/actions/setup/js/patch_path_helpers.cjs @@ -0,0 +1,95 @@ +// @ts-check + +/** + * Parses a single `diff --git` header line and extracts both old/new paths. + * Handles unquoted and C-style quoted pathspecs. + * + * @param {string} headerLine + * @returns {{ oldPath: string|null, newPath: string|null, parseable: boolean }} + */ +function parseDiffGitHeader(headerLine) { + const rest = headerLine.replace(/^diff --git /, ""); + if (rest === headerLine) { + return { oldPath: null, newPath: null, parseable: false }; + } + + /** @type {string[]} */ + const tokens = []; + let i = 0; + while (i < rest.length && tokens.length < 2) { + while (i < rest.length && rest[i] === " ") { + i++; + } + if (i >= rest.length) { + break; + } + + let token = ""; + if (rest[i] === '"') { + token += rest[i++]; + while (i < rest.length) { + const ch = rest[i++]; + token += ch; + if (ch === "\\" && i < rest.length) { + token += rest[i++]; + } else if (ch === '"') { + break; + } + } + } else { + while (i < rest.length && rest[i] !== " ") { + token += rest[i++]; + } + } + tokens.push(token); + } + + if (tokens.length < 2) { + return { oldPath: null, newPath: null, parseable: false }; + } + + const stripPrefix = tok => { + if (tok.startsWith('"a/') || tok.startsWith('"b/')) { + return tok.slice(3, tok.endsWith('"') ? -1 : undefined); + } + if (tok.startsWith("a/") || tok.startsWith("b/")) { + return tok.slice(2); + } + return tok; + }; + + const oldPath = stripPrefix(tokens[0]) || null; + const newPath = stripPrefix(tokens[1]) || null; + if (!oldPath && !newPath) { + return { oldPath: null, newPath: null, parseable: false }; + } + + return { oldPath, newPath, parseable: true }; +} + +/** + * Extracts parsed entries for all `diff --git` headers in a patch. + * + * @param {string} patchContent + * @returns {{ oldPath: string|null, newPath: string|null, parseable: boolean, headerIndex: number, headerLine: string }[]} + */ +function extractDiffGitHeaderEntries(patchContent) { + if (!patchContent || !patchContent.trim()) { + return []; + } + + /** @type {{ oldPath: string|null, newPath: string|null, parseable: boolean, headerIndex: number, headerLine: string }[]} */ + const entries = []; + const headerRe = /^diff --git .*$/gm; + let match; + while ((match = headerRe.exec(patchContent)) !== null) { + entries.push({ + ...parseDiffGitHeader(match[0]), + headerIndex: match.index, + headerLine: match[0], + }); + } + return entries; +} + +module.exports = { parseDiffGitHeader, extractDiffGitHeaderEntries }; From 8b95966a969de1a9206e23eac07bab6a6bbf97bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 06:26:47 +0000 Subject: [PATCH 3/8] test: harden shared diff header parser for malformed quotes Agent-Logs-Url: https://github.com/github/gh-aw/sessions/983e8824-ff4e-496f-a356-853ef69b87fb Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/create_pull_request.test.cjs | 2 +- actions/setup/js/manifest_file_helpers.test.cjs | 8 ++++++++ actions/setup/js/patch_path_helpers.cjs | 5 +++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 7fccfac3e74..73b31e15143 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -1089,7 +1089,7 @@ describe("create_pull_request - max limit enforcement", () => { expect(countUniquePatchFiles(patchContent)).toBe(3); // Mixed: 2 parseable + 2 unparseable = 4 unique entries. - const mixed = ["diff --git a/a.txt b/a.txt", "diff --git ", "diff --git b/b.txt c/b.txt", "diff --git "].join("\n"); + const mixed = ["diff --git a/a.txt b/a.txt", 'diff --git "a/missing b/missing', "diff --git b/b.txt c/b.txt", "diff --git "].join("\n"); expect(countUniquePatchFiles(mixed)).toBe(4); // 200 unparseable headers must still trigger the default 100-file limit. diff --git a/actions/setup/js/manifest_file_helpers.test.cjs b/actions/setup/js/manifest_file_helpers.test.cjs index 66faa540809..ded47de63cd 100644 --- a/actions/setup/js/manifest_file_helpers.test.cjs +++ b/actions/setup/js/manifest_file_helpers.test.cjs @@ -255,6 +255,14 @@ index 0000000..abc expect(result).toContain(".github/workflows/ci file.yml"); expect(result).toHaveLength(1); }); + + it("should preserve full escaped paths from quoted headers", () => { + const patch = `diff --git "a/foo\\\\bar/config.json" "b/foo\\\\bar/config.json" +index abc..def 100644 +`; + const result = extractPathsFromPatch(patch); + expect(result).toContain("foo\\\\bar/config.json"); + }); }); describe("checkForProtectedPaths", () => { diff --git a/actions/setup/js/patch_path_helpers.cjs b/actions/setup/js/patch_path_helpers.cjs index 4c8faee6ea6..e89458adaba 100644 --- a/actions/setup/js/patch_path_helpers.cjs +++ b/actions/setup/js/patch_path_helpers.cjs @@ -27,15 +27,20 @@ function parseDiffGitHeader(headerLine) { let token = ""; if (rest[i] === '"') { token += rest[i++]; + let closedQuote = false; while (i < rest.length) { const ch = rest[i++]; token += ch; if (ch === "\\" && i < rest.length) { token += rest[i++]; } else if (ch === '"') { + closedQuote = true; break; } } + if (!closedQuote) { + return { oldPath: null, newPath: null, parseable: false }; + } } else { while (i < rest.length && rest[i] !== " ") { token += rest[i++]; From f5db498e5bbd4466a6c8160ff644be575274175d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:05:23 +0000 Subject: [PATCH 4/8] fix: handle CRLF in shared diff header parser Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pull_request.test.cjs | 2 ++ actions/setup/js/manifest_file_helpers.test.cjs | 6 ++++++ actions/setup/js/patch_path_helpers.cjs | 10 ++++++---- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 73b31e15143..0b7f75d0dd9 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -1052,6 +1052,8 @@ describe("create_pull_request - max limit enforcement", () => { expect(parseDiffGitHeader("diff --git a/foo.txt b/foo.txt")).toBe("foo.txt"); // Path with spaces (git always emits quoted form when path contains spaces) expect(parseDiffGitHeader('diff --git "a/dir/with space/x" "b/dir/with space/x"')).toBe("dir/with space/x"); + // CRLF line ending should not leak trailing carriage-return into path + expect(parseDiffGitHeader("diff --git a/crlf.txt b/crlf.txt\r")).toBe("crlf.txt"); // A patch with three different quoted/escaped files should count as 3. const patch = [ diff --git a/actions/setup/js/manifest_file_helpers.test.cjs b/actions/setup/js/manifest_file_helpers.test.cjs index ded47de63cd..58e3ef98948 100644 --- a/actions/setup/js/manifest_file_helpers.test.cjs +++ b/actions/setup/js/manifest_file_helpers.test.cjs @@ -249,6 +249,12 @@ index 0000000..abc expect(result).not.toContain("dev/null"); }); + it("should parse CRLF patch headers without trailing carriage returns", () => { + const patch = "diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\r\nindex abc..def 100644\r\n"; + const result = extractPathsFromPatch(patch); + expect(result).toEqual([".github/workflows/ci.yml"]); + }); + it("should parse quoted headers and ignore malformed headers", () => { const patch = [`diff --git "a/.github/workflows/ci file.yml" "b/.github/workflows/ci file.yml"`, "index abc..def 100644", "diff --git ", "index abc..def 100644"].join("\n"); const result = extractPathsFromPatch(patch); diff --git a/actions/setup/js/patch_path_helpers.cjs b/actions/setup/js/patch_path_helpers.cjs index e89458adaba..ae6f3d7081e 100644 --- a/actions/setup/js/patch_path_helpers.cjs +++ b/actions/setup/js/patch_path_helpers.cjs @@ -8,16 +8,18 @@ * @returns {{ oldPath: string|null, newPath: string|null, parseable: boolean }} */ function parseDiffGitHeader(headerLine) { - const rest = headerLine.replace(/^diff --git /, ""); - if (rest === headerLine) { + const sanitizedHeaderLine = headerLine.endsWith("\r") ? headerLine.slice(0, -1) : headerLine; + const rest = sanitizedHeaderLine.replace(/^diff --git /, ""); + if (rest === sanitizedHeaderLine) { return { oldPath: null, newPath: null, parseable: false }; } /** @type {string[]} */ const tokens = []; + const isWhitespace = ch => ch === " " || ch === "\t" || ch === "\r" || ch === "\n"; let i = 0; while (i < rest.length && tokens.length < 2) { - while (i < rest.length && rest[i] === " ") { + while (i < rest.length && isWhitespace(rest[i])) { i++; } if (i >= rest.length) { @@ -42,7 +44,7 @@ function parseDiffGitHeader(headerLine) { return { oldPath: null, newPath: null, parseable: false }; } } else { - while (i < rest.length && rest[i] !== " ") { + while (i < rest.length && !isWhitespace(rest[i])) { token += rest[i++]; } } From e2367e289c4d1916b39118b30c3e07f442ae4cd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:20:01 +0000 Subject: [PATCH 5/8] test: add real git integration coverage for patch header parsing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/patch_path_helpers.cjs | 23 ++++ .../patch_path_helpers.integration.test.cjs | 111 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 actions/setup/js/patch_path_helpers.integration.test.cjs diff --git a/actions/setup/js/patch_path_helpers.cjs b/actions/setup/js/patch_path_helpers.cjs index ae6f3d7081e..2e09284f469 100644 --- a/actions/setup/js/patch_path_helpers.cjs +++ b/actions/setup/js/patch_path_helpers.cjs @@ -14,6 +14,29 @@ function parseDiffGitHeader(headerLine) { return { oldPath: null, newPath: null, parseable: false }; } + // Git may emit unquoted paths that still contain spaces in `diff --git` + // headers. In that case, split using the required ` b/` token boundary + // instead of generic whitespace tokenization. + if (rest.startsWith("a/")) { + const quotedSep = rest.indexOf(' "b/'); + const unquotedSep = rest.indexOf(" b/"); + const sepCandidates = [quotedSep, unquotedSep].filter(idx => idx > 0); + if (sepCandidates.length > 0) { + const sep = Math.min(...sepCandidates); + const oldPath = rest.slice(0, sep).slice(2) || null; + const newToken = rest.slice(sep + 1).trimEnd(); + let newPath = null; + if (newToken.startsWith('"b/')) { + newPath = newToken.slice(3, newToken.endsWith('"') ? -1 : undefined) || null; + } else if (newToken.startsWith("b/")) { + newPath = newToken.slice(2) || null; + } + if (oldPath || newPath) { + return { oldPath, newPath, parseable: true }; + } + } + } + /** @type {string[]} */ const tokens = []; const isWhitespace = ch => ch === " " || ch === "\t" || ch === "\r" || ch === "\n"; diff --git a/actions/setup/js/patch_path_helpers.integration.test.cjs b/actions/setup/js/patch_path_helpers.integration.test.cjs new file mode 100644 index 00000000000..1f571db5a30 --- /dev/null +++ b/actions/setup/js/patch_path_helpers.integration.test.cjs @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; + +import { extractDiffGitHeaderEntries } from "./patch_path_helpers.cjs"; +import { countUniquePatchFiles } from "./create_pull_request.cjs"; +import { extractPathsFromPatch } from "./manifest_file_helpers.cjs"; + +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: ${result.stderr}`); + } + return result; +} + +function createRepo() { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "patch-path-helper-it-")); + execGit(["init", "-q"], { cwd: repoDir }); + execGit(["config", "user.name", "Test"], { cwd: repoDir }); + execGit(["config", "user.email", "test@example.com"], { cwd: repoDir }); + execGit(["config", "commit.gpgsign", "false"], { cwd: repoDir }); + fs.writeFileSync(path.join(repoDir, "README.md"), "init\n"); + execGit(["add", "."], { cwd: repoDir }); + execGit(["commit", "-q", "-m", "init"], { cwd: repoDir }); + return repoDir; +} + +function cleanupRepo(repoDir) { + if (repoDir && fs.existsSync(repoDir)) { + fs.rmSync(repoDir, { recursive: true, force: true }); + } +} + +function lastCommitPatch(repoDir) { + return execGit(["show", "--pretty=format:", "--patch", "HEAD"], { cwd: repoDir }).stdout; +} + +describe("patch_path_helpers integration - real git outputs", () => { + let repoDir; + + beforeEach(() => { + repoDir = createRepo(); + }); + + afterEach(() => { + cleanupRepo(repoDir); + }); + + it("parses real git headers for unquoted paths containing spaces", () => { + fs.mkdirSync(path.join(repoDir, "dir with space"), { recursive: true }); + fs.writeFileSync(path.join(repoDir, "dir with space", "file name.txt"), "space\n"); + execGit(["add", "."], { cwd: repoDir }); + execGit(["commit", "-q", "-m", "add spaced path"], { cwd: repoDir }); + const patch = lastCommitPatch(repoDir); + + const entries = extractDiffGitHeaderEntries(patch); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual( + expect.objectContaining({ + parseable: true, + oldPath: "dir with space/file name.txt", + newPath: "dir with space/file name.txt", + }) + ); + expect(countUniquePatchFiles(patch)).toBe(1); + expect(extractPathsFromPatch(patch)).toContain("dir with space/file name.txt"); + }); + + it("parses real git headers for quoted escaped filenames", () => { + fs.writeFileSync(path.join(repoDir, 'foo"bar.txt'), "quoted\n"); + fs.writeFileSync(path.join(repoDir, "foo\\bar.txt"), "slash\n"); + execGit(["add", "."], { cwd: repoDir }); + execGit(["commit", "-q", "-m", "add escaped names"], { cwd: repoDir }); + const patch = lastCommitPatch(repoDir); + + const entries = extractDiffGitHeaderEntries(patch); + expect(entries).toHaveLength(2); + expect(entries[0].parseable).toBe(true); + expect(entries[1].parseable).toBe(true); + expect(countUniquePatchFiles(patch)).toBe(2); + expect(extractPathsFromPatch(patch)).toContain('foo\\"bar.txt'); + expect(extractPathsFromPatch(patch)).toContain("foo\\\\bar.txt"); + }); + + it("parses real git rename headers and exposes both old/new paths", () => { + fs.writeFileSync(path.join(repoDir, "old-name.txt"), "hello\n"); + execGit(["add", "."], { cwd: repoDir }); + execGit(["commit", "-q", "-m", "add old file"], { cwd: repoDir }); + + execGit(["mv", "old-name.txt", "new-name.txt"], { cwd: repoDir }); + execGit(["commit", "-q", "-m", "rename file"], { cwd: repoDir }); + const patch = lastCommitPatch(repoDir); + + const entries = extractDiffGitHeaderEntries(patch); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual( + expect.objectContaining({ + parseable: true, + oldPath: "old-name.txt", + newPath: "new-name.txt", + }) + ); + expect(countUniquePatchFiles(patch)).toBe(1); + expect(extractPathsFromPatch(patch)).toContain("old-name.txt"); + expect(extractPathsFromPatch(patch)).toContain("new-name.txt"); + }); +}); From 402beeab4aa67ae876ca01a15d7bb0a19428d1ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:20:59 +0000 Subject: [PATCH 6/8] fix: support unquoted spaced diff headers in parser Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/patch_path_helpers.cjs | 8 ++++---- actions/setup/js/patch_path_helpers.integration.test.cjs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/patch_path_helpers.cjs b/actions/setup/js/patch_path_helpers.cjs index 2e09284f469..60d803dd37c 100644 --- a/actions/setup/js/patch_path_helpers.cjs +++ b/actions/setup/js/patch_path_helpers.cjs @@ -20,10 +20,10 @@ function parseDiffGitHeader(headerLine) { if (rest.startsWith("a/")) { const quotedSep = rest.indexOf(' "b/'); const unquotedSep = rest.indexOf(" b/"); - const sepCandidates = [quotedSep, unquotedSep].filter(idx => idx > 0); - if (sepCandidates.length > 0) { - const sep = Math.min(...sepCandidates); - const oldPath = rest.slice(0, sep).slice(2) || null; + const foundSeparatorIndices = [quotedSep, unquotedSep].filter(idx => idx > 0); + if (foundSeparatorIndices.length > 0) { + const sep = Math.min(...foundSeparatorIndices); + const oldPath = rest.slice(2, sep) || null; const newToken = rest.slice(sep + 1).trimEnd(); let newPath = null; if (newToken.startsWith('"b/')) { diff --git a/actions/setup/js/patch_path_helpers.integration.test.cjs b/actions/setup/js/patch_path_helpers.integration.test.cjs index 1f571db5a30..4340beeeb23 100644 --- a/actions/setup/js/patch_path_helpers.integration.test.cjs +++ b/actions/setup/js/patch_path_helpers.integration.test.cjs @@ -12,7 +12,7 @@ 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: ${result.stderr}`); + throw new Error(`git ${args.join(" ")} failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); } return result; } From 41e075be7c40e39d3b7680e9d8757ca88aff4013 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:21:51 +0000 Subject: [PATCH 7/8] chore: polish parser split logic from review feedback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/patch_path_helpers.cjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/patch_path_helpers.cjs b/actions/setup/js/patch_path_helpers.cjs index 60d803dd37c..d166e0df724 100644 --- a/actions/setup/js/patch_path_helpers.cjs +++ b/actions/setup/js/patch_path_helpers.cjs @@ -20,14 +20,18 @@ function parseDiffGitHeader(headerLine) { if (rest.startsWith("a/")) { const quotedSep = rest.indexOf(' "b/'); const unquotedSep = rest.indexOf(" b/"); - const foundSeparatorIndices = [quotedSep, unquotedSep].filter(idx => idx > 0); + const foundSeparatorIndices = [quotedSep, unquotedSep].filter(idx => idx >= 0); if (foundSeparatorIndices.length > 0) { const sep = Math.min(...foundSeparatorIndices); const oldPath = rest.slice(2, sep) || null; const newToken = rest.slice(sep + 1).trimEnd(); let newPath = null; if (newToken.startsWith('"b/')) { - newPath = newToken.slice(3, newToken.endsWith('"') ? -1 : undefined) || null; + if (newToken.endsWith('"')) { + newPath = newToken.slice(3, -1) || null; + } else { + newPath = newToken.slice(3) || null; + } } else if (newToken.startsWith("b/")) { newPath = newToken.slice(2) || null; } From 1838f5aabb048581cffacb1cc63402bb13e583d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:22:46 +0000 Subject: [PATCH 8/8] chore: improve parser readability constants Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/patch_path_helpers.cjs | 15 +++++++++------ .../js/patch_path_helpers.integration.test.cjs | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/patch_path_helpers.cjs b/actions/setup/js/patch_path_helpers.cjs index d166e0df724..9912c9506cf 100644 --- a/actions/setup/js/patch_path_helpers.cjs +++ b/actions/setup/js/patch_path_helpers.cjs @@ -1,4 +1,7 @@ // @ts-check +const A_PREFIX_LENGTH = 2; +const B_PREFIX_LENGTH = 2; +const QUOTED_PREFIX_LENGTH = 3; /** * Parses a single `diff --git` header line and extracts both old/new paths. @@ -23,17 +26,17 @@ function parseDiffGitHeader(headerLine) { const foundSeparatorIndices = [quotedSep, unquotedSep].filter(idx => idx >= 0); if (foundSeparatorIndices.length > 0) { const sep = Math.min(...foundSeparatorIndices); - const oldPath = rest.slice(2, sep) || null; + const oldPath = rest.slice(A_PREFIX_LENGTH, sep) || null; const newToken = rest.slice(sep + 1).trimEnd(); let newPath = null; if (newToken.startsWith('"b/')) { if (newToken.endsWith('"')) { - newPath = newToken.slice(3, -1) || null; + newPath = newToken.slice(QUOTED_PREFIX_LENGTH, -1) || null; } else { - newPath = newToken.slice(3) || null; + newPath = newToken.slice(QUOTED_PREFIX_LENGTH) || null; } } else if (newToken.startsWith("b/")) { - newPath = newToken.slice(2) || null; + newPath = newToken.slice(B_PREFIX_LENGTH) || null; } if (oldPath || newPath) { return { oldPath, newPath, parseable: true }; @@ -84,10 +87,10 @@ function parseDiffGitHeader(headerLine) { const stripPrefix = tok => { if (tok.startsWith('"a/') || tok.startsWith('"b/')) { - return tok.slice(3, tok.endsWith('"') ? -1 : undefined); + return tok.slice(QUOTED_PREFIX_LENGTH, tok.endsWith('"') ? -1 : undefined); } if (tok.startsWith("a/") || tok.startsWith("b/")) { - return tok.slice(2); + return tok.slice(B_PREFIX_LENGTH); } return tok; }; diff --git a/actions/setup/js/patch_path_helpers.integration.test.cjs b/actions/setup/js/patch_path_helpers.integration.test.cjs index 4340beeeb23..be40762d685 100644 --- a/actions/setup/js/patch_path_helpers.integration.test.cjs +++ b/actions/setup/js/patch_path_helpers.integration.test.cjs @@ -18,7 +18,7 @@ function execGit(args, options = {}) { } function createRepo() { - const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "patch-path-helper-it-")); + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "patch-path-helpers-it-")); execGit(["init", "-q"], { cwd: repoDir }); execGit(["config", "user.name", "Test"], { cwd: repoDir }); execGit(["config", "user.email", "test@example.com"], { cwd: repoDir });