rewrite.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. import path from 'node:path';
  2. import { formatPatch, normalizePatchText } from './codec';
  3. import {
  4. createApplyPatchVerificationError,
  5. ensureApplyPatchError,
  6. } from './errors';
  7. import {
  8. createPatchExecutionContext,
  9. resolvePreparedUpdate,
  10. stageAddedText,
  11. } from './execution-context';
  12. import { deriveNewContentFromText } from './resolution';
  13. import type {
  14. ApplyPatchRuntimeOptions,
  15. PatchHunk,
  16. UpdatePatchHunk,
  17. } from './types';
  18. export type RewritePatchResult = {
  19. patchText: string;
  20. changed: boolean;
  21. };
  22. type RewriteUpdateGroup = {
  23. index: number;
  24. sourcePath: string;
  25. outputPath: string;
  26. sourceFilePath: string;
  27. outputFilePath: string;
  28. baseText: string;
  29. finalText: string;
  30. chunks?: UpdatePatchHunk['chunks'];
  31. };
  32. type RewriteAddGroup = {
  33. index: number;
  34. outputPath: string;
  35. outputFilePath: string;
  36. finalText: string;
  37. };
  38. type RewriteDependencyGroup =
  39. | { kind: 'add'; group: RewriteAddGroup }
  40. | { kind: 'update'; group: RewriteUpdateGroup };
  41. function normalizeTextLineEndings(text: string): string {
  42. return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  43. }
  44. function splitPatchTextLines(text: string): string[] {
  45. const normalized = normalizeTextLineEndings(text);
  46. const lines = normalized.split('\n');
  47. if (normalized.endsWith('\n')) {
  48. lines.pop();
  49. }
  50. return lines;
  51. }
  52. function createCollapsedUpdateHunk(
  53. pathValue: string,
  54. filePath: string,
  55. baseText: string,
  56. finalText: string,
  57. cfg: ApplyPatchRuntimeOptions,
  58. movePath?: string,
  59. ): UpdatePatchHunk {
  60. const collapsedChunk = {
  61. old_lines: splitPatchTextLines(baseText),
  62. new_lines: splitPatchTextLines(finalText),
  63. change_context: undefined,
  64. is_end_of_file: true,
  65. } satisfies UpdatePatchHunk['chunks'][number];
  66. const minimizedChunk = minimizeMergedChunk(collapsedChunk);
  67. const chunk =
  68. minimizedChunk.old_lines.length === collapsedChunk.old_lines.length &&
  69. minimizedChunk.new_lines.length === collapsedChunk.new_lines.length &&
  70. minimizedChunk.change_context === collapsedChunk.change_context &&
  71. minimizedChunk.is_end_of_file === collapsedChunk.is_end_of_file
  72. ? collapsedChunk
  73. : (() => {
  74. try {
  75. return deriveNewContentFromText(
  76. filePath,
  77. baseText,
  78. [minimizedChunk],
  79. cfg,
  80. ) === finalText
  81. ? minimizedChunk
  82. : collapsedChunk;
  83. } catch {
  84. // Keep the whole-file chunk when trimming shared context would make
  85. // the fallback ambiguous or no longer reproduce the same result.
  86. return collapsedChunk;
  87. }
  88. })();
  89. return {
  90. type: 'update',
  91. path: pathValue,
  92. move_path: movePath,
  93. chunks: [chunk],
  94. };
  95. }
  96. function clonePatchChunks(
  97. chunks: UpdatePatchHunk['chunks'],
  98. ): UpdatePatchHunk['chunks'] {
  99. return chunks.map((chunk) => ({
  100. old_lines: [...chunk.old_lines],
  101. new_lines: [...chunk.new_lines],
  102. change_context: chunk.change_context,
  103. is_end_of_file: chunk.is_end_of_file,
  104. }));
  105. }
  106. function minimizeMergedChunk(chunk: UpdatePatchHunk['chunks'][number]) {
  107. if (chunk.old_lines.length === 0 && chunk.new_lines.length === 0) {
  108. return {
  109. old_lines: [],
  110. new_lines: [],
  111. change_context: chunk.change_context,
  112. is_end_of_file: chunk.is_end_of_file,
  113. };
  114. }
  115. let prefixLength = 0;
  116. while (
  117. prefixLength < chunk.old_lines.length &&
  118. prefixLength < chunk.new_lines.length &&
  119. chunk.old_lines[prefixLength] === chunk.new_lines[prefixLength]
  120. ) {
  121. prefixLength += 1;
  122. }
  123. let suffixLength = 0;
  124. while (
  125. chunk.old_lines.length - suffixLength - 1 >= prefixLength &&
  126. chunk.new_lines.length - suffixLength - 1 >= prefixLength &&
  127. chunk.old_lines[chunk.old_lines.length - suffixLength - 1] ===
  128. chunk.new_lines[chunk.new_lines.length - suffixLength - 1]
  129. ) {
  130. suffixLength += 1;
  131. }
  132. if (prefixLength === 0 && suffixLength === 0) {
  133. return {
  134. old_lines: [...chunk.old_lines],
  135. new_lines: [...chunk.new_lines],
  136. change_context: chunk.change_context,
  137. is_end_of_file: chunk.is_end_of_file,
  138. };
  139. }
  140. return {
  141. old_lines: chunk.old_lines.slice(
  142. prefixLength,
  143. chunk.old_lines.length - suffixLength,
  144. ),
  145. new_lines: chunk.new_lines.slice(
  146. prefixLength,
  147. chunk.new_lines.length - suffixLength,
  148. ),
  149. change_context:
  150. prefixLength > 0
  151. ? chunk.old_lines[prefixLength - 1]
  152. : chunk.change_context,
  153. is_end_of_file:
  154. chunk.is_end_of_file && suffixLength === 0 ? true : undefined,
  155. };
  156. }
  157. function createUpdateHunk(
  158. pathValue: string,
  159. chunks: UpdatePatchHunk['chunks'],
  160. movePath?: string,
  161. ): UpdatePatchHunk {
  162. return {
  163. type: 'update',
  164. path: pathValue,
  165. move_path: movePath,
  166. chunks: clonePatchChunks(chunks),
  167. };
  168. }
  169. function mergeSameFileUpdateGroupChunks(
  170. filePath: string,
  171. group: RewriteUpdateGroup,
  172. nextChunks: UpdatePatchHunk['chunks'],
  173. finalText: string,
  174. cfg: ApplyPatchRuntimeOptions,
  175. ): UpdatePatchHunk['chunks'] | undefined {
  176. if (!group.chunks) {
  177. return undefined;
  178. }
  179. const mergedChunks = [
  180. ...clonePatchChunks(group.chunks).map(minimizeMergedChunk),
  181. ...clonePatchChunks(nextChunks).map(minimizeMergedChunk),
  182. ];
  183. try {
  184. const mergedText = deriveNewContentFromText(
  185. filePath,
  186. group.baseText,
  187. mergedChunks,
  188. cfg,
  189. );
  190. return mergedText === finalText ? mergedChunks : undefined;
  191. } catch {
  192. return undefined;
  193. }
  194. }
  195. function addContentsFromFinalText(text: string): string {
  196. return text.endsWith('\n') ? text.slice(0, -1) : text;
  197. }
  198. function renderRewriteDependencyGroup(
  199. group: RewriteDependencyGroup,
  200. cfg: ApplyPatchRuntimeOptions,
  201. ): PatchHunk {
  202. if (group.kind === 'add') {
  203. return {
  204. type: 'add',
  205. path: group.group.outputPath,
  206. contents: addContentsFromFinalText(group.group.finalText),
  207. };
  208. }
  209. return group.group.chunks
  210. ? createUpdateHunk(
  211. group.group.sourcePath,
  212. group.group.chunks,
  213. group.group.outputPath !== group.group.sourcePath
  214. ? group.group.outputPath
  215. : undefined,
  216. )
  217. : createCollapsedUpdateHunk(
  218. group.group.sourcePath,
  219. group.group.sourceFilePath,
  220. group.group.baseText,
  221. group.group.finalText,
  222. cfg,
  223. group.group.outputPath !== group.group.sourcePath
  224. ? group.group.outputPath
  225. : undefined,
  226. );
  227. }
  228. function combineDependentUpdateGroup(
  229. filePath: string,
  230. group: RewriteDependencyGroup,
  231. nextChunks: UpdatePatchHunk['chunks'],
  232. finalText: string,
  233. nextOutputPath: string,
  234. nextOutputFilePath: string,
  235. cfg: ApplyPatchRuntimeOptions,
  236. ): RewriteDependencyGroup {
  237. if (group.kind === 'add') {
  238. return {
  239. kind: 'add',
  240. group: {
  241. ...group.group,
  242. outputPath: nextOutputPath,
  243. outputFilePath: nextOutputFilePath,
  244. finalText,
  245. },
  246. };
  247. }
  248. const mergedChunks =
  249. group.group.outputFilePath === filePath &&
  250. group.group.sourceFilePath === filePath &&
  251. nextOutputFilePath === filePath
  252. ? mergeSameFileUpdateGroupChunks(
  253. filePath,
  254. group.group,
  255. nextChunks,
  256. finalText,
  257. cfg,
  258. )
  259. : undefined;
  260. return {
  261. kind: 'update',
  262. group: {
  263. ...group.group,
  264. outputPath: nextOutputPath,
  265. outputFilePath: nextOutputFilePath,
  266. finalText,
  267. chunks: mergedChunks,
  268. },
  269. };
  270. }
  271. export async function rewritePatch(
  272. root: string,
  273. patchText: string,
  274. cfg: ApplyPatchRuntimeOptions,
  275. worktree?: string,
  276. ): Promise<RewritePatchResult> {
  277. try {
  278. const {
  279. hunks,
  280. pathsNormalized,
  281. staged,
  282. getPreparedFileState,
  283. assertPreparedPathMissing,
  284. } = await createPatchExecutionContext(root, patchText, worktree);
  285. const normalizedPatchText = normalizePatchText(patchText);
  286. const rewritten: PatchHunk[] = [];
  287. let changed = false;
  288. const dependencyGroups = new Map<string, RewriteDependencyGroup>();
  289. function clearDependencyGroup(filePath: string) {
  290. dependencyGroups.delete(filePath);
  291. }
  292. for (const hunk of hunks) {
  293. if (hunk.type === 'add') {
  294. const filePath = path.resolve(root, hunk.path);
  295. await assertPreparedPathMissing(filePath, 'add');
  296. rewritten.push(hunk);
  297. clearDependencyGroup(filePath);
  298. const finalText = stageAddedText(hunk.contents);
  299. staged.set(filePath, {
  300. exists: true,
  301. text: finalText,
  302. derived: true,
  303. });
  304. dependencyGroups.set(filePath, {
  305. kind: 'add',
  306. group: {
  307. index: rewritten.length - 1,
  308. outputPath: hunk.path,
  309. outputFilePath: filePath,
  310. finalText,
  311. },
  312. });
  313. continue;
  314. }
  315. if (hunk.type === 'delete') {
  316. const filePath = path.resolve(root, hunk.path);
  317. await getPreparedFileState(filePath, 'delete');
  318. clearDependencyGroup(filePath);
  319. rewritten.push(hunk);
  320. staged.set(filePath, { exists: false, derived: true });
  321. continue;
  322. }
  323. const filePath = path.resolve(root, hunk.path);
  324. const currentDependency = dependencyGroups.get(filePath);
  325. const current = await getPreparedFileState(filePath, 'update');
  326. if (!current.exists) {
  327. throw createApplyPatchVerificationError(
  328. `Failed to read file to update: ${filePath}`,
  329. );
  330. }
  331. const movePath = hunk.move_path
  332. ? path.resolve(root, hunk.move_path)
  333. : undefined;
  334. if (movePath && movePath !== filePath) {
  335. await assertPreparedPathMissing(movePath, 'move');
  336. }
  337. const { resolved, nextText } = resolvePreparedUpdate(
  338. filePath,
  339. current.text,
  340. hunk,
  341. cfg,
  342. );
  343. const next = resolved.map((chunk, index) => ({
  344. old_lines: [...chunk.canonical_old_lines],
  345. new_lines: [...chunk.canonical_new_lines],
  346. change_context:
  347. chunk.canonical_change_context ?? hunk.chunks[index].change_context,
  348. is_end_of_file:
  349. hunk.chunks[index].is_end_of_file && chunk.resolved_is_end_of_file
  350. ? true
  351. : undefined,
  352. }));
  353. for (const chunk of resolved) {
  354. if (!chunk.rewritten) {
  355. continue;
  356. }
  357. changed = true;
  358. }
  359. const nextOutputPath = hunk.move_path ?? hunk.path;
  360. const nextOutputFilePath = movePath ?? filePath;
  361. if (current.derived && currentDependency) {
  362. const nextGroup = combineDependentUpdateGroup(
  363. filePath,
  364. currentDependency,
  365. next,
  366. nextText,
  367. nextOutputPath,
  368. nextOutputFilePath,
  369. cfg,
  370. );
  371. rewritten[currentDependency.group.index] = renderRewriteDependencyGroup(
  372. nextGroup,
  373. cfg,
  374. );
  375. changed = true;
  376. clearDependencyGroup(filePath);
  377. if (movePath && movePath !== filePath) {
  378. clearDependencyGroup(movePath);
  379. }
  380. dependencyGroups.set(nextOutputFilePath, nextGroup);
  381. } else {
  382. rewritten.push(createUpdateHunk(hunk.path, next, hunk.move_path));
  383. clearDependencyGroup(filePath);
  384. if (movePath && movePath !== filePath) {
  385. clearDependencyGroup(movePath);
  386. }
  387. dependencyGroups.set(nextOutputFilePath, {
  388. kind: 'update',
  389. group: {
  390. index: rewritten.length - 1,
  391. sourcePath: hunk.path,
  392. outputPath: nextOutputPath,
  393. sourceFilePath: filePath,
  394. outputFilePath: nextOutputFilePath,
  395. baseText: current.text,
  396. finalText: nextText,
  397. chunks: clonePatchChunks(next),
  398. },
  399. });
  400. }
  401. if (movePath && movePath !== filePath) {
  402. staged.set(filePath, { exists: false, derived: true });
  403. staged.set(movePath, {
  404. exists: true,
  405. text: nextText,
  406. mode: current.mode,
  407. derived: true,
  408. });
  409. } else {
  410. staged.set(filePath, {
  411. exists: true,
  412. text: nextText,
  413. mode: current.mode,
  414. derived: true,
  415. });
  416. }
  417. }
  418. if (!changed) {
  419. if (pathsNormalized) {
  420. return {
  421. patchText: formatPatch({ hunks }),
  422. changed: true,
  423. };
  424. }
  425. if (normalizedPatchText !== patchText) {
  426. return {
  427. patchText: normalizedPatchText,
  428. changed: true,
  429. };
  430. }
  431. return {
  432. patchText,
  433. changed: false,
  434. };
  435. }
  436. return {
  437. patchText: formatPatch({ hunks: rewritten }),
  438. changed: true,
  439. };
  440. } catch (error) {
  441. throw ensureApplyPatchError(error, 'Unexpected rewrite failure');
  442. }
  443. }
  444. export async function rewritePatchText(
  445. root: string,
  446. patchText: string,
  447. cfg: ApplyPatchRuntimeOptions,
  448. worktree?: string,
  449. ): Promise<string> {
  450. return (await rewritePatch(root, patchText, cfg, worktree)).patchText;
  451. }