@ottocode/sdk 0.1.265 → 0.1.266

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.
Files changed (28) hide show
  1. package/package.json +2 -2
  2. package/src/core/src/providers/resolver.ts +29 -70
  3. package/src/core/src/tools/bin-manager/cache.ts +13 -0
  4. package/src/core/src/tools/bin-manager/filesystem.ts +32 -0
  5. package/src/core/src/tools/bin-manager/paths.ts +36 -0
  6. package/src/core/src/tools/bin-manager/vendor.ts +80 -0
  7. package/src/core/src/tools/bin-manager.ts +14 -140
  8. package/src/core/src/tools/builtin/patch/apply-hunk.ts +308 -0
  9. package/src/core/src/tools/builtin/patch/apply-report.ts +99 -0
  10. package/src/core/src/tools/builtin/patch/apply.ts +6 -663
  11. package/src/core/src/tools/builtin/patch/hunk-header.ts +17 -0
  12. package/src/core/src/tools/builtin/patch/indentation.ts +160 -0
  13. package/src/core/src/tools/builtin/patch/matching.ts +58 -0
  14. package/src/core/src/tools/builtin/patch/parse-enveloped.ts +10 -72
  15. package/src/core/src/tools/builtin/patch/parse-unified.ts +15 -105
  16. package/src/core/src/tools/builtin/patch/replace-builder.ts +64 -0
  17. package/src/core/src/tools/builtin/patch/unified-state.ts +86 -0
  18. package/src/core/src/tools/builtin/websearch-strategies.ts +197 -0
  19. package/src/core/src/tools/builtin/websearch.ts +9 -187
  20. package/src/core/src/tools/loader.ts +6 -49
  21. package/src/core/src/tools/plugin-discovery.ts +86 -0
  22. package/src/core/src/utils/logger/format.ts +50 -0
  23. package/src/core/src/utils/logger/sinks.ts +61 -0
  24. package/src/core/src/utils/logger.ts +2 -119
  25. package/src/index.ts +2 -0
  26. package/src/providers/src/index.ts +4 -0
  27. package/src/providers/src/model-resolution.ts +21 -0
  28. package/src/providers/src/zai-client.ts +5 -2
