@hifullmoon/aicommit 2.3.0 → 2.4.1

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.
@@ -0,0 +1,108 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { closeSync, fstatSync, mkdtempSync, openSync, readSync, rmSync, writeSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const active = new Set();
7
+ export function cleanupGitSpools() {
8
+ for (const source of active) source.dispose();
9
+ }
10
+ process.once('exit', cleanupGitSpools);
11
+
12
+ // Git writes directly to a private temporary file, never to a Node stdout
13
+ // buffer. Consumers scan the captured bytes without reopening the worktree.
14
+ export function spoolGit(commands, cwd) {
15
+ const dir = mkdtempSync(join(tmpdir(), 'aicommit-diff-'));
16
+ const path = join(dir, 'patch');
17
+ let fd;
18
+ try {
19
+ fd = openSync(path, 'wx+', 0o600);
20
+ for (const args of commands) {
21
+ execFileSync('git', ['--no-pager', ...args], {
22
+ cwd,
23
+ stdio: ['ignore', fd, 'pipe'],
24
+ timeout: 120000,
25
+ maxBuffer: 1024 * 1024,
26
+ });
27
+ }
28
+ let size = fstatSync(fd).size;
29
+ closeSync(fd);
30
+ fd = undefined;
31
+ const source = {
32
+ get size() {
33
+ return size;
34
+ },
35
+ append(buffer) {
36
+ const output = openSync(path, 'r+');
37
+ try {
38
+ let written = 0;
39
+ while (written < buffer.length)
40
+ written += writeSync(output, buffer, written, buffer.length - written, size + written);
41
+ size += written;
42
+ } finally {
43
+ closeSync(output);
44
+ }
45
+ },
46
+ *buffers() {
47
+ const input = openSync(path, 'r');
48
+ const buffer = Buffer.alloc(64 * 1024);
49
+ let offset = 0;
50
+ try {
51
+ while (offset < size) {
52
+ const count = readSync(input, buffer, 0, buffer.length, offset);
53
+ if (!count) throw new Error('Captured Git patch was truncated.');
54
+ offset += count;
55
+ yield buffer.subarray(0, count);
56
+ }
57
+ } finally {
58
+ closeSync(input);
59
+ }
60
+ },
61
+ *lines(maxLineBytes = 1024 * 1024) {
62
+ let pending = Buffer.alloc(0);
63
+ for (const buffer of this.buffers()) {
64
+ const data = Buffer.concat([pending, buffer]);
65
+ let start = 0;
66
+ for (let end = data.indexOf(10); end !== -1; end = data.indexOf(10, start)) {
67
+ if (end - start > maxLineBytes)
68
+ throw new Error(
69
+ 'Diff line exceeds the safe analysis limit (1 MiB); split the change before retrying.',
70
+ );
71
+ yield data.subarray(start, end + 1).toString('utf8');
72
+ start = end + 1;
73
+ }
74
+ pending = Buffer.from(data.subarray(start));
75
+ if (pending.length > maxLineBytes)
76
+ throw new Error(
77
+ 'Diff line exceeds the safe analysis limit (1 MiB); split the change before retrying.',
78
+ );
79
+ }
80
+ if (pending.length) yield pending.toString('utf8');
81
+ },
82
+ text(limit) {
83
+ if (size > limit) return null;
84
+ return [...this.lines()].join('').trim();
85
+ },
86
+ dispose() {
87
+ if (!active.delete(source)) return;
88
+ rmSync(dir, { recursive: true, force: true });
89
+ },
90
+ };
91
+ active.add(source);
92
+ return source;
93
+ } catch (err) {
94
+ if (fd !== undefined) closeSync(fd);
95
+ rmSync(dir, { recursive: true, force: true });
96
+ throw new Error(`Failed to capture Git patch: ${err.message}`, { cause: err });
97
+ }
98
+ }
99
+
100
+ export function updateGitHash(hash, args, cwd) {
101
+ const source = spoolGit([args], cwd);
102
+ try {
103
+ for (const buffer of source.buffers()) hash.update(buffer);
104
+ } finally {
105
+ source.dispose();
106
+ }
107
+ return hash;
108
+ }
package/src/git.js CHANGED
@@ -3,6 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync
3
3
  import { createHash } from 'node:crypto';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, isAbsolute, join, resolve } from 'node:path';
