git-coco 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -472,6 +472,7 @@ type LlmCallMetadata = {
472
472
  provider?: string;
473
473
  model?: string;
474
474
  retryAttempt?: number;
475
+ planAttempt?: number;
475
476
  parserType?: string;
476
477
  variableKeys?: string[];
477
478
  promptTokens?: number;
@@ -54,7 +54,7 @@ import { pathToFileURL } from 'url';
54
54
  /**
55
55
  * Current build version from package.json
56
56
  */
57
- const BUILD_VERSION = "0.43.0";
57
+ const BUILD_VERSION = "0.44.0";
58
58
 
59
59
  const isInteractive = (config) => {
60
60
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -12415,6 +12415,164 @@ const CommitSplitPlanSchema = objectType({
12415
12415
  }))
12416
12416
  .min(1),
12417
12417
  });
12418
+
12419
+ const getGroupFiles$1 = (group) => group.files || [];
12420
+ const getGroupHunks$1 = (group) => group.hunks || [];
12421
+ function getPlanValidationIssues(plan, staged, hunkInventory) {
12422
+ const stagedFiles = new Set(staged.map((change) => change.filePath));
12423
+ const seen = new Set();
12424
+ const seenHunks = new Set();
12425
+ const unknownFiles = [];
12426
+ const duplicateFiles = [];
12427
+ const unknownHunks = [];
12428
+ const duplicateHunks = [];
12429
+ plan.groups.forEach((group) => {
12430
+ getGroupFiles$1(group).forEach((file) => {
12431
+ if (!stagedFiles.has(file)) {
12432
+ unknownFiles.push(file);
12433
+ return;
12434
+ }
12435
+ if (seen.has(file)) {
12436
+ duplicateFiles.push(file);
12437
+ return;
12438
+ }
12439
+ seen.add(file);
12440
+ });
12441
+ getGroupHunks$1(group).forEach((hunkId) => {
12442
+ const hunk = hunkInventory?.byId.get(hunkId);
12443
+ if (!hunk) {
12444
+ unknownHunks.push(hunkId);
12445
+ return;
12446
+ }
12447
+ if (seenHunks.has(hunkId)) {
12448
+ duplicateHunks.push(hunkId);
12449
+ return;
12450
+ }
12451
+ seenHunks.add(hunkId);
12452
+ });
12453
+ });
12454
+ const hunkCoveredFiles = new Set([...seenHunks].map((hunkId) => hunkInventory?.byId.get(hunkId)?.filePath));
12455
+ const mixedFiles = [...seen].filter((file) => hunkCoveredFiles.has(file));
12456
+ const partiallyCoveredFiles = [...hunkCoveredFiles]
12457
+ .filter((file) => Boolean(file))
12458
+ .filter((file) => {
12459
+ const fileHunks = hunkInventory?.byFile.get(file) || [];
12460
+ return fileHunks.some((hunk) => !seenHunks.has(hunk.id));
12461
+ });
12462
+ const missingFiles = [...stagedFiles].filter((file) => !seen.has(file) && !hunkCoveredFiles.has(file));
12463
+ return {
12464
+ unknownFiles,
12465
+ duplicateFiles,
12466
+ unknownHunks,
12467
+ duplicateHunks,
12468
+ mixedFiles,
12469
+ partiallyCoveredFiles,
12470
+ missingFiles,
12471
+ };
12472
+ }
12473
+ function hasPlanValidationIssues(issues) {
12474
+ return (issues.unknownFiles.length > 0 ||
12475
+ issues.duplicateFiles.length > 0 ||
12476
+ issues.unknownHunks.length > 0 ||
12477
+ issues.duplicateHunks.length > 0 ||
12478
+ issues.mixedFiles.length > 0 ||
12479
+ issues.partiallyCoveredFiles.length > 0 ||
12480
+ issues.missingFiles.length > 0);
12481
+ }
12482
+ function formatPlanValidationIssuesError(issues) {
12483
+ return [
12484
+ issues.unknownFiles.length ? `unknown files: ${issues.unknownFiles.join(', ')}` : undefined,
12485
+ issues.duplicateFiles.length
12486
+ ? `duplicate files: ${issues.duplicateFiles.join(', ')}`
12487
+ : undefined,
12488
+ issues.unknownHunks.length ? `unknown hunks: ${issues.unknownHunks.join(', ')}` : undefined,
12489
+ issues.duplicateHunks.length
12490
+ ? `duplicate hunks: ${issues.duplicateHunks.join(', ')}`
12491
+ : undefined,
12492
+ issues.mixedFiles.length
12493
+ ? `files assigned both as whole files and hunks: ${issues.mixedFiles.join(', ')}`
12494
+ : undefined,
12495
+ issues.partiallyCoveredFiles.length
12496
+ ? `files with only some hunks assigned: ${issues.partiallyCoveredFiles.join(', ')}`
12497
+ : undefined,
12498
+ issues.missingFiles.length ? `missing files: ${issues.missingFiles.join(', ')}` : undefined,
12499
+ ]
12500
+ .filter(Boolean)
12501
+ .join('; ');
12502
+ }
12503
+ function formatPlanValidationFeedback(issues) {
12504
+ const sections = [];
12505
+ if (issues.unknownFiles.length) {
12506
+ sections.push(`Files referenced that are NOT in the staged file inventory (remove or replace): ${issues.unknownFiles.join(', ')}`);
12507
+ }
12508
+ if (issues.duplicateFiles.length) {
12509
+ sections.push(`Files assigned to more than one group (each file may appear at most once): ${issues.duplicateFiles.join(', ')}`);
12510
+ }
12511
+ if (issues.unknownHunks.length) {
12512
+ sections.push(`Hunk IDs referenced that are NOT in the staged hunk inventory: ${issues.unknownHunks.join(', ')}`);
12513
+ }
12514
+ if (issues.duplicateHunks.length) {
12515
+ sections.push(`Hunk IDs assigned to more than one group (each hunk may appear at most once): ${issues.duplicateHunks.join(', ')}`);
12516
+ }
12517
+ if (issues.mixedFiles.length) {
12518
+ sections.push(`Files assigned BOTH as whole files and via hunks (pick one mode per file): ${issues.mixedFiles.join(', ')}`);
12519
+ }
12520
+ if (issues.partiallyCoveredFiles.length) {
12521
+ sections.push(`Files with only some hunks assigned (every hunk for these files must be covered): ${issues.partiallyCoveredFiles.join(', ')}`);
12522
+ }
12523
+ if (issues.missingFiles.length) {
12524
+ sections.push(`Staged files missing from every group (must appear exactly once): ${issues.missingFiles.join(', ')}`);
12525
+ }
12526
+ return sections.map((section) => `- ${section}`).join('\n');
12527
+ }
12528
+
12529
+ const NO_PREVIOUS_FEEDBACK_PLACEHOLDER = 'None — this is the first attempt.';
12530
+ const DEFAULT_MAX_PLAN_ATTEMPTS = 3;
12531
+ /**
12532
+ * Generate a commit-split plan with self-correcting retries on validator failures.
12533
+ *
12534
+ * The first attempt runs as normal. If `validatePlanForStagedFiles` rejects the result,
12535
+ * the validator's complaints are formatted as natural-language feedback and fed back
12536
+ * into the same prompt template (`previous_attempt_feedback` slot) so the model can
12537
+ * fix its own mistakes without re-running pre-processing.
12538
+ */
12539
+ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, }) {
12540
+ let lastIssues = null;
12541
+ let attempt = 0;
12542
+ while (attempt < maxAttempts) {
12543
+ attempt++;
12544
+ const previousFeedback = lastIssues
12545
+ ? formatPlanValidationFeedback(lastIssues)
12546
+ : NO_PREVIOUS_FEEDBACK_PLACEHOLDER;
12547
+ const plan = await executeChainWithSchema(CommitSplitPlanSchema, llm, prompt, {
12548
+ ...variables,
12549
+ previous_attempt_feedback: previousFeedback,
12550
+ }, {
12551
+ logger,
12552
+ tokenizer,
12553
+ metadata: {
12554
+ task: 'commit-split-plan',
12555
+ ...metadata,
12556
+ planAttempt: attempt,
12557
+ },
12558
+ });
12559
+ const issues = getPlanValidationIssues(plan, staged, hunkInventory);
12560
+ if (!hasPlanValidationIssues(issues)) {
12561
+ if (attempt > 1 && logger) {
12562
+ logger.verbose(`Plan validated after ${attempt} attempts.`, { color: 'green' });
12563
+ }
12564
+ return { plan, attempts: attempt };
12565
+ }
12566
+ lastIssues = issues;
12567
+ if (logger) {
12568
+ logger.verbose(`Plan attempt ${attempt}/${maxAttempts} failed validation: ${formatPlanValidationIssuesError(issues)}`, { color: 'yellow' });
12569
+ }
12570
+ }
12571
+ throw new Error(lastIssues
12572
+ ? `Failed to produce a valid commit-split plan after ${maxAttempts} attempts. Final validator issues: ${formatPlanValidationIssuesError(lastIssues)}`
12573
+ : `Failed to produce a valid commit-split plan after ${maxAttempts} attempts.`);
12574
+ }
12575
+
12418
12576
  const COMMIT_SPLIT_PROMPT = PromptTemplate.fromTemplate(`You are helping split staged git changes into a small sequence of coherent commits.
12419
12577
 
12420
12578
  Return ONLY valid JSON matching this schema:
@@ -12431,14 +12589,13 @@ Return ONLY valid JSON matching this schema:
12431
12589
  }}
12432
12590
 
12433
12591
  Rules:
12434
- - Use each staged file exactly once.
12435
- - If a file has hunk IDs and contains unrelated changes, assign every hunk ID exactly once instead of assigning the whole file.
12436
- - Do not list the same file in "files" when assigning that file through "hunks".
12437
- - Only use file paths listed in the staged file inventory.
12438
- - Only use hunk IDs listed in the staged hunk inventory.
12592
+ - Every staged file MUST be assigned exactly once across all groups, either via "files" OR via every one of its hunk IDs (never both).
12593
+ - If you assign any hunk for a file, you MUST assign EVERY hunk for that file across the groups partial coverage is invalid.
12594
+ - Do not list the same file in "files" of more than one group, and do not assign the same hunk ID to more than one group.
12595
+ - Only use file paths listed in the staged file inventory. Do not invent files.
12596
+ - Only use hunk IDs listed in the staged hunk inventory. Do not invent hunk IDs.
12439
12597
  - Prefer 2-5 commits unless the changes are truly all one topic.
12440
12598
  - Keep commit titles concise and understandable.
12441
- - Do not invent files.
12442
12599
 
12443
12600
  Staged file inventory:
12444
12601
  {file_inventory}
@@ -12450,7 +12607,10 @@ Condensed staged diff:
12450
12607
  {summary}
12451
12608
 
12452
12609
  Additional context:
12453
- {additional_context}`);
12610
+ {additional_context}
12611
+
12612
+ Feedback on previous attempt (fix every item before responding):
12613
+ {previous_attempt_feedback}`);
12454
12614
  function isCommitSplitCommand(argv) {
12455
12615
  return Boolean(argv.split || argv.plan || argv.apply || argv._.includes('split'));
12456
12616
  }
@@ -12469,9 +12629,6 @@ function formatCommitSplitPlan(plan) {
12469
12629
  })
12470
12630
  .join('\n\n---\n\n');
12471
12631
  }
12472
- function getStagedFileSet(changes) {
12473
- return new Set(changes.map((change) => change.filePath));
12474
- }
12475
12632
  function getGroupFiles(group) {
12476
12633
  return group.files || [];
12477
12634
  }
@@ -12528,67 +12685,9 @@ function formatHunkInventory(inventory) {
12528
12685
  .join('\n');
12529
12686
  }
12530
12687
  function validatePlanForStagedFiles(plan, staged, hunkInventory) {
12531
- const stagedFiles = getStagedFileSet(staged);
12532
- const seen = new Set();
12533
- const seenHunks = new Set();
12534
- const unknown = [];
12535
- const duplicate = [];
12536
- const unknownHunks = [];
12537
- const duplicateHunks = [];
12538
- plan.groups.forEach((group) => {
12539
- getGroupFiles(group).forEach((file) => {
12540
- if (!stagedFiles.has(file)) {
12541
- unknown.push(file);
12542
- return;
12543
- }
12544
- if (seen.has(file)) {
12545
- duplicate.push(file);
12546
- return;
12547
- }
12548
- seen.add(file);
12549
- });
12550
- getGroupHunks(group).forEach((hunkId) => {
12551
- const hunk = hunkInventory?.byId.get(hunkId);
12552
- if (!hunk) {
12553
- unknownHunks.push(hunkId);
12554
- return;
12555
- }
12556
- if (seenHunks.has(hunkId)) {
12557
- duplicateHunks.push(hunkId);
12558
- return;
12559
- }
12560
- seenHunks.add(hunkId);
12561
- });
12562
- });
12563
- const hunkCoveredFiles = new Set([...seenHunks].map((hunkId) => hunkInventory?.byId.get(hunkId)?.filePath));
12564
- const mixedFiles = [...seen].filter((file) => hunkCoveredFiles.has(file));
12565
- const partiallyCoveredFiles = [...hunkCoveredFiles]
12566
- .filter((file) => Boolean(file))
12567
- .filter((file) => {
12568
- const fileHunks = hunkInventory?.byFile.get(file) || [];
12569
- return fileHunks.some((hunk) => !seenHunks.has(hunk.id));
12570
- });
12571
- const missing = [...stagedFiles].filter((file) => !seen.has(file) && !hunkCoveredFiles.has(file));
12572
- if (unknown.length ||
12573
- duplicate.length ||
12574
- unknownHunks.length ||
12575
- duplicateHunks.length ||
12576
- mixedFiles.length ||
12577
- partiallyCoveredFiles.length ||
12578
- missing.length) {
12579
- throw new Error([
12580
- unknown.length ? `unknown files: ${unknown.join(', ')}` : undefined,
12581
- duplicate.length ? `duplicate files: ${duplicate.join(', ')}` : undefined,
12582
- unknownHunks.length ? `unknown hunks: ${unknownHunks.join(', ')}` : undefined,
12583
- duplicateHunks.length ? `duplicate hunks: ${duplicateHunks.join(', ')}` : undefined,
12584
- mixedFiles.length ? `files assigned both as whole files and hunks: ${mixedFiles.join(', ')}` : undefined,
12585
- partiallyCoveredFiles.length
12586
- ? `files with only some hunks assigned: ${partiallyCoveredFiles.join(', ')}`
12587
- : undefined,
12588
- missing.length ? `missing files: ${missing.join(', ')}` : undefined,
12589
- ]
12590
- .filter(Boolean)
12591
- .join('; '));
12688
+ const issues = getPlanValidationIssues(plan, staged, hunkInventory);
12689
+ if (hasPlanValidationIssues(issues)) {
12690
+ throw new Error(formatPlanValidationIssuesError(issues));
12592
12691
  }
12593
12692
  }
12594
12693
  function assertNoUnstagedOverlap(plan, changes, hunkInventory) {
@@ -12692,22 +12791,26 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, })
12692
12791
  .map((change) => `- ${change.filePath}: ${change.status} - ${change.summary}`)
12693
12792
  .join('\n');
12694
12793
  const hunkInventoryText = formatHunkInventory(hunkInventory);
12695
- const plan = await executeChainWithSchema(CommitSplitPlanSchema, llm, COMMIT_SPLIT_PROMPT, {
12696
- file_inventory: fileInventory,
12697
- hunk_inventory: hunkInventoryText,
12698
- summary,
12699
- additional_context: argv.additional || '',
12700
- }, {
12794
+ const { plan } = await generateValidatedCommitSplitPlan({
12795
+ llm,
12796
+ prompt: COMMIT_SPLIT_PROMPT,
12797
+ variables: {
12798
+ file_inventory: fileInventory,
12799
+ hunk_inventory: hunkInventoryText,
12800
+ summary,
12801
+ additional_context: argv.additional || '',
12802
+ },
12803
+ staged: changes.staged,
12804
+ hunkInventory,
12701
12805
  logger,
12702
12806
  tokenizer,
12703
12807
  metadata: {
12704
- task: 'commit-split-plan',
12705
12808
  command: 'commit',
12706
12809
  provider: config.service.provider,
12707
12810
  model: String(config.service.model),
12708
12811
  },
12812
+ maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
12709
12813
  });
12710
- validatePlanForStagedFiles(plan, changes.staged, hunkInventory);
12711
12814
  if (argv.apply) {
12712
12815
  return await applyCommitSplitPlan({
12713
12816
  plan,
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.43.0";
81
+ const BUILD_VERSION = "0.44.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -12439,6 +12439,164 @@ const CommitSplitPlanSchema = objectType({
12439
12439
  }))
12440
12440
  .min(1),
12441
12441
  });
12442
+
12443
+ const getGroupFiles$1 = (group) => group.files || [];
12444
+ const getGroupHunks$1 = (group) => group.hunks || [];
12445
+ function getPlanValidationIssues(plan, staged, hunkInventory) {
12446
+ const stagedFiles = new Set(staged.map((change) => change.filePath));
12447
+ const seen = new Set();
12448
+ const seenHunks = new Set();
12449
+ const unknownFiles = [];
12450
+ const duplicateFiles = [];
12451
+ const unknownHunks = [];
12452
+ const duplicateHunks = [];
12453
+ plan.groups.forEach((group) => {
12454
+ getGroupFiles$1(group).forEach((file) => {
12455
+ if (!stagedFiles.has(file)) {
12456
+ unknownFiles.push(file);
12457
+ return;
12458
+ }
12459
+ if (seen.has(file)) {
12460
+ duplicateFiles.push(file);
12461
+ return;
12462
+ }
12463
+ seen.add(file);
12464
+ });
12465
+ getGroupHunks$1(group).forEach((hunkId) => {
12466
+ const hunk = hunkInventory?.byId.get(hunkId);
12467
+ if (!hunk) {
12468
+ unknownHunks.push(hunkId);
12469
+ return;
12470
+ }
12471
+ if (seenHunks.has(hunkId)) {
12472
+ duplicateHunks.push(hunkId);
12473
+ return;
12474
+ }
12475
+ seenHunks.add(hunkId);
12476
+ });
12477
+ });
12478
+ const hunkCoveredFiles = new Set([...seenHunks].map((hunkId) => hunkInventory?.byId.get(hunkId)?.filePath));
12479
+ const mixedFiles = [...seen].filter((file) => hunkCoveredFiles.has(file));
12480
+ const partiallyCoveredFiles = [...hunkCoveredFiles]
12481
+ .filter((file) => Boolean(file))
12482
+ .filter((file) => {
12483
+ const fileHunks = hunkInventory?.byFile.get(file) || [];
12484
+ return fileHunks.some((hunk) => !seenHunks.has(hunk.id));
12485
+ });
12486
+ const missingFiles = [...stagedFiles].filter((file) => !seen.has(file) && !hunkCoveredFiles.has(file));
12487
+ return {
12488
+ unknownFiles,
12489
+ duplicateFiles,
12490
+ unknownHunks,
12491
+ duplicateHunks,
12492
+ mixedFiles,
12493
+ partiallyCoveredFiles,
12494
+ missingFiles,
12495
+ };
12496
+ }
12497
+ function hasPlanValidationIssues(issues) {
12498
+ return (issues.unknownFiles.length > 0 ||
12499
+ issues.duplicateFiles.length > 0 ||
12500
+ issues.unknownHunks.length > 0 ||
12501
+ issues.duplicateHunks.length > 0 ||
12502
+ issues.mixedFiles.length > 0 ||
12503
+ issues.partiallyCoveredFiles.length > 0 ||
12504
+ issues.missingFiles.length > 0);
12505
+ }
12506
+ function formatPlanValidationIssuesError(issues) {
12507
+ return [
12508
+ issues.unknownFiles.length ? `unknown files: ${issues.unknownFiles.join(', ')}` : undefined,
12509
+ issues.duplicateFiles.length
12510
+ ? `duplicate files: ${issues.duplicateFiles.join(', ')}`
12511
+ : undefined,
12512
+ issues.unknownHunks.length ? `unknown hunks: ${issues.unknownHunks.join(', ')}` : undefined,
12513
+ issues.duplicateHunks.length
12514
+ ? `duplicate hunks: ${issues.duplicateHunks.join(', ')}`
12515
+ : undefined,
12516
+ issues.mixedFiles.length
12517
+ ? `files assigned both as whole files and hunks: ${issues.mixedFiles.join(', ')}`
12518
+ : undefined,
12519
+ issues.partiallyCoveredFiles.length
12520
+ ? `files with only some hunks assigned: ${issues.partiallyCoveredFiles.join(', ')}`
12521
+ : undefined,
12522
+ issues.missingFiles.length ? `missing files: ${issues.missingFiles.join(', ')}` : undefined,
12523
+ ]
12524
+ .filter(Boolean)
12525
+ .join('; ');
12526
+ }
12527
+ function formatPlanValidationFeedback(issues) {
12528
+ const sections = [];
12529
+ if (issues.unknownFiles.length) {
12530
+ sections.push(`Files referenced that are NOT in the staged file inventory (remove or replace): ${issues.unknownFiles.join(', ')}`);
12531
+ }
12532
+ if (issues.duplicateFiles.length) {
12533
+ sections.push(`Files assigned to more than one group (each file may appear at most once): ${issues.duplicateFiles.join(', ')}`);
12534
+ }
12535
+ if (issues.unknownHunks.length) {
12536
+ sections.push(`Hunk IDs referenced that are NOT in the staged hunk inventory: ${issues.unknownHunks.join(', ')}`);
12537
+ }
12538
+ if (issues.duplicateHunks.length) {
12539
+ sections.push(`Hunk IDs assigned to more than one group (each hunk may appear at most once): ${issues.duplicateHunks.join(', ')}`);
12540
+ }
12541
+ if (issues.mixedFiles.length) {
12542
+ sections.push(`Files assigned BOTH as whole files and via hunks (pick one mode per file): ${issues.mixedFiles.join(', ')}`);
12543
+ }
12544
+ if (issues.partiallyCoveredFiles.length) {
12545
+ sections.push(`Files with only some hunks assigned (every hunk for these files must be covered): ${issues.partiallyCoveredFiles.join(', ')}`);
12546
+ }
12547
+ if (issues.missingFiles.length) {
12548
+ sections.push(`Staged files missing from every group (must appear exactly once): ${issues.missingFiles.join(', ')}`);
12549
+ }
12550
+ return sections.map((section) => `- ${section}`).join('\n');
12551
+ }
12552
+
12553
+ const NO_PREVIOUS_FEEDBACK_PLACEHOLDER = 'None — this is the first attempt.';
12554
+ const DEFAULT_MAX_PLAN_ATTEMPTS = 3;
12555
+ /**
12556
+ * Generate a commit-split plan with self-correcting retries on validator failures.
12557
+ *
12558
+ * The first attempt runs as normal. If `validatePlanForStagedFiles` rejects the result,
12559
+ * the validator's complaints are formatted as natural-language feedback and fed back
12560
+ * into the same prompt template (`previous_attempt_feedback` slot) so the model can
12561
+ * fix its own mistakes without re-running pre-processing.
12562
+ */
12563
+ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, }) {
12564
+ let lastIssues = null;
12565
+ let attempt = 0;
12566
+ while (attempt < maxAttempts) {
12567
+ attempt++;
12568
+ const previousFeedback = lastIssues
12569
+ ? formatPlanValidationFeedback(lastIssues)
12570
+ : NO_PREVIOUS_FEEDBACK_PLACEHOLDER;
12571
+ const plan = await executeChainWithSchema(CommitSplitPlanSchema, llm, prompt, {
12572
+ ...variables,
12573
+ previous_attempt_feedback: previousFeedback,
12574
+ }, {
12575
+ logger,
12576
+ tokenizer,
12577
+ metadata: {
12578
+ task: 'commit-split-plan',
12579
+ ...metadata,
12580
+ planAttempt: attempt,
12581
+ },
12582
+ });
12583
+ const issues = getPlanValidationIssues(plan, staged, hunkInventory);
12584
+ if (!hasPlanValidationIssues(issues)) {
12585
+ if (attempt > 1 && logger) {
12586
+ logger.verbose(`Plan validated after ${attempt} attempts.`, { color: 'green' });
12587
+ }
12588
+ return { plan, attempts: attempt };
12589
+ }
12590
+ lastIssues = issues;
12591
+ if (logger) {
12592
+ logger.verbose(`Plan attempt ${attempt}/${maxAttempts} failed validation: ${formatPlanValidationIssuesError(issues)}`, { color: 'yellow' });
12593
+ }
12594
+ }
12595
+ throw new Error(lastIssues
12596
+ ? `Failed to produce a valid commit-split plan after ${maxAttempts} attempts. Final validator issues: ${formatPlanValidationIssuesError(lastIssues)}`
12597
+ : `Failed to produce a valid commit-split plan after ${maxAttempts} attempts.`);
12598
+ }
12599
+
12442
12600
  const COMMIT_SPLIT_PROMPT = prompts.PromptTemplate.fromTemplate(`You are helping split staged git changes into a small sequence of coherent commits.
