gitxplain 0.1.0 → 0.1.3

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/cli/index.js CHANGED
@@ -5,15 +5,43 @@ import process from "node:process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { realpathSync } from "node:fs";
7
7
  import { generateExplanation } from "./services/aiService.js";
8
+ import { startChatSession } from "./services/chatService.js";
9
+ import { loadEnvFile } from "./services/envLoader.js";
10
+ import {
11
+ saveGitConnection,
12
+ isGitConnected,
13
+ loadGitConnection,
14
+ getGitUserInfo,
15
+ verifyGitToken
16
+ } from "./services/gitConnectionService.js";
8
17
  import { copyToClipboard } from "./services/clipboardService.js";
9
18
  import { loadConfig } from "./services/configService.js";
10
19
  import {
11
20
  buildBranchRange,
21
+ deletePaths,
12
22
  fetchCommitData,
23
+ fetchWorkingTreeData,
24
+ gitAddFiles,
25
+ gitPush,
26
+ gitStashPop,
27
+ gitRestoreStaged,
28
+ getRepositoryLog,
29
+ getRepositoryStatus,
13
30
  getDefaultBaseRef,
14
- isGitRepository
31
+ isGitRepository,
32
+ resolveStashRef
15
33
  } from "./services/gitService.js";
16
34
  import { installHook } from "./services/hookService.js";
