@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/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
- analysis = await analyzeChanges(
1661
- planningConfig,
1662
- captured,
1663
- protectModelInput,
1664
- untrackedSnapshot.previews,
1665
- ({ completedChunks }) =>
1666
- console.error(` Analysis: ${completedChunks} chunks completed`),
1667
- );
1668
- const plan = await planAnalyzedChanges(planningConfig, analysis.facts, analysis.coverage);
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: null,
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
- throw fail(ERROR_CATEGORIES.GIT_STATE, 'One or more split commits failed.', {
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: true,
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
- throw fail(ERROR_CATEGORIES.GIT_STATE, 'One or more resumed split commits failed.', {
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
- throw fail(ERROR_CATEGORIES.GIT_STATE, 'One or more split commits failed.', {
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,