12443
12601
 
12444
12602
  Return ONLY valid JSON matching this schema:
@@ -12455,14 +12613,13 @@ Return ONLY valid JSON matching this schema:
12455
12613
  }}
12456
12614
 
12457
12615
  Rules:
12458
- - Use each staged file exactly once.
12459
- - If a file has hunk IDs and contains unrelated changes, assign every hunk ID exactly once instead of assigning the whole file.
12460
- - Do not list the same file in "files" when assigning that file through "hunks".
12461
- - Only use file paths listed in the staged file inventory.
12462
- - Only use hunk IDs listed in the staged hunk inventory.
12616
+ - Every staged file MUST be assigned exactly once across all groups, either via "files" OR via every one of its hunk IDs (never both).
12617
+ - If you assign any hunk for a file, you MUST assign EVERY hunk for that file across the groups partial coverage is invalid.
12618
+ - Do not list the same file in "files" of more than one group, and do not assign the same hunk ID to more than one group.
12619
+ - Only use file paths listed in the staged file inventory. Do not invent files.
12620
+ - Only use hunk IDs listed in the staged hunk inventory. Do not invent hunk IDs.
12463
12621
  - Prefer 2-5 commits unless the changes are truly all one topic.
12464
12622
  - Keep commit titles concise and understandable.
