@hifullmoon/aicommit 1.4.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.
Files changed (57) hide show
  1. package/.aicommit.config.example.json +118 -0
  2. package/CHANGELOG.md +131 -0
  3. package/LICENSE +21 -0
  4. package/README.md +406 -0
  5. package/README.zh-CN.md +408 -0
  6. package/SECURITY.md +33 -0
  7. package/bin/aicommit.js +47 -0
  8. package/docs/distribution.md +104 -0
  9. package/docs/examples/aicommit-policy.yml +24 -0
  10. package/docs/examples/commit-msg +4 -0
  11. package/docs/examples/extension/aicommit-extension.json +9 -0
  12. package/docs/examples/extension/index.mjs +32 -0
  13. package/docs/extensions.md +93 -0
  14. package/docs/privacy.md +58 -0
  15. package/docs/provider-compatibility.md +37 -0
  16. package/docs/provider-presets.md +100 -0
  17. package/docs/team-policy.md +86 -0
  18. package/docs/troubleshooting.md +37 -0
  19. package/package.json +100 -0
  20. package/presets/provider-presets.json +60 -0
  21. package/schemas/aicommit-extension.schema.json +27 -0
  22. package/schemas/aicommit-output.schema.json +86 -0
  23. package/schemas/aicommit-provider-presets.schema.json +56 -0
  24. package/schemas/aicommit-split-checkpoint.schema.json +92 -0
  25. package/schemas/aicommit-split-plan.schema.json +108 -0
  26. package/schemas/aicommit-team-policy.schema.json +59 -0
  27. package/src/api.js +740 -0
  28. package/src/cli.js +558 -0
  29. package/src/completion.js +136 -0
  30. package/src/config-command.js +59 -0
  31. package/src/config.js +524 -0
  32. package/src/context.js +537 -0
  33. package/src/credentials.js +123 -0
  34. package/src/doctor.js +131 -0
  35. package/src/errors.js +96 -0
  36. package/src/extension-runner.mjs +36 -0
  37. package/src/extensions.js +426 -0
  38. package/src/generation-ui.js +53 -0
  39. package/src/git.js +458 -0
  40. package/src/main.js +768 -0
  41. package/src/metrics.js +375 -0
  42. package/src/output.js +87 -0
  43. package/src/policy-command.js +172 -0
  44. package/src/policy.js +421 -0
  45. package/src/preset-command.js +91 -0
  46. package/src/provider-presets.js +361 -0
  47. package/src/providers.js +306 -0
  48. package/src/setup.js +268 -0
  49. package/src/split-checkpoint.js +252 -0
  50. package/src/split-hunks.js +263 -0
  51. package/src/split-plan.js +339 -0
  52. package/src/split.js +2285 -0
  53. package/src/team-policy.js +95 -0
  54. package/src/trust.js +31 -0
  55. package/src/ui.js +488 -0
  56. package/src/utils.js +165 -0
  57. package/templates/.aicommit.policy.json +22 -0
