@synmux/claude-commit 1.0.4 → 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/src/generate.ts DELETED
@@ -1,501 +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
- * With filenamesOnly, the summary stage is skipped entirely and the final
31
- * model receives only paths from the filtered, priority-grouped diff.
32
- */
33
- import { runPrompt } from "./agent";
34
- import { isOllamaModel } from "./models";
35
- import { resolveOllamaContext } from "./ollama";
36
- import {
37
- applyIgnorePatterns,
38
- diffPaths,
39
- partitionDiff,
40
- redactOpaqueRuns,
41
- splitDiffToFit,
42
- } from "./diff";
43
- import { createPathMatcher } from "./paths";
44
- import { clampChunkTokens } from "./tokens";
45
- import { ClaudeCommitError, isPromptTooLongError } from "./errors";
46
- import {
47
- buildFinalSystem,
48
- buildFinalUser,
49
- buildFilenamesUser,
50
- buildSummarySystem,
51
- buildSummaryUser,
52
- cleanMessage,
53
- extractMessages,
54
- hasLowPrioritySummaries,
55
- MESSAGES_SCHEMA,
56
- parseOptions,
57
- } from "./prompts";
58
- import type {
59
- ChangePriority,
60
- Config,
61
- DiffSummary,
62
- OllamaConfig,
63
- } from "./types";
64
-
65
- export interface GenerateProgress {
66
- /** Called when a new phase of work begins (for spinner labels). */
67
- onPhase?: (label: string) => void;
68
- /** Receives streamed text of the final message as it is produced. */
69
- onText?: (delta: string) => void;
70
- }
71
-
72
- export interface GenerateOptions {
73
- /** Number of candidate messages to produce (interactive mode uses > 1). */
74
- count?: number;
75
- progress?: GenerateProgress;
76
- abortController?: AbortController;
77
- /**
78
- * Model runner used for every prompt; injectable so tests can exercise the
79
- * pipeline (including overflow retries) without real model calls.
80
- * Defaults to {@link runPrompt}.
81
- */
82
- runner?: typeof runPrompt;
83
- /**
84
- * Resolves an `ollama:` model's context window, called once per model
85
- * per run before any chunk is sized; injectable so tests can exercise an
86
- * `"auto"` configuration without a server. Defaults to
87
- * {@link resolveOllamaContext}.
88
- */
89
- resolveOllamaContext?: typeof resolveOllamaContext;
90
- }
91
-
92
- /** The context window one Ollama model ran with during this run. */
93
- export interface OllamaContextWindow {
94
- /** The model string as configured, prefix included. */
95
- model: string;
96
- tokens: number;
97
- /** Whether the number was configured or chosen by the server (`"auto"`). */
98
- source: "config" | "auto";
99
- }
100
-
101
- /** How the `ignore` patterns applied to this diff (for `--verbose`). */
102
- export interface IgnoreStats {
103
- /** File sections dropped before any model saw them. */
104
- ignoredFiles: number;
105
- /** File sections in the staged diff with a recognisable path. */
106
- totalFiles: number;
107
- }
108
-
109
- /** How the `lowPriorityPaths` patterns applied to this diff (for `--verbose`). */
110
- export interface LowPriorityStats {
111
- /** File sections whose paths all matched a pattern. */
112
- matchedFiles: number;
113
- /** File sections in the diff with a recognisable path. */
114
- totalFiles: number;
115
- /** Every file matched, so the changes were treated as primary after all. */
116
- promoted: boolean;
117
- }
118
-
119
- export interface GenerateResult {
120
- /** Candidate commit messages (length 1 in non-interactive mode). */
121
- messages: string[];
122
- /** Intermediate summaries, primary first. Empty when filenamesOnly is enabled. */
123
- summaries: DiffSummary[];
124
- /** Number of diff chunks the summary stage processed, across both partitions. */
125
- chunkCount: number;
126
- /** Total cost across all model calls, in USD. */
127
- costUsd: number;
128
- /** How the low-priority patterns applied to this diff. */
129
- lowPriority: LowPriorityStats;
130
- /** How the ignore patterns applied to this diff. */
131
- ignored: IgnoreStats;
132
- /** The context window each Ollama model ran with, in order of first use. */
133
- ollamaContexts: OllamaContextWindow[];
134
- }
135
-
136
- /**
137
- * Resolves each `ollama:` model's context window once and hands back an
138
- * {@link OllamaConfig} with the number pinned in place of `"auto"`, so the
139
- * runner never repeats the probe. Claude models get `undefined`: they
140
- * neither need nor understand the block.
141
- */
142
- class OllamaContextResolver {
143
- private readonly windows = new Map<string, Promise<number>>();
144
- readonly resolved: OllamaContextWindow[] = [];
145
-
146
- constructor(
147
- private readonly config: OllamaConfig,
148
- private readonly resolve: typeof resolveOllamaContext,
149
- private readonly signal?: AbortSignal,
150
- ) {}
151
-
152
- /** The Ollama settings to run `model` with, or `undefined` for a Claude model. */
153
- async settingsFor(model: string): Promise<OllamaConfig | undefined> {
154
- if (!isOllamaModel(model)) return undefined;
155
- const tokens = await this.windowFor(model);
156
- return { ...this.config, context: tokens };
157
- }
158
-
159
- private windowFor(model: string): Promise<number> {
160
- let pending = this.windows.get(model);
161
- if (!pending) {
162
- pending = this.resolve(model, this.config, this.signal).then((tokens) => {
163
- this.resolved.push({
164
- model,
165
- tokens,
166
- source: this.config.context === "auto" ? "auto" : "config",
167
- });
168
- return tokens;
169
- });
170
- this.windows.set(model, pending);
171
- }
172
- return pending;
173
- }
174
- }
175
-
176
- /**
177
- * Floor for overflow-retry halving. Below this a chunk is essentially
178
- * prompt-sized already, so a "prompt is too long" rejection indicates
179
- * something other than chunk sizing and is surfaced instead of retried.
180
- */
181
- const MIN_RETRY_CHUNK_TOKENS = 8_000;
182
-
183
- interface PartitionSummaryOptions {
184
- config: Config;
185
- runner: typeof runPrompt;
186
- progress: GenerateProgress;
187
- contexts: OllamaContextResolver;
188
- abortController?: AbortController;
189
- }
190
-
191
- /** Spinner label for one chunk of a partition. */
192
- function readingLabel(
193
- priority: ChangePriority,
194
- position: number,
195
- total: number,
196
- ): string {
197
- const subject = priority === "low" ? "low-priority diff" : "diff";
198
- return total > 1
199
- ? `Reading ${subject} (part ${position + 1}/${total})`
200
- : `Reading ${subject}`;
201
- }
202
-
203
- /**
204
- * Stage 1 for one partition: split it into chunks and summarise each, via a
205
- * work queue so an oversized chunk can be re-split and retried in place.
206
- * The estimate is calibrated, but only the backend knows the true token
207
- * count; its "prompt is too long" rejection is free, so treat it as the
208
- * final arbiter: halve the budget, re-split just that chunk, and continue
209
- * where we left off. Returns no summaries for an empty partition.
210
- */
211
- async function summarizePartition(
212
- diff: string,
213
- priority: ChangePriority,
214
- options: PartitionSummaryOptions,
215
- ): Promise<{ summaries: DiffSummary[]; costUsd: number }> {
216
- const { config, runner, progress, contexts, abortController } = options;
217
-
218
- // The configured chunk budget is clamped to the summary model's context
219
- // window so a single chunk (plus prompt scaffolding and response headroom)
220
- // can never overflow it, whatever `maxChunkTokens` says. For an Ollama
221
- // model that window is resolved here first - possibly by asking the
222
- // server - so the chunks and the request agree on the same number.
223
- // Chunks are sized by a content-classified token estimate: opaque content
224
- // (age/gpg armor, binary patches) measures near 1 char/token, so a plain
225
- // chars-based budget underestimates armor-heavy diffs more than threefold.
226
- const ollama = await contexts.settingsFor(config.models.summary);
227
- const chunkTokens = clampChunkTokens(
228
- config.models.summary,
229
- config.maxChunkTokens,
230
- typeof ollama?.context === "number" ? ollama.context : undefined,
231
- );
232
- const chunks = splitDiffToFit(diff, chunkTokens, config.charsPerToken);
233
-
234
- const summarySystem = buildSummarySystem(priority);
235
- const summaries: DiffSummary[] = [];
236
- let costUsd = 0;
237
-
238
- const queue = chunks.map((chunk) => ({ chunk, tokenBudget: chunkTokens }));
239
- while (queue.length > 0) {
240
- const task = queue.shift()!;
241
- const position = summaries.length;
242
- const total = summaries.length + queue.length + 1;
243
- progress.onPhase?.(readingLabel(priority, position, total));
244
- try {
245
- const result = await runner(
246
- buildSummaryUser(task.chunk, position, total, priority),
247
- {
248
- model: config.models.summary,
249
- system: summarySystem,
250
- allowApiKey: config.allowApiKey,
251
- ...(ollama ? { ollama } : {}),
252
- ...(abortController ? { abortController } : {}),
253
- },
254
- );
255
- summaries.push({ priority, text: result.text });
256
- costUsd += result.costUsd;
257
- } catch (error) {
258
- const halvedBudget = Math.floor(task.tokenBudget / 2);
259
- if (
260
- !isPromptTooLongError(error) ||
261
- halvedBudget < MIN_RETRY_CHUNK_TOKENS
262
- ) {
263
- throw error;
264
- }
265
- const pieces = splitDiffToFit(
266
- task.chunk,
267
- halvedBudget,
268
- config.charsPerToken,
269
- );
270
- if (pieces.length === 1 && pieces[0] === task.chunk) {
271
- // Nothing left to split on (a single oversized hunk): retrying the
272
- // identical request would loop forever, so surface the error.
273
- throw error;
274
- }
275
- queue.unshift(
276
- ...pieces.map((chunk) => ({ chunk, tokenBudget: halvedBudget })),
277
- );
278
- }
279
- }
280
-
281
- return { summaries, costUsd };
282
- }
283
-
284
- /** Run the full pipeline over a staged diff. */
285
- export async function generateCommit(
286
- diff: string,
287
- config: Config,
288
- options: GenerateOptions = {},
289
- ): Promise<GenerateResult> {
290
- const {
291
- count = 1,
292
- progress = {},
293
- abortController,
294
- runner = runPrompt,
295
- resolveOllamaContext: resolveContext = resolveOllamaContext,
296
- } = options;
297
- const contexts = new OllamaContextResolver(
298
- config.ollama,
299
- resolveContext,
300
- abortController?.signal,
301
- );
302
-
303
- // Ignore first: dropped sections cost nothing downstream. Unlike a
304
- // low-priority partition, an ignored one has nowhere to be promoted to,
305
- // so matching every file is a dead end rather than a special case.
306
- const ignoreResult = applyIgnorePatterns(
307
- diff,
308
- createPathMatcher(config.ignore),
309
- );
310
- const ignored: IgnoreStats = {
311
- ignoredFiles: ignoreResult.ignoredFiles,
312
- totalFiles: ignoreResult.totalFiles,
313
- };
314
- if (ignoreResult.diff.trim() === "" && ignoreResult.ignoredFiles > 0) {
315
- throw new ClaudeCommitError(describeFullyIgnored(ignored));
316
- }
317
-
318
- const effectiveDiff =
319
- config.skipArmored && !config.filenamesOnly
320
- ? redactOpaqueRuns(ignoreResult.diff)
321
- : ignoreResult.diff;
322
- const partition = partitionDiff(
323
- effectiveDiff,
324
- createPathMatcher(config.lowPriorityPaths),
325
- );
326
- if (partition.primary.trim() === "") {
327
- throw new ClaudeCommitError("There are no staged changes to summarize.");
328
- }
329
-
330
- const filenames = config.filenamesOnly
331
- ? {
332
- primary: diffPaths(partition.primary),
333
- lowPriority: diffPaths(partition.lowPriority),
334
- }
335
- : undefined;
336
- const summaries: DiffSummary[] = [];
337
- let costUsd = 0;
338
- if (filenames) {
339
- if (filenames.primary.length + filenames.lowPriority.length === 0) {
340
- throw new ClaudeCommitError("There are no staged filenames to describe.");
341
- }
342
- } else {
343
- // Stage 1: primary first, then low priority. filenamesOnly bypasses
344
- // chunking, summary calls and even the summary model's context probe.
345
- const partitionOptions: PartitionSummaryOptions = {
346
- config,
347
- runner,
348
- progress,
349
- contexts,
350
- ...(abortController ? { abortController } : {}),
351
- };
352
- const primaryStage = await summarizePartition(
353
- partition.primary,
354
- "primary",
355
- partitionOptions,
356
- );
357
- const lowPriorityStage =
358
- partition.lowPriority.trim() === ""
359
- ? { summaries: [], costUsd: 0 }
360
- : await summarizePartition(
361
- partition.lowPriority,
362
- "low",
363
- partitionOptions,
364
- );
365
- summaries.push(...primaryStage.summaries, ...lowPriorityStage.summaries);
366
- if (summaries.length === 0) {
367
- throw new ClaudeCommitError("There are no staged changes to summarize.");
368
- }
369
- costUsd = primaryStage.costUsd + lowPriorityStage.costUsd;
370
- }
371
- const hasLowPriority = filenames
372
- ? filenames.primary.length > 0 && filenames.lowPriority.length > 0
373
- : hasLowPrioritySummaries(summaries);
374
-
375
- // Final stage: write the commit message(s) from summaries or filenames.
376
- //
377
- // Prefer a structured (JSON-schema) response so parsing is robust regardless
378
- // of how the model formats its prose. We try, in order: structured output
379
- // with a temperature bump (for interactive variety), then structured output
380
- // without it (for models that reject a temperature override), then plain text
381
- // with delimiter parsing (for models that don't support structured output at
382
- // all). Whichever succeeds first wins.
383
- progress.onPhase?.(
384
- count > 1 ? "Writing commit options" : "Writing commit message",
385
- );
386
-
387
- const finalOllama = await contexts.settingsFor(config.models.final);
388
- const baseOpts = {
389
- model: config.models.final,
390
- allowApiKey: config.allowApiKey,
391
- ...(finalOllama ? { ollama: finalOllama } : {}),
392
- ...(abortController ? { abortController } : {}),
393
- };
394
- const temperature =
395
- count > 1 && config.interactiveTemperature != null
396
- ? config.interactiveTemperature
397
- : undefined;
398
-
399
- const attempts: Array<{ structured: boolean; temperature?: number }> = [];
400
- if (temperature != null) attempts.push({ structured: true, temperature });
401
- attempts.push({ structured: true });
402
- attempts.push({ structured: false });
403
-
404
- let messages: string[] | null = null;
405
- let lastError: unknown;
406
- for (const attempt of attempts) {
407
- try {
408
- const result = await runner(
409
- filenames
410
- ? buildFilenamesUser(filenames, count, attempt.structured)
411
- : buildFinalUser(summaries, count, attempt.structured),
412
- {
413
- ...baseOpts,
414
- system: buildFinalSystem(config, attempt.structured, hasLowPriority),
415
- ...(attempt.structured
416
- ? {
417
- outputFormat: {
418
- type: "json_schema" as const,
419
- schema: MESSAGES_SCHEMA,
420
- },
421
- }
422
- : {}),
423
- ...(attempt.temperature != null
424
- ? { temperature: attempt.temperature }
425
- : {}),
426
- ...(!attempt.structured && progress.onText
427
- ? { onText: progress.onText }
428
- : {}),
429
- },
430
- );
431
- costUsd += result.costUsd;
432
- messages = attempt.structured
433
- ? extractMessages(result.structured)
434
- : count > 1
435
- ? parseOptions(result.text)
436
- : [result.text];
437
- if (messages && messages.length > 0) break;
438
- } catch (err) {
439
- lastError = err;
440
- // If the run was cancelled, stop retrying: the shared abort signal would
441
- // make every remaining attempt fail immediately in the same way.
442
- if (abortController?.signal.aborted) break;
443
- }
444
- }
445
-
446
- const cleaned = (messages ?? [])
447
- .map(cleanMessage)
448
- .filter((message) => message.length > 0);
449
- const deduped = dedupe(cleaned);
450
- if (deduped.length === 0) {
451
- if (lastError instanceof ClaudeCommitError) throw lastError;
452
- throw new ClaudeCommitError("The model did not produce a commit message.");
453
- }
454
-
455
- // Report chunks actually processed: overflow retries can split further
456
- // than the initial estimate planned.
457
- return {
458
- messages: deduped,
459
- summaries,
460
- chunkCount: summaries.length,
461
- costUsd,
462
- lowPriority: {
463
- matchedFiles: partition.matchedFiles,
464
- totalFiles: partition.totalFiles,
465
- promoted: partition.promoted,
466
- },
467
- ignored,
468
- ollamaContexts: contexts.resolved,
469
- };
470
- }
471
-
472
- /**
473
- * The error for a commit whose every changed file matched `ignore`.
474
- *
475
- * There is no sensible fallback here. Describing the ignored files anyway
476
- * would contradict the directive the user wrote; committing an empty or
477
- * invented message would be worse. Naming the directive and the count makes
478
- * the cause obvious, because the alternative - a run that mysteriously
479
- * reports no staged changes when `git status` plainly disagrees - is the
480
- * kind of bug people spend an afternoon on.
481
- */
482
- export function describeFullyIgnored(stats: IgnoreStats): string {
483
- const files = `${stats.ignoredFiles} staged file${stats.ignoredFiles === 1 ? "" : "s"}`;
484
- return (
485
- `Every one of the ${files} matches an "ignore" pattern, so there is ` +
486
- `nothing left to describe. Narrow the patterns, or pass --no-ignore to ` +
487
- `write a message about these changes for this commit.`
488
- );
489
- }
490
-
491
- function dedupe(items: string[]): string[] {
492
- const seen = new Set<string>();
493
- const out: string[] = [];
494
- for (const item of items) {
495
- if (!seen.has(item)) {
496
- seen.add(item);
497
- out.push(item);
498
- }
499
- }
500
- return out;
501
- }
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
- }