prepared-changes.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. import { randomUUID } from 'node:crypto';
  2. import * as fs from 'node:fs/promises';
  3. import path from 'node:path';
  4. import {
  5. createApplyPatchInternalError,
  6. createApplyPatchValidationError,
  7. createApplyPatchVerificationError,
  8. ensureApplyPatchError,
  9. getErrorMessage,
  10. } from './errors';
  11. import {
  12. createPatchExecutionContext,
  13. isMissingPathError,
  14. resolvePreparedUpdate,
  15. stageAddedText,
  16. } from './execution-context';
  17. import type { ApplyPatchRuntimeOptions, PreparedChange } from './types';
  18. function isNormalizedAbsolutePath(filePath: string): boolean {
  19. return path.isAbsolute(filePath) && path.normalize(filePath) === filePath;
  20. }
  21. function assertPreparedChangePath(
  22. value: unknown,
  23. field: 'file' | 'move',
  24. index: number,
  25. ): asserts value is string {
  26. if (typeof value !== 'string' || value.length === 0) {
  27. throw createApplyPatchValidationError(
  28. `Prepared changes require a non-empty string ${field} at index ${index}`,
  29. );
  30. }
  31. if (!isNormalizedAbsolutePath(value)) {
  32. throw createApplyPatchValidationError(
  33. `Prepared changes require absolute normalized ${field} paths at index ${index}: ${value}`,
  34. );
  35. }
  36. }
  37. function assertPreparedChangesContract(
  38. changes: readonly PreparedChange[],
  39. ): void {
  40. for (const [index, change] of changes.entries()) {
  41. if (!change || typeof change !== 'object') {
  42. throw createApplyPatchValidationError(
  43. `Prepared change at index ${index} must be an object`,
  44. );
  45. }
  46. if (!('type' in change)) {
  47. throw createApplyPatchValidationError(
  48. `Prepared change at index ${index} is missing type`,
  49. );
  50. }
  51. assertPreparedChangePath(change.file, 'file', index);
  52. if (change.type === 'add') {
  53. if (typeof change.text !== 'string') {
  54. throw createApplyPatchValidationError(
  55. `Prepared add at index ${index} is missing text`,
  56. );
  57. }
  58. continue;
  59. }
  60. if (change.type === 'delete') {
  61. continue;
  62. }
  63. if (change.type === 'update') {
  64. if (typeof change.text !== 'string') {
  65. throw createApplyPatchValidationError(
  66. `Prepared update at index ${index} is missing text`,
  67. );
  68. }
  69. if (change.move !== undefined) {
  70. assertPreparedChangePath(change.move, 'move', index);
  71. }
  72. continue;
  73. }
  74. throw createApplyPatchValidationError(
  75. `Prepared change at index ${index} has unsupported type`,
  76. );
  77. }
  78. }
  79. export async function preparePatchChanges(
  80. root: string,
  81. patchText: string,
  82. cfg: ApplyPatchRuntimeOptions,
  83. worktree?: string,
  84. ): Promise<PreparedChange[]> {
  85. try {
  86. const { hunks, staged, getPreparedFileState, assertPreparedPathMissing } =
  87. await createPatchExecutionContext(root, patchText, worktree);
  88. const changes: PreparedChange[] = [];
  89. for (const hunk of hunks) {
  90. const filePath = path.resolve(root, hunk.path);
  91. if (hunk.type === 'add') {
  92. await assertPreparedPathMissing(filePath, 'add');
  93. const text = stageAddedText(hunk.contents);
  94. changes.push({
  95. type: 'add',
  96. file: filePath,
  97. text,
  98. });
  99. staged.set(filePath, { exists: true, text, derived: true });
  100. continue;
  101. }
  102. if (hunk.type === 'delete') {
  103. await getPreparedFileState(filePath, 'delete');
  104. changes.push({ type: 'delete', file: filePath });
  105. staged.set(filePath, { exists: false, derived: true });
  106. continue;
  107. }
  108. const current = await getPreparedFileState(filePath, 'update');
  109. if (!current.exists) {
  110. throw createApplyPatchVerificationError(
  111. `Failed to read file to update: ${filePath}`,
  112. );
  113. }
  114. const move = hunk.move_path
  115. ? path.resolve(root, hunk.move_path)
  116. : undefined;
  117. if (move && move !== filePath) {
  118. await assertPreparedPathMissing(move, 'move');
  119. }
  120. const { nextText } = resolvePreparedUpdate(
  121. filePath,
  122. current.text,
  123. hunk,
  124. cfg,
  125. );
  126. changes.push({
  127. type: 'update',
  128. file: filePath,
  129. move,
  130. text: nextText,
  131. });
  132. if (move && move !== filePath) {
  133. staged.set(filePath, { exists: false, derived: true });
  134. staged.set(move, {
  135. exists: true,
  136. text: nextText,
  137. mode: current.mode,
  138. derived: true,
  139. });
  140. continue;
  141. }
  142. staged.set(filePath, {
  143. exists: true,
  144. text: nextText,
  145. mode: current.mode,
  146. derived: true,
  147. });
  148. }
  149. return changes;
  150. } catch (error) {
  151. throw ensureApplyPatchError(error, 'Unexpected prepare failure');
  152. }
  153. }
  154. type FileSnapshot =
  155. | { type: 'missing' }
  156. | {
  157. type: 'file';
  158. // Raw bytes: rolling back must restore binary files byte-for-byte,
  159. // which utf-8 decoding cannot guarantee.
  160. bytes: Buffer;
  161. mode: number;
  162. };
  163. async function readSnapshot(filePath: string): Promise<FileSnapshot> {
  164. try {
  165. const stat = await fs.stat(filePath);
  166. if (stat.isDirectory()) {
  167. throw createApplyPatchInternalError(
  168. `Refusing to overwrite directory while applying prepared changes: ${filePath}`,
  169. );
  170. }
  171. return {
  172. type: 'file',
  173. bytes: await fs.readFile(filePath),
  174. mode: stat.mode & 0o7777,
  175. };
  176. } catch (error) {
  177. if (isMissingPathError(error)) {
  178. return { type: 'missing' };
  179. }
  180. throw createApplyPatchInternalError(
  181. `Failed to snapshot file before apply: ${filePath}`,
  182. error,
  183. );
  184. }
  185. }
  186. async function restoreSnapshot(
  187. filePath: string,
  188. snapshot: FileSnapshot,
  189. ): Promise<void> {
  190. if (snapshot.type === 'missing') {
  191. await fs.rm(filePath, { force: true });
  192. return;
  193. }
  194. await fs.mkdir(path.dirname(filePath), { recursive: true });
  195. await writeFileAtomically(filePath, snapshot.bytes, snapshot.mode);
  196. }
  197. function createTempSiblingPath(target: string): string {
  198. return path.join(
  199. path.dirname(target),
  200. `.${path.basename(target)}.apply-patch-${randomUUID()}.tmp`,
  201. );
  202. }
  203. async function writeFileAtomically(
  204. target: string,
  205. data: string | Buffer,
  206. mode?: number,
  207. ): Promise<void> {
  208. const tempPath = createTempSiblingPath(target);
  209. try {
  210. await fs.mkdir(path.dirname(target), { recursive: true });
  211. await fs.writeFile(tempPath, data);
  212. if (mode !== undefined) {
  213. await fs.chmod(tempPath, mode);
  214. }
  215. await fs.rename(tempPath, target);
  216. } finally {
  217. await fs.rm(tempPath, { force: true }).catch(() => undefined);
  218. }
  219. }
  220. function getSnapshotMode(snapshot: FileSnapshot): number | undefined {
  221. return snapshot.type === 'file' ? snapshot.mode : undefined;
  222. }
  223. function assertPreparedApplyPreconditions(
  224. changes: PreparedChange[],
  225. snapshots: Map<string, FileSnapshot>,
  226. ): void {
  227. const staged = new Map<string, FileSnapshot['type']>();
  228. function pathState(filePath: string): FileSnapshot['type'] {
  229. if (staged.has(filePath)) {
  230. return staged.get(filePath) ?? 'missing';
  231. }
  232. return snapshots.get(filePath)?.type ?? 'missing';
  233. }
  234. for (const change of changes) {
  235. if (change.type === 'add') {
  236. if (pathState(change.file) !== 'missing') {
  237. throw createApplyPatchVerificationError(
  238. `Prepared add target already exists: ${change.file}`,
  239. );
  240. }
  241. staged.set(change.file, 'file');
  242. continue;
  243. }
  244. if (change.type === 'delete') {
  245. if (pathState(change.file) !== 'file') {
  246. throw createApplyPatchVerificationError(
  247. `Prepared delete source does not exist: ${change.file}`,
  248. );
  249. }
  250. staged.set(change.file, 'missing');
  251. continue;
  252. }
  253. if (pathState(change.file) !== 'file') {
  254. throw createApplyPatchVerificationError(
  255. change.move && change.move !== change.file
  256. ? `Prepared move source does not exist: ${change.file}`
  257. : `Prepared update source does not exist: ${change.file}`,
  258. );
  259. }
  260. if (change.move && change.move !== change.file) {
  261. if (pathState(change.move) !== 'missing') {
  262. throw createApplyPatchVerificationError(
  263. `Prepared move destination already exists: ${change.move}`,
  264. );
  265. }
  266. staged.set(change.file, 'missing');
  267. staged.set(change.move, 'file');
  268. continue;
  269. }
  270. staged.set(change.file, 'file');
  271. }
  272. }
  273. /**
  274. * Internal best-effort helper that applies the output of
  275. * `preparePatchChanges()`: it snapshots all touched paths first and uses
  276. * temp + rename for writes to regular files. It is not a universal multi-file
  277. * transaction and is not perfect against concurrent external interference,
  278. * but it avoids leaving silent partial states on normal apply failures.
  279. *
  280. * Contract: although it is exported for local tests/helpers, its expected
  281. * input is the already prepared output of `preparePatchChanges()`. If it
  282. * receives manual arrays, it revalidates the basic shape
  283. * (types/text/normalized absolute paths) and filesystem invariants: it
  284. * rejects updates/deletes/moves whose source does not exist, and add/move
  285. * operations whose destination is already occupied.
  286. */
  287. export async function applyPreparedChanges(
  288. changes: PreparedChange[],
  289. ): Promise<void> {
  290. assertPreparedChangesContract(changes);
  291. const snapshots = new Map<string, FileSnapshot>();
  292. for (const change of changes) {
  293. if (!snapshots.has(change.file)) {
  294. snapshots.set(change.file, await readSnapshot(change.file));
  295. }
  296. if (
  297. change.type === 'update' &&
  298. change.move &&
  299. !snapshots.has(change.move)
  300. ) {
  301. snapshots.set(change.move, await readSnapshot(change.move));
  302. }
  303. }
  304. assertPreparedApplyPreconditions(changes, snapshots);
  305. // Effective mode per path as changes are applied sequentially: a move
  306. // transfers the source mode to the destination, so later writes on that
  307. // destination must keep it instead of falling back to the initial
  308. // snapshot (which may not exist yet).
  309. const effectiveModes = new Map<string, number | undefined>();
  310. function effectiveMode(filePath: string): number | undefined {
  311. if (effectiveModes.has(filePath)) {
  312. return effectiveModes.get(filePath);
  313. }
  314. return getSnapshotMode(snapshots.get(filePath) ?? { type: 'missing' });
  315. }
  316. try {
  317. for (const change of changes) {
  318. if (change.type === 'add') {
  319. await writeFileAtomically(change.file, change.text);
  320. // A (re)created path has no prior mode to preserve: drop any mode
  321. // tracked for it so later writes do not resurrect a stale one.
  322. effectiveModes.set(change.file, undefined);
  323. continue;
  324. }
  325. if (change.type === 'delete') {
  326. await fs.unlink(change.file);
  327. effectiveModes.set(change.file, undefined);
  328. continue;
  329. }
  330. if (change.move && change.move !== change.file) {
  331. const mode = effectiveMode(change.file);
  332. await writeFileAtomically(change.move, change.text, mode);
  333. await fs.unlink(change.file);
  334. effectiveModes.set(change.move, mode);
  335. effectiveModes.set(change.file, undefined);
  336. continue;
  337. }
  338. await writeFileAtomically(
  339. change.file,
  340. change.text,
  341. effectiveMode(change.file),
  342. );
  343. }
  344. } catch (error) {
  345. const rollbackFailures: string[] = [];
  346. for (const [filePath, snapshot] of [...snapshots.entries()].reverse()) {
  347. try {
  348. await restoreSnapshot(filePath, snapshot);
  349. } catch (rollbackError) {
  350. rollbackFailures.push(`${filePath}: ${getErrorMessage(rollbackError)}`);
  351. }
  352. }
  353. const message = rollbackFailures.length
  354. ? `Failed to apply prepared changes and rollback was incomplete: ${getErrorMessage(error)}; rollback issues: ${rollbackFailures.join('; ')}`
  355. : `Failed to apply prepared changes; rolled back touched files: ${getErrorMessage(error)}`;
  356. throw createApplyPatchInternalError(message, error);
  357. }
  358. }