35
+ import {
36
+ buildReleaseMergePlan,
37
+ buildReleaseTagPlan,
38
+ executeReleaseMerge,
39
+ executeReleaseTagPlan,
40
+ finalizeReleaseMergePlan,
41
+ finalizeReleaseTagPlan,
42
+ formatReleaseMergePlan,
43
+ formatReleaseTagPlan
44
+ } from "./services/mergeService.js";
17
45
  import {
18
46
  formatFooter,
19
47
  formatHtmlOutput,
@@ -22,6 +50,14 @@ import {
22
50
  formatOutput,
23
51
  formatPreamble
24
52
  } from "./services/outputFormatter.js";
53
+ import { executeCommitPlan, formatCommitPlan, parseCommitPlan, reconcileCommitPlan } from "./services/commitService.js";
54
+ import {
55
+ executeSplit,
56
+ formatSplitPlan,
57
+ parseSplitPlan,
58
+ reconcileSplitPlan,
59
+ validateSplitExecutionTarget
60
+ } from "./services/splitService.js";
25
61
 
26
62
  const MODE_FLAGS = new Map([
27
63
  ["--summary", "summary"],
@@ -31,7 +67,13 @@ const MODE_FLAGS = new Map([
31
67
  ["--full", "full"],
32
68
  ["--lines", "lines"],
33
69
  ["--review", "review"],
34
- ["--security", "security"]
70
+ ["--security", "security"],
71
+ ["--split", "split"],
72
+ ["--merge", "merge"],
73
+ ["--tag", "tag"],
74
+ ["--commit", "commit"],
75
+ ["--log", "log"],
76
+ ["--status", "status"]
35
77
  ]);
36
78
 
37
79
  const FORMAT_FLAGS = new Map([
@@ -41,35 +83,78 @@ const FORMAT_FLAGS = new Map([
41
83
  ]);
42
84
 
43
85
  function printHelp() {
44
- console.log(`gitxplain - AI-powered Git and branch explainer
86
+ console.log(`gitxplain - AI-powered Git change analysis, review, and commit workflow CLI
45
87
 
46
88
  Usage:
47
89
  gitxplain help
48
90
  gitxplain --help
91
+ gitxplain commit
92
+ gitxplain --commit
93
+ gitxplain merge
94
+ gitxplain --merge
95
+ gitxplain tag
96
+ gitxplain --tag
97
+ gitxplain log
98
+ gitxplain --log
99
+ gitxplain status
100
+ gitxplain --status
101
+ gitxplain add <path> [more-paths...]
102
+ gitxplain remove <path> [more-paths...]
103
+ gitxplain del <path> [more-paths...]
104
+ gitxplain pop [stash-index]
105
+ gitxplain push [remote] [branch]
49
106
  gitxplain install-hook [hook-name]
107
+ gitxplain --connect-git [token]
108
+ gitxplain --boot [options]
50
109
  gitxplain <commit-id> [options]
51
110
  gitxplain <start>..<end> [options]
52
111
  gitxplain --branch [base-ref] [options]
53
112
  gitxplain --pr [base-ref] [options]
54
113
 
114
+ What It Does:
115
+ Analyze commits, ranges, branches, and working tree changes
116
+ Generate summaries, reviews, security checks, and line-by-line walkthroughs
117
+ Plan commits for uncommitted work and split oversized commits into atomic steps
118
+ Merge release-version branch changes into a dedicated release branch
119
+ Tag release-version commit windows on the current branch
120
+ Inspect repository history and working tree status without calling the LLM
121
+ Run quick local actions to stage, unstage, delete files, pop stashes, or push
122
+
55
123
  Modes:
56
- --summary Generate a one-line summary
57
- --issues Focus on bug or issue analysis
58
- --fix Explain the fix in simple terms
124
+ --summary Generate a one-line summary of a change
125
+ --issues Focus on the bug, issue, or failure being addressed
126
+ --fix Explain the fix in simple, junior-friendly terms
59
127
  --impact Explain before-vs-after behavior changes
60
128
  --full Generate a full structured analysis
61
- --lines Explain the changed code line by line
62
- --review Generate a code review with risks and suggestions
63
- --security Focus on security risks introduced by the change
129
+ --lines Walk through the changed code file by file
130
+ --review Generate review findings, risks, and suggestions
131
+ --security Focus on security-relevant changes and concerns
132
+ --split Propose splitting a commit into smaller atomic commits
133
+ --merge Preview or apply a merge into the release branch based on version bumps
134
+ --tag Preview or create release tags based on version bumps
135
+ --commit Propose commits for current uncommitted changes
136
+ --log Print Git log entries for the current repository
137
+ --status Print Git working tree status for the current repository
138
+ --execute Execute a proposed split or commit plan
139
+ --dry-run Preview the plan without executing it (default for --split and --commit)
140
+
141
+ Quick Actions:
142
+ add Stage one or more files with git add
143
+ remove Unstage one or more files with git restore --staged
144
+ del Delete one or more files from the working tree
145
+ pop Pop a stash entry with a plain numeric index like "pop 2"
146
+ push Run git push, optionally with a remote and branch
64
147
 
65
148
  Output:
66
- --json Print JSON output
149
+ --json Print structured JSON output
67
150
  --markdown Print Markdown output
68
151
  --html Print HTML output
69
- --quiet Print only the explanation body
152
+ --quiet Print only the main body without extra framing
70
153
  --verbose Print provider, model, cache, latency, and usage details
71
154
  --clipboard Copy the final output to the system clipboard
72
- --stream Stream the explanation as it is generated when supported
155
+ --stream Stream model output as it is generated when supported
156
+ --boot Launch an interactive chat session for dynamic querying, PR creation, and cloning.
157
+ --connect-git Save your GitHub Personal Access Token to act autonomously inside Chat.
73
158
 
74
159
  Providers:
75
160
  --provider LLM provider: openai, groq, openrouter, gemini, ollama, chutes
@@ -79,14 +164,34 @@ Diff Budget:
79
164
  --max-diff-lines <n> Limit diff lines sent to the model
80
165
 
81
166
  Comparison:
82
- --branch [base-ref] Analyze current branch against base branch
83
- --pr [base-ref] Alias for --branch, useful for PR-style summaries
167
+ --branch [base-ref] Analyze the current branch against a base branch
168
+ --pr [base-ref] Alias for --branch, useful for PR-style comparisons
84
169
 
85
170
  Examples:
86
171
  gitxplain HEAD~1 --full
172
+ gitxplain HEAD~1 --review
87
173
  gitxplain HEAD~5..HEAD --markdown
88
174
  gitxplain --branch main --review
89
175
  gitxplain --pr origin/main --security --stream
176
+ gitxplain commit
177
+ gitxplain --commit --execute
178
+ gitxplain merge
179
+ gitxplain --merge --execute
180
+ gitxplain tag
181
+ gitxplain --tag --execute
182
+ gitxplain log
183
+ gitxplain --log
184
+ gitxplain status
185
+ gitxplain --status
186
+ gitxplain add README.md
187
+ gitxplain remove README.md
188
+ gitxplain del scratch.txt
189
+ gitxplain pop
190
+ gitxplain pop 2
191
+ gitxplain push
192
+ gitxplain push origin main
193
+ gitxplain HEAD~1 --split
194
+ gitxplain HEAD --split --execute
90
195
  gitxplain HEAD~1 --provider chutes --model deepseek-ai/DeepSeek-V3-0324
91
196
 
92
197
  Provider Setup:
@@ -124,6 +229,7 @@ Hook Installation:
124
229
 
125
230
  Notes:
126
231
  Run gitxplain inside a Git repository.
232
+ If no mode is supplied, gitxplain will prompt you to choose one interactively.
127
233
  Use --provider or --model to override your config or environment for one command.
128
234
  `);
129
235
  }
@@ -186,13 +292,58 @@ export function parseArgs(argv) {
186
292
  const explicitMode = [...MODE_FLAGS.entries()].find(([flag]) => flags.has(flag))?.[1] ?? null;
187
293
  const explicitFormat = [...FORMAT_FLAGS.entries()].find(([flag]) => flags.has(flag))?.[1] ?? null;
188
294
  const isInstallHook = subcommand === "install-hook";
295
+ const isConnectGit = flags.has("--connect-git");
296
+ const isBoot = flags.has("--boot");
297
+ const isLogCommand = subcommand === "log";
298
+ const isStatusCommand = subcommand === "status";
299
+ const isCommitCommand = subcommand === "commit";
300
+ const isMergeCommand = subcommand === "merge";
301
+ const isTagCommand = subcommand === "tag";
302
+ const isAddCommand = subcommand === "add";
303
+ const isRemoveCommand = subcommand === "remove";
304
+ const isDeleteCommand = subcommand === "del";
305
+ const isPopCommand = subcommand === "pop";
306
+ const isPushCommand = subcommand === "push";
189
307
 
190
308
  return {
191
309
  subcommand,
192
310
  help: flags.has("--help") || subcommand === "help",
193
311
  installHook: isInstallHook,
312
+ connectGit: isConnectGit,
313
+ boot: isBoot,
314
+ logCommand: isLogCommand,
315
+ statusCommand: isStatusCommand,
316
+ commitCommand: isCommitCommand,
317
+ mergeCommand: isMergeCommand,
318
+ tagCommand: isTagCommand,
319
+ addCommand: isAddCommand,
320
+ removeCommand: isRemoveCommand,
321
+ deleteCommand: isDeleteCommand,
322
+ popCommand: isPopCommand,
323
+ pushCommand: isPushCommand,
194
324
  hookName: isInstallHook ? positional[1] ?? "post-commit" : null,
195
- commitRef: isInstallHook || subcommand === "help" ? null : positional[0] ?? null,
325
+ actionPaths: isAddCommand || isRemoveCommand || isDeleteCommand ? positional.slice(1) : [],
326
+ connectToken: isConnectGit ? positional[0] : null,
327
+ stashIndex: isPopCommand ? positional[1] ?? null : null,
328
+ pushRemote: isPushCommand ? positional[1] ?? null : null,
329
+ pushBranch: isPushCommand ? positional[2] ?? null : null,
330
+ commitRef:
331
+ isInstallHook ||
332
+ isConnectGit ||
333
+ isBoot ||
334
+ isLogCommand ||
335
+ isStatusCommand ||
336
+ isCommitCommand ||
337
+ isMergeCommand ||
338
+ isTagCommand ||
339
+ isAddCommand ||
340
+ isRemoveCommand ||
341
+ isDeleteCommand ||
342
+ isPopCommand ||
343
+ isPushCommand ||
344
+ subcommand === "help"
345
+ ? null
346
+ : positional[0] ?? null,
196
347
  mode: explicitMode,
197
348
  format: explicitFormat,
198
349
  provider: getFlagValue(args, "--provider"),
@@ -205,7 +356,13 @@ export function parseArgs(argv) {
205
356
  clipboard: flags.has("--clipboard"),
206
357
  stream: flags.has("--stream"),
207
358
  verbose: flags.has("--verbose"),
208
- quiet: flags.has("--quiet")
359
+ quiet: flags.has("--quiet"),
360
+ execute: flags.has("--execute"),
361
+ dryRun: flags.has("--dry-run"),
362
+ log: flags.has("--log"),
363
+ status: flags.has("--status"),
364
+ merge: flags.has("--merge"),
365
+ tag: flags.has("--tag")
209
366
  };
210
367
  }
211
368
 
@@ -233,6 +390,11 @@ async function chooseModeInteractively() {
233
390
  "6. Line-by-Line Code Walkthrough",
234
391
  "7. Code Review",
235
392
  "8. Security Review",
393
+ "9. Split Commit",
394
+ "10. Merge To Release Branch",
395
+ "11. Tag Release Commits",
396
+ "12. Repository Log",
397
+ "13. Commit Working Tree",
236
398
  "> "
237
399
  ].join("\n")
238
400
  );
@@ -245,7 +407,12 @@ async function chooseModeInteractively() {
245
407
  "5": "full",
246
408
  "6": "lines",
247
409
  "7": "review",
248
- "8": "security"
410
+ "8": "security",
411
+ "9": "split",
412
+ "10": "merge",
413
+ "11": "tag",
414
+ "12": "log",
415
+ "13": "commit"
249
416
  };
250
417
 
251
418
  return selections[answer] ?? "full";
@@ -305,8 +472,11 @@ export async function main(argv = process.argv) {
305
472
  const cwd = process.cwd();
306
473
  const config = loadConfig(cwd);
307
474
  const parsed = parseArgs(argv);
475
+ const hasNoCommandOrFlags = argv.slice(2).length === 0;
308
476
 
309
- if (parsed.help) {
477
+ loadEnvFile(cwd); // Ensure environment is loaded first
478
+
479
+ if (parsed.help || hasNoCommandOrFlags) {
310
480
  printHelp();
311
481
  return 0;
312
482
  }
@@ -322,7 +492,216 @@ export async function main(argv = process.argv) {
322
492
  return 0;
323
493
  }
324
494
 
495
+ if (parsed.connectGit) {
496
+ let token = parsed.connectToken;
497
+ if (!token) {
498
+ if (process.env.GITHUB_TOKEN) {
499
+ token = process.env.GITHUB_TOKEN;
500
+ } else {
501
+ console.error("Please provide your GitHub Personal Access Token.\nRun: gitxplain --connect-git <YOUR_TOKEN>\nOr set it in your .env as GITHUB_TOKEN=...");
502
+ return 1;
503
+ }
504
+ }
505
+ try {
506
+ console.log("Verifying token with GitHub API...");
507
+ const userInfo = await verifyGitToken(token);
508
+ await saveGitConnection(token, "github", userInfo);
509
+ console.log(`\nSuccessfully connected to GitHub as: \x1b[36m${userInfo.login}\x1b[0m`);
510
+ console.log(`Token saved securely to your local configuration.\n`);
511
+ } catch (e) {
512
+ console.error(`Token verification failed: ${e.message}`);
513
+ return 1;
514
+ }
515
+ return 0;
516
+ }
517
+
518
+ if (parsed.boot) {
519
+ if (!isGitConnected()) {
520
+ console.error("You must connect a GitHub account first to use the interactive agent.\nCommand: gitxplain --connect-git <YOUR_TOKEN>");
521
+ return 1;
522
+ }
523
+ const connection = loadGitConnection();
524
+ const userInfo = getGitUserInfo();
525
+ try {
526
+ const { getProviderConfig, validateProviderConfig } = await import(
527
+ "./services/aiService.js"
528
+ );
529
+ const config = getProviderConfig(parsed.provider, parsed.model);
530
+ validateProviderConfig(config);
531
+ await startChatSession(connection.token, parsed.provider, parsed.model, userInfo.name || connection.user?.login);
532
+ } catch (configError) {
533
+ console.error(`Missing LLM Key. Please check your .env variables or --provider flags.\n${configError.message}`);
534
+ return 1;
535
+ }
536
+ return 0;
537
+ }
538
+
539
+ if (parsed.logCommand || parsed.log) {
540
+ console.log(getRepositoryLog(cwd));
541
+ return 0;
542
+ }
543
+
544
+ if (parsed.statusCommand || parsed.status) {
545
+ console.log(getRepositoryStatus(cwd));
546
+ return 0;
547
+ }
548
+
549
+ if (parsed.addCommand || parsed.removeCommand || parsed.deleteCommand || parsed.popCommand || parsed.pushCommand) {
550
+ if (!parsed.popCommand && parsed.actionPaths.length === 0) {
551
+ if (!parsed.pushCommand) {
552
+ throw new Error(`No paths provided for "${parsed.subcommand}".`);
553
+ }
554
+ }
555
+
556
+ if (parsed.addCommand) {
557
+ gitAddFiles(parsed.actionPaths, cwd);
558
+ console.log(`Staged ${parsed.actionPaths.join(", ")}.`);
559
+ return 0;
560
+ }
561
+
562
+ if (parsed.removeCommand) {
563
+ gitRestoreStaged(parsed.actionPaths, cwd);
564
+ console.log(`Unstaged ${parsed.actionPaths.join(", ")}.`);
565
+ return 0;
566
+ }
567
+
568
+ if (parsed.deleteCommand) {
569
+ deletePaths(parsed.actionPaths, cwd);
570
+ console.log(`Deleted ${parsed.actionPaths.join(", ")}.`);
571
+ return 0;
572
+ }
573
+
574
+ if (parsed.popCommand) {
575
+ const stashRef = resolveStashRef(parsed.stashIndex);
576
+ gitStashPop(parsed.stashIndex, cwd);
577
+ console.log(`Popped ${stashRef}.`);
578
+ return 0;
579
+ }
580
+
581
+ gitPush(cwd, parsed.pushRemote, parsed.pushBranch);
582
+ console.log(
583
+ `Pushed${parsed.pushRemote ? ` to ${parsed.pushRemote}` : ""}${parsed.pushBranch ? ` ${parsed.pushBranch}` : ""}.`
584
+ );
585
+ return 0;
586
+ }
587
+
325
588
  const runtimeOptions = resolveRuntimeOptions(parsed, config);
589
+ const mode = parsed.mode ?? config.mode ?? (await chooseModeInteractively());
590
+
591
+ if (mode === "commit" || parsed.commitCommand) {
592
+ const commitData = fetchWorkingTreeData(cwd);
593
+
594
+ if (commitData.filesChanged.length === 0 || commitData.diff === "") {
595
+ console.log("Working tree is clean. Nothing to commit.");
596
+ return 0;
597
+ }
598
+
599
+ const { explanation, responseMeta, promptMeta } = await generateExplanation({
600
+ mode: "commit",
601
+ commitData,
602
+ providerOverride: runtimeOptions.provider,
603
+ modelOverride: runtimeOptions.model,
604
+ maxDiffLines: runtimeOptions.maxDiffLines,
605
+ stream: false,
606
+ onChunk: null,
607
+ onStart: null
608
+ });
609
+
610
+ const plan = reconcileCommitPlan(parseCommitPlan(explanation), cwd);
611
+
612
+ if (!plan.reason_to_commit || plan.commits.length === 0) {
613
+ console.log("No meaningful commit grouping recommended.");
614
+ return 0;
615
+ }
616
+
617
+ console.log(formatCommitPlan(plan));
618
+
619
+ if (parsed.execute && !parsed.dryRun) {
620
+ const confirmed = await askQuestion(
621
+ "\nThis will create new commits from your working tree changes. Continue? (yes/no) > "
622
+ );
623
+ if (confirmed.toLowerCase() !== "yes") {
624
+ console.log("Aborted.");
625
+ return 0;
626
+ }
627
+
628
+ executeCommitPlan(plan, cwd);
629
+ console.log(`\nCommit complete. Created ${plan.commits.length} commits.`);
630
+ } else {
631
+ console.log("\nThis is a preview. Run with --execute to apply the commit plan.");
632
+ }
633
+
634
+ if (runtimeOptions.verbose) {
635
+ process.stdout.write(formatFooter({ responseMeta, promptMeta, options: runtimeOptions }));
636
+ }
637
+
638
+ return 0;
639
+ }
640
+
641
+ if (mode === "merge" || parsed.mergeCommand || parsed.merge) {
642
+ if (parsed.commitRef) {
643
+ throw new Error("--merge works from the current branch and does not accept a commit ref.");
644
+ }
645
+
646
+ const plan = finalizeReleaseMergePlan(buildReleaseMergePlan(cwd));
647
+
648
+ if (plan.windows.length === 0) {
649
+ console.log("No unreleased release commits detected. Nothing to merge.");
650
+ return 0;
651
+ }
652
+
653
+ console.log(formatReleaseMergePlan(plan));
654
+
655
+ if (parsed.execute && !parsed.dryRun) {
656
+ const confirmed = await askQuestion(
657
+ `\nThis will create ${plan.windows.length} release commit(s) on ${plan.releaseBranch}. Continue? (yes/no) > `
658
+ );
659
+ if (confirmed.toLowerCase() !== "yes") {
660
+ console.log("Aborted.");
661
+ return 0;
662
+ }
663
+
664
+ executeReleaseMerge(plan, cwd);
665
+ console.log(`\nRelease promotion complete. Created ${plan.windows.length} release commit(s) on ${plan.releaseBranch}.`);
666
+ } else {
667
+ console.log(`\nThis is a preview. Run with --execute to create release commits on ${plan.releaseBranch}.`);
668
+ }
669
+
670
+ return 0;
671
+ }
672
+
673
+ if (mode === "tag" || parsed.tagCommand || parsed.tag) {
674
+ if (parsed.commitRef) {
675
+ throw new Error("--tag works from the current branch and does not accept a commit ref.");
676
+ }
677
+
678
+ const plan = finalizeReleaseTagPlan(buildReleaseTagPlan(cwd));
679
+
680
+ if (plan.tags.length === 0) {
681
+ console.log("No unreleased release tags detected. Nothing to tag.");
682
+ return 0;
683
+ }
684
+
685
+ console.log(formatReleaseTagPlan(plan));
686
+
687
+ if (parsed.execute && !parsed.dryRun) {
688
+ const confirmed = await askQuestion(
689
+ `\nThis will create ${plan.tags.length} release tag(s). Continue? (yes/no) > `
690
+ );
691
+ if (confirmed.toLowerCase() !== "yes") {
692
+ console.log("Aborted.");
693
+ return 0;
694
+ }
695
+
696
+ executeReleaseTagPlan(plan, cwd);
697
+ console.log(`\nRelease tagging complete. Created ${plan.tags.length} release tag(s).`);
698
+ } else {
699
+ console.log("\nThis is a preview. Run with --execute to create release tags.");
700
+ }
701
+
702
+ return 0;
703
+ }
704
+
326
705
  const targetRef = resolveTargetRef(parsed, cwd);
327
706
 
328
707
  if (!targetRef) {
@@ -330,8 +709,56 @@ export async function main(argv = process.argv) {
330
709
  return 1;
331
710
  }
332
711
 
333
- const mode = parsed.mode ?? config.mode ?? (await chooseModeInteractively());
334
712
  const commitData = fetchCommitData(targetRef, cwd);
713
+
714
+ if (mode === "split") {
715
+ if (commitData.analysisType !== "commit") {
716
+ throw new Error("--split only supports analyzing a single commit.");
717
+ }
718
+
719
+ const { explanation, responseMeta, promptMeta } = await generateExplanation({
720
+ mode: "split",
721
+ commitData,
722
+ providerOverride: runtimeOptions.provider,
723
+ modelOverride: runtimeOptions.model,
724
+ maxDiffLines: runtimeOptions.maxDiffLines,
725
+ stream: false,
726
+ onChunk: null,
727
+ onStart: null
728
+ });
729
+
730
+ const plan = reconcileSplitPlan(parseSplitPlan(explanation), commitData.filesChanged);
731
+
732
+ if (!plan.reason_to_split || plan.commits.length === 0) {
733
+ console.log("This commit is already atomic. No split recommended.");
734
+ return 0;
735
+ }
736
+
737
+ console.log(formatSplitPlan(plan));
738
+
739
+ if (parsed.execute && !parsed.dryRun) {
740
+ validateSplitExecutionTarget(commitData.commitId, cwd);
741
+ const confirmed = await askQuestion(
742
+ "\nThis will rewrite git history. Continue? (yes/no) > "
743
+ );
744
+ if (confirmed.toLowerCase() !== "yes") {
745
+ console.log("Aborted.");
746
+ return 0;
747
+ }
748
+
749
+ executeSplit(plan, commitData.commitId, cwd);
750
+ console.log(`\nSplit complete. Created ${plan.commits.length} commits.`);
751
+ } else {
752
+ console.log("\nThis is a preview. Run with --execute to apply the split.");
753
+ }
754
+
755
+ if (runtimeOptions.verbose) {
756
+ process.stdout.write(formatFooter({ responseMeta, promptMeta, options: runtimeOptions }));
757
+ }
758
+
759
+ return 0;
760
+ }
761
+
335
762
  const canStream = runtimeOptions.stream && runtimeOptions.format === "plain";
336
763
  let streamStarted = false;
337
764
 
@@ -12,7 +12,7 @@ const SUPPORTED_PROVIDERS = new Set([
12
12
  ]);
13
13
  const SYSTEM_PROMPT = "You explain Git commits clearly and accurately for developers.";
14
14
 
15
- function getProviderConfig(providerOverride, modelOverride) {
15
+ export function getProviderConfig(providerOverride, modelOverride) {
16
16
  const provider = (providerOverride ?? process.env.LLM_PROVIDER ?? "openai").toLowerCase();
17
17
 
18
18
  if (!SUPPORTED_PROVIDERS.has(provider)) {
@@ -79,7 +79,7 @@ function getProviderConfig(providerOverride, modelOverride) {
79
79
  };
80
80
  }
81
81
 
82
- function validateProviderConfig(config) {
82
+ export function validateProviderConfig(config) {
83
83
  if (!config.model) {
84
84
  throw new Error(`No model configured for provider "${config.provider}".`);
85
85
  }