@hifullmoon/aicommit 2.4.1 → 2.6.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 +7 -1
- package/CHANGELOG.md +30 -1
- package/README.md +42 -22
- package/README.zh-CN.md +42 -22
- package/docs/distribution.md +1 -1
- package/docs/large-change-implementation-plan.md +14 -14
- package/docs/privacy.md +2 -2
- package/docs/troubleshooting.md +21 -17
- package/package.json +1 -1
- package/schemas/aicommit-output.schema.json +61 -3
- package/src/analysis-budget.js +17 -7
- package/src/analysis-cache.js +246 -0
- package/src/change-analysis.js +213 -57
- package/src/cli.js +74 -15
- package/src/completion.js +17 -8
- package/src/config.js +30 -2
- package/src/errors.js +6 -2
- package/src/generation-ui.js +1 -1
- package/src/git.js +17 -5
- package/src/local-analysis.js +46 -16
- package/src/main.js +148 -6
- package/src/output.js +19 -2
- package/src/split.js +231 -27
package/src/completion.js
CHANGED
|
@@ -5,6 +5,8 @@ const TOP_LEVEL = [
|
|
|
5
5
|
'config',
|
|
6
6
|
'policy',
|
|
7
7
|
'completion',
|
|
8
|
+
'generate',
|
|
9
|
+
'apply',
|
|
8
10
|
'split',
|
|
9
11
|
'--help',
|
|
10
12
|
'--version',
|
|
@@ -16,6 +18,7 @@ const TOP_LEVEL = [
|
|
|
16
18
|
'--no-reasoning',
|
|
17
19
|
'--dry-run',
|
|
18
20
|
'--yes',
|
|
21
|
+
'--allow-single-fallback',
|
|
19
22
|
'--output',
|
|
20
23
|
'--debug',
|
|
21
24
|
];
|
|
@@ -32,7 +35,7 @@ _aicommit() {
|
|
|
32
35
|
config) COMPREPLY=( $(compgen -W "show validate path --provider --model --output --debug" -- "$cur") ); return ;;
|
|
33
36
|
policy) COMPREPLY=( $(compgen -W "template check --file --range --output --debug" -- "$cur") ); return ;;
|
|
34
37
|
completion) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ); return ;;
|
|
35
|
-
split) COMPREPLY=( $(compgen -W "run plan apply resume abort --scope --file --yes --output --debug" -- "$cur") ); return ;;
|
|
38
|
+
split) COMPREPLY=( $(compgen -W "run plan apply resume abort status --scope --file --yes --allow-single-fallback --output --debug" -- "$cur") ); return ;;
|
|
36
39
|
esac
|
|
37
40
|
case "$prev" in
|
|
38
41
|
--lang|-l) COMPREPLY=( $(compgen -W "zh en" -- "$cur") ); return ;;
|
|
@@ -57,7 +60,9 @@ _aicommit() {
|
|
|
57
60
|
'config:inspect or validate configuration'
|
|
58
61
|
'policy:print or enforce a repository team policy'
|
|
59
62
|
'completion:generate shell completion'
|
|
60
|
-
'
|
|
63
|
+
'generate:export one commit plan'
|
|
64
|
+
'apply:apply a validated commit plan'
|
|
65
|
+
'split:run, plan, apply, resume, abort, or inspect split commits'
|
|
61
66
|
)
|
|
62
67
|
options=(
|
|
63
68
|
'--help[show help]'
|
|
@@ -65,13 +70,14 @@ _aicommit() {
|
|
|
65
70
|
'--lang=[commit language]:language:(zh en)'
|
|
66
71
|
'--provider=[provider name]:provider:'
|
|
67
72
|
'--model=[model name]:model:'
|
|
68
|
-
'--scope=[
|
|
69
|
-
'--file=[
|
|
73
|
+
'--scope=[change scope]:scope:(staged all)'
|
|
74
|
+
'--file=[commit plan or commit-message file]:path:_files'
|
|
70
75
|
'--range=[Git range for policy check]:revision:'
|
|
71
76
|
'--reasoning=[reasoning effort]:effort:(low medium high xhigh max)'
|
|
72
77
|
'--no-reasoning[disable reasoning]'
|
|
73
78
|
'--dry-run[review without committing]'
|
|
74
79
|
'--yes[accept without prompts]'
|
|
80
|
+
'--allow-single-fallback[allow non-interactive split to commit one conservative group]'
|
|
75
81
|
'--output=[output mode]:output:(text json)'
|
|
76
82
|
'--debug[show debug details]'
|
|
77
83
|
)
|
|
@@ -85,7 +91,7 @@ _aicommit() {
|
|
|
85
91
|
config) _values 'config action' show validate path ;;
|
|
86
92
|
policy) _values 'policy action' template check ;;
|
|
87
93
|
completion) _values 'shell' bash zsh fish ;;
|
|
88
|
-
split) _values 'split action' run plan apply resume abort ;;
|
|
94
|
+
split) _values 'split action' run plan apply resume abort status ;;
|
|
89
95
|
esac
|
|
90
96
|
;;
|
|
91
97
|
esac
|
|
@@ -101,23 +107,26 @@ complete -c aicommit -n '__fish_use_subcommand' -a doctor -d 'Diagnose configura
|
|
|
101
107
|
complete -c aicommit -n '__fish_use_subcommand' -a config -d 'Inspect or validate configuration'
|
|
102
108
|
complete -c aicommit -n '__fish_use_subcommand' -a policy -d 'Print or enforce a repository team policy'
|
|
103
109
|
complete -c aicommit -n '__fish_use_subcommand' -a completion -d 'Generate shell completion'
|
|
110
|
+
complete -c aicommit -n '__fish_use_subcommand' -a generate -d 'Export one commit plan'
|
|
111
|
+
complete -c aicommit -n '__fish_use_subcommand' -a apply -d 'Apply a validated commit plan'
|
|
104
112
|
complete -c aicommit -n '__fish_use_subcommand' -a split -d 'Run, plan, apply, resume, or abort split commits'
|
|
105
113
|
complete -c aicommit -n '__fish_seen_subcommand_from config' -a 'show validate path'
|
|
106
114
|
complete -c aicommit -n '__fish_seen_subcommand_from policy' -a 'template check'
|
|
107
115
|
complete -c aicommit -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
|
|
108
|
-
complete -c aicommit -n '__fish_seen_subcommand_from split' -a 'run plan apply resume abort'
|
|
116
|
+
complete -c aicommit -n '__fish_seen_subcommand_from split' -a 'run plan apply resume abort status'
|
|
109
117
|
complete -c aicommit -s h -l help -d 'Show help'
|
|
110
118
|
complete -c aicommit -s v -l version -d 'Show version'
|
|
111
119
|
complete -c aicommit -s l -l lang -x -a 'zh en' -d 'Commit language'
|
|
112
120
|
complete -c aicommit -s p -l provider -x -d 'Provider name'
|
|
113
121
|
complete -c aicommit -s m -l model -x -d 'Model name'
|
|
114
|
-
complete -c aicommit -l scope -x -a 'staged all' -d '
|
|
115
|
-
complete -c aicommit -l file -r -d '
|
|
122
|
+
complete -c aicommit -l scope -x -a 'staged all' -d 'Change scope'
|
|
123
|
+
complete -c aicommit -l file -r -d 'Commit plan file'
|
|
116
124
|
complete -c aicommit -l range -r -d 'Git range for policy check'
|
|
117
125
|
complete -c aicommit -l reasoning -x -a 'low medium high xhigh max' -d 'Reasoning effort'
|
|
118
126
|
complete -c aicommit -l no-reasoning -d 'Disable reasoning'
|
|
119
127
|
complete -c aicommit -l dry-run -d 'Review without committing'
|
|
120
128
|
complete -c aicommit -s y -l yes -d 'Accept without prompts'
|
|
129
|
+
complete -c aicommit -l allow-single-fallback -d 'Allow non-interactive split to commit one conservative group'
|
|
121
130
|
complete -c aicommit -l output -x -a 'text json' -d 'Output mode'
|
|
122
131
|
complete -c aicommit -l debug -d 'Show debug details'
|
|
123
132
|
`;
|
package/src/config.js
CHANGED
|
@@ -420,7 +420,11 @@ function assertUrl(config, key) {
|
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
-
function assertNumber(
|
|
423
|
+
function assertNumber(
|
|
424
|
+
config,
|
|
425
|
+
key,
|
|
426
|
+
{ integer = false, min = -Infinity, max = Infinity, label = key } = {},
|
|
427
|
+
) {
|
|
424
428
|
const value = config[key];
|
|
425
429
|
const ok =
|
|
426
430
|
typeof value === 'number' &&
|
|
@@ -431,7 +435,7 @@ function assertNumber(config, key, { integer = false, min = -Infinity, max = Inf
|
|
|
431
435
|
if (!ok) {
|
|
432
436
|
const kind = integer ? 'integer' : 'number';
|
|
433
437
|
const range = Number.isFinite(max) ? ` between ${min} and ${max}` : ` >= ${min}`;
|
|
434
|
-
throw new Error(`Invalid config "${
|
|
438
|
+
throw new Error(`Invalid config "${label}": expected a ${kind}${range}.`);
|
|
435
439
|
}
|
|
436
440
|
}
|
|
437
441
|
|
|
@@ -449,6 +453,30 @@ export function validateConfig(config) {
|
|
|
449
453
|
assertNumber(settings, 'concurrency', { integer: true, min: 1, max: 4 });
|
|
450
454
|
if (settings.maxTotalTokens < settings.chunkInputTokens)
|
|
451
455
|
throw new Error('largeChange.maxTotalTokens must cover one input request.');
|
|
456
|
+
if (!object(settings.cache))
|
|
457
|
+
throw new Error('Invalid config "largeChange.cache": expected an object.');
|
|
458
|
+
assertKnownKeys(
|
|
459
|
+
settings.cache,
|
|
460
|
+
new Set(Object.keys(DEFAULT_LARGE_CHANGE.cache)),
|
|
461
|
+
'largeChange.cache',
|
|
462
|
+
);
|
|
463
|
+
const cache = { ...DEFAULT_LARGE_CHANGE.cache, ...settings.cache };
|
|
464
|
+
for (const key of ['enabled', 'allowUnprotected']) {
|
|
465
|
+
if (typeof cache[key] !== 'boolean')
|
|
466
|
+
throw new Error(`Invalid config "largeChange.cache.${key}": expected a boolean.`);
|
|
467
|
+
}
|
|
468
|
+
assertNumber(cache, 'ttlMs', {
|
|
469
|
+
integer: true,
|
|
470
|
+
min: 60_000,
|
|
471
|
+
max: 7 * 24 * 60 * 60 * 1000,
|
|
472
|
+
label: 'largeChange.cache.ttlMs',
|
|
473
|
+
});
|
|
474
|
+
assertNumber(cache, 'maxBytes', {
|
|
475
|
+
integer: true,
|
|
476
|
+
min: 1024 * 1024,
|
|
477
|
+
max: 256 * 1024 * 1024,
|
|
478
|
+
label: 'largeChange.cache.maxBytes',
|
|
479
|
+
});
|
|
452
480
|
}
|
|
453
481
|
assertUrl(config, 'apiUrl');
|
|
454
482
|
assertString(config, 'modelId');
|
package/src/errors.js
CHANGED
|
@@ -38,8 +38,13 @@ export class AicommitError extends Error {
|
|
|
38
38
|
super(message);
|
|
39
39
|
this.name = 'AicommitError';
|
|
40
40
|
this.category = category;
|
|
41
|
+
this.code = options.code || category;
|
|
41
42
|
this.exitCode = EXIT_CODES[category] ?? EXIT_CODES.internal;
|
|
42
43
|
this.reported = Boolean(options.reported);
|
|
44
|
+
this.retryable = Boolean(options.retryable);
|
|
45
|
+
this.nextAction = options.nextAction || null;
|
|
46
|
+
this.committed = Boolean(options.committed);
|
|
47
|
+
this.commitState = options.commitState || 'none';
|
|
43
48
|
if (options.data && typeof options.data === 'object' && !Array.isArray(options.data)) {
|
|
44
49
|
this.data = options.data;
|
|
45
50
|
}
|
|
@@ -62,11 +67,10 @@ export function classifyError(err) {
|
|
|
62
67
|
const lower = message.toLowerCase();
|
|
63
68
|
const code = errorCode(err);
|
|
64
69
|
if (
|
|
65
|
-
err instanceof TypeError ||
|
|
66
70
|
NETWORK_CODES.has(code) ||
|
|
67
71
|
/timed out|fetch failed|socket|network|dns|econn|enotfound/.test(lower)
|
|
68
72
|
) {
|
|
69
|
-
return fail(ERROR_CATEGORIES.NETWORK, message, { cause: err });
|
|
73
|
+
return fail(ERROR_CATEGORIES.NETWORK, message, { cause: err, retryable: true });
|
|
70
74
|
}
|
|
71
75
|
if (/^http \d{3}:|streaming api error|rate limit|provider request/.test(lower)) {
|
|
72
76
|
return fail(ERROR_CATEGORIES.PROVIDER, message, { cause: err });
|
package/src/generation-ui.js
CHANGED
|
@@ -18,7 +18,7 @@ export async function runModelTask({
|
|
|
18
18
|
const spinner = ora({ text: chalk.dim(spinnerText), color: 'cyan' }).start();
|
|
19
19
|
let liveReasoning;
|
|
20
20
|
const stream =
|
|
21
|
-
reasoning
|
|
21
|
+
reasoning && reasoning.mode !== 'off' && !machineOutput
|
|
22
22
|
? {
|
|
23
23
|
onReasoningDelta(chunk) {
|
|
24
24
|
if (!liveReasoning) {
|
package/src/git.js
CHANGED
|
@@ -70,13 +70,24 @@ export function getStagedDiff(cwd, contextLines) {
|
|
|
70
70
|
return readGit(['diff', unifiedArg(contextLines), '--staged'], cwd).trim();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
73
|
+
// Git's raw staged diff contains the full old/new blob IDs, modes, status,
|
|
74
|
+
// and NUL-delimited paths for every changed entry. Hashing that metadata is
|
|
75
|
+
// equivalent to hashing the staged patch for snapshot identity, including
|
|
76
|
+
// binary changes, without regenerating and spooling the entire patch during
|
|
77
|
+
// every pre/post-model safety check.
|
|
76
78
|
export function getIndexFingerprint(cwd) {
|
|
77
79
|
return updateGitHash(
|
|
78
80
|
createHash('sha256'),
|
|
79
|
-
[
|
|
81
|
+
[
|
|
82
|
+
'diff',
|
|
83
|
+
'--raw',
|
|
84
|
+
'--staged',
|
|
85
|
+
'--no-renames',
|
|
86
|
+
'--no-ext-diff',
|
|
87
|
+
'--no-textconv',
|
|
88
|
+
'--abbrev=64',
|
|
89
|
+
'-z',
|
|
90
|
+
],
|
|
80
91
|
cwd,
|
|
81
92
|
).digest('hex');
|
|
82
93
|
}
|
|
@@ -263,7 +274,8 @@ export function condenseDiff(diff, maxChars, stat, maxSectionChars = Infinity) {
|
|
|
263
274
|
}
|
|
264
275
|
const marker = `... (diff truncated — ${diff.length} chars total)`;
|
|
265
276
|
const body = kept ? `${kept}\n${marker}` : marker;
|
|
266
|
-
|
|
277
|
+
const resolvedStat = typeof stat === 'function' ? stat() : stat;
|
|
278
|
+
return { diff: resolvedStat ? `${resolvedStat}\n\n${body}` : body, truncated: true };
|
|
267
279
|
}
|
|
268
280
|
|
|
269
281
|
export function getChangedFiles(cwd) {
|
package/src/local-analysis.js
CHANGED
|
@@ -245,27 +245,57 @@ export function analyzeLocally(config, capture, protect, previews) {
|
|
|
245
245
|
metadataOnlyFiles: capture.manifest.length - overview.sampledFiles,
|
|
246
246
|
failedFiles: 0,
|
|
247
247
|
completedChunks: 0,
|
|
248
|
+
cachedChunks: 0,
|
|
249
|
+
requestedChunks: 0,
|
|
248
250
|
},
|
|
249
251
|
};
|
|
250
252
|
}
|
|
251
253
|
|
|
252
|
-
export function
|
|
254
|
+
export function localPlanBatches(config, facts, cap) {
|
|
253
255
|
const error = () =>
|
|
254
|
-
fail(
|
|
256
|
+
fail(ERROR_CATEGORIES.CONFIG, 'A split candidate is too large for one planning request.', {
|
|
257
|
+
data: { fallbackPlan: true },
|
|
258
|
+
});
|
|
259
|
+
const maxItems = config.splitMaxPlanFiles || 100;
|
|
260
|
+
const batches = [];
|
|
261
|
+
let batch = [];
|
|
262
|
+
for (const fact of facts) {
|
|
263
|
+
const item = {
|
|
264
|
+
...summaryOf(fact, false),
|
|
265
|
+
summary: 'Local candidate only; verify grouping. Content not fully analyzed.',
|
|
266
|
+
};
|
|
267
|
+
if (Buffer.byteLength(JSON.stringify([item])) > cap) throw error();
|
|
268
|
+
const candidate = [...batch, item];
|
|
269
|
+
if (
|
|
270
|
+
batch.length &&
|
|
271
|
+
(candidate.length > maxItems || Buffer.byteLength(JSON.stringify(candidate)) > cap)
|
|
272
|
+
) {
|
|
273
|
+
batches.push(batch);
|
|
274
|
+
batch = [];
|
|
275
|
+
}
|
|
276
|
+
batch.push(item);
|
|
277
|
+
}
|
|
278
|
+
if (batch.length) batches.push(batch);
|
|
279
|
+
|
|
280
|
+
const factsById = new Map(facts.map((fact) => [fact.id, fact]));
|
|
281
|
+
for (const items of batches) {
|
|
282
|
+
for (const item of items) {
|
|
283
|
+
const evidence = factsById.get(item.id)?.evidence;
|
|
284
|
+
if (!evidence) continue;
|
|
285
|
+
item.representativeExcerpt = evidence;
|
|
286
|
+
if (Buffer.byteLength(JSON.stringify(items)) > cap) delete item.representativeExcerpt;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return batches;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function localPlanItems(config, facts, cap) {
|
|
293
|
+
const batches = localPlanBatches(config, facts, cap);
|
|
294
|
+
if (batches.length !== 1)
|
|
295
|
+
throw fail(
|
|
255
296
|
ERROR_CATEGORIES.CONFIG,
|
|
256
|
-
'The complete split candidate inventory
|
|
297
|
+
'The complete split candidate inventory requires multiple planning requests.',
|
|
298
|
+
{ data: { fallbackPlan: true } },
|
|
257
299
|
);
|
|
258
|
-
|
|
259
|
-
const items = facts.map((fact) => ({
|
|
260
|
-
...summaryOf(fact, false),
|
|
261
|
-
summary: 'Local candidate only; verify grouping. Content not fully analyzed.',
|
|
262
|
-
}));
|
|
263
|
-
if (Buffer.byteLength(JSON.stringify(items)) > cap) throw error();
|
|
264
|
-
for (const fact of ranked(facts)) {
|
|
265
|
-
if (!fact.evidence) continue;
|
|
266
|
-
const item = items.find((item) => item.id === fact.id);
|
|
267
|
-
item.representativeExcerpt = fact.evidence;
|
|
268
|
-
if (Buffer.byteLength(JSON.stringify(items)) > cap) delete item.representativeExcerpt;
|
|
269
|
-
}
|
|
270
|
-
return items;
|
|
300
|
+
return batches[0];
|
|
271
301
|
}
|
package/src/main.js
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
runGit,
|
|
18
18
|
isGitRepo,
|
|
19
19
|
getIndexFingerprint,
|
|
20
|
+
hasHead,
|
|
21
|
+
readGit,
|
|
20
22
|
createIndexTransaction,
|
|
21
23
|
protectSensitiveDiff,
|
|
22
24
|
unifiedArg,
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
summarizeChanges,
|
|
30
32
|
} from './change-analysis.js';
|
|
31
33
|
import { cleanupGitSpools } from './git-spool.js';
|
|
34
|
+
import { createAnalysisCache } from './analysis-cache.js';
|
|
32
35
|
import { generateCommitMessage } from './api.js';
|
|
33
36
|
import {
|
|
34
37
|
statusColor,
|
|
@@ -53,8 +56,13 @@ import {
|
|
|
53
56
|
applySplitPlan,
|
|
54
57
|
resumeSplit,
|
|
55
58
|
splitFlow,
|
|
59
|
+
splitStatus,
|
|
56
60
|
getStagedChangedFiles,
|
|
61
|
+
getAllChangedFiles,
|
|
62
|
+
getSplitStateFingerprint,
|
|
63
|
+
safeExportPlanPath,
|
|
57
64
|
} from './split.js';
|
|
65
|
+
import { createSplitPlanArtifact, writeSplitPlanArtifact } from './split-plan.js';
|
|
58
66
|
import { runModelTask } from './generation-ui.js';
|
|
59
67
|
import { runSetup } from './setup.js';
|
|
60
68
|
import { detectProviderType } from './providers.js';
|
|
@@ -107,9 +115,13 @@ async function runMain() {
|
|
|
107
115
|
debug,
|
|
108
116
|
split,
|
|
109
117
|
splitCommand,
|
|
118
|
+
generate,
|
|
119
|
+
generateScope,
|
|
120
|
+
previewScope,
|
|
110
121
|
splitPlanFile,
|
|
111
122
|
dryRun,
|
|
112
123
|
yes,
|
|
124
|
+
allowSingleFallback,
|
|
113
125
|
setup,
|
|
114
126
|
update,
|
|
115
127
|
doctor,
|
|
@@ -128,7 +140,15 @@ async function runMain() {
|
|
|
128
140
|
return { exitReason: 'completion' };
|
|
129
141
|
}
|
|
130
142
|
const machineOutput = output === 'json';
|
|
131
|
-
if (
|
|
143
|
+
if (
|
|
144
|
+
machineOutput &&
|
|
145
|
+
!yes &&
|
|
146
|
+
!doctor &&
|
|
147
|
+
!configAction &&
|
|
148
|
+
!policyAction &&
|
|
149
|
+
!update &&
|
|
150
|
+
splitCommand !== 'status'
|
|
151
|
+
) {
|
|
132
152
|
throw fail(ERROR_CATEGORIES.CONFIG, '--output=json requires --yes for commit and split flows.');
|
|
133
153
|
}
|
|
134
154
|
|
|
@@ -176,13 +196,14 @@ async function runMain() {
|
|
|
176
196
|
console.log(' ' + chalk.dim('─'.repeat(45)));
|
|
177
197
|
console.log(' ' + chalk.dim(`Working directory: ${sanitizeTerminalText(process.cwd())}`));
|
|
178
198
|
|
|
179
|
-
if (['apply', 'resume', 'abort'].includes(splitCommand)) {
|
|
199
|
+
if (['apply', 'resume', 'abort', 'status'].includes(splitCommand)) {
|
|
180
200
|
const projectRoot = getProjectRoot();
|
|
181
201
|
if (!isGitRepo(projectRoot)) {
|
|
182
202
|
throw fail(ERROR_CATEGORIES.GIT_STATE, `Not a git repository: ${process.cwd()}`);
|
|
183
203
|
}
|
|
184
204
|
if (splitCommand === 'resume') return resumeSplit(projectRoot, { yes, machineOutput });
|
|
185
205
|
if (splitCommand === 'abort') return abortSplit(projectRoot, { yes, machineOutput });
|
|
206
|
+
if (splitCommand === 'status') return splitStatus(projectRoot);
|
|
186
207
|
return applySplitPlan(projectRoot, splitPlanFile, { yes, machineOutput });
|
|
187
208
|
}
|
|
188
209
|
|
|
@@ -291,6 +312,7 @@ async function runMain() {
|
|
|
291
312
|
console.log(chalk.dim(` split: ${split}`));
|
|
292
313
|
console.log(chalk.dim(` dryRun: ${dryRun}`));
|
|
293
314
|
console.log(chalk.dim(` yes: ${yes}`));
|
|
315
|
+
console.log(chalk.dim(` singleFallback: ${allowSingleFallback}`));
|
|
294
316
|
console.log(chalk.dim(' final config:'));
|
|
295
317
|
for (const [key, value] of Object.entries(config)) {
|
|
296
318
|
const display =
|
|
@@ -321,6 +343,7 @@ async function runMain() {
|
|
|
321
343
|
scope: split,
|
|
322
344
|
dryRun,
|
|
323
345
|
yes,
|
|
346
|
+
allowSingleFallback,
|
|
324
347
|
machineOutput,
|
|
325
348
|
provider: selectedProvider,
|
|
326
349
|
exportPlanPath: splitCommand === 'plan' ? splitPlanFile : null,
|
|
@@ -336,6 +359,35 @@ async function runMain() {
|
|
|
336
359
|
// All git commands run at the repo root (projectRoot), even when aicommit
|
|
337
360
|
// is invoked from a subdirectory.
|
|
338
361
|
let indexTransaction = null;
|
|
362
|
+
const effectiveScope = generate ? generateScope : previewScope;
|
|
363
|
+
let stagedAllForPreview = false;
|
|
364
|
+
const generateSnapshot = generate
|
|
365
|
+
? (() => {
|
|
366
|
+
const changes =
|
|
367
|
+
generateScope === 'staged'
|
|
368
|
+
? getStagedChangedFiles(projectRoot)
|
|
369
|
+
: getAllChangedFiles(projectRoot);
|
|
370
|
+
if (!changes.length) {
|
|
371
|
+
throw fail(
|
|
372
|
+
ERROR_CATEGORIES.GIT_STATE,
|
|
373
|
+
`No ${generateScope} changes to generate a plan for.`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
changes,
|
|
378
|
+
baseHead: hasHead(projectRoot)
|
|
379
|
+
? readGit(['rev-parse', 'HEAD'], projectRoot).trim()
|
|
380
|
+
: null,
|
|
381
|
+
fingerprint: getSplitStateFingerprint(
|
|
382
|
+
projectRoot,
|
|
383
|
+
hasHead(projectRoot),
|
|
384
|
+
changes,
|
|
385
|
+
generateScope,
|
|
386
|
+
),
|
|
387
|
+
file: safeExportPlanPath(projectRoot, splitPlanFile),
|
|
388
|
+
};
|
|
389
|
+
})()
|
|
390
|
+
: null;
|
|
339
391
|
const finishCancelled = ({
|
|
340
392
|
notice = 'Commit cancelled.',
|
|
341
393
|
message = null,
|
|
@@ -370,6 +422,20 @@ async function runMain() {
|
|
|
370
422
|
indexTransaction ||= createIndexTransaction(projectRoot);
|
|
371
423
|
return indexTransaction;
|
|
372
424
|
};
|
|
425
|
+
if (effectiveScope === 'all') {
|
|
426
|
+
try {
|
|
427
|
+
beginIndexTransaction();
|
|
428
|
+
runGit(['add', '-A'], projectRoot);
|
|
429
|
+
indexTransaction.markOwned();
|
|
430
|
+
stagedAllForPreview = true;
|
|
431
|
+
} catch (err) {
|
|
432
|
+
indexTransaction?.restore({ force: true });
|
|
433
|
+
indexTransaction = null;
|
|
434
|
+
throw fail(ERROR_CATEGORIES.GIT_STATE, `Failed to stage plan snapshot: ${err.message}`, {
|
|
435
|
+
cause: err,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
373
439
|
if (!getChangedFiles(projectRoot).length) {
|
|
374
440
|
// Nothing staged. But git diff --staged is also empty for unstaged work
|
|
375
441
|
// and untracked files — surface what git status actually shows instead of
|
|
@@ -386,6 +452,10 @@ async function runMain() {
|
|
|
386
452
|
throw fail(ERROR_CATEGORIES.GIT_STATE, 'No changes to commit.', { reported: true });
|
|
387
453
|
}
|
|
388
454
|
|
|
455
|
+
if (effectiveScope === 'staged' || generate) {
|
|
456
|
+
throw fail(ERROR_CATEGORIES.GIT_STATE, `No ${effectiveScope} changes to preview.`);
|
|
457
|
+
}
|
|
458
|
+
|
|
389
459
|
console.log('\n ' + chalk.yellow(`✗ No staged changes — ${tips.join(', ')}.`));
|
|
390
460
|
printChangeList(withUntrackedFiles(unstaged, untracked));
|
|
391
461
|
console.log('');
|
|
@@ -527,6 +597,13 @@ async function runMain() {
|
|
|
527
597
|
let diffForModel = diff;
|
|
528
598
|
let protectAnalysis = true;
|
|
529
599
|
if (protectedInput.findings.length) {
|
|
600
|
+
if (generate && generateScope === 'all' && yes) {
|
|
601
|
+
throw fail(
|
|
602
|
+
ERROR_CATEGORIES.SENSITIVE_DATA,
|
|
603
|
+
'Non-interactive generate --scope=all refuses sensitive content; review and stage intended files explicitly.',
|
|
604
|
+
{ code: 'sensitive_all_scope' },
|
|
605
|
+
);
|
|
606
|
+
}
|
|
530
607
|
warnings.push('Sensitive data was detected and protected before the provider request.');
|
|
531
608
|
console.log('\n ' + chalk.yellow.bold('⚠ Potential sensitive data detected:'));
|
|
532
609
|
for (const finding of protectedInput.findings) {
|
|
@@ -567,14 +644,30 @@ async function runMain() {
|
|
|
567
644
|
: condenseDiff(
|
|
568
645
|
strippedDiff,
|
|
569
646
|
config.maxDiffChars,
|
|
570
|
-
getDiffStat(projectRoot),
|
|
647
|
+
() => getDiffStat(projectRoot),
|
|
571
648
|
config.maxFileDiffChars,
|
|
572
649
|
);
|
|
573
650
|
let analysis;
|
|
574
651
|
let analyzedFacts;
|
|
652
|
+
let persistentAnalysisCache = null;
|
|
575
653
|
let generationConfig = config;
|
|
576
654
|
if (large) {
|
|
577
655
|
generationConfig = analysisConfig(config);
|
|
656
|
+
persistentAnalysisCache = createAnalysisCache({
|
|
657
|
+
projectRoot,
|
|
658
|
+
snapshotFingerprint: plannedIndexFingerprint,
|
|
659
|
+
config: generationConfig,
|
|
660
|
+
protect: protectAnalysis,
|
|
661
|
+
});
|
|
662
|
+
if (
|
|
663
|
+
generationConfig.largeChange?.strategy === 'deep' &&
|
|
664
|
+
generationConfig.largeChange.cache?.enabled &&
|
|
665
|
+
!protectAnalysis &&
|
|
666
|
+
!generationConfig.largeChange.cache.allowUnprotected
|
|
667
|
+
) {
|
|
668
|
+
warnings.push('Deep-analysis recovery cache was disabled for unprotected input.');
|
|
669
|
+
console.log(chalk.dim(' Recovery cache: disabled for unprotected input.'));
|
|
670
|
+
}
|
|
578
671
|
console.log(
|
|
579
672
|
chalk.dim(
|
|
580
673
|
generationConfig.largeChange?.strategy === 'deep'
|
|
@@ -622,8 +715,11 @@ async function runMain() {
|
|
|
622
715
|
captured,
|
|
623
716
|
protectAnalysis,
|
|
624
717
|
null,
|
|
625
|
-
({ completedChunks }) =>
|
|
626
|
-
console.error(
|
|
718
|
+
({ completedChunks, cachedChunks }) =>
|
|
719
|
+
console.error(
|
|
720
|
+
` Analysis: ${completedChunks} chunks completed${cachedChunks ? ` (${cachedChunks} cached)` : ''}`,
|
|
721
|
+
),
|
|
722
|
+
persistentAnalysisCache,
|
|
627
723
|
);
|
|
628
724
|
modelDiff =
|
|
629
725
|
analyzedFacts.summary ||
|
|
@@ -691,6 +787,8 @@ async function runMain() {
|
|
|
691
787
|
reported: true,
|
|
692
788
|
});
|
|
693
789
|
}
|
|
790
|
+
persistentAnalysisCache?.clear();
|
|
791
|
+
persistentAnalysisCache = null;
|
|
694
792
|
|
|
695
793
|
// ── 4. User action ─────────────────────────────────────────────────
|
|
696
794
|
|
|
@@ -698,7 +796,7 @@ async function runMain() {
|
|
|
698
796
|
? 'use'
|
|
699
797
|
: await confirmAction(
|
|
700
798
|
message,
|
|
701
|
-
reasoningEnabled
|
|
799
|
+
reasoningEnabled || reasoningText
|
|
702
800
|
? {
|
|
703
801
|
text: reasoningText,
|
|
704
802
|
maxChars: config.reasoning.maxDisplayChars,
|
|
@@ -748,14 +846,51 @@ async function runMain() {
|
|
|
748
846
|
console.log('');
|
|
749
847
|
|
|
750
848
|
if (dryRun) {
|
|
849
|
+
stagedAllForPreview ||= Boolean(indexTransaction);
|
|
751
850
|
const restored = indexTransaction ? indexTransaction.restore() : true;
|
|
752
851
|
indexTransaction = null;
|
|
753
852
|
if (!restored) {
|
|
853
|
+
if (generate) {
|
|
854
|
+
throw fail(
|
|
855
|
+
ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
|
|
856
|
+
'The Git index changed while generating a commit plan; no plan was written.',
|
|
857
|
+
);
|
|
858
|
+
}
|
|
754
859
|
warnings.push('The Git index changed during the run and was left untouched.');
|
|
755
860
|
console.log(
|
|
756
861
|
' ' + chalk.yellow('⚠ The Git index changed during the run and was left untouched.'),
|
|
757
862
|
);
|
|
758
863
|
}
|
|
864
|
+
let plan = null;
|
|
865
|
+
let planFile = null;
|
|
866
|
+
if (generate) {
|
|
867
|
+
const fingerprint = getSplitStateFingerprint(
|
|
868
|
+
projectRoot,
|
|
869
|
+
hasHead(projectRoot),
|
|
870
|
+
undefined,
|
|
871
|
+
generateScope,
|
|
872
|
+
);
|
|
873
|
+
const baseHead = hasHead(projectRoot)
|
|
874
|
+
? readGit(['rev-parse', 'HEAD'], projectRoot).trim()
|
|
875
|
+
: null;
|
|
876
|
+
if (fingerprint !== generateSnapshot.fingerprint || baseHead !== generateSnapshot.baseHead) {
|
|
877
|
+
throw fail(
|
|
878
|
+
ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
|
|
879
|
+
'Repository changes changed while generating a commit plan; no plan was written.',
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
plan = [{ message, files: generateSnapshot.changes.map((change) => change.path) }];
|
|
883
|
+
const artifact = createSplitPlanArtifact({
|
|
884
|
+
scope: generateScope,
|
|
885
|
+
baseHead,
|
|
886
|
+
fingerprint,
|
|
887
|
+
language: config.language,
|
|
888
|
+
commitPolicy: config.commitPolicy,
|
|
889
|
+
changes: generateSnapshot.changes,
|
|
890
|
+
groups: plan,
|
|
891
|
+
});
|
|
892
|
+
planFile = await writeSplitPlanArtifact(generateSnapshot.file, artifact);
|
|
893
|
+
}
|
|
759
894
|
console.log(
|
|
760
895
|
' ' +
|
|
761
896
|
chalk.green.bold(
|
|
@@ -764,6 +899,10 @@ async function runMain() {
|
|
|
764
899
|
);
|
|
765
900
|
return {
|
|
766
901
|
message,
|
|
902
|
+
plan,
|
|
903
|
+
planFile,
|
|
904
|
+
scope: effectiveScope || (stagedAllForPreview || indexTransaction ? 'all' : 'staged'),
|
|
905
|
+
changeCount: generate ? generateSnapshot.changes.length : changedFiles.length,
|
|
767
906
|
provider: selectedProvider,
|
|
768
907
|
model: config.modelId,
|
|
769
908
|
latencyMs: elapsed,
|
|
@@ -810,6 +949,9 @@ async function runMain() {
|
|
|
810
949
|
console.log('\n ' + chalk.green.bold('✓ Done!\n'));
|
|
811
950
|
return {
|
|
812
951
|
message,
|
|
952
|
+
commitSha: readGit(['rev-parse', 'HEAD'], projectRoot).trim(),
|
|
953
|
+
scope: 'staged',
|
|
954
|
+
changeCount: changedFiles.length,
|
|
813
955
|
provider: selectedProvider,
|
|
814
956
|
model: config.modelId,
|
|
815
957
|
latencyMs: elapsed,
|
package/src/output.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { classifyError, EXIT_CODES } from './errors.js';
|
|
2
2
|
|
|
3
|
-
export const OUTPUT_SCHEMA_VERSION = '1.
|
|
3
|
+
export const OUTPUT_SCHEMA_VERSION = '1.1';
|
|
4
|
+
|
|
5
|
+
function safeString(value) {
|
|
6
|
+
return typeof value === 'string' && value ? value : null;
|
|
7
|
+
}
|
|
4
8
|
|
|
5
9
|
function normalizedUsage(usage) {
|
|
6
10
|
if (!usage) return null;
|
|
@@ -43,6 +47,11 @@ export function successOutput(result = {}) {
|
|
|
43
47
|
warnings: Array.isArray(result.warnings) ? result.warnings.map(String) : [],
|
|
44
48
|
exitReason: result.exitReason || 'success',
|
|
45
49
|
committed: Boolean(result.committed),
|
|
50
|
+
commitState: result.commitState || (result.committed ? 'complete' : 'none'),
|
|
51
|
+
commitSha: safeString(result.commitSha),
|
|
52
|
+
planFile: safeString(result.planFile),
|
|
53
|
+
scope: safeString(result.scope),
|
|
54
|
+
changeCount: Number.isInteger(result.changeCount) ? result.changeCount : null,
|
|
46
55
|
error: null,
|
|
47
56
|
};
|
|
48
57
|
if (result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
@@ -64,10 +73,18 @@ export function errorOutput(err) {
|
|
|
64
73
|
usage: null,
|
|
65
74
|
warnings: [],
|
|
66
75
|
exitReason: classified.category,
|
|
67
|
-
committed:
|
|
76
|
+
committed: classified.committed,
|
|
77
|
+
commitState: classified.commitState,
|
|
78
|
+
commitSha: null,
|
|
79
|
+
planFile: null,
|
|
80
|
+
scope: null,
|
|
81
|
+
changeCount: null,
|
|
68
82
|
error: {
|
|
69
83
|
category: classified.category,
|
|
84
|
+
code: classified.code,
|
|
70
85
|
message: classified.message,
|
|
86
|
+
retryable: classified.retryable,
|
|
87
|
+
nextAction: classified.nextAction,
|
|
71
88
|
},
|
|
72
89
|
};
|
|
73
90
|
if (classified.data && typeof classified.data === 'object' && !Array.isArray(classified.data)) {
|