gitxplain 0.1.0 → 0.1.6
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/.env.example +5 -10
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/release.yml +27 -0
- package/IMPLEMENTATION.md +225 -0
- package/README.md +201 -0
- package/cli/index.js +693 -27
- package/cli/services/aiService.js +2 -2
- package/cli/services/chatService.js +683 -0
- package/cli/services/commitService.js +379 -0
- package/cli/services/envLoader.js +33 -0
- package/cli/services/gitConnectionService.js +267 -0
- package/cli/services/gitService.js +633 -1
- package/cli/services/mergeService.js +826 -0
- package/cli/services/outputFormatter.js +185 -13
- package/cli/services/pipelineService.js +721 -0
- package/cli/services/promptService.js +66 -2
- package/cli/services/splitService.js +472 -0
- package/package.json +4 -3
- package/prompts/commit.txt +57 -0
- package/prompts/split.txt +44 -0
package/cli/index.js
CHANGED
|
@@ -5,15 +5,50 @@ 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
|
+
gitPull,
|
|
26
|
+
gitPush,
|
|
27
|
+
gitResetHard,
|
|
28
|
+
gitResetSoft,
|
|
29
|
+
gitStashPop,
|
|
30
|
+
gitRestoreStaged,
|
|
31
|
+
getRepositoryLog,
|
|
32
|
+
getRepositoryStatus,
|
|
13
33
|
getDefaultBaseRef,
|
|
14
|
-
isGitRepository
|
|
34
|
+
isGitRepository,
|
|
35
|
+
listGitSubcommands,
|
|
36
|
+
runNativeGitPassthrough,
|
|
37
|
+
resolveStashRef
|
|
15
38
|
} from "./services/gitService.js";
|
|
16
39
|
import { installHook } from "./services/hookService.js";
|
|
40
|
+
import {
|
|
41
|
+
buildReleaseMergePlan,
|
|
42
|
+
buildReleaseStatus,
|
|
43
|
+
buildReleaseTagPlan,
|
|
44
|
+
executeReleaseMerge,
|
|
45
|
+
executeReleaseTagPlan,
|
|
46
|
+
finalizeReleaseMergePlan,
|
|
47
|
+
finalizeReleaseTagPlan,
|
|
48
|
+
formatReleaseMergePlan,
|
|
49
|
+
formatReleaseStatus,
|
|
50
|
+
formatReleaseTagPlan
|
|
51
|
+
} from "./services/mergeService.js";
|
|
17
52
|
import {
|
|
18
53
|
formatFooter,
|
|
19
54
|
formatHtmlOutput,
|
|
@@ -22,6 +57,20 @@ import {
|
|
|
22
57
|
formatOutput,
|
|
23
58
|
formatPreamble
|
|
24
59
|
} from "./services/outputFormatter.js";
|
|
60
|
+
import {
|
|
61
|
+
formatPipelineRecommendations,
|
|
62
|
+
inspectRepositoryForPipeline,
|
|
63
|
+
resolvePipelineSelection,
|
|
64
|
+
writePipelineFiles
|
|
65
|
+
} from "./services/pipelineService.js";
|
|
66
|
+
import { executeCommitPlan, formatCommitPlan, parseCommitPlan, reconcileCommitPlan } from "./services/commitService.js";
|
|
67
|
+
import {
|
|
68
|
+
executeSplit,
|
|
69
|
+
formatSplitPlan,
|
|
70
|
+
parseSplitPlan,
|
|
71
|
+
reconcileSplitPlan,
|
|
72
|
+
validateSplitExecutionTarget
|
|
73
|
+
} from "./services/splitService.js";
|
|
25
74
|
|
|
26
75
|
const MODE_FLAGS = new Map([
|
|
27
76
|
["--summary", "summary"],
|
|
@@ -31,7 +80,14 @@ const MODE_FLAGS = new Map([
|
|
|
31
80
|
["--full", "full"],
|
|
32
81
|
["--lines", "lines"],
|
|
33
82
|
["--review", "review"],
|
|
34
|
-
["--security", "security"]
|
|
83
|
+
["--security", "security"],
|
|
84
|
+
["--split", "split"],
|
|
85
|
+
["--merge", "merge"],
|
|
86
|
+
["--tag", "tag"],
|
|
87
|
+
["--commit", "commit"],
|
|
88
|
+
["--log", "log"],
|
|
89
|
+
["--status", "status"],
|
|
90
|
+
["--pipeline", "pipeline"]
|
|
35
91
|
]);
|
|
36
92
|
|
|
37
93
|
const FORMAT_FLAGS = new Map([
|
|
@@ -40,36 +96,126 @@ const FORMAT_FLAGS = new Map([
|
|
|
40
96
|
["--html", "html"]
|
|
41
97
|
]);
|
|
42
98
|
|
|
99
|
+
const RESERVED_SUBCOMMANDS = new Set([
|
|
100
|
+
"help",
|
|
101
|
+
"example",
|
|
102
|
+
"install-hook",
|
|
103
|
+
"git",
|
|
104
|
+
"add",
|
|
105
|
+
"remove",
|
|
106
|
+
"del",
|
|
107
|
+
"bin",
|
|
108
|
+
"pop",
|
|
109
|
+
"pull",
|
|
110
|
+
"push",
|
|
111
|
+
"commit",
|
|
112
|
+
"merge",
|
|
113
|
+
"tag",
|
|
114
|
+
"release",
|
|
115
|
+
"log",
|
|
116
|
+
"status",
|
|
117
|
+
"pipeline"
|
|
118
|
+
]);
|
|
119
|
+
|
|
43
120
|
function printHelp() {
|
|
44
|
-
console.log(`gitxplain - AI-powered Git and
|
|
121
|
+
console.log(`gitxplain - AI-powered Git change analysis, review, and commit workflow CLI
|
|
45
122
|
|
|
46
123
|
Usage:
|
|
47
124
|
gitxplain help
|
|
48
125
|
gitxplain --help
|
|
126
|
+
gitxplain example
|
|
127
|
+
gitxplain --example
|
|
128
|
+
gitxplain git <native-git-args...>
|
|
129
|
+
|
|
130
|
+
Git:
|
|
131
|
+
gitxplain commit
|
|
132
|
+
gitxplain --commit
|
|
133
|
+
gitxplain merge
|
|
134
|
+
gitxplain --merge
|
|
135
|
+
gitxplain tag
|
|
136
|
+
gitxplain --tag
|
|
137
|
+
gitxplain release
|
|
138
|
+
gitxplain release status
|
|
139
|
+
gitxplain log
|
|
140
|
+
gitxplain --log
|
|
141
|
+
gitxplain status
|
|
142
|
+
gitxplain --status
|
|
143
|
+
gitxplain --pipeline
|
|
144
|
+
gitxplain add <path> [more-paths...]
|
|
145
|
+
gitxplain remove <path> [more-paths...]
|
|
146
|
+
gitxplain remove hard
|
|
147
|
+
gitxplain del <path> [more-paths...]
|
|
148
|
+
gitxplain bin
|
|
149
|
+
gitxplain pop [stash-index]
|
|
150
|
+
gitxplain pull [remote] [branch]
|
|
151
|
+
gitxplain push [remote] [branch]
|
|
49
152
|
gitxplain install-hook [hook-name]
|
|
153
|
+
|
|
154
|
+
GitHub:
|
|
155
|
+
gitxplain --connect-github [token]
|
|
156
|
+
gitxplain --boot [options]
|
|
157
|
+
|
|
158
|
+
Analysis:
|
|
50
159
|
gitxplain <commit-id> [options]
|
|
51
160
|
gitxplain <start>..<end> [options]
|
|
52
161
|
gitxplain --branch [base-ref] [options]
|
|
53
162
|
gitxplain --pr [base-ref] [options]
|
|
54
163
|
|
|
164
|
+
What It Does:
|
|
165
|
+
Analyze commits, ranges, branches, and working tree changes
|
|
166
|
+
Generate summaries, reviews, security checks, and line-by-line walkthroughs
|
|
167
|
+
Plan commits for uncommitted work and split oversized commits into atomic steps
|
|
168
|
+
Merge release-version branch changes into a dedicated release branch
|
|
169
|
+
Tag release-version commit windows on the current branch
|
|
170
|
+
Inspect release branch health, missing tags, and drift from the source branch
|
|
171
|
+
Inspect repository history and working tree status without calling the LLM
|
|
172
|
+
Inspect the current repository and scaffold GitHub Actions CI/CD workflows
|
|
173
|
+
Run quick local actions to stage, unstage, delete files, pop stashes, or push
|
|
174
|
+
Pull from a remote or soft-reset the latest commit without leaving the CLI
|
|
175
|
+
Pass through any native Git command and flags when you need the full Git surface
|
|
176
|
+
|
|
55
177
|
Modes:
|
|
56
|
-
--summary Generate a one-line summary
|
|
57
|
-
--issues Focus on bug or
|
|
58
|
-
--fix Explain the fix in simple terms
|
|
178
|
+
--summary Generate a one-line summary of a change
|
|
179
|
+
--issues Focus on the bug, issue, or failure being addressed
|
|
180
|
+
--fix Explain the fix in simple, junior-friendly terms
|
|
59
181
|
--impact Explain before-vs-after behavior changes
|
|
60
182
|
--full Generate a full structured analysis
|
|
61
|
-
--lines
|
|
62
|
-
--review Generate
|
|
63
|
-
--security Focus on security
|
|
183
|
+
--lines Walk through the changed code file by file
|
|
184
|
+
--review Generate review findings, risks, and suggestions
|
|
185
|
+
--security Focus on security-relevant changes and concerns
|
|
186
|
+
--split Propose splitting a commit into smaller atomic commits
|
|
187
|
+
--merge Preview or apply a merge into the release branch based on version bumps
|
|
188
|
+
--tag Preview or create release tags based on version bumps
|
|
189
|
+
release Show release branch health, missing tags, and next recommended action
|
|
190
|
+
--commit Propose commits for current uncommitted changes
|
|
191
|
+
--log Print Git log entries for the current repository
|
|
192
|
+
--status Print Git working tree status for the current repository
|
|
193
|
+
--pipeline Detect the current repository stack and create CI/CD workflow files
|
|
194
|
+
--execute Execute a proposed split or commit plan
|
|
195
|
+
--dry-run Preview the plan without executing it (default for --split and --commit)
|
|
196
|
+
|
|
197
|
+
Quick Actions:
|
|
198
|
+
add Stage one or more files with git add
|
|
199
|
+
remove Unstage one or more files with git restore --staged
|
|
200
|
+
remove hard Hard reset the repository to HEAD
|
|
201
|
+
del Delete one or more files from the working tree
|
|
202
|
+
bin Soft reset HEAD~1 while keeping your changes
|
|
203
|
+
pop Pop a stash entry with a plain numeric index like "pop 2"
|
|
204
|
+
pull Run git pull, optionally with a remote and branch
|
|
205
|
+
push Run git push, optionally with a remote and branch
|
|
64
206
|
|
|
65
207
|
Output:
|
|
66
|
-
--json Print JSON output
|
|
208
|
+
--json Print structured JSON output
|
|
67
209
|
--markdown Print Markdown output
|
|
68
210
|
--html Print HTML output
|
|
69
|
-
--quiet Print only the
|
|
211
|
+
--quiet Print only the main body without extra framing
|
|
70
212
|
--verbose Print provider, model, cache, latency, and usage details
|
|
71
213
|
--clipboard Copy the final output to the system clipboard
|
|
72
|
-
--stream Stream
|
|
214
|
+
--stream Stream model output as it is generated when supported
|
|
215
|
+
|
|
216
|
+
GitHub:
|
|
217
|
+
--connect-github Save your GitHub Personal Access Token to act autonomously inside Chat
|
|
218
|
+
--boot Launch an interactive chat session for dynamic querying, PR creation, and cloning
|
|
73
219
|
|
|
74
220
|
Providers:
|
|
75
221
|
--provider LLM provider: openai, groq, openrouter, gemini, ollama, chutes
|
|
@@ -79,15 +225,8 @@ Diff Budget:
|
|
|
79
225
|
--max-diff-lines <n> Limit diff lines sent to the model
|
|
80
226
|
|
|
81
227
|
Comparison:
|
|
82
|
-
--branch [base-ref] Analyze current branch against base branch
|
|
83
|
-
--pr [base-ref] Alias for --branch, useful for PR-style
|
|
84
|
-
|
|
85
|
-
Examples:
|
|
86
|
-
gitxplain HEAD~1 --full
|
|
87
|
-
gitxplain HEAD~5..HEAD --markdown
|
|
88
|
-
gitxplain --branch main --review
|
|
89
|
-
gitxplain --pr origin/main --security --stream
|
|
90
|
-
gitxplain HEAD~1 --provider chutes --model deepseek-ai/DeepSeek-V3-0324
|
|
228
|
+
--branch [base-ref] Analyze the current branch against a base branch
|
|
229
|
+
--pr [base-ref] Alias for --branch, useful for PR-style comparisons
|
|
91
230
|
|
|
92
231
|
Provider Setup:
|
|
93
232
|
OpenAI:
|
|
@@ -124,7 +263,51 @@ Hook Installation:
|
|
|
124
263
|
|
|
125
264
|
Notes:
|
|
126
265
|
Run gitxplain inside a Git repository.
|
|
266
|
+
If no mode is supplied, gitxplain will prompt you to choose one interactively.
|
|
127
267
|
Use --provider or --model to override your config or environment for one command.
|
|
268
|
+
Use gitxplain git <args...> to run any native Git subcommand with its normal flags.
|
|
269
|
+
`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function printExamples() {
|
|
273
|
+
console.log(`gitxplain examples
|
|
274
|
+
|
|
275
|
+
Examples:
|
|
276
|
+
gitxplain --connect-github <token>
|
|
277
|
+
gitxplain --boot
|
|
278
|
+
gitxplain HEAD~1 --full
|
|
279
|
+
gitxplain HEAD~1 --review
|
|
280
|
+
gitxplain HEAD~5..HEAD --markdown
|
|
281
|
+
gitxplain --branch main --review
|
|
282
|
+
gitxplain --pr origin/main --security --stream
|
|
283
|
+
gitxplain commit
|
|
284
|
+
gitxplain --commit --execute
|
|
285
|
+
gitxplain merge
|
|
286
|
+
gitxplain --merge --execute
|
|
287
|
+
gitxplain tag
|
|
288
|
+
gitxplain --tag --execute
|
|
289
|
+
gitxplain release
|
|
290
|
+
gitxplain release status
|
|
291
|
+
gitxplain log
|
|
292
|
+
gitxplain --log
|
|
293
|
+
gitxplain status
|
|
294
|
+
gitxplain --status
|
|
295
|
+
gitxplain pipeline
|
|
296
|
+
gitxplain --pipeline
|
|
297
|
+
gitxplain add README.md
|
|
298
|
+
gitxplain remove README.md
|
|
299
|
+
gitxplain remove hard
|
|
300
|
+
gitxplain del scratch.txt
|
|
301
|
+
gitxplain bin
|
|
302
|
+
gitxplain pop
|
|
303
|
+
gitxplain pop 2
|
|
304
|
+
gitxplain pull
|
|
305
|
+
gitxplain pull origin main
|
|
306
|
+
gitxplain push
|
|
307
|
+
gitxplain push origin main
|
|
308
|
+
gitxplain HEAD~1 --split
|
|
309
|
+
gitxplain HEAD --split --execute
|
|
310
|
+
gitxplain HEAD~1 --provider chutes --model deepseek-ai/DeepSeek-V3-0324
|
|
128
311
|
`);
|
|
129
312
|
}
|
|
130
313
|
|
|
@@ -156,9 +339,22 @@ function parseNumber(value, fallback = null) {
|
|
|
156
339
|
return parsed;
|
|
157
340
|
}
|
|
158
341
|
|
|
159
|
-
|
|
342
|
+
function isDirectNativeGitSubcommand(subcommand, knownGitSubcommands) {
|
|
343
|
+
if (!subcommand || subcommand.startsWith("-")) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (RESERVED_SUBCOMMANDS.has(subcommand)) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return knownGitSubcommands.has(subcommand);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function parseArgs(argv, options = {}) {
|
|
160
355
|
const args = argv.slice(2);
|
|
161
356
|
const subcommand = args[0];
|
|
357
|
+
const knownGitSubcommands = options.gitSubcommands ?? listGitSubcommands();
|
|
162
358
|
const flags = new Set(args.filter((arg) => arg.startsWith("--")));
|
|
163
359
|
const valueFlags = new Set(["--provider", "--model", "--max-diff-lines", "--branch", "--pr"]);
|
|
164
360
|
const positional = [];
|
|
@@ -186,13 +382,84 @@ export function parseArgs(argv) {
|
|
|
186
382
|
const explicitMode = [...MODE_FLAGS.entries()].find(([flag]) => flags.has(flag))?.[1] ?? null;
|
|
187
383
|
const explicitFormat = [...FORMAT_FLAGS.entries()].find(([flag]) => flags.has(flag))?.[1] ?? null;
|
|
188
384
|
const isInstallHook = subcommand === "install-hook";
|
|
385
|
+
const isExample = flags.has("--example") || subcommand === "example";
|
|
386
|
+
const isNativeGitWrapper = subcommand === "git";
|
|
387
|
+
const isConnectGitHub = flags.has("--connect-github") || flags.has("--connect-git");
|
|
388
|
+
const isBoot = flags.has("--boot");
|
|
389
|
+
const isLogCommand = subcommand === "log";
|
|
390
|
+
const isStatusCommand = subcommand === "status";
|
|
391
|
+
const isCommitCommand = subcommand === "commit";
|
|
392
|
+
const isMergeCommand = subcommand === "merge";
|
|
393
|
+
const isTagCommand = subcommand === "tag";
|
|
394
|
+
const isReleaseCommand = subcommand === "release";
|
|
395
|
+
const isAddCommand = subcommand === "add";
|
|
396
|
+
const isRemoveCommand = subcommand === "remove";
|
|
397
|
+
const isDeleteCommand = subcommand === "del";
|
|
398
|
+
const isPipelineCommand = subcommand === "pipeline" || flags.has("--pipeline");
|
|
399
|
+
const isBinCommand = subcommand === "bin";
|
|
400
|
+
const isPopCommand = subcommand === "pop";
|
|
401
|
+
const isPullCommand = subcommand === "pull";
|
|
402
|
+
const isPushCommand = subcommand === "push";
|
|
403
|
+
const isRemoveHardCommand = isRemoveCommand && positional[1] === "hard" && positional.length === 2;
|
|
404
|
+
const isNativeGitCommand = isNativeGitWrapper || isDirectNativeGitSubcommand(subcommand, knownGitSubcommands);
|
|
189
405
|
|
|
190
406
|
return {
|
|
191
407
|
subcommand,
|
|
192
408
|
help: flags.has("--help") || subcommand === "help",
|
|
409
|
+
example: isExample,
|
|
410
|
+
nativeGitCommand: isNativeGitCommand,
|
|
193
411
|
installHook: isInstallHook,
|
|
412
|
+
connectGitHub: isConnectGitHub,
|
|
413
|
+
boot: isBoot,
|
|
414
|
+
logCommand: isLogCommand,
|
|
415
|
+
statusCommand: isStatusCommand,
|
|
416
|
+
commitCommand: isCommitCommand,
|
|
417
|
+
mergeCommand: isMergeCommand,
|
|
418
|
+
tagCommand: isTagCommand,
|
|
419
|
+
releaseCommand: isReleaseCommand,
|
|
420
|
+
releaseAction: isReleaseCommand ? positional[1] ?? "status" : null,
|
|
421
|
+
addCommand: isAddCommand,
|
|
422
|
+
removeCommand: isRemoveCommand,
|
|
423
|
+
deleteCommand: isDeleteCommand,
|
|
424
|
+
pipelineCommand: isPipelineCommand,
|
|
425
|
+
binCommand: isBinCommand,
|
|
426
|
+
popCommand: isPopCommand,
|
|
427
|
+
pullCommand: isPullCommand,
|
|
428
|
+
pushCommand: isPushCommand,
|
|
429
|
+
removeHardCommand: isRemoveHardCommand,
|
|
430
|
+
nativeGitArgs: isNativeGitWrapper ? args.slice(1) : isNativeGitCommand ? args : [],
|
|
194
431
|
hookName: isInstallHook ? positional[1] ?? "post-commit" : null,
|
|
195
|
-
|
|
432
|
+
actionPaths:
|
|
433
|
+
isAddCommand || isDeleteCommand ? positional.slice(1) : isRemoveHardCommand ? [] : isRemoveCommand ? positional.slice(1) : [],
|
|
434
|
+
connectToken: isConnectGitHub ? positional[0] : null,
|
|
435
|
+
stashIndex: isPopCommand ? positional[1] ?? null : null,
|
|
436
|
+
pullRemote: isPullCommand ? positional[1] ?? null : null,
|
|
437
|
+
pullBranch: isPullCommand ? positional[2] ?? null : null,
|
|
438
|
+
pushRemote: isPushCommand ? positional[1] ?? null : null,
|
|
439
|
+
pushBranch: isPushCommand ? positional[2] ?? null : null,
|
|
440
|
+
commitRef:
|
|
441
|
+
isInstallHook ||
|
|
442
|
+
isExample ||
|
|
443
|
+
isNativeGitCommand ||
|
|
444
|
+
isConnectGitHub ||
|
|
445
|
+
isBoot ||
|
|
446
|
+
isLogCommand ||
|
|
447
|
+
isStatusCommand ||
|
|
448
|
+
isCommitCommand ||
|
|
449
|
+
isMergeCommand ||
|
|
450
|
+
isTagCommand ||
|
|
451
|
+
isReleaseCommand ||
|
|
452
|
+
isAddCommand ||
|
|
453
|
+
isRemoveCommand ||
|
|
454
|
+
isDeleteCommand ||
|
|
455
|
+
isPipelineCommand ||
|
|
456
|
+
isBinCommand ||
|
|
457
|
+
isPopCommand ||
|
|
458
|
+
isPullCommand ||
|
|
459
|
+
isPushCommand ||
|
|
460
|
+
subcommand === "help"
|
|
461
|
+
? null
|
|
462
|
+
: positional[0] ?? null,
|
|
196
463
|
mode: explicitMode,
|
|
197
464
|
format: explicitFormat,
|
|
198
465
|
provider: getFlagValue(args, "--provider"),
|
|
@@ -205,7 +472,13 @@ export function parseArgs(argv) {
|
|
|
205
472
|
clipboard: flags.has("--clipboard"),
|
|
206
473
|
stream: flags.has("--stream"),
|
|
207
474
|
verbose: flags.has("--verbose"),
|
|
208
|
-
quiet: flags.has("--quiet")
|
|
475
|
+
quiet: flags.has("--quiet"),
|
|
476
|
+
execute: flags.has("--execute"),
|
|
477
|
+
dryRun: flags.has("--dry-run"),
|
|
478
|
+
log: flags.has("--log"),
|
|
479
|
+
status: flags.has("--status"),
|
|
480
|
+
merge: flags.has("--merge"),
|
|
481
|
+
tag: flags.has("--tag")
|
|
209
482
|
};
|
|
210
483
|
}
|
|
211
484
|
|
|
@@ -233,6 +506,12 @@ async function chooseModeInteractively() {
|
|
|
233
506
|
"6. Line-by-Line Code Walkthrough",
|
|
234
507
|
"7. Code Review",
|
|
235
508
|
"8. Security Review",
|
|
509
|
+
"9. Split Commit",
|
|
510
|
+
"10. Merge To Release Branch",
|
|
511
|
+
"11. Tag Release Commits",
|
|
512
|
+
"12. Repository Log",
|
|
513
|
+
"13. Commit Working Tree",
|
|
514
|
+
"14. Create CI/CD Pipelines",
|
|
236
515
|
"> "
|
|
237
516
|
].join("\n")
|
|
238
517
|
);
|
|
@@ -245,7 +524,13 @@ async function chooseModeInteractively() {
|
|
|
245
524
|
"5": "full",
|
|
246
525
|
"6": "lines",
|
|
247
526
|
"7": "review",
|
|
248
|
-
"8": "security"
|
|
527
|
+
"8": "security",
|
|
528
|
+
"9": "split",
|
|
529
|
+
"10": "merge",
|
|
530
|
+
"11": "tag",
|
|
531
|
+
"12": "log",
|
|
532
|
+
"13": "commit",
|
|
533
|
+
"14": "pipeline"
|
|
249
534
|
};
|
|
250
535
|
|
|
251
536
|
return selections[answer] ?? "full";
|
|
@@ -278,6 +563,15 @@ function resolveTargetRef(parsed, cwd) {
|
|
|
278
563
|
return null;
|
|
279
564
|
}
|
|
280
565
|
|
|
566
|
+
export function buildBootSessionArgs(connection, userInfo, parsed) {
|
|
567
|
+
return {
|
|
568
|
+
token: connection.token,
|
|
569
|
+
providerOverride: parsed.provider,
|
|
570
|
+
modelOverride: parsed.model,
|
|
571
|
+
username: userInfo.name || connection.user?.login || null
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
281
575
|
function renderFinalOutput({ runtimeOptions, mode, commitData, explanation, responseMeta, promptMeta }) {
|
|
282
576
|
if (runtimeOptions.format === "json") {
|
|
283
577
|
return formatJsonOutput({ mode, commitData, explanation, responseMeta, promptMeta });
|
|
@@ -305,12 +599,24 @@ export async function main(argv = process.argv) {
|
|
|
305
599
|
const cwd = process.cwd();
|
|
306
600
|
const config = loadConfig(cwd);
|
|
307
601
|
const parsed = parseArgs(argv);
|
|
602
|
+
const hasNoCommandOrFlags = argv.slice(2).length === 0;
|
|
603
|
+
|
|
604
|
+
loadEnvFile(cwd); // Ensure environment is loaded first
|
|
308
605
|
|
|
309
|
-
if (parsed.help) {
|
|
606
|
+
if (parsed.help || hasNoCommandOrFlags) {
|
|
310
607
|
printHelp();
|
|
311
608
|
return 0;
|
|
312
609
|
}
|
|
313
610
|
|
|
611
|
+
if (parsed.example) {
|
|
612
|
+
printExamples();
|
|
613
|
+
return 0;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (parsed.nativeGitCommand) {
|
|
617
|
+
return runNativeGitPassthrough(parsed.nativeGitArgs, cwd);
|
|
618
|
+
}
|
|
619
|
+
|
|
314
620
|
if (!isGitRepository(cwd)) {
|
|
315
621
|
console.error("gitxplain must be run inside a Git repository.");
|
|
316
622
|
return 1;
|
|
@@ -322,7 +628,319 @@ export async function main(argv = process.argv) {
|
|
|
322
628
|
return 0;
|
|
323
629
|
}
|
|
324
630
|
|
|
631
|
+
if (parsed.connectGitHub) {
|
|
632
|
+
let token = parsed.connectToken;
|
|
633
|
+
if (!token) {
|
|
634
|
+
if (process.env.GITHUB_TOKEN) {
|
|
635
|
+
token = process.env.GITHUB_TOKEN;
|
|
636
|
+
} else {
|
|
637
|
+
console.error("Please provide your GitHub Personal Access Token.\nRun: gitxplain --connect-github <YOUR_TOKEN>\nOr set it in your .env as GITHUB_TOKEN=...");
|
|
638
|
+
return 1;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
console.log("Verifying token with GitHub API...");
|
|
643
|
+
const userInfo = await verifyGitToken(token);
|
|
644
|
+
await saveGitConnection(token, "github", userInfo);
|
|
645
|
+
console.log(`\nSuccessfully connected to GitHub as: \x1b[36m${userInfo.login}\x1b[0m`);
|
|
646
|
+
console.log(`Token saved securely to your local configuration.\n`);
|
|
647
|
+
} catch (e) {
|
|
648
|
+
console.error(`Token verification failed: ${e.message}`);
|
|
649
|
+
return 1;
|
|
650
|
+
}
|
|
651
|
+
return 0;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (parsed.boot) {
|
|
655
|
+
if (!isGitConnected()) {
|
|
656
|
+
console.error("You must connect a GitHub account first to use the interactive agent.\nCommand: gitxplain --connect-github <YOUR_TOKEN>");
|
|
657
|
+
return 1;
|
|
658
|
+
}
|
|
659
|
+
const connection = loadGitConnection();
|
|
660
|
+
const userInfo = getGitUserInfo();
|
|
661
|
+
try {
|
|
662
|
+
const { getProviderConfig, validateProviderConfig } = await import(
|
|
663
|
+
"./services/aiService.js"
|
|
664
|
+
);
|
|
665
|
+
const config = getProviderConfig(parsed.provider, parsed.model);
|
|
666
|
+
validateProviderConfig(config);
|
|
667
|
+
const sessionArgs = buildBootSessionArgs(connection, userInfo, parsed);
|
|
668
|
+
await startChatSession(
|
|
669
|
+
sessionArgs.token,
|
|
670
|
+
sessionArgs.providerOverride,
|
|
671
|
+
sessionArgs.modelOverride,
|
|
672
|
+
sessionArgs.username
|
|
673
|
+
);
|
|
674
|
+
} catch (configError) {
|
|
675
|
+
console.error(`Missing LLM Key. Please check your .env variables or --provider flags.\n${configError.message}`);
|
|
676
|
+
return 1;
|
|
677
|
+
}
|
|
678
|
+
return 0;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (parsed.logCommand || parsed.log) {
|
|
682
|
+
console.log(getRepositoryLog(cwd));
|
|
683
|
+
return 0;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (parsed.statusCommand || parsed.status) {
|
|
687
|
+
console.log(getRepositoryStatus(cwd));
|
|
688
|
+
return 0;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (parsed.releaseCommand) {
|
|
692
|
+
if (parsed.releaseAction !== "status") {
|
|
693
|
+
throw new Error(`Unknown release subcommand: ${parsed.releaseAction}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
console.log(formatReleaseStatus(buildReleaseStatus(cwd)));
|
|
697
|
+
return 0;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (parsed.pipelineCommand) {
|
|
701
|
+
const analysis = inspectRepositoryForPipeline(cwd);
|
|
702
|
+
|
|
703
|
+
if (!analysis.supported) {
|
|
704
|
+
console.log(analysis.reason);
|
|
705
|
+
return 1;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
console.log(formatPipelineRecommendations(analysis));
|
|
709
|
+
|
|
710
|
+
const answer = await askQuestion(
|
|
711
|
+
`\nChoose a pipeline option (1-${analysis.options.length}) or type "cancel" > `
|
|
712
|
+
);
|
|
713
|
+
const selection = resolvePipelineSelection(analysis, answer);
|
|
714
|
+
|
|
715
|
+
if (!selection) {
|
|
716
|
+
console.log("Aborted.");
|
|
717
|
+
return 0;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const { writtenFiles, notes } = writePipelineFiles(cwd, analysis, selection);
|
|
721
|
+
console.log(`\nCreated workflow files: ${writtenFiles.join(", ")}`);
|
|
722
|
+
|
|
723
|
+
if (notes.length > 0) {
|
|
724
|
+
console.log(`\n${notes.join("\n")}`);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return 0;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (
|
|
731
|
+
parsed.addCommand ||
|
|
732
|
+
parsed.removeCommand ||
|
|
733
|
+
parsed.deleteCommand ||
|
|
734
|
+
parsed.binCommand ||
|
|
735
|
+
parsed.popCommand ||
|
|
736
|
+
parsed.pullCommand ||
|
|
737
|
+
parsed.pushCommand
|
|
738
|
+
) {
|
|
739
|
+
if (!parsed.popCommand && !parsed.binCommand && !parsed.pullCommand && !parsed.removeHardCommand && parsed.actionPaths.length === 0) {
|
|
740
|
+
if (!parsed.pushCommand) {
|
|
741
|
+
throw new Error(`No paths provided for "${parsed.subcommand}".`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (parsed.addCommand) {
|
|
746
|
+
gitAddFiles(parsed.actionPaths, cwd);
|
|
747
|
+
console.log(`Staged ${parsed.actionPaths.join(", ")}.`);
|
|
748
|
+
return 0;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (parsed.removeCommand) {
|
|
752
|
+
if (parsed.removeHardCommand) {
|
|
753
|
+
gitResetHard("HEAD", cwd);
|
|
754
|
+
console.log("Hard reset to HEAD.");
|
|
755
|
+
return 0;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
gitRestoreStaged(parsed.actionPaths, cwd);
|
|
759
|
+
console.log(`Unstaged ${parsed.actionPaths.join(", ")}.`);
|
|
760
|
+
return 0;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (parsed.deleteCommand) {
|
|
764
|
+
deletePaths(parsed.actionPaths, cwd);
|
|
765
|
+
console.log(`Deleted ${parsed.actionPaths.join(", ")}.`);
|
|
766
|
+
return 0;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (parsed.binCommand) {
|
|
770
|
+
gitResetSoft(cwd);
|
|
771
|
+
console.log("Soft reset HEAD~1 and kept your changes.");
|
|
772
|
+
return 0;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (parsed.popCommand) {
|
|
776
|
+
const stashRef = resolveStashRef(parsed.stashIndex);
|
|
777
|
+
gitStashPop(parsed.stashIndex, cwd);
|
|
778
|
+
console.log(`Popped ${stashRef}.`);
|
|
779
|
+
return 0;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (parsed.pullCommand) {
|
|
783
|
+
gitPull(cwd, parsed.pullRemote, parsed.pullBranch);
|
|
784
|
+
console.log(
|
|
785
|
+
`Pulled${parsed.pullRemote ? ` from ${parsed.pullRemote}` : ""}${parsed.pullBranch ? ` ${parsed.pullBranch}` : ""}.`
|
|
786
|
+
);
|
|
787
|
+
return 0;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
gitPush(cwd, parsed.pushRemote, parsed.pushBranch);
|
|
791
|
+
console.log(
|
|
792
|
+
`Pushed${parsed.pushRemote ? ` to ${parsed.pushRemote}` : ""}${parsed.pushBranch ? ` ${parsed.pushBranch}` : ""}.`
|
|
793
|
+
);
|
|
794
|
+
return 0;
|
|
795
|
+
}
|
|
796
|
+
|
|
325
797
|
const runtimeOptions = resolveRuntimeOptions(parsed, config);
|
|
798
|
+
const mode = parsed.mode ?? config.mode ?? (await chooseModeInteractively());
|
|
799
|
+
|
|
800
|
+
if (mode === "pipeline") {
|
|
801
|
+
const analysis = inspectRepositoryForPipeline(cwd);
|
|
802
|
+
|
|
803
|
+
if (!analysis.supported) {
|
|
804
|
+
console.log(analysis.reason);
|
|
805
|
+
return 1;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
console.log(formatPipelineRecommendations(analysis));
|
|
809
|
+
|
|
810
|
+
const answer = await askQuestion(
|
|
811
|
+
`\nChoose a pipeline option (1-${analysis.options.length}) or type "cancel" > `
|
|
812
|
+
);
|
|
813
|
+
const selection = resolvePipelineSelection(analysis, answer);
|
|
814
|
+
|
|
815
|
+
if (!selection) {
|
|
816
|
+
console.log("Aborted.");
|
|
817
|
+
return 0;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const { writtenFiles, notes } = writePipelineFiles(cwd, analysis, selection);
|
|
821
|
+
console.log(`\nCreated workflow files: ${writtenFiles.join(", ")}`);
|
|
822
|
+
|
|
823
|
+
if (notes.length > 0) {
|
|
824
|
+
console.log(`\n${notes.join("\n")}`);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return 0;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (mode === "commit" || parsed.commitCommand) {
|
|
831
|
+
const commitData = fetchWorkingTreeData(cwd);
|
|
832
|
+
|
|
833
|
+
if (commitData.filesChanged.length === 0 || commitData.diff === "") {
|
|
834
|
+
console.log("Working tree is clean. Nothing to commit.");
|
|
835
|
+
return 0;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const { explanation, responseMeta, promptMeta } = await generateExplanation({
|
|
839
|
+
mode: "commit",
|
|
840
|
+
commitData,
|
|
841
|
+
providerOverride: runtimeOptions.provider,
|
|
842
|
+
modelOverride: runtimeOptions.model,
|
|
843
|
+
maxDiffLines: runtimeOptions.maxDiffLines,
|
|
844
|
+
stream: false,
|
|
845
|
+
onChunk: null,
|
|
846
|
+
onStart: null
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const plan = reconcileCommitPlan(parseCommitPlan(explanation), cwd);
|
|
850
|
+
|
|
851
|
+
if (!plan.reason_to_commit || plan.commits.length === 0) {
|
|
852
|
+
console.log("No meaningful commit grouping recommended.");
|
|
853
|
+
return 0;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
console.log(formatCommitPlan(plan));
|
|
857
|
+
|
|
858
|
+
if (parsed.execute && !parsed.dryRun) {
|
|
859
|
+
const confirmed = await askQuestion(
|
|
860
|
+
"\nThis will create new commits from your working tree changes. Continue? (yes/no) > "
|
|
861
|
+
);
|
|
862
|
+
if (confirmed.toLowerCase() !== "yes") {
|
|
863
|
+
console.log("Aborted.");
|
|
864
|
+
return 0;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
executeCommitPlan(plan, cwd);
|
|
868
|
+
console.log(`\nCommit complete. Created ${plan.commits.length} commits.`);
|
|
869
|
+
} else {
|
|
870
|
+
console.log("\nThis is a preview. Run with --execute to apply the commit plan.");
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (runtimeOptions.verbose) {
|
|
874
|
+
process.stdout.write(formatFooter({ responseMeta, promptMeta, options: runtimeOptions }));
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return 0;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (mode === "merge" || parsed.mergeCommand || parsed.merge) {
|
|
881
|
+
if (parsed.commitRef) {
|
|
882
|
+
throw new Error("--merge works from the current branch and does not accept a commit ref.");
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const plan = finalizeReleaseMergePlan(buildReleaseMergePlan(cwd));
|
|
886
|
+
|
|
887
|
+
if (plan.windows.length === 0) {
|
|
888
|
+
console.log("No unreleased release commits detected. Nothing to merge.");
|
|
889
|
+
return 0;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
console.log(formatReleaseMergePlan(plan));
|
|
893
|
+
|
|
894
|
+
if (parsed.execute && !parsed.dryRun) {
|
|
895
|
+
const confirmed = await askQuestion(
|
|
896
|
+
`\nThis will create ${plan.windows.length} release commit(s) on ${plan.releaseBranch}. Continue? (yes/no) > `
|
|
897
|
+
);
|
|
898
|
+
if (confirmed.toLowerCase() !== "yes") {
|
|
899
|
+
console.log("Aborted.");
|
|
900
|
+
return 0;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
executeReleaseMerge(plan, cwd);
|
|
904
|
+
console.log(`\nRelease promotion complete. Created ${plan.windows.length} release commit(s) on ${plan.releaseBranch}.`);
|
|
905
|
+
} else {
|
|
906
|
+
console.log(`\nThis is a preview. Run with --execute to create release commits on ${plan.releaseBranch}.`);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return 0;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (mode === "tag" || parsed.tagCommand || parsed.tag) {
|
|
913
|
+
if (parsed.commitRef) {
|
|
914
|
+
throw new Error("--tag works from the current branch and does not accept a commit ref.");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const plan = finalizeReleaseTagPlan(buildReleaseTagPlan(cwd));
|
|
918
|
+
|
|
919
|
+
if (plan.tags.length === 0) {
|
|
920
|
+
console.log("No unreleased release tags detected. Nothing to tag.");
|
|
921
|
+
return 0;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
console.log(formatReleaseTagPlan(plan));
|
|
925
|
+
|
|
926
|
+
if (parsed.execute && !parsed.dryRun) {
|
|
927
|
+
const confirmed = await askQuestion(
|
|
928
|
+
`\nThis will create ${plan.tags.length} release tag(s). Continue? (yes/no) > `
|
|
929
|
+
);
|
|
930
|
+
if (confirmed.toLowerCase() !== "yes") {
|
|
931
|
+
console.log("Aborted.");
|
|
932
|
+
return 0;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
executeReleaseTagPlan(plan, cwd);
|
|
936
|
+
console.log(`\nRelease tagging complete. Created ${plan.tags.length} release tag(s).`);
|
|
937
|
+
} else {
|
|
938
|
+
console.log("\nThis is a preview. Run with --execute to create release tags.");
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return 0;
|
|
942
|
+
}
|
|
943
|
+
|
|
326
944
|
const targetRef = resolveTargetRef(parsed, cwd);
|
|
327
945
|
|
|
328
946
|
if (!targetRef) {
|
|
@@ -330,8 +948,56 @@ export async function main(argv = process.argv) {
|
|
|
330
948
|
return 1;
|
|
331
949
|
}
|
|
332
950
|
|
|
333
|
-
const mode = parsed.mode ?? config.mode ?? (await chooseModeInteractively());
|
|
334
951
|
const commitData = fetchCommitData(targetRef, cwd);
|
|
952
|
+
|
|
953
|
+
if (mode === "split") {
|
|
954
|
+
if (commitData.analysisType !== "commit") {
|
|
955
|
+
throw new Error("--split only supports analyzing a single commit.");
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const { explanation, responseMeta, promptMeta } = await generateExplanation({
|
|
959
|
+
mode: "split",
|
|
960
|
+
commitData,
|
|
961
|
+
providerOverride: runtimeOptions.provider,
|
|
962
|
+
modelOverride: runtimeOptions.model,
|
|
963
|
+
maxDiffLines: runtimeOptions.maxDiffLines,
|
|
964
|
+
stream: false,
|
|
965
|
+
onChunk: null,
|
|
966
|
+
onStart: null
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
const plan = reconcileSplitPlan(parseSplitPlan(explanation), commitData.filesChanged);
|
|
970
|
+
|
|
971
|
+
if (!plan.reason_to_split || plan.commits.length === 0) {
|
|
972
|
+
console.log("This commit is already atomic. No split recommended.");
|
|
973
|
+
return 0;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
console.log(formatSplitPlan(plan));
|
|
977
|
+
|
|
978
|
+
if (parsed.execute && !parsed.dryRun) {
|
|
979
|
+
validateSplitExecutionTarget(commitData.commitId, cwd);
|
|
980
|
+
const confirmed = await askQuestion(
|
|
981
|
+
"\nThis will rewrite git history. Continue? (yes/no) > "
|
|
982
|
+
);
|
|
983
|
+
if (confirmed.toLowerCase() !== "yes") {
|
|
984
|
+
console.log("Aborted.");
|
|
985
|
+
return 0;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
executeSplit(plan, commitData.commitId, cwd);
|
|
989
|
+
console.log(`\nSplit complete. Created ${plan.commits.length} commits.`);
|
|
990
|
+
} else {
|
|
991
|
+
console.log("\nThis is a preview. Run with --execute to apply the split.");
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (runtimeOptions.verbose) {
|
|
995
|
+
process.stdout.write(formatFooter({ responseMeta, promptMeta, options: runtimeOptions }));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
return 0;
|
|
999
|
+
}
|
|
1000
|
+
|
|
335
1001
|
const canStream = runtimeOptions.stream && runtimeOptions.format === "plain";
|
|
336
1002
|
let streamStarted = false;
|
|
337
1003
|
|