12465
- - Do not invent files.
12466
12623
 
12467
12624
  Staged file inventory:
12468
12625
  {file_inventory}
@@ -12474,7 +12631,10 @@ Condensed staged diff:
12474
12631
  {summary}
12475
12632
 
12476
12633
  Additional context:
12477
- {additional_context}`);
12634
+ {additional_context}
12635
+
12636
+ Feedback on previous attempt (fix every item before responding):
12637
+ {previous_attempt_feedback}`);
12478
12638
  function isCommitSplitCommand(argv) {
12479
12639
  return Boolean(argv.split || argv.plan || argv.apply || argv._.includes('split'));
12480
12640
  }
@@ -12493,9 +12653,6 @@ function formatCommitSplitPlan(plan) {
12493
12653
  })
12494
12654
  .join('\n\n---\n\n');
12495
12655
  }
12496
- function getStagedFileSet(changes) {
12497
- return new Set(changes.map((change) => change.filePath));
12498
- }
12499
12656
  function getGroupFiles(group) {
12500
12657
  return group.files || [];
12501
12658
  }
@@ -12552,67 +12709,9 @@ function formatHunkInventory(inventory) {
12552
12709
  .join('\n');
12553
12710
  }
12554
12711
  function validatePlanForStagedFiles(plan, staged, hunkInventory) {
12555
- const stagedFiles = getStagedFileSet(staged);
12556
- const seen = new Set();
12557
- const seenHunks = new Set();
12558
- const unknown = [];
12559
- const duplicate = [];
12560
- const unknownHunks = [];
12561
- const duplicateHunks = [];
12562
- plan.groups.forEach((group) => {
12563
- getGroupFiles(group).forEach((file) => {
12564
- if (!stagedFiles.has(file)) {
12565
- unknown.push(file);
12566
- return;
12567
- }
12568
- if (seen.has(file)) {
12569
- duplicate.push(file);
12570
- return;
12571
- }
12572
- seen.add(file);
12573
- });
12574
- getGroupHunks(group).forEach((hunkId) => {
12575
- const hunk = hunkInventory?.byId.get(hunkId);
12576
- if (!hunk) {
12577
- unknownHunks.push(hunkId);
12578
- return;
12579
- }
12580
- if (seenHunks.has(hunkId)) {
12581
- duplicateHunks.push(hunkId);
12582
- return;
12583
- }
12584
- seenHunks.add(hunkId);
12585
- });
12586
- });
12587
- const hunkCoveredFiles = new Set([...seenHunks].map((hunkId) => hunkInventory?.byId.get(hunkId)?.filePath));
12588
- const mixedFiles = [...seen].filter((file) => hunkCoveredFiles.has(file));
12589
- const partiallyCoveredFiles = [...hunkCoveredFiles]
12590
- .filter((file) => Boolean(file))
12591
- .filter((file) => {
12592
- const fileHunks = hunkInventory?.byFile.get(file) || [];
12593
- return fileHunks.some((hunk) => !seenHunks.has(hunk.id));
12594
- });
12595
- const missing = [...stagedFiles].filter((file) => !seen.has(file) && !hunkCoveredFiles.has(file));
12596
- if (unknown.length ||
12597
- duplicate.length ||
12598
- unknownHunks.length ||
12599
- duplicateHunks.length ||
12600
- mixedFiles.length ||
12601
- partiallyCoveredFiles.length ||
12602
- missing.length) {
12603
- throw new Error([
12604
- unknown.length ? `unknown files: ${unknown.join(', ')}` : undefined,
12605
- duplicate.length ? `duplicate files: ${duplicate.join(', ')}` : undefined,
12606
- unknownHunks.length ? `unknown hunks: ${unknownHunks.join(', ')}` : undefined,
12607
- duplicateHunks.length ? `duplicate hunks: ${duplicateHunks.join(', ')}` : undefined,
12608
- mixedFiles.length ? `files assigned both as whole files and hunks: ${mixedFiles.join(', ')}` : undefined,
12609
- partiallyCoveredFiles.length
12610
- ? `files with only some hunks assigned: ${partiallyCoveredFiles.join(', ')}`
12611
- : undefined,
12612
- missing.length ? `missing files: ${missing.join(', ')}` : undefined,
12613
- ]
12614
- .filter(Boolean)
12615
- .join('; '));
12712
+ const issues = getPlanValidationIssues(plan, staged, hunkInventory);
12713
+ if (hasPlanValidationIssues(issues)) {
12714
+ throw new Error(formatPlanValidationIssuesError(issues));
12616
12715
  }
12617
12716
  }
12618
12717
  function assertNoUnstagedOverlap(plan, changes, hunkInventory) {
@@ -12716,22 +12815,26 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, })
12716
12815
  .map((change) => `- ${change.filePath}: ${change.status} - ${change.summary}`)
