@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/split.js
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
summarizeChanges,
|
|
75
75
|
} from './change-analysis.js';
|
|
76
76
|
import { updateGitHash, spoolGit } from './git-spool.js';
|
|
77
|
+
import { createAnalysisCache } from './analysis-cache.js';
|
|
77
78
|
|
|
78
79
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
79
80
|
// Split mode: group changes into multiple logical commits
|
|
@@ -468,6 +469,51 @@ function groupMessage(g, policy) {
|
|
|
468
469
|
return validateCommitCandidate(message, { policy }).valid ? message : '';
|
|
469
470
|
}
|
|
470
471
|
|
|
472
|
+
function fallbackCommitMessage(policy) {
|
|
473
|
+
const type = policy.types.includes('chore') ? 'chore' : policy.types[0];
|
|
474
|
+
let scope = '';
|
|
475
|
+
if (policy.scope.mode === 'required') {
|
|
476
|
+
if (policy.scope.values.length) scope = policy.scope.values[0];
|
|
477
|
+
else {
|
|
478
|
+
const disallowed = new Set(policy.scope.disallowedValues || []);
|
|
479
|
+
const preferred = ['changes', 'repository', 'all'];
|
|
480
|
+
scope = preferred.find((candidate) => !disallowed.has(candidate)) || '';
|
|
481
|
+
for (let index = 1; !scope; index++) {
|
|
482
|
+
const candidate = `fallback-${index}`;
|
|
483
|
+
if (!disallowed.has(candidate)) scope = candidate;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const breaking = policy.breakingChange === 'require' ? '!' : '';
|
|
488
|
+
const prefix = `${type}${scope ? `(${scope})` : ''}${breaking}: `;
|
|
489
|
+
const preferred = policy.effectiveLanguage === 'zh' ? '更新其余文件' : 'update remaining files';
|
|
490
|
+
const available = Math.max(
|
|
491
|
+
1,
|
|
492
|
+
Math.min(
|
|
493
|
+
policy.subject.maxLength,
|
|
494
|
+
policy.subject.headerMaxLength
|
|
495
|
+
? policy.subject.headerMaxLength - [...prefix].length
|
|
496
|
+
: policy.subject.maxLength,
|
|
497
|
+
),
|
|
498
|
+
);
|
|
499
|
+
const subject = [...preferred].slice(0, available).join('');
|
|
500
|
+
const body =
|
|
501
|
+
policy.body.mode === 'required'
|
|
502
|
+
? policy.effectiveLanguage === 'zh'
|
|
503
|
+
? '- 包含本次已审核的全部文件变更'
|
|
504
|
+
: '- Include all reviewed file changes'
|
|
505
|
+
: '';
|
|
506
|
+
const message = cleanCommitMessage(`${prefix}${subject}${body ? `\n\n${body}` : ''}`);
|
|
507
|
+
const validation = validateCommitCandidate(message, { policy });
|
|
508
|
+
if (!validation.valid) {
|
|
509
|
+
throw fail(
|
|
510
|
+
ERROR_CATEGORIES.CONFIG,
|
|
511
|
+
`Cannot construct a conservative fallback message under commitPolicy: ${validation.errors.map((item) => item.message).join(' ')}`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
return message;
|
|
515
|
+
}
|
|
516
|
+
|
|
471
517
|
// Clean up the model's plan: drop unknown/duplicate files, drop empty
|
|
472
518
|
// groups, and sweep any file the model forgot into a final catch-all group.
|
|
473
519
|
export function normalizePlan(groups, allFiles, language, commitPolicy = null) {
|
|
@@ -531,12 +577,8 @@ export function normalizePlan(groups, allFiles, language, commitPolicy = null) {
|
|
|
531
577
|
if (ids.length) leftoverHunks.push({ path: change.path, ids });
|
|
532
578
|
}
|
|
533
579
|
if (leftoverFiles.length || leftoverHunks.length) {
|
|
534
|
-
const type = policy.types.includes('chore') ? 'chore' : policy.types[0];
|
|
535
580
|
result.push({
|
|
536
|
-
message:
|
|
537
|
-
policy.effectiveLanguage === 'zh'
|
|
538
|
-
? `${type}: 更新其余文件`
|
|
539
|
-
: `${type}: update remaining files`,
|
|
581
|
+
message: fallbackCommitMessage(policy),
|
|
540
582
|
files: leftoverFiles,
|
|
541
583
|
...(leftoverHunks.length ? { hunks: leftoverHunks } : {}),
|
|
542
584
|
});
|
|
@@ -1121,6 +1163,83 @@ function currentHeadParent(projectRoot) {
|
|
|
1121
1163
|
return fields[1] || null;
|
|
1122
1164
|
}
|
|
1123
1165
|
|
|
1166
|
+
function splitCheckpointSummary(projectRoot, loaded) {
|
|
1167
|
+
const { path, checkpoint } = loaded;
|
|
1168
|
+
const knownCommits = checkpoint.completed.map((record) => record.commit);
|
|
1169
|
+
let inFlight = null;
|
|
1170
|
+
if (checkpoint.inFlight) {
|
|
1171
|
+
const record = checkpoint.inFlight;
|
|
1172
|
+
const head = currentHead(projectRoot);
|
|
1173
|
+
const state =
|
|
1174
|
+
head === record.parent
|
|
1175
|
+
? 'pending'
|
|
1176
|
+
: currentHeadParent(projectRoot) === record.parent &&
|
|
1177
|
+
currentHeadTree(projectRoot) === record.tree
|
|
1178
|
+
? 'committed'
|
|
1179
|
+
: 'unknown';
|
|
1180
|
+
if (state === 'committed') knownCommits.push(head);
|
|
1181
|
+
inFlight = { group: record.index + 1, state };
|
|
1182
|
+
}
|
|
1183
|
+
const totalGroups = checkpoint.plan.groups.length;
|
|
1184
|
+
return {
|
|
1185
|
+
state: 'pending',
|
|
1186
|
+
transactionId: checkpoint.transactionId,
|
|
1187
|
+
checkpointPath: path,
|
|
1188
|
+
scope: checkpoint.plan.scope,
|
|
1189
|
+
totalGroups,
|
|
1190
|
+
completedGroups: knownCommits.length,
|
|
1191
|
+
pendingGroups: totalGroups - knownCommits.length,
|
|
1192
|
+
completedCommits: knownCommits,
|
|
1193
|
+
inFlight,
|
|
1194
|
+
nextAction: 'aicommit split resume --yes',
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
export function splitStatus(projectRoot) {
|
|
1199
|
+
let summary;
|
|
1200
|
+
try {
|
|
1201
|
+
summary = splitCheckpointSummary(projectRoot, readSplitCheckpoint(projectRoot));
|
|
1202
|
+
} catch (err) {
|
|
1203
|
+
if (!/No split checkpoint found:/.test(err.message)) {
|
|
1204
|
+
throw fail(ERROR_CATEGORIES.GIT_STATE, `Cannot inspect split checkpoint: ${err.message}`, {
|
|
1205
|
+
code: 'split_checkpoint_invalid',
|
|
1206
|
+
cause: err,
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
summary = { state: 'idle' };
|
|
1210
|
+
}
|
|
1211
|
+
const known = summary.completedCommits || [];
|
|
1212
|
+
const commitState =
|
|
1213
|
+
summary.inFlight?.state === 'unknown'
|
|
1214
|
+
? 'unknown'
|
|
1215
|
+
: !known.length
|
|
1216
|
+
? 'none'
|
|
1217
|
+
: summary.pendingGroups
|
|
1218
|
+
? 'partial'
|
|
1219
|
+
: 'complete';
|
|
1220
|
+
return {
|
|
1221
|
+
exitReason: 'split_status',
|
|
1222
|
+
committed: false,
|
|
1223
|
+
commitState,
|
|
1224
|
+
commitSha: known.at(-1) || null,
|
|
1225
|
+
scope: summary.scope || null,
|
|
1226
|
+
data: { split: summary },
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function splitCommitFailure(projectRoot, message) {
|
|
1231
|
+
const status = splitStatus(projectRoot);
|
|
1232
|
+
const knownCommits = status.data.split.completedCommits || [];
|
|
1233
|
+
throw fail(ERROR_CATEGORIES.GIT_STATE, message, {
|
|
1234
|
+
code: knownCommits.length ? 'split_partial_failure' : 'split_commit_failed',
|
|
1235
|
+
reported: true,
|
|
1236
|
+
committed: knownCommits.length > 0,
|
|
1237
|
+
commitState: status.commitState,
|
|
1238
|
+
nextAction: 'aicommit split resume --yes',
|
|
1239
|
+
data: status.data,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1124
1243
|
function readHeadEntries(projectRoot, paths) {
|
|
1125
1244
|
const entries = new Map();
|
|
1126
1245
|
if (!hasHead(projectRoot) || !paths.length) return entries;
|
|
@@ -1421,6 +1540,7 @@ export async function splitFlow(
|
|
|
1421
1540
|
scope = 'prompt',
|
|
1422
1541
|
dryRun = false,
|
|
1423
1542
|
yes = false,
|
|
1543
|
+
allowSingleFallback = false,
|
|
1424
1544
|
machineOutput = false,
|
|
1425
1545
|
provider = null,
|
|
1426
1546
|
exportPlanPath = null,
|
|
@@ -1621,6 +1741,7 @@ export async function splitFlow(
|
|
|
1621
1741
|
|
|
1622
1742
|
let raw, reasoningText, elapsed, usage;
|
|
1623
1743
|
let analysis;
|
|
1744
|
+
let persistentAnalysisCache = null;
|
|
1624
1745
|
const large =
|
|
1625
1746
|
needsAnalysis(captured, config, true) ||
|
|
1626
1747
|
untrackedSnapshot.previews.sources?.size > 0 ||
|
|
@@ -1634,7 +1755,22 @@ export async function splitFlow(
|
|
|
1634
1755
|
ERROR_CATEGORIES.CONFIG,
|
|
1635
1756
|
'Large-change analysis does not support experimental hunk plans; use file-level split.',
|
|
1636
1757
|
);
|
|
1637
|
-
planningConfig = analysisConfig(planningConfig);
|
|
1758
|
+
planningConfig = analysisConfig({ ...planningConfig, analysisTask: 'split' });
|
|
1759
|
+
persistentAnalysisCache = createAnalysisCache({
|
|
1760
|
+
projectRoot,
|
|
1761
|
+
snapshotFingerprint: plannedStateFingerprint,
|
|
1762
|
+
config: planningConfig,
|
|
1763
|
+
protect: protectModelInput,
|
|
1764
|
+
});
|
|
1765
|
+
if (
|
|
1766
|
+
planningConfig.largeChange?.strategy === 'deep' &&
|
|
1767
|
+
planningConfig.largeChange.cache?.enabled &&
|
|
1768
|
+
!protectModelInput &&
|
|
1769
|
+
!planningConfig.largeChange.cache.allowUnprotected
|
|
1770
|
+
) {
|
|
1771
|
+
warnings.push('Deep-analysis recovery cache was disabled for unprotected input.');
|
|
1772
|
+
console.log(chalk.dim(' Recovery cache: disabled for unprotected input.'));
|
|
1773
|
+
}
|
|
1638
1774
|
console.log(
|
|
1639
1775
|
chalk.dim(
|
|
1640
1776
|
planningConfig.largeChange?.strategy === 'deep'
|
|
@@ -1657,15 +1793,71 @@ export async function splitFlow(
|
|
|
1657
1793
|
failureMessage: 'API call failed',
|
|
1658
1794
|
task: async (stream) => {
|
|
1659
1795
|
if (large) {
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1796
|
+
let plan;
|
|
1797
|
+
let planReasoning = null;
|
|
1798
|
+
const planningStream = stream
|
|
1799
|
+
? {
|
|
1800
|
+
onReasoningDelta(chunk) {
|
|
1801
|
+
planReasoning = (planReasoning || '') + chunk;
|
|
1802
|
+
stream.onReasoningDelta(chunk);
|
|
1803
|
+
},
|
|
1804
|
+
onReasoningComplete(text) {
|
|
1805
|
+
if (text) planReasoning = text;
|
|
1806
|
+
},
|
|
1807
|
+
}
|
|
1808
|
+
: null;
|
|
1809
|
+
try {
|
|
1810
|
+
analysis = await analyzeChanges(
|
|
1811
|
+
planningConfig,
|
|
1812
|
+
captured,
|
|
1813
|
+
protectModelInput,
|
|
1814
|
+
untrackedSnapshot.previews,
|
|
1815
|
+
({ completedChunks, cachedChunks }) =>
|
|
1816
|
+
console.error(
|
|
1817
|
+
` Analysis: ${completedChunks} chunks completed${cachedChunks ? ` (${cachedChunks} cached)` : ''}`,
|
|
1818
|
+
),
|
|
1819
|
+
persistentAnalysisCache,
|
|
1820
|
+
);
|
|
1821
|
+
plan = await planAnalyzedChanges(
|
|
1822
|
+
planningConfig,
|
|
1823
|
+
analysis.facts,
|
|
1824
|
+
analysis.coverage,
|
|
1825
|
+
planningStream,
|
|
1826
|
+
);
|
|
1827
|
+
} catch (err) {
|
|
1828
|
+
const exhausted =
|
|
1829
|
+
err.data?.analysis?.exhausted ||
|
|
1830
|
+
(!planningConfig.analysisBudget.remainingMs() ? 'time' : null);
|
|
1831
|
+
if (!exhausted && !err.data?.fallbackPlan) throw err;
|
|
1832
|
+
if (yes && !dryRun && !allowSingleFallback) {
|
|
1833
|
+
throw fail(
|
|
1834
|
+
err.category || ERROR_CATEGORIES.PROVIDER,
|
|
1835
|
+
'Large-change planning requires a conservative single-commit fallback. No commit was created; review interactively or rerun with --allow-single-fallback.',
|
|
1836
|
+
{ cause: err, data: err.data },
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
const fallbackConfig = {
|
|
1840
|
+
...planningConfig,
|
|
1841
|
+
largeChange: { ...planningConfig.largeChange, strategy: 'auto' },
|
|
1842
|
+
};
|
|
1843
|
+
analysis = await analyzeChanges(
|
|
1844
|
+
fallbackConfig,
|
|
1845
|
+
captured,
|
|
1846
|
+
protectModelInput,
|
|
1847
|
+
untrackedSnapshot.previews,
|
|
1848
|
+
);
|
|
1849
|
+
analysis.coverage = {
|
|
1850
|
+
...analysis.coverage,
|
|
1851
|
+
strategy: 'fallback',
|
|
1852
|
+
degradedFrom: planningConfig.largeChange.strategy,
|
|
1853
|
+
fallbackReason: exhausted || 'planning_capacity',
|
|
1854
|
+
};
|
|
1855
|
+
plan = normalizePlan([], allFiles, config.language, config.commitPolicy);
|
|
1856
|
+
planReasoning = null;
|
|
1857
|
+
const warning = `Large-change planning used one conservative all-files commit after ${exhausted || 'planning capacity'} exhaustion; review the fallback message and grouping.`;
|
|
1858
|
+
warnings.push(warning);
|
|
1859
|
+
console.error(` Fallback: ${warning}`);
|
|
1860
|
+
}
|
|
1669
1861
|
if (analysis.coverage.strategy === 'auto')
|
|
1670
1862
|
warnings.push(
|
|
1671
1863
|
'Split candidates used local metadata and selected excerpts; review the grouping because content was not fully analyzed.',
|
|
@@ -1684,7 +1876,7 @@ export async function splitFlow(
|
|
|
1684
1876
|
raw: JSON.stringify(plan),
|
|
1685
1877
|
elapsed: planningConfig.analysisBudget.snapshot().elapsedMs,
|
|
1686
1878
|
usage: planningConfig.analysisBudget.snapshot().usage,
|
|
1687
|
-
reasoning:
|
|
1879
|
+
reasoning: planReasoning,
|
|
1688
1880
|
};
|
|
1689
1881
|
}
|
|
1690
1882
|
return generateSplitPlan(
|
|
@@ -1736,6 +1928,8 @@ export async function splitFlow(
|
|
|
1736
1928
|
reported: true,
|
|
1737
1929
|
});
|
|
1738
1930
|
}
|
|
1931
|
+
persistentAnalysisCache?.clear();
|
|
1932
|
+
persistentAnalysisCache = null;
|
|
1739
1933
|
|
|
1740
1934
|
// Review / edit / regenerate loop
|
|
1741
1935
|
let regenCounts = groups.map(() => 0);
|
|
@@ -1777,7 +1971,7 @@ export async function splitFlow(
|
|
|
1777
1971
|
{ name: 'Cancel', value: 'cancel', description: 'Abort without committing' },
|
|
1778
1972
|
],
|
|
1779
1973
|
},
|
|
1780
|
-
reasoningEnabled
|
|
1974
|
+
reasoningEnabled || reasoningText
|
|
1781
1975
|
? {
|
|
1782
1976
|
text: reasoningText,
|
|
1783
1977
|
maxChars: config.reasoning.maxDisplayChars,
|
|
@@ -1914,6 +2108,8 @@ export async function splitFlow(
|
|
|
1914
2108
|
return {
|
|
1915
2109
|
plan: groups,
|
|
1916
2110
|
planFile: writtenPlanPath,
|
|
2111
|
+
scope,
|
|
2112
|
+
changeCount: allFiles.length,
|
|
1917
2113
|
provider,
|
|
1918
2114
|
model: config.modelId,
|
|
1919
2115
|
latencyMs: elapsed,
|
|
@@ -1955,12 +2151,13 @@ export async function splitFlow(
|
|
|
1955
2151
|
if (ok) {
|
|
1956
2152
|
console.log('\n ' + chalk.green.bold(`✓ Done! Created ${groups.length} commits.\n`));
|
|
1957
2153
|
} else {
|
|
1958
|
-
|
|
1959
|
-
reported: true,
|
|
1960
|
-
});
|
|
2154
|
+
splitCommitFailure(projectRoot, 'One or more split commits failed.');
|
|
1961
2155
|
}
|
|
1962
2156
|
return {
|
|
1963
2157
|
plan: groups,
|
|
2158
|
+
commitSha: currentHead(projectRoot),
|
|
2159
|
+
scope,
|
|
2160
|
+
changeCount: allFiles.length,
|
|
1964
2161
|
provider,
|
|
1965
2162
|
model: config.modelId,
|
|
1966
2163
|
latencyMs: elapsed,
|
|
@@ -2069,9 +2266,13 @@ export async function resumeSplit(projectRoot, { yes = false, machineOutput = fa
|
|
|
2069
2266
|
);
|
|
2070
2267
|
return {
|
|
2071
2268
|
plan: checkpoint.plan.groups,
|
|
2269
|
+
commitSha: currentHead(projectRoot),
|
|
2270
|
+
scope: checkpoint.plan.scope,
|
|
2271
|
+
changeCount: checkpoint.plan.changes.length,
|
|
2072
2272
|
warnings: [],
|
|
2073
2273
|
exitReason: 'success',
|
|
2074
|
-
committed:
|
|
2274
|
+
committed: false,
|
|
2275
|
+
commitState: 'complete',
|
|
2075
2276
|
edited: false,
|
|
2076
2277
|
rewrites: 0,
|
|
2077
2278
|
};
|
|
@@ -2126,9 +2327,7 @@ export async function resumeSplit(projectRoot, { yes = false, machineOutput = fa
|
|
|
2126
2327
|
{ transaction: { path: transaction.path, checkpoint } },
|
|
2127
2328
|
);
|
|
2128
2329
|
if (!ok) {
|
|
2129
|
-
|
|
2130
|
-
reported: true,
|
|
2131
|
-
});
|
|
2330
|
+
splitCommitFailure(projectRoot, 'One or more resumed split commits failed.');
|
|
2132
2331
|
}
|
|
2133
2332
|
console.log(
|
|
2134
2333
|
'\n ' +
|
|
@@ -2138,6 +2337,9 @@ export async function resumeSplit(projectRoot, { yes = false, machineOutput = fa
|
|
|
2138
2337
|
);
|
|
2139
2338
|
return {
|
|
2140
2339
|
plan: checkpoint.plan.groups,
|
|
2340
|
+
commitSha: currentHead(projectRoot),
|
|
2341
|
+
scope: checkpoint.plan.scope,
|
|
2342
|
+
changeCount: checkpoint.plan.changes.length,
|
|
2141
2343
|
provider: null,
|
|
2142
2344
|
model: null,
|
|
2143
2345
|
latencyMs: null,
|
|
@@ -2344,13 +2546,15 @@ export async function applySplitPlan(
|
|
|
2344
2546
|
{ planArtifact: artifact },
|
|
2345
2547
|
);
|
|
2346
2548
|
if (!ok) {
|
|
2347
|
-
|
|
2348
|
-
reported: true,
|
|
2349
|
-
});
|
|
2549
|
+
splitCommitFailure(projectRoot, 'One or more split commits failed.');
|
|
2350
2550
|
}
|
|
2351
2551
|
console.log('\n ' + chalk.green.bold(`✓ Done! Created ${artifact.groups.length} commits.\n`));
|
|
2352
2552
|
return {
|
|
2353
2553
|
plan: artifact.groups,
|
|
2554
|
+
planFile: loaded.path,
|
|
2555
|
+
commitSha: readGit(['rev-parse', 'HEAD'], projectRoot).trim(),
|
|
2556
|
+
scope: artifact.scope,
|
|
2557
|
+
changeCount: artifact.changes.length,
|
|
2354
2558
|
provider: null,
|
|
2355
2559
|
model: null,
|
|
2356
2560
|
latencyMs: null,
|