@@ -0,0 +1,160 @@
1
+ import {
2
+ applyIndentDelta,
3
+ computeIndentDelta,
4
+ detectIndentStyle,
5
+ expandWhitespace,
6
+ getLeadingWhitespace,
7
+ inferTabSizeFromPairs,
8
+ } from './normalize.ts';
9
+ import type { PatchHunk } from './types.ts';
10
+
11
+ export function adjustReplacementIndentation(
12
+ hunk: PatchHunk,
13
+ matchedFileLines: string[],
14
+ allFileLines?: string[],
15
+ ): string[] {
16
+ const result: string[] = [];
17
+ let expectedIdx = 0;
18
+ let lastDelta = 0;
19
+ let lastFileIndentExpanded = 0;
20
+ let lastPatchIndentExpanded = 0;
21
+ let hasDelta = false;
22
+ let hasStyleMismatch = false;
23
+ let fileIndentChar: 'tab' | 'space' = 'space';
24
+ const deltas: number[] = [];
25
+ let hasAddStyleMismatch = false;
26
+ let fileIndentDetected = false;
27
+
28
+ for (const fl of matchedFileLines) {
29
+ const ws = getLeadingWhitespace(fl);
30
+ if (ws.length > 0) {
31
+ fileIndentChar = detectIndentStyle(ws);
32
+ fileIndentDetected = true;
33
+ break;
34
+ }
35
+ }
36
+
37
+ if (!fileIndentDetected && allFileLines) {
38
+ for (const fl of allFileLines) {
39
+ const ws = getLeadingWhitespace(fl);
40
+ if (ws.length > 0) {
41
+ fileIndentChar = detectIndentStyle(ws);
42
+ fileIndentDetected = true;
43
+ break;
44
+ }
45
+ }
46
+ }
47
+
48
+ const patchContextLines = hunk.lines
49
+ .filter((l) => l.kind === 'context' || l.kind === 'remove')
50
+ .map((l) => l.content);
51
+ const tabSize = inferTabSizeFromPairs(patchContextLines, matchedFileLines);
52
+
53
+ let tempIdx = 0;
54
+ for (const line of hunk.lines) {
55
+ if (line.kind === 'context' || line.kind === 'remove') {
56
+ const fileLine = matchedFileLines[tempIdx];
57
+ if (fileLine !== undefined) {
58
+ const d = computeIndentDelta(line.content, fileLine, tabSize);
59
+ if (d !== 0) deltas.push(d);
60
+ }
61
+ tempIdx++;
62
+ }
63
+ }
64
+ const sortedDeltas = [...deltas].sort((a, b) => a - b);
65
+ const medianDelta =
66
+ sortedDeltas.length > 0
67
+ ? sortedDeltas[Math.floor(sortedDeltas.length / 2)]
68
+ : 0;
69
+
70
+ for (const line of hunk.lines) {
71
+ if (line.kind === 'add' && line.content.trim() !== '') {
72
+ const ws = getLeadingWhitespace(line.content);
73
+ if (ws.length > 0 && detectIndentStyle(ws) !== fileIndentChar) {
74
+ hasAddStyleMismatch = true;
75
+ break;
76
+ }
77
+ }
78
+ }
79
+
80
+ for (const line of hunk.lines) {
81
+ if (line.kind === 'context') {
82
+ const fileLine = matchedFileLines[expectedIdx];
83
+ if (fileLine !== undefined) {
84
+ lastDelta = computeIndentDelta(line.content, fileLine, tabSize);
85
+ lastFileIndentExpanded = expandWhitespace(
86
+ getLeadingWhitespace(fileLine),
87
+ tabSize,
88
+ );
89
+ lastPatchIndentExpanded = expandWhitespace(
90
+ getLeadingWhitespace(line.content),
91
+ tabSize,
92
+ );
93
+ if (lastDelta !== 0) hasDelta = true;
94
+ if (
95
+ detectIndentStyle(getLeadingWhitespace(fileLine)) !==
96
+ detectIndentStyle(getLeadingWhitespace(line.content)) &&
97
+ getLeadingWhitespace(fileLine).length > 0
98
+ ) {
99
+ hasStyleMismatch = true;
100
+ }
101
+ result.push(fileLine);
102
+ } else {
103
+ result.push(line.content);
104
+ }
105
+ expectedIdx++;
106
+ } else if (line.kind === 'remove') {
107
+ const fileLine = matchedFileLines[expectedIdx];
108
+ if (fileLine !== undefined) {
109
+ lastDelta = computeIndentDelta(line.content, fileLine, tabSize);
110
+ lastFileIndentExpanded = expandWhitespace(
111
+ getLeadingWhitespace(fileLine),
112
+ tabSize,
113
+ );
114
+ lastPatchIndentExpanded = expandWhitespace(
115
+ getLeadingWhitespace(line.content),
116
+ tabSize,
117
+ );
118
+ if (lastDelta !== 0) hasDelta = true;
119
+ if (
120
+ detectIndentStyle(getLeadingWhitespace(fileLine)) !==
121
+ detectIndentStyle(getLeadingWhitespace(line.content)) &&
122
+ getLeadingWhitespace(fileLine).length > 0
123
+ ) {
124
+ hasStyleMismatch = true;
125
+ }
126
+ }
127
+ expectedIdx++;
128
+ } else if (line.kind === 'add') {
129
+ const addIndent = expandWhitespace(
130
+ getLeadingWhitespace(line.content),
131
+ tabSize,
132
+ );
133
+ const addWs = getLeadingWhitespace(line.content);
134
+ const addStyle =
135
+ addWs.length > 0 ? detectIndentStyle(addWs) : fileIndentChar;
136
+ const styleMismatch =
137
+ addStyle !== fileIndentChar && line.content.trim() !== '';
138
+ if (styleMismatch) {
139
+ const relativeOffset = addIndent - lastPatchIndentExpanded;
140
+ const targetIndent = lastFileIndentExpanded + relativeOffset;
141
+ const actualDelta = targetIndent - addIndent;
142
+ result.push(
143
+ applyIndentDelta(line.content, actualDelta, fileIndentChar, tabSize),
144
+ );
145
+ } else if (Math.abs(medianDelta) > tabSize) {
146
+ result.push(
147
+ applyIndentDelta(line.content, medianDelta, fileIndentChar, tabSize),
148
+ );
149
+ } else {
150
+ result.push(line.content);
151
+ }
152
+ }
153
+ }
154
+
155
+ if (!hasDelta && !hasStyleMismatch && !hasAddStyleMismatch) {
156
+ return hunk.lines.filter((l) => l.kind !== 'remove').map((l) => l.content);
157
+ }
158
+
159
+ return result;
160
+ }
@@ -0,0 +1,58 @@
1
+ import { NORMALIZATION_LEVELS, normalizeWhitespace } from './normalize.ts';
2
+
3
+ export function linesMatch(
4
+ line: string,
5
+ pattern: string,
6
+ useFuzzy: boolean,
7
+ ): boolean {
8
+ if (line === pattern) return true;
9
+ if (!useFuzzy) return false;
10
+ for (const level of NORMALIZATION_LEVELS.slice(1)) {
11
+ if (
12
+ normalizeWhitespace(line, level) === normalizeWhitespace(pattern, level)
13
+ ) {
14
+ return true;
15
+ }
16
+ }
17
+ return false;
18
+ }
19
+
20
+ export function findLineIndex(
21
+ lines: string[],
22
+ pattern: string,
23
+ start: number,
24
+ useFuzzy: boolean,
25
+ ): number {
26
+ for (let i = Math.max(0, start); i < lines.length; i++) {
27
+ if (linesMatch(lines[i], pattern, useFuzzy)) return i;
28
+ }
29
+ return -1;
30
+ }
31
+
32
+ export function findSubsequence(
33
+ lines: string[],
34
+ pattern: string[],
35
+ startIndex: number,
36
+ useFuzzy: boolean,
37
+ ): number {
38
+ if (pattern.length === 0) return -1;
39
+ const start = Math.max(0, startIndex);
40
+ for (let i = start; i <= lines.length - pattern.length; i++) {
41
+ let matches = true;
42
+ for (let j = 0; j < pattern.length; j++) {
43
+ if (linesMatch(lines[i + j], pattern[j], useFuzzy)) continue;
44
+ matches = false;
45
+ break;
46
+ }
47
+ if (matches) return i;
48
+ }
49
+ return -1;
50
+ }
51
+
52
+ export function lineExists(
53
+ lines: string[],
54
+ target: string,
55
+ useFuzzy: boolean,
56
+ ): boolean {
57
+ return findLineIndex(lines, target, 0, useFuzzy) !== -1;
58
+ }
@@ -8,6 +8,13 @@ import {
8
8
  PATCH_UPDATE_PREFIX,
9
9
  PATCH_WITH_MARKER,
10
10
  } from './constants.ts';
