gitxplain 0.1.8 → 0.1.9

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,36 @@ 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, notes } = writePipelineFiles(cwd, analysis, selection);
693
+ console.log(`\nCreated workflow files: ${writtenFiles.join(", ")}`);
694
+
695
+ if (notes.length > 0) {
696
+ console.log(`\n${notes.join("\n")}`);
697
+ }
698
+
699
+ return 0;
700
+ }
701
+
559
702
  export async function main(argv = process.argv) {
560
703
  const cwd = process.cwd();
561
704
  const parsed = parseArgs(argv);
@@ -565,6 +708,16 @@ export async function main(argv = process.argv) {
565
708
  const config = loadConfig(cwd);
566
709
  applyConfigEnvironment(config);
567
710
 
711
+ if (parsed.version) {
712
+ console.log(CLI_VERSION);
713
+ return 0;
714
+ }
715
+
716
+ if (parsed.cost) {
717
+ console.log(formatUsageStats(getUsageStats()));
718
+ return 0;
719
+ }
720
+
568
721
  if (parsed.help || hasNoCommandOrFlags) {
569
722
  printHelp();
570
723
  return 0;
@@ -574,6 +727,10 @@ export async function main(argv = process.argv) {
574
727
  return handleConfigCommand(parsed);
575
728
  }
576
729
 
730
+ if (parsed.cacheCommand) {
731
+ return handleCacheCommand(parsed);
732
+ }
733
+
577
734
  if (parsed.nativeGitCommand) {
578
735
  return runNativeGitPassthrough(parsed.nativeGitArgs, cwd);
579
736
  }
@@ -608,36 +765,6 @@ export async function main(argv = process.argv) {
608
765
  return 0;
609
766
  }
610
767
 
611
- 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;
639
- }
640
-
641
768
  if (
642
769
  parsed.addCommand ||
643
770
  parsed.removeCommand ||
@@ -706,39 +833,9 @@ export async function main(argv = process.argv) {
706
833
  }
707
834
 
708
835
  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
- }
836
+ const mode = ANALYSIS_MODES.has(parsed.mode) ? parsed.mode : resolveConfiguredAnalysisMode(config);
737
837
 
738
- return 0;
739
- }
740
-
741
- if (mode === "commit") {
838
+ if (parsed.mode === "commit") {
742
839
  const commitData = fetchWorkingTreeData(cwd);
743
840
 
744
841
  if (commitData.filesChanged.length === 0 || commitData.diff === "") {
@@ -752,6 +849,7 @@ export async function main(argv = process.argv) {
752
849
  providerOverride: runtimeOptions.provider,
753
850
  modelOverride: runtimeOptions.model,
754
851
  maxDiffLines: runtimeOptions.maxDiffLines,
852
+ noCache: runtimeOptions.noCache,
755
853
  stream: false,
756
854
  onChunk: null,
757
855
  onStart: null
@@ -788,7 +886,7 @@ export async function main(argv = process.argv) {
788
886
  return 0;
789
887
  }
790
888
 
791
- if (mode === "merge" || parsed.merge) {
889
+ if (parsed.mode === "merge" || parsed.merge) {
792
890
  if (parsed.commitRef) {
793
891
  throw new Error("--merge works from the current branch and does not accept a commit ref.");
794
892
  }
@@ -820,7 +918,7 @@ export async function main(argv = process.argv) {
820
918
  return 0;
821
919
  }
822
920
 
823
- if (mode === "tag" || parsed.tag) {
921
+ if (parsed.mode === "tag" || parsed.tag) {
824
922
  if (parsed.commitRef) {
825
923
  throw new Error("--tag works from the current branch and does not accept a commit ref.");
826
924
  }
@@ -854,12 +952,210 @@ export async function main(argv = process.argv) {
854
952
 
855
953
  const targetRef = resolveTargetRef(parsed, cwd);
856
954
 
955
+ if (parsed.mode === "blame") {
956
+ if (!parsed.blameFile) {
957
+ throw new Error("--blame requires a file path.");
958
+ }
959
+
960
+ const commitData = fetchBlameData(parsed.blameFile, cwd);
961
+ const canStream = runtimeOptions.stream && runtimeOptions.format === "plain";
962
+ let streamStarted = false;
963
+
964
+ if (runtimeOptions.stream && !canStream && !runtimeOptions.quiet) {
965
+ console.error(`Streaming is only supported with plain output. Ignoring --stream for ${runtimeOptions.format} format.`);
966
+ }
967
+
968
+ const { explanation, responseMeta, promptMeta } = await generateExplanation({
969
+ mode: "blame",
970
+ commitData,
971
+ providerOverride: runtimeOptions.provider,
972
+ modelOverride: runtimeOptions.model,
973
+ maxDiffLines: runtimeOptions.maxDiffLines,
974
+ noCache: runtimeOptions.noCache,
975
+ stream: canStream,
976
+ onStart: canStream
977
+ ? ({ promptMeta: streamPromptMeta }) => {
978
+ if (!runtimeOptions.quiet && !streamStarted) {
979
+ process.stdout.write(
980
+ formatPreamble({
981
+ mode: "blame",
982
+ commitData,
983
+ responseMeta: null,
984
+ promptMeta: streamPromptMeta,
985
+ options: runtimeOptions
986
+ })
987
+ );
988
+ streamStarted = true;
989
+ }
990
+ }
991
+ : null,
992
+ onChunk: canStream ? (chunk) => process.stdout.write(chunk) : null
993
+ });
994
+
995
+ const renderedOutput = renderFinalOutput({
996
+ runtimeOptions,
997
+ mode: "blame",
998
+ commitData,
999
+ explanation,
1000
+ responseMeta,
1001
+ promptMeta
1002
+ });
1003
+
1004
+ if (canStream) {
1005
+ process.stdout.write("\n");
1006
+ if (runtimeOptions.verbose) {
1007
+ process.stdout.write(formatFooter({ responseMeta, promptMeta, options: runtimeOptions }));
1008
+ }
1009
+ } else {
1010
+ console.log(renderedOutput);
1011
+ }
1012
+
1013
+ if (runtimeOptions.clipboard) {
1014
+ copyToClipboard(renderedOutput);
1015
+ if (!runtimeOptions.quiet) {
1016
+ console.error("Copied output to clipboard.");
1017
+ }
1018
+ }
1019
+
1020
+ return 0;
1021
+ }
1022
+
1023
+ if (parsed.mode === "conflict") {
1024
+ const commitData = fetchConflictData(cwd, parsed.diffFile);
1025
+ const canStream = runtimeOptions.stream && runtimeOptions.format === "plain";
1026
+ let streamStarted = false;
1027
+
1028
+ if (runtimeOptions.stream && !canStream && !runtimeOptions.quiet) {
1029
+ console.error(`Streaming is only supported with plain output. Ignoring --stream for ${runtimeOptions.format} format.`);
1030
+ }
1031
+
1032
+ const { explanation, responseMeta, promptMeta } = await generateExplanation({
1033
+ mode: "conflict",
1034
+ commitData,
1035
+ providerOverride: runtimeOptions.provider,
1036
+ modelOverride: runtimeOptions.model,
1037
+ maxDiffLines: runtimeOptions.maxDiffLines,
1038
+ noCache: runtimeOptions.noCache,
1039
+ stream: canStream,
1040
+ onStart: canStream
1041
+ ? ({ promptMeta: streamPromptMeta }) => {
1042
+ if (!runtimeOptions.quiet && !streamStarted) {
1043
+ process.stdout.write(
1044
+ formatPreamble({
1045
+ mode: "conflict",
1046
+ commitData,
1047
+ responseMeta: null,
1048
+ promptMeta: streamPromptMeta,
1049
+ options: runtimeOptions
1050
+ })
1051
+ );
1052
+ streamStarted = true;
1053
+ }
1054
+ }
1055
+ : null,
1056
+ onChunk: canStream ? (chunk) => process.stdout.write(chunk) : null
1057
+ });
1058
+
1059
+ const renderedOutput = renderFinalOutput({
1060
+ runtimeOptions,
1061
+ mode: "conflict",
1062
+ commitData,
1063
+ explanation,
1064
+ responseMeta,
1065
+ promptMeta
1066
+ });
1067
+
1068
+ if (canStream) {
1069
+ process.stdout.write("\n");
1070
+ if (runtimeOptions.verbose) {
1071
+ process.stdout.write(formatFooter({ responseMeta, promptMeta, options: runtimeOptions }));
1072
+ }
1073
+ } else {
1074
+ console.log(renderedOutput);
1075
+ }
1076
+
1077
+ if (runtimeOptions.clipboard) {
1078
+ copyToClipboard(renderedOutput);
1079
+ if (!runtimeOptions.quiet) {
1080
+ console.error("Copied output to clipboard.");
1081
+ }
1082
+ }
1083
+
1084
+ return 0;
1085
+ }
1086
+
1087
+ if (parsed.mode === "stash") {
1088
+ const commitData = fetchStashData(parsed.stashRef, cwd, parsed.diffFile);
1089
+ const canStream = runtimeOptions.stream && runtimeOptions.format === "plain";
1090
+ let streamStarted = false;
1091
+
1092
+ if (runtimeOptions.stream && !canStream && !runtimeOptions.quiet) {
1093
+ console.error(`Streaming is only supported with plain output. Ignoring --stream for ${runtimeOptions.format} format.`);
1094
+ }
1095
+
1096
+ const { explanation, responseMeta, promptMeta } = await generateExplanation({
1097
+ mode: "stash",
1098
+ commitData,
1099
+ providerOverride: runtimeOptions.provider,
1100
+ modelOverride: runtimeOptions.model,
1101
+ maxDiffLines: runtimeOptions.maxDiffLines,
1102
+ noCache: runtimeOptions.noCache,
1103
+ stream: canStream,
1104
+ onStart: canStream
1105
+ ? ({ promptMeta: streamPromptMeta }) => {
1106
+ if (!runtimeOptions.quiet && !streamStarted) {
1107
+ process.stdout.write(
1108
+ formatPreamble({
1109
+ mode: "stash",
1110
+ commitData,
1111
+ responseMeta: null,
1112
+ promptMeta: streamPromptMeta,
1113
+ options: runtimeOptions
1114
+ })
1115
+ );
1116
+ streamStarted = true;
1117
+ }
1118
+ }
1119
+ : null,
1120
+ onChunk: canStream ? (chunk) => process.stdout.write(chunk) : null
1121
+ });
1122
+
1123
+ const renderedOutput = renderFinalOutput({
1124
+ runtimeOptions,
1125
+ mode: "stash",
1126
+ commitData,
1127
+ explanation,
1128
+ responseMeta,
1129
+ promptMeta
1130
+ });
1131
+
1132
+ if (canStream) {
1133
+ process.stdout.write("\n");
1134
+ if (runtimeOptions.verbose) {
1135
+ process.stdout.write(formatFooter({ responseMeta, promptMeta, options: runtimeOptions }));
1136
+ }
1137
+ } else {
1138
+ console.log(renderedOutput);
1139
+ }
1140
+
1141
+ if (runtimeOptions.clipboard) {
1142
+ copyToClipboard(renderedOutput);
1143
+ if (!runtimeOptions.quiet) {
1144
+ console.error("Copied output to clipboard.");
1145
+ }
1146
+ }
1147
+
1148
+ return 0;
1149
+ }
1150
+
857
1151
  if (!targetRef) {
858
1152
  printHelp();
859
1153
  return 1;
860
1154
  }
861
1155
 
862
- const commitData = fetchCommitData(targetRef, cwd);
1156
+ const commitData = parsed.diffFile
1157
+ ? fetchCommitDataForFile(targetRef, parsed.diffFile, cwd)
1158
+ : fetchCommitData(targetRef, cwd);
863
1159
 
864
1160
  if (mode === "split") {
865
1161
  if (commitData.analysisType !== "commit") {
@@ -872,6 +1168,7 @@ export async function main(argv = process.argv) {
872
1168
  providerOverride: runtimeOptions.provider,
873
1169
  modelOverride: runtimeOptions.model,
874
1170
  maxDiffLines: runtimeOptions.maxDiffLines,
1171
+ noCache: runtimeOptions.noCache,
875
1172
  stream: false,
876
1173
  onChunk: null,
877
1174
  onStart: null
@@ -887,6 +1184,17 @@ export async function main(argv = process.argv) {
887
1184
  console.log(formatSplitPlan(plan));
888
1185
 
889
1186
  if (parsed.execute && !parsed.dryRun) {
1187
+ const reviewedPlan = parsed.interactive ? await reviewSplitPlanInteractively(plan) : plan;
1188
+ if (reviewedPlan == null) {
1189
+ console.log("Aborted.");
1190
+ return 0;
1191
+ }
1192
+
1193
+ if (parsed.interactive) {
1194
+ console.log("");
1195
+ console.log(formatSplitPlan(reviewedPlan));
1196
+ }
1197
+
890
1198
  validateSplitExecutionTarget(commitData.commitId, cwd);
891
1199
  const confirmed = await askQuestion(
892
1200
  "\nThis will rewrite git history. Continue? (yes/no) > "
@@ -896,8 +1204,8 @@ export async function main(argv = process.argv) {
896
1204
  return 0;
897
1205
  }
898
1206
 
899
- executeSplit(plan, commitData.commitId, cwd);
900
- console.log(`\nSplit complete. Created ${plan.commits.length} commits.`);
1207
+ executeSplit(reviewedPlan, commitData.commitId, cwd);
1208
+ console.log(`\nSplit complete. Created ${reviewedPlan.commits.length} commits.`);
901
1209
  } else {
902
1210
  console.log("\nThis is a preview. Run with --execute to apply the split.");
903
1211
  }
@@ -912,12 +1220,17 @@ export async function main(argv = process.argv) {
912
1220
  const canStream = runtimeOptions.stream && runtimeOptions.format === "plain";
913
1221
  let streamStarted = false;
914
1222
 
1223
+ if (runtimeOptions.stream && !canStream && !runtimeOptions.quiet) {
1224
+ console.error(`Streaming is only supported with plain output. Ignoring --stream for ${runtimeOptions.format} format.`);
1225
+ }
1226
+
915
1227
  const { explanation, responseMeta, promptMeta } = await generateExplanation({
916
1228
  mode,
917
1229
  commitData,
918
1230
  providerOverride: runtimeOptions.provider,
919
1231
  modelOverride: runtimeOptions.model,
920
1232
  maxDiffLines: runtimeOptions.maxDiffLines,
1233
+ noCache: runtimeOptions.noCache,
921
1234
  stream: canStream,
922
1235
  onStart: canStream
923
1236
  ? ({ promptMeta: streamPromptMeta }) => {