hook.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766
  1. import { describe, expect, test } from 'bun:test';
  2. import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
  3. import path from 'node:path';
  4. import { parsePatch } from './codec';
  5. import { createApplyPatchHook } from './index';
  6. import { applyPreparedChanges, preparePatchChanges } from './operations';
  7. import { createTempDir, DEFAULT_OPTIONS, writeFixture } from './test-helpers';
  8. function createHook() {
  9. return createApplyPatchHook({
  10. client: {} as never,
  11. directory: '/tmp/hook-root',
  12. worktree: '/tmp/hook-root',
  13. } as never);
  14. }
  15. describe('apply-patch/hook', () => {
  16. test('ignores tools other than apply_patch', async () => {
  17. const hook = createHook();
  18. const patchText = '*** Begin Patch\n*** End Patch';
  19. const output = { args: { patchText } };
  20. await hook['tool.execute.before']({ tool: 'read' }, output);
  21. expect(output.args.patchText).toBe(patchText);
  22. });
  23. test('blocks an unrecoverable patch as verification before native execution', async () => {
  24. const root = await createTempDir('apply-patch-hook-');
  25. await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
  26. const hook = createHook();
  27. const patchText = `*** Begin Patch
  28. *** Update File: sample.txt
  29. @@
  30. -missing
  31. +omega
  32. *** End Patch`;
  33. const output = { args: { patchText } };
  34. await expect(
  35. hook['tool.execute.before'](
  36. { tool: 'apply_patch', directory: root },
  37. output,
  38. ),
  39. ).rejects.toThrow(
  40. 'apply_patch verification failed: Failed to find expected lines',
  41. );
  42. expect(output.args.patchText).toBe(patchText);
  43. });
  44. test('normalizes an exact patch wrapped in a heredoc before native execution', async () => {
  45. const root = await createTempDir('apply-patch-hook-');
  46. await writeFixture(
  47. root,
  48. 'sample.txt',
  49. 'line-01\nexact-top\nexact-old\nexact-bottom\nline-05\n',
  50. );
  51. const hook = createHook();
  52. const cleanPatchText = `*** Begin Patch
  53. *** Update File: sample.txt
  54. @@ exact-top
  55. -exact-old
  56. +exact-new
  57. exact-bottom
  58. *** End Patch`;
  59. const output = {
  60. args: {
  61. patchText: `cat <<'PATCH'
  62. ${cleanPatchText}
  63. PATCH`,
  64. },
  65. };
  66. await hook['tool.execute.before'](
  67. { tool: 'apply_patch', directory: root },
  68. output,
  69. );
  70. expect(output.args.patchText).toBe(cleanPatchText);
  71. const changes = await preparePatchChanges(
  72. root,
  73. output.args.patchText as string,
  74. DEFAULT_OPTIONS,
  75. );
  76. await applyPreparedChanges(changes);
  77. expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
  78. 'line-01\nexact-top\nexact-new\nexact-bottom\nline-05\n',
  79. );
  80. });
  81. test('normalizes absolute paths inside root before native execution', async () => {
  82. const root = await createTempDir('apply-patch-hook-');
  83. const absolutePath = path.join(root, 'sample.txt');
  84. await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
  85. const hook = createHook();
  86. const patchText = `*** Begin Patch
  87. *** Update File: ${absolutePath}
  88. @@
  89. -alpha
  90. +omega
  91. *** End Patch`;
  92. const output = { args: { patchText } };
  93. await hook['tool.execute.before'](
  94. { tool: 'apply_patch', directory: root },
  95. output,
  96. );
  97. expect(parsePatch(output.args.patchText as string).hunks[0]).toMatchObject({
  98. type: 'update',
  99. path: 'sample.txt',
  100. });
  101. });
  102. test('passes through an absolute target outside root/worktree before native execution', async () => {
  103. const root = await createTempDir('apply-patch-hook-');
  104. const outsideDir = await createTempDir('apply-patch-hook-outside-');
  105. const outsidePath = path.join(outsideDir, 'outside.txt');
  106. await writeFile(outsidePath, 'outside\n', 'utf-8');
  107. const hook = createApplyPatchHook({
  108. client: {} as never,
  109. directory: root,
  110. worktree: root,
  111. } as never);
  112. const patchText = `*** Begin Patch
  113. *** Update File: ${outsidePath}
  114. @@
  115. -outside
  116. +changed
  117. *** End Patch`;
  118. const output = { args: { patchText } };
  119. await expect(
  120. hook['tool.execute.before'](
  121. { tool: 'apply_patch', directory: root },
  122. output,
  123. ),
  124. ).resolves.toBeUndefined();
  125. expect(output.args.patchText).toBe(patchText);
  126. expect(await readFile(outsidePath, 'utf-8')).toBe('outside\n');
  127. });
  128. test('passes through mixed stale inside and absolute outside patch without partial rewrite', async () => {
  129. const root = await createTempDir('apply-patch-hook-');
  130. const outsideDir = await createTempDir('apply-patch-hook-outside-');
  131. const outsidePath = path.join(outsideDir, 'outside.txt');
  132. await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
  133. await writeFile(outsidePath, 'outside\n', 'utf-8');
  134. const hook = createApplyPatchHook({
  135. client: {} as never,
  136. directory: root,
  137. worktree: root,
  138. } as never);
  139. const patchText = `*** Begin Patch
  140. *** Update File: sample.txt
  141. @@
  142. prefix
  143. -old-value
  144. +new-value
  145. suffix
  146. *** Update File: ${outsidePath}
  147. @@
  148. -outside
  149. +changed
  150. *** End Patch`;
  151. const output = { args: { patchText } };
  152. await expect(
  153. hook['tool.execute.before'](
  154. { tool: 'apply_patch', directory: root },
  155. output,
  156. ),
  157. ).resolves.toBeUndefined();
  158. expect(output.args.patchText).toBe(patchText);
  159. expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
  160. 'prefix\nstale-value\nsuffix\n',
  161. );
  162. expect(await readFile(outsidePath, 'utf-8')).toBe('outside\n');
  163. });
  164. test('rewrites a stale prefix patch and remains applicable', async () => {
  165. const root = await createTempDir('apply-patch-hook-');
  166. await writeFixture(
  167. root,
  168. 'sample.txt',
  169. 'top\nA\nB-stale\nC\nD\nE\nbottom\n',
  170. );
  171. const hook = createHook();
  172. const patchText = `*** Begin Patch
  173. *** Update File: sample.txt
  174. @@ top
  175. A
  176. -B
  177. -C
  178. -D
  179. -E
  180. +B
  181. +C
  182. +D
  183. +X
  184. *** End Patch`;
  185. const output = { args: { patchText } };
  186. await hook['tool.execute.before'](
  187. { tool: 'apply_patch', directory: root },
  188. output,
  189. );
  190. const rewritten = parsePatch(output.args.patchText as string).hunks[0];
  191. expect(rewritten.type).toBe('update');
  192. expect(
  193. rewritten.type === 'update' && rewritten.chunks[0]?.old_lines,
  194. ).toEqual(['A', 'B-stale', 'C', 'D', 'E']);
  195. const changes = await preparePatchChanges(
  196. root,
  197. output.args.patchText as string,
  198. DEFAULT_OPTIONS,
  199. );
  200. await applyPreparedChanges(changes);
  201. expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
  202. 'top\nA\nB\nC\nD\nX\nbottom\n',
  203. );
  204. });
  205. test('does not alter new_lines during rewrite', async () => {
  206. const root = await createTempDir('apply-patch-hook-');
  207. await writeFixture(
  208. root,
  209. 'sample.txt',
  210. 'top\nprefix\nstale-value\nsuffix\nbottom\n',
  211. );
  212. const hook = createHook();
  213. const patchText = `*** Begin Patch
  214. *** Update File: sample.txt
  215. @@ top
  216. prefix
  217. -old-value
  218. + \tverbatim "" Ω
  219. suffix
  220. *** End Patch`;
  221. const expected = parsePatch(patchText).hunks[0];
  222. const output = { args: { patchText } };
  223. await hook['tool.execute.before'](
  224. { tool: 'apply_patch', directory: root },
  225. output,
  226. );
  227. const rewritten = parsePatch(output.args.patchText as string).hunks[0];
  228. expect(expected.type).toBe('update');
  229. expect(rewritten.type).toBe('update');
  230. expect(
  231. expected.type === 'update' && rewritten.type === 'update'
  232. ? rewritten.chunks[0]?.new_lines
  233. : undefined,
  234. ).toEqual(expected.type === 'update' ? expected.chunks[0]?.new_lines : []);
  235. });
  236. test('rewrites a unicode-only stale patch and remains applicable', async () => {
  237. const root = await createTempDir('apply-patch-hook-');
  238. await writeFixture(root, 'sample.txt', 'const title = “Hola”;\n');
  239. const hook = createHook();
  240. const patchText = `*** Begin Patch
  241. *** Update File: sample.txt
  242. @@
  243. -const title = "Hola";
  244. +const title = "Hola mundo";
  245. *** End Patch`;
  246. const output = { args: { patchText } };
  247. await hook['tool.execute.before'](
  248. { tool: 'apply_patch', directory: root },
  249. output,
  250. );
  251. const rewritten = parsePatch(output.args.patchText as string).hunks[0];
  252. expect(rewritten.type).toBe('update');
  253. expect(
  254. rewritten.type === 'update' ? rewritten.chunks[0]?.old_lines : undefined,
  255. ).toEqual(['const title = “Hola”;']);
  256. const changes = await preparePatchChanges(
  257. root,
  258. output.args.patchText as string,
  259. DEFAULT_OPTIONS,
  260. );
  261. await applyPreparedChanges(changes);
  262. expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
  263. 'const title = "Hola mundo";\n',
  264. );
  265. });
  266. test('rewrites a trim-end stale patch and remains applicable', async () => {
  267. const root = await createTempDir('apply-patch-hook-');
  268. await writeFixture(root, 'sample.txt', 'alpha \n');
  269. const hook = createHook();
  270. const patchText = `*** Begin Patch
  271. *** Update File: sample.txt
  272. @@
  273. -alpha
  274. +omega
  275. *** End Patch`;
  276. const output = { args: { patchText } };
  277. await hook['tool.execute.before'](
  278. { tool: 'apply_patch', directory: root },
  279. output,
  280. );
  281. const rewritten = parsePatch(output.args.patchText as string).hunks[0];
  282. expect(rewritten.type).toBe('update');
  283. expect(
  284. rewritten.type === 'update' ? rewritten.chunks[0]?.old_lines : undefined,
  285. ).toEqual(['alpha ']);
  286. const changes = await preparePatchChanges(
  287. root,
  288. output.args.patchText as string,
  289. DEFAULT_OPTIONS,
  290. );
  291. await applyPreparedChanges(changes);
  292. expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
  293. 'omega\n',
  294. );
  295. });
  296. test('blocks a trim-only stale patch as verification', async () => {
  297. const root = await createTempDir('apply-patch-hook-');
  298. await writeFixture(root, 'sample.txt', ' alpha \n');
  299. const hook = createHook();
  300. const patchText = `*** Begin Patch
  301. *** Update File: sample.txt
  302. @@
  303. -alpha
  304. +omega
  305. *** End Patch`;
  306. const output = { args: { patchText } };
  307. await expect(
  308. hook['tool.execute.before'](
  309. { tool: 'apply_patch', directory: root },
  310. output,
  311. ),
  312. ).rejects.toThrow(
  313. 'apply_patch verification failed: Failed to find expected lines',
  314. );
  315. expect(output.args.patchText).toBe(patchText);
  316. });
  317. test('blocks a malformed @@ at runtime before native execution', async () => {
  318. const root = await createTempDir('apply-patch-hook-');
  319. await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
  320. const hook = createHook();
  321. const patchText = `*** Begin Patch
  322. *** Update File: sample.txt
  323. @@
  324. alpha
  325. garbage
  326. -beta
  327. +BETA
  328. *** End Patch`;
  329. const output = { args: { patchText } };
  330. await expect(
  331. hook['tool.execute.before'](
  332. { tool: 'apply_patch', directory: root },
  333. output,
  334. ),
  335. ).rejects.toThrow(
  336. 'apply_patch validation failed: Invalid patch format: unexpected line in patch chunk: garbage',
  337. );
  338. expect(output.args.patchText).toBe(patchText);
  339. });
  340. test('blocks a malformed Add File at runtime before native execution', async () => {
  341. const root = await createTempDir('apply-patch-hook-');
  342. const hook = createHook();
  343. const patchText = `*** Begin Patch
  344. *** Add File: added.txt
  345. +fresh
  346. garbage
  347. *** End Patch`;
  348. const output = { args: { patchText } };
  349. await expect(
  350. hook['tool.execute.before'](
  351. { tool: 'apply_patch', directory: root },
  352. output,
  353. ),
  354. ).rejects.toThrow(
  355. 'apply_patch validation failed: Invalid patch format: unexpected line in Add File body: garbage',
  356. );
  357. expect(output.args.patchText).toBe(patchText);
  358. });
  359. test('blocks internal guard errors before native execution', async () => {
  360. const root = await createTempDir('apply-patch-hook-');
  361. const lockedDir = path.join(root, 'locked');
  362. await mkdir(lockedDir, { recursive: true });
  363. await chmod(lockedDir, 0o000);
  364. const hook = createHook();
  365. const patchText = `*** Begin Patch
  366. *** Add File: locked/child.txt
  367. +fresh
  368. *** End Patch`;
  369. const output = { args: { patchText } };
  370. try {
  371. await expect(
  372. hook['tool.execute.before'](
  373. { tool: 'apply_patch', directory: root },
  374. output,
  375. ),
  376. ).rejects.toThrow('apply_patch internal error:');
  377. expect(output.args.patchText).toBe(patchText);
  378. } finally {
  379. await chmod(lockedDir, 0o755);
  380. }
  381. });
  382. test('blocks a dangerous indented case as verification', async () => {
  383. const root = await createTempDir('apply-patch-hook-');
  384. await writeFixture(
  385. root,
  386. 'sample.yml',
  387. 'root:\n child:\n enabled: false\nnext: true\n',
  388. );
  389. const hook = createHook();
  390. const patchText = `*** Begin Patch
  391. *** Update File: sample.yml
  392. @@
  393. -enabled: false
  394. +enabled: true
  395. *** End Patch`;
  396. const output = { args: { patchText } };
  397. await expect(
  398. hook['tool.execute.before'](
  399. { tool: 'apply_patch', directory: root },
  400. output,
  401. ),
  402. ).rejects.toThrow(
  403. 'apply_patch verification failed: Failed to find expected lines',
  404. );
  405. expect(output.args.patchText).toBe(patchText);
  406. });
  407. test('rewrites anchored insertion to avoid native EOF handling', async () => {
  408. const root = await createTempDir('apply-patch-hook-');
  409. await writeFixture(
  410. root,
  411. 'sample.txt',
  412. 'top\nanchor-insert\nafter-anchor\nend\n',
  413. );
  414. const hook = createHook();
  415. const patchText = `*** Begin Patch
  416. *** Update File: sample.txt
  417. @@ anchor-insert
  418. +middle-inserted
  419. *** End Patch`;
  420. const output = { args: { patchText } };
  421. await hook['tool.execute.before'](
  422. { tool: 'apply_patch', directory: root },
  423. output,
  424. );
  425. const changes = await preparePatchChanges(
  426. root,
  427. output.args.patchText as string,
  428. DEFAULT_OPTIONS,
  429. );
  430. await applyPreparedChanges(changes);
  431. expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
  432. 'top\nanchor-insert\nmiddle-inserted\nafter-anchor\nend\n',
  433. );
  434. });
  435. test('blocks a pure insertion when the anchor is missing', async () => {
  436. const root = await createTempDir('apply-patch-hook-');
  437. await writeFixture(root, 'sample.txt', 'top\nafter-anchor\nend\n');
  438. const hook = createHook();
  439. const patchText = `*** Begin Patch
  440. *** Update File: sample.txt
  441. @@ anchor-insert
  442. +middle-inserted
  443. *** End Patch`;
  444. const output = { args: { patchText } };
  445. await expect(
  446. hook['tool.execute.before'](
  447. { tool: 'apply_patch', directory: root },
  448. output,
  449. ),
  450. ).rejects.toThrow(
  451. 'apply_patch verification failed: Failed to find insertion anchor',
  452. );
  453. expect(output.args.patchText).toBe(patchText);
  454. });
  455. test('blocks a pure insertion when the anchor is ambiguous', async () => {
  456. const root = await createTempDir('apply-patch-hook-');
  457. await writeFixture(
  458. root,
  459. 'sample.txt',
  460. 'top\nanchor-insert\nafter-first\nsplit\nanchor-insert\nafter-second\nend\n',
  461. );
  462. const hook = createHook();
  463. const patchText = `*** Begin Patch
  464. *** Update File: sample.txt
  465. @@ anchor-insert
  466. +middle-inserted
  467. *** End Patch`;
  468. const output = { args: { patchText } };
  469. await expect(
  470. hook['tool.execute.before'](
  471. { tool: 'apply_patch', directory: root },
  472. output,
  473. ),
  474. ).rejects.toThrow(
  475. 'apply_patch verification failed: Insertion anchor was ambiguous',
  476. );
  477. expect(output.args.patchText).toBe(patchText);
  478. });
  479. test('blocks real patch ambiguity before native execution', async () => {
  480. const root = await createTempDir('apply-patch-hook-');
  481. await writeFixture(
  482. root,
  483. 'sample.txt',
  484. 'left\nstale-one\nright\nseparator\nleft\nstale-two\nright\n',
  485. );
  486. const hook = createHook();
  487. const patchText = `*** Begin Patch
  488. *** Update File: sample.txt
  489. @@
  490. left
  491. -old
  492. +new
  493. right
  494. *** End Patch`;
  495. const output = { args: { patchText } };
  496. await expect(
  497. hook['tool.execute.before'](
  498. { tool: 'apply_patch', directory: root },
  499. output,
  500. ),
  501. ).rejects.toThrow('apply_patch verification failed:');
  502. expect(output.args.patchText).toBe(patchText);
  503. });
  504. test('rewrites only the update hunk in a patch with add + update', async () => {
  505. const root = await createTempDir('apply-patch-hook-');
  506. await writeFixture(
  507. root,
  508. 'sample.txt',
  509. 'top\nprefix\nstale-value\nsuffix\n',
  510. );
  511. const hook = createHook();
  512. const patchText = `*** Begin Patch
  513. *** Add File: added.txt
  514. +fresh
  515. *** Update File: sample.txt
  516. @@ top
  517. prefix
  518. -old-value
  519. +new-value
  520. suffix
  521. *** End Patch`;
  522. const output = { args: { patchText } };
  523. await hook['tool.execute.before'](
  524. { tool: 'apply_patch', directory: root },
  525. output,
  526. );
  527. const rewritten = parsePatch(output.args.patchText as string);
  528. expect(rewritten.hunks[0]).toEqual({
  529. type: 'add',
  530. path: 'added.txt',
  531. contents: 'fresh',
  532. });
  533. expect(rewritten.hunks[1]).toEqual({
  534. type: 'update',
  535. path: 'sample.txt',
  536. chunks: [
  537. {
  538. old_lines: ['prefix', 'stale-value', 'suffix'],
  539. new_lines: ['prefix', 'new-value', 'suffix'],
  540. change_context: 'top',
  541. is_end_of_file: undefined,
  542. },
  543. ],
  544. });
  545. const changes = await preparePatchChanges(
  546. root,
  547. output.args.patchText as string,
  548. DEFAULT_OPTIONS,
  549. );
  550. await applyPreparedChanges(changes);
  551. expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
  552. 'top\nprefix\nnew-value\nsuffix\n',
  553. );
  554. expect(await readFile(path.join(root, 'added.txt'), 'utf-8')).toBe(
  555. 'fresh\n',
  556. );
  557. });
  558. test('passes through sibling-directory targets outside root/worktree before native execution', async () => {
  559. const root = await createTempDir('apply-patch-hook-');
  560. const outside = path.join(path.dirname(root), 'outside.txt');
  561. await writeFile(outside, 'outside\n', 'utf-8');
  562. const hook = createHook();
  563. const patchText = `*** Begin Patch
  564. *** Update File: ../outside.txt
  565. @@
  566. -outside
  567. +changed
  568. *** End Patch`;
  569. const output = { args: { patchText } };
  570. await expect(
  571. hook['tool.execute.before'](
  572. { tool: 'apply_patch', directory: root },
  573. output,
  574. ),
  575. ).resolves.toBeUndefined();
  576. expect(output.args.patchText).toBe(patchText);
  577. expect(await readFile(outside, 'utf-8')).toBe('outside\n');
  578. });
  579. test('normalizes an absolute path inside worktree even when it is outside root', async () => {
  580. const worktree = await createTempDir('apply-patch-worktree-');
  581. const root = path.join(worktree, 'subdir');
  582. await mkdir(root, { recursive: true });
  583. const siblingPath = path.join(worktree, 'shared.txt');
  584. const hook = createApplyPatchHook({
  585. client: {} as never,
  586. directory: root,
  587. worktree,
  588. } as never);
  589. const patchText = `*** Begin Patch
  590. *** Add File: ${siblingPath}
  591. +fresh
  592. *** End Patch`;
  593. const output = { args: { patchText } };
  594. await expect(
  595. hook['tool.execute.before'](
  596. { tool: 'apply_patch', directory: root },
  597. output,
  598. ),
  599. ).resolves.toBeUndefined();
  600. expect(parsePatch(output.args.patchText as string).hunks[0]).toMatchObject({
  601. type: 'add',
  602. path: '../shared.txt',
  603. contents: 'fresh',
  604. });
  605. });
  606. test('passes through mixed patches with outside paths without partial rewrite', async () => {
  607. const root = await createTempDir('apply-patch-hook-');
  608. const outsideDir = await createTempDir('apply-patch-hook-outside-');
  609. await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
  610. await writeFixture(outsideDir, 'outside.txt', 'legacy\n');
  611. const hook = createHook();
  612. const outsideAdded = path.join(path.dirname(root), 'outside-added.txt');
  613. const patchText = `*** Begin Patch
  614. *** Add File: ../outside-added.txt
  615. +fresh
  616. *** Update File: sample.txt
  617. @@
  618. prefix
  619. -old-value
  620. +new-value
  621. suffix
  622. *** Delete File: ../${path.basename(outsideDir)}/outside.txt
  623. *** End Patch`;
  624. const output = { args: { patchText } };
  625. await expect(
  626. hook['tool.execute.before'](
  627. { tool: 'apply_patch', directory: root },
  628. output,
  629. ),
  630. ).resolves.toBeUndefined();
  631. expect(output.args.patchText).toBe(patchText);
  632. expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
  633. 'prefix\nstale-value\nsuffix\n',
  634. );
  635. expect(await stat(outsideAdded).catch(() => null)).toBeNull();
  636. expect(await readFile(path.join(outsideDir, 'outside.txt'), 'utf-8')).toBe(
  637. 'legacy\n',
  638. );
  639. });
  640. test('keeps normal behavior for patches entirely inside root/worktree', async () => {
  641. const root = await createTempDir('apply-patch-hook-');
  642. await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
  643. const hook = createHook();
  644. const patchText = `*** Begin Patch
  645. *** Update File: sample.txt
  646. @@
  647. -alpha
  648. +omega
  649. beta
  650. *** End Patch`;
  651. const output = { args: { patchText } };
  652. await expect(
  653. hook['tool.execute.before'](
  654. { tool: 'apply_patch', directory: root },
  655. output,
  656. ),
  657. ).resolves.toBeUndefined();
  658. expect(output.args.patchText).toBe(patchText);
  659. });
  660. test('does not expose the tool.execute.after hook', () => {
  661. const hook = createHook() as Record<string, unknown>;
  662. expect(hook['tool.execute.after']).toBeUndefined();
  663. });
  664. test('does not alter an exact patch', async () => {
  665. const root = await createTempDir('apply-patch-hook-');
  666. await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
  667. const hook = createHook();
  668. const patchText = `*** Begin Patch
  669. *** Update File: sample.txt
  670. @@
  671. -alpha
  672. +omega
  673. beta
  674. *** End Patch`;
  675. const output = { args: { patchText } };
  676. await hook['tool.execute.before'](
  677. { tool: 'apply_patch', directory: root },
  678. output,
  679. );
  680. expect(output.args.patchText).toBe(patchText);
  681. });
  682. });