opencode-dux 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/agents/descriptions.d.ts +6 -0
- package/dist/agents/designer.d.ts +2 -0
- package/dist/agents/explorer.d.ts +2 -0
- package/dist/agents/fixer.d.ts +2 -0
- package/dist/agents/index.d.ts +22 -0
- package/dist/agents/interpreter.d.ts +2 -0
- package/dist/agents/librarian.d.ts +2 -0
- package/dist/agents/oracle.d.ts +2 -0
- package/dist/agents/orchestrator.d.ts +27 -0
- package/dist/agents/overrides.d.ts +18 -0
- package/dist/agents/prompt-blocks.d.ts +97 -0
- package/dist/agents/steward.d.ts +3 -0
- package/dist/cli/config-io.d.ts +24 -0
- package/dist/cli/config-manager.d.ts +4 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1006 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/mcps.d.ts +13 -0
- package/dist/cli/model-key-normalization.d.ts +1 -0
- package/dist/cli/paths.d.ts +35 -0
- package/dist/cli/providers.d.ts +137 -0
- package/dist/cli/skills.d.ts +22 -0
- package/dist/cli/system.d.ts +5 -0
- package/dist/cli/types.d.ts +38 -0
- package/dist/config/constants.d.ts +12 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/loader.d.ts +40 -0
- package/dist/config/runtime-preset.d.ts +12 -0
- package/dist/config/schema.d.ts +281 -0
- package/dist/config/utils.d.ts +10 -0
- package/dist/discovery/local/types.d.ts +79 -0
- package/dist/discovery/local.d.ts +73 -0
- package/dist/discovery/mcp-servers.d.ts +88 -0
- package/dist/discovery/skills.d.ts +94 -0
- package/dist/hooks/apply-patch/codec.d.ts +7 -0
- package/dist/hooks/apply-patch/errors.d.ts +25 -0
- package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
- package/dist/hooks/apply-patch/index.d.ts +15 -0
- package/dist/hooks/apply-patch/matching.d.ts +26 -0
- package/dist/hooks/apply-patch/operations.d.ts +3 -0
- package/dist/hooks/apply-patch/patch.d.ts +2 -0
- package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
- package/dist/hooks/apply-patch/resolution.d.ts +19 -0
- package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
- package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
- package/dist/hooks/apply-patch/types.d.ts +80 -0
- package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
- package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
- package/dist/hooks/auto-update-checker/index.d.ts +18 -0
- package/dist/hooks/auto-update-checker/types.d.ts +22 -0
- package/dist/hooks/chat-headers.d.ts +16 -0
- package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
- package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
- package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
- package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
- package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
- package/dist/hooks/filter-available-skills/index.d.ts +32 -0
- package/dist/hooks/foreground-fallback/index.d.ts +72 -0
- package/dist/hooks/image-hook.d.ts +5 -0
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
- package/dist/hooks/json-error-recovery/index.d.ts +1 -0
- package/dist/hooks/phase-reminder/index.d.ts +26 -0
- package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
- package/dist/hooks/task-session-manager/index.d.ts +52 -0
- package/dist/hooks/todo-continuation/index.d.ts +53 -0
- package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +31782 -0
- package/dist/mcp/context7.d.ts +6 -0
- package/dist/mcp/grep-app.d.ts +6 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/types.d.ts +12 -0
- package/dist/mcp/websearch.d.ts +9 -0
- package/dist/skills/registry.d.ts +29 -0
- package/dist/subscriptions/accounts-store.d.ts +57 -0
- package/dist/subscriptions/index.d.ts +13 -0
- package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
- package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
- package/dist/subscriptions/types.d.ts +115 -0
- package/dist/subscriptions/usage-service.d.ts +74 -0
- package/dist/tools/ast-grep/cli.d.ts +15 -0
- package/dist/tools/ast-grep/constants.d.ts +25 -0
- package/dist/tools/ast-grep/downloader.d.ts +5 -0
- package/dist/tools/ast-grep/index.d.ts +10 -0
- package/dist/tools/ast-grep/tools.d.ts +3 -0
- package/dist/tools/ast-grep/types.d.ts +30 -0
- package/dist/tools/ast-grep/utils.d.ts +4 -0
- package/dist/tools/delegate.d.ts +14 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/preset-manager.d.ts +27 -0
- package/dist/tools/smartfetch/binary.d.ts +3 -0
- package/dist/tools/smartfetch/cache.d.ts +6 -0
- package/dist/tools/smartfetch/constants.d.ts +12 -0
- package/dist/tools/smartfetch/index.d.ts +3 -0
- package/dist/tools/smartfetch/network.d.ts +38 -0
- package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
- package/dist/tools/smartfetch/tool.d.ts +3 -0
- package/dist/tools/smartfetch/types.d.ts +122 -0
- package/dist/tools/smartfetch/utils.d.ts +18 -0
- package/dist/tui-state.d.ts +168 -0
- package/dist/tui.d.ts +37 -0
- package/dist/tui.js +1896 -0
- package/dist/utils/agent-variant.d.ts +63 -0
- package/dist/utils/compat.d.ts +30 -0
- package/dist/utils/env.d.ts +1 -0
- package/dist/utils/index.d.ts +9 -0
- package/dist/utils/internal-initiator.d.ts +6 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/polling.d.ts +21 -0
- package/dist/utils/session-manager.d.ts +55 -0
- package/dist/utils/session.d.ts +90 -0
- package/dist/utils/subagent-depth.d.ts +35 -0
- package/dist/utils/system-collapse.d.ts +6 -0
- package/dist/utils/task.d.ts +4 -0
- package/dist/utils/zip-extractor.d.ts +1 -0
- package/index.ts +1 -0
- package/opencode-dux.schema.json +634 -0
- package/package.json +103 -0
- package/src/agents/descriptions.ts +55 -0
- package/src/agents/designer.test.ts +86 -0
- package/src/agents/designer.ts +154 -0
- package/src/agents/display-name.test.ts +186 -0
- package/src/agents/explorer.test.ts +79 -0
- package/src/agents/explorer.ts +144 -0
- package/src/agents/fixer.test.ts +79 -0
- package/src/agents/fixer.ts +145 -0
- package/src/agents/index.test.ts +472 -0
- package/src/agents/index.ts +248 -0
- package/src/agents/interpreter.ts +136 -0
- package/src/agents/librarian.test.ts +80 -0
- package/src/agents/librarian.ts +145 -0
- package/src/agents/oracle.test.ts +89 -0
- package/src/agents/oracle.ts +184 -0
- package/src/agents/orchestrator.test.ts +116 -0
- package/src/agents/orchestrator.ts +574 -0
- package/src/agents/overrides.ts +95 -0
- package/src/agents/prompt-blocks.test.ts +114 -0
- package/src/agents/prompt-blocks.ts +640 -0
- package/src/agents/steward.ts +146 -0
- package/src/cli/config-io.test.ts +536 -0
- package/src/cli/config-io.ts +473 -0
- package/src/cli/config-manager.test.ts +141 -0
- package/src/cli/config-manager.ts +4 -0
- package/src/cli/index.ts +88 -0
- package/src/cli/install.ts +282 -0
- package/src/cli/mcps.test.ts +62 -0
- package/src/cli/mcps.ts +39 -0
- package/src/cli/model-key-normalization.test.ts +21 -0
- package/src/cli/model-key-normalization.ts +60 -0
- package/src/cli/paths.test.ts +167 -0
- package/src/cli/paths.ts +144 -0
- package/src/cli/providers.test.ts +118 -0
- package/src/cli/providers.ts +141 -0
- package/src/cli/skills.test.ts +111 -0
- package/src/cli/skills.ts +103 -0
- package/src/cli/system.test.ts +91 -0
- package/src/cli/system.ts +180 -0
- package/src/cli/types.ts +43 -0
- package/src/config/constants.ts +58 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.test.ts +1194 -0
- package/src/config/loader.ts +269 -0
- package/src/config/model-resolution.test.ts +176 -0
- package/src/config/runtime-preset.test.ts +61 -0
- package/src/config/runtime-preset.ts +37 -0
- package/src/config/schema.ts +248 -0
- package/src/config/utils.test.ts +41 -0
- package/src/config/utils.ts +23 -0
- package/src/discovery/local/types.ts +85 -0
- package/src/discovery/local.ts +322 -0
- package/src/discovery/mcp-servers.ts +804 -0
- package/src/discovery/skills.ts +959 -0
- package/src/hooks/apply-patch/codec.test.ts +184 -0
- package/src/hooks/apply-patch/codec.ts +352 -0
- package/src/hooks/apply-patch/errors.ts +117 -0
- package/src/hooks/apply-patch/execution-context.ts +432 -0
- package/src/hooks/apply-patch/hook.test.ts +768 -0
- package/src/hooks/apply-patch/index.ts +126 -0
- package/src/hooks/apply-patch/matching.test.ts +215 -0
- package/src/hooks/apply-patch/matching.ts +586 -0
- package/src/hooks/apply-patch/operations.test.ts +1535 -0
- package/src/hooks/apply-patch/operations.ts +3 -0
- package/src/hooks/apply-patch/patch.ts +9 -0
- package/src/hooks/apply-patch/prepared-changes.ts +400 -0
- package/src/hooks/apply-patch/resolution.test.ts +420 -0
- package/src/hooks/apply-patch/resolution.ts +437 -0
- package/src/hooks/apply-patch/rewrite.ts +496 -0
- package/src/hooks/apply-patch/test-helpers.ts +52 -0
- package/src/hooks/apply-patch/types.ts +111 -0
- package/src/hooks/auto-update-checker/cache.test.ts +179 -0
- package/src/hooks/auto-update-checker/cache.ts +188 -0
- package/src/hooks/auto-update-checker/checker.test.ts +159 -0
- package/src/hooks/auto-update-checker/checker.ts +308 -0
- package/src/hooks/auto-update-checker/constants.ts +33 -0
- package/src/hooks/auto-update-checker/index.test.ts +282 -0
- package/src/hooks/auto-update-checker/index.ts +225 -0
- package/src/hooks/auto-update-checker/types.ts +26 -0
- package/src/hooks/chat-headers.test.ts +236 -0
- package/src/hooks/chat-headers.ts +97 -0
- package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
- package/src/hooks/context-pressure-reminder/index.ts +137 -0
- package/src/hooks/delegate-task-retry/guidance.ts +41 -0
- package/src/hooks/delegate-task-retry/hook.ts +23 -0
- package/src/hooks/delegate-task-retry/index.test.ts +38 -0
- package/src/hooks/delegate-task-retry/index.ts +7 -0
- package/src/hooks/delegate-task-retry/patterns.ts +79 -0
- package/src/hooks/filter-available-skills/index.test.ts +297 -0
- package/src/hooks/filter-available-skills/index.ts +160 -0
- package/src/hooks/foreground-fallback/index.test.ts +624 -0
- package/src/hooks/foreground-fallback/index.ts +374 -0
- package/src/hooks/image-hook.ts +6 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/json-error-recovery/hook.ts +73 -0
- package/src/hooks/json-error-recovery/index.test.ts +111 -0
- package/src/hooks/json-error-recovery/index.ts +6 -0
- package/src/hooks/phase-reminder/index.test.ts +74 -0
- package/src/hooks/phase-reminder/index.ts +85 -0
- package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
- package/src/hooks/post-file-tool-nudge/index.ts +63 -0
- package/src/hooks/task-session-manager/index.test.ts +833 -0
- package/src/hooks/task-session-manager/index.ts +434 -0
- package/src/hooks/todo-continuation/index.test.ts +3026 -0
- package/src/hooks/todo-continuation/index.ts +878 -0
- package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
- package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
- package/src/index.ts +1672 -0
- package/src/mcp/context7.ts +14 -0
- package/src/mcp/grep-app.ts +11 -0
- package/src/mcp/index.test.ts +96 -0
- package/src/mcp/index.ts +66 -0
- package/src/mcp/types.ts +16 -0
- package/src/mcp/websearch.ts +47 -0
- package/src/skills/codemap/README.md +60 -0
- package/src/skills/codemap/SKILL.md +174 -0
- package/src/skills/codemap/scripts/codemap.mjs +483 -0
- package/src/skills/codemap/scripts/codemap.test.ts +129 -0
- package/src/skills/registry.ts +218 -0
- package/src/skills/simplify/README.md +19 -0
- package/src/skills/simplify/SKILL.md +138 -0
- package/src/subscriptions/accounts-store.test.ts +236 -0
- package/src/subscriptions/accounts-store.ts +184 -0
- package/src/subscriptions/index.ts +30 -0
- package/src/subscriptions/neuralwatt-scraper.ts +108 -0
- package/src/subscriptions/opencode-go-scraper.ts +301 -0
- package/src/subscriptions/types.ts +145 -0
- package/src/subscriptions/usage-service.test.ts +202 -0
- package/src/subscriptions/usage-service.ts +651 -0
- package/src/tools/ast-grep/cli.ts +257 -0
- package/src/tools/ast-grep/constants.ts +214 -0
- package/src/tools/ast-grep/downloader.ts +131 -0
- package/src/tools/ast-grep/index.ts +24 -0
- package/src/tools/ast-grep/tools.ts +117 -0
- package/src/tools/ast-grep/types.ts +51 -0
- package/src/tools/ast-grep/utils.ts +126 -0
- package/src/tools/delegate-handoff.test.ts +18 -0
- package/src/tools/delegate.ts +508 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/preset-manager.test.ts +795 -0
- package/src/tools/preset-manager.ts +332 -0
- package/src/tools/smartfetch/binary.ts +58 -0
- package/src/tools/smartfetch/cache.test.ts +34 -0
- package/src/tools/smartfetch/cache.ts +112 -0
- package/src/tools/smartfetch/constants.ts +29 -0
- package/src/tools/smartfetch/index.ts +8 -0
- package/src/tools/smartfetch/network.test.ts +178 -0
- package/src/tools/smartfetch/network.ts +614 -0
- package/src/tools/smartfetch/secondary-model.test.ts +85 -0
- package/src/tools/smartfetch/secondary-model.ts +276 -0
- package/src/tools/smartfetch/tool.test.ts +60 -0
- package/src/tools/smartfetch/tool.ts +832 -0
- package/src/tools/smartfetch/types.ts +135 -0
- package/src/tools/smartfetch/utils.test.ts +24 -0
- package/src/tools/smartfetch/utils.ts +456 -0
- package/src/tui-state.test.ts +867 -0
- package/src/tui-state.ts +1255 -0
- package/src/tui.test.ts +336 -0
- package/src/tui.ts +1539 -0
- package/src/utils/agent-variant.test.ts +244 -0
- package/src/utils/agent-variant.ts +187 -0
- package/src/utils/compat.ts +91 -0
- package/src/utils/env.ts +12 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/internal-initiator.ts +28 -0
- package/src/utils/logger.test.ts +220 -0
- package/src/utils/logger.ts +136 -0
- package/src/utils/polling.test.ts +191 -0
- package/src/utils/polling.ts +67 -0
- package/src/utils/session-manager.test.ts +173 -0
- package/src/utils/session-manager.ts +356 -0
- package/src/utils/session.test.ts +110 -0
- package/src/utils/session.ts +389 -0
- package/src/utils/subagent-depth.test.ts +170 -0
- package/src/utils/subagent-depth.ts +75 -0
- package/src/utils/system-collapse.test.ts +86 -0
- package/src/utils/system-collapse.ts +24 -0
- package/src/utils/task.test.ts +24 -0
- package/src/utils/task.ts +20 -0
- package/src/utils/zip-extractor.ts +102 -0
|
@@ -0,0 +1,1535 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { chmod, mkdir, stat, symlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { platform } from 'node:os';
|
|
5
|
+
|
|
6
|
+
function maybeMode(mode: number): number | undefined {
|
|
7
|
+
return platform() === 'win32' ? undefined : mode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function expectMode(p: string, mode: number): Promise<void> {
|
|
11
|
+
if (platform() === 'win32') return;
|
|
12
|
+
expect((await stat(p)).mode & 0o777).toBe(mode);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
import { parsePatch } from './codec';
|
|
16
|
+
import {
|
|
17
|
+
isApplyPatchBlockedError,
|
|
18
|
+
isApplyPatchValidationError,
|
|
19
|
+
isApplyPatchVerificationError,
|
|
20
|
+
} from './errors';
|
|
21
|
+
import {
|
|
22
|
+
applyPreparedChanges,
|
|
23
|
+
preparePatchChanges,
|
|
24
|
+
rewritePatch,
|
|
25
|
+
rewritePatchText,
|
|
26
|
+
} from './operations';
|
|
27
|
+
import {
|
|
28
|
+
applyPatch,
|
|
29
|
+
createTempDir,
|
|
30
|
+
DEFAULT_OPTIONS,
|
|
31
|
+
readText,
|
|
32
|
+
writeFixture,
|
|
33
|
+
} from './test-helpers';
|
|
34
|
+
|
|
35
|
+
describe('apply-patch/operations', () => {
|
|
36
|
+
test('preparePatchChanges and applyPreparedChanges apply an exact match', async () => {
|
|
37
|
+
const root = await createTempDir();
|
|
38
|
+
await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
|
|
39
|
+
await chmod(path.join(root, 'sample.txt'), 0o750);
|
|
40
|
+
|
|
41
|
+
await applyPatch(
|
|
42
|
+
root,
|
|
43
|
+
`*** Begin Patch
|
|
44
|
+
*** Update File: sample.txt
|
|
45
|
+
@@
|
|
46
|
+
alpha
|
|
47
|
+
-beta
|
|
48
|
+
+BETA
|
|
49
|
+
gamma
|
|
50
|
+
*** End Patch`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA\ngamma\n');
|
|
54
|
+
await expectMode(path.join(root, 'sample.txt'), 0o750);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('rewritePatchText leaves a healthy patch intact', async () => {
|
|
58
|
+
const root = await createTempDir();
|
|
59
|
+
const patchText = `*** Begin Patch
|
|
60
|
+
*** Update File: sample.txt
|
|
61
|
+
@@ exact-top
|
|
62
|
+
-exact-old
|
|
63
|
+
+exact-new
|
|
64
|
+
exact-bottom
|
|
65
|
+
*** End Patch`;
|
|
66
|
+
await writeFixture(
|
|
67
|
+
root,
|
|
68
|
+
'sample.txt',
|
|
69
|
+
'line-01\nexact-top\nexact-old\nexact-bottom\nline-05\n',
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(await rewritePatchText(root, patchText, DEFAULT_OPTIONS)).toBe(
|
|
73
|
+
patchText,
|
|
74
|
+
);
|
|
75
|
+
expect(await rewritePatch(root, patchText, DEFAULT_OPTIONS)).toMatchObject({
|
|
76
|
+
patchText,
|
|
77
|
+
changed: false,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('rewritePatchText unwraps an exact patch wrapped in a heredoc', async () => {
|
|
82
|
+
const root = await createTempDir();
|
|
83
|
+
const cleanPatchText = `*** Begin Patch
|
|
84
|
+
*** Update File: sample.txt
|
|
85
|
+
@@ exact-top
|
|
86
|
+
-exact-old
|
|
87
|
+
+exact-new
|
|
88
|
+
exact-bottom
|
|
89
|
+
*** End Patch`;
|
|
90
|
+
const patchText = `cat <<'PATCH'
|
|
91
|
+
${cleanPatchText}
|
|
92
|
+
PATCH`;
|
|
93
|
+
await writeFixture(
|
|
94
|
+
root,
|
|
95
|
+
'sample.txt',
|
|
96
|
+
'line-01\nexact-top\nexact-old\nexact-bottom\nline-05\n',
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(await rewritePatchText(root, patchText, DEFAULT_OPTIONS)).toBe(
|
|
100
|
+
cleanPatchText,
|
|
101
|
+
);
|
|
102
|
+
expect(await rewritePatch(root, patchText, DEFAULT_OPTIONS)).toMatchObject({
|
|
103
|
+
patchText: cleanPatchText,
|
|
104
|
+
changed: true,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('rewritePatchText normalizes exact CRLF + heredoc input and the patch still works', async () => {
|
|
109
|
+
const root = await createTempDir();
|
|
110
|
+
const cleanPatchText = `*** Begin Patch
|
|
111
|
+
*** Update File: sample.txt
|
|
112
|
+
@@ exact-top
|
|
113
|
+
-exact-old
|
|
114
|
+
+exact-new
|
|
115
|
+
exact-bottom
|
|
116
|
+
*** End Patch`;
|
|
117
|
+
const patchText = [
|
|
118
|
+
"cat <<'PATCH'",
|
|
119
|
+
'*** Begin Patch',
|
|
120
|
+
'*** Update File: sample.txt',
|
|
121
|
+
'@@ exact-top',
|
|
122
|
+
'-exact-old',
|
|
123
|
+
'+exact-new',
|
|
124
|
+
' exact-bottom',
|
|
125
|
+
'*** End Patch',
|
|
126
|
+
'PATCH',
|
|
127
|
+
].join('\r\n');
|
|
128
|
+
await writeFixture(
|
|
129
|
+
root,
|
|
130
|
+
'sample.txt',
|
|
131
|
+
'line-01\nexact-top\nexact-old\nexact-bottom\nline-05\n',
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const rewritten = await rewritePatchText(root, patchText, DEFAULT_OPTIONS);
|
|
135
|
+
|
|
136
|
+
expect(rewritten).toBe(cleanPatchText);
|
|
137
|
+
await applyPatch(root, rewritten);
|
|
138
|
+
expect(await readText(root, 'sample.txt')).toBe(
|
|
139
|
+
'line-01\nexact-top\nexact-new\nexact-bottom\nline-05\n',
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('rewritePatchText rewrites a stale patch and preserves new_lines byte-for-byte', async () => {
|
|
144
|
+
const root = await createTempDir();
|
|
145
|
+
await writeFixture(
|
|
146
|
+
root,
|
|
147
|
+
'sample.txt',
|
|
148
|
+
'top\nprefix\nstale-value\nsuffix\nbottom\n',
|
|
149
|
+
);
|
|
150
|
+
const patchText = `*** Begin Patch
|
|
151
|
+
*** Update File: sample.txt
|
|
152
|
+
@@ top
|
|
153
|
+
prefix
|
|
154
|
+
-old-value
|
|
155
|
+
+ \tverbatim "" Ω
|
|
156
|
+
suffix
|
|
157
|
+
*** End Patch`;
|
|
158
|
+
|
|
159
|
+
const rewritten = parsePatch(
|
|
160
|
+
await rewritePatchText(root, patchText, DEFAULT_OPTIONS),
|
|
161
|
+
).hunks[0];
|
|
162
|
+
|
|
163
|
+
expect(rewritten.type).toBe('update');
|
|
164
|
+
expect(
|
|
165
|
+
rewritten.type === 'update' && rewritten.chunks[0]?.old_lines,
|
|
166
|
+
).toEqual(['prefix', 'stale-value', 'suffix']);
|
|
167
|
+
expect(
|
|
168
|
+
rewritten.type === 'update' && rewritten.chunks[0]?.new_lines,
|
|
169
|
+
).toEqual(['prefix', ' \tverbatim "" Ω ', 'suffix']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('rewritePatchText removes EOF when a rescue moves the chunk away from the real end', async () => {
|
|
173
|
+
const root = await createTempDir();
|
|
174
|
+
await writeFixture(
|
|
175
|
+
root,
|
|
176
|
+
'sample.txt',
|
|
177
|
+
'top\nprefix\nstale\nsuffix\nbottom\n',
|
|
178
|
+
);
|
|
179
|
+
const patchText = `*** Begin Patch
|
|
180
|
+
*** Update File: sample.txt
|
|
181
|
+
@@ top
|
|
182
|
+
prefix
|
|
183
|
+
-old
|
|
184
|
+
+new
|
|
185
|
+
suffix
|
|
186
|
+
*** End of File
|
|
187
|
+
*** End Patch`;
|
|
188
|
+
|
|
189
|
+
const rewrittenText = await rewritePatchText(
|
|
190
|
+
root,
|
|
191
|
+
patchText,
|
|
192
|
+
DEFAULT_OPTIONS,
|
|
193
|
+
);
|
|
194
|
+
const rewritten = parsePatch(rewrittenText).hunks[0];
|
|
195
|
+
|
|
196
|
+
expect(rewrittenText.includes('*** End of File')).toBeFalse();
|
|
197
|
+
expect(rewritten.type).toBe('update');
|
|
198
|
+
expect(
|
|
199
|
+
rewritten.type === 'update'
|
|
200
|
+
? rewritten.chunks[0]?.is_end_of_file
|
|
201
|
+
: undefined,
|
|
202
|
+
).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('rewritePatchText keeps EOF when the resolved chunk still ends at the real end', async () => {
|
|
206
|
+
const root = await createTempDir();
|
|
207
|
+
await writeFixture(root, 'sample.txt', 'alpha\nstale\nomega');
|
|
208
|
+
const patchText = `*** Begin Patch
|
|
209
|
+
*** Update File: sample.txt
|
|
210
|
+
@@
|
|
211
|
+
-alpha
|
|
212
|
+
-old
|
|
213
|
+
-omega
|
|
214
|
+
+alpha
|
|
215
|
+
+new
|
|
216
|
+
+omega
|
|
217
|
+
*** End of File
|
|
218
|
+
*** End Patch`;
|
|
219
|
+
|
|
220
|
+
const rewrittenText = await rewritePatchText(
|
|
221
|
+
root,
|
|
222
|
+
patchText,
|
|
223
|
+
DEFAULT_OPTIONS,
|
|
224
|
+
);
|
|
225
|
+
const rewritten = parsePatch(rewrittenText).hunks[0];
|
|
226
|
+
|
|
227
|
+
expect(rewrittenText.includes('*** End of File')).toBeTrue();
|
|
228
|
+
expect(rewritten.type).toBe('update');
|
|
229
|
+
expect(
|
|
230
|
+
rewritten.type === 'update'
|
|
231
|
+
? rewritten.chunks[0]?.is_end_of_file
|
|
232
|
+
: undefined,
|
|
233
|
+
).toBeTrue();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('rewritePatchText canonicalizes a unicode-only stale patch', async () => {
|
|
237
|
+
const root = await createTempDir();
|
|
238
|
+
await writeFixture(root, 'sample.txt', 'const title = “Hola”;\n');
|
|
239
|
+
const patchText = `*** Begin Patch
|
|
240
|
+
*** Update File: sample.txt
|
|
241
|
+
@@
|
|
242
|
+
-const title = "Hola";
|
|
243
|
+
+const title = "Hola mundo";
|
|
244
|
+
*** End Patch`;
|
|
245
|
+
|
|
246
|
+
const rewritten = parsePatch(
|
|
247
|
+
await rewritePatchText(root, patchText, DEFAULT_OPTIONS),
|
|
248
|
+
).hunks[0];
|
|
249
|
+
|
|
250
|
+
expect(rewritten.type).toBe('update');
|
|
251
|
+
expect(
|
|
252
|
+
rewritten.type === 'update' ? rewritten.chunks[0]?.old_lines : undefined,
|
|
253
|
+
).toEqual(['const title = “Hola”;']);
|
|
254
|
+
expect(
|
|
255
|
+
rewritten.type === 'update' ? rewritten.chunks[0]?.new_lines : undefined,
|
|
256
|
+
).toEqual(['const title = "Hola mundo";']);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('rewritePatchText canonicalizes a trim-end stale patch', async () => {
|
|
260
|
+
const root = await createTempDir();
|
|
261
|
+
await writeFixture(root, 'sample.txt', 'alpha \n');
|
|
262
|
+
const patchText = `*** Begin Patch
|
|
263
|
+
*** Update File: sample.txt
|
|
264
|
+
@@
|
|
265
|
+
-alpha
|
|
266
|
+
+omega
|
|
267
|
+
*** End Patch`;
|
|
268
|
+
|
|
269
|
+
const rewritten = parsePatch(
|
|
270
|
+
await rewritePatchText(root, patchText, DEFAULT_OPTIONS),
|
|
271
|
+
).hunks[0];
|
|
272
|
+
|
|
273
|
+
expect(rewritten.type).toBe('update');
|
|
274
|
+
expect(
|
|
275
|
+
rewritten.type === 'update' ? rewritten.chunks[0]?.old_lines : undefined,
|
|
276
|
+
).toEqual(['alpha ']);
|
|
277
|
+
expect(
|
|
278
|
+
rewritten.type === 'update' ? rewritten.chunks[0]?.new_lines : undefined,
|
|
279
|
+
).toEqual(['omega']);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('rewritePatchText no longer rescues a trim-only stale patch', async () => {
|
|
283
|
+
const root = await createTempDir();
|
|
284
|
+
await writeFixture(root, 'sample.txt', ' alpha \n');
|
|
285
|
+
const patchText = `*** Begin Patch
|
|
286
|
+
*** Update File: sample.txt
|
|
287
|
+
@@
|
|
288
|
+
-alpha
|
|
289
|
+
+omega
|
|
290
|
+
*** End Patch`;
|
|
291
|
+
|
|
292
|
+
await expect(
|
|
293
|
+
rewritePatchText(root, patchText, DEFAULT_OPTIONS),
|
|
294
|
+
).rejects.toThrow(
|
|
295
|
+
'apply_patch verification failed: Failed to find expected lines',
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('rewritePatchText no longer canonicalizes a dangerous indented case', async () => {
|
|
300
|
+
const root = await createTempDir();
|
|
301
|
+
await writeFixture(
|
|
302
|
+
root,
|
|
303
|
+
'sample.yml',
|
|
304
|
+
'root:\n child:\n enabled: false\nnext: true\n',
|
|
305
|
+
);
|
|
306
|
+
const patchText = `*** Begin Patch
|
|
307
|
+
*** Update File: sample.yml
|
|
308
|
+
@@
|
|
309
|
+
-enabled: false
|
|
310
|
+
+enabled: true
|
|
311
|
+
*** End Patch`;
|
|
312
|
+
|
|
313
|
+
await expect(
|
|
314
|
+
rewritePatchText(root, patchText, DEFAULT_OPTIONS),
|
|
315
|
+
).rejects.toThrow(
|
|
316
|
+
'apply_patch verification failed: Failed to find expected lines',
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('rewritePatchText rejects malformed @@ instead of silently sanitizing it', async () => {
|
|
321
|
+
const root = await createTempDir();
|
|
322
|
+
await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
|
|
323
|
+
|
|
324
|
+
await expect(
|
|
325
|
+
rewritePatchText(
|
|
326
|
+
root,
|
|
327
|
+
`*** Begin Patch
|
|
328
|
+
*** Update File: sample.txt
|
|
329
|
+
@@
|
|
330
|
+
alpha
|
|
331
|
+
garbage
|
|
332
|
+
-beta
|
|
333
|
+
+BETA
|
|
334
|
+
*** End Patch`,
|
|
335
|
+
DEFAULT_OPTIONS,
|
|
336
|
+
),
|
|
337
|
+
).rejects.toThrow(
|
|
338
|
+
'apply_patch validation failed: Invalid patch format: unexpected line in patch chunk: garbage',
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('preparePatchChanges rejects a malformed Add File', async () => {
|
|
343
|
+
const root = await createTempDir();
|
|
344
|
+
|
|
345
|
+
await expect(
|
|
346
|
+
preparePatchChanges(
|
|
347
|
+
root,
|
|
348
|
+
`*** Begin Patch
|
|
349
|
+
*** Add File: added.txt
|
|
350
|
+
+fresh
|
|
351
|
+
garbage
|
|
352
|
+
*** End Patch`,
|
|
353
|
+
DEFAULT_OPTIONS,
|
|
354
|
+
),
|
|
355
|
+
).rejects.toThrow(
|
|
356
|
+
'apply_patch validation failed: Invalid patch format: unexpected line in Add File body: garbage',
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('preparePatchChanges normalizes an Update File with an absolute path inside root', async () => {
|
|
361
|
+
const root = await createTempDir();
|
|
362
|
+
const absolutePath = path.join(root, 'sample.txt');
|
|
363
|
+
await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
|
|
364
|
+
|
|
365
|
+
const rewritten = await rewritePatch(
|
|
366
|
+
root,
|
|
367
|
+
`*** Begin Patch
|
|
368
|
+
*** Update File: ${absolutePath}
|
|
369
|
+
@@
|
|
370
|
+
-alpha
|
|
371
|
+
+omega
|
|
372
|
+
*** End Patch`,
|
|
373
|
+
DEFAULT_OPTIONS,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
expect(rewritten.changed).toBeTrue();
|
|
377
|
+
const [rewrittenHunk] = parsePatch(rewritten.patchText).hunks;
|
|
378
|
+
expect(rewrittenHunk.type).toBe('update');
|
|
379
|
+
expect(rewrittenHunk.path).toBe('sample.txt');
|
|
380
|
+
|
|
381
|
+
await expect(
|
|
382
|
+
preparePatchChanges(
|
|
383
|
+
root,
|
|
384
|
+
`*** Begin Patch
|
|
385
|
+
*** Update File: ${absolutePath}
|
|
386
|
+
@@
|
|
387
|
+
-alpha
|
|
388
|
+
+omega
|
|
389
|
+
*** End Patch`,
|
|
390
|
+
DEFAULT_OPTIONS,
|
|
391
|
+
),
|
|
392
|
+
).resolves.toEqual([
|
|
393
|
+
{
|
|
394
|
+
type: 'update',
|
|
395
|
+
file: absolutePath,
|
|
396
|
+
move: undefined,
|
|
397
|
+
text: 'omega\nbeta\n',
|
|
398
|
+
},
|
|
399
|
+
]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('preparePatchChanges normalizes an Add File with an absolute path inside root', async () => {
|
|
403
|
+
const root = await createTempDir();
|
|
404
|
+
const absolutePath = path.join(root, 'added.txt');
|
|
405
|
+
|
|
406
|
+
const rewritten = await rewritePatch(
|
|
407
|
+
root,
|
|
408
|
+
`*** Begin Patch
|
|
409
|
+
*** Add File: ${absolutePath}
|
|
410
|
+
+fresh
|
|
411
|
+
*** End Patch`,
|
|
412
|
+
DEFAULT_OPTIONS,
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
expect(rewritten.changed).toBeTrue();
|
|
416
|
+
expect(parsePatch(rewritten.patchText).hunks[0]).toMatchObject({
|
|
417
|
+
type: 'add',
|
|
418
|
+
path: 'added.txt',
|
|
419
|
+
contents: 'fresh',
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
await expect(
|
|
423
|
+
preparePatchChanges(
|
|
424
|
+
root,
|
|
425
|
+
`*** Begin Patch
|
|
426
|
+
*** Add File: ${absolutePath}
|
|
427
|
+
+fresh
|
|
428
|
+
*** End Patch`,
|
|
429
|
+
DEFAULT_OPTIONS,
|
|
430
|
+
),
|
|
431
|
+
).resolves.toEqual([
|
|
432
|
+
{
|
|
433
|
+
type: 'add',
|
|
434
|
+
file: absolutePath,
|
|
435
|
+
text: 'fresh\n',
|
|
436
|
+
},
|
|
437
|
+
]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('preparePatchChanges normalizes a Move to with an absolute path inside root', async () => {
|
|
441
|
+
const root = await createTempDir();
|
|
442
|
+
const absoluteMovePath = path.join(root, 'nested/after.txt');
|
|
443
|
+
|
|
444
|
+
await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
|
|
445
|
+
|
|
446
|
+
const rewritten = await rewritePatch(
|
|
447
|
+
root,
|
|
448
|
+
`*** Begin Patch
|
|
449
|
+
*** Update File: before.txt
|
|
450
|
+
*** Move to: ${absoluteMovePath}
|
|
451
|
+
@@
|
|
452
|
+
alpha
|
|
453
|
+
-beta
|
|
454
|
+
+BETA
|
|
455
|
+
*** End Patch`,
|
|
456
|
+
DEFAULT_OPTIONS,
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
expect(rewritten.changed).toBeTrue();
|
|
460
|
+
expect(parsePatch(rewritten.patchText).hunks[0]).toMatchObject({
|
|
461
|
+
type: 'update',
|
|
462
|
+
path: 'before.txt',
|
|
463
|
+
move_path: 'nested/after.txt',
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
await expect(
|
|
467
|
+
preparePatchChanges(
|
|
468
|
+
root,
|
|
469
|
+
`*** Begin Patch
|
|
470
|
+
*** Update File: before.txt
|
|
471
|
+
*** Move to: ${absoluteMovePath}
|
|
472
|
+
@@
|
|
473
|
+
alpha
|
|
474
|
+
-beta
|
|
475
|
+
+BETA
|
|
476
|
+
*** End Patch`,
|
|
477
|
+
DEFAULT_OPTIONS,
|
|
478
|
+
),
|
|
479
|
+
).resolves.toEqual([
|
|
480
|
+
{
|
|
481
|
+
type: 'update',
|
|
482
|
+
file: path.join(root, 'before.txt'),
|
|
483
|
+
move: absoluteMovePath,
|
|
484
|
+
text: 'alpha\nBETA\n',
|
|
485
|
+
},
|
|
486
|
+
]);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('preparePatchChanges blocks an absolute path outside root/worktree', async () => {
|
|
490
|
+
const root = await createTempDir();
|
|
491
|
+
const outsidePath = path.join(path.dirname(root), 'outside.txt');
|
|
492
|
+
|
|
493
|
+
const error = await preparePatchChanges(
|
|
494
|
+
root,
|
|
495
|
+
`*** Begin Patch
|
|
496
|
+
*** Add File: ${outsidePath}
|
|
497
|
+
+fresh
|
|
498
|
+
*** End Patch`,
|
|
499
|
+
DEFAULT_OPTIONS,
|
|
500
|
+
).catch((caughtError) => caughtError);
|
|
501
|
+
|
|
502
|
+
expect(isApplyPatchBlockedError(error)).toBeTrue();
|
|
503
|
+
expect(error).toBeInstanceOf(Error);
|
|
504
|
+
expect((error as Error).message).toBe(
|
|
505
|
+
`apply_patch blocked: patch contains path outside workspace root: ${outsidePath}`,
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('preparePatchChanges allows an absolute path inside worktree even when it is outside root', async () => {
|
|
510
|
+
const worktree = await createTempDir();
|
|
511
|
+
const root = path.join(worktree, 'subdir');
|
|
512
|
+
await mkdir(root, { recursive: true });
|
|
513
|
+
const siblingPath = path.join(worktree, 'shared.txt');
|
|
514
|
+
|
|
515
|
+
const rewritten = await rewritePatch(
|
|
516
|
+
root,
|
|
517
|
+
`*** Begin Patch
|
|
518
|
+
*** Add File: ${siblingPath}
|
|
519
|
+
+fresh
|
|
520
|
+
*** End Patch`,
|
|
521
|
+
DEFAULT_OPTIONS,
|
|
522
|
+
worktree,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
expect(rewritten.changed).toBeTrue();
|
|
526
|
+
expect(parsePatch(rewritten.patchText).hunks[0]).toMatchObject({
|
|
527
|
+
type: 'add',
|
|
528
|
+
path: '../shared.txt',
|
|
529
|
+
contents: 'fresh',
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
await expect(
|
|
533
|
+
preparePatchChanges(
|
|
534
|
+
root,
|
|
535
|
+
`*** Begin Patch
|
|
536
|
+
*** Add File: ${siblingPath}
|
|
537
|
+
+fresh
|
|
538
|
+
*** End Patch`,
|
|
539
|
+
DEFAULT_OPTIONS,
|
|
540
|
+
worktree,
|
|
541
|
+
),
|
|
542
|
+
).resolves.toEqual([
|
|
543
|
+
{
|
|
544
|
+
type: 'add',
|
|
545
|
+
file: siblingPath,
|
|
546
|
+
text: 'fresh\n',
|
|
547
|
+
},
|
|
548
|
+
]);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('preparePatchChanges does not redirect an absolute root target to its basename', async () => {
|
|
552
|
+
const root = await createTempDir();
|
|
553
|
+
|
|
554
|
+
await expect(
|
|
555
|
+
preparePatchChanges(
|
|
556
|
+
root,
|
|
557
|
+
`*** Begin Patch
|
|
558
|
+
*** Add File: ${root}
|
|
559
|
+
+fresh
|
|
560
|
+
*** End Patch`,
|
|
561
|
+
DEFAULT_OPTIONS,
|
|
562
|
+
),
|
|
563
|
+
).rejects.toThrow(
|
|
564
|
+
`apply_patch verification failed: Add File target already exists: ${root}`,
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test('preparePatchChanges rejects Add File on an existing path', async () => {
|
|
569
|
+
const root = await createTempDir();
|
|
570
|
+
await writeFixture(root, 'added.txt', 'legacy\n');
|
|
571
|
+
|
|
572
|
+
await expect(
|
|
573
|
+
preparePatchChanges(
|
|
574
|
+
root,
|
|
575
|
+
`*** Begin Patch
|
|
576
|
+
*** Add File: added.txt
|
|
577
|
+
+fresh
|
|
578
|
+
*** End Patch`,
|
|
579
|
+
DEFAULT_OPTIONS,
|
|
580
|
+
),
|
|
581
|
+
).rejects.toThrow(
|
|
582
|
+
`apply_patch verification failed: Add File target already exists: ${path.join(root, 'added.txt')}`,
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('preparePatchChanges rejects Move to on a different existing destination', async () => {
|
|
587
|
+
const root = await createTempDir();
|
|
588
|
+
await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
|
|
589
|
+
await writeFixture(root, 'nested/after.txt', 'legacy\n');
|
|
590
|
+
|
|
591
|
+
await expect(
|
|
592
|
+
preparePatchChanges(
|
|
593
|
+
root,
|
|
594
|
+
`*** Begin Patch
|
|
595
|
+
*** Update File: before.txt
|
|
596
|
+
*** Move to: nested/after.txt
|
|
597
|
+
@@
|
|
598
|
+
alpha
|
|
599
|
+
-beta
|
|
600
|
+
+BETA
|
|
601
|
+
*** End Patch`,
|
|
602
|
+
DEFAULT_OPTIONS,
|
|
603
|
+
),
|
|
604
|
+
).rejects.toThrow(
|
|
605
|
+
`apply_patch verification failed: Move destination already exists: ${path.join(root, 'nested/after.txt')}`,
|
|
606
|
+
);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test('rewritePatchText rejects a missing Delete File like preparePatchChanges', async () => {
|
|
610
|
+
const root = await createTempDir();
|
|
611
|
+
const patchText = `*** Begin Patch
|
|
612
|
+
*** Delete File: missing.txt
|
|
613
|
+
*** End Patch`;
|
|
614
|
+
const expectedMessage = `apply_patch verification failed: Failed to read file to delete: ${path.join(root, 'missing.txt')}`;
|
|
615
|
+
|
|
616
|
+
await expect(
|
|
617
|
+
rewritePatchText(root, patchText, DEFAULT_OPTIONS),
|
|
618
|
+
).rejects.toThrow(expectedMessage);
|
|
619
|
+
await expect(
|
|
620
|
+
preparePatchChanges(root, patchText, DEFAULT_OPTIONS),
|
|
621
|
+
).rejects.toThrow(expectedMessage);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('rewritePatchText rejects duplicate Delete File on the same path', async () => {
|
|
625
|
+
const root = await createTempDir();
|
|
626
|
+
await writeFixture(root, 'obsolete.txt', 'legacy\n');
|
|
627
|
+
|
|
628
|
+
await expect(
|
|
629
|
+
rewritePatchText(
|
|
630
|
+
root,
|
|
631
|
+
`*** Begin Patch
|
|
632
|
+
*** Delete File: obsolete.txt
|
|
633
|
+
*** Delete File: obsolete.txt
|
|
634
|
+
*** End Patch`,
|
|
635
|
+
DEFAULT_OPTIONS,
|
|
636
|
+
),
|
|
637
|
+
).rejects.toThrow(
|
|
638
|
+
`apply_patch verification failed: Failed to read file to delete: ${path.join(root, 'obsolete.txt')}`,
|
|
639
|
+
);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test('rewritePatchText rejects Delete File on the source after a previous move', async () => {
|
|
643
|
+
const root = await createTempDir();
|
|
644
|
+
await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
|
|
645
|
+
|
|
646
|
+
await expect(
|
|
647
|
+
rewritePatchText(
|
|
648
|
+
root,
|
|
649
|
+
`*** Begin Patch
|
|
650
|
+
*** Update File: before.txt
|
|
651
|
+
*** Move to: nested/after.txt
|
|
652
|
+
@@
|
|
653
|
+
alpha
|
|
654
|
+
-beta
|
|
655
|
+
+BETA
|
|
656
|
+
*** Delete File: before.txt
|
|
657
|
+
*** End Patch`,
|
|
658
|
+
DEFAULT_OPTIONS,
|
|
659
|
+
),
|
|
660
|
+
).rejects.toThrow(
|
|
661
|
+
`apply_patch verification failed: Failed to read file to delete: ${path.join(root, 'before.txt')}`,
|
|
662
|
+
);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('rewritePatchText keeps a valid Delete File and apply still works', async () => {
|
|
666
|
+
const root = await createTempDir();
|
|
667
|
+
await writeFixture(root, 'obsolete.txt', 'legacy\n');
|
|
668
|
+
const patchText = `*** Begin Patch
|
|
669
|
+
*** Delete File: obsolete.txt
|
|
670
|
+
*** End Patch`;
|
|
671
|
+
|
|
672
|
+
expect(await rewritePatchText(root, patchText, DEFAULT_OPTIONS)).toBe(
|
|
673
|
+
patchText,
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
await applyPatch(root, patchText);
|
|
677
|
+
await expect(readText(root, 'obsolete.txt')).rejects.toThrow();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test('applyPreparedChanges rejects direct add on an existing path', async () => {
|
|
681
|
+
const root = await createTempDir();
|
|
682
|
+
const target = path.join(root, 'added.txt');
|
|
683
|
+
await writeFixture(root, 'added.txt', 'legacy\n');
|
|
684
|
+
|
|
685
|
+
await expect(
|
|
686
|
+
applyPreparedChanges([
|
|
687
|
+
{
|
|
688
|
+
type: 'add',
|
|
689
|
+
file: target,
|
|
690
|
+
text: 'fresh\n',
|
|
691
|
+
},
|
|
692
|
+
]),
|
|
693
|
+
).rejects.toThrow(
|
|
694
|
+
`apply_patch verification failed: Prepared add target already exists: ${target}`,
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
expect(await readText(root, 'added.txt')).toBe('legacy\n');
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test('applyPreparedChanges rejects direct move on an existing destination', async () => {
|
|
701
|
+
const root = await createTempDir();
|
|
702
|
+
const source = path.join(root, 'before.txt');
|
|
703
|
+
const target = path.join(root, 'nested/after.txt');
|
|
704
|
+
await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
|
|
705
|
+
await writeFixture(root, 'nested/after.txt', 'legacy\n');
|
|
706
|
+
|
|
707
|
+
await expect(
|
|
708
|
+
applyPreparedChanges([
|
|
709
|
+
{
|
|
710
|
+
type: 'update',
|
|
711
|
+
file: source,
|
|
712
|
+
move: target,
|
|
713
|
+
text: 'alpha\nBETA\n',
|
|
714
|
+
},
|
|
715
|
+
]),
|
|
716
|
+
).rejects.toThrow(
|
|
717
|
+
`apply_patch verification failed: Prepared move destination already exists: ${target}`,
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
expect(await readText(root, 'before.txt')).toBe('alpha\nbeta\n');
|
|
721
|
+
expect(await readText(root, 'nested/after.txt')).toBe('legacy\n');
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test('applyPreparedChanges rejects legacy arrays with relative paths', async () => {
|
|
725
|
+
const error = await applyPreparedChanges([
|
|
726
|
+
{
|
|
727
|
+
type: 'add',
|
|
728
|
+
file: 'relative.txt' as unknown as string,
|
|
729
|
+
text: 'fresh\n',
|
|
730
|
+
},
|
|
731
|
+
]).catch((caughtError) => caughtError);
|
|
732
|
+
|
|
733
|
+
expect(isApplyPatchValidationError(error)).toBeTrue();
|
|
734
|
+
expect(error).toBeInstanceOf(Error);
|
|
735
|
+
expect((error as Error).message).toBe(
|
|
736
|
+
'apply_patch validation failed: Prepared changes require absolute normalized file paths at index 0: relative.txt',
|
|
737
|
+
);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test('rewritePatchText and preparePatchChanges share the validation/verification taxonomy', async () => {
|
|
741
|
+
const root = await createTempDir();
|
|
742
|
+
await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
|
|
743
|
+
|
|
744
|
+
const verificationError = await rewritePatchText(
|
|
745
|
+
root,
|
|
746
|
+
`*** Begin Patch
|
|
747
|
+
*** Update File: sample.txt
|
|
748
|
+
@@
|
|
749
|
+
-missing
|
|
750
|
+
+omega
|
|
751
|
+
*** End Patch`,
|
|
752
|
+
DEFAULT_OPTIONS,
|
|
753
|
+
).catch((error) => error);
|
|
754
|
+
|
|
755
|
+
const validationError = await preparePatchChanges(
|
|
756
|
+
root,
|
|
757
|
+
`*** Begin Patch
|
|
758
|
+
*** Add File: added.txt
|
|
759
|
+
+fresh
|
|
760
|
+
garbage
|
|
761
|
+
*** End Patch`,
|
|
762
|
+
DEFAULT_OPTIONS,
|
|
763
|
+
).catch((error) => error);
|
|
764
|
+
|
|
765
|
+
expect(isApplyPatchVerificationError(verificationError)).toBeTrue();
|
|
766
|
+
expect(isApplyPatchValidationError(validationError)).toBeTrue();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test('rewritePatchText canonicalizes EOF insertion with a tolerant anchor', async () => {
|
|
770
|
+
const root = await createTempDir();
|
|
771
|
+
await writeFixture(root, 'sample.txt', 'top\n“anchor”\n');
|
|
772
|
+
|
|
773
|
+
const rewritten = parsePatch(
|
|
774
|
+
await rewritePatchText(
|
|
775
|
+
root,
|
|
776
|
+
`*** Begin Patch
|
|
777
|
+
*** Update File: sample.txt
|
|
778
|
+
@@ "anchor"
|
|
779
|
+
+middle
|
|
780
|
+
*** End Patch`,
|
|
781
|
+
DEFAULT_OPTIONS,
|
|
782
|
+
),
|
|
783
|
+
).hunks[0];
|
|
784
|
+
|
|
785
|
+
expect(rewritten.type).toBe('update');
|
|
786
|
+
expect(
|
|
787
|
+
rewritten.type === 'update'
|
|
788
|
+
? rewritten.chunks[0]?.change_context
|
|
789
|
+
: undefined,
|
|
790
|
+
).toBe('“anchor”');
|
|
791
|
+
expect(
|
|
792
|
+
rewritten.type === 'update' ? rewritten.chunks[0]?.new_lines : undefined,
|
|
793
|
+
).toEqual(['middle']);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test('rewritePatch groups two exact Update File hunks on the same path', async () => {
|
|
797
|
+
const root = await createTempDir();
|
|
798
|
+
await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\ndelta\n');
|
|
799
|
+
|
|
800
|
+
const result = await rewritePatch(
|
|
801
|
+
root,
|
|
802
|
+
`*** Begin Patch
|
|
803
|
+
*** Update File: sample.txt
|
|
804
|
+
@@
|
|
805
|
+
alpha
|
|
806
|
+
-beta
|
|
807
|
+
+BETA
|
|
808
|
+
gamma
|
|
809
|
+
*** Update File: sample.txt
|
|
810
|
+
@@
|
|
811
|
+
gamma
|
|
812
|
+
-delta
|
|
813
|
+
+DELTA
|
|
814
|
+
*** End Patch`,
|
|
815
|
+
DEFAULT_OPTIONS,
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
const rewritten = parsePatch(result.patchText);
|
|
819
|
+
expect(result.changed).toBeTrue();
|
|
820
|
+
expect(rewritten.hunks).toHaveLength(1);
|
|
821
|
+
expect(rewritten.hunks[0]).toEqual({
|
|
822
|
+
type: 'update',
|
|
823
|
+
path: 'sample.txt',
|
|
824
|
+
move_path: undefined,
|
|
825
|
+
chunks: [
|
|
826
|
+
{
|
|
827
|
+
old_lines: ['beta'],
|
|
828
|
+
new_lines: ['BETA'],
|
|
829
|
+
change_context: 'alpha',
|
|
830
|
+
is_end_of_file: undefined,
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
old_lines: ['delta'],
|
|
834
|
+
new_lines: ['DELTA'],
|
|
835
|
+
change_context: 'gamma',
|
|
836
|
+
is_end_of_file: undefined,
|
|
837
|
+
},
|
|
838
|
+
],
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test('rewritePatch groups a second update that depends on the first', async () => {
|
|
843
|
+
const root = await createTempDir();
|
|
844
|
+
await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
|
|
845
|
+
|
|
846
|
+
const rewrittenText = await rewritePatchText(
|
|
847
|
+
root,
|
|
848
|
+
`*** Begin Patch
|
|
849
|
+
*** Update File: sample.txt
|
|
850
|
+
@@
|
|
851
|
+
alpha
|
|
852
|
+
-beta
|
|
853
|
+
+BETA
|
|
854
|
+
gamma
|
|
855
|
+
*** Update File: sample.txt
|
|
856
|
+
@@
|
|
857
|
+
alpha
|
|
858
|
+
-BETA
|
|
859
|
+
+BETA!
|
|
860
|
+
gamma
|
|
861
|
+
*** End Patch`,
|
|
862
|
+
DEFAULT_OPTIONS,
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
expect(parsePatch(rewrittenText).hunks).toEqual([
|
|
866
|
+
{
|
|
867
|
+
type: 'update',
|
|
868
|
+
path: 'sample.txt',
|
|
869
|
+
move_path: undefined,
|
|
870
|
+
chunks: [
|
|
871
|
+
{
|
|
872
|
+
old_lines: ['beta'],
|
|
873
|
+
new_lines: ['BETA!'],
|
|
874
|
+
change_context: 'alpha',
|
|
875
|
+
is_end_of_file: undefined,
|
|
876
|
+
},
|
|
877
|
+
],
|
|
878
|
+
},
|
|
879
|
+
]);
|
|
880
|
+
|
|
881
|
+
const changes = await preparePatchChanges(
|
|
882
|
+
root,
|
|
883
|
+
rewrittenText,
|
|
884
|
+
DEFAULT_OPTIONS,
|
|
885
|
+
);
|
|
886
|
+
await applyPreparedChanges(changes);
|
|
887
|
+
expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA!\ngamma\n');
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test('rewritePatch collapses Add File + exact Update File into a self-contained add', async () => {
|
|
891
|
+
const root = await createTempDir();
|
|
892
|
+
|
|
893
|
+
const result = await rewritePatch(
|
|
894
|
+
root,
|
|
895
|
+
`*** Begin Patch
|
|
896
|
+
*** Add File: added.txt
|
|
897
|
+
+alpha
|
|
898
|
+
+beta
|
|
899
|
+
*** Update File: added.txt
|
|
900
|
+
@@
|
|
901
|
+
alpha
|
|
902
|
+
-beta
|
|
903
|
+
+BETA
|
|
904
|
+
*** End Patch`,
|
|
905
|
+
DEFAULT_OPTIONS,
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
expect(result.changed).toBeTrue();
|
|
909
|
+
expect(parsePatch(result.patchText).hunks).toEqual([
|
|
910
|
+
{
|
|
911
|
+
type: 'add',
|
|
912
|
+
path: 'added.txt',
|
|
913
|
+
contents: 'alpha\nBETA',
|
|
914
|
+
},
|
|
915
|
+
]);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test('rewritePatch collapses Add File + Update File + Move to into a self-contained final add', async () => {
|
|
919
|
+
const root = await createTempDir();
|
|
920
|
+
|
|
921
|
+
const result = await rewritePatch(
|
|
922
|
+
root,
|
|
923
|
+
`*** Begin Patch
|
|
924
|
+
*** Add File: before.txt
|
|
925
|
+
+alpha
|
|
926
|
+
+beta
|
|
927
|
+
*** Update File: before.txt
|
|
928
|
+
*** Move to: nested/after.txt
|
|
929
|
+
@@
|
|
930
|
+
alpha
|
|
931
|
+
-beta
|
|
932
|
+
+BETA
|
|
933
|
+
*** End Patch`,
|
|
934
|
+
DEFAULT_OPTIONS,
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
expect(result.changed).toBeTrue();
|
|
938
|
+
expect(parsePatch(result.patchText).hunks).toEqual([
|
|
939
|
+
{
|
|
940
|
+
type: 'add',
|
|
941
|
+
path: 'nested/after.txt',
|
|
942
|
+
contents: 'alpha\nBETA',
|
|
943
|
+
},
|
|
944
|
+
]);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test('rewritePatch collapses an exact move followed by an update on the destination', async () => {
|
|
948
|
+
const root = await createTempDir();
|
|
949
|
+
await writeFixture(root, 'before.txt', 'alpha\nbeta\ngamma\n');
|
|
950
|
+
|
|
951
|
+
const result = await rewritePatch(
|
|
952
|
+
root,
|
|
953
|
+
`*** Begin Patch
|
|
954
|
+
*** Update File: before.txt
|
|
955
|
+
*** Move to: nested/after.txt
|
|
956
|
+
@@
|
|
957
|
+
alpha
|
|
958
|
+
-beta
|
|
959
|
+
+BETA
|
|
960
|
+
gamma
|
|
961
|
+
*** Update File: nested/after.txt
|
|
962
|
+
@@
|
|
963
|
+
alpha
|
|
964
|
+
BETA
|
|
965
|
+
-gamma
|
|
966
|
+
+GAMMA
|
|
967
|
+
*** End Patch`,
|
|
968
|
+
DEFAULT_OPTIONS,
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
expect(result.changed).toBeTrue();
|
|
972
|
+
expect(parsePatch(result.patchText).hunks).toEqual([
|
|
973
|
+
{
|
|
974
|
+
type: 'update',
|
|
975
|
+
path: 'before.txt',
|
|
976
|
+
move_path: 'nested/after.txt',
|
|
977
|
+
chunks: [
|
|
978
|
+
{
|
|
979
|
+
old_lines: ['beta', 'gamma'],
|
|
980
|
+
new_lines: ['BETA', 'GAMMA'],
|
|
981
|
+
change_context: 'alpha',
|
|
982
|
+
is_end_of_file: true,
|
|
983
|
+
},
|
|
984
|
+
],
|
|
985
|
+
},
|
|
986
|
+
]);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test('rewritePatch minimizes whole-file collapse when the fallback remains verifiable', async () => {
|
|
990
|
+
const root = await createTempDir();
|
|
991
|
+
await writeFixture(root, 'before.txt', 'alpha\nbeta\ngamma\n');
|
|
992
|
+
|
|
993
|
+
const result = await rewritePatch(
|
|
994
|
+
root,
|
|
995
|
+
`*** Begin Patch
|
|
996
|
+
*** Update File: before.txt
|
|
997
|
+
*** Move to: nested/after.txt
|
|
998
|
+
@@
|
|
999
|
+
alpha
|
|
1000
|
+
beta
|
|
1001
|
+
gamma
|
|
1002
|
+
*** Update File: nested/after.txt
|
|
1003
|
+
@@
|
|
1004
|
+
alpha
|
|
1005
|
+
-beta
|
|
1006
|
+
+BETA
|
|
1007
|
+
gamma
|
|
1008
|
+
*** End Patch`,
|
|
1009
|
+
DEFAULT_OPTIONS,
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
expect(result.changed).toBeTrue();
|
|
1013
|
+
expect(parsePatch(result.patchText).hunks).toEqual([
|
|
1014
|
+
{
|
|
1015
|
+
type: 'update',
|
|
1016
|
+
path: 'before.txt',
|
|
1017
|
+
move_path: 'nested/after.txt',
|
|
1018
|
+
chunks: [
|
|
1019
|
+
{
|
|
1020
|
+
old_lines: ['beta'],
|
|
1021
|
+
new_lines: ['BETA'],
|
|
1022
|
+
change_context: 'alpha',
|
|
1023
|
+
is_end_of_file: undefined,
|
|
1024
|
+
},
|
|
1025
|
+
],
|
|
1026
|
+
},
|
|
1027
|
+
]);
|
|
1028
|
+
|
|
1029
|
+
await applyPatch(root, result.patchText);
|
|
1030
|
+
expect(await readText(root, 'nested/after.txt')).toBe(
|
|
1031
|
+
'alpha\nBETA\ngamma\n',
|
|
1032
|
+
);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
test('rewritePatch keeps the correct change order when grouping same-file updates', async () => {
|
|
1036
|
+
const root = await createTempDir();
|
|
1037
|
+
await writeFixture(root, 'sample.txt', 'one\ntwo\nthree\nfour\nfive\n');
|
|
1038
|
+
|
|
1039
|
+
const rewrittenText = await rewritePatchText(
|
|
1040
|
+
root,
|
|
1041
|
+
`*** Begin Patch
|
|
1042
|
+
*** Update File: sample.txt
|
|
1043
|
+
@@
|
|
1044
|
+
one
|
|
1045
|
+
-two
|
|
1046
|
+
+TWO
|
|
1047
|
+
three
|
|
1048
|
+
*** Update File: sample.txt
|
|
1049
|
+
@@
|
|
1050
|
+
three
|
|
1051
|
+
-four
|
|
1052
|
+
+FOUR
|
|
1053
|
+
five
|
|
1054
|
+
*** End Patch`,
|
|
1055
|
+
DEFAULT_OPTIONS,
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
expect(parsePatch(rewrittenText).hunks[0]).toEqual({
|
|
1059
|
+
type: 'update',
|
|
1060
|
+
path: 'sample.txt',
|
|
1061
|
+
move_path: undefined,
|
|
1062
|
+
chunks: [
|
|
1063
|
+
{
|
|
1064
|
+
old_lines: ['two'],
|
|
1065
|
+
new_lines: ['TWO'],
|
|
1066
|
+
change_context: 'one',
|
|
1067
|
+
is_end_of_file: undefined,
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
old_lines: ['four'],
|
|
1071
|
+
new_lines: ['FOUR'],
|
|
1072
|
+
change_context: 'three',
|
|
1073
|
+
is_end_of_file: undefined,
|
|
1074
|
+
},
|
|
1075
|
+
],
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
await applyPatch(root, rewrittenText);
|
|
1079
|
+
expect(await readText(root, 'sample.txt')).toBe(
|
|
1080
|
+
'one\nTWO\nthree\nFOUR\nfive\n',
|
|
1081
|
+
);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test('preparePatchChanges fails when rescue is ambiguous', async () => {
|
|
1085
|
+
const root = await createTempDir();
|
|
1086
|
+
await writeFixture(
|
|
1087
|
+
root,
|
|
1088
|
+
'sample.txt',
|
|
1089
|
+
'left\nstale-one\nright\nseparator\nleft\nstale-two\nright\n',
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
await expect(
|
|
1093
|
+
preparePatchChanges(
|
|
1094
|
+
root,
|
|
1095
|
+
`*** Begin Patch
|
|
1096
|
+
*** Update File: sample.txt
|
|
1097
|
+
@@
|
|
1098
|
+
left
|
|
1099
|
+
-old
|
|
1100
|
+
+new
|
|
1101
|
+
right
|
|
1102
|
+
*** End Patch`,
|
|
1103
|
+
DEFAULT_OPTIONS,
|
|
1104
|
+
),
|
|
1105
|
+
).rejects.toThrow('apply_patch verification failed:');
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
test('applyPreparedChanges reverts previous changes when a later apply fails', async () => {
|
|
1109
|
+
const root = await createTempDir();
|
|
1110
|
+
await writeFixture(root, 'first.txt', 'one\n');
|
|
1111
|
+
await writeFixture(root, 'blocker', 'not-a-dir\n');
|
|
1112
|
+
await chmod(path.join(root, 'first.txt'), 0o755);
|
|
1113
|
+
|
|
1114
|
+
await expect(
|
|
1115
|
+
applyPreparedChanges([
|
|
1116
|
+
{
|
|
1117
|
+
type: 'update',
|
|
1118
|
+
file: path.join(root, 'first.txt'),
|
|
1119
|
+
text: 'ONE\n',
|
|
1120
|
+
},
|
|
1121
|
+
{
|
|
1122
|
+
type: 'add',
|
|
1123
|
+
file: path.join(root, 'blocker', 'second.txt'),
|
|
1124
|
+
text: 'two\n',
|
|
1125
|
+
},
|
|
1126
|
+
]),
|
|
1127
|
+
).rejects.toThrow(
|
|
1128
|
+
'apply_patch internal error: Failed to apply prepared changes',
|
|
1129
|
+
);
|
|
1130
|
+
|
|
1131
|
+
expect(await readText(root, 'first.txt')).toBe('one\n');
|
|
1132
|
+
await expectMode(path.join(root, 'first.txt'), 0o755);
|
|
1133
|
+
expect(await readText(root, 'blocker')).toBe('not-a-dir\n');
|
|
1134
|
+
await expect(readText(root, 'blocker/second.txt')).rejects.toThrow();
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
test('applyPreparedChanges supports update with move_path and preserves the source mode', async () => {
|
|
1138
|
+
const root = await createTempDir();
|
|
1139
|
+
await writeFixture(root, 'before.txt', 'alpha\nbeta\ngamma\n');
|
|
1140
|
+
await chmod(path.join(root, 'before.txt'), 0o755);
|
|
1141
|
+
|
|
1142
|
+
const changes = await preparePatchChanges(
|
|
1143
|
+
root,
|
|
1144
|
+
`*** Begin Patch
|
|
1145
|
+
*** Update File: before.txt
|
|
1146
|
+
*** Move to: nested/after.txt
|
|
1147
|
+
@@
|
|
1148
|
+
alpha
|
|
1149
|
+
-beta
|
|
1150
|
+
+BETA
|
|
1151
|
+
gamma
|
|
1152
|
+
*** End Patch`,
|
|
1153
|
+
DEFAULT_OPTIONS,
|
|
1154
|
+
);
|
|
1155
|
+
await applyPreparedChanges(changes);
|
|
1156
|
+
|
|
1157
|
+
expect(await readText(root, 'nested/after.txt')).toBe(
|
|
1158
|
+
'alpha\nBETA\ngamma\n',
|
|
1159
|
+
);
|
|
1160
|
+
await expectMode(path.join(root, 'nested/after.txt'), 0o755);
|
|
1161
|
+
await expect(readText(root, 'before.txt')).rejects.toThrow();
|
|
1162
|
+
expect(changes[0]).toMatchObject({
|
|
1163
|
+
type: 'update',
|
|
1164
|
+
file: path.join(root, 'before.txt'),
|
|
1165
|
+
move: path.join(root, 'nested/after.txt'),
|
|
1166
|
+
});
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
test('applyPreparedChanges rejects direct update on a missing source', async () => {
|
|
1170
|
+
const root = await createTempDir();
|
|
1171
|
+
const target = path.join(root, 'missing.txt');
|
|
1172
|
+
|
|
1173
|
+
await expect(
|
|
1174
|
+
applyPreparedChanges([
|
|
1175
|
+
{
|
|
1176
|
+
type: 'update',
|
|
1177
|
+
file: target,
|
|
1178
|
+
text: 'fresh\n',
|
|
1179
|
+
},
|
|
1180
|
+
]),
|
|
1181
|
+
).rejects.toThrow(
|
|
1182
|
+
`apply_patch verification failed: Prepared update source does not exist: ${target}`,
|
|
1183
|
+
);
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
test('applyPreparedChanges rejects direct delete on a missing source', async () => {
|
|
1187
|
+
const root = await createTempDir();
|
|
1188
|
+
const target = path.join(root, 'missing.txt');
|
|
1189
|
+
|
|
1190
|
+
await expect(
|
|
1191
|
+
applyPreparedChanges([
|
|
1192
|
+
{
|
|
1193
|
+
type: 'delete',
|
|
1194
|
+
file: target,
|
|
1195
|
+
},
|
|
1196
|
+
]),
|
|
1197
|
+
).rejects.toThrow(
|
|
1198
|
+
`apply_patch verification failed: Prepared delete source does not exist: ${target}`,
|
|
1199
|
+
);
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
test('applyPreparedChanges rejects direct move with a missing source', async () => {
|
|
1203
|
+
const root = await createTempDir();
|
|
1204
|
+
const source = path.join(root, 'missing.txt');
|
|
1205
|
+
const target = path.join(root, 'nested/after.txt');
|
|
1206
|
+
|
|
1207
|
+
await expect(
|
|
1208
|
+
applyPreparedChanges([
|
|
1209
|
+
{
|
|
1210
|
+
type: 'update',
|
|
1211
|
+
file: source,
|
|
1212
|
+
move: target,
|
|
1213
|
+
text: 'fresh\n',
|
|
1214
|
+
},
|
|
1215
|
+
]),
|
|
1216
|
+
).rejects.toThrow(
|
|
1217
|
+
`apply_patch verification failed: Prepared move source does not exist: ${source}`,
|
|
1218
|
+
);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
test('applyPreparedChanges rejects an invalid transition after a previous delete', async () => {
|
|
1222
|
+
const root = await createTempDir();
|
|
1223
|
+
const target = path.join(root, 'sample.txt');
|
|
1224
|
+
await writeFixture(root, 'sample.txt', 'alpha\n');
|
|
1225
|
+
|
|
1226
|
+
await expect(
|
|
1227
|
+
applyPreparedChanges([
|
|
1228
|
+
{
|
|
1229
|
+
type: 'delete',
|
|
1230
|
+
file: target,
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
type: 'update',
|
|
1234
|
+
file: target,
|
|
1235
|
+
text: 'omega\n',
|
|
1236
|
+
},
|
|
1237
|
+
]),
|
|
1238
|
+
).rejects.toThrow(
|
|
1239
|
+
`apply_patch verification failed: Prepared update source does not exist: ${target}`,
|
|
1240
|
+
);
|
|
1241
|
+
|
|
1242
|
+
expect(await readText(root, 'sample.txt')).toBe('alpha\n');
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test('applyPatch supports move + update when the block is stale', async () => {
|
|
1246
|
+
const root = await createTempDir();
|
|
1247
|
+
await writeFixture(
|
|
1248
|
+
root,
|
|
1249
|
+
'before.txt',
|
|
1250
|
+
'top\nprefix\nstale-value\nsuffix\nbottom\n',
|
|
1251
|
+
);
|
|
1252
|
+
|
|
1253
|
+
await applyPatch(
|
|
1254
|
+
root,
|
|
1255
|
+
`*** Begin Patch
|
|
1256
|
+
*** Update File: before.txt
|
|
1257
|
+
*** Move to: nested/after.txt
|
|
1258
|
+
@@ top
|
|
1259
|
+
prefix
|
|
1260
|
+
-old-value
|
|
1261
|
+
+new-value
|
|
1262
|
+
suffix
|
|
1263
|
+
*** End Patch`,
|
|
1264
|
+
);
|
|
1265
|
+
|
|
1266
|
+
expect(await readText(root, 'nested/after.txt')).toBe(
|
|
1267
|
+
'top\nprefix\nnew-value\nsuffix\nbottom\n',
|
|
1268
|
+
);
|
|
1269
|
+
await expect(readText(root, 'before.txt')).rejects.toThrow();
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
test('preparePatchChanges and applyPreparedChanges preserve CRLF with stale rescue + exact chunk', async () => {
|
|
1273
|
+
const root = await createTempDir();
|
|
1274
|
+
await writeFixture(
|
|
1275
|
+
root,
|
|
1276
|
+
'sample.txt',
|
|
1277
|
+
'top\r\nprefix\r\nstale-value\r\nsuffix\r\nkeep\r\ntail-old\r\n',
|
|
1278
|
+
);
|
|
1279
|
+
|
|
1280
|
+
const changes = await preparePatchChanges(
|
|
1281
|
+
root,
|
|
1282
|
+
`*** Begin Patch
|
|
1283
|
+
*** Update File: sample.txt
|
|
1284
|
+
@@ top
|
|
1285
|
+
prefix
|
|
1286
|
+
-old-value
|
|
1287
|
+
+new-value
|
|
1288
|
+
suffix
|
|
1289
|
+
@@ suffix
|
|
1290
|
+
keep
|
|
1291
|
+
-tail-old
|
|
1292
|
+
+tail-new
|
|
1293
|
+
*** End Patch`,
|
|
1294
|
+
DEFAULT_OPTIONS,
|
|
1295
|
+
);
|
|
1296
|
+
await applyPreparedChanges(changes);
|
|
1297
|
+
|
|
1298
|
+
expect(await readText(root, 'sample.txt')).toBe(
|
|
1299
|
+
'top\r\nprefix\r\nnew-value\r\nsuffix\r\nkeep\r\ntail-new\r\n',
|
|
1300
|
+
);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test('preparePatchChanges and applyPreparedChanges support pure insertion at EOF', async () => {
|
|
1304
|
+
const root = await createTempDir();
|
|
1305
|
+
await writeFixture(root, 'sample.txt', 'top\nanchor\n');
|
|
1306
|
+
|
|
1307
|
+
const changes = await preparePatchChanges(
|
|
1308
|
+
root,
|
|
1309
|
+
`*** Begin Patch
|
|
1310
|
+
*** Update File: sample.txt
|
|
1311
|
+
@@ anchor
|
|
1312
|
+
+middle
|
|
1313
|
+
*** End Patch`,
|
|
1314
|
+
DEFAULT_OPTIONS,
|
|
1315
|
+
);
|
|
1316
|
+
await applyPreparedChanges(changes);
|
|
1317
|
+
|
|
1318
|
+
expect(await readText(root, 'sample.txt')).toBe('top\nanchor\nmiddle\n');
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
test('preparePatchChanges and applyPreparedChanges accumulate two Update File hunks on the same path', async () => {
|
|
1322
|
+
const root = await createTempDir();
|
|
1323
|
+
await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
|
|
1324
|
+
|
|
1325
|
+
const changes = await preparePatchChanges(
|
|
1326
|
+
root,
|
|
1327
|
+
`*** Begin Patch
|
|
1328
|
+
*** Update File: sample.txt
|
|
1329
|
+
@@
|
|
1330
|
+
alpha
|
|
1331
|
+
-beta
|
|
1332
|
+
+BETA
|
|
1333
|
+
gamma
|
|
1334
|
+
*** Update File: sample.txt
|
|
1335
|
+
@@
|
|
1336
|
+
alpha
|
|
1337
|
+
BETA
|
|
1338
|
+
-gamma
|
|
1339
|
+
+GAMMA
|
|
1340
|
+
*** End Patch`,
|
|
1341
|
+
DEFAULT_OPTIONS,
|
|
1342
|
+
);
|
|
1343
|
+
|
|
1344
|
+
expect(changes).toHaveLength(2);
|
|
1345
|
+
expect(changes[0]).toMatchObject({
|
|
1346
|
+
type: 'update',
|
|
1347
|
+
file: path.join(root, 'sample.txt'),
|
|
1348
|
+
text: 'alpha\nBETA\ngamma\n',
|
|
1349
|
+
});
|
|
1350
|
+
expect(changes[1]).toMatchObject({
|
|
1351
|
+
type: 'update',
|
|
1352
|
+
file: path.join(root, 'sample.txt'),
|
|
1353
|
+
text: 'alpha\nBETA\nGAMMA\n',
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
await applyPreparedChanges(changes);
|
|
1357
|
+
|
|
1358
|
+
expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA\nGAMMA\n');
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
test('preparePatchChanges and applyPreparedChanges preserve a file without a final newline', async () => {
|
|
1362
|
+
const root = await createTempDir();
|
|
1363
|
+
await writeFixture(root, 'sample.txt', 'alpha\nbeta');
|
|
1364
|
+
|
|
1365
|
+
const changes = await preparePatchChanges(
|
|
1366
|
+
root,
|
|
1367
|
+
`*** Begin Patch
|
|
1368
|
+
*** Update File: sample.txt
|
|
1369
|
+
@@
|
|
1370
|
+
alpha
|
|
1371
|
+
-beta
|
|
1372
|
+
+omega
|
|
1373
|
+
*** End Patch`,
|
|
1374
|
+
DEFAULT_OPTIONS,
|
|
1375
|
+
);
|
|
1376
|
+
|
|
1377
|
+
await applyPreparedChanges(changes);
|
|
1378
|
+
|
|
1379
|
+
expect(await readText(root, 'sample.txt')).toBe('alpha\nomega');
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
test('applyPatch applies add + update in the same patch', async () => {
|
|
1383
|
+
const root = await createTempDir();
|
|
1384
|
+
await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
|
|
1385
|
+
|
|
1386
|
+
await applyPatch(
|
|
1387
|
+
root,
|
|
1388
|
+
`*** Begin Patch
|
|
1389
|
+
*** Add File: added.txt
|
|
1390
|
+
+fresh
|
|
1391
|
+
*** Update File: sample.txt
|
|
1392
|
+
@@
|
|
1393
|
+
alpha
|
|
1394
|
+
-beta
|
|
1395
|
+
+BETA
|
|
1396
|
+
*** End Patch`,
|
|
1397
|
+
);
|
|
1398
|
+
|
|
1399
|
+
expect(await readText(root, 'added.txt')).toBe('fresh\n');
|
|
1400
|
+
expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA\n');
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
test('applyPatch applies update + delete in the same patch', async () => {
|
|
1404
|
+
const root = await createTempDir();
|
|
1405
|
+
await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
|
|
1406
|
+
await writeFixture(root, 'obsolete.txt', 'legacy\n');
|
|
1407
|
+
|
|
1408
|
+
await applyPatch(
|
|
1409
|
+
root,
|
|
1410
|
+
`*** Begin Patch
|
|
1411
|
+
*** Update File: sample.txt
|
|
1412
|
+
@@
|
|
1413
|
+
alpha
|
|
1414
|
+
-beta
|
|
1415
|
+
+BETA
|
|
1416
|
+
*** Delete File: obsolete.txt
|
|
1417
|
+
*** End Patch`,
|
|
1418
|
+
);
|
|
1419
|
+
|
|
1420
|
+
expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA\n');
|
|
1421
|
+
await expect(readText(root, 'obsolete.txt')).rejects.toThrow();
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
test('applyPatch applies move + add in the same patch', async () => {
|
|
1425
|
+
const root = await createTempDir();
|
|
1426
|
+
await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
|
|
1427
|
+
|
|
1428
|
+
await applyPatch(
|
|
1429
|
+
root,
|
|
1430
|
+
`*** Begin Patch
|
|
1431
|
+
*** Update File: before.txt
|
|
1432
|
+
*** Move to: nested/after.txt
|
|
1433
|
+
@@
|
|
1434
|
+
alpha
|
|
1435
|
+
-beta
|
|
1436
|
+
+BETA
|
|
1437
|
+
*** Add File: before.txt
|
|
1438
|
+
+replacement
|
|
1439
|
+
*** End Patch`,
|
|
1440
|
+
);
|
|
1441
|
+
|
|
1442
|
+
expect(await readText(root, 'nested/after.txt')).toBe('alpha\nBETA\n');
|
|
1443
|
+
expect(await readText(root, 'before.txt')).toBe('replacement\n');
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
const testSymlink = platform() === 'win32' ? test.skip : test;
|
|
1447
|
+
testSymlink('rewritePatchText blocks a patch when the path escapes through a symlink with a missing ancestor', async () => {
|
|
1448
|
+
const root = await createTempDir();
|
|
1449
|
+
const outside = await createTempDir();
|
|
1450
|
+
await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
|
|
1451
|
+
await symlink(outside, path.join(root, 'linked-outside'));
|
|
1452
|
+
|
|
1453
|
+
const patchText = `*** Begin Patch
|
|
1454
|
+
*** Update File: before.txt
|
|
1455
|
+
*** Move to: linked-outside/missing/child.txt
|
|
1456
|
+
@@
|
|
1457
|
+
alpha
|
|
1458
|
+
-beta
|
|
1459
|
+
+BETA
|
|
1460
|
+
*** End Patch`;
|
|
1461
|
+
|
|
1462
|
+
await expect(
|
|
1463
|
+
rewritePatchText(root, patchText, DEFAULT_OPTIONS, root),
|
|
1464
|
+
).rejects.toThrow(
|
|
1465
|
+
'apply_patch blocked: patch contains path outside workspace root:',
|
|
1466
|
+
);
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
test('rewritePatchText blocks the whole patch if any add/delete escapes root even when an update is rewritable', async () => {
|
|
1470
|
+
const root = await createTempDir();
|
|
1471
|
+
const outsideDir = await createTempDir();
|
|
1472
|
+
await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
|
|
1473
|
+
await writeFixture(outsideDir, 'outside.txt', 'legacy\n');
|
|
1474
|
+
|
|
1475
|
+
const patchText = `*** Begin Patch
|
|
1476
|
+
*** Add File: ../outside-added.txt
|
|
1477
|
+
+fresh
|
|
1478
|
+
*** Update File: sample.txt
|
|
1479
|
+
@@
|
|
1480
|
+
prefix
|
|
1481
|
+
-old-value
|
|
1482
|
+
+new-value
|
|
1483
|
+
suffix
|
|
1484
|
+
*** Delete File: ../${path.basename(outsideDir)}/outside.txt
|
|
1485
|
+
*** End Patch`;
|
|
1486
|
+
|
|
1487
|
+
await expect(
|
|
1488
|
+
rewritePatchText(root, patchText, DEFAULT_OPTIONS, root),
|
|
1489
|
+
).rejects.toThrow(
|
|
1490
|
+
'apply_patch blocked: patch contains path outside workspace root:',
|
|
1491
|
+
);
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
test('preparePatchChanges keeps an escaping relative path as blocked', async () => {
|
|
1495
|
+
const root = await createTempDir();
|
|
1496
|
+
|
|
1497
|
+
const error = await preparePatchChanges(
|
|
1498
|
+
root,
|
|
1499
|
+
`*** Begin Patch
|
|
1500
|
+
*** Add File: ../outside-added.txt
|
|
1501
|
+
+fresh
|
|
1502
|
+
*** End Patch`,
|
|
1503
|
+
DEFAULT_OPTIONS,
|
|
1504
|
+
root,
|
|
1505
|
+
).catch((caughtError) => caughtError);
|
|
1506
|
+
|
|
1507
|
+
expect(isApplyPatchBlockedError(error)).toBeTrue();
|
|
1508
|
+
expect(isApplyPatchValidationError(error)).toBeFalse();
|
|
1509
|
+
expect(error).toBeInstanceOf(Error);
|
|
1510
|
+
expect((error as Error).message).toContain(
|
|
1511
|
+
'apply_patch blocked: patch contains path outside workspace root:',
|
|
1512
|
+
);
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
testSymlink('preparePatchChanges rejects a path that escapes through a symlink with a missing ancestor', async () => {
|
|
1516
|
+
const root = await createTempDir();
|
|
1517
|
+
const outside = await createTempDir();
|
|
1518
|
+
await mkdir(path.join(outside, 'real-target'), { recursive: true });
|
|
1519
|
+
await symlink(outside, path.join(root, 'linked-outside'));
|
|
1520
|
+
|
|
1521
|
+
await expect(
|
|
1522
|
+
preparePatchChanges(
|
|
1523
|
+
root,
|
|
1524
|
+
`*** Begin Patch
|
|
1525
|
+
*** Add File: linked-outside/missing/child.txt
|
|
1526
|
+
+fresh
|
|
1527
|
+
*** End Patch`,
|
|
1528
|
+
DEFAULT_OPTIONS,
|
|
1529
|
+
root,
|
|
1530
|
+
),
|
|
1531
|
+
).rejects.toThrow(
|
|
1532
|
+
'apply_patch blocked: patch contains path outside workspace root:',
|
|
1533
|
+
);
|
|
1534
|
+
});
|
|
1535
|
+
});
|