| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766 |
- import { describe, expect, test } from 'bun:test';
- import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
- import path from 'node:path';
- import { parsePatch } from './codec';
- import { createApplyPatchHook } from './index';
- import { applyPreparedChanges, preparePatchChanges } from './operations';
- import { createTempDir, DEFAULT_OPTIONS, writeFixture } from './test-helpers';
- function createHook() {
- return createApplyPatchHook({
- client: {} as never,
- directory: '/tmp/hook-root',
- worktree: '/tmp/hook-root',
- } as never);
- }
- describe('apply-patch/hook', () => {
- test('ignores tools other than apply_patch', async () => {
- const hook = createHook();
- const patchText = '*** Begin Patch\n*** End Patch';
- const output = { args: { patchText } };
- await hook['tool.execute.before']({ tool: 'read' }, output);
- expect(output.args.patchText).toBe(patchText);
- });
- test('blocks an unrecoverable patch as verification before native execution', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@
- -missing
- +omega
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).rejects.toThrow(
- 'apply_patch verification failed: Failed to find expected lines',
- );
- expect(output.args.patchText).toBe(patchText);
- });
- test('normalizes an exact patch wrapped in a heredoc before native execution', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(
- root,
- 'sample.txt',
- 'line-01\nexact-top\nexact-old\nexact-bottom\nline-05\n',
- );
- const hook = createHook();
- const cleanPatchText = `*** Begin Patch
- *** Update File: sample.txt
- @@ exact-top
- -exact-old
- +exact-new
- exact-bottom
- *** End Patch`;
- const output = {
- args: {
- patchText: `cat <<'PATCH'
- ${cleanPatchText}
- PATCH`,
- },
- };
- await hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- );
- expect(output.args.patchText).toBe(cleanPatchText);
- const changes = await preparePatchChanges(
- root,
- output.args.patchText as string,
- DEFAULT_OPTIONS,
- );
- await applyPreparedChanges(changes);
- expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
- 'line-01\nexact-top\nexact-new\nexact-bottom\nline-05\n',
- );
- });
- test('normalizes absolute paths inside root before native execution', async () => {
- const root = await createTempDir('apply-patch-hook-');
- const absolutePath = path.join(root, 'sample.txt');
- await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: ${absolutePath}
- @@
- -alpha
- +omega
- *** End Patch`;
- const output = { args: { patchText } };
- await hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- );
- expect(parsePatch(output.args.patchText as string).hunks[0]).toMatchObject({
- type: 'update',
- path: 'sample.txt',
- });
- });
- test('passes through an absolute target outside root/worktree before native execution', async () => {
- const root = await createTempDir('apply-patch-hook-');
- const outsideDir = await createTempDir('apply-patch-hook-outside-');
- const outsidePath = path.join(outsideDir, 'outside.txt');
- await writeFile(outsidePath, 'outside\n', 'utf-8');
- const hook = createApplyPatchHook({
- client: {} as never,
- directory: root,
- worktree: root,
- } as never);
- const patchText = `*** Begin Patch
- *** Update File: ${outsidePath}
- @@
- -outside
- +changed
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).resolves.toBeUndefined();
- expect(output.args.patchText).toBe(patchText);
- expect(await readFile(outsidePath, 'utf-8')).toBe('outside\n');
- });
- test('passes through mixed stale inside and absolute outside patch without partial rewrite', async () => {
- const root = await createTempDir('apply-patch-hook-');
- const outsideDir = await createTempDir('apply-patch-hook-outside-');
- const outsidePath = path.join(outsideDir, 'outside.txt');
- await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
- await writeFile(outsidePath, 'outside\n', 'utf-8');
- const hook = createApplyPatchHook({
- client: {} as never,
- directory: root,
- worktree: root,
- } as never);
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@
- prefix
- -old-value
- +new-value
- suffix
- *** Update File: ${outsidePath}
- @@
- -outside
- +changed
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).resolves.toBeUndefined();
- expect(output.args.patchText).toBe(patchText);
- expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
- 'prefix\nstale-value\nsuffix\n',
- );
- expect(await readFile(outsidePath, 'utf-8')).toBe('outside\n');
- });
- test('rewrites a stale prefix patch and remains applicable', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(
- root,
- 'sample.txt',
- 'top\nA\nB-stale\nC\nD\nE\nbottom\n',
- );
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@ top
- A
- -B
- -C
- -D
- -E
- +B
- +C
- +D
- +X
- *** End Patch`;
- const output = { args: { patchText } };
- await hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- );
- const rewritten = parsePatch(output.args.patchText as string).hunks[0];
- expect(rewritten.type).toBe('update');
- expect(
- rewritten.type === 'update' && rewritten.chunks[0]?.old_lines,
- ).toEqual(['A', 'B-stale', 'C', 'D', 'E']);
- const changes = await preparePatchChanges(
- root,
- output.args.patchText as string,
- DEFAULT_OPTIONS,
- );
- await applyPreparedChanges(changes);
- expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
- 'top\nA\nB\nC\nD\nX\nbottom\n',
- );
- });
- test('does not alter new_lines during rewrite', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(
- root,
- 'sample.txt',
- 'top\nprefix\nstale-value\nsuffix\nbottom\n',
- );
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@ top
- prefix
- -old-value
- + \tverbatim "" Ω
- suffix
- *** End Patch`;
- const expected = parsePatch(patchText).hunks[0];
- const output = { args: { patchText } };
- await hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- );
- const rewritten = parsePatch(output.args.patchText as string).hunks[0];
- expect(expected.type).toBe('update');
- expect(rewritten.type).toBe('update');
- expect(
- expected.type === 'update' && rewritten.type === 'update'
- ? rewritten.chunks[0]?.new_lines
- : undefined,
- ).toEqual(expected.type === 'update' ? expected.chunks[0]?.new_lines : []);
- });
- test('rewrites a unicode-only stale patch and remains applicable', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(root, 'sample.txt', 'const title = “Hola”;\n');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@
- -const title = "Hola";
- +const title = "Hola mundo";
- *** End Patch`;
- const output = { args: { patchText } };
- await hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- );
- const rewritten = parsePatch(output.args.patchText as string).hunks[0];
- expect(rewritten.type).toBe('update');
- expect(
- rewritten.type === 'update' ? rewritten.chunks[0]?.old_lines : undefined,
- ).toEqual(['const title = “Hola”;']);
- const changes = await preparePatchChanges(
- root,
- output.args.patchText as string,
- DEFAULT_OPTIONS,
- );
- await applyPreparedChanges(changes);
- expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
- 'const title = "Hola mundo";\n',
- );
- });
- test('rewrites a trim-end stale patch and remains applicable', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(root, 'sample.txt', 'alpha \n');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@
- -alpha
- +omega
- *** End Patch`;
- const output = { args: { patchText } };
- await hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- );
- const rewritten = parsePatch(output.args.patchText as string).hunks[0];
- expect(rewritten.type).toBe('update');
- expect(
- rewritten.type === 'update' ? rewritten.chunks[0]?.old_lines : undefined,
- ).toEqual(['alpha ']);
- const changes = await preparePatchChanges(
- root,
- output.args.patchText as string,
- DEFAULT_OPTIONS,
- );
- await applyPreparedChanges(changes);
- expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
- 'omega\n',
- );
- });
- test('blocks a trim-only stale patch as verification', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(root, 'sample.txt', ' alpha \n');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@
- -alpha
- +omega
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).rejects.toThrow(
- 'apply_patch verification failed: Failed to find expected lines',
- );
- expect(output.args.patchText).toBe(patchText);
- });
- test('blocks a malformed @@ at runtime before native execution', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@
- alpha
- garbage
- -beta
- +BETA
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).rejects.toThrow(
- 'apply_patch validation failed: Invalid patch format: unexpected line in patch chunk: garbage',
- );
- expect(output.args.patchText).toBe(patchText);
- });
- test('blocks a malformed Add File at runtime before native execution', async () => {
- const root = await createTempDir('apply-patch-hook-');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Add File: added.txt
- +fresh
- garbage
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).rejects.toThrow(
- 'apply_patch validation failed: Invalid patch format: unexpected line in Add File body: garbage',
- );
- expect(output.args.patchText).toBe(patchText);
- });
- test('blocks internal guard errors before native execution', async () => {
- const root = await createTempDir('apply-patch-hook-');
- const lockedDir = path.join(root, 'locked');
- await mkdir(lockedDir, { recursive: true });
- await chmod(lockedDir, 0o000);
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Add File: locked/child.txt
- +fresh
- *** End Patch`;
- const output = { args: { patchText } };
- try {
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).rejects.toThrow('apply_patch internal error:');
- expect(output.args.patchText).toBe(patchText);
- } finally {
- await chmod(lockedDir, 0o755);
- }
- });
- test('blocks a dangerous indented case as verification', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(
- root,
- 'sample.yml',
- 'root:\n child:\n enabled: false\nnext: true\n',
- );
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.yml
- @@
- -enabled: false
- +enabled: true
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).rejects.toThrow(
- 'apply_patch verification failed: Failed to find expected lines',
- );
- expect(output.args.patchText).toBe(patchText);
- });
- test('rewrites anchored insertion to avoid native EOF handling', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(
- root,
- 'sample.txt',
- 'top\nanchor-insert\nafter-anchor\nend\n',
- );
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@ anchor-insert
- +middle-inserted
- *** End Patch`;
- const output = { args: { patchText } };
- await hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- );
- const changes = await preparePatchChanges(
- root,
- output.args.patchText as string,
- DEFAULT_OPTIONS,
- );
- await applyPreparedChanges(changes);
- expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
- 'top\nanchor-insert\nmiddle-inserted\nafter-anchor\nend\n',
- );
- });
- test('blocks a pure insertion when the anchor is missing', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(root, 'sample.txt', 'top\nafter-anchor\nend\n');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@ anchor-insert
- +middle-inserted
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).rejects.toThrow(
- 'apply_patch verification failed: Failed to find insertion anchor',
- );
- expect(output.args.patchText).toBe(patchText);
- });
- test('blocks a pure insertion when the anchor is ambiguous', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(
- root,
- 'sample.txt',
- 'top\nanchor-insert\nafter-first\nsplit\nanchor-insert\nafter-second\nend\n',
- );
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@ anchor-insert
- +middle-inserted
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).rejects.toThrow(
- 'apply_patch verification failed: Insertion anchor was ambiguous',
- );
- expect(output.args.patchText).toBe(patchText);
- });
- test('blocks real patch ambiguity before native execution', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(
- root,
- 'sample.txt',
- 'left\nstale-one\nright\nseparator\nleft\nstale-two\nright\n',
- );
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@
- left
- -old
- +new
- right
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).rejects.toThrow('apply_patch verification failed:');
- expect(output.args.patchText).toBe(patchText);
- });
- test('rewrites only the update hunk in a patch with add + update', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(
- root,
- 'sample.txt',
- 'top\nprefix\nstale-value\nsuffix\n',
- );
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Add File: added.txt
- +fresh
- *** Update File: sample.txt
- @@ top
- prefix
- -old-value
- +new-value
- suffix
- *** End Patch`;
- const output = { args: { patchText } };
- await hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- );
- const rewritten = parsePatch(output.args.patchText as string);
- expect(rewritten.hunks[0]).toEqual({
- type: 'add',
- path: 'added.txt',
- contents: 'fresh',
- });
- expect(rewritten.hunks[1]).toEqual({
- type: 'update',
- path: 'sample.txt',
- chunks: [
- {
- old_lines: ['prefix', 'stale-value', 'suffix'],
- new_lines: ['prefix', 'new-value', 'suffix'],
- change_context: 'top',
- is_end_of_file: undefined,
- },
- ],
- });
- const changes = await preparePatchChanges(
- root,
- output.args.patchText as string,
- DEFAULT_OPTIONS,
- );
- await applyPreparedChanges(changes);
- expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
- 'top\nprefix\nnew-value\nsuffix\n',
- );
- expect(await readFile(path.join(root, 'added.txt'), 'utf-8')).toBe(
- 'fresh\n',
- );
- });
- test('passes through sibling-directory targets outside root/worktree before native execution', async () => {
- const root = await createTempDir('apply-patch-hook-');
- const outside = path.join(path.dirname(root), 'outside.txt');
- await writeFile(outside, 'outside\n', 'utf-8');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: ../outside.txt
- @@
- -outside
- +changed
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).resolves.toBeUndefined();
- expect(output.args.patchText).toBe(patchText);
- expect(await readFile(outside, 'utf-8')).toBe('outside\n');
- });
- test('normalizes an absolute path inside worktree even when it is outside root', async () => {
- const worktree = await createTempDir('apply-patch-worktree-');
- const root = path.join(worktree, 'subdir');
- await mkdir(root, { recursive: true });
- const siblingPath = path.join(worktree, 'shared.txt');
- const hook = createApplyPatchHook({
- client: {} as never,
- directory: root,
- worktree,
- } as never);
- const patchText = `*** Begin Patch
- *** Add File: ${siblingPath}
- +fresh
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).resolves.toBeUndefined();
- expect(parsePatch(output.args.patchText as string).hunks[0]).toMatchObject({
- type: 'add',
- path: '../shared.txt',
- contents: 'fresh',
- });
- });
- test('passes through mixed patches with outside paths without partial rewrite', async () => {
- const root = await createTempDir('apply-patch-hook-');
- const outsideDir = await createTempDir('apply-patch-hook-outside-');
- await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
- await writeFixture(outsideDir, 'outside.txt', 'legacy\n');
- const hook = createHook();
- const outsideAdded = path.join(path.dirname(root), 'outside-added.txt');
- const patchText = `*** Begin Patch
- *** Add File: ../outside-added.txt
- +fresh
- *** Update File: sample.txt
- @@
- prefix
- -old-value
- +new-value
- suffix
- *** Delete File: ../${path.basename(outsideDir)}/outside.txt
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).resolves.toBeUndefined();
- expect(output.args.patchText).toBe(patchText);
- expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
- 'prefix\nstale-value\nsuffix\n',
- );
- expect(await stat(outsideAdded).catch(() => null)).toBeNull();
- expect(await readFile(path.join(outsideDir, 'outside.txt'), 'utf-8')).toBe(
- 'legacy\n',
- );
- });
- test('keeps normal behavior for patches entirely inside root/worktree', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@
- -alpha
- +omega
- beta
- *** End Patch`;
- const output = { args: { patchText } };
- await expect(
- hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- ),
- ).resolves.toBeUndefined();
- expect(output.args.patchText).toBe(patchText);
- });
- test('does not expose the tool.execute.after hook', () => {
- const hook = createHook() as Record<string, unknown>;
- expect(hook['tool.execute.after']).toBeUndefined();
- });
- test('does not alter an exact patch', async () => {
- const root = await createTempDir('apply-patch-hook-');
- await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
- const hook = createHook();
- const patchText = `*** Begin Patch
- *** Update File: sample.txt
- @@
- -alpha
- +omega
- beta
- *** End Patch`;
- const output = { args: { patchText } };
- await hook['tool.execute.before'](
- { tool: 'apply_patch', directory: root },
- output,
- );
- expect(output.args.patchText).toBe(patchText);
- });
- });
|