Browse Source

Merge pull request #406 from alvinunreal/fix/apply-patch-stale-rescue-performance

Alvin 3 months ago
parent
commit
0591913b88

+ 25 - 0
src/hooks/apply-patch/codec.test.ts

@@ -82,6 +82,31 @@ PATCH`);
     ]);
   });
 
+  test('parsePatchStrict preserves End Patch text when it is hunk context', () => {
+    const markerPadding = '  ';
+    const parsed = parsePatchStrict(`*** Begin Patch${markerPadding}
+*** Update File: sample.txt
+@@ marker
+ *** End Patch
+ keep
+*** End Patch${markerPadding}`);
+
+    expect(parsed.hunks).toEqual([
+      {
+        type: 'update',
+        path: 'sample.txt',
+        chunks: [
+          {
+            old_lines: ['*** End Patch', 'keep'],
+            new_lines: ['*** End Patch', 'keep'],
+            change_context: 'marker',
+            is_end_of_file: undefined,
+          },
+        ],
+      },
+    ]);
+  });
+
   test('parsePatchStrict fails on garbage inside @@', () => {
     expect(() =>
       parsePatchStrict(`*** Begin Patch

+ 23 - 10
src/hooks/apply-patch/codec.ts

@@ -68,6 +68,19 @@ function unexpectedPatchLine(context: string, line: string): never {
   );
 }
 
