@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.
- package/.aicommit.config.example.json +118 -0
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +406 -0
- package/README.zh-CN.md +408 -0
- package/SECURITY.md +33 -0
- package/bin/aicommit.js +47 -0
- package/docs/distribution.md +104 -0
- package/docs/examples/aicommit-policy.yml +24 -0
- package/docs/examples/commit-msg +4 -0
- package/docs/examples/extension/aicommit-extension.json +9 -0
- package/docs/examples/extension/index.mjs +32 -0
- package/docs/extensions.md +93 -0
- package/docs/privacy.md +58 -0
- package/docs/provider-compatibility.md +37 -0
- package/docs/provider-presets.md +100 -0
- package/docs/team-policy.md +86 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +100 -0
- package/presets/provider-presets.json +60 -0
- package/schemas/aicommit-extension.schema.json +27 -0
- package/schemas/aicommit-output.schema.json +86 -0
- package/schemas/aicommit-provider-presets.schema.json +56 -0
- package/schemas/aicommit-split-checkpoint.schema.json +92 -0
- package/schemas/aicommit-split-plan.schema.json +108 -0
- package/schemas/aicommit-team-policy.schema.json +59 -0
- package/src/api.js +740 -0
- package/src/cli.js +558 -0
- package/src/completion.js +136 -0
- package/src/config-command.js +59 -0
- package/src/config.js +524 -0
- package/src/context.js +537 -0
- package/src/credentials.js +123 -0
- package/src/doctor.js +131 -0
- package/src/errors.js +96 -0
- package/src/extension-runner.mjs +36 -0
- package/src/extensions.js +426 -0
- package/src/generation-ui.js +53 -0
- package/src/git.js +458 -0
- package/src/main.js +768 -0
- package/src/metrics.js +375 -0
- package/src/output.js +87 -0
- package/src/policy-command.js +172 -0
- package/src/policy.js +421 -0
- package/src/preset-command.js +91 -0
- package/src/provider-presets.js +361 -0
- package/src/providers.js +306 -0
- package/src/setup.js +268 -0
- package/src/split-checkpoint.js +252 -0
- package/src/split-hunks.js +263 -0
- package/src/split-plan.js +339 -0
- package/src/split.js +2285 -0
- package/src/team-policy.js +95 -0
- package/src/trust.js +31 -0
- package/src/ui.js +488 -0
- package/src/utils.js +165 -0
- 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
|
+
}
|