package/src/main.js ADDED
@@ -0,0 +1,768 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+
4
+ import chalk from 'chalk';
5
+
6
+ import { parseArgs } from './cli.js';
7
+ import { getProjectRoot, loadConfig } from './config.js';
8
+ import {
9
+ getStagedDiff,
10
+ getChangedFiles,
11
+ getDiffStats,
12
+ getBranch,
13
+ gitCommit,
14
+ stripLockFileContent,
15
+ condenseDiff,
16
+ getDiffStat,
17
+ getUntrackedFiles,
18
+ getUnstagedFiles,
19
+ runGit,
20
+ isGitRepo,
21
+ getIndexFingerprint,
22
+ createIndexTransaction,
23
+ protectSensitiveDiff,
24
+ } from './git.js';
25
+ import { generateCommitMessage } from './api.js';
26
+ import {
27
+ statusColor,
28
+ statusIcon,
29
+ confirmAction,
30
+ editMessage,
31
+ vimSelect,
32
+ vimCheckbox,
33
+ } from './ui.js';
34
+ import {
35
+ formatMs,
36
+ maskApiKey,
37
+ formatUsage,
38
+ indentError,
39
+ sanitizeTerminalText,
40
+ stringifyConfigRedacted,
41
+ redactSensitiveUrl,
42
+ } from './utils.js';
43
+ import { abortSplit, applySplitPlan, resumeSplit, splitFlow } from './split.js';
44
+ import { runModelTask } from './generation-ui.js';
45
+ import { runSetup } from './setup.js';
46
+ import { detectProviderType } from './providers.js';
47
+ import { configureExtensionHost } from './extensions.js';
48
+ import { ERROR_CATEGORIES, fail } from './errors.js';
49
+ import { runDoctor } from './doctor.js';
50
+ import { configureMetrics, runStatsCommand } from './metrics.js';
51
+ import { runConfigCommand } from './config-command.js';
52
+ import { generateCompletion } from './completion.js';
53
+ import { runPolicyCommand } from './policy-command.js';
54
+ import { runPresetCommand } from './preset-command.js';
55
+ import {
56
+ applyCommitlintPolicy,
57
+ collectRepositoryContext,
58
+ repositoryContextSummary,
59
+ } from './context.js';
60
+
61
+ function withUntrackedFiles(changes, untracked) {
62
+ return [...changes, ...untracked.map((path) => ({ status: '?', path, addPaths: [path] }))];
63
+ }
64
+
65
+ function printChangeList(changes) {
66
+ for (const { status, path } of changes) {
67
+ const code = status.charAt(0);
68
+ const c = statusColor[code] || chalk.dim;
69
+ const icon = statusIcon[code] || code;
70
+ console.log(` ${c(' ' + icon)} ${c(sanitizeTerminalText(path))}`);
71
+ }
72
+ }
73
+
74
+ async function pickPathsToStage(unstaged, untracked, message) {
75
+ const picked = await vimCheckbox({
76
+ message,
77
+ choices: withUntrackedFiles(unstaged, untracked).map(({ status, path, addPaths }) => ({
78
+ name: `${statusIcon[status.charAt(0)] || status.charAt(0)} ${sanitizeTerminalText(path)}`,
79
+ value: addPaths,
80
+ })),
81
+ });
82
+ return picked.flat();
83
+ }
84
+
85
+ export async function main() {
86
+ // ── CLI arguments ───────────────────────────────────────────────────
87
+
88
+ const {
89
+ targetPath,
90
+ cliLang,
91
+ cliProvider,
92
+ cliReasoning,
93
+ output,
94
+ debug,
95
+ split,
96
+ splitHunks,
97
+ splitCommand,
98
+ splitPlanFile,
99
+ dryRun,
100
+ yes,
101
+ setup,
102
+ doctor,
103
+ configAction,
104
+ policyAction,
105
+ policyMessageFile,
106
+ policyRange,
107
+ presetAction,
108
+ presetFile,
109
+ completionShell,
110
+ statsAction,
111
+ help,
112
+ version,
113
+ } = parseArgs();
114
+
115
+ if (help || version) return { exitReason: help ? 'help' : 'version' };
116
+ if (completionShell) {
117
+ process.stdout.write(generateCompletion(completionShell));
118
+ return { exitReason: 'completion' };
119
+ }
120
+ if (statsAction) {
121
+ await runStatsCommand(statsAction);
122
+ return { exitReason: `stats_${statsAction}` };
123
+ }
124
+
125
+ const machineOutput = output === 'json';
126
+ if (machineOutput && !yes && !doctor && !configAction && !policyAction && !presetAction) {
127
+ throw fail(ERROR_CATEGORIES.CONFIG, '--output=json requires --yes for commit and split flows.');
128
+ }
129
+
130
+ // The setup wizard is a standalone flow — no git repo, diff, or loaded
131
+ // config required.
132
+ if (setup) {
133
+ await runSetup();
134
+ return;
135
+ }
136
+
137
+ if (targetPath) {
138
+ const resolved = resolve(targetPath);
139
+ try {
140
+ await stat(resolved);
141
+ process.chdir(resolved);
142
+ } catch {
143
+ throw fail(ERROR_CATEGORIES.CONFIG, `'${targetPath}' is not a valid directory.`);
144
+ }
145
+ }
146
+
147
+ if (configAction) {
148
+ return runConfigCommand(configAction, {
149
+ provider: cliProvider,
150
+ machineOutput,
151
+ });
152
+ }
153
+
154
+ if (policyAction) {
155
+ return runPolicyCommand(policyAction, {
156
+ messageFile: policyMessageFile,
157
+ range: policyRange,
158
+ machineOutput,
159
+ });
160
+ }
161
+
162
+ if (presetAction) {
163
+ return runPresetCommand(presetAction, { file: presetFile, machineOutput });
164
+ }
165
+
166
+ // ── Banner ──────────────────────────────────────────────────────────
167
+
168
+ console.log('');
169
+ console.log(
170
+ ' ' + chalk.cyan.bold('⚡ aicommit ') + chalk.dim('AI-powered commit message generator'),
171
+ );
172
+ console.log(' ' + chalk.dim('─'.repeat(45)));
173
+ console.log(' ' + chalk.dim(`Working directory: ${sanitizeTerminalText(process.cwd())}`));
174
+
175
+ if (['apply', 'resume', 'abort'].includes(splitCommand)) {
176
+ const projectRoot = getProjectRoot();
177
+ if (!isGitRepo(projectRoot)) {
178
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `Not a git repository: ${process.cwd()}`);
179
+ }
180
+ if (splitCommand === 'resume') return resumeSplit(projectRoot, { yes, machineOutput });
181
+ if (splitCommand === 'abort') return abortSplit(projectRoot, { yes, machineOutput });
182
+ return applySplitPlan(projectRoot, splitPlanFile, { yes, machineOutput });
183
+ }
184
+
185
+ if (doctor) return runDoctor(cliProvider);
186
+
187
+ // ── 1. Config ───────────────────────────────────────────────────────
188
+
189
+ const { config, projectRoot, loaded, providerName, teamPolicyPath } =
190
+ await loadConfig(cliProvider);
191
+ const extensionHost = await configureExtensionHost(config);
192
+ const selectedProvider = providerName || detectProviderType(config.apiUrl, config.providerType);
193
+ const warnings = [];
194
+
195
+ if (loaded.length === 0) {
196
+ console.log(chalk.dim('\n No config files found — using defaults.'));
197
+ console.log(
198
+ chalk.dim(' Run ') +
199
+ chalk.bold('aicommit setup') +
200
+ chalk.dim(' to configure interactively.'),
201
+ );
202
+ } else {
203
+ const labels = loaded.map((l) => chalk.bold(l)).join(', ');
204
+ console.log('\n ' + chalk.green('✓') + chalk.dim(` Config loaded from: ${labels}`));
205
+ }
206
+
207
+ // ── CLI language override ─────────────────────────────────────────
208
+ // Apply the -l/--lang override before the summary lines below, so the
209
+ // printed language always reflects the one that will actually be used.
210
+ // Validate after applying the override, so a bad value is caught whether
211
+ // it came from the CLI or from a config file.
212
+
213
+ if (cliLang && teamPolicyPath) {
214
+ throw fail(
215
+ ERROR_CATEGORIES.CONFIG,
216
+ `--lang cannot override the repository team policy at ${teamPolicyPath}.`,
217
+ );
218
+ }
219
+ if (cliLang) config.language = cliLang;
220
+ if (config.language !== 'zh' && config.language !== 'en') {
221
+ throw fail(
222
+ ERROR_CATEGORIES.CONFIG,
223
+ `Invalid language: "${config.language}". Use "zh" or "en".`,
224
+ );
225
+ }
226
+ if (cliLang) {
227
+ config.commitPolicy = { ...config.commitPolicy, language: cliLang };
228
+ }
229
+
230
+ if (cliReasoning === 'off') {
231
+ config.reasoning = { ...config.reasoning, mode: 'off' };
232
+ } else if (cliReasoning) {
233
+ config.reasoning = { ...config.reasoning, mode: 'on', effort: cliReasoning };
234
+ }
235
+ const reasoningEnabled = config.reasoning.mode === 'on';
236
+
237
+ const viaCli = cliProvider ? chalk.dim(' (via CLI)') : '';
238
+ const modelLabel = providerName ? `${providerName} (${config.modelId})` : config.modelId;
239
+ console.log(
240
+ ' ' + chalk.green('✓') + chalk.dim(` Model: ${sanitizeTerminalText(modelLabel)}${viaCli}`),
241
+ );
242
+ console.log(
243
+ ' ' +
244
+ chalk.green('✓') +
245
+ chalk.dim(` Endpoint: ${sanitizeTerminalText(redactSensitiveUrl(config.apiUrl))}`),
246
+ );
247
+
248
+ const langLabel = config.language === 'zh' ? '中文' : 'English';
249
+ const langViaCli = cliLang ? chalk.dim(' (via CLI)') : '';
250
+ console.log(' ' + chalk.green('✓') + chalk.dim(` Language: ${langLabel}${langViaCli}`));
251
+ if (config.reasoning.mode === 'on') {
252
+ console.log(
253
+ ' ' +
254
+ chalk.green('✓') +
255
+ chalk.dim(` Reasoning: ${config.reasoning.effort}${cliReasoning ? ' (via CLI)' : ''}`),
256
+ );
257
+ } else if (config.reasoning.mode === 'off' && cliReasoning) {
258
+ console.log(' ' + chalk.green('✓') + chalk.dim(' Reasoning: off (via CLI)'));
259
+ }
260
+
261
+ configureMetrics(config.metrics);
262
+
263
+ // An empty apiKey is valid for local, keyless endpoints (Ollama, LM Studio,
264
+ // LiteLLM); the request layer omits the Authorization header for them, so a
265
+ // missing key must not abort the run here.
266
+
267
+ // ── Debug output ─────────────────────────────────────────────────────
268
+
269
+ if (debug) {
270
+ console.log('');
271
+ console.log(' ' + chalk.magenta.bold('🐛 Debug info'));
272
+ console.log(' ' + chalk.dim('─'.repeat(45)));
273
+ console.log(chalk.dim(` argv: ${process.argv.slice(2).join(' ') || '(none)'}`));
274
+ console.log(chalk.dim(` targetPath: ${targetPath || '(not set)'}`));
275
+ console.log(chalk.dim(` cwd: ${process.cwd()}`));
276
+ console.log(chalk.dim(` projectRoot: ${projectRoot}`));
277
+ console.log(chalk.dim(` config files: ${loaded.join(', ') || '(none — defaults only)'}`));
278
+ console.log(chalk.dim(` cliLang: ${cliLang || '(not set)'}`));
279
+ console.log(chalk.dim(` cliProvider: ${cliProvider || '(not set)'}`));
280
+ console.log(
281
+ chalk.dim(
282
+ ` reasoning: ${config.reasoning.mode === 'on' ? config.reasoning.effort : config.reasoning.mode}${cliReasoning ? ' (via CLI)' : ''}`,
283
+ ),
284
+ );
285
+ console.log(chalk.dim(` providerName: ${providerName || '(not set)'}`));
286
+ console.log(chalk.dim(` split: ${split}`));
287
+ console.log(chalk.dim(` dryRun: ${dryRun}`));
288
+ console.log(chalk.dim(` yes: ${yes}`));
289
+ console.log(chalk.dim(' final config:'));
290
+ for (const [key, value] of Object.entries(config)) {
291
+ const display =
292
+ key === 'apiKey'
293
+ ? maskApiKey(value)
294
+ : key === 'apiUrl'
295
+ ? redactSensitiveUrl(value)
296
+ : stringifyConfigRedacted(value);
297
+ const truncated = display.length > 100 ? display.slice(0, 100) + '…' : display;
298
+ console.log(chalk.dim(` ${key}: ${truncated}`));
299
+ }
300
+ }
301
+
302
+ // ── 2. Diff ─────────────────────────────────────────────────────────
303
+
304
+ // All git helpers swallow errors and return empty output, so without this
305
+ // guard a non-repo directory would be misreported as "no changes to commit".
306
+ if (!isGitRepo(projectRoot)) {
307
+ console.log('\n ' + chalk.red(`✗ Not a git repository: ${process.cwd()}`));
308
+ console.log(chalk.dim(' Run aicommit inside a git working tree.\n'));
309
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `Not a git repository: ${process.cwd()}`, {
310
+ reported: true,
311
+ });
312
+ }
313
+
314
+ if (split) {
315
+ const handled = await splitFlow(config, projectRoot, {
316
+ scope: split,
317
+ dryRun,
318
+ yes,
319
+ machineOutput,
320
+ provider: selectedProvider,
321
+ exportPlanPath: splitCommand === 'plan' ? splitPlanFile : null,
322
+ splitHunks,
323
+ });
324
+ if (handled) return handled === true ? { exitReason: 'success' } : handled;
325
+ // Only one changed file — continue with the normal single-commit flow.
326
+ }
327
+
328
+ // Only staged changes are considered — the diff the model sees covers
329
+ // exactly what `git commit` will commit. When nothing is staged but the
330
+ // working tree has changes, the user is offered to stage all/some files
331
+ // interactively instead of being sent away to run `git add` manually.
332
+ // All git commands run at the repo root (projectRoot), even when aicommit
333
+ // is invoked from a subdirectory.
334
+ let indexTransaction = null;
335
+ const finishCancelled = ({
336
+ notice = 'Commit cancelled.',
337
+ message = null,
338
+ latencyMs = null,
339
+ usage = null,
340
+ edited = false,
341
+ rewrites = 0,
342
+ } = {}) => {
343
+ const restored = indexTransaction ? indexTransaction.restore() : true;
344
+ indexTransaction = null;
345
+ if (!restored) {
346
+ warnings.push('The Git index changed during the run and was left untouched.');
347
+ console.log(
348
+ ' ' + chalk.yellow('⚠ The Git index changed during the run and was left untouched.'),
349
+ );
350
+ }
351
+ if (notice) console.log(chalk.dim(`\n ${notice}\n`));
352
+ return {
353
+ message,
354
+ provider: selectedProvider,
355
+ model: config.modelId,
356
+ latencyMs,
357
+ usage,
358
+ warnings,
359
+ exitReason: 'cancelled',
360
+ committed: false,
361
+ edited,
362
+ rewrites,
363
+ };
364
+ };
365
+ const beginIndexTransaction = () => {
366
+ indexTransaction ||= createIndexTransaction(projectRoot);
367
+ return indexTransaction;
368
+ };
369
+ let diff = getStagedDiff(projectRoot, config.diffContextLines);
370
+ if (!diff) {
371
+ // Nothing staged. But git diff --staged is also empty for unstaged work
372
+ // and untracked files — surface what git status actually shows instead of
373
+ // falsely claiming there's nothing to commit.
374
+ const unstaged = getUnstagedFiles(projectRoot);
375
+ const untracked = getUntrackedFiles(projectRoot);
376
+
377
+ const tips = [];
378
+ if (unstaged.length) tips.push(`${unstaged.length} unstaged file(s)`);
379
+ if (untracked.length) tips.push(`${untracked.length} untracked file(s)`);
380
+
381
+ if (!tips.length) {
382
+ console.log('\n ' + chalk.yellow('✗ No changes to commit.\n'));
383
+ throw fail(ERROR_CATEGORIES.GIT_STATE, 'No changes to commit.', { reported: true });
384
+ }
385
+
386
+ console.log('\n ' + chalk.yellow(`✗ No staged changes — ${tips.join(', ')}.`));
387
+ printChangeList(withUntrackedFiles(unstaged, untracked));
388
+ console.log('');
389
+
390
+ if (yes && !dryRun) {
391
+ console.log(chalk.red(' ✗ --yes requires changes to be staged explicitly.'));
392
+ console.log(
393
+ chalk.dim(' Stage the intended files with git add, then run aicommit --yes again.\n'),
394
+ );
395
+ throw fail(ERROR_CATEGORIES.GIT_STATE, '--yes requires explicitly staged changes.', {
396
+ reported: true,
397
+ });
398
+ }
399
+
400
+ // Offer to stage instead of exiting — the user can stage everything or
401
+ // pick files one by one.
402
+ const stageAction = yes
403
+ ? 'all'
404
+ : await vimSelect({
405
+ message: 'No staged changes. How would you like to stage files?',
406
+ choices: [
407
+ {
408
+ name: 'Stage all changes',
409
+ value: 'all',
410
+ description: 'Run git add -A (unstaged + untracked)',
411
+ },
412
+ {
413
+ name: 'Select files to stage',
414
+ value: 'pick',
415
+ description: 'Choose files individually',
416
+ },
417
+ { name: 'Cancel', value: 'cancel', description: 'Exit without staging' },
418
+ ],
419
+ });
420
+
421
+ if (stageAction === 'cancel') {
422
+ return finishCancelled({
423
+ notice: 'Cancelled — stage files with git add and run aicommit again.',
424
+ });
425
+ }
426
+
427
+ let toStage = null; // null → stage everything
428
+ if (stageAction === 'pick') {
429
+ toStage = await pickPathsToStage(
430
+ unstaged,
431
+ untracked,
432
+ 'Select files to stage (space to select, enter to confirm)',
433
+ );
434
+ if (!toStage.length) {
435
+ return finishCancelled({ notice: 'No files selected — nothing staged.' });
436
+ }
437
+ }
438
+
439
+ try {
440
+ beginIndexTransaction();
441
+ runGit(toStage ? ['add', '--', ...toStage] : ['add', '-A'], projectRoot);
442
+ indexTransaction.markOwned();
443
+ } catch (err) {
444
+ indexTransaction?.restore({ force: true });
445
+ indexTransaction = null;
446
+ console.log(
447
+ '\n ' + chalk.red(`✗ Failed to stage files: ${sanitizeTerminalText(err.message)}\n`),
448
+ );
449
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `Failed to stage files: ${err.message}`, {
450
+ cause: err,
451
+ reported: true,
452
+ });
453
+ }
454
+
455
+ diff = getStagedDiff(projectRoot, config.diffContextLines);
456
+ if (!diff) {
457
+ console.log('\n ' + chalk.yellow('✗ Nothing staged — no diff to commit.\n'));
458
+ throw fail(ERROR_CATEGORIES.GIT_STATE, 'Nothing staged; no diff to commit.', {
459
+ reported: true,
460
+ });
461
+ }
462
+ }
463
+
464
+ // Re-read the final diff between two complete-index fingerprints so the
465
+ // prompt is guaranteed to describe one stable staged snapshot.
466
+ const plannedIndexFingerprint = getIndexFingerprint(projectRoot);
467
+ diff = getStagedDiff(projectRoot, config.diffContextLines);
468
+ if (getIndexFingerprint(projectRoot) !== plannedIndexFingerprint) {
469
+ console.log(
470
+ '\n ' + chalk.red('✗ The staged changes are being modified concurrently; commit aborted.\n'),
471
+ );
472
+ throw fail(
473
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
474
+ 'The staged changes are being modified concurrently; commit aborted.',
475
+ { reported: true },
476
+ );
477
+ }
478
+
479
+ const stats = getDiffStats(diff);
480
+ const changedFiles = getChangedFiles(projectRoot);
481
+ const branch = getBranch(projectRoot);
482
+ const stageIcon = chalk.green('staged');
483
+ const changeStr = chalk.green(`+${stats.additions}`) + ' ' + chalk.red(`-${stats.deletions}`);
484
+
485
+ let statLine =
486
+ chalk.dim(' ') + `✓ ${chalk.bold(stats.files)} files (${stageIcon}) ${changeStr}`;
487
+ if (branch) statLine += chalk.dim(` on ${sanitizeTerminalText(branch)}`);
488
+ console.log(statLine);
489
+
490
+ printChangeList(changedFiles);
491
+
492
+ const contextReport = collectRepositoryContext(
493
+ projectRoot,
494
+ changedFiles,
495
+ config.repositoryContext,
496
+ );
497
+ config.commitPolicy = applyCommitlintPolicy(
498
+ config.commitPolicy,
499
+ contextReport.constraints,
500
+ config.language,
501
+ );
502
+ const extensionContext = await extensionHost.collectContext({ files: changedFiles, branch });
503
+ config.repositoryContextText = [contextReport.text, extensionContext.text]
504
+ .filter(Boolean)
505
+ .join('\n\n');
506
+ warnings.push(...contextReport.warnings);
507
+ warnings.push(...extensionContext.warnings);
508
+ console.log(
509
+ ' ' + chalk.dim(`Context: ${sanitizeTerminalText(repositoryContextSummary(contextReport))}`),
510
+ );
511
+
512
+ // Protect common secrets before any repository content leaves the machine.
513
+ // The protected diff affects only the model request, never the actual index.
514
+ const protectedInput = protectSensitiveDiff(diff);
515
+ let diffForModel = diff;
516
+ if (protectedInput.findings.length) {
517
+ warnings.push('Sensitive data was detected and protected before the provider request.');
518
+ console.log('\n ' + chalk.yellow.bold('⚠ Potential sensitive data detected:'));
519
+ for (const finding of protectedInput.findings) {
520
+ console.log(' ' + chalk.yellow(sanitizeTerminalText(finding)));
521
+ }
522
+ const sensitiveAction = yes
523
+ ? 'protect'
524
+ : await vimSelect({
525
+ message: 'How should aicommit handle the model request?',
526
+ choices: [
527
+ {
528
+ name: 'Send protected diff',
529
+ value: 'protect',
530
+ description:
531
+ 'Omit sensitive files/private keys and redact detected credential values',
532
+ },
533
+ { name: 'Cancel', value: 'cancel', description: 'Do not send repository content' },
534
+ {
535
+ name: 'Send original diff',
536
+ value: 'original',
537
+ description: 'Send the unredacted content to the configured provider',
538
+ },
539
+ ],
540
+ });
541
+ if (sensitiveAction === 'cancel') {
542
+ return finishCancelled();
543
+ }
544
+ if (sensitiveAction === 'protect') diffForModel = protectedInput.diff;
545
+ }
546
+
547
+ // Prepare the diff the model sees (computed once — it doesn't change across
548
+ // regenerations): lock-file and stripFiles contents are stubbed (they carry
549
+ // no commit intent) and oversized diffs are condensed to a --stat summary
550
+ // plus truncated hunks, so token spend stays proportional to what the
551
+ // model needs.
552
+ const strippedDiff = stripLockFileContent(diffForModel, config.stripFiles);
553
+ const { diff: modelDiff, truncated } = condenseDiff(
554
+ strippedDiff,
555
+ config.maxDiffChars,
556
+ getDiffStat(projectRoot),
557
+ config.maxFileDiffChars,
558
+ );
559
+ if (truncated) {
560
+ warnings.push('The diff was condensed to fit the configured provider input limit.');
561
+ console.log(
562
+ chalk.dim(
563
+ ` (diff condensed for the model — ${strippedDiff.length} → ${modelDiff.length} chars)`,
564
+ ),
565
+ );
566
+ }
567
+ // ── 3. AI call + confirm (with regenerate loop) ────────────────────
568
+
569
+ let message, elapsed, usage, reasoningText, qualityWarnings, corrections;
570
+ let regenerateCount = 0;
571
+ let automaticCorrectionCount = 0;
572
+ let wasEdited = false;
573
+
574
+ while (true) {
575
+ try {
576
+ // Pass the previous message so a regenerate can ask for a rewording
577
+ // instead of re-sending the full diff (see generateCommitMessage).
578
+ ({
579
+ message,
580
+ elapsed,
581
+ usage,
582
+ reasoning: reasoningText,
583
+ qualityWarnings,
584
+ corrections,
585
+ } = await runModelTask({
586
+ spinnerText: `Calling ${chalk.bold(sanitizeTerminalText(config.modelId))} ...`,
587
+ reasoning: config.reasoning,
588
+ machineOutput,
589
+ cancelMessage: 'Commit cancelled.',
590
+ failureMessage: 'API call failed',
591
+ task: (stream) =>
592
+ generateCommitMessage(config, modelDiff, regenerateCount, message, stream),
593
+ successMessage(result) {
594
+ let done = `Generated in ${chalk.bold(formatMs(result.elapsed))}`;
595
+ if (result.usage) done += chalk.dim(` · tokens: ${formatUsage(result.usage)}`);
596
+ return done;
597
+ },
598
+ }));
599
+ automaticCorrectionCount += corrections || 0;
600
+ for (const warning of qualityWarnings || []) {
601
+ if (!warnings.includes(warning)) warnings.push(warning);
602
+ console.log(' ' + chalk.yellow(`⚠ Quality check: ${sanitizeTerminalText(warning)}`));
603
+ }
604
+ } catch (err) {
605
+ console.log(`\n ${indentError(err)}\n`);
606
+ // A transient API failure shouldn't kill the session — let the user
607
+ // retry, fall back to the previous message (if any), or cancel.
608
+ if (yes) throw err;
609
+ const choice = await vimSelect({
610
+ message: 'The API call failed. What would you like to do?',
611
+ choices: [
612
+ ...(message ? [{ name: 'Keep the previous message', value: 'keep' }] : []),
613
+ { name: 'Try again', value: 'retry' },
614
+ { name: 'Cancel', value: 'cancel' },
615
+ ],
616
+ });
617
+ if (choice === 'keep') break; // commit with the previous good message
618
+ if (choice === 'retry') continue;
619
+ return finishCancelled({
620
+ message,
621
+ latencyMs: elapsed,
622
+ usage,
623
+ edited: wasEdited,
624
+ rewrites: regenerateCount + automaticCorrectionCount,
625
+ });
626
+ }
627
+
628
+ if (!message) {
629
+ console.log('\n ' + chalk.red('✗ Empty response from AI.\n'));
630
+ throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Empty response from AI.', {
631
+ reported: true,
632
+ });
633
+ }
634
+
635
+ // ── 4. User action ─────────────────────────────────────────────────
636
+
637
+ const action = yes
638
+ ? 'use'
639
+ : await confirmAction(
640
+ message,
641
+ reasoningEnabled
642
+ ? {
643
+ text: reasoningText,
644
+ maxChars: config.reasoning.maxDisplayChars,
645
+ }
646
+ : null,
647
+ );
648
+
649
+ if (action === 'use') {
650
+ break; // proceed to commit
651
+ }
652
+
653
+ if (action === 'edit') {
654
+ const edited = await editMessage(message);
655
+ if (edited) {
656
+ message = edited;
657
+ wasEdited = true;
658
+ break; // proceed to commit with edited message
659
+ }
660
+ // User cancelled during edit — exit
661
+ return finishCancelled({
662
+ message,
663
+ latencyMs: elapsed,
664
+ usage,
665
+ edited: wasEdited,
666
+ rewrites: regenerateCount + automaticCorrectionCount,
667
+ });
668
+ }
669
+
670
+ if (action === 'regenerate') {
671
+ regenerateCount++;
672
+ console.log(chalk.dim(`\n ↻ Regenerating (attempt #${regenerateCount + 1})...`));
673
+ continue;
674
+ }
675
+
676
+ // action === 'cancel'
677
+ return finishCancelled({
678
+ message,
679
+ latencyMs: elapsed,
680
+ usage,
681
+ edited: wasEdited,
682
+ rewrites: regenerateCount + automaticCorrectionCount,
683
+ });
684
+ }
685
+
686
+ // ── 5. Commit ────────────────────────────────────────────────────────
687
+
688
+ console.log('');
689
+
690
+ if (dryRun) {
691
+ const restored = indexTransaction ? indexTransaction.restore() : true;
692
+ indexTransaction = null;
693
+ if (!restored) {
694
+ warnings.push('The Git index changed during the run and was left untouched.');
695
+ console.log(
696
+ ' ' + chalk.yellow('⚠ The Git index changed during the run and was left untouched.'),
697
+ );
698
+ }
699
+ console.log(
700
+ ' ' +
701
+ chalk.green.bold(
702
+ '✓ Dry run complete — no commit was created and tool-owned staging was restored.\n',
703
+ ),
704
+ );
705
+ return {
706
+ message,
707
+ provider: selectedProvider,
708
+ model: config.modelId,
709
+ latencyMs: elapsed,
710
+ usage,
711
+ warnings,
712
+ exitReason: 'dry_run',
713
+ committed: false,
714
+ edited: wasEdited,
715
+ rewrites: regenerateCount + automaticCorrectionCount,
716
+ };
717
+ }
718
+
719
+ if (getIndexFingerprint(projectRoot) !== plannedIndexFingerprint) {
720
+ const restored = indexTransaction ? indexTransaction.restore() : false;
721
+ indexTransaction = null;
722
+ console.log(
723
+ ' ' + chalk.red('✗ The staged changes changed after message generation; commit aborted.'),
724
+ );
725
+ if (restored)
726
+ console.log(chalk.dim(' Tool-owned staging was restored to its original state.'));
727
+ else
728
+ console.log(
729
+ chalk.dim(' The current index was left untouched to avoid overwriting concurrent work.'),
730
+ );
731
+ console.log(chalk.dim(' Review the changes and run aicommit again.\n'));
732
+ throw fail(
733
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
734
+ 'The staged changes changed after message generation; commit aborted.',
735
+ { reported: true },
736
+ );
737
+ }
738
+
739
+ try {
740
+ gitCommit(message, projectRoot, machineOutput);
741
+ indexTransaction?.release();
742
+ indexTransaction = null;
743
+ console.log('\n ' + chalk.green.bold('✓ Done!\n'));
744
+ return {
745
+ message,
746
+ provider: selectedProvider,
747
+ model: config.modelId,
748
+ latencyMs: elapsed,
749
+ usage,
750
+ warnings,
751
+ exitReason: 'success',
752
+ committed: true,
753
+ edited: wasEdited,
754
+ rewrites: regenerateCount + automaticCorrectionCount,
755
+ };
756
+ } catch (err) {
757
+ const restored = indexTransaction ? indexTransaction.restore() : false;
758
+ indexTransaction = null;
759
+ console.log('\n ' + chalk.red(`✗ Git commit failed: ${sanitizeTerminalText(err.message)}`));
760
+ if (restored)
761
+ console.log(chalk.dim(' Tool-owned staging was restored to its original state.'));
762
+ console.log(chalk.dim(' Resolve the Git or hook error, then run aicommit again.\n'));
763
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `Git commit failed: ${err.message}`, {
764
+ cause: err,
765
+ reported: true,
766
+ });
767
+ }
768
+ }