+function parseChangeContext(line: string): string | undefined {
+  const context = line.slice(2);
+  if (context.length === 0) {
+    return undefined;
+  }
+
+  return context.startsWith(' ') ? context.slice(1) || undefined : context;
+}
+
+function isPatchBoundary(line: string, marker: string): boolean {
+  return line.trimEnd() === marker;
+}
+
 function parseChunks(lines: string[], index: number, mode: ParseMode) {
   const chunks: PatchChunk[] = [];
   let at = index;
@@ -81,7 +94,7 @@ function parseChunks(lines: string[], index: number, mode: ParseMode) {
       continue;
     }
 
-    const context = lines[at].slice(2).trim() || undefined;
+    const context = parseChangeContext(lines[at]);
     at += 1;
 
     const old_lines: string[] = [];
@@ -139,12 +152,12 @@ function parseChunks(lines: string[], index: number, mode: ParseMode) {
 }
 
 function parseAdd(lines: string[], index: number, mode: ParseMode) {
-  let contents = '';
+  const contents: string[] = [];
   let at = index;
 
   while (at < lines.length && !lines[at].startsWith('***')) {
     if (lines[at].startsWith('+')) {
-      contents += `${lines[at].slice(1)}\n`;
+      contents.push(lines[at].slice(1));
       at += 1;
       continue;
     }
@@ -156,18 +169,18 @@ function parseAdd(lines: string[], index: number, mode: ParseMode) {
     at += 1;
   }
 
-  if (contents.endsWith('\n')) {
-    contents = contents.slice(0, -1);
-  }
-
-  return { content: contents, next: at };
+  return { content: contents.join('\n'), next: at };
 }
 
 function parsePatchInternal(patchText: string, mode: ParseMode): ParsedPatch {
   const clean = normalizePatchText(patchText);
   const lines = clean.split('\n');
-  const begin = lines.findIndex((line) => line.trim() === '*** Begin Patch');
-  const end = lines.findIndex((line) => line.trim() === '*** End Patch');
+  const begin = lines.findIndex((line) =>
+    isPatchBoundary(line, '*** Begin Patch'),
+  );
+  const end = lines.findIndex(
+    (line, index) => index > begin && isPatchBoundary(line, '*** End Patch'),
+  );
 
   if (begin === -1 || end === -1 || begin >= end) {
     throw new Error('Invalid patch format: missing Begin/End markers');

+ 7 - 13
src/hooks/apply-patch/index.ts

@@ -50,35 +50,29 @@ export function createApplyPatchHook(ctx: PluginInput) {
         return;
       }
 
-      if (typeof output.args?.patchText !== 'string') {
+      const args = output.args;
+      if (!args || typeof args.patchText !== 'string') {
         return;
       }
+      const patchText = args.patchText;
 
       const root = input.directory || ctx.directory || process.cwd();
       const worktree = ctx.worktree || root;
-
       try {
         const result = await rewritePatch(
           root,
-          output.args.patchText,
+          patchText,
           APPLY_PATCH_RESCUE_OPTIONS,
           worktree,
         );
 
         if (result.changed) {
-          output.args.patchText = result.patchText;
-          logHookStatus('rewrite', {
-            rewrittenChunks: result.rewrittenChunks,
-            totalChunks: result.totalChunks,
-            strategies: result.rewriteModes,
-          });
+          args.patchText = result.patchText;
+          logHookStatus('rewrite');
           return;
         }
 
-        logHookStatus('unchanged', {
-          rewrittenChunks: 0,
-          totalChunks: result.totalChunks,
-        });
+        logHookStatus('unchanged');
         return;
       } catch (error) {
         const normalizedError = isApplyPatchError(error)

+ 40 - 0
src/hooks/apply-patch/matching.test.ts

@@ -69,6 +69,46 @@ describe('apply-patch/matching', () => {
     ).toEqual({ kind: 'ambiguous', phase: 'prefix_suffix' });
   });
 
+  test('rescueByPrefixSuffix preserves one-line unicode plus trim-end pairing', () => {
+    expect(
+      rescueByPrefixSuffix(
+        ['left “x”', 'stale', 'right  '],
+        ['left "x"', 'old', 'right'],
+        ['left "x"', 'new', 'right'],
+        0,
+      ),
+    ).toEqual({
+      kind: 'match',
+      hit: {
+        start: 1,
+        del: 1,
+        add: ['new'],
+      },
+    });
+  });
+
+  test('rescueByPrefixSuffix keeps tolerant one-line ambiguity detection', () => {
+    expect(
+      rescueByPrefixSuffix(
+        ['left', 'stale-one', 'right', 'left  ', 'stale-two', 'right'],
+        ['left', 'old', 'right'],
+        ['left', 'new', 'right'],
+        0,
+      ),
+    ).toEqual({ kind: 'ambiguous', phase: 'prefix_suffix' });
+  });
+
+  test('rescueByPrefixSuffix ignores one-line right hits before the left edge', () => {
+    expect(
+      rescueByPrefixSuffix(
+        ['right', 'left', 'stale'],
+        ['left', 'old', 'right'],
+        ['left', 'new', 'right'],
+        0,
+      ),
+    ).toEqual({ kind: 'miss' });
+  });
+
   test('rescueByLcs respects the start and finds a single candidate', () => {
     const result = rescueByLcs(
       [

+ 216 - 85
src/hooks/apply-patch/matching.ts

@@ -13,12 +13,12 @@ type NamedComparator = {
   same: LineComparator;
 };
 
-const AUTO_RESCUE_COMPARATOR_NAMES = new Set<MatchComparatorName>([
-  'exact',
-  'unicode',
-  'trim-end',
-  'unicode-trim-end',
-]);
+export type PreparedAutoRescueTarget = {
+  exact: string;
+  unicode: string;
+  trimEnd: string;
+  unicodeTrimEnd: string;
+};
 
 export function equalExact(a: string, b: string): boolean {
   return a === b;
@@ -44,7 +44,7 @@ export function equalUnicodeTrim(a: string, b: string): boolean {
   return normalizeUnicode(a.trim()) === normalizeUnicode(b.trim());
 }
 
-const comparatorEntries: NamedComparator[] = [
+const autoRescueComparatorEntries: NamedComparator[] = [
   { name: 'exact', exact: true, same: equalExact },
   { name: 'unicode', exact: false, same: equalUnicodeExact },
   { name: 'trim-end', exact: false, same: equalTrimEnd },
@@ -53,20 +53,61 @@ const comparatorEntries: NamedComparator[] = [
     exact: false,
     same: equalUnicodeTrimEnd,
   },
+];
+
+const comparatorEntries: NamedComparator[] = [
+  ...autoRescueComparatorEntries,
   { name: 'trim', exact: false, same: equalTrim },
   { name: 'unicode-trim', exact: false, same: equalUnicodeTrim },
 ];
 
-const autoRescueComparatorEntries = comparatorEntries.filter((entry) =>
-  AUTO_RESCUE_COMPARATOR_NAMES.has(entry.name),
-);
-
 const MAX_LCS_CHUNK_LINES = 48;
 const MAX_LCS_CANDIDATES = 64;
 
 export const autoRescueComparators: LineComparator[] =
   autoRescueComparatorEntries.map((entry) => entry.same);
 
+export function prepareAutoRescueTarget(
+  target: string,
+): PreparedAutoRescueTarget {
+  const trimEnd = target.trimEnd();
+  const unicode = normalizeUnicode(target);
+
+  return {
+    exact: target,
+    unicode,
+    trimEnd,
+    unicodeTrimEnd: trimEnd === target ? unicode : normalizeUnicode(trimEnd),
+  };
+}
+
+export function matchPreparedAutoRescueComparator(
+  candidate: string,
+  target: PreparedAutoRescueTarget,
+): MatchComparatorName | undefined {
+  if (candidate === target.exact) {
+    return 'exact';
+  }
+
+  const unicode = normalizeUnicode(candidate);
+  if (unicode === target.unicode) {
+    return 'unicode';
+  }
+
+  const trimEnd = candidate.trimEnd();
+  if (trimEnd === target.trimEnd) {
+    return 'trim-end';
+  }
+
+  const unicodeTrimEnd =
+    trimEnd === candidate ? unicode : normalizeUnicode(trimEnd);
+  if (unicodeTrimEnd === target.unicodeTrimEnd) {
+    return 'unicode-trim-end';
+  }
+
+  return undefined;
+}
+
 // Full-trim comparators remain available as explicit utilities, but stay out
 // of automatic canonicalization because they can cross indentation levels and
 // rescue semantically unsafe patches.
@@ -183,6 +224,23 @@ export function list(
   return out;
 }
 
+function lowerBound(values: number[], target: number): number {
+  let low = 0;
+  let high = values.length;
+
+  while (low < high) {
+    const middle = Math.floor((low + high) / 2);
+    if (values[middle] < target) {
+      low = middle + 1;
+      continue;
+    }
+
+    high = middle;
+  }
+
+  return low;
+}
+
 export function sameRescueLine(a: string, b: string): boolean {
   return equalExact(a, b) || equalUnicodeExact(a, b);
 }
@@ -238,49 +296,159 @@ export function rescueByPrefixSuffix(
   const left = old_lines.slice(0, prefixLength);
   const right = old_lines.slice(old_lines.length - suffixLength);
   const middle = new_lines.slice(prefixLength, new_lines.length - suffixLength);
-  const hits = new Map<string, MatchHit>();
+
+  if (left.length === 1 && right.length === 1) {
+    const { leftHits, rightHits } = collectOneLinePrefixSuffixHits(
+      lines,
+      left[0],
+      right[0],
+      start,
+    );
+
+    return resolvePrefixSuffixHits(leftHits, rightHits, left.length, middle);
+  }
+
+  const hits = new Set<string>();
+  let hit: MatchHit | undefined;
 
   for (const same of autoRescueComparators) {
-    for (const leftIndex of list(lines, left, start, same)) {
+    const leftHits = list(lines, left, start, same);
+    if (leftHits.length === 0) {
+      continue;
+    }
+
+    const rightHits = list(lines, right, leftHits[0] + left.length, same);
+    if (rightHits.length === 0) {
+      continue;
+    }
+
+    for (const leftIndex of leftHits) {
       const from = leftIndex + left.length;
 
-      for (const rightIndex of list(lines, right, from, same)) {
+      for (
+        let index = lowerBound(rightHits, from);
+        index < rightHits.length;
+        index += 1
+      ) {
+        const rightIndex = rightHits[index];
         const key = `${from}:${rightIndex}`;
-        hits.set(key, {
+        if (!hits.has(key)) {
+          hits.add(key);
+          hit = {
+            start: from,
+            del: rightIndex - from,
+            add: [...middle],
+          };
+        }
+
+        if (hits.size > 1) {
+          return { kind: 'ambiguous', phase: 'prefix_suffix' };
+        }
+      }
+    }
+  }
+
+  if (!hit) {
+    return { kind: 'miss' };
+  }
+
+  return { kind: 'match', hit };
+}
+
+function collectOneLinePrefixSuffixHits(
+  lines: string[],
+  left: string,
+  right: string,
+  start: number,
+): { leftHits: number[]; rightHits: number[] } {
+  const leftTarget = prepareAutoRescueTarget(left);
+  const rightTarget = prepareAutoRescueTarget(right);
+  const leftHits: number[] = [];
+  const rightHits: number[] = [];
+
+  // The one-line prefix/suffix fast path intentionally compares at the
+  // broadest safe automatic level. This preserves exact/unicode/trim-end
+  // behavior while avoiding multiple full scans for the common one-line edge
+  // case. Full-trim remains excluded from automatic rescue.
+  for (let index = start; index < lines.length; index += 1) {
+    const line = prepareAutoRescueTarget(lines[index]);
+
+    if (line.unicodeTrimEnd === leftTarget.unicodeTrimEnd) {
+      leftHits.push(index);
+    }
+
+    if (index > start && line.unicodeTrimEnd === rightTarget.unicodeTrimEnd) {
+      rightHits.push(index);
+    }
+  }
+
+  return { leftHits, rightHits };
+}
+
+function resolvePrefixSuffixHits(
+  leftHits: number[],
+  rightHits: number[],
+  leftLength: number,
+  middle: string[],
+): RescueResult {
+  if (leftHits.length === 0 || rightHits.length === 0) {
+    return { kind: 'miss' };
+  }
+
+  const hits = new Set<string>();
+  let hit: MatchHit | undefined;
+
+  for (const leftIndex of leftHits) {
+    const from = leftIndex + leftLength;
+
+    for (
+      let index = lowerBound(rightHits, from);
+      index < rightHits.length;
+      index += 1
+    ) {
+      const rightIndex = rightHits[index];
+      const key = `${from}:${rightIndex}`;
+      if (!hits.has(key)) {
+        hits.add(key);
+        hit = {
           start: from,
           del: rightIndex - from,
           add: [...middle],
-        });
+        };
+      }
+
+      if (hits.size > 1) {
+        return { kind: 'ambiguous', phase: 'prefix_suffix' };
       }
     }
   }
 
-  if (hits.size === 0) {
+  if (!hit) {
     return { kind: 'miss' };
   }
 
-  if (hits.size > 1) {
-    return { kind: 'ambiguous', phase: 'prefix_suffix' };
-  }
-
-  return { kind: 'match', hit: [...hits.values()][0] };
+  return { kind: 'match', hit };
 }
 
 export function score(a: string[], b: string[]): number {
-  const dp = Array.from({ length: a.length + 1 }, () =>
-    Array<number>(b.length + 1).fill(0),
-  );
+  const normalizedA = a.map(normalizeLcsLine);
+  const normalizedB = b.map(normalizeLcsLine);
+  let previous = Array<number>(b.length + 1).fill(0);
 
   for (let i = 1; i <= a.length; i += 1) {
+    const current = Array<number>(b.length + 1).fill(0);
+
     for (let j = 1; j <= b.length; j += 1) {
-      dp[i][j] =
-        normalizeUnicode(a[i - 1].trim()) === normalizeUnicode(b[j - 1].trim())
-          ? dp[i - 1][j - 1] + 1
-          : Math.max(dp[i - 1][j], dp[i][j - 1]);
+      current[j] =
+        normalizedA[i - 1] === normalizedB[j - 1]
+          ? previous[j - 1] + 1
+          : Math.max(previous[j], current[j - 1]);
     }
+
+    previous = current;
   }
 
-  return dp[a.length][b.length];
+  return previous[b.length];
 }
 
 function normalizeLcsLine(line: string): string {
@@ -315,30 +483,6 @@ function countLcsUpperBound(a: string[], b: string[]): number {
   return shared;
 }
 
-function hasStableBorders(oldLines: string[], candidate: string[]): boolean {
-  if (oldLines.length === 0 || candidate.length !== oldLines.length) {
-    return false;
-  }
-
-  // LCS keeps its current scoring, but only competes across windows whose
-  // edges pass safe comparators. Ignoring full-trim here prevents automatic
-  // rescue from changing indentation depth in format-sensitive files.
-  const same = autoRescueComparators.some((compare) =>
-    compare(oldLines[0], candidate[0]),
-  );
-  if (!same) {
-    return false;
-  }
-
-  if (oldLines.length === 1) {
-    return true;
-  }
-
-  return autoRescueComparators.some((compare) =>
-    compare(oldLines[oldLines.length - 1], candidate[candidate.length - 1]),
-  );
-}
-
 function collectBorderAnchoredStarts(
   lines: string[],
   oldLines: string[],
@@ -348,33 +492,31 @@ function collectBorderAnchoredStarts(
     return [];
   }
 
-  const firstHits = new Set<number>();
-  const lastHits = new Set<number>();
-  const lastLine = oldLines[oldLines.length - 1];
+  const candidates: number[] = [];
+  const firstLine = prepareAutoRescueTarget(oldLines[0]);
+  const lastLine = prepareAutoRescueTarget(oldLines[oldLines.length - 1]);
 
-  for (const same of autoRescueComparators) {
-    for (const index of list(lines, [oldLines[0]], start, same)) {
-      firstHits.add(index);
-    }
+  // LCS keeps its current scoring, but only competes across windows whose
+  // edges pass safe comparators. Ignoring full-trim here prevents automatic
+  // rescue from changing indentation depth in format-sensitive files.
+  const lastOffset = oldLines.length - 1;
+  const maxStart = lines.length - oldLines.length;
 
-    for (const index of list(lines, [lastLine], start, same)) {
-      lastHits.add(index);
-    }
-  }
+  for (let index = start; index <= maxStart; index += 1) {
+    const end = index + lastOffset;
 
-  const candidates: number[] = [];
-  for (const index of [...firstHits].sort((a, b) => a - b)) {
-    const end = index + oldLines.length - 1;
-    if (end >= lines.length || !lastHits.has(end)) {
+    if (
+      matchPreparedAutoRescueComparator(lines[index], firstLine) === undefined
+    ) {
       continue;
     }
 
-    const candidate = lines.slice(index, index + oldLines.length);
-    if (!hasStableBorders(oldLines, candidate)) {
-      continue;
+    if (
+      oldLines.length === 1 ||
+      matchPreparedAutoRescueComparator(lines[end], lastLine) !== undefined
+    ) {
+      candidates.push(index);
     }
-
-    candidates.push(index);
   }
 
   return candidates;
@@ -390,13 +532,6 @@ export function rescueByLcs(
     return { kind: 'miss' };
   }
 
-  const from = start;
-  const to = lines.length - old_lines.length;
-
-  if (to < from) {
-    return { kind: 'miss' };
-  }
-
   if (old_lines.length > MAX_LCS_CHUNK_LINES) {
     return { kind: 'miss' };
   }
@@ -416,10 +551,6 @@ export function rescueByLcs(
   let ties = 0;
 
   for (const index of candidates) {
-    if (index < from || index > to) {
-      continue;
-    }
-
     const window = lines.slice(index, index + old_lines.length);
     if (countLcsUpperBound(old_lines, window) < needed) {
       continue;

+ 0 - 10
src/hooks/apply-patch/operations.test.ts

@@ -67,8 +67,6 @@ describe('apply-patch/operations', () => {
     expect(await rewritePatch(root, patchText, DEFAULT_OPTIONS)).toMatchObject({
       patchText,
       changed: false,
-      rewrittenChunks: 0,
-      rewriteModes: [],
     });
   });
 
@@ -96,8 +94,6 @@ PATCH`;
     expect(await rewritePatch(root, patchText, DEFAULT_OPTIONS)).toMatchObject({
       patchText: cleanPatchText,
       changed: true,
-      rewrittenChunks: 0,
-      rewriteModes: ['normalize:patch-text'],
     });
   });
 
@@ -370,7 +366,6 @@ garbage
     );
 
     expect(rewritten.changed).toBeTrue();
-    expect(rewritten.rewriteModes).toContain('normalize:patch-paths');
     const [rewrittenHunk] = parsePatch(rewritten.patchText).hunks;
     expect(rewrittenHunk.type).toBe('update');
     expect(rewrittenHunk.path).toBe('sample.txt');
@@ -814,7 +809,6 @@ garbage
 
     const rewritten = parsePatch(result.patchText);
     expect(result.changed).toBeTrue();
-    expect(result.rewriteModes).toContain('merge:same-file-updates');
     expect(rewritten.hunks).toHaveLength(1);
     expect(rewritten.hunks[0]).toEqual({
       type: 'update',
@@ -904,7 +898,6 @@ garbage
     );
 
     expect(result.changed).toBeTrue();
-    expect(result.rewriteModes).toContain('collapse:add-followed-by-update');
     expect(parsePatch(result.patchText).hunks).toEqual([
       {
         type: 'add',
@@ -934,7 +927,6 @@ garbage
     );
 
     expect(result.changed).toBeTrue();
-    expect(result.rewriteModes).toContain('collapse:add-followed-by-update');
     expect(parsePatch(result.patchText).hunks).toEqual([
       {
         type: 'add',
@@ -969,7 +961,6 @@ garbage
     );
 
     expect(result.changed).toBeTrue();
-    expect(result.rewriteModes).toContain('collapse:move-followed-by-update');
     expect(parsePatch(result.patchText).hunks).toEqual([
       {
         type: 'update',
@@ -1011,7 +1002,6 @@ garbage
     );
 
     expect(result.changed).toBeTrue();
-    expect(result.rewriteModes).toContain('collapse:move-followed-by-update');
     expect(parsePatch(result.patchText).hunks).toEqual([
       {
         type: 'update',

+ 45 - 0
src/hooks/apply-patch/resolution.test.ts

@@ -265,6 +265,31 @@ describe('apply-patch/resolution', () => {
     });
   });
 
+  test('resolveUpdateChunks canonicalizes non-EOF insertion with a trim-end anchor', async () => {
+    const root = await createTempDir();
+    const file = path.join(root, 'sample.txt');
+    await writeFixture(root, 'sample.txt', 'top\nanchor  \nbottom\n');
+
+    const { resolved } = await resolveUpdateChunks(
+      file,
+      [
+        {
+          old_lines: [],
+          new_lines: ['middle'],
+          change_context: 'anchor',
+        },
+      ],
+      DEFAULT_OPTIONS,
+    );
+
+    expect(resolved[0]).toMatchObject({
+      canonical_change_context: 'anchor  ',
+      rewritten: true,
+      strategy: 'anchor',
+      matchComparator: 'trim-end',
+    });
+  });
+
   test('deriveNewContent fails if a pure insertion cannot find its anchor', async () => {
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
@@ -309,6 +334,26 @@ describe('apply-patch/resolution', () => {
     ).rejects.toThrow('Insertion anchor was ambiguous');
   });
 
+  test('deriveNewContent fails if a tolerant insertion anchor is ambiguous', async () => {
+    const root = await createTempDir();
+    const file = path.join(root, 'sample.txt');
+    await writeFixture(root, 'sample.txt', 'top\n“anchor”\n"anchor"\n');
+
+    await expect(
+      deriveNewContent(
+        file,
+        [
+          {
+            old_lines: [],
+            new_lines: ['middle'],
+            change_context: '"anchor"',
+          },
+        ],
+        DEFAULT_OPTIONS,
+      ),
+    ).rejects.toThrow('Insertion anchor was ambiguous');
+  });
+
   test('deriveNewContent fails if a later chunk remains ambiguous', async () => {
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');

+ 27 - 17
src/hooks/apply-patch/resolution.ts

@@ -1,9 +1,9 @@
 import * as fs from 'node:fs/promises';
 
 import {
-  autoRescueComparators,
-  list,
+  matchPreparedAutoRescueComparator,
   prefix,
+  prepareAutoRescueTarget,
   rescueByLcs,
   rescueByPrefixSuffix,
   seek,
@@ -80,31 +80,38 @@ function resolveUniqueAnchor(
       comparator: MatchComparatorName;
       canonicalLine: string;
     } {
-  const hits = new Set<number>();
+  let matchedIndex: number | undefined;
+  let matchedComparator: MatchComparatorName | undefined;
+  const anchorTarget = prepareAutoRescueTarget(changeContext);
+
+  for (let index = start; index < lines.length; index += 1) {
+    const comparator = matchPreparedAutoRescueComparator(
+      lines[index],
+      anchorTarget,
+    );
+    if (!comparator) {
+      continue;
+    }
 
-  for (const same of autoRescueComparators) {
-    for (const index of list(lines, [changeContext], start, same)) {
-      hits.add(index);
+    if (matchedIndex !== undefined) {
+      return { kind: 'ambiguous' };
     }
-  }
 
-  if (hits.size === 0) {
-    return { kind: 'missing' };
+    matchedIndex = index;
+    matchedComparator = comparator;
   }
 
-  if (hits.size > 1) {
-    return { kind: 'ambiguous' };
+  if (matchedIndex === undefined) {
+    return { kind: 'missing' };
   }
 
-  const index = [...hits][0];
-  const canonicalLine = lines[index];
-  const comparator = seekMatch(lines, [changeContext], index)?.comparator;
+  const canonicalLine = lines[matchedIndex];
 
   return {
     kind: 'match',
-    index,
+    index: matchedIndex,
     exact: canonicalLine === changeContext,
-    comparator: comparator ?? 'exact',
+    comparator: matchedComparator ?? 'exact',
     canonicalLine,
   };
 }
@@ -322,10 +329,13 @@ function resolveUpdateChunksFromFileLines(
         old_lines: [],
         canonical_old_lines: [anchor],
         canonical_new_lines: [...chunk.new_lines, anchor],
+        canonical_change_context: anchorMatch.exact
+          ? undefined
+          : anchorMatch.canonicalLine,
         resolved_is_end_of_file: insertAt + 1 === lines.length,
         rewritten: true,
         strategy,
-        matchComparator: 'exact',
+        matchComparator: anchorMatch.comparator,
       });
       start = insertAt;
       continue;

+ 0 - 48
src/hooks/apply-patch/rewrite.ts

@@ -20,9 +20,6 @@ import type {
 export type RewritePatchResult = {
   patchText: string;
   changed: boolean;
-  rewrittenChunks: number;
-  totalChunks: number;
-  rewriteModes: string[];
 };
 
 type RewriteUpdateGroup = {
@@ -253,18 +250,6 @@ function renderRewriteDependencyGroup(
       );
 }
 
-function rewriteModeForDependentUpdate(group: RewriteDependencyGroup): string {
-  if (group.kind === 'add') {
-    return 'collapse:add-followed-by-update';
-  }
-
-  if (group.group.outputPath !== group.group.sourcePath) {
-    return 'collapse:move-followed-by-update';
-  }
-
-  return 'merge:same-file-updates';
-}
-
 function combineDependentUpdateGroup(
   filePath: string,
   group: RewriteDependencyGroup,
@@ -328,16 +313,6 @@ export async function rewritePatch(
     const normalizedPatchText = normalizePatchText(patchText);
     const rewritten: PatchHunk[] = [];
     let changed = false;
-    let rewrittenChunks = 0;
-    const rewriteModes = new Set<string>();
-    const totalChunks = hunks.reduce(
-      (count, hunk) =>
-        count + (hunk.type === 'update' ? hunk.chunks.length : 0),
-      0,
-    );
-    if (pathsNormalized) {
-      rewriteModes.add('normalize:patch-paths');
-    }
 
     const dependencyGroups = new Map<string, RewriteDependencyGroup>();
 
@@ -416,17 +391,7 @@ export async function rewritePatch(
         if (!chunk.rewritten) {
           continue;
         }
-
         changed = true;
-        rewrittenChunks += 1;
-        if (chunk.strategy) {
-          rewriteModes.add(chunk.strategy);
-          continue;
-        }
-
-        if (chunk.matchComparator && chunk.matchComparator !== 'exact') {
-          rewriteModes.add(`match:${chunk.matchComparator}`);
-        }
       }
 
       const nextOutputPath = hunk.move_path ?? hunk.path;
@@ -447,7 +412,6 @@ export async function rewritePatch(
           cfg,
         );
         changed = true;
-        rewriteModes.add(rewriteModeForDependentUpdate(currentDependency));
         clearDependencyGroup(filePath);
         if (movePath && movePath !== filePath) {
           clearDependencyGroup(movePath);
@@ -497,9 +461,6 @@ export async function rewritePatch(
         return {
           patchText: formatPatch({ hunks }),
           changed: true,
-          rewrittenChunks: 0,
-          totalChunks,
-          rewriteModes: [...rewriteModes].sort(),
         };
       }
 
@@ -507,27 +468,18 @@ export async function rewritePatch(
         return {
           patchText: normalizedPatchText,
           changed: true,
-          rewrittenChunks: 0,
-          totalChunks,
-          rewriteModes: ['normalize:patch-text'],
         };
       }
 
       return {
         patchText,
         changed: false,
-        rewrittenChunks: 0,
-        totalChunks,
-        rewriteModes: [],
       };
     }
 
     return {
       patchText: formatPatch({ hunks: rewritten }),
       changed: true,
-      rewrittenChunks,
-      totalChunks,
-      rewriteModes: [...rewriteModes].sort(),
     };
   } catch (error) {
     throw ensureApplyPatchError(error, 'Unexpected rewrite failure');