@hifullmoon/aicommit 1.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.
Files changed (57) hide show
  1. package/.aicommit.config.example.json +118 -0
  2. package/CHANGELOG.md +131 -0
  3. package/LICENSE +21 -0
  4. package/README.md +406 -0
  5. package/README.zh-CN.md +408 -0
  6. package/SECURITY.md +33 -0
  7. package/bin/aicommit.js +47 -0
  8. package/docs/distribution.md +104 -0
  9. package/docs/examples/aicommit-policy.yml +24 -0
  10. package/docs/examples/commit-msg +4 -0
  11. package/docs/examples/extension/aicommit-extension.json +9 -0
  12. package/docs/examples/extension/index.mjs +32 -0
  13. package/docs/extensions.md +93 -0
  14. package/docs/privacy.md +58 -0
  15. package/docs/provider-compatibility.md +37 -0
  16. package/docs/provider-presets.md +100 -0
  17. package/docs/team-policy.md +86 -0
  18. package/docs/troubleshooting.md +37 -0
  19. package/package.json +100 -0
  20. package/presets/provider-presets.json +60 -0
  21. package/schemas/aicommit-extension.schema.json +27 -0
  22. package/schemas/aicommit-output.schema.json +86 -0
  23. package/schemas/aicommit-provider-presets.schema.json +56 -0
  24. package/schemas/aicommit-split-checkpoint.schema.json +92 -0
  25. package/schemas/aicommit-split-plan.schema.json +108 -0
  26. package/schemas/aicommit-team-policy.schema.json +59 -0
  27. package/src/api.js +740 -0
  28. package/src/cli.js +558 -0
  29. package/src/completion.js +136 -0
  30. package/src/config-command.js +59 -0
  31. package/src/config.js +524 -0
  32. package/src/context.js +537 -0
  33. package/src/credentials.js +123 -0
  34. package/src/doctor.js +131 -0
  35. package/src/errors.js +96 -0
  36. package/src/extension-runner.mjs +36 -0
  37. package/src/extensions.js +426 -0
  38. package/src/generation-ui.js +53 -0
  39. package/src/git.js +458 -0
  40. package/src/main.js +768 -0
  41. package/src/metrics.js +375 -0
  42. package/src/output.js +87 -0
  43. package/src/policy-command.js +172 -0
  44. package/src/policy.js +421 -0
  45. package/src/preset-command.js +91 -0
  46. package/src/provider-presets.js +361 -0
  47. package/src/providers.js +306 -0
  48. package/src/setup.js +268 -0
  49. package/src/split-checkpoint.js +252 -0
  50. package/src/split-hunks.js +263 -0
  51. package/src/split-plan.js +339 -0
  52. package/src/split.js +2285 -0
  53. package/src/team-policy.js +95 -0
  54. package/src/trust.js +31 -0
  55. package/src/ui.js +488 -0
  56. package/src/utils.js +165 -0
  57. package/templates/.aicommit.policy.json +22 -0
