From 543512cdd19dea67bdc7cf9b77052e79421c28d7 Mon Sep 17 00:00:00 2001 From: Mark Amery Date: Mon, 2 Mar 2026 12:14:38 +0000 Subject: [PATCH 1/8] Commit first part of Codex's fix --- src/patch/parse.ts | 2 +- test/patch/parse.js | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/patch/parse.ts b/src/patch/parse.ts index 29356d61..c73da717 100755 --- a/src/patch/parse.ts +++ b/src/patch/parse.ts @@ -41,7 +41,7 @@ export function parsePatch(uniDiff: string): StructuredPatch[] { // it's going to change, it should be done cautiously and in a new // major release, for backwards-compat reasons. // -- ExplodingCabbage - const headerMatch = (/^(?:Index:|diff(?: -r \w+)+)\s+/).exec(line); + const headerMatch = (/^(?:Index:|diff(?: -r \w+)+|diff)\s+/).exec(line); if (headerMatch) { index.index = line.substring(headerMatch[0].length).trim(); } diff --git a/test/patch/parse.js b/test/patch/parse.js index ed604ab1..0e05239f 100644 --- a/test/patch/parse.js +++ b/test/patch/parse.js @@ -299,6 +299,26 @@ diff -r 9117c6561b0b -r 273ce12ad8f1 README ]); }); + it('should parse generic diff headers', function() { + const patchStr = `diff --git a/foo b/foo +--- a/foo ++++ b/foo +@@ -1 +1 @@ +-old ++new`; + + expect(parsePatch(patchStr)).to.eql([{ + index: '--git a/foo b/foo', + oldFileName: 'a/foo', + oldHeader: '', + newFileName: 'b/foo', + newHeader: '', + hunks: [ + { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-old', '+new'] } + ] + }]); + }); + it('should parse multiple files without the Index line', function() { expect(parsePatch( `--- from\theader1 From 285e4497c7852246789fa8f0e6f0d92141f898d2 Mon Sep 17 00:00:00 2001 From: Mark Amery Date: Mon, 2 Mar 2026 13:10:41 +0000 Subject: [PATCH 2/8] More Codex stuff --- src/patch/parse.ts | 188 +++++++++++++++++++++++++++++++++++++++++++- test/patch/parse.js | 16 +++- 2 files changed, 202 insertions(+), 2 deletions(-) diff --git a/src/patch/parse.ts b/src/patch/parse.ts index c73da717..fd2a7b0b 100755 --- a/src/patch/parse.ts +++ b/src/patch/parse.ts @@ -10,8 +10,144 @@ export function parsePatch(uniDiff: string): StructuredPatch[] { list: Partial[] = []; let i = 0; + function parseGitPathToken(input: string, startIndex: number): { value: string; nextIndex: number } | null { + let i = startIndex; + while (i < input.length && input[i] === ' ') { + i++; + } + if (i >= input.length) { + return null; + } + if (input[i] === '"') { + i++; + let value = ''; + while (i < input.length) { + const ch = input[i]; + if (ch === '"') { + return { value, nextIndex: i + 1 }; + } + if (ch === '\\') { + i++; + if (i >= input.length) { + return null; + } + const esc = input[i]; + if (esc >= '0' && esc <= '7') { + let octal = esc; + for (let count = 0; count < 2; count++) { + const next = input[i + 1]; + if (next >= '0' && next <= '7') { + i++; + octal += next; + } else { + break; + } + } + value += String.fromCharCode(parseInt(octal, 8)); + i++; + continue; + } + if (esc === 'x') { + const hex = input.substring(i + 1, i + 3); + if (/^[0-9a-fA-F]{2}$/.test(hex)) { + value += String.fromCharCode(parseInt(hex, 16)); + i += 3; + continue; + } + value += 'x'; + i++; + continue; + } + switch (esc) { + case 'n': + value += '\n'; + break; + case 't': + value += '\t'; + break; + case 'r': + value += '\r'; + break; + case 'b': + value += '\b'; + break; + case 'f': + value += '\f'; + break; + case 'a': + value += '\u0007'; + break; + case 'v': + value += '\u000b'; + break; + case '\\': + value += '\\'; + break; + case '"': + value += '"'; + break; + default: + value += esc; + } + i++; + continue; + } + value += ch; + i++; + } + return null; + } + const start = i; + while (i < input.length && input[i] !== ' ') { + i++; + } + return { value: input.substring(start, i), nextIndex: i }; + } + + function parseGitPathTokens(input: string, count: number): string[] | null { + let index = 0; + const paths: string[] = []; + for (let parsed = 0; parsed < count; parsed++) { + const token = parseGitPathToken(input, index); + if (!token) { + return null; + } + paths.push(token.value); + index = token.nextIndex; + } + return paths; + } + + function parseGitDiffHeader(line: string): { oldFileName?: string; newFileName?: string } | null { + const prefix = 'diff --git '; + if (!line.startsWith(prefix)) { + return null; + } + const paths = parseGitPathTokens(line.substring(prefix.length), 2); + if (!paths) { + return null; + } + let [oldFileName, newFileName] = paths; + if (oldFileName.startsWith('a/')) { + oldFileName = oldFileName.substring(2); + } + if (newFileName.startsWith('b/')) { + newFileName = newFileName.substring(2); + } + return { oldFileName, newFileName }; + } + + function parseGitExtendedPath(line: string, prefix: string): string | null { + if (!line.startsWith(prefix)) { + return null; + } + const token = parseGitPathToken(line, prefix.length); + return token ? token.value : null; + } + function parseIndex() { const index: Partial = {}; + let seenGitHeader = false; list.push(index); // Parse diff metadata @@ -23,6 +159,56 @@ export function parsePatch(uniDiff: string): StructuredPatch[] { break; } + const gitHeader = parseGitDiffHeader(line); + if (gitHeader) { + if (seenGitHeader || index.index || index.oldFileName || index.newFileName) { + break; + } + seenGitHeader = true; + if (gitHeader.oldFileName) { + index.oldFileName = gitHeader.oldFileName; + } + if (gitHeader.newFileName) { + index.newFileName = gitHeader.newFileName; + if (!index.index) { + index.index = gitHeader.newFileName; + } + } + i++; + continue; + } + + const renameFrom = parseGitExtendedPath(line, 'rename from '); + if (renameFrom) { + index.oldFileName = renameFrom; + i++; + continue; + } + const renameTo = parseGitExtendedPath(line, 'rename to '); + if (renameTo) { + index.newFileName = renameTo; + if (!index.index) { + index.index = renameTo; + } + i++; + continue; + } + const copyFrom = parseGitExtendedPath(line, 'copy from '); + if (copyFrom) { + index.oldFileName = copyFrom; + i++; + continue; + } + const copyTo = parseGitExtendedPath(line, 'copy to '); + if (copyTo) { + index.newFileName = copyTo; + if (!index.index) { + index.index = copyTo; + } + i++; + continue; + } + // Try to parse the line as a diff header, like // Index: README.md // or @@ -41,7 +227,7 @@ export function parsePatch(uniDiff: string): StructuredPatch[] { // it's going to change, it should be done cautiously and in a new // major release, for backwards-compat reasons. // -- ExplodingCabbage - const headerMatch = (/^(?:Index:|diff(?: -r \w+)+|diff)\s+/).exec(line); + const headerMatch = (/^(?:Index:|diff(?: -r \w+)+)\s+/).exec(line); if (headerMatch) { index.index = line.substring(headerMatch[0].length).trim(); } diff --git a/test/patch/parse.js b/test/patch/parse.js index 0e05239f..b6f5cf54 100644 --- a/test/patch/parse.js +++ b/test/patch/parse.js @@ -308,7 +308,7 @@ diff -r 9117c6561b0b -r 273ce12ad8f1 README +new`; expect(parsePatch(patchStr)).to.eql([{ - index: '--git a/foo b/foo', + index: 'foo', oldFileName: 'a/foo', oldHeader: '', newFileName: 'b/foo', @@ -319,6 +319,20 @@ diff -r 9117c6561b0b -r 273ce12ad8f1 README }]); }); + it('should parse git rename-only patches', function() { + const patchStr = `diff --git a/README.md b/README-2.md +similarity index 100% +rename from README.md +rename to README-2.md`; + + expect(parsePatch(patchStr)).to.eql([{ + index: 'README-2.md', + oldFileName: 'README.md', + newFileName: 'README-2.md', + hunks: [] + }]); + }); + it('should parse multiple files without the Index line', function() { expect(parsePatch( `--- from\theader1 From 556d4e03de902ca783f1812ba0f163ea03be61f0 Mon Sep 17 00:00:00 2001 From: Mark Amery Date: Mon, 2 Mar 2026 13:39:40 +0000 Subject: [PATCH 3/8] Latest Codex stuff --- src/patch/parse.ts | 257 +++++++++++++++++++++++--------------------- test/patch/parse.js | 26 +++++ 2 files changed, 161 insertions(+), 122 deletions(-) diff --git a/src/patch/parse.ts b/src/patch/parse.ts index fd2a7b0b..1e65da90 100755 --- a/src/patch/parse.ts +++ b/src/patch/parse.ts @@ -1,149 +1,162 @@ import type { StructuredPatch } from '../types.js'; /** - * Parses a patch into structured data, in the same structure returned by `structuredPatch`. - * - * @return a JSON object representation of the a patch, suitable for use with the `applyPatch` method. + * Parse a single git path token starting at the provided index. + * Supports C-style quoted paths used by git when `core.quotePath` is enabled. */ -export function parsePatch(uniDiff: string): StructuredPatch[] { - const diffstr = uniDiff.split(/\n/), - list: Partial[] = []; - let i = 0; - - function parseGitPathToken(input: string, startIndex: number): { value: string; nextIndex: number } | null { - let i = startIndex; - while (i < input.length && input[i] === ' ') { - i++; - } - if (i >= input.length) { - return null; - } - if (input[i] === '"') { - i++; - let value = ''; - while (i < input.length) { - const ch = input[i]; - if (ch === '"') { - return { value, nextIndex: i + 1 }; +function parseGitPathToken(input: string, startIndex: number): { value: string; nextIndex: number } | null { + let i = startIndex; + while (i < input.length && input[i] === ' ') { + i++; + } + if (i >= input.length) { + return null; + } + if (input[i] === '"') { + i++; + let value = ''; + while (i < input.length) { + const ch = input[i]; + if (ch === '"') { + return { value, nextIndex: i + 1 }; + } + if (ch === '\\') { + i++; + if (i >= input.length) { + return null; } - if (ch === '\\') { - i++; - if (i >= input.length) { - return null; - } - const esc = input[i]; - if (esc >= '0' && esc <= '7') { - let octal = esc; - for (let count = 0; count < 2; count++) { - const next = input[i + 1]; - if (next >= '0' && next <= '7') { - i++; - octal += next; - } else { - break; - } + const esc = input[i]; + if (esc >= '0' && esc <= '7') { + let octal = esc; + for (let count = 0; count < 2; count++) { + const next = input[i + 1]; + if (next >= '0' && next <= '7') { + i++; + octal += next; + } else { + break; } - value += String.fromCharCode(parseInt(octal, 8)); - i++; - continue; } - if (esc === 'x') { - const hex = input.substring(i + 1, i + 3); - if (/^[0-9a-fA-F]{2}$/.test(hex)) { - value += String.fromCharCode(parseInt(hex, 16)); - i += 3; - continue; - } - value += 'x'; - i++; + value += String.fromCharCode(parseInt(octal, 8)); + i++; + continue; + } + if (esc === 'x') { + const hex = input.substring(i + 1, i + 3); + if (/^[0-9a-fA-F]{2}$/.test(hex)) { + value += String.fromCharCode(parseInt(hex, 16)); + i += 3; continue; } - switch (esc) { - case 'n': - value += '\n'; - break; - case 't': - value += '\t'; - break; - case 'r': - value += '\r'; - break; - case 'b': - value += '\b'; - break; - case 'f': - value += '\f'; - break; - case 'a': - value += '\u0007'; - break; - case 'v': - value += '\u000b'; - break; - case '\\': - value += '\\'; - break; - case '"': - value += '"'; - break; - default: - value += esc; - } + value += 'x'; i++; continue; } - value += ch; + switch (esc) { + case 'n': + value += '\n'; + break; + case 't': + value += '\t'; + break; + case 'r': + value += '\r'; + break; + case 'b': + value += '\b'; + break; + case 'f': + value += '\f'; + break; + case 'a': + value += '\u0007'; + break; + case 'v': + value += '\u000b'; + break; + case '\\': + value += '\\'; + break; + case '"': + value += '"'; + break; + default: + value += esc; + } i++; + continue; } - return null; - } - const start = i; - while (i < input.length && input[i] !== ' ') { + value += ch; i++; } - return { value: input.substring(start, i), nextIndex: i }; + return null; } - - function parseGitPathTokens(input: string, count: number): string[] | null { - let index = 0; - const paths: string[] = []; - for (let parsed = 0; parsed < count; parsed++) { - const token = parseGitPathToken(input, index); - if (!token) { - return null; - } - paths.push(token.value); - index = token.nextIndex; - } - return paths; + const start = i; + while (i < input.length && input[i] !== ' ') { + i++; } + return { value: input.substring(start, i), nextIndex: i }; +} - function parseGitDiffHeader(line: string): { oldFileName?: string; newFileName?: string } | null { - const prefix = 'diff --git '; - if (!line.startsWith(prefix)) { - return null; - } - const paths = parseGitPathTokens(line.substring(prefix.length), 2); - if (!paths) { +/** + * Parse a fixed number of git path tokens from a string. + */ +function parseGitPathTokens(input: string, count: number): string[] | null { + let index = 0; + const paths: string[] = []; + for (let parsed = 0; parsed < count; parsed++) { + const token = parseGitPathToken(input, index); + if (!token) { return null; } - let [oldFileName, newFileName] = paths; - if (oldFileName.startsWith('a/')) { - oldFileName = oldFileName.substring(2); - } - if (newFileName.startsWith('b/')) { - newFileName = newFileName.substring(2); - } - return { oldFileName, newFileName }; + paths.push(token.value); + index = token.nextIndex; } + return paths; +} - function parseGitExtendedPath(line: string, prefix: string): string | null { - if (!line.startsWith(prefix)) { - return null; - } - const token = parseGitPathToken(line, prefix.length); - return token ? token.value : null; +/** + * Parse a git `diff --git a/... b/...` header into old/new file names. + */ +function parseGitDiffHeader(line: string): { oldFileName?: string; newFileName?: string } | null { + const prefix = 'diff --git '; + if (!line.startsWith(prefix)) { + return null; + } + const paths = parseGitPathTokens(line.substring(prefix.length), 2); + if (!paths) { + return null; } + let [oldFileName, newFileName] = paths; + if (oldFileName.startsWith('a/')) { + oldFileName = oldFileName.substring(2); + } + if (newFileName.startsWith('b/')) { + newFileName = newFileName.substring(2); + } + return { oldFileName, newFileName }; +} + +/** + * Parse extended git headers like `rename from`, `rename to`, `copy from`, and `copy to`. + */ +function parseGitExtendedPath(line: string, prefix: string): string | null { + if (!line.startsWith(prefix)) { + return null; + } + const token = parseGitPathToken(line, prefix.length); + return token ? token.value : null; +} + +/** + * Parses a patch into structured data, in the same structure returned by `structuredPatch`. + * + * @return a JSON object representation of the a patch, suitable for use with the `applyPatch` method. + */ +export function parsePatch(uniDiff: string): StructuredPatch[] { + const diffstr = uniDiff.split(/\n/), + list: Partial[] = []; + let i = 0; function parseIndex() { const index: Partial = {}; diff --git a/test/patch/parse.js b/test/patch/parse.js index b6f5cf54..db34f699 100644 --- a/test/patch/parse.js +++ b/test/patch/parse.js @@ -333,6 +333,32 @@ rename to README-2.md`; }]); }); + it('should parse git C-quoted paths in headers', function() { + const patchStr = `diff --git "a/old\\040name\\tfile" "b/new\\x20name\\"file" +rename from "old\\040name\\tfile" +rename to "new\\x20name\\"file"`; + + expect(parsePatch(patchStr)).to.eql([{ + index: 'new name"file', + oldFileName: 'old name\tfile', + newFileName: 'new name"file', + hunks: [] + }]); + }); + + it('should handle edge cases in git C-quoted escapes', function() { + const patchStr = `diff --git "a/odd\\qpath\\7" "b/new\\xZZname\\077" +rename from "odd\\qpath\\7" +rename to "new\\xZZname\\077"`; + + expect(parsePatch(patchStr)).to.eql([{ + index: 'newxZZname?', + oldFileName: 'oddqpath\u0007', + newFileName: 'newxZZname?', + hunks: [] + }]); + }); + it('should parse multiple files without the Index line', function() { expect(parsePatch( `--- from\theader1 From a3a3bed7bedcf5ff6d445075e1f52b7f47181c93 Mon Sep 17 00:00:00 2001 From: Mark Amery Date: Mon, 2 Mar 2026 13:43:19 +0000 Subject: [PATCH 4/8] Latest changes --- src/patch/parse.ts | 10 +++++----- test/patch/parse.js | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/patch/parse.ts b/src/patch/parse.ts index 1e65da90..f1765026 100755 --- a/src/patch/parse.ts +++ b/src/patch/parse.ts @@ -1,8 +1,8 @@ import type { StructuredPatch } from '../types.js'; /** - * Parse a single git path token starting at the provided index. - * Supports C-style quoted paths used by git when `core.quotePath` is enabled. + * Parse a single Git path token starting at the provided index. + * Supports C-style quoted paths used by Git when `core.quotePath` is enabled. */ function parseGitPathToken(input: string, startIndex: number): { value: string; nextIndex: number } | null { let i = startIndex; @@ -99,7 +99,7 @@ function parseGitPathToken(input: string, startIndex: number): { value: string; } /** - * Parse a fixed number of git path tokens from a string. + * Parse a fixed number of Git path tokens from a string. */ function parseGitPathTokens(input: string, count: number): string[] | null { let index = 0; @@ -116,7 +116,7 @@ function parseGitPathTokens(input: string, count: number): string[] | null { } /** - * Parse a git `diff --git a/... b/...` header into old/new file names. + * Parse a Git `diff --git a/... b/...` header into old/new file names. */ function parseGitDiffHeader(line: string): { oldFileName?: string; newFileName?: string } | null { const prefix = 'diff --git '; @@ -138,7 +138,7 @@ function parseGitDiffHeader(line: string): { oldFileName?: string; newFileName?: } /** - * Parse extended git headers like `rename from`, `rename to`, `copy from`, and `copy to`. + * Parse extended Git headers like `rename from`, `rename to`, `copy from`, and `copy to`. */ function parseGitExtendedPath(line: string, prefix: string): string | null { if (!line.startsWith(prefix)) { diff --git a/test/patch/parse.js b/test/patch/parse.js index db34f699..cb495af8 100644 --- a/test/patch/parse.js +++ b/test/patch/parse.js @@ -319,7 +319,7 @@ diff -r 9117c6561b0b -r 273ce12ad8f1 README }]); }); - it('should parse git rename-only patches', function() { + it('should parse Git rename-only patches', function() { const patchStr = `diff --git a/README.md b/README-2.md similarity index 100% rename from README.md @@ -333,7 +333,7 @@ rename to README-2.md`; }]); }); - it('should parse git C-quoted paths in headers', function() { + it('should parse Git C-quoted paths in headers', function() { const patchStr = `diff --git "a/old\\040name\\tfile" "b/new\\x20name\\"file" rename from "old\\040name\\tfile" rename to "new\\x20name\\"file"`; @@ -346,7 +346,7 @@ rename to "new\\x20name\\"file"`; }]); }); - it('should handle edge cases in git C-quoted escapes', function() { + it('should handle edge cases in Git C-quoted escapes', function() { const patchStr = `diff --git "a/odd\\qpath\\7" "b/new\\xZZname\\077" rename from "odd\\qpath\\7" rename to "new\\xZZname\\077"`; From 176b7b9ce2e1b8ad3c2fc3ea61ec0d78cc841ae2 Mon Sep 17 00:00:00 2001 From: Mark Amery Date: Mon, 2 Mar 2026 14:11:44 +0000 Subject: [PATCH 5/8] More, uh, 'fixes' --- src/patch/parse.ts | 75 ++++++++++++++++++++++++++++++++++++++++++--- test/patch/parse.js | 21 +++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/patch/parse.ts b/src/patch/parse.ts index f1765026..6bbb53fe 100755 --- a/src/patch/parse.ts +++ b/src/patch/parse.ts @@ -115,6 +115,54 @@ function parseGitPathTokens(input: string, count: number): string[] | null { return paths; } +function scoreGitDiffCandidate(oldPath: string, newPath: string): number { + const oldValue = oldPath.substring(2); + const newValue = newPath.substring(2); + let score = Math.abs(oldValue.length - newValue.length); + if (oldValue === newValue) { + score -= 1000; + } + return score; +} + +/** + * Parse unquoted Git diff paths separated by the ` b/` marker. + * If multiple splits are possible, prefer paths with similar length and exact matches. + */ +function parseGitUnquotedDiffPaths(input: string): { oldPath: string; newPath: string } | null { + if (!input.startsWith('a/')) { + return null; + } + const candidates: Array<{ oldPath: string; newPath: string }> = []; + let searchIndex = 0; + while (searchIndex < input.length) { + const separatorIndex = input.indexOf(' b/', searchIndex); + if (separatorIndex === -1) { + break; + } + const oldPath = input.substring(0, separatorIndex); + const newPath = input.substring(separatorIndex + 1); + if (oldPath.startsWith('a/') && newPath.startsWith('b/')) { + candidates.push({ oldPath, newPath }); + } + searchIndex = separatorIndex + 1; + } + if (!candidates.length) { + return null; + } + let best = candidates[0]; + let bestScore = scoreGitDiffCandidate(best.oldPath, best.newPath); + for (let i = 1; i < candidates.length; i++) { + const candidate = candidates[i]; + const score = scoreGitDiffCandidate(candidate.oldPath, candidate.newPath); + if (score < bestScore) { + best = candidate; + bestScore = score; + } + } + return { oldPath: best.oldPath, newPath: best.newPath }; +} + /** * Parse a Git `diff --git a/... b/...` header into old/new file names. */ @@ -123,11 +171,30 @@ function parseGitDiffHeader(line: string): { oldFileName?: string; newFileName?: if (!line.startsWith(prefix)) { return null; } - const paths = parseGitPathTokens(line.substring(prefix.length), 2); - if (!paths) { - return null; + const rawPaths = line.substring(prefix.length); + let oldFileName: string | undefined; + let newFileName: string | undefined; + + if (rawPaths.startsWith('"')) { + const paths = parseGitPathTokens(rawPaths, 2); + if (!paths) { + return null; + } + [oldFileName, newFileName] = paths; + } else { + const unquoted = parseGitUnquotedDiffPaths(rawPaths); + if (unquoted) { + oldFileName = unquoted.oldPath; + newFileName = unquoted.newPath; + } else { + const paths = parseGitPathTokens(rawPaths, 2); + if (!paths) { + return null; + } + [oldFileName, newFileName] = paths; + } } - let [oldFileName, newFileName] = paths; + if (oldFileName.startsWith('a/')) { oldFileName = oldFileName.substring(2); } diff --git a/test/patch/parse.js b/test/patch/parse.js index cb495af8..931274ca 100644 --- a/test/patch/parse.js +++ b/test/patch/parse.js @@ -359,6 +359,27 @@ rename to "new\\xZZname\\077"`; }]); }); + it('should parse Git diff headers with unquoted spaces', function() { + const patchStr = `diff --git a/foo b/x b/foo b/x +new file mode 100644 +index 0000000..e69de29 +diff --git a/name with spaces in it b/name with spaces in it +new file mode 100644 +index 0000000..e69de29`; + + expect(parsePatch(patchStr)).to.eql([{ + index: 'foo b/x', + oldFileName: 'foo b/x', + newFileName: 'foo b/x', + hunks: [] + }, { + index: 'name with spaces in it', + oldFileName: 'name with spaces in it', + newFileName: 'name with spaces in it', + hunks: [] + }]); + }); + it('should parse multiple files without the Index line', function() { expect(parsePatch( `--- from\theader1 From 3a0ab58aabafb2d360f9b020a8f9476c4c966518 Mon Sep 17 00:00:00 2001 From: Mark Amery Date: Mon, 2 Mar 2026 14:22:26 +0000 Subject: [PATCH 6/8] Even more stuff --- src/patch/parse.ts | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/src/patch/parse.ts b/src/patch/parse.ts index 6bbb53fe..0fac3949 100755 --- a/src/patch/parse.ts +++ b/src/patch/parse.ts @@ -115,25 +115,17 @@ function parseGitPathTokens(input: string, count: number): string[] | null { return paths; } -function scoreGitDiffCandidate(oldPath: string, newPath: string): number { - const oldValue = oldPath.substring(2); - const newValue = newPath.substring(2); - let score = Math.abs(oldValue.length - newValue.length); - if (oldValue === newValue) { - score -= 1000; - } - return score; -} - /** * Parse unquoted Git diff paths separated by the ` b/` marker. - * If multiple splits are possible, prefer paths with similar length and exact matches. + * If multiple splits are possible, prefer a split where old and new paths match. + * If no matching split exists, use the last possible split. */ function parseGitUnquotedDiffPaths(input: string): { oldPath: string; newPath: string } | null { if (!input.startsWith('a/')) { return null; } const candidates: Array<{ oldPath: string; newPath: string }> = []; + const matchingCandidates: Array<{ oldPath: string; newPath: string }> = []; let searchIndex = 0; while (searchIndex < input.length) { const separatorIndex = input.indexOf(' b/', searchIndex); @@ -143,24 +135,24 @@ function parseGitUnquotedDiffPaths(input: string): { oldPath: string; newPath: s const oldPath = input.substring(0, separatorIndex); const newPath = input.substring(separatorIndex + 1); if (oldPath.startsWith('a/') && newPath.startsWith('b/')) { - candidates.push({ oldPath, newPath }); + const candidate = { oldPath, newPath }; + candidates.push(candidate); + if (oldPath.substring(2) === newPath.substring(2)) { + matchingCandidates.push(candidate); + } } searchIndex = separatorIndex + 1; } - if (!candidates.length) { - return null; + if (matchingCandidates.length === 1) { + return matchingCandidates[0]; } - let best = candidates[0]; - let bestScore = scoreGitDiffCandidate(best.oldPath, best.newPath); - for (let i = 1; i < candidates.length; i++) { - const candidate = candidates[i]; - const score = scoreGitDiffCandidate(candidate.oldPath, candidate.newPath); - if (score < bestScore) { - best = candidate; - bestScore = score; - } + if (matchingCandidates.length > 1) { + return matchingCandidates[matchingCandidates.length - 1]; + } + if (candidates.length) { + return candidates[candidates.length - 1]; } - return { oldPath: best.oldPath, newPath: best.newPath }; + return null; } /** From 4f715b91d0dcb76e55c78a261e38eafbdf998bc0 Mon Sep 17 00:00:00 2001 From: Mark Amery Date: Mon, 2 Mar 2026 14:28:54 +0000 Subject: [PATCH 7/8] More --- src/patch/parse.ts | 19 ++++++++++++++++--- test/patch/parse.js | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/patch/parse.ts b/src/patch/parse.ts index 0fac3949..07632a89 100755 --- a/src/patch/parse.ts +++ b/src/patch/parse.ts @@ -168,11 +168,24 @@ function parseGitDiffHeader(line: string): { oldFileName?: string; newFileName?: let newFileName: string | undefined; if (rawPaths.startsWith('"')) { - const paths = parseGitPathTokens(rawPaths, 2); - if (!paths) { + const firstToken = parseGitPathToken(rawPaths, 0); + if (!firstToken) { return null; } - [oldFileName, newFileName] = paths; + oldFileName = firstToken.value; + const remainder = rawPaths.substring(firstToken.nextIndex).trimStart(); + if (!remainder.length) { + return null; + } + if (remainder.startsWith('"')) { + const secondToken = parseGitPathToken(remainder, 0); + if (!secondToken) { + return null; + } + newFileName = secondToken.value; + } else { + newFileName = remainder; + } } else { const unquoted = parseGitUnquotedDiffPaths(rawPaths); if (unquoted) { diff --git a/test/patch/parse.js b/test/patch/parse.js index 931274ca..4b3b693b 100644 --- a/test/patch/parse.js +++ b/test/patch/parse.js @@ -380,6 +380,27 @@ index 0000000..e69de29`; }]); }); + it('should parse Git diff headers with mixed quoting', function() { + const patchStr = `diff --git "a/old name.txt" b/new name.txt +new file mode 100644 +index 0000000..e69de29 +diff --git a/simple.txt "b/new name.txt" +new file mode 100644 +index 0000000..e69de29`; + + expect(parsePatch(patchStr)).to.eql([{ + index: 'new name.txt', + oldFileName: 'old name.txt', + newFileName: 'new name.txt', + hunks: [] + }, { + index: 'new name.txt', + oldFileName: 'simple.txt', + newFileName: 'new name.txt', + hunks: [] + }]); + }); + it('should parse multiple files without the Index line', function() { expect(parsePatch( `--- from\theader1 From 9bb0ee48b5825c1681d4e2cfac0003c7a70f9ea7 Mon Sep 17 00:00:00 2001 From: Mark Amery Date: Mon, 2 Mar 2026 14:47:14 +0000 Subject: [PATCH 8/8] More changes --- src/patch/parse.ts | 11 +++++++++-- test/patch/parse.js | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/patch/parse.ts b/src/patch/parse.ts index 07632a89..acd52982 100755 --- a/src/patch/parse.ts +++ b/src/patch/parse.ts @@ -216,8 +216,15 @@ function parseGitExtendedPath(line: string, prefix: string): string | null { if (!line.startsWith(prefix)) { return null; } - const token = parseGitPathToken(line, prefix.length); - return token ? token.value : null; + const remainder = line.substring(prefix.length).trimStart(); + if (!remainder.length) { + return null; + } + if (remainder.startsWith('"')) { + const token = parseGitPathToken(remainder, 0); + return token ? token.value : null; + } + return remainder; } /** diff --git a/test/patch/parse.js b/test/patch/parse.js index 4b3b693b..89661af8 100644 --- a/test/patch/parse.js +++ b/test/patch/parse.js @@ -401,6 +401,20 @@ index 0000000..e69de29`; }]); }); + it('should parse unquoted rename-from with spaces', function() { + const patchStr = `diff --git a/foo bar "b/baz\\t" +similarity index 100% +rename from foo bar +rename to "baz\\t"`; + + expect(parsePatch(patchStr)).to.eql([{ + index: 'baz\t', + oldFileName: 'foo bar', + newFileName: 'baz\t', + hunks: [] + }]); + }); + it('should parse multiple files without the Index line', function() { expect(parsePatch( `--- from\theader1