11
+ import { parseHunkHeader } from './hunk-header.ts';
12
+ import {
13
+ createReplaceBuilder,
14
+ flushReplaceBuilder,
15
+ flushReplacePair,
16
+ type ReplaceBuilder,
17
+ } from './replace-builder.ts';
11
18
  import type {
12
19
  PatchAddOperation,
13
20
  PatchDeleteOperation,
@@ -28,70 +35,6 @@ function parseDirectivePath(line: string, prefix: string): string {
28
35
  return filePath;
29
36
  }
30
37
 
31
- function parseHunkHeader(raw: string) {
32
- const match = raw.match(
33
- /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/,
34
- );
35
- if (match) {
36
- const [, oldStart, oldCount, newStart, newCount, context] = match;
37
- return {
38
- oldStart: Number.parseInt(oldStart, 10),
39
- oldLines: oldCount ? Number.parseInt(oldCount, 10) : undefined,
40
- newStart: Number.parseInt(newStart, 10),
41
- newLines: newCount ? Number.parseInt(newCount, 10) : undefined,
42
- context: context?.trim() || undefined,
43
- };
44
- }
45
- const context = raw.replace(/^@@/, '').trim();
46
- return context ? { context } : {};
47
- }
48
-
49
- interface ReplaceBuilder {
50
- kind: 'replace';
51
- filePath: string;
52
- hunks: PatchHunk[];
53
- phase: 'idle' | 'find' | 'with';
54
- findLines: string[];
55
- withLines: string[];
56
- }
57
-
58
- function flushReplacePair(builder: ReplaceBuilder) {
59
- if (builder.findLines.length === 0 && builder.withLines.length === 0) return;
60
- if (builder.findLines.length === 0) {
61
- throw new Error(
62
- `Replace in ${builder.filePath}: *** Find: block is empty.`,
63
- );
64
- }
65
- const lines: PatchHunkLine[] = [];
66
- for (const line of builder.findLines) {
67
- lines.push({ kind: 'remove', content: line });
68
- }
69
- for (const line of builder.withLines) {
70
- lines.push({ kind: 'add', content: line });
71
- }
72
- builder.hunks.push({ header: {}, lines });
73
- builder.findLines = [];
74
- builder.withLines = [];
75
- builder.phase = 'idle';
76
- }
77
-
78
- function flushReplaceBuilder(builder: ReplaceBuilder): PatchUpdateOperation {
79
- flushReplacePair(builder);
80
- if (builder.hunks.length === 0) {
81
- throw new Error(
82
- `Replace in ${builder.filePath} does not contain any *** Find:/*** With: pairs.`,
83
- );
84
- }
85
- return {
86
- kind: 'update',
87
- filePath: builder.filePath,
88
- hunks: builder.hunks.map((hunk) => ({
89
- header: { ...hunk.header },
90
- lines: hunk.lines.map((line) => ({ ...line })),
91
- })),
92
- };
93
- }
94
-
95
38
  export function parseEnvelopedPatch(patch: string): PatchOperation[] {
96
39
  const normalized = patch.replace(/\r\n/g, '\n');
97
40
  const lines = normalized.split('\n');
@@ -205,14 +148,9 @@ export function parseEnvelopedPatch(patch: string): PatchOperation[] {
205
148
 
206
149
  if (line.startsWith(PATCH_REPLACE_PREFIX)) {
207
150
  flushBuilder();
208
- builder = {
209
- kind: 'replace',
210
- filePath: parseDirectivePath(line, PATCH_REPLACE_PREFIX),
211
- hunks: [],
212
- phase: 'idle',
213
- findLines: [],
214
- withLines: [],
215
- };
151
+ builder = createReplaceBuilder(
152
+ parseDirectivePath(line, PATCH_REPLACE_PREFIX),
153
+ );
216
154
  continue;
217
155
  }
218
156
 
@@ -1,111 +1,21 @@
1
- import type {
2
- PatchAddOperation,
3
- PatchDeleteOperation,
4
- PatchHunk,
5
- PatchHunkLine,
6
- PatchOperation,
7
- PatchUpdateOperation,
8
- } from './types.ts';
9
-
10
- function stripPath(raw: string): string | null {
11
- let trimmed = raw.trim();
12
- if (!trimmed || trimmed === '/dev/null') return null;
13
- if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
14
- trimmed = trimmed.slice(1, -1).replace(/\\"/g, '"');
15
- }
16
- if (trimmed.startsWith('a/') || trimmed.startsWith('b/')) {
17
- trimmed = trimmed.slice(2);
18
- }
19
- if (trimmed.startsWith('./')) {
20
- trimmed = trimmed.slice(2);
21
- }
22
- return trimmed || null;
23
- }
24
-
25
- function parseHunkHeader(raw: string) {
26
- const match = raw.match(
27
- /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/,
28
- );
29
- if (match) {
30
- const [, oldStart, oldCount, newStart, newCount, context] = match;
31
- return {
32
- oldStart: Number.parseInt(oldStart, 10),
33
- oldLines: oldCount ? Number.parseInt(oldCount, 10) : undefined,
34
- newStart: Number.parseInt(newStart, 10),
35
- newLines: newCount ? Number.parseInt(newCount, 10) : undefined,
36
- context: context?.trim() || undefined,
37
- };
38
- }
39
- const context = raw.replace(/^@@/, '').trim();
40
- return context ? { context } : {};
41
- }
42
-
43
- function shouldIgnoreMetadata(line: string) {
44
- const trimmed = line.trim();
45
- if (trimmed === '') return true;
46
- if (trimmed === '\') return true;
47
- const prefixes = [
48
- 'diff --git',
49
- 'index ',
50
- 'similarity index',
51
- 'dissimilarity index',
52
- 'rename from',
53
- 'rename to',
54
- 'copy from',
55
- 'copy to',
56
- 'new file mode',
57
- 'deleted file mode',
58
- 'old mode',
59
- 'new mode',
60
- 'Binary files',
61
- ];
62
- return prefixes.some((prefix) => trimmed.startsWith(prefix));
63
- }
1
+ import type { PatchHunk, PatchHunkLine, PatchOperation } from './types.ts';
2
+ import { parseHunkHeader } from './hunk-header.ts';
3
+ import {
4
+ flushUnifiedBuilder,
5
+ shouldIgnoreUnifiedMetadata,
6
+ stripUnifiedPath,
7
+ type UnifiedBuilder,
8
+ } from './unified-state.ts';
64
9
 
65
10
  export function parseUnifiedPatch(patch: string): PatchOperation[] {
66
11
  const normalized = patch.replace(/\r\n/g, '\n');
67
12
  const lines = normalized.split('\n');
68
13
  const operations: PatchOperation[] = [];
69
14
 
70
- type Builder =
71
- | (PatchAddOperation & { kind: 'add' })
72
- | (PatchDeleteOperation & { kind: 'delete' })
73
- | (PatchUpdateOperation & {
74
- kind: 'update';
75
- currentHunk: PatchHunk | null;
76
- });
77
-
78
- let builder: Builder | null = null;
15
+ let builder: UnifiedBuilder | null = null;
79
16
 
80
17
  const flush = () => {
81
- if (!builder) return;
82
- if (builder.kind === 'update') {
83
- if (builder.currentHunk && builder.currentHunk.lines.length === 0) {
84
- builder.hunks.pop();
85
- }
86
- if (builder.hunks.length === 0) {
87
- throw new Error(
88
- `Update for ${builder.filePath} does not contain any diff hunks.`,
89
- );
90
- }
91
- operations.push({
92
- kind: 'update',
93
- filePath: builder.filePath,
94
- hunks: builder.hunks.map((hunk) => ({
95
- header: { ...hunk.header },
96
- lines: hunk.lines.map((line) => ({ ...line })),
97
- })),
98
- });
99
- } else if (builder.kind === 'add') {
100
- operations.push({
101
- kind: 'add',
102
- filePath: builder.filePath,
103
- lines: [...builder.lines],
104
- });
105
- } else {
106
- operations.push({ kind: 'delete', filePath: builder.filePath });
107
- }
108
- builder = null;
18
+ builder = flushUnifiedBuilder(builder, operations);
109
19
  };
110
20
 
111
21
  for (let i = 0; i < lines.length; i++) {
@@ -124,8 +34,8 @@ export function parseUnifiedPatch(patch: string): PatchOperation[] {
124
34
 
125
35
  flush();
126
36
 
127
- const oldPath = stripPath(oldPathRaw);
128
- const newPath = stripPath(newPathRaw);
37
+ const oldPath = stripUnifiedPath(oldPathRaw);
38
+ const newPath = stripUnifiedPath(newPathRaw);
129
39
 
130
40
  if (!oldPath && !newPath) {
131
41
  throw new Error(
@@ -171,13 +81,13 @@ export function parseUnifiedPatch(patch: string): PatchOperation[] {
171
81
  }
172
82
 
173
83
  if (!builder) {
174
- if (shouldIgnoreMetadata(line)) continue;
84
+ if (shouldIgnoreUnifiedMetadata(line)) continue;
175
85
  if (line.trim() === '') continue;
176
86
  throw new Error(`Unrecognized content in patch: "${line}"`);
177
87
  }
178
88
 
179
89
  if (builder.kind === 'add') {
180
- if (shouldIgnoreMetadata(line) || line.startsWith('@@')) continue;
90
+ if (shouldIgnoreUnifiedMetadata(line) || line.startsWith('@@')) continue;
181
91
  if (line.startsWith('+')) {
182
92
  builder.lines.push(line.slice(1));
183
93
  }
@@ -188,7 +98,7 @@ export function parseUnifiedPatch(patch: string): PatchOperation[] {
188
98
  continue;
189
99
  }
190
100
 
191
- if (shouldIgnoreMetadata(line)) continue;
101
+ if (shouldIgnoreUnifiedMetadata(line)) continue;
192
102
 
193
103
  if (line.startsWith('@@')) {
194
104
  const hunk: PatchHunk = { header: parseHunkHeader(line), lines: [] };
@@ -0,0 +1,64 @@
1
+ import type {
2
+ PatchHunk,
3
+ PatchHunkLine,
4
+ PatchUpdateOperation,
5
+ } from './types.ts';
6
+
7
+ export interface ReplaceBuilder {
8
+ kind: 'replace';
9
+ filePath: string;
10
+ hunks: PatchHunk[];
11
+ phase: 'idle' | 'find' | 'with';
12
+ findLines: string[];
13
+ withLines: string[];
14
+ }
15
+
16
+ export function createReplaceBuilder(filePath: string): ReplaceBuilder {
17
+ return {
18
+ kind: 'replace',
19
+ filePath,
20
+ hunks: [],
21
+ phase: 'idle',
22
+ findLines: [],
23
+ withLines: [],
24
+ };
25
+ }
26
+
27
+ export function flushReplacePair(builder: ReplaceBuilder) {
28
+ if (builder.findLines.length === 0 && builder.withLines.length === 0) return;
29
+ if (builder.findLines.length === 0) {
30
+ throw new Error(
31
+ `Replace in ${builder.filePath}: *** Find: block is empty.`,
32
+ );
33
+ }
34
+ const lines: PatchHunkLine[] = [];
35
+ for (const line of builder.findLines) {
36
+ lines.push({ kind: 'remove', content: line });
37
+ }
38
+ for (const line of builder.withLines) {
39
+ lines.push({ kind: 'add', content: line });
40
+ }
41
+ builder.hunks.push({ header: {}, lines });
42
+ builder.findLines = [];
43
+ builder.withLines = [];
44
+ builder.phase = 'idle';
45
+ }
46
+
47
+ export function flushReplaceBuilder(
48
+ builder: ReplaceBuilder,
49
+ ): PatchUpdateOperation {
50
+ flushReplacePair(builder);
51
+ if (builder.hunks.length === 0) {
52
+ throw new Error(
53
+ `Replace in ${builder.filePath} does not contain any *** Find:/*** With: pairs.`,
54
+ );
55
+ }
56
+ return {
57
+ kind: 'update',
58
+ filePath: builder.filePath,
59
+ hunks: builder.hunks.map((hunk) => ({
60
+ header: { ...hunk.header },
61
+ lines: hunk.lines.map((line) => ({ ...line })),
62
+ })),
63
+ };
64
+ }
@@ -0,0 +1,86 @@
1
+ import type {
2
+ PatchAddOperation,
3
+ PatchDeleteOperation,
4
+ PatchHunk,
5
+ PatchOperation,
6
+ PatchUpdateOperation,
7
+ } from './types.ts';
8
+
9
+ export type UnifiedBuilder =
10
+ | (PatchAddOperation & { kind: 'add' })
11
+ | (PatchDeleteOperation & { kind: 'delete' })
12
+ | (PatchUpdateOperation & {
13
+ kind: 'update';
14
+ currentHunk: PatchHunk | null;
15
+ });
16
+
17
+ export function stripUnifiedPath(raw: string): string | null {
18
+ let trimmed = raw.trim();
19
+ if (!trimmed || trimmed === '/dev/null') return null;
20
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
21
+ trimmed = trimmed.slice(1, -1).replace(/\\"/g, '"');
22
+ }
23
+ if (trimmed.startsWith('a/') || trimmed.startsWith('b/')) {
24
+ trimmed = trimmed.slice(2);
25
+ }
26
+ if (trimmed.startsWith('./')) {
27
+ trimmed = trimmed.slice(2);
28
+ }
29
+ return trimmed || null;
30
+ }
31
+
32
+ export function shouldIgnoreUnifiedMetadata(line: string) {
33
+ const trimmed = line.trim();
34
+ if (trimmed === '') return true;
35
+ if (trimmed === '\') return true;
36
+ const prefixes = [
37
+ 'diff --git',
38
+ 'index ',
39
+ 'similarity index',
40
+ 'dissimilarity index',
41
+ 'rename from',
42
+ 'rename to',
43
+ 'copy from',
44
+ 'copy to',
45
+ 'new file mode',
46
+ 'deleted file mode',
47
+ 'old mode',
48
+ 'new mode',
49
+ 'Binary files',
50
+ ];
51
+ return prefixes.some((prefix) => trimmed.startsWith(prefix));
52
+ }
53
+
54
+ export function flushUnifiedBuilder(
55
+ builder: UnifiedBuilder | null,
56
+ operations: PatchOperation[],
57
+ ): UnifiedBuilder | null {
58
+ if (!builder) return null;
59
+ if (builder.kind === 'update') {
60
+ if (builder.currentHunk && builder.currentHunk.lines.length === 0) {
61
+ builder.hunks.pop();
62
+ }
63
+ if (builder.hunks.length === 0) {
64
+ throw new Error(
65
+ `Update for ${builder.filePath} does not contain any diff hunks.`,
66
+ );
67
+ }
68
+ operations.push({
69
+ kind: 'update',
70
+ filePath: builder.filePath,
71
+ hunks: builder.hunks.map((hunk) => ({
72
+ header: { ...hunk.header },
73
+ lines: hunk.lines.map((line) => ({ ...line })),
74
+ })),
75
+ });
76
+ } else if (builder.kind === 'add') {
77
+ operations.push({
78
+ kind: 'add',
79
+ filePath: builder.filePath,
80
+ lines: [...builder.lines],
81
+ });
82
+ } else {
83
+ operations.push({ kind: 'delete', filePath: builder.filePath });
84
+ }
85
+ return null;
86
+ }