@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/generate.ts
DELETED
|
@@ -1,478 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The commit-message pipeline:
|
|
3
|
-
*
|
|
4
|
-
* diff ──ignore──▶ ──partition──▶ primary diff, low-priority diff
|
|
5
|
-
* ──split──▶ [chunk, chunk, ...] ──summary model──▶ [summary, ...]
|
|
6
|
-
* ──final model──▶ commit message(s)
|
|
7
|
-
*
|
|
8
|
-
* The `ignore` patterns run first and remove file sections outright, so
|
|
9
|
-
* ignored content is never chunked, never sent and never paid for. Those
|
|
10
|
-
* files are still committed - `ignore` governs what the model reads, not
|
|
11
|
-
* what git stages - but when it matches *everything* there is nothing left
|
|
12
|
-
* to describe and the run stops rather than inventing a message.
|
|
13
|
-
*
|
|
14
|
-
* The remaining diff is partitioned by the configured `lowPriorityPaths`: file
|
|
15
|
-
* sections under those paths (generated docs, lockfiles, ...) form a
|
|
16
|
-
* low-priority partition that is summarised after, and more briefly than,
|
|
17
|
-
* the primary one, and the final model is told which is which so the
|
|
18
|
-
* subject line describes the primary changes. When every file is low
|
|
19
|
-
* priority the partition is promoted and the run is identical to one with
|
|
20
|
-
* no patterns configured.
|
|
21
|
-
*
|
|
22
|
-
* The summary model (default `sonnet`) reads each diff chunk and writes a
|
|
23
|
-
* factual summary; chunks are sized by a content-classified token estimate
|
|
24
|
-
* (`splitDiffToFit`) so each request fits the model's context window, and a
|
|
25
|
-
* chunk the backend still rejects as too long is re-split with a halved
|
|
26
|
-
* budget and retried - the rejection happens before the model runs and is
|
|
27
|
-
* not billed, so the API acts as the final arbiter of token counts. The
|
|
28
|
-
* final model (default `sonnet`) turns the summaries into the commit
|
|
29
|
-
* message(s), applying the configured formatting rules.
|
|
30
|
-
*/
|
|
31
|
-
import { runPrompt } from "./agent";
|
|
32
|
-
import { isOllamaModel } from "./models";
|
|
33
|
-
import { resolveOllamaContext } from "./ollama";
|
|
34
|
-
import {
|
|
35
|
-
applyIgnorePatterns,
|
|
36
|
-
partitionDiff,
|
|
37
|
-
redactOpaqueRuns,
|
|
38
|
-
splitDiffToFit,
|
|
39
|
-
} from "./diff";
|
|
40
|
-
import { createPathMatcher } from "./paths";
|
|
41
|
-
import { clampChunkTokens } from "./tokens";
|
|
42
|
-
import { ClaudeCommitError, isPromptTooLongError } from "./errors";
|
|
43
|
-
import {
|
|
44
|
-
buildFinalSystem,
|
|
45
|
-
buildFinalUser,
|
|
46
|
-
buildSummarySystem,
|
|
47
|
-
buildSummaryUser,
|
|
48
|
-
cleanMessage,
|
|
49
|
-
extractMessages,
|
|
50
|
-
hasLowPrioritySummaries,
|
|
51
|
-
MESSAGES_SCHEMA,
|
|
52
|
-
parseOptions,
|
|
53
|
-
} from "./prompts";
|
|
54
|
-
import type {
|
|
55
|
-
ChangePriority,
|
|
56
|
-
Config,
|
|
57
|
-
DiffSummary,
|
|
58
|
-
OllamaConfig,
|
|
59
|
-
} from "./types";
|
|
60
|
-
|
|
61
|
-
export interface GenerateProgress {
|
|
62
|
-
/** Called when a new phase of work begins (for spinner labels). */
|
|
63
|
-
onPhase?: (label: string) => void;
|
|
64
|
-
/** Receives streamed text of the final message as it is produced. */
|
|
65
|
-
onText?: (delta: string) => void;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface GenerateOptions {
|
|
69
|
-
/** Number of candidate messages to produce (interactive mode uses > 1). */
|
|
70
|
-
count?: number;
|
|
71
|
-
progress?: GenerateProgress;
|
|
72
|
-
abortController?: AbortController;
|
|
73
|
-
/**
|
|
74
|
-
* Model runner used for every prompt; injectable so tests can exercise the
|
|
75
|
-
* pipeline (including overflow retries) without real model calls.
|
|
76
|
-
* Defaults to {@link runPrompt}.
|
|
77
|
-
*/
|
|
78
|
-
runner?: typeof runPrompt;
|
|
79
|
-
/**
|
|
80
|
-
* Resolves an `ollama:` model's context window, called once per model
|
|
81
|
-
* per run before any chunk is sized; injectable so tests can exercise an
|
|
82
|
-
* `"auto"` configuration without a server. Defaults to
|
|
83
|
-
* {@link resolveOllamaContext}.
|
|
84
|
-
*/
|
|
85
|
-
resolveOllamaContext?: typeof resolveOllamaContext;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** The context window one Ollama model ran with during this run. */
|
|
89
|
-
export interface OllamaContextWindow {
|
|
90
|
-
/** The model string as configured, prefix included. */
|
|
91
|
-
model: string;
|
|
92
|
-
tokens: number;
|
|
93
|
-
/** Whether the number was configured or chosen by the server (`"auto"`). */
|
|
94
|
-
source: "config" | "auto";
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** How the `ignore` patterns applied to this diff (for `--verbose`). */
|
|
98
|
-
export interface IgnoreStats {
|
|
99
|
-
/** File sections dropped before any model saw them. */
|
|
100
|
-
ignoredFiles: number;
|
|
101
|
-
/** File sections in the staged diff with a recognisable path. */
|
|
102
|
-
totalFiles: number;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** How the `lowPriorityPaths` patterns applied to this diff (for `--verbose`). */
|
|
106
|
-
export interface LowPriorityStats {
|
|
107
|
-
/** File sections whose paths all matched a pattern. */
|
|
108
|
-
matchedFiles: number;
|
|
109
|
-
/** File sections in the diff with a recognisable path. */
|
|
110
|
-
totalFiles: number;
|
|
111
|
-
/** Every file matched, so the changes were treated as primary after all. */
|
|
112
|
-
promoted: boolean;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export interface GenerateResult {
|
|
116
|
-
/** Candidate commit messages (length 1 in non-interactive mode). */
|
|
117
|
-
messages: string[];
|
|
118
|
-
/** The intermediate summaries, primary first, each tagged with its priority. */
|
|
119
|
-
summaries: DiffSummary[];
|
|
120
|
-
/** Number of diff chunks the summary stage processed, across both partitions. */
|
|
121
|
-
chunkCount: number;
|
|
122
|
-
/** Total cost across all model calls, in USD. */
|
|
123
|
-
costUsd: number;
|
|
124
|
-
/** How the low-priority patterns applied to this diff. */
|
|
125
|
-
lowPriority: LowPriorityStats;
|
|
126
|
-
/** How the ignore patterns applied to this diff. */
|
|
127
|
-
ignored: IgnoreStats;
|
|
128
|
-
/** The context window each Ollama model ran with, in order of first use. */
|
|
129
|
-
ollamaContexts: OllamaContextWindow[];
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Resolves each `ollama:` model's context window once and hands back an
|
|
134
|
-
* {@link OllamaConfig} with the number pinned in place of `"auto"`, so the
|
|
135
|
-
* runner never repeats the probe. Claude models get `undefined`: they
|
|
136
|
-
* neither need nor understand the block.
|
|
137
|
-
*/
|
|
138
|
-
class OllamaContextResolver {
|
|
139
|
-
private readonly windows = new Map<string, Promise<number>>();
|
|
140
|
-
readonly resolved: OllamaContextWindow[] = [];
|
|
141
|
-
|
|
142
|
-
constructor(
|
|
143
|
-
private readonly config: OllamaConfig,
|
|
144
|
-
private readonly resolve: typeof resolveOllamaContext,
|
|
145
|
-
private readonly signal?: AbortSignal,
|
|
146
|
-
) {}
|
|
147
|
-
|
|
148
|
-
/** The Ollama settings to run `model` with, or `undefined` for a Claude model. */
|
|
149
|
-
async settingsFor(model: string): Promise<OllamaConfig | undefined> {
|
|
150
|
-
if (!isOllamaModel(model)) return undefined;
|
|
151
|
-
const tokens = await this.windowFor(model);
|
|
152
|
-
return { ...this.config, context: tokens };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private windowFor(model: string): Promise<number> {
|
|
156
|
-
let pending = this.windows.get(model);
|
|
157
|
-
if (!pending) {
|
|
158
|
-
pending = this.resolve(model, this.config, this.signal).then((tokens) => {
|
|
159
|
-
this.resolved.push({
|
|
160
|
-
model,
|
|
161
|
-
tokens,
|
|
162
|
-
source: this.config.context === "auto" ? "auto" : "config",
|
|
163
|
-
});
|
|
164
|
-
return tokens;
|
|
165
|
-
});
|
|
166
|
-
this.windows.set(model, pending);
|
|
167
|
-
}
|
|
168
|
-
return pending;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Floor for overflow-retry halving. Below this a chunk is essentially
|
|
174
|
-
* prompt-sized already, so a "prompt is too long" rejection indicates
|
|
175
|
-
* something other than chunk sizing and is surfaced instead of retried.
|
|
176
|
-
*/
|
|
177
|
-
const MIN_RETRY_CHUNK_TOKENS = 8_000;
|
|
178
|
-
|
|
179
|
-
interface PartitionSummaryOptions {
|
|
180
|
-
config: Config;
|
|
181
|
-
runner: typeof runPrompt;
|
|
182
|
-
progress: GenerateProgress;
|
|
183
|
-
contexts: OllamaContextResolver;
|
|
184
|
-
abortController?: AbortController;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Spinner label for one chunk of a partition. */
|
|
188
|
-
function readingLabel(
|
|
189
|
-
priority: ChangePriority,
|
|
190
|
-
position: number,
|
|
191
|
-
total: number,
|
|
192
|
-
): string {
|
|
193
|
-
const subject = priority === "low" ? "low-priority diff" : "diff";
|
|
194
|
-
return total > 1
|
|
195
|
-
? `Reading ${subject} (part ${position + 1}/${total})`
|
|
196
|
-
: `Reading ${subject}`;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Stage 1 for one partition: split it into chunks and summarise each, via a
|
|
201
|
-
* work queue so an oversized chunk can be re-split and retried in place.
|
|
202
|
-
* The estimate is calibrated, but only the backend knows the true token
|
|
203
|
-
* count; its "prompt is too long" rejection is free, so treat it as the
|
|
204
|
-
* final arbiter: halve the budget, re-split just that chunk, and continue
|
|
205
|
-
* where we left off. Returns no summaries for an empty partition.
|
|
206
|
-
*/
|
|
207
|
-
async function summarizePartition(
|
|
208
|
-
diff: string,
|
|
209
|
-
priority: ChangePriority,
|
|
210
|
-
options: PartitionSummaryOptions,
|
|
211
|
-
): Promise<{ summaries: DiffSummary[]; costUsd: number }> {
|
|
212
|
-
const { config, runner, progress, contexts, abortController } = options;
|
|
213
|
-
|
|
214
|
-
// The configured chunk budget is clamped to the summary model's context
|
|
215
|
-
// window so a single chunk (plus prompt scaffolding and response headroom)
|
|
216
|
-
// can never overflow it, whatever `maxChunkTokens` says. For an Ollama
|
|
217
|
-
// model that window is resolved here first - possibly by asking the
|
|
218
|
-
// server - so the chunks and the request agree on the same number.
|
|
219
|
-
// Chunks are sized by a content-classified token estimate: opaque content
|
|
220
|
-
// (age/gpg armor, binary patches) measures near 1 char/token, so a plain
|
|
221
|
-
// chars-based budget underestimates armor-heavy diffs more than threefold.
|
|
222
|
-
const ollama = await contexts.settingsFor(config.models.summary);
|
|
223
|
-
const chunkTokens = clampChunkTokens(
|
|
224
|
-
config.models.summary,
|
|
225
|
-
config.maxChunkTokens,
|
|
226
|
-
typeof ollama?.context === "number" ? ollama.context : undefined,
|
|
227
|
-
);
|
|
228
|
-
const chunks = splitDiffToFit(diff, chunkTokens, config.charsPerToken);
|
|
229
|
-
|
|
230
|
-
const summarySystem = buildSummarySystem(priority);
|
|
231
|
-
const summaries: DiffSummary[] = [];
|
|
232
|
-
let costUsd = 0;
|
|
233
|
-
|
|
234
|
-
const queue = chunks.map((chunk) => ({ chunk, tokenBudget: chunkTokens }));
|
|
235
|
-
while (queue.length > 0) {
|
|
236
|
-
const task = queue.shift()!;
|
|
237
|
-
const position = summaries.length;
|
|
238
|
-
const total = summaries.length + queue.length + 1;
|
|
239
|
-
progress.onPhase?.(readingLabel(priority, position, total));
|
|
240
|
-
try {
|
|
241
|
-
const result = await runner(
|
|
242
|
-
buildSummaryUser(task.chunk, position, total, priority),
|
|
243
|
-
{
|
|
244
|
-
model: config.models.summary,
|
|
245
|
-
system: summarySystem,
|
|
246
|
-
allowApiKey: config.allowApiKey,
|
|
247
|
-
...(ollama ? { ollama } : {}),
|
|
248
|
-
...(abortController ? { abortController } : {}),
|
|
249
|
-
},
|
|
250
|
-
);
|
|
251
|
-
summaries.push({ priority, text: result.text });
|
|
252
|
-
costUsd += result.costUsd;
|
|
253
|
-
} catch (error) {
|
|
254
|
-
const halvedBudget = Math.floor(task.tokenBudget / 2);
|
|
255
|
-
if (
|
|
256
|
-
!isPromptTooLongError(error) ||
|
|
257
|
-
halvedBudget < MIN_RETRY_CHUNK_TOKENS
|
|
258
|
-
) {
|
|
259
|
-
throw error;
|
|
260
|
-
}
|
|
261
|
-
const pieces = splitDiffToFit(
|
|
262
|
-
task.chunk,
|
|
263
|
-
halvedBudget,
|
|
264
|
-
config.charsPerToken,
|
|
265
|
-
);
|
|
266
|
-
if (pieces.length === 1 && pieces[0] === task.chunk) {
|
|
267
|
-
// Nothing left to split on (a single oversized hunk): retrying the
|
|
268
|
-
// identical request would loop forever, so surface the error.
|
|
269
|
-
throw error;
|
|
270
|
-
}
|
|
271
|
-
queue.unshift(
|
|
272
|
-
...pieces.map((chunk) => ({ chunk, tokenBudget: halvedBudget })),
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return { summaries, costUsd };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** Run the full pipeline over a staged diff. */
|
|
281
|
-
export async function generateCommit(
|
|
282
|
-
diff: string,
|
|
283
|
-
config: Config,
|
|
284
|
-
options: GenerateOptions = {},
|
|
285
|
-
): Promise<GenerateResult> {
|
|
286
|
-
const {
|
|
287
|
-
count = 1,
|
|
288
|
-
progress = {},
|
|
289
|
-
abortController,
|
|
290
|
-
runner = runPrompt,
|
|
291
|
-
resolveOllamaContext: resolveContext = resolveOllamaContext,
|
|
292
|
-
} = options;
|
|
293
|
-
const contexts = new OllamaContextResolver(
|
|
294
|
-
config.ollama,
|
|
295
|
-
resolveContext,
|
|
296
|
-
abortController?.signal,
|
|
297
|
-
);
|
|
298
|
-
|
|
299
|
-
// Ignore first: dropped sections cost nothing downstream. Unlike a
|
|
300
|
-
// low-priority partition, an ignored one has nowhere to be promoted to,
|
|
301
|
-
// so matching every file is a dead end rather than a special case.
|
|
302
|
-
const ignoreResult = applyIgnorePatterns(
|
|
303
|
-
diff,
|
|
304
|
-
createPathMatcher(config.ignore),
|
|
305
|
-
);
|
|
306
|
-
const ignored: IgnoreStats = {
|
|
307
|
-
ignoredFiles: ignoreResult.ignoredFiles,
|
|
308
|
-
totalFiles: ignoreResult.totalFiles,
|
|
309
|
-
};
|
|
310
|
-
if (ignoreResult.diff.trim() === "" && ignoreResult.ignoredFiles > 0) {
|
|
311
|
-
throw new ClaudeCommitError(describeFullyIgnored(ignored));
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const effectiveDiff = config.skipArmored
|
|
315
|
-
? redactOpaqueRuns(ignoreResult.diff)
|
|
316
|
-
: ignoreResult.diff;
|
|
317
|
-
const partition = partitionDiff(
|
|
318
|
-
effectiveDiff,
|
|
319
|
-
createPathMatcher(config.lowPriorityPaths),
|
|
320
|
-
);
|
|
321
|
-
if (partition.primary.trim() === "") {
|
|
322
|
-
throw new ClaudeCommitError("There are no staged changes to summarize.");
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Stage 1: summarise the primary partition first - fail fast on the part
|
|
326
|
-
// that matters - then the low-priority one (skipped when empty).
|
|
327
|
-
const partitionOptions: PartitionSummaryOptions = {
|
|
328
|
-
config,
|
|
329
|
-
runner,
|
|
330
|
-
progress,
|
|
331
|
-
contexts,
|
|
332
|
-
...(abortController ? { abortController } : {}),
|
|
333
|
-
};
|
|
334
|
-
const primaryStage = await summarizePartition(
|
|
335
|
-
partition.primary,
|
|
336
|
-
"primary",
|
|
337
|
-
partitionOptions,
|
|
338
|
-
);
|
|
339
|
-
const lowPriorityStage =
|
|
340
|
-
partition.lowPriority.trim() === ""
|
|
341
|
-
? { summaries: [], costUsd: 0 }
|
|
342
|
-
: await summarizePartition(
|
|
343
|
-
partition.lowPriority,
|
|
344
|
-
"low",
|
|
345
|
-
partitionOptions,
|
|
346
|
-
);
|
|
347
|
-
const summaries = [...primaryStage.summaries, ...lowPriorityStage.summaries];
|
|
348
|
-
if (summaries.length === 0) {
|
|
349
|
-
throw new ClaudeCommitError("There are no staged changes to summarize.");
|
|
350
|
-
}
|
|
351
|
-
let costUsd = primaryStage.costUsd + lowPriorityStage.costUsd;
|
|
352
|
-
const hasLowPriority = hasLowPrioritySummaries(summaries);
|
|
353
|
-
|
|
354
|
-
// Stage 2: write the commit message(s) from the summaries.
|
|
355
|
-
//
|
|
356
|
-
// Prefer a structured (JSON-schema) response so parsing is robust regardless
|
|
357
|
-
// of how the model formats its prose. We try, in order: structured output
|
|
358
|
-
// with a temperature bump (for interactive variety), then structured output
|
|
359
|
-
// without it (for models that reject a temperature override), then plain text
|
|
360
|
-
// with delimiter parsing (for models that don't support structured output at
|
|
361
|
-
// all). Whichever succeeds first wins.
|
|
362
|
-
progress.onPhase?.(
|
|
363
|
-
count > 1 ? "Writing commit options" : "Writing commit message",
|
|
364
|
-
);
|
|
365
|
-
|
|
366
|
-
const finalOllama = await contexts.settingsFor(config.models.final);
|
|
367
|
-
const baseOpts = {
|
|
368
|
-
model: config.models.final,
|
|
369
|
-
allowApiKey: config.allowApiKey,
|
|
370
|
-
...(finalOllama ? { ollama: finalOllama } : {}),
|
|
371
|
-
...(abortController ? { abortController } : {}),
|
|
372
|
-
};
|
|
373
|
-
const temperature =
|
|
374
|
-
count > 1 && config.interactiveTemperature != null
|
|
375
|
-
? config.interactiveTemperature
|
|
376
|
-
: undefined;
|
|
377
|
-
|
|
378
|
-
const attempts: Array<{ structured: boolean; temperature?: number }> = [];
|
|
379
|
-
if (temperature != null) attempts.push({ structured: true, temperature });
|
|
380
|
-
attempts.push({ structured: true });
|
|
381
|
-
attempts.push({ structured: false });
|
|
382
|
-
|
|
383
|
-
let messages: string[] | null = null;
|
|
384
|
-
let lastError: unknown;
|
|
385
|
-
for (const attempt of attempts) {
|
|
386
|
-
try {
|
|
387
|
-
const result = await runner(
|
|
388
|
-
buildFinalUser(summaries, count, attempt.structured),
|
|
389
|
-
{
|
|
390
|
-
...baseOpts,
|
|
391
|
-
system: buildFinalSystem(config, attempt.structured, hasLowPriority),
|
|
392
|
-
...(attempt.structured
|
|
393
|
-
? {
|
|
394
|
-
outputFormat: {
|
|
395
|
-
type: "json_schema" as const,
|
|
396
|
-
schema: MESSAGES_SCHEMA,
|
|
397
|
-
},
|
|
398
|
-
}
|
|
399
|
-
: {}),
|
|
400
|
-
...(attempt.temperature != null
|
|
401
|
-
? { temperature: attempt.temperature }
|
|
402
|
-
: {}),
|
|
403
|
-
...(!attempt.structured && progress.onText
|
|
404
|
-
? { onText: progress.onText }
|
|
405
|
-
: {}),
|
|
406
|
-
},
|
|
407
|
-
);
|
|
408
|
-
costUsd += result.costUsd;
|
|
409
|
-
messages = attempt.structured
|
|
410
|
-
? extractMessages(result.structured)
|
|
411
|
-
: count > 1
|
|
412
|
-
? parseOptions(result.text)
|
|
413
|
-
: [result.text];
|
|
414
|
-
if (messages && messages.length > 0) break;
|
|
415
|
-
} catch (err) {
|
|
416
|
-
lastError = err;
|
|
417
|
-
// If the run was cancelled, stop retrying: the shared abort signal would
|
|
418
|
-
// make every remaining attempt fail immediately in the same way.
|
|
419
|
-
if (abortController?.signal.aborted) break;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const cleaned = (messages ?? [])
|
|
424
|
-
.map(cleanMessage)
|
|
425
|
-
.filter((message) => message.length > 0);
|
|
426
|
-
const deduped = dedupe(cleaned);
|
|
427
|
-
if (deduped.length === 0) {
|
|
428
|
-
if (lastError instanceof ClaudeCommitError) throw lastError;
|
|
429
|
-
throw new ClaudeCommitError("The model did not produce a commit message.");
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Report chunks actually processed: overflow retries can split further
|
|
433
|
-
// than the initial estimate planned.
|
|
434
|
-
return {
|
|
435
|
-
messages: deduped,
|
|
436
|
-
summaries,
|
|
437
|
-
chunkCount: summaries.length,
|
|
438
|
-
costUsd,
|
|
439
|
-
lowPriority: {
|
|
440
|
-
matchedFiles: partition.matchedFiles,
|
|
441
|
-
totalFiles: partition.totalFiles,
|
|
442
|
-
promoted: partition.promoted,
|
|
443
|
-
},
|
|
444
|
-
ignored,
|
|
445
|
-
ollamaContexts: contexts.resolved,
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* The error for a commit whose every changed file matched `ignore`.
|
|
451
|
-
*
|
|
452
|
-
* There is no sensible fallback here. Describing the ignored files anyway
|
|
453
|
-
* would contradict the directive the user wrote; committing an empty or
|
|
454
|
-
* invented message would be worse. Naming the directive and the count makes
|
|
455
|
-
* the cause obvious, because the alternative - a run that mysteriously
|
|
456
|
-
* reports no staged changes when `git status` plainly disagrees - is the
|
|
457
|
-
* kind of bug people spend an afternoon on.
|
|
458
|
-
*/
|
|
459
|
-
export function describeFullyIgnored(stats: IgnoreStats): string {
|
|
460
|
-
const files = `${stats.ignoredFiles} staged file${stats.ignoredFiles === 1 ? "" : "s"}`;
|
|
461
|
-
return (
|
|
462
|
-
`Every one of the ${files} matches an "ignore" pattern, so there is ` +
|
|
463
|
-
`nothing left to describe. Narrow the patterns, or pass --no-ignore to ` +
|
|
464
|
-
`write a message about these changes for this commit.`
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function dedupe(items: string[]): string[] {
|
|
469
|
-
const seen = new Set<string>();
|
|
470
|
-
const out: string[] = [];
|
|
471
|
-
for (const item of items) {
|
|
472
|
-
if (!seen.has(item)) {
|
|
473
|
-
seen.add(item);
|
|
474
|
-
out.push(item);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
return out;
|
|
478
|
-
}
|
package/src/git.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Git operations, implemented with Bun's shell (`Bun.$`).
|
|
3
|
-
*/
|
|
4
|
-
import { $ } from "bun";
|
|
5
|
-
import { ClaudeCommitError } from "./errors";
|
|
6
|
-
import type { FileChange } from "./types";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* A git command failed. Subclasses {@link ClaudeCommitError} so the CLI prints
|
|
10
|
-
* it as a clean, user-facing error rather than a stack trace.
|
|
11
|
-
*/
|
|
12
|
-
export class GitError extends ClaudeCommitError {
|
|
13
|
-
override name = "GitError";
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Run a git command, returning stdout. Throws {@link GitError} on failure. */
|
|
17
|
-
async function git(args: string[]): Promise<string> {
|
|
18
|
-
let res;
|
|
19
|
-
try {
|
|
20
|
-
res = await $`git ${args}`.quiet().nothrow();
|
|
21
|
-
} catch (err) {
|
|
22
|
-
// Should not happen with `.nothrow()`, but never let a raw shell error leak.
|
|
23
|
-
throw new GitError(`Could not run git: ${(err as Error).message}`);
|
|
24
|
-
}
|
|
25
|
-
if (res.exitCode !== 0) {
|
|
26
|
-
const stderr = res.stderr.toString().trim();
|
|
27
|
-
throw new GitError(
|
|
28
|
-
stderr || `git ${args.join(" ")} exited with code ${res.exitCode}`,
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
return res.stdout.toString();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** True if the current working directory is inside a git work tree. */
|
|
35
|
-
export async function isGitRepo(): Promise<boolean> {
|
|
36
|
-
try {
|
|
37
|
-
const res = await $`git rev-parse --is-inside-work-tree`.quiet().nothrow();
|
|
38
|
-
return res.exitCode === 0 && res.stdout.toString().trim() === "true";
|
|
39
|
-
} catch {
|
|
40
|
-
// git missing or unrunnable - treat as "not a usable repo".
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Absolute path to the repository root. */
|
|
46
|
-
export async function getRepoRoot(): Promise<string> {
|
|
47
|
-
return (await git(["rev-parse", "--show-toplevel"])).trim();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Flags that pin the shape and membership of every staged-change reader
|
|
52
|
-
* against user diff settings. The list is exhaustive, not illustrative:
|
|
53
|
-
*
|
|
54
|
-
* - `--no-relative`: with `diff.relative=true`, running from a subdirectory
|
|
55
|
-
* would both strip leading path segments (breaking `lowPriorityPaths`
|
|
56
|
-
* matching, which is always repository-root-relative) and omit staged
|
|
57
|
-
* files outside that directory entirely, so the message would describe a
|
|
58
|
-
* subset of what gets committed.
|
|
59
|
-
* - `--no-ext-diff`: `diff.external` / `GIT_EXTERNAL_DIFF` / a gitattributes
|
|
60
|
-
* `diff=<driver>` replace the diff body wholesale - a difftastic-style
|
|
61
|
-
* driver emits no `diff --git` headers at all, and a driver can even forge
|
|
62
|
-
* headers that attach one file's changes to another path.
|
|
63
|
-
* - `--ignore-submodules=none`: `diff.ignoreSubmodules=all` erases a staged
|
|
64
|
-
* submodule bump from all three readers.
|
|
65
|
-
* - `--submodule=short`: `diff.submodule=log|diff` replace a submodule's
|
|
66
|
-
* `diff --git` section with a header-less `Submodule <path> <a>..<b>:`
|
|
67
|
-
* block, which the section splitter would glue onto the preceding file's
|
|
68
|
-
* section (and priority).
|
|
69
|
-
* - The `a/`/`b/` prefixes are forced so `diff.noprefix` /
|
|
70
|
-
* `diff.mnemonicPrefix` cannot change the header format the diff parser
|
|
71
|
-
* (`sectionPaths` in `src/diff.ts`) expects.
|
|
72
|
-
*/
|
|
73
|
-
const STAGED_DIFF_FLAGS = [
|
|
74
|
-
"--cached",
|
|
75
|
-
"--no-color",
|
|
76
|
-
"--no-relative",
|
|
77
|
-
"--no-ext-diff",
|
|
78
|
-
"--ignore-submodules=none",
|
|
79
|
-
"--submodule=short",
|
|
80
|
-
"--src-prefix=a/",
|
|
81
|
-
"--dst-prefix=b/",
|
|
82
|
-
];
|
|
83
|
-
|
|
84
|
-
/** The unified diff of staged changes (`git diff --cached`), repository-root-relative. */
|
|
85
|
-
export async function getStagedDiff(): Promise<string> {
|
|
86
|
-
return git(["diff", ...STAGED_DIFF_FLAGS]);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Parsed list of staged files with their status codes. */
|
|
90
|
-
export async function getStagedFiles(): Promise<FileChange[]> {
|
|
91
|
-
const out = await git(["diff", ...STAGED_DIFF_FLAGS, "--name-status"]);
|
|
92
|
-
return out
|
|
93
|
-
.split("\n")
|
|
94
|
-
.map((line) => line.trim())
|
|
95
|
-
.filter(Boolean)
|
|
96
|
-
.map((line) => {
|
|
97
|
-
const parts = line.split("\t");
|
|
98
|
-
const status = parts[0] ?? "";
|
|
99
|
-
// For renames/copies (`R100\told\tnew`) the destination is the last field.
|
|
100
|
-
const path = parts[parts.length - 1] ?? "";
|
|
101
|
-
return { status, path };
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Stage every change in the work tree (`git add -A`). */
|
|
106
|
-
export async function stageAll(): Promise<void> {
|
|
107
|
-
await git(["add", "-A"]);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** A short one-line stat summary of staged changes (for display). */
|
|
111
|
-
export async function getStagedStat(): Promise<string> {
|
|
112
|
-
return (await git(["diff", ...STAGED_DIFF_FLAGS, "--stat"])).trimEnd();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Create a commit with the given message. The message is piped to
|
|
117
|
-
* `git commit -F -` over stdin, so arbitrary content (leading dashes, multiple
|
|
118
|
-
* lines, special characters) is handled safely - and nothing touches disk, so
|
|
119
|
-
* there is no temp file to be raced or read by another user.
|
|
120
|
-
*/
|
|
121
|
-
export async function commit(message: string): Promise<void> {
|
|
122
|
-
let proc;
|
|
123
|
-
try {
|
|
124
|
-
// `Bun.spawn` throws synchronously if `git` isn't on PATH.
|
|
125
|
-
proc = Bun.spawn(["git", "commit", "-F", "-"], {
|
|
126
|
-
stdin: new TextEncoder().encode(message),
|
|
127
|
-
// We surface our own confirmation, so discard git's stdout summary rather
|
|
128
|
-
// than leaving an unread pipe that could (in theory) fill and block.
|
|
129
|
-
stdout: "ignore",
|
|
130
|
-
stderr: "pipe",
|
|
131
|
-
});
|
|
132
|
-
} catch (err) {
|
|
133
|
-
throw new GitError(`Could not run git: ${(err as Error).message}`);
|
|
134
|
-
}
|
|
135
|
-
const exitCode = await proc.exited;
|
|
136
|
-
if (exitCode !== 0) {
|
|
137
|
-
const stderr = (await new Response(proc.stderr).text()).trim();
|
|
138
|
-
throw new GitError(stderr || `git commit exited with code ${exitCode}`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** The current branch name (or `HEAD` when detached). */
|
|
143
|
-
export async function getCurrentBranch(): Promise<string> {
|
|
144
|
-
return (await git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
145
|
-
}
|