@@ -267,87 +267,237 @@ function buildDiffFile(
267267 * SQL/Lua/Haskell comment, which becomes `--- foo` on disk) is not mistaken for a file
268268 * header inside the hunk body.
269269 */
270+ type GitHeaderRewriteMode = "add" | "strip" ;
271+
270272function normalizeGitPatchPrefixes ( patchText : string ) {
271273 if ( ! patchText . includes ( "diff --git " ) ) {
272274 return patchText ;
273275 }
274276
275- let blockNeedsPrefix = false ;
277+ const lines = patchText . split ( "\n" ) ;
278+ const normalizedLines : string [ ] = [ ] ;
279+ let blockLines : string [ ] = [ ] ;
276280
277- return patchText
278- . split ( "\n" )
279- . map ( ( line ) => {
280- if ( line . startsWith ( "diff --git " ) ) {
281- const result = rewriteGitDiffHeader ( line ) ;
282- blockNeedsPrefix = result . changed ;
283- return result . line ;
284- }
281+ const flushBlock = ( ) => {
282+ if ( blockLines . length === 0 ) {
283+ return ;
284+ }
285285
286- if ( blockNeedsPrefix && line . startsWith ( "--- " ) ) {
287- return rewriteUnifiedFileLine ( line , "--- " , "a/" ) ;
288- }
286+ for ( const line of rewriteGitPatchBlock ( blockLines ) ) {
287+ normalizedLines . push ( line ) ;
288+ }
289+ blockLines = [ ] ;
290+ } ;
289291
290- if ( blockNeedsPrefix && line . startsWith ( "+++ " ) ) {
291- blockNeedsPrefix = false ;
292- return rewriteUnifiedFileLine ( line , "+++ " , "b/" ) ;
293- }
292+ for ( const line of lines ) {
293+ if ( line . startsWith ( "diff --git " ) ) {
294+ flushBlock ( ) ;
295+ blockLines . push ( line ) ;
296+ continue ;
297+ }
294298
295- return line ;
296- } )
297- . join ( "\n" ) ;
299+ if ( blockLines . length > 0 ) {
300+ blockLines . push ( line ) ;
301+ } else {
302+ normalizedLines . push ( line ) ;
303+ }
304+ }
305+
306+ flushBlock ( ) ;
307+ return normalizedLines . join ( "\n" ) ;
298308}
299309
300- /** Detect prefixed/noprefix `diff --git` lines and rewrite the noprefix form into `a/X b/Y`. */
301- function rewriteGitDiffHeader ( line : string ) : { line : string ; changed : boolean } {
310+ /** Rewrite one `diff --git` block, keeping file-header rewrites out of hunk bodies. */
311+ function rewriteGitPatchBlock ( blockLines : string [ ] ) {
312+ const firstLine = blockLines [ 0 ] ;
313+ if ( ! firstLine ?. startsWith ( "diff --git " ) ) {
314+ return blockLines ;
315+ }
316+
317+ const result = rewriteGitDiffHeader ( firstLine , blockLines ) ;
318+ let blockRewriteMode = result . rewriteMode ;
319+
320+ const rewrittenLines = [ result . line ] ;
321+
322+ for ( const line of blockLines . slice ( 1 ) ) {
323+ if ( blockRewriteMode && line . startsWith ( "--- " ) ) {
324+ rewrittenLines . push ( rewriteUnifiedFileLine ( line , "--- " , "a/" , blockRewriteMode ) ) ;
325+ continue ;
326+ }
327+
328+ if ( blockRewriteMode && line . startsWith ( "+++ " ) ) {
329+ const rewriteMode = blockRewriteMode ;
330+ blockRewriteMode = null ;
331+ rewrittenLines . push ( rewriteUnifiedFileLine ( line , "+++ " , "b/" , rewriteMode ) ) ;
332+ continue ;
333+ }
334+
335+ rewrittenLines . push ( line ) ;
336+ }
337+
338+ return rewrittenLines ;
339+ }
340+
341+ /** Detect prefixed/noprefix `diff --git` lines and rewrite them into Pierre's `a/X b/Y` form. */
342+ function rewriteGitDiffHeader (
343+ line : string ,
344+ blockLines : string [ ] ,
345+ ) : {
346+ line : string ;
347+ rewriteMode : GitHeaderRewriteMode | null ;
348+ } {
302349 const rest = line . slice ( "diff --git " . length ) . trimEnd ( ) ;
303350
304351 const quotedMatch = rest . match ( / ^ " ( (?: \\ .| [ ^ " \\ ] ) * ) " " ( (?: \\ .| [ ^ " \\ ] ) * ) " $ / ) ;
305352 if ( quotedMatch ) {
306353 const [ , oldPath = "" , newPath = "" ] = quotedMatch ;
354+ const pair = canonicalizeGitPathPair ( oldPath , newPath , blockLines ) ;
307355 // Pierre's git header parser does not currently handle the quoted `"a/..." "b/..."`
308356 // form, so canonicalize quoted paths to the unquoted form even when prefixes exist.
309- return {
310- line : `diff --git ${ withGitPrefix ( oldPath , "a/" ) } ${ withGitPrefix ( newPath , "b/" ) } ` ,
311- changed : true ,
312- } ;
357+ return { line : `diff --git ${ pair . oldPath } ${ pair . newPath } ` , rewriteMode : pair . rewriteMode } ;
313358 }
314359
315360 const tokens = rest . split ( " " ) ;
316361
317- // Already prefixed: `a/X b/Y` (covers single-token and equally split multi-token paths).
318- if ( tokens . length === 2 && tokens [ 0 ] ?. startsWith ( "a/" ) && tokens [ 1 ] ?. startsWith ( "b/" ) ) {
319- return { line, changed : false } ;
320- }
321362 if ( tokens . length >= 2 && tokens . length % 2 === 0 ) {
322363 const half = tokens . length / 2 ;
323364 const firstHalf = tokens . slice ( 0 , half ) . join ( " " ) ;
324365 const secondHalf = tokens . slice ( half ) . join ( " " ) ;
325- if ( firstHalf . startsWith ( "a/" ) && secondHalf . startsWith ( "b/" ) ) {
326- return { line, changed : false } ;
366+ const knownPair = canonicalizeKnownGitPathPair ( firstHalf , secondHalf , blockLines ) ;
367+
368+ if ( knownPair ?. changed ) {
369+ return {
370+ line : `diff --git ${ knownPair . oldPath } ${ knownPair . newPath } ` ,
371+ rewriteMode : knownPair . rewriteMode ,
372+ } ;
373+ }
374+
375+ // Already prefixed: `a/X b/Y` (covers single-token and equally split multi-token paths).
376+ if ( knownPair ?. isCanonical ) {
377+ return { line, rewriteMode : null } ;
327378 }
379+
328380 // Non-rename noprefix: identical halves regardless of whether the path contains spaces.
329381 if ( firstHalf === secondHalf && firstHalf . length > 0 ) {
330- return { line : `diff --git a/${ firstHalf } b/${ secondHalf } ` , changed : true } ;
382+ return { line : `diff --git a/${ firstHalf } b/${ secondHalf } ` , rewriteMode : "add" } ;
331383 }
332384 }
333385
334386 // Two-token rename without prefix and without spaces in either path.
335387 if ( tokens . length === 2 && tokens [ 0 ] && tokens [ 1 ] ) {
336- return { line : `diff --git a/${ tokens [ 0 ] } b/${ tokens [ 1 ] } ` , changed : true } ;
388+ return { line : `diff --git a/${ tokens [ 0 ] } b/${ tokens [ 1 ] } ` , rewriteMode : "add" } ;
337389 }
338390
339391 // Genuinely ambiguous (rename with spaces and no quoting). Leave untouched and let the
340392 // parser surface the existing failure rather than guess at the path split.
341- return { line, changed : false } ;
393+ return { line, rewriteMode : null } ;
394+ }
395+
396+ const GIT_MNEMONIC_PREFIXES = new Set ( [ "c" , "i" , "o" , "w" , "1" , "2" ] ) ;
397+
398+ /** Return one Git mnemonic side prefix from a path, if present. */
399+ function splitGitMnemonicPrefix ( path : string ) {
400+ const [ prefix , ...rest ] = path . split ( "/" ) ;
401+ if ( ! prefix || rest . length === 0 || ! GIT_MNEMONIC_PREFIXES . has ( prefix ) ) {
402+ return null ;
403+ }
404+
405+ return { prefix, path : rest . join ( "/" ) } ;
406+ }
407+
408+ /** Remove Git's outer quotes from one path-like metadata value. */
409+ function stripGitPathQuotes ( path : string ) {
410+ return path . match ( / ^ " ( (?: \\ .| [ ^ " \\ ] ) * ) " $ / ) ?. [ 1 ] ?? path ;
411+ }
412+
413+ /** Return rename metadata, which Git writes without mnemonic side prefixes. */
414+ function findRenameMetadata ( blockLines : string [ ] ) {
415+ const oldPath = blockLines . find ( ( line ) => line . startsWith ( "rename from " ) ) ;
416+ const newPath = blockLines . find ( ( line ) => line . startsWith ( "rename to " ) ) ;
417+
418+ if ( ! oldPath || ! newPath ) {
419+ return null ;
420+ }
421+
422+ return {
423+ oldPath : stripGitPathQuotes ( oldPath . slice ( "rename from " . length ) ) ,
424+ newPath : stripGitPathQuotes ( newPath . slice ( "rename to " . length ) ) ,
425+ } ;
342426}
343427
344428/** Return a path with the expected Git side prefix while avoiding double-prefixing. */
345429function withGitPrefix ( path : string , prefix : "a/" | "b/" ) {
346430 return path . startsWith ( prefix ) ? path : `${ prefix } ${ path } ` ;
347431}
348432
433+ /** Decide whether a mnemonic-looking path pair is real mnemonic output or a noprefix rename. */
434+ function shouldStripMnemonicPair ( oldPath : string , newPath : string , blockLines : string [ ] ) {
435+ const oldMnemonic = splitGitMnemonicPrefix ( oldPath ) ;
436+ const newMnemonic = splitGitMnemonicPrefix ( newPath ) ;
437+
438+ if ( ! oldMnemonic || ! newMnemonic || oldMnemonic . prefix === newMnemonic . prefix ) {
439+ return null ;
440+ }
441+
442+ const rename = findRenameMetadata ( blockLines ) ;
443+ if ( ! rename ) {
444+ return true ;
445+ }
446+
447+ if ( rename . oldPath === oldPath && rename . newPath === newPath ) {
448+ return false ;
449+ }
450+
451+ if ( rename . oldPath === oldMnemonic . path && rename . newPath === newMnemonic . path ) {
452+ return true ;
453+ }
454+
455+ return true ;
456+ }
457+
458+ /** Convert already-prefixed or mnemonic-prefixed path pairs into Pierre's canonical shape. */
459+ function canonicalizeKnownGitPathPair ( oldPath : string , newPath : string , blockLines : string [ ] ) {
460+ const oldMnemonic = splitGitMnemonicPrefix ( oldPath ) ;
461+ const newMnemonic = splitGitMnemonicPrefix ( newPath ) ;
462+ const isCanonical = oldPath . startsWith ( "a/" ) && newPath . startsWith ( "b/" ) ;
463+
464+ if ( isCanonical ) {
465+ return { oldPath, newPath, rewriteMode : "add" as const , changed : false , isCanonical : true } ;
466+ }
467+
468+ if ( oldMnemonic && newMnemonic && shouldStripMnemonicPair ( oldPath , newPath , blockLines ) ) {
469+ return {
470+ oldPath : `a/${ oldMnemonic . path } ` ,
471+ newPath : `b/${ newMnemonic . path } ` ,
472+ rewriteMode : "strip" as const ,
473+ changed : true ,
474+ isCanonical : false ,
475+ } ;
476+ }
477+
478+ return null ;
479+ }
480+
481+ /** Convert one quoted `diff --git` path pair into Pierre's canonical side-prefix shape. */
482+ function canonicalizeGitPathPair ( oldPath : string , newPath : string , blockLines : string [ ] ) {
483+ return (
484+ canonicalizeKnownGitPathPair ( oldPath , newPath , blockLines ) ?? {
485+ oldPath : withGitPrefix ( oldPath , "a/" ) ,
486+ newPath : withGitPrefix ( newPath , "b/" ) ,
487+ rewriteMode : "add" as const ,
488+ changed : true ,
489+ isCanonical : false ,
490+ }
491+ ) ;
492+ }
493+
349494/** Insert the canonical `a/` or `b/` prefix on a unified-diff header that is missing it. */
350- function rewriteUnifiedFileLine ( line : string , marker : "--- " | "+++ " , prefix : "a/" | "b/" ) {
495+ function rewriteUnifiedFileLine (
496+ line : string ,
497+ marker : "--- " | "+++ " ,
498+ prefix : "a/" | "b/" ,
499+ mode : GitHeaderRewriteMode ,
500+ ) {
351501 const path = line . slice ( marker . length ) ;
352502 const quotedPath = path . match ( / ^ " ( (?: \\ .| [ ^ " \\ ] ) * ) " ( .* ) $ / ) ;
353503 const pathName = quotedPath ?. [ 1 ] ?? path ;
@@ -357,7 +507,10 @@ function rewriteUnifiedFileLine(line: string, marker: "--- " | "+++ ", prefix: "
357507 return line ;
358508 }
359509
360- return `${ marker } ${ withGitPrefix ( pathName , prefix ) } ${ suffix } ` ;
510+ const normalizedPath =
511+ mode === "strip" ? ( splitGitMnemonicPrefix ( pathName ) ?. path ?? pathName ) : pathName ;
512+
513+ return `${ marker } ${ withGitPrefix ( normalizedPath , prefix ) } ${ suffix } ` ;
361514}
362515
363516/** Escape only the filename characters that break unified-diff header parsing. */
0 commit comments