gitxplain 0.1.8 → 0.2.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/cli/index.js CHANGED
@@ -3,10 +3,12 @@
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
5
  import { fileURLToPath } from "node:url";
6
- import { realpathSync } from "node:fs";
6
+ import { readFileSync, realpathSync } from "node:fs";
7
7
  import { generateExplanation } from "./services/aiService.js";
8
+ import { clearCache, getCacheStats } from "./services/cacheService.js";
8
9
  import { loadEnvFile } from "./services/envLoader.js";
9
10
  import { copyToClipboard } from "./services/clipboardService.js";
11
+ import { getUsageStats } from "./services/usageService.js";
10
12
  import {
11
13
  applyConfigEnvironment,
12
14
  getProviderApiKeyField,
@@ -18,7 +20,11 @@ import {
18
20
  import {
19
21
  buildBranchRange,
20
22
  deletePaths,
23
+ fetchBlameData,
21
24
  fetchCommitData,
25
+ fetchCommitDataForFile,
26
+ fetchConflictData,
27
+ fetchStashData,
22
28
  fetchWorkingTreeData,
23
29
  gitAddFiles,
24
30
  gitPull,
@@ -80,6 +86,13 @@ const MODE_FLAGS = new Map([
80
86
  ["--lines", "lines"],
81
87
  ["--review", "review"],
82
88
  ["--security", "security"],
89
+ ["--refactor", "refactor"],
90
+ ["--test-suggest", "test-suggest"],
91
+ ["--pr-description", "pr-description"],
92
+ ["--changelog", "changelog"],
93
+ ["--blame", "blame"],
94
+ ["--conflict", "conflict"],
95
+ ["--stash", "stash"],
83
96
  ["--split", "split"],
84
97
  ["--merge", "merge"],
85
98
  ["--tag", "tag"],
@@ -96,8 +109,28 @@ const FORMAT_FLAGS = new Map([
96
109
  ["--html", "html"]
97
110
  ]);
98
111
 
112
+ const ANALYSIS_MODES = new Set([
113
+ "summary",
114
+ "issues",
115
+ "fix",
116
+ "impact",
117
+ "full",
118
+ "lines",
119
+ "review",
120
+ "security",
121
+ "refactor",
122
+ "test-suggest",
123
+ "pr-description",
124
+ "changelog",
125
+ "blame",
126
+ "conflict",
127
+ "stash",
128
+ "split"
129
+ ]);
130
+
99
131
  const RESERVED_SUBCOMMANDS = new Set([
100
132
  "help",
133
+ "cache",
101
134
  "config",
102
135
  "install-hook",
103
136
  "git",
@@ -110,11 +143,18 @@ const RESERVED_SUBCOMMANDS = new Set([
110
143
  "push"
111
144
  ]);
112
145
 
146
+ const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
147
+
113
148
  function printHelp() {
114
149
  console.log(`gitxplain - AI-powered Git change analysis, review, and commit workflow CLI
115
150
 
116
151
  Usage:
117
152
  gitxplain --help
153
+ gitxplain --version
154
+ gitxplain cache clear
155
+ gitxplain cache stats
156
+ gitxplain --cost
157
+ gitxplain install-hook [post-commit|post-merge|pre-push]
118
158
  gitxplain config set provider <name>
119
159
  gitxplain config set api-key <value> [--provider <name>]
120
160
  gitxplain config get [key]
@@ -127,6 +167,8 @@ Usage:
127
167
  gitxplain --release [status]
128
168
  gitxplain --merge
129
169
  gitxplain --tag
170
+ gitxplain --conflict
171
+ gitxplain --stash [stash-ref]
130
172
  gitxplain --log
131
173
  gitxplain --status
132
174
  gitxplain --pipeline
@@ -140,10 +182,19 @@ Analysis:
140
182
  --lines Walk through the changed code file by file
141
183
  --review Generate review findings, risks, and suggestions
142
184
  --security Focus on security-relevant changes and concerns
185
+ --refactor Suggest refactoring opportunities in the change
186
+ --test-suggest Suggest tests to add or update for the change
187
+ --pr-description Generate a ready-to-paste PR description
188
+ --changelog Generate changelog-style release notes
189
+ --blame <file> Analyze ownership and history for one file with git blame
190
+ --conflict Suggest resolutions for unresolved merge conflicts in the working tree
191
+ --stash [ref] Explain a stash entry, defaulting to stash@{0}
143
192
  --split Propose splitting a commit into smaller atomic commits
193
+ --cost Show cumulative token usage and estimated cost totals
144
194
  --commit Propose commits for current uncommitted changes
145
195
  --execute Execute a proposed split or commit plan
146
196
  --dry-run Preview the plan without executing it
197
+ --interactive Review or edit a split plan before execution
147
198
 
148
199
  Release:
149
200
  --release [status] Show release branch health and next recommended action
@@ -153,7 +204,7 @@ Release:
153
204
  Repo:
154
205
  --log Print Git log entries for the current repository
155
206
  --status Print Git working tree status for the current repository
156
- --pipeline Detect the current repository stack and create CI/CD workflow files
207
+ --pipeline Detect the current repository stack and create GitHub/GitLab/CircleCI/Bitbucket CI files
157
208
 
158
209
  Quick Actions:
159
210
  config Persist provider, model, and API key settings
@@ -165,7 +216,8 @@ Quick Actions:
165
216
  pop Pop a stash entry like "pop 2"
166
217
  pull Run git pull, optionally with a remote and branch
167
218
  push Run git push, optionally with a remote and branch
168
- install-hook Install the gitxplain hook
219
+ install-hook Install a post-commit, post-merge, or pre-push gitxplain hook
220
+ cache Manage gitxplain cache entries
169
221
  git Pass through to native git commands
170
222
 
171
223
  Output:
@@ -178,6 +230,8 @@ Output:
178
230
  --verbose
179
231
  --clipboard
180
232
  --stream
233
+ --no-cache
234
+ --diff <file>
181
235
  --max-diff-lines <n>
182
236
 
183
237
  Comparison:
@@ -193,6 +247,7 @@ Notes:
193
247
  If no command or mode is supplied, gitxplain prints this help text.
194
248
  Use --provider or --model to override your config or environment for one command.
195
249
  Use gitxplain git <args...> to run any native Git subcommand with its normal flags.
250
+ install-hook supports: post-commit, post-merge, pre-push.
196
251
  `);
197
252
  }
198
253
 
@@ -322,6 +377,34 @@ function handleConfigCommand(parsed) {
322
377
  throw new Error(`Unknown config subcommand: ${parsed.configAction}`);
323
378
  }
324
379
 
380
+ function handleCacheCommand(parsed) {
381
+ if (parsed.cacheAction == null) {
382
+ throw new Error('Usage: gitxplain cache <clear|stats>');
383
+ }
384
+
385
+ if (parsed.cacheAction === "clear") {
386
+ const deletedCount = clearCache();
387
+ console.log(`Cleared ${deletedCount} cache entr${deletedCount === 1 ? "y" : "ies"}.`);
388
+ return 0;
389
+ }
390
+
391
+ if (parsed.cacheAction === "stats") {
392
+ const stats = getCacheStats();
393
+ console.log(
394
+ [
395
+ "Cache Stats",
396
+ `Entries: ${stats.entryCount}`,
397
+ `Size: ${stats.totalSizeBytes} bytes`,
398
+ `Oldest: ${stats.oldestEntryIso ?? "n/a"}`,
399
+ `Newest: ${stats.newestEntryIso ?? "n/a"}`
400
+ ].join("\n")
401
+ );
402
+ return 0;
403
+ }
404
+
405
+ throw new Error(`Unknown cache subcommand: ${parsed.cacheAction}`);
406
+ }
407
+
325
408
  function isDirectNativeGitSubcommand(subcommand, knownGitSubcommands) {
326
409
  if (!subcommand || subcommand.startsWith("-")) {
327
410
  return false;
@@ -339,7 +422,7 @@ export function parseArgs(argv, options = {}) {
339
422
  const subcommand = args[0];
340
423
  const knownGitSubcommands = options.gitSubcommands ?? listGitSubcommands();
341
424
  const flags = new Set(args.filter((arg) => arg.startsWith("--")));
342
- const valueFlags = new Set(["--provider", "--model", "--max-diff-lines", "--branch", "--pr"]);
425
+ const valueFlags = new Set(["--provider", "--model", "--max-diff-lines", "--branch", "--pr", "--blame", "--stash", "--diff"]);
343
426
  const positional = [];
344
427
 
345
428
  for (let index = 0; index < args.length; index += 1) {
@@ -366,6 +449,7 @@ export function parseArgs(argv, options = {}) {
366
449
  const explicitFormat = [...FORMAT_FLAGS.entries()].find(([flag]) => flags.has(flag))?.[1] ?? null;
367
450
  const isInstallHook = subcommand === "install-hook";
368
451
  const isConfigCommand = subcommand === "config";
452
+ const isCacheCommand = subcommand === "cache";
369
453
  const isNativeGitWrapper = subcommand === "git";
370
454
  const isReleaseCommand = flags.has("--release");
371
455
  const isAddCommand = subcommand === "add";
@@ -382,17 +466,16 @@ export function parseArgs(argv, options = {}) {
382
466
  return {
383
467
  subcommand,
384
468
  help: flags.has("--help") || subcommand === "help",
469
+ version: flags.has("--version"),
470
+ cost: flags.has("--cost"),
385
471
  nativeGitCommand: isNativeGitCommand,
386
472
  installHook: isInstallHook,
387
473
  configCommand: isConfigCommand,
474
+ cacheCommand: isCacheCommand,
388
475
  configAction: isConfigCommand ? positional[1] ?? null : null,
389
476
  configKey: isConfigCommand ? positional[2] ?? null : null,
390
477
  configValue: isConfigCommand ? positional.slice(3).join(" ") || null : null,
391
- logCommand: false,
392
- statusCommand: false,
393
- commitCommand: false,
394
- mergeCommand: false,
395
- tagCommand: false,
478
+ cacheAction: isCacheCommand ? positional[1] ?? null : null,
396
479
  releaseCommand: isReleaseCommand,
397
480
  releaseAction: isReleaseCommand ? positional[0] ?? "status" : null,
398
481
  addCommand: isAddCommand,
@@ -416,6 +499,7 @@ export function parseArgs(argv, options = {}) {
416
499
  commitRef:
417
500
  isInstallHook ||
418
501
  isConfigCommand ||
502
+ isCacheCommand ||
419
503
  isNativeGitCommand ||
420
504
  isReleaseCommand ||
421
505
  isAddCommand ||
@@ -434,16 +518,21 @@ export function parseArgs(argv, options = {}) {
434
518
  provider: getFlagValue(args, "--provider"),
435
519
  model: getFlagValue(args, "--model"),
436
520
  maxDiffLines: parseNumber(getFlagValue(args, "--max-diff-lines")),
521
+ blameFile: getFlagValue(args, "--blame"),
522
+ stashRef: flags.has("--stash") || args.some((arg) => arg.startsWith("--stash=")) ? getFlagValue(args, "--stash") : null,
523
+ diffFile: getFlagValue(args, "--diff"),
437
524
  hasBranchFlag: flags.has("--branch") || args.some((arg) => arg.startsWith("--branch=")),
438
525
  branchBase: getFlagValue(args, "--branch"),
439
526
  hasPrFlag: flags.has("--pr") || args.some((arg) => arg.startsWith("--pr=")),
440
527
  prBase: getFlagValue(args, "--pr"),
441
528
  clipboard: flags.has("--clipboard"),
442
529
  stream: flags.has("--stream"),
530
+ noCache: flags.has("--no-cache"),
443
531
  verbose: flags.has("--verbose"),
444
532
  quiet: flags.has("--quiet"),
445
533
  execute: flags.has("--execute"),
446
534
  dryRun: flags.has("--dry-run"),
535
+ interactive: flags.has("--interactive"),
447
536
  release: flags.has("--release"),
448
537
  log: flags.has("--log"),
449
538
  status: flags.has("--status"),
@@ -464,62 +553,86 @@ function askQuestion(prompt) {
464
553
  });
465
554
  }
466
555
 
467
- async function chooseModeInteractively() {
468
- const answer = await askQuestion(
469
- [
470
- "What do you want to know?",
471
- "1. Summary",
472
- "2. Issues Fixed",
473
- "3. Fix Explanation",
474
- "4. Impact",
475
- "5. Full Analysis",
476
- "6. Line-by-Line Code Walkthrough",
477
- "7. Code Review",
478
- "8. Security Review",
479
- "9. Split Commit",
480
- "10. Merge To Release Branch",
481
- "11. Tag Release Commits",
482
- "12. Repository Log",
483
- "13. Commit Working Tree",
484
- "14. Create CI/CD Pipelines",
485
- "> "
486
- ].join("\n")
487
- );
488
-
489
- const selections = {
490
- "1": "summary",
491
- "2": "issues",
492
- "3": "fix",
493
- "4": "impact",
494
- "5": "full",
495
- "6": "lines",
496
- "7": "review",
497
- "8": "security",
498
- "9": "split",
499
- "10": "merge",
500
- "11": "tag",
501
- "12": "log",
502
- "13": "commit",
503
- "14": "pipeline"
504
- };
505
-
506
- return selections[answer] ?? "full";
556
+ function resolveConfiguredAnalysisMode(config) {
557
+ return ANALYSIS_MODES.has(config.mode) ? config.mode : "full";
507
558
  }
508
559
 
509
560
  function resolveRuntimeOptions(parsed, config) {
510
561
  return {
511
- mode: parsed.mode ?? config.mode ?? "full",
562
+ mode: parsed.mode ?? resolveConfiguredAnalysisMode(config),
512
563
  format: parsed.format ?? config.format ?? "plain",
513
564
  provider: parsed.provider ?? config.provider ?? null,
514
565
  model: parsed.model ?? config.model ?? null,
515
566
  maxDiffLines: parsed.maxDiffLines ?? config.maxDiffLines ?? 800,
516
567
  clipboard: parsed.clipboard || config.clipboard === true,
517
568
  stream: parsed.stream || config.stream === true,
569
+ noCache: parsed.noCache,
518
570
  verbose: parsed.verbose || config.verbose === true,
519
571
  quiet: parsed.quiet || config.quiet === true
520
572
  };
521
573
  }
522
574
 
575
+ function formatUsageStats(stats) {
576
+ return [
577
+ "Usage Stats",
578
+ `Requests: ${stats.requestCount}`,
579
+ `Input Tokens: ${stats.inputTokens}`,
580
+ `Output Tokens: ${stats.outputTokens}`,
581
+ `Total Tokens: ${stats.totalTokens}`,
582
+ `Estimated Cost: $${stats.estimatedCostUsd.toFixed(6)}`
583
+ ].join("\n");
584
+ }
585
+
586
+ async function reviewSplitPlanInteractively(plan) {
587
+ const editedCommits = [];
588
+ const deferredFiles = [];
589
+
590
+ for (const commit of [...plan.commits].sort((left, right) => left.order - right.order)) {
591
+ console.log("");
592
+ console.log(`${commit.order}. ${commit.message}`);
593
+ console.log(`Files: ${commit.files.join(", ")}`);
594
+ console.log(`Why: ${commit.description}`);
595
+
596
+ const action = (await askQuestion('Action [keep/edit/skip/abort] > ')).trim().toLowerCase();
597
+
598
+ if (action === "abort") {
599
+ return null;
600
+ }
601
+
602
+ if (action === "skip") {
603
+ deferredFiles.push(...commit.files);
604
+ continue;
605
+ }
606
+
607
+ if (action === "edit") {
608
+ const nextMessage = await askQuestion("New commit message (leave blank to keep current) > ");
609
+ const nextDescription = await askQuestion("New description (leave blank to keep current) > ");
610
+ editedCommits.push({
611
+ ...commit,
612
+ message: nextMessage.trim() === "" ? commit.message : nextMessage.trim(),
613
+ description: nextDescription.trim() === "" ? commit.description : nextDescription.trim()
614
+ });
615
+ continue;
616
+ }
617
+
618
+ editedCommits.push(commit);
619
+ }
620
+
621
+ if (deferredFiles.length > 0) {
622
+ editedCommits.push({
623
+ order: editedCommits.length + 1,
624
+ message: "chore: include deferred split changes",
625
+ files: deferredFiles,
626
+ description: "Captures split groups that were skipped during interactive review."
627
+ });
628
+ }
629
+
630
+ return {
631
+ ...plan,
632
+ commits: editedCommits.map((commit, index) => ({ ...commit, order: index + 1 }))
633
+ };
634
+ }
635
+
523
636
  function resolveTargetRef(parsed, cwd) {
524
637
  if (parsed.commitRef) {
525
638
  return parsed.commitRef;
@@ -556,6 +669,44 @@ function renderFinalOutput({ runtimeOptions, mode, commitData, explanation, resp
556
669
  });
557
670
  }
558
671
 
672
+ async function runPipelineCommand(cwd) {
673
+ const analysis = inspectRepositoryForPipeline(cwd);
674
+
675
+ if (!analysis.supported) {
676
+ console.log(analysis.reason);
677
+ return 1;
678
+ }
679
+
680
+ console.log(formatPipelineRecommendations(analysis));
681
+
682
+ const answer = await askQuestion(
683
+ `\nChoose a pipeline option (1-${analysis.options.length}) or type "cancel" > `
684
+ );
685
+ const selection = resolvePipelineSelection(analysis, answer);
686
+
687
+ if (!selection) {
688
+ console.log("Aborted.");
689
+ return 0;
690
+ }
691
+
692
+ const { writtenFiles, updatedFiles, unchangedFiles, notes } = writePipelineFiles(cwd, analysis, selection);
693
+
694
+ if (updatedFiles.length === 0 && unchangedFiles.length > 0) {
695
+ console.log(`\nWorkflow files already matched the current template: ${unchangedFiles.join(", ")}`);
696
+ } else if (updatedFiles.length > 0 && unchangedFiles.length === 0) {
697
+ console.log(`\nUpdated workflow files: ${updatedFiles.join(", ")}`);
698
+ } else {
699
+ console.log(`\nUpdated workflow files: ${updatedFiles.join(", ")}`);
700
+ console.log(`Unchanged workflow files: ${unchangedFiles.join(", ")}`);
701
+ }
702
+
703
+ if (notes.length > 0) {
704
+ console.log(`\n${notes.join("\n")}`);
705
+ }
706
+
707
+ return 0;
708
+ }
709
+
559
710
  export async function main(argv = process.argv) {
560
711
  const cwd = process.cwd();
561
712
  const parsed = parseArgs(argv);
@@ -565,6 +716,16 @@ export async function main(argv = process.argv) {
565
716
  const config = loadConfig(cwd);
566
717
  applyConfigEnvironment(config);
567
718
 
719
+ if (parsed.version) {
720
+ console.log(CLI_VERSION);
721
+ return 0;
722
+ }
723
+
724
+ if (parsed.cost) {
725
+ console.log(formatUsageStats(getUsageStats()));
726
+ return 0;
727
+ }
728
+
568
729
  if (parsed.help || hasNoCommandOrFlags) {
569
730
  printHelp();
570
731
  return 0;
@@ -574,6 +735,10 @@ export async function main(argv = process.argv) {
574
735
  return handleConfigCommand(parsed);
575
736
  }
576
737
 
738
+ if (parsed.cacheCommand) {
739
+ return handleCacheCommand(parsed);
740
+ }
741
+
577
742
  if (parsed.nativeGitCommand) {
578
743
  return runNativeGitPassthrough(parsed.nativeGitArgs, cwd);
579
744
  }
@@ -609,33 +774,7 @@ export async function main(argv = process.argv) {
609
774
  }
610
775
 
611
776
  if (parsed.pipelineCommand) {
612
- const analysis = inspectRepositoryForPipeline(cwd);
613
-
614
- if (!analysis.supported) {
615
- console.log(analysis.reason);
616
- return 1;
617
- }
618
-
619
- console.log(formatPipelineRecommendations(analysis));
620
-
621
- const answer = await askQuestion(
622
- `\nChoose a pipeline option (1-${analysis.options.length}) or type "cancel" > `
623
- );
624
- const selection = resolvePipelineSelection(analysis, answer);
625
-
626
- if (!selection) {
627
- console.log("Aborted.");
628
- return 0;
629
- }
630
-
631
- const { writtenFiles, notes } = writePipelineFiles(cwd, analysis, selection);
632
- console.log(`\nCreated workflow files: ${writtenFiles.join(", ")}`);
633
-
634
- if (notes.length > 0) {
635
- console.log(`\n${notes.join("\n")}`);
636
- }
637
-
638
- return 0;
777
+ return runPipelineCommand(cwd);
639
778
  }
640
779
 
641
780
  if (
@@ -706,39 +845,9 @@ export async function main(argv = process.argv) {
706
845
  }
707
846
 
708
847
  const runtimeOptions = resolveRuntimeOptions(parsed, config);
709
- const mode = parsed.mode ?? config.mode ?? (await chooseModeInteractively());
710
-
711
- if (mode === "pipeline") {
712
- const analysis = inspectRepositoryForPipeline(cwd);
713
-
714
- if (!analysis.supported) {
715
- console.log(analysis.reason);
716
- return 1;
717
- }
718
-
719
- console.log(formatPipelineRecommendations(analysis));
720
-
721
- const answer = await askQuestion(
722
- `\nChoose a pipeline option (1-${analysis.options.length}) or type "cancel" > `
723
- );
724
- const selection = resolvePipelineSelection(analysis, answer);
725
-
726
- if (!selection) {
727
- console.log("Aborted.");
728
- return 0;
729
- }
730
-
731
- const { writtenFiles, notes } = writePipelineFiles(cwd, analysis, selection);
732
- console.log(`\nCreated workflow files: ${writtenFiles.join(", ")}`);
733
-
734
- if (notes.length > 0) {
735
- console.log(`\n${notes.join("\n")}`);
736
- }
737
-
738
- return 0;
739
- }
848
+ const mode = ANALYSIS_MODES.has(parsed.mode) ? parsed.mode : resolveConfiguredAnalysisMode(config);
740
849
 
741
- if (mode === "commit") {
850
+ if (parsed.mode === "commit") {
742
851
  const commitData = fetchWorkingTreeData(cwd);
743
852
 
744
853
  if (commitData.filesChanged.length === 0 || commitData.diff === "") {
@@ -752,6 +861,7 @@ export async function main(argv = process.argv) {
752
861
  providerOverride: runtimeOptions.provider,
753
862
  modelOverride: runtimeOptions.model,
754
863
  maxDiffLines: runtimeOptions.maxDiffLines,
864
+ noCache: runtimeOptions.noCache,
755
865
  stream: false,
756
866
  onChunk: null,
757
867
  onStart: null
@@ -788,7 +898,7 @@ export async function main(argv = process.argv) {
788
898
  return 0;
789
899
  }
790
900
 
791
- if (mode === "merge" || parsed.merge) {
901
+ if (parsed.mode === "merge" || parsed.merge) {
792
902
  if (parsed.commitRef) {
793
903
  throw new Error("--merge works from the current branch and does not accept a commit ref.");
794
904
  }
@@ -820,7 +930,7 @@ export async function main(argv = process.argv) {
820
930
  return 0;
821
931
  }
822
932
 
823
- if (mode === "tag" || parsed.tag) {
933
+ if (parsed.mode === "tag" || parsed.tag) {
824
934
  if (parsed.commitRef) {
825
935
  throw new Error("--tag works from the current branch and does not accept a commit ref.");
826
936
  }
@@ -854,12 +964,210 @@ export async function main(argv = process.argv) {
854
964
 
855
965
  const targetRef = resolveTargetRef(parsed, cwd);
856
966
 
967
+ if (parsed.mode === "blame") {
968
+ if (!parsed.blameFile) {
969
+ throw new Error("--blame requires a file path.");
970
+ }
971
+
972
+ const commitData = fetchBlameData(parsed.blameFile, cwd);
973
+ const canStream = runtimeOptions.stream && runtimeOptions.format === "plain";
974
+ let streamStarted = false;
975
+
976
+ if (runtimeOptions.stream && !canStream && !runtimeOptions.quiet) {
977
+ console.error(`Streaming is only supported with plain output. Ignoring --stream for ${runtimeOptions.format} format.`);
978
+ }
979
+
980
+ const { explanation, responseMeta, promptMeta } = await generateExplanation({
981
+ mode: "blame",
982
+ commitData,
983
+ providerOverride: runtimeOptions.provider,
984
+ modelOverride: runtimeOptions.model,
985
+ maxDiffLines: runtimeOptions.maxDiffLines,
986
+ noCache: runtimeOptions.noCache,
987
+ stream: canStream,
988
+ onStart: canStream
989
+ ? ({ promptMeta: streamPromptMeta }) => {
990
+ if (!runtimeOptions.quiet && !streamStarted) {
991
+ process.stdout.write(
992
+ formatPreamble({
993
+ mode: "blame",
994
+ commitData,
995
+ responseMeta: null,
996
+ promptMeta: streamPromptMeta,
997
+ options: runtimeOptions
998
+ })
999
+ );
1000
+ streamStarted = true;
1001
+ }
1002
+ }
1003
+ : null,
1004
+ onChunk: canStream ? (chunk) => process.stdout.write(chunk) : null
1005
+ });
1006
+
1007
+ const renderedOutput = renderFinalOutput({
1008
+ runtimeOptions,
1009
+ mode: "blame",
1010
+ commitData,
1011
+ explanation,
1012
+ responseMeta,
1013
+ promptMeta
1014
+ });
1015
+
1016
+ if (canStream) {
1017
+ process.stdout.write("\n");
1018
+ if (runtimeOptions.verbose) {
1019
+ process.stdout.write(formatFooter({ responseMeta, promptMeta, options: runtimeOptions }));
1020
+ }
1021
+ } else {
1022
+ console.log(renderedOutput);
1023
+ }
1024
+
1025
+ if (runtimeOptions.clipboard) {
1026
+ copyToClipboard(renderedOutput);
1027
+ if (!runtimeOptions.quiet) {
1028
+ console.error("Copied output to clipboard.");
1029
+ }
1030
+ }
1031
+
1032
+ return 0;
1033
+ }
1034
+
1035
+ if (parsed.mode === "conflict") {
1036
+ const commitData = fetchConflictData(cwd, parsed.diffFile);
1037
+ const canStream = runtimeOptions.stream && runtimeOptions.format === "plain";
1038
+ let streamStarted = false;
1039
+
1040
+ if (runtimeOptions.stream && !canStream && !runtimeOptions.quiet) {
1041
+ console.error(`Streaming is only supported with plain output. Ignoring --stream for ${runtimeOptions.format} format.`);
1042
+ }
1043
+
1044
+ const { explanation, responseMeta, promptMeta } = await generateExplanation({
1045
+ mode: "conflict",
1046
+ commitData,
1047
+ providerOverride: runtimeOptions.provider,
1048
+ modelOverride: runtimeOptions.model,
1049
+ maxDiffLines: runtimeOptions.maxDiffLines,
1050
+ noCache: runtimeOptions.noCache,
1051
+ stream: canStream,
1052
+ onStart: canStream
1053
+ ? ({ promptMeta: streamPromptMeta }) => {
1054
+ if (!runtimeOptions.quiet && !streamStarted) {
1055
+ process.stdout.write(
1056
+ formatPreamble({
1057
+ mode: "conflict",
1058
+ commitData,
1059
+ responseMeta: null,
1060
+ promptMeta: streamPromptMeta,
1061
+ options: runtimeOptions
1062
+ })
1063
+ );
1064
+ streamStarted = true;
1065
+ }
1066
+ }
1067
+ : null,
1068
+ onChunk: canStream ? (chunk) => process.stdout.write(chunk) : null
1069
+ });
1070
+
1071
+ const renderedOutput = renderFinalOutput({
1072
+ runtimeOptions,
1073
+ mode: "conflict",
1074
+ commitData,
1075
+ explanation,
1076
+ responseMeta,
1077
+ promptMeta
1078
+ });
1079
+
1080
+ if (canStream) {
1081
+ process.stdout.write("\n");
1082
+ if (runtimeOptions.verbose) {
1083
+ process.stdout.write(formatFooter({ responseMeta, promptMeta, options: runtimeOptions }));
1084
+ }
1085
+ } else {
1086
+ console.log(renderedOutput);
1087
+ }
1088
+
1089
+ if (runtimeOptions.clipboard) {
1090
+ copyToClipboard(renderedOutput);
1091
+ if (!runtimeOptions.quiet) {
1092
+ console.error("Copied output to clipboard.");
1093
+ }
1094
+ }
1095
+
1096
+ return 0;
1097
+ }
1098
+
1099
+ if (parsed.mode === "stash") {
1100
+ const commitData = fetchStashData(parsed.stashRef, cwd, parsed.diffFile);
1101
+ const canStream = runtimeOptions.stream && runtimeOptions.format === "plain";
1102
+ let streamStarted = false;
1103
+
1104
+ if (runtimeOptions.stream && !canStream && !runtimeOptions.quiet) {
1105
+ console.error(`Streaming is only supported with plain output. Ignoring --stream for ${runtimeOptions.format} format.`);
1106
+ }
1107
+
1108
+ const { explanation, responseMeta, promptMeta } = await generateExplanation({
1109
+ mode: "stash",
1110
+ commitData,
1111
+ providerOverride: runtimeOptions.provider,
1112
+ modelOverride: runtimeOptions.model,
1113
+ maxDiffLines: runtimeOptions.maxDiffLines,
1114
+ noCache: runtimeOptions.noCache,
1115
+ stream: canStream,
1116
+ onStart: canStream
1117
+ ? ({ promptMeta: streamPromptMeta }) => {
1118
+ if (!runtimeOptions.quiet && !streamStarted) {
1119
+ process.stdout.write(
1120
+ formatPreamble({
1121
+ mode: "stash",
1122
+ commitData,
1123
+ responseMeta: null,
1124
+ promptMeta: streamPromptMeta,
1125
+ options: runtimeOptions
1126
+ })
1127
+ );
1128
+ streamStarted = true;
1129
+ }
1130
+ }
1131
+ : null,
1132
+ onChunk: canStream ? (chunk) => process.stdout.write(chunk) : null
1133
+ });
1134
+
1135
+ const renderedOutput = renderFinalOutput({
1136
+ runtimeOptions,
1137
+ mode: "stash",
1138
+ commitData,
1139
+ explanation,
1140
+ responseMeta,
1141
+ promptMeta
1142
+ });
1143
+
1144
+ if (canStream) {
1145
+ process.stdout.write("\n");
1146
+ if (runtimeOptions.verbose) {
1147
+ process.stdout.write(formatFooter({ responseMeta, promptMeta, options: runtimeOptions }));
1148
+ }
1149
+ } else {
1150
+ console.log(renderedOutput);
1151
+ }
1152
+
1153
+ if (runtimeOptions.clipboard) {
1154
+ copyToClipboard(renderedOutput);
1155
+ if (!runtimeOptions.quiet) {
1156
+ console.error("Copied output to clipboard.");
1157
+ }
1158
+ }
1159
+
1160
+ return 0;
1161
+ }
1162
+
857
1163
  if (!targetRef) {
858
1164
  printHelp();
859
1165
  return 1;
860
1166
  }
861
1167
 
862
- const commitData = fetchCommitData(targetRef, cwd);
1168
+ const commitData = parsed.diffFile
1169
+ ? fetchCommitDataForFile(targetRef, parsed.diffFile, cwd)
1170
+ : fetchCommitData(targetRef, cwd);
863
1171
 
864
1172
  if (mode === "split") {
865
1173
  if (commitData.analysisType !== "commit") {
@@ -872,6 +1180,7 @@ export async function main(argv = process.argv) {
872
1180
  providerOverride: runtimeOptions.provider,
873
1181
  modelOverride: runtimeOptions.model,
874
1182
  maxDiffLines: runtimeOptions.maxDiffLines,
1183
+ noCache: runtimeOptions.noCache,
875
1184
  stream: false,
876
1185
  onChunk: null,
877
1186
  onStart: null
@@ -887,6 +1196,17 @@ export async function main(argv = process.argv) {
887
1196
  console.log(formatSplitPlan(plan));
888
1197
 
889
1198
  if (parsed.execute && !parsed.dryRun) {
1199
+ const reviewedPlan = parsed.interactive ? await reviewSplitPlanInteractively(plan) : plan;
1200
+ if (reviewedPlan == null) {
1201
+ console.log("Aborted.");
1202
+ return 0;
1203
+ }
1204
+
1205
+ if (parsed.interactive) {
1206
+ console.log("");
1207
+ console.log(formatSplitPlan(reviewedPlan));
1208
+ }
1209
+
890
1210
  validateSplitExecutionTarget(commitData.commitId, cwd);
891
1211
  const confirmed = await askQuestion(
892
1212
  "\nThis will rewrite git history. Continue? (yes/no) > "
@@ -896,8 +1216,8 @@ export async function main(argv = process.argv) {
896
1216
  return 0;
897
1217
  }
898
1218
 
899
- executeSplit(plan, commitData.commitId, cwd);
900
- console.log(`\nSplit complete. Created ${plan.commits.length} commits.`);
1219
+ executeSplit(reviewedPlan, commitData.commitId, cwd);
1220
+ console.log(`\nSplit complete. Created ${reviewedPlan.commits.length} commits.`);
901
1221
  } else {
902
1222
  console.log("\nThis is a preview. Run with --execute to apply the split.");
903
1223
  }
@@ -912,12 +1232,17 @@ export async function main(argv = process.argv) {
912
1232
  const canStream = runtimeOptions.stream && runtimeOptions.format === "plain";
913
1233
  let streamStarted = false;
914
1234
 
1235
+ if (runtimeOptions.stream && !canStream && !runtimeOptions.quiet) {
1236
+ console.error(`Streaming is only supported with plain output. Ignoring --stream for ${runtimeOptions.format} format.`);
1237
+ }
1238
+
915
1239
  const { explanation, responseMeta, promptMeta } = await generateExplanation({
916
1240
  mode,
917
1241
  commitData,
918
1242
  providerOverride: runtimeOptions.provider,
919
1243
  modelOverride: runtimeOptions.model,
920
1244
  maxDiffLines: runtimeOptions.maxDiffLines,
1245
+ noCache: runtimeOptions.noCache,
921
1246
  stream: canStream,
922
1247
  onStart: canStream
923
1248
  ? ({ promptMeta: streamPromptMeta }) => {