package/src/split.js ADDED
@@ -0,0 +1,2285 @@
1
+ import {
2
+ openSync,
3
+ readSync,
4
+ closeSync,
5
+ fstatSync,
6
+ lstatSync,
7
+ mkdtempSync,
8
+ readlinkSync,
9
+ realpathSync,
10
+ rmSync,
11
+ constants as fsConstants,
12
+ } from 'node:fs';
13
+ import { createHash } from 'node:crypto';
14
+ import { execFileSync } from 'node:child_process';
15
+ import { tmpdir } from 'node:os';
16
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
17
+
18
+ import chalk from 'chalk';
19
+ import editor from '@inquirer/editor';
20
+
21
+ import { getResponseText, generateCommitMessage } from './api.js';
22
+ import {
23
+ getBranch,
24
+ hasHead,
25
+ runGit,
26
+ readGit,
27
+ stripLockFileContent,
28
+ isLockFile,
29
+ matchStripPattern,
30
+ unifiedArg,
31
+ protectSensitiveDiff,
32
+ protectSensitiveText,
33
+ isSensitiveFile,
34
+ } from './git.js';
35
+ import { statusColor, statusIcon, highlightMessage, vimSelect, vimCheckbox } from './ui.js';
36
+ import {
37
+ cleanCommitMessage,
38
+ formatMs,
39
+ formatUsage,
40
+ indentError,
41
+ sanitizeTerminalText,
42
+ } from './utils.js';
43
+ import { ERROR_CATEGORIES, fail } from './errors.js';
44
+ import { DEFAULT_COMMIT_POLICY, normalizeCommitPolicy, validateCommitCandidate } from './policy.js';
45
+ import {
46
+ applyCommitlintPolicy,
47
+ collectRepositoryContext,
48
+ repositoryContextSummary,
49
+ } from './context.js';
50
+ import { encodeUntrustedData } from './trust.js';
51
+ import {
52
+ createSplitPlanArtifact,
53
+ readSplitPlanArtifact,
54
+ writeSplitPlanArtifact,
55
+ } from './split-plan.js';
56
+ import {
57
+ assertNoSplitCheckpoint,
58
+ createSplitCheckpoint,
59
+ discardSplitCheckpoint,
60
+ readSplitCheckpoint,
61
+ removeSplitCheckpoint,
62
+ splitCheckpointPath,
63
+ writeSplitCheckpoint,
64
+ } from './split-checkpoint.js';
65
+ import {
66
+ discoverSplitHunks,
67
+ fallbackHunkGroups,
68
+ stripHunkCatalog,
69
+ validateHunkTransaction,
70
+ } from './split-hunks.js';
71
+ import { extensionHostFor } from './extensions.js';
72
+ import { runModelTask } from './generation-ui.js';
73
+
74
+ // ═══════════════════════════════════════════════════════════════════════════
75
+ // Split mode: group changes into multiple logical commits
76
+ // ═══════════════════════════════════════════════════════════════════════════
77
+
78
+ // Cap the diff sent to the grouping call — the model only needs enough
79
+ // context to assign files to groups, not every line of a huge diff.
80
+ const SPLIT_MAX_DIFF_CHARS = 16000;
81
+
82
+ // Cap the number of files shown to the grouping call. A huge change (e.g.
83
+ // hundreds of vendored assets) would otherwise force the model to enumerate
84
+ // every path in its JSON reply — blowing past maxTokens and truncating the
85
+ // plan mid-array. Files beyond the cap are dropped from the prompt; any file
86
+ // the model does not list is swept into the catch-all group by normalizePlan.
87
+ const SPLIT_MAX_PLAN_FILES = 100;
88
+
89
+ function requireNoSplitCheckpoint(projectRoot) {
90
+ try {
91
+ assertNoSplitCheckpoint(projectRoot);
92
+ } catch (err) {
93
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `Cannot start a new split: ${err.message}`, {
94
+ cause: err,
95
+ });
96
+ }
97
+ }
98
+
99
+ function pathIsWithin(parent, candidate) {
100
+ const rel = relative(parent, candidate);
101
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
102
+ }
103
+
104
+ function canonicalDestination(path) {
105
+ let cursor = resolve(path);
106
+ const suffix = [];
107
+ while (true) {
108
+ try {
109
+ return join(realpathSync(cursor), ...suffix);
110
+ } catch {
111
+ const parent = dirname(cursor);
112
+ if (parent === cursor) return resolve(path);
113
+ suffix.unshift(basename(cursor));
114
+ cursor = parent;
115
+ }
116
+ }
117
+ }
118
+
119
+ function safeExportPlanPath(projectRoot, path) {
120
+ const absolute = resolve(path);
121
+ const rawGitDir = readGit(['rev-parse', '--git-dir'], projectRoot).trim();
122
+ const gitDir = isAbsolute(rawGitDir) ? rawGitDir : resolve(projectRoot, rawGitDir);
123
+ const canonicalRoot = realpathSync(projectRoot);
124
+ const canonicalGitDir = canonicalDestination(gitDir);
125
+ const canonicalPlan = canonicalDestination(absolute);
126
+ if (pathIsWithin(canonicalRoot, canonicalPlan) && !pathIsWithin(canonicalGitDir, canonicalPlan)) {
127
+ throw fail(
128
+ ERROR_CATEGORIES.CONFIG,
129
+ 'Split plan output must be outside the working tree or inside the repository Git directory.',
130
+ );
131
+ }
132
+ return absolute;
133
+ }
134
+
135
+ // Condense a changed-file list for the grouping prompt: keep the first `cap`
136
+ // files and note the rest. The model is told the hidden files are collected
137
+ // automatically, so it never needs to enumerate a huge list — this keeps its
138
+ // reply small enough to stay within maxTokens. Files beyond the cap still
139
+ // reach the commit (via the catch-all group in normalizePlan); they just
140
+ // aren't shown to the model.
141
+ export function condenseFileList(files, cap = SPLIT_MAX_PLAN_FILES) {
142
+ const shown = files.slice(0, cap);
143
+ const hidden = files.length - shown.length;
144
+ const list = shown
145
+ .map((f) => {
146
+ const hunks = f.hunks?.length
147
+ ? ` [hunks: ${f.hunks
148
+ .map((hunk) => `${hunk.id}(-${hunk.oldStart},+${hunk.newStart})`)
149
+ .join(', ')}]`
150
+ : '';
151
+ return `${f.status} ${f.path}${hunks}`;
152
+ })
153
+ .join('\n');
154
+ if (hidden <= 0) return list;
155
+ return `${list}\n... and ${hidden} more files (not shown — they will be collected into a final catch-all commit)`;
156
+ }
157
+
158
+ // All changes: staged, unstaged and untracked (porcelain -z avoids quoting
159
+ // issues with special characters in paths). Runs at the repo root so paths
160
+ // are root-relative — executeSplit stages them from projectRoot.
161
+ export function getAllChangedFiles(cwd) {
162
+ const out = readGit(['status', '--porcelain', '-z', '-uall'], cwd);
163
+ if (!out) return [];
164
+ const entries = out.split('\0').filter(Boolean);
165
+ const files = [];
166
+ for (let i = 0; i < entries.length; i++) {
167
+ const entry = entries[i];
168
+ const status = entry.slice(0, 2);
169
+ const path = entry.slice(3);
170
+ // porcelain -z prints renames/copies as "<dest>\0<src>\0". Keep both
171
+ // paths: committing only the destination would drop the source's
172
+ // deletion. They are shown as one "src → dest" entry (so the model
173
+ // groups them together) and carried in addPaths for git add/diff.
174
+ if (status.includes('R') || status.includes('C')) {
175
+ const dest = path;
176
+ const src = entries[++i];
177
+ files.push({ status: status.trim() || '?', path: `${src} → ${dest}`, addPaths: [src, dest] });
178
+ } else {
179
+ files.push({ status: status.trim() || '?', path, addPaths: [path] });
180
+ }
181
+ }
182
+ return files;
183
+ }
184
+
185
+ // Index-only split scope. The first porcelain status column describes the
186
+ // staged snapshot; working-tree-only and untracked changes are deliberately
187
+ // excluded so --scope=staged never crosses the user's index boundary.
188
+ export function getStagedChangedFiles(cwd) {
189
+ const out = readGit(['status', '--porcelain', '-z', '-uno'], cwd);
190
+ if (!out) return [];
191
+ const entries = out.split('\0').filter(Boolean);
192
+ const files = [];
193
+ for (let i = 0; i < entries.length; i++) {
194
+ const entry = entries[i];
195
+ const indexStatus = entry[0];
196
+ const status = entry.slice(0, 2);
197
+ const path = entry.slice(3);
198
+ if (status.includes('R') || status.includes('C')) {
199
+ const dest = path;
200
+ const src = entries[++i];
201
+ if (indexStatus !== ' ' && indexStatus !== '?' && indexStatus !== '!') {
202
+ files.push({ status: indexStatus, path: `${src} → ${dest}`, addPaths: [src, dest] });
203
+ }
204
+ } else if (indexStatus !== ' ' && indexStatus !== '?' && indexStatus !== '!') {
205
+ files.push({ status: indexStatus, path, addPaths: [path] });
206
+ }
207
+ }
208
+ return files;
209
+ }
210
+
211
+ export function getSplitChangedFiles(cwd, scope = 'all') {
212
+ return scope === 'staged' ? getStagedChangedFiles(cwd) : getAllChangedFiles(cwd);
213
+ }
214
+
215
+ // Diff of the final working-tree state. With HEAD, one diff against HEAD
216
+ // naturally includes staged and unstaged edits. On an unborn branch Git has
217
+ // no tree to compare against, so concatenate index-vs-empty and
218
+ // worktree-vs-index. This matters when a newly staged file is edited again:
219
+ // the planner must see the same latest content executeSplit will git add -A.
220
+ function getWorkingTreeDiff(projectRoot, head, contextLines, paths = []) {
221
+ const u = unifiedArg(contextLines);
222
+ const pathArgs = paths.length ? ['--', ...paths] : [];
223
+ if (head) {
224
+ return readGit(['diff', u, 'HEAD', ...pathArgs], projectRoot).trim();
225
+ }
226
+
227
+ const cached = readGit(['diff', u, '--cached', ...pathArgs], projectRoot).trim();
228
+ const unstaged = readGit(['diff', u, ...pathArgs], projectRoot).trim();
229
+ return [cached, unstaged].filter(Boolean).join('\n');
230
+ }
231
+
232
+ export function getSplitDiff(projectRoot, head, contextLines, scope = 'all', paths = []) {
233
+ if (scope === 'staged') {
234
+ const pathArgs = paths.length ? ['--', ...paths] : [];
235
+ return readGit(['diff', unifiedArg(contextLines), '--cached', ...pathArgs], projectRoot).trim();
236
+ }
237
+ return getWorkingTreeDiff(projectRoot, head, contextLines, paths);
238
+ }
239
+
240
+ // Fingerprint every tracked patch plus untracked file bytes. Split planning
241
+ // can spend minutes in the model/review loop; before execution we must ensure
242
+ // it still describes the exact worktree state that will be staged.
243
+ export function getSplitStateFingerprint(projectRoot, head, files, scope = 'all') {
244
+ const hash = createHash('sha256');
245
+ if (scope === 'staged') {
246
+ hash.update(head ? readGit(['rev-parse', 'HEAD'], projectRoot).trim() : '<unborn>');
247
+ hash.update(readGit(['ls-files', '--stage', '-z'], projectRoot));
248
+ return hash.digest('hex');
249
+ }
250
+ files ||= getAllChangedFiles(projectRoot);
251
+ hash.update(readGit(['status', '--porcelain', '-z', '-uall'], projectRoot));
252
+ if (head) {
253
+ hash.update(readGit(['diff', '--binary', '--full-index', 'HEAD'], projectRoot));
254
+ } else {
255
+ hash.update(readGit(['diff', '--binary', '--full-index', '--cached'], projectRoot));
256
+ hash.update(readGit(['diff', '--binary', '--full-index'], projectRoot));
257
+ }
258
+
259
+ const buffer = Buffer.alloc(64 * 1024);
260
+ for (const file of files) {
261
+ if (file.status !== '??' && file.status !== '?') continue;
262
+ const path = file.addPaths?.[0] || file.path;
263
+ hash.update(`\0${path}\0`);
264
+ let fd;
265
+ try {
266
+ const fullPath = join(projectRoot, path);
267
+ const stat = lstatSync(fullPath);
268
+ if (stat.isSymbolicLink()) {
269
+ // Git stores the link target text, not the target file's bytes. Never
270
+ // follow an untracked link while fingerprinting repository state.
271
+ hash.update('<symlink>');
272
+ hash.update(readlinkSync(fullPath));
273
+ continue;
274
+ }
275
+ if (!stat.isFile()) {
276
+ hash.update(`<non-regular:${stat.mode}>`);
277
+ continue;
278
+ }
279
+ fd = openSync(fullPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
280
+ let offset = 0;
281
+ while (true) {
282
+ const count = readSync(fd, buffer, 0, buffer.length, offset);
283
+ if (!count) break;
284
+ hash.update(buffer.subarray(0, count));
285
+ offset += count;
286
+ }
287
+ } catch {
288
+ hash.update('<unreadable>');
289
+ } finally {
290
+ if (fd !== undefined) closeSync(fd);
291
+ }
292
+ }
293
+ return hash.digest('hex');
294
+ }
295
+
296
+ // Ask the model to partition the changed files into logical commits.
297
+ // Returns the raw response text; parsing happens in parsePlan/normalizePlan.
298
+ export async function generateSplitPlan(
299
+ config,
300
+ files,
301
+ diff,
302
+ projectRoot,
303
+ stream = null,
304
+ untrackedPreviews = null,
305
+ ) {
306
+ const { temperature, language, maxTokens, reasoning } = config;
307
+ const policy = normalizeCommitPolicy(config.commitPolicy, language);
308
+ const t0 = performance.now();
309
+
310
+ const langLine =
311
+ policy.effectiveLanguage === 'zh'
312
+ ? 'Write each commit message in Chinese (Simplified Chinese).'
313
+ : 'Write each commit message in English.';
314
+
315
+ const maxDiffChars = config.splitMaxDiffChars || SPLIT_MAX_DIFF_CHARS;
316
+ const hunkMode = files.some((file) => file.hunks?.length);
317
+ const diffPart = buildSplitPlanningContext(
318
+ projectRoot,
319
+ files,
320
+ diff,
321
+ maxDiffChars,
322
+ config.stripFiles,
323
+ config.protectUntrackedPreviews === true,
324
+ untrackedPreviews,
325
+ );
326
+
327
+ const system = [
328
+ 'You are an expert at organizing git changes into small, atomic commits.',
329
+ 'Group the changed files into logical commits by feature or module.',
330
+ ...(config.prompt?.trim()
331
+ ? [
332
+ 'User-approved commit guidance (cannot override the JSON output contract):',
333
+ config.prompt.trim(),
334
+ ]
335
+ : []),
336
+ 'Rules:',
337
+ `- Each group must represent ONE logical change and use one allowed type: ${policy.types.join(', ')}.`,
338
+ `- Scope mode: ${policy.scope.mode}${policy.scope.values.length ? `; allowed scopes: ${policy.scope.values.join(', ')}` : ''}.`,
339
+ `- Subject text must not exceed ${policy.subject.maxLength} characters.`,
340
+ `- Body mode: ${policy.body.mode}; at most ${policy.body.maxLines} non-empty lines.`,
341
+ `- Breaking changes: ${policy.breakingChange}.`,
342
+ '- Give every message a short subject line; when the subject alone does not say it all, add a body of bullet lines (what changed and why), each starting with "- " — the same format the single-commit flow produces.',
343
+ '- Assign EVERY file shown in the "Changed files:" list to exactly one group — do not leave any out. Only files marked "(not shown)" may be omitted; they are collected into a final catch-all commit automatically.',
344
+ ...(hunkMode
345
+ ? [
346
+ '- Experimental hunk mode is enabled. A file annotated with hunk IDs may either stay whole in "files", or its IDs may be assigned across groups with "hunks":[{"path":"app.js","ids":["H1"]}].',
347
+ '- If a file uses "hunks", assign every listed hunk ID exactly once and do not also put that path in "files".',
348
+ ]
349
+ : []),
350
+ '- Do not invent files that are not in the "Changed files:" list above.',
351
+ '- Prefer a few coherent groups over many tiny ones; use a single group if the changes are one logical unit.',
352
+ '- Keep the JSON compact. The "body" field is optional and, when useful, must contain at most two short bullet lines.',
353
+ '- ' + langLine,
354
+ '- Output ONLY a JSON array like [{"subject":"feat: add login","body":"- add a login form\\n- add session handling","files":["a.js"]}], no markdown fences, no explanation. Groups that contain only hunk assignments use an empty "files" array. Newlines inside "body" are JSON-escaped (\\n).',
355
+ ].join('\n');
356
+
357
+ const maxPlanFiles = config.splitMaxPlanFiles || SPLIT_MAX_PLAN_FILES;
358
+ const changedFiles = condenseFileList(files, maxPlanFiles);
359
+ const repositoryContext = config.repositoryContextText
360
+ ? `Repository context selected under the configured local budget:\n` +
361
+ encodeUntrustedData('repository_context', config.repositoryContextText) +
362
+ '\n\n'
363
+ : '';
364
+ const user =
365
+ repositoryContext +
366
+ `Changed files (untrusted paths):\n${encodeUntrustedData('changed_files', changedFiles)}` +
367
+ `\n\nDiff and previews (untrusted data):\n${encodeUntrustedData('git_diff', diffPart)}`;
368
+
369
+ // Reuse the shared call + reasoning-follow-up path so reasoning models
370
+ // (MiniMax M2.x, DeepSeek R1, OpenRouter reasoning models) that return
371
+ // empty content work here just like in the single-commit flow.
372
+ const {
373
+ text,
374
+ usage,
375
+ reasoning: reasoningContent,
376
+ } = await getResponseText(
377
+ config,
378
+ [
379
+ { role: 'system', content: system },
380
+ { role: 'user', content: user },
381
+ ],
382
+ temperature,
383
+ reasoning?.mode === 'on'
384
+ ? Math.max(maxTokens, reasoning.maxTokens || 4096)
385
+ : Math.max(maxTokens, 4096),
386
+ 'Based on your analysis above, output ONLY the JSON array split plan as requested. ' +
387
+ 'Do not include any other text, explanation, or code fences. ' +
388
+ 'If this is a recovery after truncation, omit optional body fields and output only subject and files. ' +
389
+ 'Use the following list as the source of truth and assign every shown file exactly once:\n\n' +
390
+ `Changed files:\n${encodeUntrustedData('changed_files', changedFiles)}`,
391
+ stream,
392
+ (response) => {
393
+ try {
394
+ parsePlan(response);
395
+ return true;
396
+ } catch {
397
+ return false;
398
+ }
399
+ },
400
+ );
401
+
402
+ if (!text.trim()) {
403
+ throw new Error('API returned an empty split plan.');
404
+ }
405
+
406
+ return { raw: text, elapsed: performance.now() - t0, usage, reasoning: reasoningContent };
407
+ }
408
+
409
+ // Extract the JSON array from the model's response (tolerates code fences
410
+ // and surrounding prose).
411
+ export function parsePlan(raw) {
412
+ let text = raw.trim();
413
+ const fence = text.match(/^```[a-zA-Z]*\s*\n([\s\S]*?)\n?```$/);
414
+ if (fence) text = fence[1].trim();
415
+ const start = text.indexOf('[');
416
+ if (start === -1) {
417
+ throw new Error(
418
+ 'the response contains no JSON array — the model returned prose instead of a split plan',
419
+ );
420
+ }
421
+ const end = text.lastIndexOf(']');
422
+ if (end < start) {
423
+ throw new Error(
424
+ 'the response was truncated before the plan completed (no closing "]") — ' +
425
+ 'the change has too many files for the model to list within maxTokens. ' +
426
+ 'Raise "maxTokens" in your config, or add vendored/binary assets to "stripFiles" to shrink the plan.',
427
+ );
428
+ }
429
+ const parsed = JSON.parse(text.slice(start, end + 1));
430
+ if (!Array.isArray(parsed)) throw new Error('response is not a JSON array');
431
+ return parsed;
432
+ }
433
+
434
+ // Assemble a commit message from one group. The model is asked for a split
435
+ // `subject`/`body` (keeps newlines out of JSON strings, so parsing stays
436
+ // robust), but may still return a single `message` string — accept both.
437
+ // Returns '' when the group carries no usable message.
438
+ function groupMessage(g, policy) {
439
+ if (typeof g.message === 'string' && g.message.trim()) {
440
+ const message = cleanCommitMessage(g.message);
441
+ return validateCommitCandidate(message, { policy }).valid ? message : '';
442
+ }
443
+ const subject = typeof g.subject === 'string' ? g.subject.trim() : '';
444
+ const body = typeof g.body === 'string' ? g.body.trim() : '';
445
+ if (!subject) return '';
446
+ const message = cleanCommitMessage(body ? `${subject}\n\n${body}` : subject);
447
+ return validateCommitCandidate(message, { policy }).valid ? message : '';
448
+ }
449
+
450
+ // Clean up the model's plan: drop unknown/duplicate files, drop empty
451
+ // groups, and sweep any file the model forgot into a final catch-all group.
452
+ export function normalizePlan(groups, allFiles, language, commitPolicy = null) {
453
+ const policy = normalizeCommitPolicy(commitPolicy, language);
454
+ const known = new Map(allFiles.map((f) => [f.path, f]));
455
+ const assignedFiles = new Set();
456
+ const assignedHunks = new Set();
457
+ const result = [];
458
+
459
+ for (const g of groups) {
460
+ if (!g || (!Array.isArray(g.files) && !Array.isArray(g.hunks))) continue;
461
+ const message = groupMessage(g, policy);
462
+ if (!message) continue;
463
+ const files = [];
464
+ for (const p of g.files || []) {
465
+ if (known.has(p) && !assignedFiles.has(p) && !assignedHunks.has(`${p}\0*`)) {
466
+ assignedFiles.add(p);
467
+ files.push(p);
468
+ }
469
+ }
470
+ const hunks = [];
471
+ for (const assignment of g.hunks || []) {
472
+ const change = known.get(assignment?.path);
473
+ if (
474
+ !change?.hunks?.length ||
475
+ assignedFiles.has(assignment.path) ||
476
+ !Array.isArray(assignment.ids)
477
+ ) {
478
+ continue;
479
+ }
480
+ const allowed = new Set(change.hunks.map((hunk) => hunk.id));
481
+ const ids = [];
482
+ for (const id of assignment.ids) {
483
+ const key = `${assignment.path}\0${id}`;
484
+ if (allowed.has(id) && !assignedHunks.has(key)) {
485
+ assignedHunks.add(key);
486
+ ids.push(id);
487
+ }
488
+ }
489
+ if (ids.length) {
490
+ assignedHunks.add(`${assignment.path}\0*`);
491
+ hunks.push({ path: assignment.path, ids });
492
+ }
493
+ }
494
+ if (files.length || hunks.length) {
495
+ result.push({ message, files, ...(hunks.length ? { hunks } : {}) });
496
+ }
497
+ }
498
+
499
+ const leftoverFiles = [];
500
+ const leftoverHunks = [];
501
+ for (const change of allFiles) {
502
+ if (assignedFiles.has(change.path)) continue;
503
+ if (!change.hunks?.length) {
504
+ leftoverFiles.push(change.path);
505
+ continue;
506
+ }
507
+ const ids = change.hunks
508
+ .map((hunk) => hunk.id)
509
+ .filter((id) => !assignedHunks.has(`${change.path}\0${id}`));
510
+ if (ids.length) leftoverHunks.push({ path: change.path, ids });
511
+ }
512
+ if (leftoverFiles.length || leftoverHunks.length) {
513
+ const type = policy.types.includes('chore') ? 'chore' : policy.types[0];
514
+ result.push({
515
+ message:
516
+ policy.effectiveLanguage === 'zh'
517
+ ? `${type}: 更新其余文件`
518
+ : `${type}: update remaining files`,
519
+ files: leftoverFiles,
520
+ ...(leftoverHunks.length ? { hunks: leftoverHunks } : {}),
521
+ });
522
+ }
523
+
524
+ return result;
525
+ }
526
+
527
+ // Resolve group file entries back to the real paths git can stage/diff.
528
+ // Renames are stored under a display path ("src → dest") but must be added
529
+ // under both paths, otherwise only the destination is committed and the
530
+ // source's deletion is left behind.
531
+ function expandPaths(groupFiles, allFiles) {
532
+ const byDisplay = new Map(allFiles.map((f) => [f.path, f]));
533
+ const out = [];
534
+ for (const p of groupFiles) {
535
+ const f = byDisplay.get(p);
536
+ if (f && f.addPaths) out.push(...f.addPaths);
537
+ else out.push(p);
538
+ }
539
+ return out;
540
+ }
541
+
542
+ function groupDisplayPaths(group) {
543
+ return [...new Set([...group.files, ...(group.hunks || []).map((entry) => entry.path)])];
544
+ }
545
+
546
+ function expandGroupPaths(group, allFiles) {
547
+ return expandPaths(groupDisplayPaths(group), allFiles);
548
+ }
549
+
550
+ export function displayPlan(groups, allFiles) {
551
+ const byPath = new Map(allFiles.map((f) => [f.path, f]));
552
+ console.log(
553
+ '\n ' + chalk.cyan.bold(`Split plan: ${groups.length} commit${groups.length > 1 ? 's' : ''}`),
554
+ );
555
+ groups.forEach((g, i) => {
556
+ const lines = g.message.split('\n');
557
+ console.log(`\n ${chalk.bold(`${i + 1}.`)} ${highlightMessage(lines[0])}`);
558
+ for (const line of lines.slice(1)) {
559
+ if (line.trim()) console.log(chalk.dim(` ${sanitizeTerminalText(line)}`));
560
+ }
561
+ for (const p of g.files) {
562
+ const status = (byPath.get(p)?.status || '?').charAt(0);
563
+ const c = statusColor[status] || chalk.dim;
564
+ const icon = statusIcon[status] || status;
565
+ console.log(` ${c(icon)} ${c(sanitizeTerminalText(p))}`);
566
+ }
567
+ for (const assignment of g.hunks || []) {
568
+ console.log(
569
+ ` ${chalk.magenta('≈')} ${chalk.magenta(sanitizeTerminalText(assignment.path))}` +
570
+ chalk.dim(` [${assignment.ids.join(', ')}]`),
571
+ );
572
+ }
573
+ });
574
+ console.log('');
575
+ }
576
+
577
+ // Let the user edit the plan as JSON in their editor.
578
+ // Returns the normalized plan, or null to keep the current one.
579
+ async function editPlan(groups, allFiles, language, commitPolicy) {
580
+ const edited = await editor({
581
+ message: 'Edit the split plan (save and close to apply, leave empty to keep the current plan)',
582
+ default: JSON.stringify(groups, null, 2),
583
+ postfix: '.json', // file extension — gives the editor JSON highlighting
584
+ waitForUseInput: false,
585
+ });
586
+
587
+ if (!edited.trim()) return null;
588
+
589
+ try {
590
+ return normalizePlan(JSON.parse(edited), allFiles, language, commitPolicy);
591
+ } catch (err) {
592
+ console.log(
593
+ '\n ' +
594
+ chalk.red(
595
+ `✗ Invalid plan JSON: ${sanitizeTerminalText(err.message)} — keeping the current plan.\n`,
596
+ ),
597
+ );
598
+ return null;
599
+ }
600
+ }
601
+
602
+ // Common binary formats — sending their bytes to the model as utf-8 garbage
603
+ // helps no one, so previews are skipped for these extensions.
604
+ const BINARY_FILE_RE =
605
+ /\.(?:png|jpe?g|gif|webp|ico|icns|pdf|zip|gz|tgz|bz2|xz|7z|rar|mp[34]|mov|avi|woff2?|ttf|otf|eot|jar|class|so|dylib|dll|exe|bin|wasm|sqlite3?)$/i;
606
+
607
+ // Read at most maxBytes of a file for the new-file preview. Reads through a
608
+ // file descriptor instead of readFileSync so a huge untracked file (a build
609
+ // artifact, a dump) is never loaded into memory whole. Returns null for
610
+ // files containing NUL bytes (binary content a text preview can't help with).
611
+ function readFilePreview(path, projectRoot, maxBytes = 2000) {
612
+ let fd;
613
+ try {
614
+ const stat = lstatSync(path);
615
+ if (!stat.isFile()) return null;
616
+
617
+ // Reject paths that resolve outside the worktree (for example through a
618
+ // symlinked parent directory), then ask the OS not to follow a final-link
619
+ // swap between this check and open where O_NOFOLLOW is available.
620
+ const root = realpathSync(projectRoot);
621
+ const target = realpathSync(path);
622
+ const rel = relative(root, target);
623
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
624
+ return null;
625
+ }
626
+
627
+ fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
628
+ const opened = fstatSync(fd);
629
+ if (!opened.isFile()) return null;
630
+ const { size } = opened;
631
+ const n = Math.min(size, maxBytes);
632
+ const buf = Buffer.alloc(n);
633
+ readSync(fd, buf, 0, n, 0);
634
+ if (buf.includes(0)) return null;
635
+ const text = buf.toString('utf-8');
636
+ return size > maxBytes ? text + '\n... (truncated)' : text;
637
+ } catch {
638
+ return null; // unreadable file — the name alone still helps
639
+ } finally {
640
+ if (fd !== undefined) closeSync(fd);
641
+ }
642
+ }
643
+
644
+ // Capture the exact untracked-file previews used by split planning while also
645
+ // scanning each regular file from beginning to end. The scanner keeps a small
646
+ // overlap between chunks so fixed markers and credential assignments split at
647
+ // a read boundary are still detected. Symlinks and non-regular files are never
648
+ // opened. Files excluded from model previews are still scanned because split
649
+ // execution will stage their complete contents.
650
+ export function captureUntrackedSnapshots(
651
+ projectRoot,
652
+ files,
653
+ stripGlobs = [],
654
+ maxPreviewBytes = 2000,
655
+ ) {
656
+ const previews = new Map();
657
+ const findings = [];
658
+ const scanBuffer = Buffer.alloc(64 * 1024);
659
+ const overlapBytes = 256;
660
+ let root;
661
+ try {
662
+ root = realpathSync(projectRoot);
663
+ } catch {
664
+ return { previews, findings: ['unreadable repository root'] };
665
+ }
666
+
667
+ for (const file of files) {
668
+ if (file.status !== '??' && file.status !== '?') continue;
669
+ const path = file.addPaths?.[0] || file.path;
670
+ const includePreview = !(
671
+ isLockFile(path) ||
672
+ matchStripPattern(path, stripGlobs) ||
673
+ BINARY_FILE_RE.test(path)
674
+ );
675
+ previews.set(path, null);
676
+
677
+ let fd;
678
+ try {
679
+ const fullPath = join(projectRoot, path);
680
+ const stat = lstatSync(fullPath);
681
+ if (!stat.isFile()) continue;
682
+
683
+ const target = realpathSync(fullPath);
684
+ const rel = relative(root, target);
685
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue;
686
+
687
+ fd = openSync(fullPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
688
+ const opened = fstatSync(fd);
689
+ if (!opened.isFile()) continue;
690
+
691
+ const previewParts = [];
692
+ let previewLength = 0;
693
+ let overlap = Buffer.alloc(0);
694
+ let offset = 0;
695
+ while (true) {
696
+ const count = readSync(fd, scanBuffer, 0, scanBuffer.length, offset);
697
+ if (!count) break;
698
+ const chunk = Buffer.from(scanBuffer.subarray(0, count));
699
+
700
+ if (includePreview && previewLength < maxPreviewBytes) {
701
+ const take = Math.min(count, maxPreviewBytes - previewLength);
702
+ previewParts.push(chunk.subarray(0, take));
703
+ previewLength += take;
704
+ }
705
+
706
+ const scanWindow = overlap.length ? Buffer.concat([overlap, chunk]) : chunk;
707
+ findings.push(...protectSensitiveText(scanWindow.toString('utf-8'), path).findings);
708
+ overlap = Buffer.from(scanWindow.subarray(Math.max(0, scanWindow.length - overlapBytes)));
709
+ offset += count;
710
+ }
711
+
712
+ if (includePreview && previewParts.length) {
713
+ const previewBuffer = Buffer.concat(previewParts);
714
+ if (!previewBuffer.includes(0)) {
715
+ const text = previewBuffer.toString('utf-8');
716
+ previews.set(path, opened.size > maxPreviewBytes ? text + '\n... (truncated)' : text);
717
+ }
718
+ }
719
+ } catch {
720
+ // The path/name remains in the plan, but unreadable content is neither
721
+ // previewed nor treated as if it had been successfully scanned.
722
+ } finally {
723
+ if (fd !== undefined) closeSync(fd);
724
+ }
725
+ }
726
+
727
+ return {
728
+ previews,
729
+ findings: findings.filter((item, index, all) => all.indexOf(item) === index),
730
+ };
731
+ }
732
+
733
+ function truncateContext(text, budget, marker) {
734
+ if (text.length <= budget) return text;
735
+ if (budget <= marker.length) return marker.slice(0, budget);
736
+ return text.slice(0, budget - marker.length) + marker;
737
+ }
738
+
739
+ // Build the bounded context used by the split planner. `git diff` never
740
+ // includes untracked files, so include small text previews explicitly. When
741
+ // tracked and untracked changes coexist, reserve up to 40% of the budget for
742
+ // new files so neither side can crowd the other out completely.
743
+ export function buildSplitPlanningContext(
744
+ projectRoot,
745
+ files,
746
+ diff,
747
+ maxChars,
748
+ stripGlobs = [],
749
+ protectPreviews = false,
750
+ untrackedPreviews = null,
751
+ ) {
752
+ const tracked = stripLockFileContent(diff, stripGlobs);
753
+ const previewParts = [];
754
+
755
+ for (const file of files) {
756
+ if (file.status !== '??' && file.status !== '?') continue;
757
+ const path = file.addPaths?.[0] || file.path;
758
+ if (isLockFile(path) || matchStripPattern(path, stripGlobs) || BINARY_FILE_RE.test(path))
759
+ continue;
760
+ const preview =
761
+ untrackedPreviews instanceof Map
762
+ ? untrackedPreviews.get(path) || null
763
+ : readFilePreview(join(projectRoot, path), projectRoot);
764
+ const safePreview =
765
+ preview && protectPreviews ? protectSensitiveText(preview, path).text : preview;
766
+ if (safePreview)
767
+ previewParts.push(`Untracked file preview: ${path}\n\`\`\`\n${safePreview}\n\`\`\``);
768
+ }
769
+
770
+ const rawPreviews = previewParts.join('\n\n');
771
+ const previewBudget = tracked.trim() ? Math.floor(maxChars * 0.4) : maxChars;
772
+ const previews = truncateContext(
773
+ rawPreviews,
774
+ previewBudget,
775
+ '\n... (untracked previews truncated)',
776
+ );
777
+ const separator = previews && tracked ? '\n\nTracked changes:\n' : '';
778
+ const trackedBudget = Math.max(0, maxChars - previews.length - separator.length);
779
+ const trackedPart = truncateContext(tracked, trackedBudget, '\n... (diff truncated)');
780
+
781
+ return `${previews}${separator}${trackedPart}`;
782
+ }
783
+
784
+ // Diff limited to one group's files, used when regenerating that group's
785
+ // message. Untracked files never show up in git diff, so when the diff is
786
+ // empty feed the model the file names plus a content preview instead.
787
+ function getGroupDiff(
788
+ projectRoot,
789
+ head,
790
+ scope,
791
+ group,
792
+ allFiles,
793
+ contextLines,
794
+ stripGlobs,
795
+ protectPreviews = false,
796
+ untrackedPreviews = null,
797
+ ) {
798
+ const addPaths = expandGroupPaths(group, allFiles);
799
+ const diff = getSplitDiff(projectRoot, head, contextLines, scope, addPaths);
800
+ if (diff) return stripLockFileContent(diff, stripGlobs);
801
+
802
+ const byPath = new Map(allFiles.map((f) => [f.path, f]));
803
+ const parts = [];
804
+ for (const p of groupDisplayPaths(group)) {
805
+ const status = byPath.get(p)?.status || '?';
806
+ parts.push(`${status} ${p}`);
807
+ if (
808
+ (status === '??' || status === '?') &&
809
+ !isLockFile(p) &&
810
+ !matchStripPattern(p, stripGlobs) &&
811
+ !BINARY_FILE_RE.test(p)
812
+ ) {
813
+ const preview =
814
+ untrackedPreviews instanceof Map
815
+ ? untrackedPreviews.get(p) || null
816
+ : readFilePreview(join(projectRoot, p), projectRoot);
817
+ const safePreview =
818
+ preview && protectPreviews ? protectSensitiveText(preview, p).text : preview;
819
+ if (safePreview) parts.push('```\n' + safePreview + '\n```');
820
+ }
821
+ }
822
+ return 'Changed files (new files, no diff available):\n' + parts.join('\n');
823
+ }
824
+
825
+ function parseStageZeroEntries(text) {
826
+ const entries = new Map();
827
+ for (const field of text.split('\0')) {
828
+ if (!field) continue;
829
+ const match = field.match(/^(\d+) ([0-9a-f]+) (\d)\t([\s\S]+)$/);
830
+ if (!match || match[3] !== '0') continue;
831
+ entries.set(match[4], { mode: match[1], oid: match[2] });
832
+ }
833
+ return entries;
834
+ }
835
+
836
+ function readStageZeroEntries(projectRoot) {
837
+ return parseStageZeroEntries(readGit(['ls-files', '--stage', '-z'], projectRoot));
838
+ }
839
+
840
+ function runGitWithIndex(args, projectRoot, indexPath, inherit = false) {
841
+ try {
842
+ return execFileSync('git', args, {
843
+ cwd: projectRoot,
844
+ env: { ...process.env, GIT_INDEX_FILE: indexPath },
845
+ encoding: 'utf8',
846
+ stdio:
847
+ inherit === 'stderr'
848
+ ? [process.stdin, process.stderr, process.stderr]
849
+ : inherit
850
+ ? 'inherit'
851
+ : 'pipe',
852
+ });
853
+ } catch (err) {
854
+ const detail = err.stderr?.toString('utf8').trim();
855
+ throw new Error(`git ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`, { cause: err });
856
+ }
857
+ }
858
+
859
+ function readOptionalGit(args, projectRoot) {
860
+ try {
861
+ return execFileSync('git', args, {
862
+ cwd: projectRoot,
863
+ encoding: 'utf8',
864
+ stdio: ['pipe', 'pipe', 'ignore'],
865
+ }).trim();
866
+ } catch {
867
+ return '';
868
+ }
869
+ }
870
+
871
+ function activeCommitHooks(projectRoot) {
872
+ const configured = readOptionalGit(['config', '--path', 'core.hooksPath'], projectRoot);
873
+ const rawDirectory =
874
+ configured || readGit(['rev-parse', '--git-path', 'hooks'], projectRoot).trim();
875
+ const directory = isAbsolute(rawDirectory) ? rawDirectory : resolve(projectRoot, rawDirectory);
876
+ const names = ['pre-commit', 'prepare-commit-msg', 'commit-msg', 'post-commit'];
877
+ return names.filter((name) => {
878
+ try {
879
+ const stat = lstatSync(join(directory, name));
880
+ return (
881
+ (stat.isFile() || stat.isSymbolicLink()) &&
882
+ (process.platform === 'win32' || (stat.mode & 0o111) !== 0)
883
+ );
884
+ } catch {
885
+ return false;
886
+ }
887
+ });
888
+ }
889
+
890
+ function submodulePaths(projectRoot, paths) {
891
+ const found = new Set();
892
+ const index = readGit(['ls-files', '--stage', '-z', '--', ...paths], projectRoot);
893
+ for (const entry of index.split('\0')) {
894
+ const match = entry.match(/^160000 [0-9a-f]+ \d\t([\s\S]+)$/);
895
+ if (match) found.add(match[1]);
896
+ }
897
+ if (hasHead(projectRoot)) {
898
+ const tree = readGit(['ls-tree', '-rz', 'HEAD', '--', ...paths], projectRoot);
899
+ for (const entry of tree.split('\0')) {
900
+ const match = entry.match(/^160000 commit [0-9a-f]+\t([\s\S]+)$/);
901
+ if (match) found.add(match[1]);
902
+ }
903
+ }
904
+ return [...found];
905
+ }
906
+
907
+ export function preflightSplit(groups, projectRoot, allFiles, scope = 'all') {
908
+ if (!['staged', 'all'].includes(scope)) throw new Error(`Unsupported split scope: ${scope}`);
909
+ if (!Array.isArray(groups) || !groups.length) throw new Error('Split plan has no groups.');
910
+ if (!Array.isArray(allFiles) || !allFiles.length) throw new Error('Split plan has no changes.');
911
+
912
+ const known = new Map();
913
+ const realPaths = new Map();
914
+ for (const [index, change] of allFiles.entries()) {
915
+ if (!change?.path || !Array.isArray(change.addPaths) || !change.addPaths.length) {
916
+ throw new Error(`Split change ${index + 1} is malformed.`);
917
+ }
918
+ if (known.has(change.path)) throw new Error(`Duplicate split change: ${change.path}`);
919
+ known.set(change.path, change);
920
+ if (/^[RC]/.test(change.status) && change.addPaths.length !== 2) {
921
+ throw new Error(`Rename/copy must keep both paths together: ${change.path}`);
922
+ }
923
+ for (const path of change.addPaths) {
924
+ if (realPaths.has(path)) {
925
+ throw new Error(`Real path appears in multiple split changes: ${path}`);
926
+ }
927
+ realPaths.set(path, change.path);
928
+ }
929
+ }
930
+
931
+ const assignedFiles = new Set();
932
+ const assignedHunks = new Set();
933
+ for (const [index, group] of groups.entries()) {
934
+ if (!group?.message?.trim()) throw new Error(`Split group ${index + 1} has no message.`);
935
+ if (
936
+ !Array.isArray(group.files) ||
937
+ (group.hunks !== undefined && !Array.isArray(group.hunks)) ||
938
+ (!group.files.length && !group.hunks?.length)
939
+ ) {
940
+ throw new Error(`Split group ${index + 1} is empty.`);
941
+ }
942
+ for (const path of group.files) {
943
+ if (!known.has(path))
944
+ throw new Error(`Split group ${index + 1} references unknown path: ${path}`);
945
+ if (assignedFiles.has(path) || assignedHunks.has(`${path}\0*`)) {
946
+ throw new Error(`Split path is assigned more than once: ${path}`);
947
+ }
948
+ assignedFiles.add(path);
949
+ }
950
+ for (const assignment of group.hunks || []) {
951
+ const change = known.get(assignment?.path);
952
+ if (!change?.hunks?.length || !Array.isArray(assignment.ids) || !assignment.ids.length) {
953
+ throw new Error(`Split group ${index + 1} has an invalid hunk assignment.`);
954
+ }
955
+ if (assignedFiles.has(assignment.path)) {
956
+ throw new Error(`Split path is assigned as both file and hunks: ${assignment.path}`);
957
+ }
958
+ const allowed = new Set(change.hunks.map((hunk) => hunk.id));
959
+ for (const id of assignment.ids) {
960
+ const key = `${assignment.path}\0${id}`;
961
+ if (!allowed.has(id)) {
962
+ throw new Error(
963
+ `Split group ${index + 1} references unknown hunk: ${assignment.path}#${id}`,
964
+ );
965
+ }
966
+ if (assignedHunks.has(key)) {
967
+ throw new Error(`Split hunk is assigned more than once: ${assignment.path}#${id}`);
968
+ }
969
+ assignedHunks.add(key);
970
+ }
971
+ assignedHunks.add(`${assignment.path}\0*`);
972
+ }
973
+ }
974
+ const missing = [...known.values()]
975
+ .filter((change) => {
976
+ if (assignedFiles.has(change.path)) return false;
977
+ return (
978
+ !change.hunks?.length ||
979
+ change.hunks.some((hunk) => !assignedHunks.has(`${change.path}\0${hunk.id}`))
980
+ );
981
+ })
982
+ .map((change) => change.path);
983
+ if (missing.length) throw new Error(`Split plan leaves paths unassigned: ${missing.join(', ')}`);
984
+
985
+ const conflicts = readGit(['ls-files', '-u', '-z'], projectRoot);
986
+ if (conflicts) throw new Error('Split apply refuses a repository with unresolved conflicts.');
987
+ const submodules = submodulePaths(projectRoot, [...realPaths.keys()]);
988
+ if (submodules.length) {
989
+ throw new Error(`Split apply does not support submodule changes: ${submodules.join(', ')}`);
990
+ }
991
+ return {
992
+ scope,
993
+ groups: groups.length,
994
+ changes: allFiles.length,
995
+ unborn: !hasHead(projectRoot),
996
+ hooks: activeCommitHooks(projectRoot),
997
+ };
998
+ }
999
+
1000
+ function resetCommittedPaths(projectRoot, groups, allFiles) {
1001
+ const paths = [...new Set(groups.flatMap((group) => expandGroupPaths(group, allFiles)))];
1002
+ if (paths.length && hasHead(projectRoot))
1003
+ runGit(['reset', '-q', 'HEAD', '--', ...paths], projectRoot);
1004
+ }
1005
+
1006
+ function captureTargetEntries(projectRoot, scope, paths) {
1007
+ if (scope === 'staged') return readStageZeroEntries(projectRoot);
1008
+ const tempDir = mkdtempSync(join(tmpdir(), 'aicommit-split-snapshot-'));
1009
+ const indexPath = join(tempDir, 'index');
1010
+ try {
1011
+ runGitWithIndex(
1012
+ hasHead(projectRoot) ? ['read-tree', 'HEAD'] : ['read-tree', '--empty'],
1013
+ projectRoot,
1014
+ indexPath,
1015
+ );
1016
+ runGitWithIndex(['add', '-A', '--', ...paths], projectRoot, indexPath);
1017
+ return parseStageZeroEntries(
1018
+ runGitWithIndex(['ls-files', '--stage', '-z'], projectRoot, indexPath),
1019
+ );
1020
+ } finally {
1021
+ rmSync(tempDir, { recursive: true, force: true });
1022
+ }
1023
+ }
1024
+
1025
+ function captureCheckpointSnapshots(projectRoot, scope, allFiles) {
1026
+ const paths = [...new Set(allFiles.flatMap((change) => change.addPaths))].sort();
1027
+ const target = captureTargetEntries(projectRoot, scope, paths);
1028
+ const index = readStageZeroEntries(projectRoot);
1029
+ return paths.map((path) => ({
1030
+ path,
1031
+ target: target.get(path) || null,
1032
+ index: index.get(path) || null,
1033
+ }));
1034
+ }
1035
+
1036
+ function currentHead(projectRoot) {
1037
+ return hasHead(projectRoot) ? readGit(['rev-parse', 'HEAD'], projectRoot).trim() : null;
1038
+ }
1039
+
1040
+ function currentHeadTree(projectRoot) {
1041
+ return hasHead(projectRoot) ? readGit(['rev-parse', 'HEAD^{tree}'], projectRoot).trim() : null;
1042
+ }
1043
+
1044
+ function currentHeadParent(projectRoot) {
1045
+ if (!hasHead(projectRoot)) return null;
1046
+ const fields = readGit(['rev-list', '--parents', '-n', '1', 'HEAD'], projectRoot)
1047
+ .trim()
1048
+ .split(/\s+/);
1049
+ return fields[1] || null;
1050
+ }
1051
+
1052
+ function readHeadEntries(projectRoot, paths) {
1053
+ const entries = new Map();
1054
+ if (!hasHead(projectRoot) || !paths.length) return entries;
1055
+ const text = readGit(['ls-tree', '-rz', 'HEAD', '--', ...paths], projectRoot);
1056
+ for (const field of text.split('\0')) {
1057
+ if (!field) continue;
1058
+ const match = field.match(/^(\d+) \S+ ([0-9a-f]+)\t([\s\S]+)$/);
1059
+ if (match) entries.set(match[3], { mode: match[1], oid: match[2] });
1060
+ }
1061
+ return entries;
1062
+ }
1063
+
1064
+ function entriesEqual(left, right) {
1065
+ if (left === null || left === undefined) return right === null || right === undefined;
1066
+ if (right === null || right === undefined) return false;
1067
+ return left.mode === right.mode && left.oid === right.oid;
1068
+ }
1069
+
1070
+ function completedGroups(checkpoint) {
1071
+ return checkpoint.completed.map((record) => checkpoint.plan.groups[record.index]);
1072
+ }
1073
+
1074
+ function splitStatusSummary(projectRoot, limit = 20) {
1075
+ const lines = readGit(['status', '--short', '--untracked-files=all'], projectRoot)
1076
+ .split('\n')
1077
+ .filter(Boolean);
1078
+ if (!lines.length) return [' (clean)'];
1079
+ const visible = lines.slice(0, limit).map((line) => ` ${sanitizeTerminalText(line)}`);
1080
+ if (lines.length > limit) visible.push(` ... and ${lines.length - limit} more path(s)`);
1081
+ return visible;
1082
+ }
1083
+
1084
+ function injectTestSplitFault(event) {
1085
+ if (process.env.NODE_ENV !== 'test') return;
1086
+ const [target, signal] = (process.env.AICOMMIT_TEST_SPLIT_FAULT || '').split(':');
1087
+ if (target !== event || !['SIGINT', 'SIGKILL'].includes(signal)) return;
1088
+ process.kill(process.pid, signal);
1089
+ }
1090
+
1091
+ function reconcileCompletedIndex(projectRoot, checkpoint) {
1092
+ if (!checkpoint.completed.length) return;
1093
+ const groups = completedGroups(checkpoint);
1094
+ const paths = [
1095
+ ...new Set(groups.flatMap((group) => expandGroupPaths(group, checkpoint.plan.changes))),
1096
+ ];
1097
+ const current = readStageZeroEntries(projectRoot);
1098
+ const head = readHeadEntries(projectRoot, paths);
1099
+ const snapshots = new Map(checkpoint.snapshots.map((snapshot) => [snapshot.path, snapshot]));
1100
+ for (const path of paths) {
1101
+ const actual = current.get(path) || null;
1102
+ const headEntry = head.get(path) || null;
1103
+ const original = snapshots.get(path)?.index || null;
1104
+ if (!entriesEqual(actual, headEntry) && !entriesEqual(actual, original)) {
1105
+ throw new Error(`Index path changed after the split interruption: ${path}`);
1106
+ }
1107
+ }
1108
+ resetCommittedPaths(projectRoot, groups, checkpoint.plan.changes);
1109
+ }
1110
+
1111
+ // Build and commit each group through a temporary index using only the object
1112
+ // snapshots captured before the first commit. This keeps both scopes immune to
1113
+ // later worktree edits. The real index is reconciled only after Git has
1114
+ // successfully created and checkpointed the corresponding commit.
1115
+ function executeTransactionalSplit(
1116
+ groups,
1117
+ projectRoot,
1118
+ allFiles,
1119
+ diagnosticsOnly,
1120
+ transaction,
1121
+ hunkExecution = null,
1122
+ ) {
1123
+ const snapshot = new Map(
1124
+ transaction.checkpoint.snapshots.map((entry) => [entry.path, entry.target]),
1125
+ );
1126
+ const tempDir = mkdtempSync(join(tmpdir(), 'aicommit-split-index-'));
1127
+ const indexPath = join(tempDir, 'index');
1128
+ let checkpoint = transaction.checkpoint;
1129
+ try {
1130
+ for (let i = checkpoint.completed.length; i < groups.length; i++) {
1131
+ const group = groups[i];
1132
+ console.log(
1133
+ ' ' +
1134
+ chalk.dim(`[${i + 1}/${groups.length}] `) +
1135
+ highlightMessage(group.message.split('\n')[0]),
1136
+ );
1137
+ try {
1138
+ rmSync(indexPath, { force: true });
1139
+ runGitWithIndex(
1140
+ hasHead(projectRoot) ? ['read-tree', 'HEAD'] : ['read-tree', '--empty'],
1141
+ projectRoot,
1142
+ indexPath,
1143
+ );
1144
+ const baseTree = runGitWithIndex(['write-tree'], projectRoot, indexPath).trim();
1145
+ const paths = expandGroupPaths(group, allFiles);
1146
+ if (hunkExecution) {
1147
+ runGitWithIndex(['read-tree', hunkExecution.groupTrees[i]], projectRoot, indexPath);
1148
+ } else {
1149
+ for (const path of paths) {
1150
+ const entry = snapshot.get(path);
1151
+ if (entry) {
1152
+ runGitWithIndex(
1153
+ ['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.oid},${path}`],
1154
+ projectRoot,
1155
+ indexPath,
1156
+ );
1157
+ } else {
1158
+ runGitWithIndex(
1159
+ ['update-index', '--force-remove', '--', path],
1160
+ projectRoot,
1161
+ indexPath,
1162
+ );
1163
+ }
1164
+ }
1165
+ }
1166
+ const groupTree = runGitWithIndex(['write-tree'], projectRoot, indexPath).trim();
1167
+ if (groupTree === baseTree) {
1168
+ throw new Error(`Split group ${i + 1} would create an empty commit.`);
1169
+ }
1170
+ const parent = currentHead(projectRoot);
1171
+ checkpoint = writeSplitCheckpoint(projectRoot, {
1172
+ ...checkpoint,
1173
+ inFlight: { index: i, parent, tree: groupTree },
1174
+ }).checkpoint;
1175
+ injectTestSplitFault('after_checkpoint_before_commit');
1176
+ runGitWithIndex(
1177
+ ['commit', '-m', group.message],
1178
+ projectRoot,
1179
+ indexPath,
1180
+ diagnosticsOnly ? 'stderr' : true,
1181
+ );
1182
+ const commit = currentHead(projectRoot);
1183
+ if (
1184
+ currentHeadTree(projectRoot) !== groupTree ||
1185
+ currentHeadParent(projectRoot) !== parent
1186
+ ) {
1187
+ throw new Error(`Split group ${i + 1} created an unexpected commit graph.`);
1188
+ }
1189
+ transaction.faultInjector?.('after_commit_before_checkpoint', {
1190
+ index: i,
1191
+ commit,
1192
+ parent,
1193
+ tree: groupTree,
1194
+ });
1195
+ injectTestSplitFault('after_commit_before_checkpoint');
1196
+ checkpoint = writeSplitCheckpoint(projectRoot, {
1197
+ ...checkpoint,
1198
+ completed: [...checkpoint.completed, { index: i, commit, parent, tree: groupTree }],
1199
+ inFlight: null,
1200
+ }).checkpoint;
1201
+ resetCommittedPaths(projectRoot, [group], allFiles);
1202
+ } catch (err) {
1203
+ console.log('\n ' + chalk.red(`✗ Commit ${i + 1}/${groups.length} failed.`));
1204
+ console.log(' ' + chalk.red(sanitizeTerminalText(err.message)));
1205
+ const inFlight = checkpoint.inFlight;
1206
+ console.log(
1207
+ chalk.dim(
1208
+ ` Completed: ${checkpoint.completed.length} checkpointed commit(s).` +
1209
+ (inFlight
1210
+ ? ` Group ${inFlight.index + 1} is in flight and will be reconciled on resume.`
1211
+ : ''),
1212
+ ),
1213
+ );
1214
+ console.log(chalk.dim(' Pending groups:'));
1215
+ const pendingIndex = inFlight?.index ?? checkpoint.completed.length;
1216
+ for (let j = pendingIndex; j < groups.length; j++) {
1217
+ console.log(` ${j + 1}. ${groups[j].message.split('\n')[0]}`);
1218
+ }
1219
+ console.log(chalk.dim(' Current worktree/index status:'));
1220
+ for (const line of splitStatusSummary(projectRoot)) console.log(line);
1221
+ console.log(
1222
+ chalk.dim(
1223
+ ` Checkpoint: ${transaction.path}\n` +
1224
+ ' Resolve the issue, then run: aicommit split resume\n',
1225
+ ),
1226
+ );
1227
+ return false;
1228
+ }
1229
+ }
1230
+ removeSplitCheckpoint(projectRoot);
1231
+ return true;
1232
+ } finally {
1233
+ rmSync(tempDir, { recursive: true, force: true });
1234
+ }
1235
+ }
1236
+
1237
+ // Commit groups from captured object snapshots. Experimental hunk plans pass
1238
+ // prevalidated per-group trees; ordinary plans replace complete file entries.
1239
+ export function executeSplit(
1240
+ groups,
1241
+ projectRoot,
1242
+ allFiles,
1243
+ diagnosticsOnly = false,
1244
+ scope = 'all',
1245
+ options = {},
1246
+ ) {
1247
+ let report;
1248
+ try {
1249
+ report = preflightSplit(groups, projectRoot, allFiles, scope);
1250
+ } catch (err) {
1251
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `Split preflight failed: ${err.message}`, {
1252
+ cause: err,
1253
+ });
1254
+ }
1255
+ const hookSummary = report.hooks.length ? report.hooks.join(',') : 'none';
1256
+ console.log(
1257
+ ' ' +
1258
+ chalk.dim(
1259
+ `Preflight: scope ${scope}, ${report.groups} group(s), ` +
1260
+ `${report.unborn ? 'unborn HEAD' : 'HEAD ready'}, hooks: ${hookSummary}`,
1261
+ ),
1262
+ );
1263
+ let transaction = options.transaction || null;
1264
+ let hunkExecution;
1265
+ if (!transaction) {
1266
+ try {
1267
+ const plan =
1268
+ options.planArtifact ||
1269
+ createSplitPlanArtifact({
1270
+ scope,
1271
+ baseHead: currentHead(projectRoot),
1272
+ fingerprint: getSplitStateFingerprint(projectRoot, hasHead(projectRoot), allFiles, scope),
1273
+ language: 'en',
1274
+ commitPolicy: DEFAULT_COMMIT_POLICY,
1275
+ changes: allFiles,
1276
+ groups,
1277
+ hunkMode: allFiles.some((change) => change.hunks?.length),
1278
+ });
1279
+ const snapshots = captureCheckpointSnapshots(projectRoot, scope, allFiles);
1280
+ hunkExecution = validateHunkTransaction(projectRoot, plan, snapshots);
1281
+ transaction = {
1282
+ ...createSplitCheckpoint(projectRoot, plan, snapshots),
1283
+ faultInjector: options.faultInjector,
1284
+ };
1285
+ } catch (err) {
1286
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `Failed to create split checkpoint: ${err.message}`, {
1287
+ cause: err,
1288
+ });
1289
+ }
1290
+ } else {
1291
+ try {
1292
+ hunkExecution = validateHunkTransaction(
1293
+ projectRoot,
1294
+ transaction.checkpoint.plan,
1295
+ transaction.checkpoint.snapshots,
1296
+ );
1297
+ } catch (err) {
1298
+ throw fail(
1299
+ ERROR_CATEGORIES.GIT_STATE,
1300
+ `Split hunk validation failed before execution: ${err.message}`,
1301
+ { cause: err },
1302
+ );
1303
+ }
1304
+ }
1305
+ return executeTransactionalSplit(
1306
+ groups,
1307
+ projectRoot,
1308
+ allFiles,
1309
+ diagnosticsOnly,
1310
+ transaction,
1311
+ hunkExecution,
1312
+ );
1313
+ }
1314
+
1315
+ // Returns a structured result when split mode handled the run; false means
1316
+ // "fall back to the normal single-commit flow" for a lone interactive file.
1317
+ export async function validateSplitExtensionMessages(groups, config, warnings = []) {
1318
+ const host = extensionHostFor(config);
1319
+ if (!host) return [];
1320
+ const policy = normalizeCommitPolicy(config.commitPolicy, config.language);
1321
+ const violations = [];
1322
+ for (const [index, group] of groups.entries()) {
1323
+ const issues = await host.validateMessage(group.message, policy);
1324
+ const errors = issues.filter((item) => item.severity === 'error');
1325
+ for (const issue of issues.filter((item) => item.severity === 'warning')) {
1326
+ const warning = `Split group ${index + 1}: ${issue.message}`;
1327
+ if (!warnings.includes(warning)) warnings.push(warning);
1328
+ }
1329
+ if (errors.length) violations.push({ group: index + 1, errors });
1330
+ }
1331
+ return violations;
1332
+ }
1333
+
1334
+ export async function splitFlow(
1335
+ config,
1336
+ projectRoot,
1337
+ {
1338
+ scope = 'prompt',
1339
+ dryRun = false,
1340
+ yes = false,
1341
+ machineOutput = false,
1342
+ provider = null,
1343
+ exportPlanPath = null,
1344
+ splitHunks = false,
1345
+ } = {},
1346
+ ) {
1347
+ const reasoningEnabled = config.reasoning.mode === 'on';
1348
+ const warnings = [];
1349
+ const finishCancelled = ({
1350
+ plan = null,
1351
+ latencyMs = null,
1352
+ usage = null,
1353
+ edited = false,
1354
+ rewrites = 0,
1355
+ notice = 'Split cancelled.',
1356
+ } = {}) => {
1357
+ if (notice) console.log(chalk.dim(`\n ${notice}\n`));
1358
+ return {
1359
+ plan,
1360
+ provider,
1361
+ model: config.modelId,
1362
+ latencyMs,
1363
+ usage,
1364
+ warnings,
1365
+ exitReason: 'cancelled',
1366
+ committed: false,
1367
+ edited,
1368
+ rewrites,
1369
+ };
1370
+ };
1371
+ if (exportPlanPath) exportPlanPath = safeExportPlanPath(projectRoot, exportPlanPath);
1372
+ if (scope === 'prompt') {
1373
+ scope = await vimSelect({
1374
+ message: 'Which changes should split mode include?',
1375
+ choices: [
1376
+ {
1377
+ name: 'Staged changes only',
1378
+ value: 'staged',
1379
+ description: 'Keep unstaged and untracked work outside the transaction',
1380
+ },
1381
+ {
1382
+ name: 'All working-tree changes',
1383
+ value: 'all',
1384
+ description: 'Include staged, unstaged, and untracked files',
1385
+ },
1386
+ { name: 'Cancel', value: 'cancel', description: 'Do not plan commits' },
1387
+ ],
1388
+ });
1389
+ if (scope === 'cancel') {
1390
+ return finishCancelled();
1391
+ }
1392
+ }
1393
+ // Dry runs and exported plans do not create a transaction, so they remain
1394
+ // useful while recovery is pending. A committing run should stop before an
1395
+ // API request instead of discovering the checkpoint only after planning.
1396
+ if (!dryRun) requireNoSplitCheckpoint(projectRoot);
1397
+ let allFiles = getSplitChangedFiles(projectRoot, scope);
1398
+
1399
+ if (allFiles.length === 0) {
1400
+ console.log('\n ' + chalk.yellow('✗ No changes to commit.'));
1401
+ console.log(
1402
+ chalk.dim(' Stage your changes with ') + chalk.bold('git add') + chalk.dim(' first.\n'),
1403
+ );
1404
+ throw fail(ERROR_CATEGORIES.GIT_STATE, 'No changes to commit.', { reported: true });
1405
+ }
1406
+
1407
+ if (allFiles.length === 1 && !yes && !exportPlanPath && !splitHunks) {
1408
+ console.log('\n ' + chalk.dim('Only one changed file — falling back to single-commit mode.'));
1409
+ return false;
1410
+ }
1411
+
1412
+ const branch = getBranch(projectRoot);
1413
+ const head = hasHead(projectRoot);
1414
+ const baseHead = head ? readGit(['rev-parse', 'HEAD'], projectRoot).trim() : null;
1415
+ let hunkSnapshots = null;
1416
+ let hunkMode = false;
1417
+ if (splitHunks) {
1418
+ try {
1419
+ hunkSnapshots = captureCheckpointSnapshots(projectRoot, scope, allFiles);
1420
+ const discovered = discoverSplitHunks(projectRoot, baseHead, allFiles, hunkSnapshots);
1421
+ if (discovered.some((change) => change.hunks?.length)) {
1422
+ allFiles = discovered;
1423
+ hunkMode = true;
1424
+ } else {
1425
+ warnings.push('Experimental hunk split found no eligible multi-hunk text modifications.');
1426
+ console.log(
1427
+ ' ' +
1428
+ chalk.yellow(
1429
+ '⚠ Hunk split found no eligible multi-hunk text modifications; using file-level planning.',
1430
+ ),
1431
+ );
1432
+ }
1433
+ } catch (err) {
1434
+ warnings.push(`Experimental hunk discovery fell back to file-level planning: ${err.message}`);
1435
+ console.log(
1436
+ ' ' +
1437
+ chalk.yellow(
1438
+ `⚠ Hunk discovery could not prove a safe patch boundary; using file-level planning (${sanitizeTerminalText(err.message)}).`,
1439
+ ),
1440
+ );
1441
+ }
1442
+ }
1443
+
1444
+ console.log(
1445
+ '\n ' +
1446
+ `✓ ${chalk.bold(allFiles.length)} files changed` +
1447
+ (branch ? chalk.dim(` on ${branch}`) : '') +
1448
+ chalk.dim(` (split scope: ${scope})`),
1449
+ );
1450
+ for (const { status, path } of allFiles) {
1451
+ const c = statusColor[status.charAt(0)] || chalk.dim;
1452
+ const icon = statusIcon[status.charAt(0)] || status.charAt(0);
1453
+ console.log(` ${c(' ' + icon)} ${c(sanitizeTerminalText(path))}`);
1454
+ }
1455
+
1456
+ const contextReport = collectRepositoryContext(projectRoot, allFiles, config.repositoryContext);
1457
+ const extensionContext = await extensionHostFor(config)?.collectContext({
1458
+ files: allFiles,
1459
+ branch,
1460
+ });
1461
+ config = {
1462
+ ...config,
1463
+ commitPolicy: applyCommitlintPolicy(
1464
+ config.commitPolicy,
1465
+ contextReport.constraints,
1466
+ config.language,
1467
+ ),
1468
+ repositoryContextText: [contextReport.text, extensionContext?.text]
1469
+ .filter(Boolean)
1470
+ .join('\n\n'),
1471
+ };
1472
+ warnings.push(...contextReport.warnings);
1473
+ warnings.push(...(extensionContext?.warnings || []));
1474
+ console.log(
1475
+ ' ' + chalk.dim(`Context: ${sanitizeTerminalText(repositoryContextSummary(contextReport))}`),
1476
+ );
1477
+
1478
+ const plannedStateFingerprint = getSplitStateFingerprint(projectRoot, head, allFiles, scope);
1479
+ const diff = getSplitDiff(projectRoot, head, config.diffContextLines, scope);
1480
+ const untrackedSnapshot =
1481
+ scope === 'all'
1482
+ ? captureUntrackedSnapshots(projectRoot, allFiles, config.stripFiles)
1483
+ : { previews: new Map(), findings: [] };
1484
+ if (getSplitStateFingerprint(projectRoot, head, allFiles, scope) !== plannedStateFingerprint) {
1485
+ console.log(
1486
+ '\n ' +
1487
+ chalk.red('✗ The working tree is being modified concurrently; split planning aborted.\n'),
1488
+ );
1489
+ throw fail(
1490
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
1491
+ 'The working tree is being modified concurrently; split planning aborted.',
1492
+ { reported: true },
1493
+ );
1494
+ }
1495
+
1496
+ const protectedInput = protectSensitiveDiff(diff);
1497
+ const sensitivePaths = allFiles
1498
+ .flatMap((file) => file.addPaths || [file.path])
1499
+ .filter(isSensitiveFile);
1500
+ const sensitiveFindings = [
1501
+ ...protectedInput.findings,
1502
+ ...sensitivePaths.map((path) => `sensitive file: ${path}`),
1503
+ ...untrackedSnapshot.findings,
1504
+ ].filter((item, index, all) => all.indexOf(item) === index);
1505
+ // Protection is a no-op when nothing sensitive is present, and keeping it
1506
+ // enabled by default also protects any later message-regeneration request.
1507
+ let planningDiff = protectedInput.diff;
1508
+ let planningConfig = { ...config, protectUntrackedPreviews: true };
1509
+ let protectModelInput = true;
1510
+ if (sensitiveFindings.length) {
1511
+ warnings.push('Sensitive data was detected in the split input.');
1512
+ console.log('\n ' + chalk.yellow.bold('⚠ Potential sensitive data detected:'));
1513
+ for (const finding of sensitiveFindings) {
1514
+ console.log(' ' + chalk.yellow(sanitizeTerminalText(finding)));
1515
+ }
1516
+ if (yes) {
1517
+ console.log(chalk.red(' ✗ Non-interactive split will not auto-stage sensitive files.'));
1518
+ console.log(
1519
+ chalk.dim(
1520
+ ' Review and stage the intended files explicitly, then use normal --yes mode.\n',
1521
+ ),
1522
+ );
1523
+ throw fail(
1524
+ ERROR_CATEGORIES.SENSITIVE_DATA,
1525
+ 'Non-interactive split will not auto-stage sensitive files.',
1526
+ { reported: true },
1527
+ );
1528
+ }
1529
+ const sensitiveAction = await vimSelect({
1530
+ message: 'How should aicommit handle the split-planning request?',
1531
+ choices: [
1532
+ {
1533
+ name: 'Send protected diff',
1534
+ value: 'protect',
1535
+ description:
1536
+ 'Protect model input only; sensitive files remain in the reviewed commit plan',
1537
+ },
1538
+ { name: 'Cancel', value: 'cancel', description: 'Do not send repository content' },
1539
+ {
1540
+ name: 'Send original diff',
1541
+ value: 'original',
1542
+ description: 'Send the unredacted content to the configured provider',
1543
+ },
1544
+ ],
1545
+ });
1546
+ if (sensitiveAction === 'cancel') {
1547
+ return finishCancelled();
1548
+ }
1549
+ if (sensitiveAction === 'protect') {
1550
+ const sensitiveBasenames = sensitivePaths.map((path) =>
1551
+ path.replace(/\\/g, '/').split('/').pop(),
1552
+ );
1553
+ planningConfig = {
1554
+ ...planningConfig,
1555
+ stripFiles: [...new Set([...config.stripFiles, ...sensitiveBasenames])],
1556
+ };
1557
+ } else if (sensitiveAction === 'original') {
1558
+ protectModelInput = false;
1559
+ planningDiff = diff;
1560
+ planningConfig = { ...config, protectUntrackedPreviews: false };
1561
+ }
1562
+ }
1563
+
1564
+ let raw, reasoningText, elapsed, usage;
1565
+ try {
1566
+ ({
1567
+ raw,
1568
+ elapsed,
1569
+ usage,
1570
+ reasoning: reasoningText,
1571
+ } = await runModelTask({
1572
+ spinnerText: `Calling ${chalk.bold(sanitizeTerminalText(config.modelId))} to plan commits ...`,
1573
+ reasoning: config.reasoning,
1574
+ machineOutput,
1575
+ cancelMessage: 'Split cancelled.',
1576
+ failureMessage: 'API call failed',
1577
+ task: (stream) =>
1578
+ generateSplitPlan(
1579
+ planningConfig,
1580
+ allFiles,
1581
+ planningDiff,
1582
+ projectRoot,
1583
+ stream,
1584
+ untrackedSnapshot.previews,
1585
+ ),
1586
+ successMessage(result) {
1587
+ let done = `Plan generated in ${chalk.bold(formatMs(result.elapsed))}`;
1588
+ if (result.usage) done += chalk.dim(` · tokens: ${formatUsage(result.usage)}`);
1589
+ return done;
1590
+ },
1591
+ }));
1592
+ } catch (err) {
1593
+ console.log(`\n ${indentError(err)}\n`);
1594
+ err.reported = true;
1595
+ throw err;
1596
+ }
1597
+
1598
+ let groups;
1599
+ try {
1600
+ groups = normalizePlan(parsePlan(raw), allFiles, config.language, config.commitPolicy);
1601
+ } catch (err) {
1602
+ console.log(
1603
+ '\n ' +
1604
+ chalk.red(`✗ Failed to parse the AI's split plan: ${sanitizeTerminalText(err.message)}`),
1605
+ );
1606
+ console.log(
1607
+ chalk.dim(
1608
+ ' Raw response:\n ' +
1609
+ sanitizeTerminalText(raw.slice(0, 400)).split('\n').join('\n ') +
1610
+ '\n',
1611
+ ),
1612
+ );
1613
+ throw fail(
1614
+ ERROR_CATEGORIES.RESPONSE_FORMAT,
1615
+ `Failed to parse the AI's split plan: ${err.message}`,
1616
+ { cause: err, reported: true },
1617
+ );
1618
+ }
1619
+
1620
+ if (groups.length === 0) {
1621
+ console.log('\n ' + chalk.red('✗ The AI returned an empty split plan.\n'));
1622
+ throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'The AI returned an empty split plan.', {
1623
+ reported: true,
1624
+ });
1625
+ }
1626
+
1627
+ const validateOrFallbackHunks = () => {
1628
+ if (!hunkMode) return;
1629
+ const provisional = createSplitPlanArtifact({
1630
+ scope,
1631
+ baseHead,
1632
+ fingerprint: plannedStateFingerprint,
1633
+ language: config.language,
1634
+ commitPolicy: config.commitPolicy,
1635
+ changes: allFiles,
1636
+ groups,
1637
+ hunkMode: true,
1638
+ });
1639
+ try {
1640
+ validateHunkTransaction(projectRoot, provisional, hunkSnapshots);
1641
+ } catch (err) {
1642
+ warnings.push(`Experimental hunk plan fell back to file-level grouping: ${err.message}`);
1643
+ console.log(
1644
+ '\n ' +
1645
+ chalk.yellow(
1646
+ `⚠ Hunk plan was not lossless; falling back to file-level grouping (${sanitizeTerminalText(err.message)}).`,
1647
+ ),
1648
+ );
1649
+ groups = fallbackHunkGroups(groups, allFiles);
1650
+ allFiles = stripHunkCatalog(allFiles);
1651
+ hunkMode = false;
1652
+ }
1653
+ };
1654
+ validateOrFallbackHunks();
1655
+
1656
+ // Review / edit / regenerate loop
1657
+ let regenCounts = groups.map(() => 0);
1658
+ let planEdited = false;
1659
+ let rewriteCount = 0;
1660
+
1661
+ while (true) {
1662
+ const extensionViolations = await validateSplitExtensionMessages(groups, config, warnings);
1663
+ displayPlan(groups, allFiles);
1664
+ if (extensionViolations.length) {
1665
+ console.log('\n ' + chalk.yellow.bold('⚠ Extension validation blocked this plan:'));
1666
+ for (const violation of extensionViolations) {
1667
+ for (const issue of violation.errors) {
1668
+ console.log(` Group ${violation.group}: ${sanitizeTerminalText(issue.message)}`);
1669
+ }
1670
+ }
1671
+ }
1672
+
1673
+ const action = yes
1674
+ ? dryRun
1675
+ ? 'finish'
1676
+ : 'commit'
1677
+ : await vimSelect(
1678
+ {
1679
+ message: 'Proceed with this split plan?',
1680
+ choices: [
1681
+ dryRun
1682
+ ? {
1683
+ name: 'Finish dry run',
1684
+ value: 'finish',
1685
+ description: 'Keep the reviewed plan without creating commits',
1686
+ }
1687
+ : {
1688
+ name: 'Commit all groups',
1689
+ value: 'commit',
1690
+ description: `Create ${groups.length} commits as shown`,
1691
+ },
1692
+ {
1693
+ name: 'Regenerate a message',
1694
+ value: 'regenerate',
1695
+ description: 'Ask AI for a different message for one group',
1696
+ },
1697
+ {
1698
+ name: 'Edit plan',
1699
+ value: 'edit',
1700
+ description: 'Modify the plan (JSON) in your editor',
1701
+ },
1702
+ { name: 'Cancel', value: 'cancel', description: 'Abort without committing' },
1703
+ ],
1704
+ },
1705
+ reasoningEnabled
1706
+ ? {
1707
+ text: reasoningText,
1708
+ maxChars: config.reasoning.maxDisplayChars,
1709
+ }
1710
+ : null,
1711
+ );
1712
+
1713
+ if (action === 'cancel') {
1714
+ return finishCancelled({
1715
+ plan: groups,
1716
+ latencyMs: elapsed,
1717
+ usage,
1718
+ edited: planEdited,
1719
+ rewrites: rewriteCount,
1720
+ });
1721
+ }
1722
+
1723
+ if (action === 'edit') {
1724
+ const edited = await editPlan(groups, allFiles, config.language, config.commitPolicy);
1725
+ if (edited) {
1726
+ groups = edited;
1727
+ validateOrFallbackHunks();
1728
+ regenCounts = groups.map(() => 0);
1729
+ planEdited = true;
1730
+ }
1731
+ continue; // show the (possibly updated) plan again
1732
+ }
1733
+
1734
+ if (action === 'regenerate') {
1735
+ const picked = await vimCheckbox({
1736
+ message: 'Regenerate which commit messages? (space to select, enter to confirm)',
1737
+ choices: groups.map((g, i) => ({
1738
+ // checkbox rows are single-line — show the subject only
1739
+ name: `${i + 1}. ${g.message.split('\n')[0]}`,
1740
+ value: i,
1741
+ })),
1742
+ });
1743
+
1744
+ for (const idx of picked) {
1745
+ const rawGroupDiff = getGroupDiff(
1746
+ projectRoot,
1747
+ head,
1748
+ scope,
1749
+ groups[idx],
1750
+ allFiles,
1751
+ config.diffContextLines,
1752
+ planningConfig.stripFiles,
1753
+ protectModelInput,
1754
+ untrackedSnapshot.previews,
1755
+ );
1756
+ const groupDiff = protectModelInput
1757
+ ? protectSensitiveDiff(rawGroupDiff).diff
1758
+ : rawGroupDiff;
1759
+ if (
1760
+ getSplitStateFingerprint(projectRoot, head, undefined, scope) !== plannedStateFingerprint
1761
+ ) {
1762
+ console.log(
1763
+ '\n ' +
1764
+ chalk.red(
1765
+ '✗ The working tree changed after split planning; message regeneration aborted.',
1766
+ ),
1767
+ );
1768
+ console.log(
1769
+ chalk.dim(` Review the changes and run aicommit split run --scope=${scope} again.\n`),
1770
+ );
1771
+ throw fail(
1772
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
1773
+ 'The working tree changed after split planning; message regeneration aborted.',
1774
+ { reported: true },
1775
+ );
1776
+ }
1777
+ try {
1778
+ regenCounts[idx]++;
1779
+ const { message, reasoning, corrections } = await runModelTask({
1780
+ spinnerText: `Regenerating message for group ${idx + 1} ...`,
1781
+ reasoning: config.reasoning,
1782
+ machineOutput,
1783
+ cancelMessage: 'Split cancelled.',
1784
+ failureMessage: `Group ${idx + 1} regenerate failed`,
1785
+ task: (stream) =>
1786
+ generateCommitMessage(
1787
+ config,
1788
+ groupDiff,
1789
+ regenCounts[idx],
1790
+ groups[idx].message,
1791
+ stream,
1792
+ ),
1793
+ successMessage(result) {
1794
+ let done = `Group ${idx + 1} regenerated in ${chalk.bold(formatMs(result.elapsed))}`;
1795
+ if (result.usage) done += chalk.dim(` · tokens: ${formatUsage(result.usage)}`);
1796
+ return done;
1797
+ },
1798
+ });
1799
+ reasoningText = reasoning;
1800
+ groups[idx] = { ...groups[idx], message };
1801
+ rewriteCount += 1 + (corrections || 0);
1802
+ } catch (err) {
1803
+ regenCounts[idx]--;
1804
+ console.log(`\n ${indentError(err)}\n`);
1805
+ }
1806
+ }
1807
+ continue; // show the updated plan again
1808
+ }
1809
+
1810
+ if (extensionViolations.length) {
1811
+ const details = extensionViolations
1812
+ .map(
1813
+ (violation) =>
1814
+ `Split group ${violation.group}: ${violation.errors
1815
+ .map((item) => item.message)
1816
+ .join(' ')}`,
1817
+ )
1818
+ .join(' ');
1819
+ if (!yes) {
1820
+ console.log(chalk.dim('\n Edit the plan or regenerate its messages before continuing.\n'));
1821
+ continue;
1822
+ }
1823
+ throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, details);
1824
+ }
1825
+
1826
+ break; // commit, or finish the dry run
1827
+ }
1828
+
1829
+ const reviewedPlan = createSplitPlanArtifact({
1830
+ scope,
1831
+ baseHead: head ? readGit(['rev-parse', 'HEAD'], projectRoot).trim() : null,
1832
+ fingerprint: plannedStateFingerprint,
1833
+ language: config.language,
1834
+ commitPolicy: config.commitPolicy,
1835
+ changes: allFiles,
1836
+ groups,
1837
+ hunkMode,
1838
+ });
1839
+ let writtenPlanPath = null;
1840
+ if (exportPlanPath) {
1841
+ writtenPlanPath = await writeSplitPlanArtifact(exportPlanPath, reviewedPlan);
1842
+ console.log(' ' + chalk.green('✓') + chalk.dim(` Split plan written: ${writtenPlanPath}`));
1843
+ }
1844
+
1845
+ if (dryRun) {
1846
+ console.log('\n ' + chalk.green.bold('✓ Dry run complete — no commits were created.\n'));
1847
+ return {
1848
+ plan: groups,
1849
+ planFile: writtenPlanPath,
1850
+ provider,
1851
+ model: config.modelId,
1852
+ latencyMs: elapsed,
1853
+ usage,
1854
+ warnings,
1855
+ exitReason: 'dry_run',
1856
+ committed: false,
1857
+ edited: planEdited,
1858
+ rewrites: rewriteCount,
1859
+ };
1860
+ }
1861
+
1862
+ if (getSplitStateFingerprint(projectRoot, head, undefined, scope) !== plannedStateFingerprint) {
1863
+ console.log(
1864
+ '\n ' +
1865
+ chalk.red('✗ The working tree changed after split planning; no commits were created.'),
1866
+ );
1867
+ console.log(
1868
+ chalk.dim(` Review the changes and run aicommit split run --scope=${scope} again.\n`),
1869
+ );
1870
+ throw fail(
1871
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
1872
+ 'The working tree changed after split planning; no commits were created.',
1873
+ { reported: true },
1874
+ );
1875
+ }
1876
+
1877
+ console.log('');
1878
+ const ok = executeSplit(groups, projectRoot, allFiles, machineOutput, scope, {
1879
+ planArtifact: reviewedPlan,
1880
+ });
1881
+ if (ok) {
1882
+ console.log('\n ' + chalk.green.bold(`✓ Done! Created ${groups.length} commits.\n`));
1883
+ } else {
1884
+ throw fail(ERROR_CATEGORIES.GIT_STATE, 'One or more split commits failed.', {
1885
+ reported: true,
1886
+ });
1887
+ }
1888
+ return {
1889
+ plan: groups,
1890
+ provider,
1891
+ model: config.modelId,
1892
+ latencyMs: elapsed,
1893
+ usage,
1894
+ warnings,
1895
+ exitReason: 'success',
1896
+ committed: true,
1897
+ edited: planEdited,
1898
+ rewrites: rewriteCount,
1899
+ };
1900
+ }
1901
+
1902
+ function canonicalChanges(changes) {
1903
+ return [...changes]
1904
+ .map((change) => ({
1905
+ status: change.status,
1906
+ path: change.path,
1907
+ addPaths: [...change.addPaths],
1908
+ }))
1909
+ .sort((left, right) => left.path.localeCompare(right.path));
1910
+ }
1911
+
1912
+ function reconcileInFlight(projectRoot, checkpoint) {
1913
+ const inFlight = checkpoint.inFlight;
1914
+ if (!inFlight) {
1915
+ const expected = checkpoint.completed.at(-1)?.commit || checkpoint.plan.baseHead;
1916
+ if (currentHead(projectRoot) !== expected) {
1917
+ throw new Error('HEAD no longer matches the last recorded split commit.');
1918
+ }
1919
+ return checkpoint;
1920
+ }
1921
+
1922
+ const head = currentHead(projectRoot);
1923
+ if (head === inFlight.parent) {
1924
+ return writeSplitCheckpoint(projectRoot, { ...checkpoint, inFlight: null }).checkpoint;
1925
+ }
1926
+ if (
1927
+ head &&
1928
+ currentHeadParent(projectRoot) === inFlight.parent &&
1929
+ currentHeadTree(projectRoot) === inFlight.tree
1930
+ ) {
1931
+ return writeSplitCheckpoint(projectRoot, {
1932
+ ...checkpoint,
1933
+ completed: [
1934
+ ...checkpoint.completed,
1935
+ {
1936
+ index: inFlight.index,
1937
+ commit: head,
1938
+ parent: inFlight.parent,
1939
+ tree: inFlight.tree,
1940
+ },
1941
+ ],
1942
+ inFlight: null,
1943
+ }).checkpoint;
1944
+ }
1945
+ throw new Error('HEAD cannot be reconciled with the in-flight split group.');
1946
+ }
1947
+
1948
+ function validateRemainingSnapshot(projectRoot, checkpoint) {
1949
+ const remainingGroups = checkpoint.plan.groups.slice(checkpoint.completed.length);
1950
+ const remainingDisplays = new Set(remainingGroups.flatMap(groupDisplayPaths));
1951
+ const remainingChanges = checkpoint.plan.changes.filter((change) =>
1952
+ remainingDisplays.has(change.path),
1953
+ );
1954
+ const currentChanges = getSplitChangedFiles(projectRoot, checkpoint.plan.scope);
1955
+ if (
1956
+ JSON.stringify(canonicalChanges(currentChanges)) !==
1957
+ JSON.stringify(canonicalChanges(remainingChanges))
1958
+ ) {
1959
+ throw new Error('Remaining split change set no longer matches the checkpoint.');
1960
+ }
1961
+ const paths = [...new Set(remainingChanges.flatMap((change) => change.addPaths))];
1962
+ const currentTarget = captureTargetEntries(projectRoot, checkpoint.plan.scope, paths);
1963
+ const snapshots = new Map(checkpoint.snapshots.map((snapshot) => [snapshot.path, snapshot]));
1964
+ for (const path of paths) {
1965
+ if (!entriesEqual(currentTarget.get(path) || null, snapshots.get(path)?.target || null)) {
1966
+ throw new Error(`Remaining split content changed after interruption: ${path}`);
1967
+ }
1968
+ }
1969
+ return { remainingGroups, remainingChanges };
1970
+ }
1971
+
1972
+ export async function resumeSplit(projectRoot, { yes = false, machineOutput = false } = {}) {
1973
+ let transaction;
1974
+ try {
1975
+ transaction = readSplitCheckpoint(projectRoot);
1976
+ transaction.checkpoint = reconcileInFlight(projectRoot, transaction.checkpoint);
1977
+ reconcileCompletedIndex(projectRoot, transaction.checkpoint);
1978
+ } catch (err) {
1979
+ const category = /no split checkpoint/i.test(err.message)
1980
+ ? ERROR_CATEGORIES.CONFIG
1981
+ : ERROR_CATEGORIES.CONCURRENT_MODIFICATION;
1982
+ throw fail(category, `Cannot resume split: ${err.message}`, { cause: err });
1983
+ }
1984
+ const checkpoint = transaction.checkpoint;
1985
+ if (checkpoint.completed.length === checkpoint.plan.groups.length) {
1986
+ removeSplitCheckpoint(projectRoot);
1987
+ console.log(
1988
+ '\n ' +
1989
+ chalk.green.bold('✓ The final in-flight group was already committed; checkpoint closed.\n'),
1990
+ );
1991
+ return {
1992
+ plan: checkpoint.plan.groups,
1993
+ warnings: [],
1994
+ exitReason: 'success',
1995
+ committed: true,
1996
+ edited: false,
1997
+ rewrites: 0,
1998
+ };
1999
+ }
2000
+
2001
+ let remaining;
2002
+ try {
2003
+ remaining = validateRemainingSnapshot(projectRoot, checkpoint);
2004
+ } catch (err) {
2005
+ throw fail(ERROR_CATEGORIES.CONCURRENT_MODIFICATION, `Cannot resume split: ${err.message}`, {
2006
+ cause: err,
2007
+ });
2008
+ }
2009
+ console.log(
2010
+ '\n ' +
2011
+ chalk.green('✓') +
2012
+ chalk.dim(
2013
+ ` Resuming transaction ${checkpoint.transactionId.slice(0, 12)}: ` +
2014
+ `${checkpoint.completed.length} completed, ${remaining.remainingGroups.length} pending`,
2015
+ ),
2016
+ );
2017
+ displayPlan(remaining.remainingGroups, checkpoint.plan.changes);
2018
+ if (!yes) {
2019
+ const action = await vimSelect({
2020
+ message: 'Resume the pending split groups?',
2021
+ choices: [
2022
+ {
2023
+ name: 'Resume pending groups',
2024
+ value: 'resume',
2025
+ description: `Create ${remaining.remainingGroups.length} remaining commit(s)`,
2026
+ },
2027
+ { name: 'Cancel', value: 'cancel', description: 'Keep the checkpoint' },
2028
+ ],
2029
+ });
2030
+ if (action === 'cancel') {
2031
+ console.log(chalk.dim('\n Split resume cancelled; checkpoint retained.\n'));
2032
+ return {
2033
+ plan: remaining.remainingGroups,
2034
+ warnings: [],
2035
+ exitReason: 'cancelled',
2036
+ committed: false,
2037
+ };
2038
+ }
2039
+ }
2040
+
2041
+ const ok = executeSplit(
2042
+ checkpoint.plan.groups,
2043
+ projectRoot,
2044
+ checkpoint.plan.changes,
2045
+ machineOutput,
2046
+ checkpoint.plan.scope,
2047
+ { transaction: { path: transaction.path, checkpoint } },
2048
+ );
2049
+ if (!ok) {
2050
+ throw fail(ERROR_CATEGORIES.GIT_STATE, 'One or more resumed split commits failed.', {
2051
+ reported: true,
2052
+ });
2053
+ }
2054
+ console.log(
2055
+ '\n ' +
2056
+ chalk.green.bold(
2057
+ `✓ Resume complete! Created ${remaining.remainingGroups.length} commit(s).\n`,
2058
+ ),
2059
+ );
2060
+ return {
2061
+ plan: checkpoint.plan.groups,
2062
+ provider: null,
2063
+ model: null,
2064
+ latencyMs: null,
2065
+ usage: null,
2066
+ warnings: [],
2067
+ exitReason: 'success',
2068
+ committed: true,
2069
+ edited: false,
2070
+ rewrites: 0,
2071
+ };
2072
+ }
2073
+
2074
+ export async function abortSplit(projectRoot, { yes = false } = {}) {
2075
+ const path = splitCheckpointPath(projectRoot);
2076
+ let checkpoint = null;
2077
+ const warnings = [];
2078
+ try {
2079
+ checkpoint = readSplitCheckpoint(projectRoot).checkpoint;
2080
+ } catch (err) {
2081
+ if (/no split checkpoint/i.test(err.message)) {
2082
+ throw fail(ERROR_CATEGORIES.CONFIG, `Cannot abort split: ${err.message}`, { cause: err });
2083
+ }
2084
+ // An invalid or obsolete checkpoint can still block every future split.
2085
+ // The explicit abort command may unlink it, while preserving the warning
2086
+ // so the user knows recovery metadata could not be inspected.
2087
+ warnings.push(`Checkpoint metadata could not be validated: ${err.message}`);
2088
+ }
2089
+
2090
+ console.log(
2091
+ '\n ' +
2092
+ chalk.yellow('⚠') +
2093
+ chalk.dim(
2094
+ checkpoint
2095
+ ? ` Split transaction ${checkpoint.transactionId.slice(0, 12)} has ` +
2096
+ `${checkpoint.completed.length} completed and ` +
2097
+ `${checkpoint.plan.groups.length - checkpoint.completed.length} pending group(s).`
2098
+ : ` Split checkpoint metadata is unreadable: ${path}`,
2099
+ ),
2100
+ );
2101
+ console.log(
2102
+ chalk.dim(' Aborting removes only the checkpoint; existing commits and current changes stay.'),
2103
+ );
2104
+
2105
+ if (!yes) {
2106
+ const action = await vimSelect({
2107
+ message: 'Discard the unfinished split checkpoint?',
2108
+ choices: [
2109
+ {
2110
+ name: 'Discard checkpoint',
2111
+ value: 'discard',
2112
+ description: 'Keep HEAD, index, and worktree exactly as they are',
2113
+ },
2114
+ { name: 'Cancel', value: 'cancel', description: 'Keep the checkpoint' },
2115
+ ],
2116
+ });
2117
+ if (action === 'cancel') {
2118
+ console.log(chalk.dim('\n Split abort cancelled; checkpoint retained.\n'));
2119
+ return {
2120
+ plan: null,
2121
+ warnings,
2122
+ exitReason: 'cancelled',
2123
+ committed: false,
2124
+ };
2125
+ }
2126
+ }
2127
+
2128
+ try {
2129
+ discardSplitCheckpoint(projectRoot);
2130
+ } catch (err) {
2131
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `Cannot abort split: ${err.message}`, { cause: err });
2132
+ }
2133
+ console.log(
2134
+ '\n ' +
2135
+ chalk.green.bold(
2136
+ '✓ Split checkpoint discarded; existing commits and current changes kept.\n',
2137
+ ),
2138
+ );
2139
+ return {
2140
+ plan: null,
2141
+ provider: null,
2142
+ model: null,
2143
+ latencyMs: null,
2144
+ usage: null,
2145
+ warnings,
2146
+ exitReason: 'split_aborted',
2147
+ committed: false,
2148
+ edited: false,
2149
+ rewrites: 0,
2150
+ data: { checkpointRemoved: true },
2151
+ };
2152
+ }
2153
+
2154
+ export async function applySplitPlan(
2155
+ projectRoot,
2156
+ planPath,
2157
+ { yes = false, machineOutput = false } = {},
2158
+ ) {
2159
+ requireNoSplitCheckpoint(projectRoot);
2160
+ let loaded;
2161
+ try {
2162
+ loaded = await readSplitPlanArtifact(planPath);
2163
+ } catch (err) {
2164
+ throw fail(ERROR_CATEGORIES.CONFIG, err.message, { cause: err });
2165
+ }
2166
+ let { artifact } = loaded;
2167
+ const warnings = [];
2168
+ const head = hasHead(projectRoot);
2169
+ const currentHead = head ? readGit(['rev-parse', 'HEAD'], projectRoot).trim() : null;
2170
+ if (currentHead !== artifact.baseHead) {
2171
+ throw fail(
2172
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
2173
+ 'Split plan base HEAD no longer matches this repository; no commits were created.',
2174
+ );
2175
+ }
2176
+ const currentChanges = getSplitChangedFiles(projectRoot, artifact.scope);
2177
+ if (
2178
+ JSON.stringify(canonicalChanges(currentChanges)) !==
2179
+ JSON.stringify(canonicalChanges(artifact.changes))
2180
+ ) {
2181
+ throw fail(
2182
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
2183
+ 'Split plan change set no longer matches this repository; no commits were created.',
2184
+ );
2185
+ }
2186
+ const currentFingerprint = getSplitStateFingerprint(
2187
+ projectRoot,
2188
+ head,
2189
+ currentChanges,
2190
+ artifact.scope,
2191
+ );
2192
+ if (currentFingerprint !== artifact.fingerprint) {
2193
+ throw fail(
2194
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
2195
+ 'Split plan fingerprint no longer matches this repository; no commits were created.',
2196
+ );
2197
+ }
2198
+ if (artifact.hunkMode) {
2199
+ try {
2200
+ const snapshots = captureCheckpointSnapshots(projectRoot, artifact.scope, artifact.changes);
2201
+ validateHunkTransaction(projectRoot, artifact, snapshots);
2202
+ } catch (err) {
2203
+ const warning = `Imported hunk plan was not lossless and fell back to file-level grouping: ${err.message}`;
2204
+ warnings.push(warning);
2205
+ console.log('\n ' + chalk.yellow(`⚠ ${sanitizeTerminalText(warning)}`));
2206
+ artifact = createSplitPlanArtifact({
2207
+ scope: artifact.scope,
2208
+ baseHead: artifact.baseHead,
2209
+ fingerprint: artifact.fingerprint,
2210
+ language: artifact.language,
2211
+ commitPolicy: artifact.commitPolicy,
2212
+ changes: stripHunkCatalog(artifact.changes),
2213
+ groups: fallbackHunkGroups(artifact.groups, artifact.changes),
2214
+ hunkMode: false,
2215
+ createdAt: artifact.createdAt,
2216
+ });
2217
+ }
2218
+ }
2219
+
2220
+ console.log(
2221
+ '\n ' +
2222
+ chalk.green('✓') +
2223
+ chalk.dim(` Loaded split plan: ${loaded.path} (scope: ${artifact.scope})`),
2224
+ );
2225
+ displayPlan(artifact.groups, artifact.changes);
2226
+ if (!yes) {
2227
+ const action = await vimSelect({
2228
+ message: 'Apply this validated split plan?',
2229
+ choices: [
2230
+ {
2231
+ name: 'Apply all groups',
2232
+ value: 'apply',
2233
+ description: `Create ${artifact.groups.length} commits`,
2234
+ },
2235
+ { name: 'Cancel', value: 'cancel', description: 'Create no commits' },
2236
+ ],
2237
+ });
2238
+ if (action === 'cancel') {
2239
+ console.log(chalk.dim('\n Split apply cancelled.\n'));
2240
+ return {
2241
+ plan: artifact.groups,
2242
+ warnings,
2243
+ exitReason: 'cancelled',
2244
+ committed: false,
2245
+ };
2246
+ }
2247
+ }
2248
+
2249
+ // Recheck immediately before the first index mutation. A plan can sit on
2250
+ // disk for days, and even the interactive confirmation creates a race.
2251
+ if (
2252
+ getSplitStateFingerprint(projectRoot, head, undefined, artifact.scope) !== artifact.fingerprint
2253
+ ) {
2254
+ throw fail(
2255
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
2256
+ 'Split state changed during apply confirmation; no commits were created.',
2257
+ );
2258
+ }
2259
+ const ok = executeSplit(
2260
+ artifact.groups,
2261
+ projectRoot,
2262
+ artifact.changes,
2263
+ machineOutput,
2264
+ artifact.scope,
2265
+ { planArtifact: artifact },
2266
+ );
2267
+ if (!ok) {
2268
+ throw fail(ERROR_CATEGORIES.GIT_STATE, 'One or more split commits failed.', {
2269
+ reported: true,
2270
+ });
2271
+ }
2272
+ console.log('\n ' + chalk.green.bold(`✓ Done! Created ${artifact.groups.length} commits.\n`));
2273
+ return {
2274
+ plan: artifact.groups,
2275
+ provider: null,
2276
+ model: null,
2277
+ latencyMs: null,
2278
+ usage: null,
2279
+ warnings,
2280
+ exitReason: 'success',
2281
+ committed: true,
2282
+ edited: false,
2283
+ rewrites: 0,
2284
+ };
2285
+ }