@hifullmoon/aicommit 2.3.0 → 2.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/src/split.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  protectSensitiveDiff,
32
32
  protectSensitiveText,
33
33
  isSensitiveFile,
34
+ pathBatches,
34
35
  } from './git.js';
35
36
  import { statusColor, statusIcon, highlightMessage, vimSelect, vimCheckbox } from './ui.js';
36
37
  import {
@@ -64,6 +65,15 @@ import {
64
65
  } from './split-checkpoint.js';
65
66
  import { fallbackHunkGroups, stripHunkCatalog, validateHunkTransaction } from './split-hunks.js';
66
67
  import { runModelTask } from './generation-ui.js';
68
+ import {
69
+ captureChanges,
70
+ needsAnalysis,
71
+ analysisConfig,
72
+ analyzeChanges,
73
+ planAnalyzedChanges,
74
+ summarizeChanges,
75
+ } from './change-analysis.js';
76
+ import { updateGitHash, spoolGit } from './git-spool.js';
67
77
 
68
78
  // ═══════════════════════════════════════════════════════════════════════════
69
79
  // Split mode: group changes into multiple logical commits
@@ -256,10 +266,10 @@ export function getSplitStateFingerprint(projectRoot, head, files, scope = 'all'
256
266
  files ||= getAllChangedFiles(projectRoot);
257
267
  hash.update(readGit(['status', '--porcelain', '-z', '-uall'], projectRoot));
258
268
  if (head) {
259
- hash.update(readGit(['diff', '--binary', '--full-index', 'HEAD'], projectRoot));
269
+ updateGitHash(hash, ['diff', '--binary', '--full-index', 'HEAD'], projectRoot);
260
270
  } else {
261
- hash.update(readGit(['diff', '--binary', '--full-index', '--cached'], projectRoot));
262
- hash.update(readGit(['diff', '--binary', '--full-index'], projectRoot));
271
+ updateGitHash(hash, ['diff', '--binary', '--full-index', '--cached'], projectRoot);
272
+ updateGitHash(hash, ['diff', '--binary', '--full-index'], projectRoot);
263
273
  }
264
274
 
265
275
  const buffer = Buffer.alloc(64 * 1024);
@@ -665,6 +675,7 @@ export function captureUntrackedSnapshots(
665
675
  maxPreviewBytes = 2000,
666
676
  ) {
667
677
  const previews = new Map();
678
+ previews.sources = new Map();
668
679
  const findings = [];
669
680
  const scanBuffer = Buffer.alloc(64 * 1024);
670
681
  const overlapBytes = 256;
@@ -698,6 +709,11 @@ export function captureUntrackedSnapshots(
698
709
  fd = openSync(fullPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
699
710
  const opened = fstatSync(fd);
700
711
  if (!opened.isFile()) continue;
712
+ const full =
713
+ includePreview && opened.size > maxPreviewBytes
714
+ ? { source: spoolGit([], projectRoot), privateKey: false, binary: false }
715
+ : null;
716
+ if (full) previews.sources.set(path, full);
701
717
 
702
718
  const previewParts = [];
703
719
  let previewLength = 0;
@@ -707,6 +723,10 @@ export function captureUntrackedSnapshots(
707
723
  const count = readSync(fd, scanBuffer, 0, scanBuffer.length, offset);
708
724
  if (!count) break;
709
725
  const chunk = Buffer.from(scanBuffer.subarray(0, count));
726
+ if (full) {
727
+ full.source.append(chunk);
728
+ if (chunk.includes(0)) full.binary = true;
729
+ }
710
730
 
711
731
  if (includePreview && previewLength < maxPreviewBytes) {
712
732
  const take = Math.min(count, maxPreviewBytes - previewLength);
@@ -715,7 +735,15 @@ export function captureUntrackedSnapshots(
715
735
  }
716
736
 
717
737
  const scanWindow = overlap.length ? Buffer.concat([overlap, chunk]) : chunk;
718
- findings.push(...protectSensitiveText(scanWindow.toString('utf-8'), path).findings);
738
+ const detected = protectSensitiveText(scanWindow.toString('utf-8'), path).findings;
739
+ findings.push(...detected);
740
+ if (
741
+ full &&
742
+ detected.some(
743
+ (item) => item.startsWith('private-key') || item.startsWith('sensitive file:'),
744
+ )
745
+ )
746
+ full.privateKey = true;
719
747
  overlap = Buffer.from(scanWindow.subarray(Math.max(0, scanWindow.length - overlapBytes)));
720
748
  offset += count;
721
749
  }
@@ -730,6 +758,12 @@ export function captureUntrackedSnapshots(
730
758
  } catch {
731
759
  // The path/name remains in the plan, but unreadable content is neither
732
760
  // previewed nor treated as if it had been successfully scanned.
761
+ const full = previews.sources.get(path);
762
+ if (full) {
763
+ full.source.dispose();
764
+ previews.sources.delete(path);
765
+ }
766
+ findings.push(`unreadable file: ${path}`);
733
767
  } finally {
734
768
  if (fd !== undefined) closeSync(fd);
735
769
  }
@@ -848,11 +882,12 @@ function readStageZeroEntries(projectRoot) {
848
882
  return parseStageZeroEntries(readGit(['ls-files', '--stage', '-z'], projectRoot));
849
883
  }
850
884
 
851
- function runGitWithIndex(args, projectRoot, indexPath, inherit = false) {
885
+ function runGitWithIndex(args, projectRoot, indexPath, inherit = false, input = undefined) {
852
886
  try {
853
887
  return execFileSync('git', args, {
854
888
  cwd: projectRoot,
855
889
  env: { ...process.env, GIT_INDEX_FILE: indexPath },
890
+ input,
856
891
  encoding: 'utf8',
857
892
  stdio:
858
893
  inherit === 'stderr'
@@ -900,13 +935,21 @@ function activeCommitHooks(projectRoot) {
900
935
 
901
936
  function submodulePaths(projectRoot, paths) {
902
937
  const found = new Set();
903
- const index = readGit(['ls-files', '--stage', '-z', '--', ...paths], projectRoot);
938
+ const index = pathBatches(paths)
939
+ .map((batch) =>
940
+ readGit(['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', ...batch], projectRoot),
941
+ )
942
+ .join('');
904
943
  for (const entry of index.split('\0')) {
905
944
  const match = entry.match(/^160000 [0-9a-f]+ \d\t([\s\S]+)$/);
906
945
  if (match) found.add(match[1]);
907
946
  }
908
947
  if (hasHead(projectRoot)) {
909
- const tree = readGit(['ls-tree', '-rz', 'HEAD', '--', ...paths], projectRoot);
948
+ const tree = pathBatches(paths)
949
+ .map((batch) =>
950
+ readGit(['--literal-pathspecs', 'ls-tree', '-rz', 'HEAD', '--', ...batch], projectRoot),
951
+ )
952
+ .join('');
910
953
  for (const entry of tree.split('\0')) {
911
954
  const match = entry.match(/^160000 commit [0-9a-f]+\t([\s\S]+)$/);
912
955
  if (match) found.add(match[1]);
@@ -1011,7 +1054,19 @@ export function preflightSplit(groups, projectRoot, allFiles, scope = 'all') {
1011
1054
  function resetCommittedPaths(projectRoot, groups, allFiles) {
1012
1055
  const paths = [...new Set(groups.flatMap((group) => expandGroupPaths(group, allFiles)))];
1013
1056
  if (paths.length && hasHead(projectRoot))
1014
- runGit(['reset', '-q', 'HEAD', '--', ...paths], projectRoot);
1057
+ runGit(
1058
+ [
1059
+ '--literal-pathspecs',
1060
+ 'reset',
1061
+ '-q',
1062
+ 'HEAD',
1063
+ '--pathspec-from-file=-',
1064
+ '--pathspec-file-nul',
1065
+ ],
1066
+ projectRoot,
1067
+ false,
1068
+ paths.join('\0') + '\0',
1069
+ );
1015
1070
  }
1016
1071
 
1017
1072
  function captureTargetEntries(projectRoot, scope, paths) {
@@ -1024,7 +1079,13 @@ function captureTargetEntries(projectRoot, scope, paths) {
1024
1079
  projectRoot,
1025
1080
  indexPath,
1026
1081
  );
1027
- runGitWithIndex(['add', '-A', '--', ...paths], projectRoot, indexPath);
1082
+ runGitWithIndex(
1083
+ ['--literal-pathspecs', 'add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'],
1084
+ projectRoot,
1085
+ indexPath,
1086
+ false,
1087
+ paths.join('\0') + '\0',
1088
+ );
1028
1089
  return parseStageZeroEntries(
1029
1090
  runGitWithIndex(['ls-files', '--stage', '-z'], projectRoot, indexPath),
1030
1091
  );
@@ -1063,7 +1124,11 @@ function currentHeadParent(projectRoot) {
1063
1124
  function readHeadEntries(projectRoot, paths) {
1064
1125
  const entries = new Map();
1065
1126
  if (!hasHead(projectRoot) || !paths.length) return entries;
1066
- const text = readGit(['ls-tree', '-rz', 'HEAD', '--', ...paths], projectRoot);
1127
+ const text = pathBatches(paths)
1128
+ .map((batch) =>
1129
+ readGit(['--literal-pathspecs', 'ls-tree', '-rz', 'HEAD', '--', ...batch], projectRoot),
1130
+ )
1131
+ .join('');
1067
1132
  for (const field of text.split('\0')) {
1068
1133
  if (!field) continue;
1069
1134
  const match = field.match(/^(\d+) \S+ ([0-9a-f]+)\t([\s\S]+)$/);
@@ -1157,22 +1222,20 @@ function executeTransactionalSplit(
1157
1222
  if (hunkExecution) {
1158
1223
  runGitWithIndex(['read-tree', hunkExecution.groupTrees[i]], projectRoot, indexPath);
1159
1224
  } else {
1160
- for (const path of paths) {
1161
- const entry = snapshot.get(path);
1162
- if (entry) {
1163
- runGitWithIndex(
1164
- ['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.oid},${path}`],
1165
- projectRoot,
1166
- indexPath,
1167
- );
1168
- } else {
1169
- runGitWithIndex(
1170
- ['update-index', '--force-remove', '--', path],
1171
- projectRoot,
1172
- indexPath,
1173
- );
1174
- }
1175
- }
1225
+ const oidLength = baseTree.length;
1226
+ const entries = paths
1227
+ .map((path) => {
1228
+ const entry = snapshot.get(path);
1229
+ return `${entry ? `${entry.mode} ${entry.oid}` : `0 ${'0'.repeat(oidLength)}`}\t${path}\0`;
1230
+ })
1231
+ .join('');
1232
+ runGitWithIndex(
1233
+ ['update-index', '-z', '--index-info'],
1234
+ projectRoot,
1235
+ indexPath,
1236
+ false,
1237
+ entries,
1238
+ );
1176
1239
  }
1177
1240
  const groupTree = runGitWithIndex(['write-tree'], projectRoot, indexPath).trim();
1178
1241
  if (groupTree === baseTree) {
@@ -1459,7 +1522,18 @@ export async function splitFlow(
1459
1522
  );
1460
1523
 
1461
1524
  const plannedStateFingerprint = getSplitStateFingerprint(projectRoot, head, allFiles, scope);
1462
- const diff = getSplitDiff(projectRoot, head, config.diffContextLines, scope);
1525
+ const u = unifiedArg(config.diffContextLines);
1526
+ const commands =
1527
+ scope === 'staged'
1528
+ ? [['diff', u, '--cached']]
1529
+ : head
1530
+ ? [['diff', u, 'HEAD']]
1531
+ : [
1532
+ ['diff', u, '--cached'],
1533
+ ['diff', u],
1534
+ ];
1535
+ const captured = captureChanges(commands, projectRoot, allFiles, config);
1536
+ const diff = captured.diff || '';
1463
1537
  const untrackedSnapshot =
1464
1538
  scope === 'all'
1465
1539
  ? captureUntrackedSnapshots(projectRoot, allFiles, config.stripFiles)
@@ -1482,6 +1556,7 @@ export async function splitFlow(
1482
1556
  .filter(isSensitiveFile);
1483
1557
  const sensitiveFindings = [
1484
1558
  ...protectedInput.findings,
1559
+ ...captured.findings,
1485
1560
  ...sensitivePaths.map((path) => `sensitive file: ${path}`),
1486
1561
  ...untrackedSnapshot.findings,
1487
1562
  ].filter((item, index, all) => all.indexOf(item) === index);
@@ -1545,6 +1620,29 @@ export async function splitFlow(
1545
1620
  }
1546
1621
 
1547
1622
  let raw, reasoningText, elapsed, usage;
1623
+ let analysis;
1624
+ const large =
1625
+ needsAnalysis(captured, config, true) ||
1626
+ untrackedSnapshot.previews.sources?.size > 0 ||
1627
+ [...untrackedSnapshot.previews.values()].reduce(
1628
+ (n, text) => n + (text?.length || 0),
1629
+ diff.length,
1630
+ ) > (config.splitMaxDiffChars || 16000);
1631
+ if (large) {
1632
+ if (allFiles.some((file) => file.hunks?.length))
1633
+ throw fail(
1634
+ ERROR_CATEGORIES.CONFIG,
1635
+ 'Large-change analysis does not support experimental hunk plans; use file-level split.',
1636
+ );
1637
+ planningConfig = analysisConfig(planningConfig);
1638
+ console.log(
1639
+ chalk.dim(
1640
+ planningConfig.largeChange?.strategy === 'deep'
1641
+ ? ` Large change: analyzing all ${allFiles.length} files in bounded chunks.`
1642
+ : ` Large change: building local split candidates for ${allFiles.length} files.`,
1643
+ ),
1644
+ );
1645
+ }
1548
1646
  try {
1549
1647
  ({
1550
1648
  raw,
@@ -1557,15 +1655,47 @@ export async function splitFlow(
1557
1655
  machineOutput,
1558
1656
  cancelMessage: 'Split cancelled.',
1559
1657
  failureMessage: 'API call failed',
1560
- task: (stream) =>
1561
- generateSplitPlan(
1658
+ task: async (stream) => {
1659
+ 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);
1669
+ if (analysis.coverage.strategy === 'auto')
1670
+ warnings.push(
1671
+ 'Split candidates used local metadata and selected excerpts; review the grouping because content was not fully analyzed.',
1672
+ );
1673
+ const policy = normalizeCommitPolicy(config.commitPolicy, config.language);
1674
+ if (plan.some((group) => !groupMessage(group, policy))) {
1675
+ throw fail(
1676
+ ERROR_CATEGORIES.RESPONSE_FORMAT,
1677
+ 'Large-change plan contains a commit message that violates commitPolicy. No fallback groups were created.',
1678
+ );
1679
+ }
1680
+ console.error(
1681
+ ` Coverage: ${analysis.coverage.analyzedFiles} files analyzed; ${analysis.coverage.sampledFiles || 0} representative excerpts; ${analysis.coverage.metadataOnlyFiles} metadata only.`,
1682
+ );
1683
+ return {
1684
+ raw: JSON.stringify(plan),
1685
+ elapsed: planningConfig.analysisBudget.snapshot().elapsedMs,
1686
+ usage: planningConfig.analysisBudget.snapshot().usage,
1687
+ reasoning: null,
1688
+ };
1689
+ }
1690
+ return generateSplitPlan(
1562
1691
  planningConfig,
1563
1692
  allFiles,
1564
1693
  planningDiff,
1565
1694
  projectRoot,
1566
1695
  stream,
1567
1696
  untrackedSnapshot.previews,
1568
- ),
1697
+ );
1698
+ },
1569
1699
  successMessage(result) {
1570
1700
  let done = `Plan generated in ${chalk.bold(formatMs(result.elapsed))}`;
1571
1701
  if (result.usage) done += chalk.dim(` · tokens: ${formatUsage(result.usage)}`);
@@ -1686,17 +1816,22 @@ export async function splitFlow(
1686
1816
  });
1687
1817
 
1688
1818
  for (const idx of picked) {
1689
- const rawGroupDiff = getGroupDiff(
1690
- projectRoot,
1691
- head,
1692
- scope,
1693
- groups[idx],
1694
- allFiles,
1695
- config.diffContextLines,
1696
- planningConfig.stripFiles,
1697
- protectModelInput,
1698
- untrackedSnapshot.previews,
1699
- );
1819
+ const rawGroupDiff = analysis
1820
+ ? await summarizeChanges(
1821
+ planningConfig,
1822
+ analysis.facts.filter((fact) => groups[idx].files.includes(fact.path)),
1823
+ )
1824
+ : getGroupDiff(
1825
+ projectRoot,
1826
+ head,
1827
+ scope,
1828
+ groups[idx],
1829
+ allFiles,
1830
+ config.diffContextLines,
1831
+ planningConfig.stripFiles,
1832
+ protectModelInput,
1833
+ untrackedSnapshot.previews,
1834
+ );
1700
1835
  const groupDiff = protectModelInput
1701
1836
  ? protectSensitiveDiff(rawGroupDiff).diff
1702
1837
  : rawGroupDiff;
@@ -1728,7 +1863,7 @@ export async function splitFlow(
1728
1863
  failureMessage: `Group ${idx + 1} regenerate failed`,
1729
1864
  task: (stream) =>
1730
1865
  generateCommitMessage(
1731
- config,
1866
+ analysis ? planningConfig : config,
1732
1867
  groupDiff,
1733
1868
  regenCounts[idx],
1734
1869
  groups[idx].message,
@@ -1754,6 +1889,10 @@ export async function splitFlow(
1754
1889
  break; // commit, or finish the dry run
1755
1890
  }
1756
1891
 
1892
+ if (analysis) {
1893
+ usage = planningConfig.analysisBudget.snapshot().usage;
1894
+ elapsed = planningConfig.analysisBudget.snapshot().elapsedMs;
1895
+ }
1757
1896
  const reviewedPlan = createSplitPlanArtifact({
1758
1897
  scope,
1759
1898
  baseHead: head ? readGit(['rev-parse', 'HEAD'], projectRoot).trim() : null,
@@ -1781,6 +1920,13 @@ export async function splitFlow(
1781
1920
  usage,
1782
1921
  warnings,
1783
1922
  exitReason: 'dry_run',
1923
+ ...(analysis
1924
+ ? {
1925
+ data: {
1926
+ analysis: { ...analysis.coverage, ...planningConfig.analysisBudget.snapshot() },
1927
+ },
1928
+ }
1929
+ : {}),
1784
1930
  committed: false,
1785
1931
  edited: planEdited,
1786
1932
  rewrites: rewriteCount,
@@ -1821,6 +1967,11 @@ export async function splitFlow(
1821
1967
  usage,
1822
1968
  warnings,
1823
1969
  exitReason: 'success',
1970
+ ...(analysis
1971
+ ? {
1972
+ data: { analysis: { ...analysis.coverage, ...planningConfig.analysisBudget.snapshot() } },
1973
+ }
1974
+ : {}),
1824
1975
  committed: true,
1825
1976
  edited: planEdited,
1826
1977
  rewrites: rewriteCount,