@synmux/claude-commit 1.0.3 → 1.1.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/CHANGELOG.md +238 -0
- package/README.md +92 -38
- package/bin/cco.js +16 -0
- package/bin/cco.ts +3 -6
- package/dist/bin/cco.js +2347 -0
- package/dist/index.js +1642 -0
- package/dist/types/index.d.ts +22 -0
- package/dist/types/src/agent.d.ts +93 -0
- package/dist/types/src/config.d.ts +36 -0
- package/dist/types/src/diff.d.ts +115 -0
- package/{src/errors.ts → dist/types/src/errors.d.ts} +3 -6
- package/dist/types/src/generate.d.ts +113 -0
- package/dist/types/src/git.d.ts +31 -0
- package/dist/types/src/models.d.ts +42 -0
- package/dist/types/src/ollama.d.ts +89 -0
- package/dist/types/src/paths.d.ts +9 -0
- package/dist/types/src/prompts.d.ts +59 -0
- package/dist/types/src/tokens.d.ts +58 -0
- package/dist/types/src/types.d.ts +234 -0
- package/{src/ui/colors.ts → dist/types/src/ui/colors.d.ts} +2 -5
- package/dist/types/src/ui/spinner.d.ts +23 -0
- package/package.json +45 -32
- package/index.ts +0 -69
- package/src/agent.ts +0 -280
- package/src/cli.ts +0 -442
- package/src/config.ts +0 -337
- package/src/diff.ts +0 -571
- package/src/generate.ts +0 -478
- package/src/git.ts +0 -145
- package/src/models.ts +0 -95
- package/src/ollama.ts +0 -502
- package/src/paths.ts +0 -139
- package/src/prompts.ts +0 -364
- package/src/tokens.ts +0 -147
- package/src/types.ts +0 -238
- package/src/ui/editor.ts +0 -89
- package/src/ui/interactive.ts +0 -313
- package/src/ui/spinner.ts +0 -79
- package/src/utils.ts +0 -5
package/src/diff.ts
DELETED
|
@@ -1,571 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Splitting a unified git diff into chunks that fit a character budget, and
|
|
3
|
-
* partitioning it by path priority.
|
|
4
|
-
*
|
|
5
|
-
* The packer is structure-aware: it prefers to break on file boundaries, then
|
|
6
|
-
* on hunk (`@@`) boundaries, and only falls back to raw line splitting for a
|
|
7
|
-
* single hunk that is itself larger than the budget. When a file section is
|
|
8
|
-
* split, its header (`diff --git ... / --- / +++`) is repeated at the top of
|
|
9
|
-
* every piece so each chunk remains a self-contained, interpretable diff.
|
|
10
|
-
*
|
|
11
|
-
* `applyIgnorePatterns` drops whole file sections outright (see `ignore` in
|
|
12
|
-
* the config) and `partitionDiff` sorts what remains into a primary and a
|
|
13
|
-
* low-priority diff (see `lowPriorityPaths`), both using the paths
|
|
14
|
-
* `sectionPaths` recovers from each section's header lines.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { PathMatcher } from "./paths";
|
|
18
|
-
import { estimateDiffTokens, isOpaqueLine } from "./tokens";
|
|
19
|
-
|
|
20
|
-
const FILE_HEADER = "diff --git ";
|
|
21
|
-
const HUNK_HEADER = "@@";
|
|
22
|
-
const DEV_NULL = "/dev/null";
|
|
23
|
-
const SOURCE_PREFIX = "a/";
|
|
24
|
-
const DESTINATION_PREFIX = "b/";
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Minimum per-piece character budget (after the repeated file header) below
|
|
28
|
-
* which we stop trying to subdivide a section. This guards against a
|
|
29
|
-
* misconfigured tiny `maxChars`, or a pathologically large file header,
|
|
30
|
-
* driving the line-splitter's budget to zero and shattering a hunk into
|
|
31
|
-
* one-character pieces - which would otherwise spawn a model request per
|
|
32
|
-
* character.
|
|
33
|
-
*/
|
|
34
|
-
const MIN_SPLIT_BUDGET = 64;
|
|
35
|
-
|
|
36
|
-
/** Split a full diff into per-file sections. */
|
|
37
|
-
function splitFileSections(diff: string): string[] {
|
|
38
|
-
const lines = diff.split("\n");
|
|
39
|
-
const sections: string[] = [];
|
|
40
|
-
let current: string[] = [];
|
|
41
|
-
for (const line of lines) {
|
|
42
|
-
if (line.startsWith(FILE_HEADER) && current.length > 0) {
|
|
43
|
-
sections.push(current.join("\n"));
|
|
44
|
-
current = [line];
|
|
45
|
-
} else {
|
|
46
|
-
current.push(line);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
if (current.length > 0) sections.push(current.join("\n"));
|
|
50
|
-
return sections;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Group the body of a file section (from the first `@@`) into hunks. */
|
|
54
|
-
function groupHunks(bodyLines: string[]): string[] {
|
|
55
|
-
const hunks: string[] = [];
|
|
56
|
-
let current: string[] = [];
|
|
57
|
-
for (const line of bodyLines) {
|
|
58
|
-
if (line.startsWith(HUNK_HEADER) && current.length > 0) {
|
|
59
|
-
hunks.push(current.join("\n"));
|
|
60
|
-
current = [line];
|
|
61
|
-
} else {
|
|
62
|
-
current.push(line);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
if (current.length > 0) hunks.push(current.join("\n"));
|
|
66
|
-
return hunks;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Break a string into pieces of at most `maxLen` characters, preferring line boundaries. */
|
|
70
|
-
function breakByLines(text: string, maxLen: number): string[] {
|
|
71
|
-
const limit = Math.max(1, maxLen);
|
|
72
|
-
const lines = text.split("\n");
|
|
73
|
-
const pieces: string[] = [];
|
|
74
|
-
let current = "";
|
|
75
|
-
for (const line of lines) {
|
|
76
|
-
const addition = current === "" ? line.length : line.length + 1;
|
|
77
|
-
if (current !== "" && current.length + addition > limit) {
|
|
78
|
-
pieces.push(current);
|
|
79
|
-
current = "";
|
|
80
|
-
}
|
|
81
|
-
if (line.length > limit) {
|
|
82
|
-
// A single line longer than the budget: hard-split it.
|
|
83
|
-
if (current !== "") {
|
|
84
|
-
pieces.push(current);
|
|
85
|
-
current = "";
|
|
86
|
-
}
|
|
87
|
-
for (let i = 0; i < line.length; i += limit) {
|
|
88
|
-
pieces.push(line.slice(i, i + limit));
|
|
89
|
-
}
|
|
90
|
-
} else {
|
|
91
|
-
current = current === "" ? line : current + "\n" + line;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
if (current !== "") pieces.push(current);
|
|
95
|
-
return pieces;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Break an oversized file section into units that each fit `maxChars`. */
|
|
99
|
-
function breakSection(section: string, maxChars: number): string[] {
|
|
100
|
-
if (section.length <= maxChars) return [section];
|
|
101
|
-
|
|
102
|
-
const lines = section.split("\n");
|
|
103
|
-
const firstHunk = lines.findIndex((l) => l.startsWith(HUNK_HEADER));
|
|
104
|
-
if (firstHunk === -1) {
|
|
105
|
-
// No hunks to split on (binary patch, pure rename, mode change): keep whole.
|
|
106
|
-
return [section];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const header = lines.slice(0, firstHunk).join("\n");
|
|
110
|
-
const headerLen = header.length + 1; // account for the joining newline
|
|
111
|
-
|
|
112
|
-
// If there isn't room for a meaningful piece after repeating the header,
|
|
113
|
-
// keep the section whole rather than exploding it into tiny fragments.
|
|
114
|
-
if (maxChars - headerLen < MIN_SPLIT_BUDGET) return [section];
|
|
115
|
-
|
|
116
|
-
const hunks = groupHunks(lines.slice(firstHunk));
|
|
117
|
-
const units: string[] = [];
|
|
118
|
-
|
|
119
|
-
for (const hunk of hunks) {
|
|
120
|
-
if (headerLen + hunk.length <= maxChars) {
|
|
121
|
-
units.push(header + "\n" + hunk);
|
|
122
|
-
} else {
|
|
123
|
-
for (const piece of breakByLines(hunk, maxChars - headerLen)) {
|
|
124
|
-
units.push(header + "\n" + piece);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return units;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Greedily pack pre-fitted units into as few chunks as possible. */
|
|
132
|
-
function packUnits(units: string[], maxChars: number): string[] {
|
|
133
|
-
const chunks: string[] = [];
|
|
134
|
-
let current = "";
|
|
135
|
-
for (const unit of units) {
|
|
136
|
-
if (current === "") {
|
|
137
|
-
current = unit;
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
if (current.length + 1 + unit.length <= maxChars) {
|
|
141
|
-
current = current + "\n" + unit;
|
|
142
|
-
} else {
|
|
143
|
-
chunks.push(current);
|
|
144
|
-
current = unit;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
if (current !== "") chunks.push(current);
|
|
148
|
-
return chunks;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Split a unified diff into chunks no larger than `maxChars` characters.
|
|
153
|
-
*
|
|
154
|
-
* Returns an empty array for an empty diff, and a single-element array when the
|
|
155
|
-
* whole diff already fits.
|
|
156
|
-
*/
|
|
157
|
-
export function splitDiff(diff: string, maxChars: number): string[] {
|
|
158
|
-
if (diff.trim() === "") return [];
|
|
159
|
-
if (diff.length <= maxChars) return [diff];
|
|
160
|
-
|
|
161
|
-
const units: string[] = [];
|
|
162
|
-
for (const section of splitFileSections(diff)) {
|
|
163
|
-
units.push(...breakSection(section, maxChars));
|
|
164
|
-
}
|
|
165
|
-
return packUnits(units, maxChars);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** Character budget for `text` such that its classified token estimate fits `maxTokens`. */
|
|
169
|
-
function charBudgetFor(
|
|
170
|
-
text: string,
|
|
171
|
-
maxTokens: number,
|
|
172
|
-
charsPerToken: number,
|
|
173
|
-
): number {
|
|
174
|
-
const density =
|
|
175
|
-
text.length / Math.max(1, estimateDiffTokens(text, charsPerToken));
|
|
176
|
-
return Math.max(1, Math.floor(maxTokens * density));
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Minimum consecutive opaque lines before a run is redacted. A lone long
|
|
181
|
-
* unbroken line (a URL, a hash pin, a long path) can carry real meaning; an
|
|
182
|
-
* armored blob never arrives alone.
|
|
183
|
-
*/
|
|
184
|
-
const MIN_REDACT_RUN = 3;
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Replace each run of opaque (armored/encoded) lines with a single marker
|
|
188
|
-
* line. The marker keeps the surrounding diff structure interpretable and
|
|
189
|
-
* tells the summary model what was elided, so it can still report that an
|
|
190
|
-
* encrypted file changed - without paying ~1 token per character to send
|
|
191
|
-
* ciphertext the model cannot read anyway.
|
|
192
|
-
*/
|
|
193
|
-
export function redactOpaqueRuns(diff: string): string {
|
|
194
|
-
const out: string[] = [];
|
|
195
|
-
let run: string[] = [];
|
|
196
|
-
const flush = () => {
|
|
197
|
-
if (run.length >= MIN_REDACT_RUN) {
|
|
198
|
-
out.push(`[cco: ${run.length} armored/encoded lines omitted]`);
|
|
199
|
-
} else {
|
|
200
|
-
out.push(...run);
|
|
201
|
-
}
|
|
202
|
-
run = [];
|
|
203
|
-
};
|
|
204
|
-
for (const line of diff.split("\n")) {
|
|
205
|
-
if (isOpaqueLine(line)) {
|
|
206
|
-
run.push(line);
|
|
207
|
-
} else {
|
|
208
|
-
flush();
|
|
209
|
-
out.push(line);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
flush();
|
|
213
|
-
return out.join("\n");
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* The staged diff split by path priority. `primary` drives the commit
|
|
218
|
-
* message; `lowPriority` holds the sections whose every path matched a
|
|
219
|
-
* `lowPriorityPaths` pattern. Either may be `""`. The counts make the
|
|
220
|
-
* matcher observable (`--verbose`): a pattern that matches nothing and one
|
|
221
|
-
* that matches everything (and is therefore promoted) produce the same
|
|
222
|
-
* message otherwise.
|
|
223
|
-
*/
|
|
224
|
-
export interface DiffPartition {
|
|
225
|
-
primary: string;
|
|
226
|
-
lowPriority: string;
|
|
227
|
-
/** File sections whose paths all matched. */
|
|
228
|
-
matchedFiles: number;
|
|
229
|
-
/** File sections with at least one recognisable path. */
|
|
230
|
-
totalFiles: number;
|
|
231
|
-
/** Every file matched, so the low-priority sections were promoted to primary. */
|
|
232
|
-
promoted: boolean;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const textEncoder = new TextEncoder();
|
|
236
|
-
const textDecoder = new TextDecoder();
|
|
237
|
-
|
|
238
|
-
/** Single-character C escapes git emits inside a quoted path, mapped to their byte. */
|
|
239
|
-
const SIMPLE_ESCAPES: Record<string, number> = {
|
|
240
|
-
a: 0x07,
|
|
241
|
-
b: 0x08,
|
|
242
|
-
t: 0x09,
|
|
243
|
-
n: 0x0a,
|
|
244
|
-
v: 0x0b,
|
|
245
|
-
f: 0x0c,
|
|
246
|
-
r: 0x0d,
|
|
247
|
-
"\\": 0x5c,
|
|
248
|
-
'"': 0x22,
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Undo git's C-style path quoting (`core.quotepath`): a path containing
|
|
253
|
-
* quotes, backslashes, control characters or - by default - any non-ASCII
|
|
254
|
-
* byte is emitted as `"..."` with `\"`, `\\`, `\t`-style escapes and
|
|
255
|
-
* `\NNN` octal escapes for raw bytes. Escapes are decoded to bytes and the
|
|
256
|
-
* result is read back as UTF-8, so `"\303\274"` becomes `ü`. Unquoted
|
|
257
|
-
* input is returned as-is.
|
|
258
|
-
*/
|
|
259
|
-
function unquoteGitPath(raw: string): string {
|
|
260
|
-
if (raw.length < 2 || !raw.startsWith('"') || !raw.endsWith('"')) {
|
|
261
|
-
return raw;
|
|
262
|
-
}
|
|
263
|
-
const inner = raw.slice(1, -1);
|
|
264
|
-
const bytes: number[] = [];
|
|
265
|
-
let index = 0;
|
|
266
|
-
while (index < inner.length) {
|
|
267
|
-
if (inner[index] !== "\\") {
|
|
268
|
-
// Copy one code point (possibly a surrogate pair) as UTF-8 bytes.
|
|
269
|
-
const literal = String.fromCodePoint(inner.codePointAt(index)!);
|
|
270
|
-
bytes.push(...textEncoder.encode(literal));
|
|
271
|
-
index += literal.length;
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
const octal = /^[0-7]{1,3}/.exec(inner.slice(index + 1, index + 4));
|
|
275
|
-
if (octal) {
|
|
276
|
-
bytes.push(parseInt(octal[0], 8) & 0xff);
|
|
277
|
-
index += 1 + octal[0].length;
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
const escaped = inner[index + 1];
|
|
281
|
-
if (escaped === undefined) break; // dangling backslash: drop it
|
|
282
|
-
const simple = SIMPLE_ESCAPES[escaped];
|
|
283
|
-
if (simple !== undefined) {
|
|
284
|
-
bytes.push(simple);
|
|
285
|
-
} else {
|
|
286
|
-
bytes.push(...textEncoder.encode(escaped)); // unknown escape: literal
|
|
287
|
-
}
|
|
288
|
-
index += 2;
|
|
289
|
-
}
|
|
290
|
-
return textDecoder.decode(Uint8Array.from(bytes));
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Drop a `a/` or `b/` prefix when present (diffs made with `--no-prefix` lack it). */
|
|
294
|
-
function stripDiffPrefix(path: string, prefix: string): string {
|
|
295
|
-
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* The path named by a `--- `/`+++ ` marker line, or `null` for `/dev/null`.
|
|
300
|
-
* Git appends a tab after an unquoted path that contains spaces, so that is
|
|
301
|
-
* removed before unquoting.
|
|
302
|
-
*/
|
|
303
|
-
function pathFromMarkerLine(rest: string, prefix: string): string | null {
|
|
304
|
-
const unquoted = unquoteGitPath(
|
|
305
|
-
rest.endsWith("\t") ? rest.slice(0, -1) : rest,
|
|
306
|
-
);
|
|
307
|
-
if (unquoted === DEV_NULL) return null;
|
|
308
|
-
return stripDiffPrefix(unquoted, prefix);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Read a quoted token starting at `start` (which must be a `"`), returning
|
|
313
|
-
* the token including its quotes and the index just past it, or `null` if
|
|
314
|
-
* the closing quote is missing.
|
|
315
|
-
*/
|
|
316
|
-
function readQuotedToken(
|
|
317
|
-
text: string,
|
|
318
|
-
start: number,
|
|
319
|
-
): { token: string; end: number } | null {
|
|
320
|
-
let index = start + 1;
|
|
321
|
-
while (index < text.length) {
|
|
322
|
-
if (text[index] === "\\") {
|
|
323
|
-
index += 2;
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
if (text[index] === '"') {
|
|
327
|
-
return { token: text.slice(start, index + 1), end: index + 1 };
|
|
328
|
-
}
|
|
329
|
-
index += 1;
|
|
330
|
-
}
|
|
331
|
-
return null;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* The source and destination paths of a `diff --git a/X b/Y` header line,
|
|
336
|
-
* for sections that carry no `---`/`+++`/`rename`/`copy` lines (binary
|
|
337
|
-
* patches, mode-only changes). Git separates the two paths with a single
|
|
338
|
-
* space and quotes only the ones that need it, so an unquoted path
|
|
339
|
-
* containing a space - or even ` b/` - is ambiguous; for such sections git
|
|
340
|
-
* always writes the same path twice, so the split is validated by checking
|
|
341
|
-
* that both halves agree, falling back to the last ` b/` for a rename-style
|
|
342
|
-
* header.
|
|
343
|
-
*/
|
|
344
|
-
function pathsFromHeader(headerLine: string): string[] {
|
|
345
|
-
const rest = headerLine.slice(FILE_HEADER.length);
|
|
346
|
-
let source: string | undefined;
|
|
347
|
-
let destination: string | undefined;
|
|
348
|
-
|
|
349
|
-
if (rest.startsWith('"')) {
|
|
350
|
-
const first = readQuotedToken(rest, 0);
|
|
351
|
-
if (first) {
|
|
352
|
-
source = unquoteGitPath(first.token);
|
|
353
|
-
const remainder = rest.slice(first.end).replace(/^ /, "");
|
|
354
|
-
destination = unquoteGitPath(remainder);
|
|
355
|
-
}
|
|
356
|
-
} else if (rest.endsWith('"')) {
|
|
357
|
-
const quoteStart = rest.indexOf(' "');
|
|
358
|
-
if (quoteStart !== -1) {
|
|
359
|
-
source = rest.slice(0, quoteStart);
|
|
360
|
-
destination = unquoteGitPath(rest.slice(quoteStart + 1));
|
|
361
|
-
}
|
|
362
|
-
} else if (rest.length % 2 === 1) {
|
|
363
|
-
const half = (rest.length - 1) / 2;
|
|
364
|
-
const left = rest.slice(0, half);
|
|
365
|
-
const right = rest.slice(half + 1);
|
|
366
|
-
if (
|
|
367
|
-
rest[half] === " " &&
|
|
368
|
-
stripDiffPrefix(left, SOURCE_PREFIX) ===
|
|
369
|
-
stripDiffPrefix(right, DESTINATION_PREFIX)
|
|
370
|
-
) {
|
|
371
|
-
source = left;
|
|
372
|
-
destination = right;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
if (source === undefined || destination === undefined) {
|
|
377
|
-
const split = rest.lastIndexOf(` ${DESTINATION_PREFIX}`);
|
|
378
|
-
if (split === -1) return [];
|
|
379
|
-
source = rest.slice(0, split);
|
|
380
|
-
destination = rest.slice(split + 1);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const paths = [
|
|
384
|
-
stripDiffPrefix(source, SOURCE_PREFIX),
|
|
385
|
-
stripDiffPrefix(destination, DESTINATION_PREFIX),
|
|
386
|
-
].filter((path) => path !== "");
|
|
387
|
-
return paths.filter((path, index) => paths.indexOf(path) === index);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* The repository-relative paths a file section touches: one for an ordinary
|
|
392
|
-
* change, two for a rename or copy. Paths are read from the `---`/`+++`
|
|
393
|
-
* marker lines and `rename`/`copy from`/`to` lines in the section header
|
|
394
|
-
* (before the first hunk, so a removed line that happens to start with
|
|
395
|
-
* `-- ` is never mistaken for a marker), falling back to the
|
|
396
|
-
* `diff --git a/X b/Y` line for sections that have none. Returns `[]` for
|
|
397
|
-
* content that is not a file section at all.
|
|
398
|
-
*/
|
|
399
|
-
export function sectionPaths(section: string): string[] {
|
|
400
|
-
const lines = section.split("\n");
|
|
401
|
-
const firstHunk = lines.findIndex((line) => line.startsWith(HUNK_HEADER));
|
|
402
|
-
const headerLines = firstHunk === -1 ? lines : lines.slice(0, firstHunk);
|
|
403
|
-
|
|
404
|
-
const paths: string[] = [];
|
|
405
|
-
const add = (path: string | null) => {
|
|
406
|
-
if (path !== null && path !== "" && !paths.includes(path)) {
|
|
407
|
-
paths.push(path);
|
|
408
|
-
}
|
|
409
|
-
};
|
|
410
|
-
for (const line of headerLines) {
|
|
411
|
-
if (line.startsWith("--- ")) {
|
|
412
|
-
add(pathFromMarkerLine(line.slice(4), SOURCE_PREFIX));
|
|
413
|
-
} else if (line.startsWith("+++ ")) {
|
|
414
|
-
add(pathFromMarkerLine(line.slice(4), DESTINATION_PREFIX));
|
|
415
|
-
} else if (line.startsWith("rename from ")) {
|
|
416
|
-
add(unquoteGitPath(line.slice("rename from ".length)));
|
|
417
|
-
} else if (line.startsWith("rename to ")) {
|
|
418
|
-
add(unquoteGitPath(line.slice("rename to ".length)));
|
|
419
|
-
} else if (line.startsWith("copy from ")) {
|
|
420
|
-
add(unquoteGitPath(line.slice("copy from ".length)));
|
|
421
|
-
} else if (line.startsWith("copy to ")) {
|
|
422
|
-
add(unquoteGitPath(line.slice("copy to ".length)));
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
if (paths.length > 0) return paths;
|
|
426
|
-
|
|
427
|
-
const header = lines[0];
|
|
428
|
-
return header !== undefined && header.startsWith(FILE_HEADER)
|
|
429
|
-
? pathsFromHeader(header)
|
|
430
|
-
: [];
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Sort a diff's file sections into a primary and a low-priority diff.
|
|
435
|
-
*
|
|
436
|
-
* A section is low priority only when it names at least one path and every
|
|
437
|
-
* path it names matches - so a rename into or out of a low-priority area
|
|
438
|
-
* stays primary, as does anything whose path cannot be recognised. Sections
|
|
439
|
-
* keep their original order within each partition and are joined back with
|
|
440
|
-
* newlines, so each partition is itself a valid unified diff.
|
|
441
|
-
*
|
|
442
|
-
* When nothing is primary, the low-priority sections are promoted: with no
|
|
443
|
-
* other change to yield to, they *are* the change and should be described
|
|
444
|
-
* in full, exactly as if no patterns were configured.
|
|
445
|
-
*/
|
|
446
|
-
export function partitionDiff(
|
|
447
|
-
diff: string,
|
|
448
|
-
isLowPriority: PathMatcher,
|
|
449
|
-
): DiffPartition {
|
|
450
|
-
const empty: DiffPartition = {
|
|
451
|
-
primary: "",
|
|
452
|
-
lowPriority: "",
|
|
453
|
-
matchedFiles: 0,
|
|
454
|
-
totalFiles: 0,
|
|
455
|
-
promoted: false,
|
|
456
|
-
};
|
|
457
|
-
if (diff === "") return empty;
|
|
458
|
-
|
|
459
|
-
const primary: string[] = [];
|
|
460
|
-
const lowPriority: string[] = [];
|
|
461
|
-
let totalFiles = 0;
|
|
462
|
-
for (const section of splitFileSections(diff)) {
|
|
463
|
-
const paths = sectionPaths(section);
|
|
464
|
-
if (paths.length > 0) totalFiles += 1;
|
|
465
|
-
const deprioritised = paths.length > 0 && paths.every(isLowPriority);
|
|
466
|
-
(deprioritised ? lowPriority : primary).push(section);
|
|
467
|
-
}
|
|
468
|
-
const matchedFiles = lowPriority.length;
|
|
469
|
-
|
|
470
|
-
if (primary.length === 0) {
|
|
471
|
-
return {
|
|
472
|
-
...empty,
|
|
473
|
-
primary: lowPriority.join("\n"),
|
|
474
|
-
matchedFiles,
|
|
475
|
-
totalFiles,
|
|
476
|
-
promoted: matchedFiles > 0,
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
return {
|
|
480
|
-
primary: primary.join("\n"),
|
|
481
|
-
lowPriority: lowPriority.join("\n"),
|
|
482
|
-
matchedFiles,
|
|
483
|
-
totalFiles,
|
|
484
|
-
promoted: false,
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/** What `ignore` removed from a diff. */
|
|
489
|
-
export interface IgnoreResult {
|
|
490
|
-
/** The diff with every ignored file section removed. May be `""`. */
|
|
491
|
-
diff: string;
|
|
492
|
-
/** File sections dropped because all of their paths matched. */
|
|
493
|
-
ignoredFiles: number;
|
|
494
|
-
/** File sections in the original diff with at least one recognisable path. */
|
|
495
|
-
totalFiles: number;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Drop the file sections whose every path matches, returning the rest.
|
|
500
|
-
*
|
|
501
|
-
* The rule is `partitionDiff`'s: a section is removed only when it names at
|
|
502
|
-
* least one path and all of them match, so a rename out of an ignored
|
|
503
|
-
* directory - which is news - survives, as does anything whose path cannot
|
|
504
|
-
* be read. Surviving sections keep their order and are joined with
|
|
505
|
-
* newlines, so the result is itself a valid unified diff.
|
|
506
|
-
*
|
|
507
|
-
* This runs before everything else in the pipeline, so ignored content is
|
|
508
|
-
* never chunked, never sent, and never paid for.
|
|
509
|
-
*/
|
|
510
|
-
export function applyIgnorePatterns(
|
|
511
|
-
diff: string,
|
|
512
|
-
isIgnored: PathMatcher,
|
|
513
|
-
): IgnoreResult {
|
|
514
|
-
if (diff === "") return { diff: "", ignoredFiles: 0, totalFiles: 0 };
|
|
515
|
-
|
|
516
|
-
const kept: string[] = [];
|
|
517
|
-
let ignoredFiles = 0;
|
|
518
|
-
let totalFiles = 0;
|
|
519
|
-
for (const section of splitFileSections(diff)) {
|
|
520
|
-
const paths = sectionPaths(section);
|
|
521
|
-
if (paths.length > 0) totalFiles += 1;
|
|
522
|
-
if (paths.length > 0 && paths.every(isIgnored)) {
|
|
523
|
-
ignoredFiles += 1;
|
|
524
|
-
continue;
|
|
525
|
-
}
|
|
526
|
-
kept.push(section);
|
|
527
|
-
}
|
|
528
|
-
return { diff: kept.join("\n"), ignoredFiles, totalFiles };
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* Split a diff so that every chunk's *classified token estimate* fits
|
|
533
|
-
* `maxTokens`.
|
|
534
|
-
*
|
|
535
|
-
* `splitDiff` budgets in characters, but token density varies wildly by
|
|
536
|
-
* content: prose and code sit near the configured `charsPerToken` (~3.5)
|
|
537
|
-
* while base64/armor lines measure near 1 char/token. Sizing every chunk
|
|
538
|
-
* with one blended ratio lets an armor-heavy region overflow, so after an
|
|
539
|
-
* initial blended-density split, any chunk still over budget is re-split
|
|
540
|
-
* using its own (denser) ratio until everything fits or no further split is
|
|
541
|
-
* possible. An unsplittable oversized chunk is kept - the overflow retry in
|
|
542
|
-
* the generation pipeline is the backstop for that case.
|
|
543
|
-
*/
|
|
544
|
-
export function splitDiffToFit(
|
|
545
|
-
diff: string,
|
|
546
|
-
maxTokens: number,
|
|
547
|
-
charsPerToken: number,
|
|
548
|
-
): string[] {
|
|
549
|
-
const queue = splitDiff(diff, charBudgetFor(diff, maxTokens, charsPerToken));
|
|
550
|
-
const fitted: string[] = [];
|
|
551
|
-
while (queue.length > 0) {
|
|
552
|
-
const chunk = queue.shift()!;
|
|
553
|
-
if (estimateDiffTokens(chunk, charsPerToken) <= maxTokens) {
|
|
554
|
-
fitted.push(chunk);
|
|
555
|
-
continue;
|
|
556
|
-
}
|
|
557
|
-
// Over budget: the chunk's own density is at least as dense as the
|
|
558
|
-
// blend it was sized with, so this budget is strictly smaller than the
|
|
559
|
-
// chunk - splitDiff will attempt a real split.
|
|
560
|
-
const pieces = splitDiff(
|
|
561
|
-
chunk,
|
|
562
|
-
charBudgetFor(chunk, maxTokens, charsPerToken),
|
|
563
|
-
);
|
|
564
|
-
if (pieces.length <= 1) {
|
|
565
|
-
fitted.push(chunk);
|
|
566
|
-
continue;
|
|
567
|
-
}
|
|
568
|
-
queue.unshift(...pieces);
|
|
569
|
-
}
|
|
570
|
-
return fitted;
|
|
571
|
-
}
|