6
+ import { updateGitHash } from './git-spool.js';
6
7
 
7
8
  // Big repos can produce multi-MB diffs; raise the default 1MB child-process
8
9
  // buffer and surface a contextual error if the explicit limit is exceeded.
@@ -73,8 +74,11 @@ export function getStagedDiff(cwd, contextLines) {
73
74
  // ids) so a long-running AI/review step cannot silently commit a different
74
75
  // index from the one used to generate the message.
75
76
  export function getIndexFingerprint(cwd) {
76
- const patch = readGit(['diff', '--staged', '--binary', '--full-index', '--no-ext-diff'], cwd);
77
- return createHash('sha256').update(patch).digest('hex');
77
+ return updateGitHash(
78
+ createHash('sha256'),
79
+ ['diff', '--staged', '--binary', '--full-index', '--no-ext-diff'],
80
+ cwd,
81
+ ).digest('hex');
78
82
  }
79
83
 
80
84
  function hashFileOrMissing(path) {
@@ -428,7 +432,7 @@ export function protectSensitiveDiff(diff) {
428
432
  return { diff: out.join(''), findings: [...new Set(findings)] };
429
433
  }
430
434
 
431
- export function runGit(args, projectRoot, inherit = false) {
435
+ export function runGit(args, projectRoot, inherit = false, input = undefined) {
432
436
  try {
433
437
  const stdio =
434
438
  inherit === 'stderr'
@@ -436,7 +440,7 @@ export function runGit(args, projectRoot, inherit = false) {
436
440
  : inherit
437
441
  ? 'inherit'
438
442
  : 'pipe';
439
- execFileSync('git', args, { cwd: projectRoot, stdio });
443
+ execFileSync('git', args, { cwd: projectRoot, stdio, input });
440
444
  } catch (err) {
441
445
  const detail =
442
446
  typeof err.stderr === 'string' ? err.stderr.trim() : err.stderr?.toString('utf-8').trim();
@@ -445,6 +449,23 @@ export function runGit(args, projectRoot, inherit = false) {
445
449
  }
446
450
  }
447
451
 
452
+ export function pathBatches(paths, limit = 6000) {
453
+ const batches = [];
454
+ let batch = [];
455
+ let size = 0;
456
+ for (const path of paths) {
457
+ if (batch.length && size + path.length * 2 + 4 > limit) {
458
+ batches.push(batch);
459
+ batch = [];
460
+ size = 0;
461
+ }
462
+ batch.push(path);
463
+ size += path.length * 2 + 4;
464
+ }
465
+ if (batch.length) batches.push(batch);
466
+ return batches;
467
+ }
468
+
448
469
  export function hasHead(projectRoot) {
449
470
  try {
450
471
  execFileSync('git', ['rev-parse', '--verify', 'HEAD'], {
@@ -0,0 +1,271 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isLockFile, matchStripPattern } from './git.js';
3
+ import { ERROR_CATEGORIES, fail } from './errors.js';
4
+
5
+ // These are evidence-selection rules, not exclusions from Git or secret scans.
6
+ export function isGeneratedFile(path) {
7
+ return (
8
+ /(?:^|\/)(?:dist|build|coverage|vendor|node_modules)\//.test(path) ||
9
+ /(?:\.min\.(?:js|css)|\.map|\.snap|\.generated\.[^/]+)$/.test(path)
10
+ );
11
+ }
12
+
13
+ export function isDeepAnalysis(config) {
14
+ return config.largeChange?.strategy === 'deep';
15
+ }
16
+
17
+ function clip(text, bytes) {
18
+ if (Buffer.byteLength(text) <= bytes) return text;
19
+ let result = '';
20
+ let size = 0;
21
+ for (const char of text) {
22
+ size += Buffer.byteLength(char);
23
+ if (size > Math.max(0, bytes - 3)) break;
24
+ result += char;
25
+ }
26
+ return result + '…';
27
+ }
28
+
29
+ function category(file, config) {
30
+ if (isLockFile(file.path)) return 'lockfile';
31
+ if (matchStripPattern(file.path, config.stripFiles)) return 'excluded';
32
+ if (isGeneratedFile(file.path)) return 'generated';
33
+ if (/(?:^|\/)(?:test|tests|__tests__)\/|\.(?:test|spec)\./.test(file.path)) return 'test';
34
+ if (/\.(?:md|rst|txt)$/.test(file.path)) return 'documentation';
35
+ if (/(?:^|\/)(?:[^/]*config[^/]*|package\.json)$|\.(?:ya?ml|toml)$/.test(file.path))
36
+ return 'configuration';
37
+ return 'source';
38
+ }
39
+
40
+ function moduleOf(path) {
41
+ const parts = path.split('/');
42
+ return parts.length === 1 ? '.' : parts.slice(0, Math.min(parts.length - 1, 2)).join('/');
43
+ }
44
+
45
+ export function localInputBytes(config) {
46
+ // UTF-8 bytes also bound non-ASCII input. Leave space for JSON escaping,
47
+ // repository context, policy, and the provider's message envelope.
48
+ return Math.max(
49
+ 256,
50
+ Math.min(
51
+ config.maxDiffChars || 30000,
52
+ Math.floor((config.analysisBudget?.limits.chunkInputTokens || 12000) * 0.6),
53
+ ),
54
+ );
55
+ }
56
+
57
+ function summaryOf(fact, evidence = true) {
58
+ return {
59
+ id: fact.id,
60
+ kind: fact.kind,
61
+ status: fact.status,
62
+ module: clip(fact.module, 160),
63
+ fileCount: fact.files.length,
64
+ additions: fact.additions,
65
+ deletions: fact.deletions,
66
+ ...(fact.modeChange ? { modeChange: fact.modeChange } : {}),
67
+ ...(fact.binary ? { binary: true } : {}),
68
+ examples: fact.files.slice(0, 2).map((path) => clip(path, 180)),
69
+ ...(evidence && fact.evidence ? { representativeExcerpt: fact.evidence } : {}),
70
+ };
71
+ }
72
+
73
+ const SAMPLE_NOTICE =
74
+ 'Local change inventory with selected excerpts, not complete semantic analysis. ' +
75
+ 'Counts cover every file. Excerpts are partial; omitted content and paths are not evidence of unchanged behavior. ' +
76
+ 'Identical textual edits are grouped within a module. Describe supported changes conservatively; do not infer intent.';
77
+
78
+ function ranked(facts) {
79
+ const priority = { source: 0, configuration: 1, test: 2, documentation: 3 };
80
+ return [...facts].sort(
81
+ (a, b) =>
82
+ (priority[a.kind] ?? 4) - (priority[b.kind] ?? 4) ||
83
+ b.files.length - a.files.length ||
84
+ a.id.localeCompare(b.id),
85
+ );
86
+ }
87
+
88
+ export function localOverview(config, facts) {
89
+ const kinds = {};
90
+ const statuses = {};
91
+ let totalFiles = 0;
92
+ let additions = 0;
93
+ let deletions = 0;
94
+ let modeChangedFiles = 0;
95
+ let binaryFiles = 0;
96
+ for (const fact of facts) {
97
+ totalFiles += fact.files.length;
98
+ additions += fact.additions;
99
+ deletions += fact.deletions;
100
+ if (fact.modeChange) modeChangedFiles += fact.files.length;
101
+ if (fact.binary) binaryFiles += fact.files.length;
102
+ kinds[fact.kind] = (kinds[fact.kind] || 0) + fact.files.length;
103
+ statuses[fact.status] = (statuses[fact.status] || 0) + fact.files.length;
104
+ }
105
+ const data = {
106
+ notice: SAMPLE_NOTICE,
107
+ totalFiles,
108
+ additions,
109
+ deletions,
110
+ modeChangedFiles,
111
+ binaryFiles,
112
+ lineCounts: 'tracked diff only',
113
+ kinds,
114
+ statuses,
115
+ totalGroups: facts.length,
116
+ omittedGroups: facts.length,
117
+ samples: [],
118
+ };
119
+ const cap = localInputBytes(config);
120
+ let sampledFiles = 0;
121
+ const seen = new Set();
122
+ const ordered = ranked(facts);
123
+ // First cover distinct modules/categories, then use remaining space for detail.
124
+ const diverse = [];
125
+ const rest = [];
126
+ for (const fact of ordered) {
127
+ const key = JSON.stringify([fact.module, fact.kind]);
128
+ if (seen.has(key)) rest.push(fact);
129
+ else {
130
+ seen.add(key);
131
+ diverse.push(fact);
132
+ }
133
+ }
134
+ const firstKinds = new Set();
135
+ const first = [];
136
+ const remaining = [];
137
+ for (const fact of diverse) {
138
+ if (firstKinds.has(fact.kind)) remaining.push(fact);
139
+ else {
140
+ firstKinds.add(fact.kind);
141
+ first.push(fact);
142
+ }
143
+ }
144
+ for (const fact of [...first, ...remaining, ...rest]) {
145
+ if (data.samples.length >= 16) break;
146
+ const sample = summaryOf(fact);
147
+ data.samples.push(sample);
148
+ if (Buffer.byteLength(JSON.stringify(data)) > cap) {
149
+ data.samples.pop();
150
+ continue;
151
+ }
152
+ if (fact.evidence) sampledFiles++;
153
+ data.omittedGroups--;
154
+ }
155
+ // A tiny configured budget may not even fit aggregate metadata. Fail before
156
+ // sending rather than silently slicing JSON or dropping the uncertainty notice.
157
+ const text = JSON.stringify(data);
158
+ if (Buffer.byteLength(text) > cap)
159
+ throw fail(
160
+ ERROR_CATEGORIES.CONFIG,
161
+ 'Local change inventory does not fit the input budget. Increase largeChange.chunkInputTokens or maxDiffChars.',
162
+ );
163
+ return { text, sampledFiles };
164
+ }
165
+
166
+ export function analyzeLocally(config, capture, protect, previews) {
167
+ const records = new Map(
168
+ capture.manifest.map((file) => [
169
+ file.id,
170
+ {
171
+ file,
172
+ hash: createHash('sha256'),
173
+ evidence: '',
174
+ textual: false,
175
+ },
176
+ ]),
177
+ );
178
+ for (const unit of capture.units(protect, previews)) {
179
+ if (unit.metadataOnly) continue;
180
+ const record = records.get(unit.fileId);
181
+ // Tracked fragments carry Git headers; untracked fragments are plain text.
182
+ const section = unit.id.match(/S\d+(?=P\d+$)/)?.[0];
183
+ if (section && section !== record.section) {
184
+ record.section = section;
185
+ record.inHunk = false;
186
+ }
187
+ for (const line of unit.text.split(/(?<=\n)/)) {
188
+ if (section) {
189
+ if (line.startsWith('@@')) record.inHunk = true;
190
+ if (!record.inHunk || !/^[+-]/.test(line)) continue;
191
+ }
192
+ record.hash.update(line);
193
+ record.textual = true;
194
+ const remaining = 900 - Buffer.byteLength(record.evidence);
195
+ if (remaining > 3) record.evidence += clip(line, remaining);
196
+ }
197
+ }
198
+ const grouped = new Map();
199
+ for (const { file, hash, evidence, textual } of records.values()) {
200
+ const kind = category(file, config);
201
+ const module = moduleOf(file.addPaths?.at(-1) || file.path);
202
+ const status = file.status.replace(/\d+$/, '');
203
+ const metadata = ['lockfile', 'excluded', 'generated'].includes(kind);
204
+ const modeChange =
205
+ file.oldMode && file.newMode && file.oldMode !== file.newMode
206
+ ? `${file.oldMode} -> ${file.newMode}`
207
+ : null;
208
+ // Unknown/binary changes and renames stay independent for split safety.
209
+ const identity = metadata
210
+ ? 'metadata'
211
+ : textual && !status.startsWith('R')
212
+ ? hash.digest('hex')
213
+ : file.id;
214
+ const key = JSON.stringify([kind, module, status, identity, modeChange, !!file.binary]);
215
+ let fact = grouped.get(key);
216
+ if (!fact) {
217
+ fact = {
218
+ id: file.id,
219
+ kind,
220
+ module,
221
+ status,
222
+ files: [],
223
+ additions: 0,
224
+ deletions: 0,
225
+ modeChange,
226
+ binary: !!file.binary,
227
+ evidence: metadata ? '' : evidence,
228
+ };
229
+ grouped.set(key, fact);
230
+ }
231
+ fact.files.push(file.path);
232
+ fact.additions += file.additions;
233
+ fact.deletions += file.deletions;
234
+ }
235
+ const facts = [...grouped.values()];
236
+ const overview = localOverview(config, facts);
237
+ return {
238
+ facts,
239
+ summary: overview.text,
240
+ coverage: {
241
+ strategy: 'auto',
242
+ totalFiles: capture.manifest.length,
243
+ analyzedFiles: 0,
244
+ sampledFiles: overview.sampledFiles,
245
+ metadataOnlyFiles: capture.manifest.length - overview.sampledFiles,
246
+ failedFiles: 0,
247
+ completedChunks: 0,
248
+ },
249
+ };
250
+ }
251
+
252
+ export function localPlanItems(config, facts, cap) {
253
+ const error = () =>
254
+ fail(
255
+ ERROR_CATEGORIES.CONFIG,
256
+ 'The complete split candidate inventory does not fit one request. Stage a smaller logical change or explicitly set personal largeChange.strategy to deep. No planning request was sent.',
257
+ );
258
+ if (facts.length > (config.splitMaxPlanFiles || 100)) throw error();
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;
271
+ }
package/src/main.js CHANGED
@@ -6,9 +6,7 @@ import chalk from 'chalk';
6
6
  import { parseArgs } from './cli.js';
7
7
  import { getProjectRoot, loadConfig } from './config.js';
8
8
  import {
9
- getStagedDiff,
10
9
  getChangedFiles,
11
- getDiffStats,
12
10
  getBranch,
13
11
  gitCommit,
14
12
  stripLockFileContent,
@@ -21,7 +19,16 @@ import {
21
19
  getIndexFingerprint,
22
20
  createIndexTransaction,
23
21
  protectSensitiveDiff,
22
+ unifiedArg,
24
23
  } from './git.js';
24
+ import {
25
+ captureChanges,
26
+ needsAnalysis,
27
+ analysisConfig,
28
+ analyzeChanges,
29
+ summarizeChanges,
30
+ } from './change-analysis.js';
31
+ import { cleanupGitSpools } from './git-spool.js';
25
32
  import { generateCommitMessage } from './api.js';
26
33
  import {
27
34
  statusColor,
@@ -41,7 +48,13 @@ import {
41
48
  stringifyConfigRedacted,
42
49
  redactSensitiveUrl,
43
50
  } from './utils.js';
44
- import { abortSplit, applySplitPlan, resumeSplit, splitFlow } from './split.js';
51
+ import {
52
+ abortSplit,
53
+ applySplitPlan,
54
+ resumeSplit,
55
+ splitFlow,
56
+ getStagedChangedFiles,
57
+ } from './split.js';
45
58
  import { runModelTask } from './generation-ui.js';
46
59
  import { runSetup } from './setup.js';
47
60
  import { detectProviderType } from './providers.js';
@@ -357,8 +370,7 @@ async function runMain() {
357
370
  indexTransaction ||= createIndexTransaction(projectRoot);
358
371
  return indexTransaction;
359
372
  };
360
- let diff = getStagedDiff(projectRoot, config.diffContextLines);
361
- if (!diff) {
373
+ if (!getChangedFiles(projectRoot).length) {
362
374
  // Nothing staged. But git diff --staged is also empty for unstaged work
363
375
  // and untracked files — surface what git status actually shows instead of
364
376
  // falsely claiming there's nothing to commit.
@@ -429,7 +441,14 @@ async function runMain() {
429
441
 
430
442
  try {
431
443
  beginIndexTransaction();
432
- runGit(toStage ? ['add', '--', ...toStage] : ['add', '-A'], projectRoot);
444
+ runGit(
445
+ toStage
446
+ ? ['--literal-pathspecs', 'add', '--pathspec-from-file=-', '--pathspec-file-nul']
447
+ : ['add', '-A'],
448
+ projectRoot,
449
+ false,
450
+ toStage ? toStage.join('\0') + '\0' : undefined,
451
+ );
433
452
  indexTransaction.markOwned();
434
453
  } catch (err) {
435
454
  indexTransaction?.restore({ force: true });
@@ -443,8 +462,7 @@ async function runMain() {
443
462
  });
444
463
  }
445
464
 
446
- diff = getStagedDiff(projectRoot, config.diffContextLines);
447
- if (!diff) {
465
+ if (!getChangedFiles(projectRoot).length) {
448
466
  console.log('\n ' + chalk.yellow('✗ Nothing staged — no diff to commit.\n'));
449
467
  throw fail(ERROR_CATEGORIES.GIT_STATE, 'Nothing staged; no diff to commit.', {
450
468
  reported: true,
@@ -455,7 +473,14 @@ async function runMain() {
455
473
  // Re-read the final diff between two complete-index fingerprints so the
456
474
  // prompt is guaranteed to describe one stable staged snapshot.
457
475
  const plannedIndexFingerprint = getIndexFingerprint(projectRoot);
458
- diff = getStagedDiff(projectRoot, config.diffContextLines);
476
+ const changedFiles = getChangedFiles(projectRoot);
477
+ const captured = captureChanges(
478
+ [['diff', unifiedArg(config.diffContextLines), '--staged']],
479
+ projectRoot,
480
+ getStagedChangedFiles(projectRoot),
481
+ config,
482
+ );
483
+ const diff = captured.diff || '';
459
484
  if (getIndexFingerprint(projectRoot) !== plannedIndexFingerprint) {
460
485
  console.log(
461
486
  '\n ' + chalk.red('✗ The staged changes are being modified concurrently; commit aborted.\n'),
@@ -467,8 +492,7 @@ async function runMain() {
467
492
  );
468
493
  }
469
494
 
470
- const stats = getDiffStats(diff);
471
- const changedFiles = getChangedFiles(projectRoot);
495
+ const stats = captured.stats;
472
496
  const branch = getBranch(projectRoot);
473
497
  const stageIcon = chalk.green('staged');
474
498
  const changeStr = chalk.green(`+${stats.additions}`) + ' ' + chalk.red(`-${stats.deletions}`);
@@ -499,7 +523,9 @@ async function runMain() {
499
523
  // Protect common secrets before any repository content leaves the machine.
500
524
  // The protected diff affects only the model request, never the actual index.
501
525
  const protectedInput = protectSensitiveDiff(diff);
526
+ protectedInput.findings = [...new Set([...protectedInput.findings, ...captured.findings])];
502
527
  let diffForModel = diff;
528
+ let protectAnalysis = true;
503
529
  if (protectedInput.findings.length) {
504
530
  warnings.push('Sensitive data was detected and protected before the provider request.');
505
531
  console.log('\n ' + chalk.yellow.bold('⚠ Potential sensitive data detected:'));
@@ -529,21 +555,35 @@ async function runMain() {
529
555
  return finishCancelled();
530
556
  }
531
557
  if (sensitiveAction === 'protect') diffForModel = protectedInput.diff;
558
+ if (sensitiveAction === 'original') protectAnalysis = false;
532
559
  }
533
560
 
534
- // Prepare the diff the model sees (computed once — it doesn't change across
535
- // regenerations): lock-file and stripFiles contents are stubbed (they carry
536
- // no commit intent) and oversized diffs are condensed to a --stat summary
537
- // plus truncated hunks, so token spend stays proportional to what the
538
- // model needs.
561
+ // Small changes keep the existing prompt. Large changes use a local
562
+ // inventory by default; exhaustive model analysis is an explicit opt-in.
563
+ const large = needsAnalysis(captured, config);
539
564
  const strippedDiff = stripLockFileContent(diffForModel, config.stripFiles);
540
- const { diff: modelDiff, truncated } = condenseDiff(
541
- strippedDiff,
542
- config.maxDiffChars,
543
- getDiffStat(projectRoot),
544
- config.maxFileDiffChars,
545
- );
546
- if (truncated) {
565
+ let { diff: modelDiff, truncated } = large
566
+ ? { diff: '', truncated: false }
567
+ : condenseDiff(
568
+ strippedDiff,
569
+ config.maxDiffChars,
570
+ getDiffStat(projectRoot),
571
+ config.maxFileDiffChars,
572
+ );
573
+ let analysis;
574
+ let analyzedFacts;
575
+ let generationConfig = config;
576
+ if (large) {
577
+ generationConfig = analysisConfig(config);
578
+ console.log(
579
+ chalk.dim(
580
+ generationConfig.largeChange?.strategy === 'deep'
581
+ ? ` Large change: analyzing all ${changedFiles.length} files in bounded chunks.`
582
+ : ` Large change: building a local inventory of ${changedFiles.length} files with bounded excerpts.`,
583
+ ),
584
+ );
585
+ }
586
+ if (truncated && !large) {
547
587
  warnings.push('The diff was condensed to fit the configured provider input limit.');
548
588
  console.log(
549
589
  chalk.dim(
@@ -575,8 +615,41 @@ async function runMain() {
575
615
  machineOutput,
576
616
  cancelMessage: 'Commit cancelled.',
577
617
  failureMessage: 'API call failed',
578
- task: (stream) =>
579
- generateCommitMessage(config, modelDiff, regenerateCount, message, stream),
618
+ task: async (stream) => {
619
+ if (large && !analysis) {
620
+ analyzedFacts ||= await analyzeChanges(
621
+ generationConfig,
622
+ captured,
623
+ protectAnalysis,
624
+ null,
625
+ ({ completedChunks }) =>
626
+ console.error(` Analysis: ${completedChunks} chunks completed`),
627
+ );
628
+ modelDiff =
629
+ analyzedFacts.summary ||
630
+ (await summarizeChanges(generationConfig, analyzedFacts.facts));
631
+ analysis = analyzedFacts;
632
+ console.error(
633
+ ` Coverage: ${analysis.coverage.analyzedFiles} files analyzed; ${analysis.coverage.sampledFiles || 0} representative excerpts; ${analysis.coverage.metadataOnlyFiles} metadata only.`,
634
+ );
635
+ if (analysis.coverage.strategy === 'auto')
636
+ warnings.push(
637
+ 'Large changes were summarized locally with selected excerpts; content was not fully analyzed.',
638
+ );
639
+ }
640
+ const result = await generateCommitMessage(
641
+ generationConfig,
642
+ modelDiff,
643
+ regenerateCount,
644
+ message,
645
+ stream,
646
+ );
647
+ if (large) {
648
+ result.usage = generationConfig.analysisBudget.snapshot().usage;
649
+ result.elapsed = generationConfig.analysisBudget.snapshot().elapsedMs;
650
+ }
651
+ return result;
652
+ },
580
653
  successMessage(result) {
581
654
  let done = `Generated in ${chalk.bold(formatMs(result.elapsed))}`;
582
655
  if (result.usage) done += chalk.dim(` · tokens: ${formatUsage(result.usage)}`);
@@ -697,6 +770,13 @@ async function runMain() {
697
770
  usage,
698
771
  warnings,
699
772
  exitReason: 'dry_run',
773
+ ...(analysis
774
+ ? {
775
+ data: {
776
+ analysis: { ...analysis.coverage, ...generationConfig.analysisBudget.snapshot() },
777
+ },
778
+ }
779
+ : {}),
700
780
  committed: false,
701
781
  edited: wasEdited,
702
782
  rewrites: regenerateCount + automaticCorrectionCount,
@@ -736,6 +816,13 @@ async function runMain() {
736
816
  usage,
737
817
  warnings,
738
818
  exitReason: 'success',
819
+ ...(analysis
820
+ ? {
821
+ data: {
822
+ analysis: { ...analysis.coverage, ...generationConfig.analysisBudget.snapshot() },
823
+ },
824
+ }
825
+ : {}),
739
826
  committed: true,
740
827
  edited: wasEdited,
741
828
  rewrites: regenerateCount + automaticCorrectionCount,
@@ -767,5 +854,7 @@ export async function main() {
767
854
  edited: false,
768
855
  rewrites: 0,
769
856
  };
857
+ } finally {
858
+ cleanupGitSpools();
770
859
  }
771
860
  }