12717
12816
  .join('\n');
12718
12817
  const hunkInventoryText = formatHunkInventory(hunkInventory);
12719
- const plan = await executeChainWithSchema(CommitSplitPlanSchema, llm, COMMIT_SPLIT_PROMPT, {
12720
- file_inventory: fileInventory,
12721
- hunk_inventory: hunkInventoryText,
12722
- summary,
12723
- additional_context: argv.additional || '',
12724
- }, {
12818
+ const { plan } = await generateValidatedCommitSplitPlan({
12819
+ llm,
12820
+ prompt: COMMIT_SPLIT_PROMPT,
12821
+ variables: {
12822
+ file_inventory: fileInventory,
12823
+ hunk_inventory: hunkInventoryText,
12824
+ summary,
12825
+ additional_context: argv.additional || '',
12826
+ },
12827
+ staged: changes.staged,
12828
+ hunkInventory,
12725
12829
  logger,
12726
12830
  tokenizer,
12727
12831
  metadata: {
12728
- task: 'commit-split-plan',
12729
12832
  command: 'commit',
12730
12833
  provider: config.service.provider,
12731
12834
  model: String(config.service.model),
12732
12835
  },
12836
+ maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
12733
12837
  });
12734
- validatePlanForStagedFiles(plan, changes.staged, hunkInventory);
12735
12838
  if (argv.apply) {
12736
12839
  return await applyCommitSplitPlan({
12737
12840
  plan,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",