gitxplain 0.1.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.
@@ -0,0 +1,362 @@
1
+ import process from "node:process";
2
+ import { createCacheKey, readCache, writeCache } from "./cacheService.js";
3
+ import { buildPrompt } from "./promptService.js";
4
+
5
+ const SUPPORTED_PROVIDERS = new Set([
6
+ "openai",
7
+ "groq",
8
+ "openrouter",
9
+ "gemini",
10
+ "ollama",
11
+ "chutes"
12
+ ]);
13
+ const SYSTEM_PROMPT = "You explain Git commits clearly and accurately for developers.";
14
+
15
+ function getProviderConfig(providerOverride, modelOverride) {
16
+ const provider = (providerOverride ?? process.env.LLM_PROVIDER ?? "openai").toLowerCase();
17
+
18
+ if (!SUPPORTED_PROVIDERS.has(provider)) {
19
+ throw new Error(
20
+ `Unsupported provider "${provider}". Supported providers: ${[...SUPPORTED_PROVIDERS].join(", ")}.`
21
+ );
22
+ }
23
+
24
+ if (provider === "openai") {
25
+ return {
26
+ provider,
27
+ apiKey: process.env.OPENAI_API_KEY,
28
+ baseUrl: process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1",
29
+ model: modelOverride ?? process.env.OPENAI_MODEL ?? process.env.LLM_MODEL ?? "gpt-4.1-mini"
30
+ };
31
+ }
32
+
33
+ if (provider === "groq") {
34
+ return {
35
+ provider,
36
+ apiKey: process.env.GROQ_API_KEY,
37
+ baseUrl: process.env.GROQ_BASE_URL ?? "https://api.groq.com/openai/v1",
38
+ model: modelOverride ?? process.env.GROQ_MODEL ?? process.env.LLM_MODEL ?? "llama-3.3-70b-versatile"
39
+ };
40
+ }
41
+
42
+ if (provider === "openrouter") {
43
+ return {
44
+ provider,
45
+ apiKey: process.env.OPENROUTER_API_KEY,
46
+ baseUrl: process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api/v1",
47
+ model: modelOverride ?? process.env.OPENROUTER_MODEL ?? process.env.LLM_MODEL ?? "openai/gpt-4.1-mini"
48
+ };
49
+ }
50
+
51
+ if (provider === "gemini") {
52
+ return {
53
+ provider,
54
+ apiKey: process.env.GEMINI_API_KEY,
55
+ baseUrl:
56
+ process.env.GEMINI_BASE_URL ?? "https://generativelanguage.googleapis.com/v1beta",
57
+ model: modelOverride ?? process.env.GEMINI_MODEL ?? process.env.LLM_MODEL ?? "gemini-2.5-flash"
58
+ };
59
+ }
60
+
61
+ if (provider === "chutes") {
62
+ return {
63
+ provider,
64
+ apiKey: process.env.CHUTES_API_KEY,
65
+ baseUrl: process.env.CHUTES_BASE_URL ?? "https://llm.chutes.ai/v1",
66
+ model:
67
+ modelOverride ??
68
+ process.env.CHUTES_MODEL ??
69
+ process.env.LLM_MODEL ??
70
+ "deepseek-ai/DeepSeek-V3-0324"
71
+ };
72
+ }
73
+
74
+ return {
75
+ provider,
76
+ apiKey: process.env.OLLAMA_API_KEY ?? "ollama",
77
+ baseUrl: process.env.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434/v1",
78
+ model: modelOverride ?? process.env.OLLAMA_MODEL ?? process.env.LLM_MODEL ?? "llama3.2"
79
+ };
80
+ }
81
+
82
+ function validateProviderConfig(config) {
83
+ if (!config.model) {
84
+ throw new Error(`No model configured for provider "${config.provider}".`);
85
+ }
86
+
87
+ if (config.provider !== "ollama" && !config.apiKey) {
88
+ throw new Error(`Missing API key for provider "${config.provider}".`);
89
+ }
90
+ }
91
+
92
+ function buildOpenAICompatibleHeaders(config) {
93
+ const headers = {
94
+ "Content-Type": "application/json",
95
+ Authorization: `Bearer ${config.apiKey}`
96
+ };
97
+
98
+ if (config.provider === "openrouter") {
99
+ headers["HTTP-Referer"] = process.env.OPENROUTER_SITE_URL ?? "https://github.com";
100
+ headers["X-Title"] = process.env.OPENROUTER_APP_NAME ?? "gitxplain";
101
+ }
102
+
103
+ return headers;
104
+ }
105
+
106
+ function extractUsage(data) {
107
+ return data.usage ?? null;
108
+ }
109
+
110
+ function extractOpenAIContent(data) {
111
+ return data.choices?.[0]?.message?.content?.trim() || "No explanation returned by the model.";
112
+ }
113
+
114
+ function extractGeminiText(data) {
115
+ const parts = data.candidates?.[0]?.content?.parts ?? [];
116
+ return parts
117
+ .map((part) => part.text)
118
+ .filter(Boolean)
119
+ .join("\n");
120
+ }
121
+
122
+ async function consumeSseStream(response, getChunkText, onChunk) {
123
+ const reader = response.body?.getReader();
124
+ if (!reader) {
125
+ throw new Error("Streaming is not supported by this runtime.");
126
+ }
127
+
128
+ const decoder = new TextDecoder();
129
+ let buffer = "";
130
+ let fullText = "";
131
+
132
+ while (true) {
133
+ const { done, value } = await reader.read();
134
+ if (done) {
135
+ break;
136
+ }
137
+
138
+ buffer += decoder.decode(value, { stream: true });
139
+ const events = buffer.split("\n\n");
140
+ buffer = events.pop() ?? "";
141
+
142
+ for (const event of events) {
143
+ const dataLines = event
144
+ .split("\n")
145
+ .filter((line) => line.startsWith("data:"))
146
+ .map((line) => line.slice(5).trim())
147
+ .filter(Boolean);
148
+
149
+ for (const line of dataLines) {
150
+ if (line === "[DONE]") {
151
+ continue;
152
+ }
153
+
154
+ const parsed = JSON.parse(line);
155
+ const chunkText = getChunkText(parsed);
156
+ if (!chunkText) {
157
+ continue;
158
+ }
159
+
160
+ fullText += chunkText;
161
+ onChunk?.(chunkText);
162
+ }
163
+ }
164
+ }
165
+
166
+ return fullText.trim();
167
+ }
168
+
169
+ async function requestOpenAICompatible(config, prompt, options) {
170
+ const startedAt = Date.now();
171
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
172
+ method: "POST",
173
+ headers: buildOpenAICompatibleHeaders(config),
174
+ body: JSON.stringify({
175
+ model: config.model,
176
+ messages: [
177
+ {
178
+ role: "system",
179
+ content: SYSTEM_PROMPT
180
+ },
181
+ {
182
+ role: "user",
183
+ content: prompt
184
+ }
185
+ ],
186
+ temperature: 0.2,
187
+ stream: options.stream === true
188
+ })
189
+ });
190
+
191
+ if (!response.ok) {
192
+ const errorText = await response.text();
193
+ throw new Error(`${config.provider} request failed (${response.status}): ${errorText}`);
194
+ }
195
+
196
+ if (options.stream) {
197
+ const explanation = await consumeSseStream(
198
+ response,
199
+ (data) => {
200
+ const content = data.choices?.[0]?.delta?.content;
201
+ if (typeof content === "string") {
202
+ return content;
203
+ }
204
+
205
+ if (Array.isArray(content)) {
206
+ return content.map((item) => item.text ?? "").join("");
207
+ }
208
+
209
+ return "";
210
+ },
211
+ options.onChunk
212
+ );
213
+
214
+ return {
215
+ explanation,
216
+ responseMeta: {
217
+ provider: config.provider,
218
+ model: config.model,
219
+ cacheHit: false,
220
+ latencyMs: Date.now() - startedAt,
221
+ usage: null
222
+ }
223
+ };
224
+ }
225
+
226
+ const data = await response.json();
227
+ return {
228
+ explanation: extractOpenAIContent(data),
229
+ responseMeta: {
230
+ provider: config.provider,
231
+ model: config.model,
232
+ cacheHit: false,
233
+ latencyMs: Date.now() - startedAt,
234
+ usage: extractUsage(data)
235
+ }
236
+ };
237
+ }
238
+
239
+ async function requestGemini(config, prompt, options) {
240
+ const startedAt = Date.now();
241
+ const endpoint = options.stream
242
+ ? `${config.baseUrl}/models/${config.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(config.apiKey)}`
243
+ : `${config.baseUrl}/models/${config.model}:generateContent?key=${encodeURIComponent(config.apiKey)}`;
244
+
245
+ const response = await fetch(endpoint, {
246
+ method: "POST",
247
+ headers: {
248
+ "Content-Type": "application/json"
249
+ },
250
+ body: JSON.stringify({
251
+ systemInstruction: {
252
+ parts: [
253
+ {
254
+ text: SYSTEM_PROMPT
255
+ }
256
+ ]
257
+ },
258
+ contents: [
259
+ {
260
+ role: "user",
261
+ parts: [
262
+ {
263
+ text: prompt
264
+ }
265
+ ]
266
+ }
267
+ ],
268
+ generationConfig: {
269
+ temperature: 0.2
270
+ }
271
+ })
272
+ });
273
+
274
+ if (!response.ok) {
275
+ const errorText = await response.text();
276
+ throw new Error(`gemini request failed (${response.status}): ${errorText}`);
277
+ }
278
+
279
+ if (options.stream) {
280
+ const explanation = await consumeSseStream(response, extractGeminiText, options.onChunk);
281
+ return {
282
+ explanation,
283
+ responseMeta: {
284
+ provider: config.provider,
285
+ model: config.model,
286
+ cacheHit: false,
287
+ latencyMs: Date.now() - startedAt,
288
+ usage: null
289
+ }
290
+ };
291
+ }
292
+
293
+ const data = await response.json();
294
+ return {
295
+ explanation: extractGeminiText(data).trim() || "No explanation returned by the model.",
296
+ responseMeta: {
297
+ provider: config.provider,
298
+ model: config.model,
299
+ cacheHit: false,
300
+ latencyMs: Date.now() - startedAt,
301
+ usage: data.usageMetadata ?? null
302
+ }
303
+ };
304
+ }
305
+
306
+ export async function generateExplanation({
307
+ mode,
308
+ commitData,
309
+ providerOverride,
310
+ modelOverride,
311
+ maxDiffLines,
312
+ stream = false,
313
+ onChunk = null,
314
+ onStart = null
315
+ }) {
316
+ const config = getProviderConfig(providerOverride, modelOverride);
317
+ validateProviderConfig(config);
318
+
319
+ const { prompt, promptMeta } = buildPrompt(mode, commitData, { maxDiffLines });
320
+ onStart?.({
321
+ promptMeta,
322
+ provider: config.provider,
323
+ model: config.model
324
+ });
325
+
326
+ const cacheKey = createCacheKey({
327
+ targetRef: commitData.targetRef,
328
+ mode,
329
+ provider: config.provider,
330
+ model: config.model,
331
+ prompt
332
+ });
333
+ const cached = readCache(cacheKey);
334
+
335
+ if (cached) {
336
+ return {
337
+ explanation: cached.explanation,
338
+ promptMeta,
339
+ responseMeta: {
340
+ ...cached.responseMeta,
341
+ cacheHit: true
342
+ }
343
+ };
344
+ }
345
+
346
+ const requestOptions = { stream, onChunk };
347
+ const result =
348
+ config.provider === "gemini"
349
+ ? await requestGemini(config, prompt, requestOptions)
350
+ : await requestOpenAICompatible(config, prompt, requestOptions);
351
+
352
+ writeCache(cacheKey, {
353
+ explanation: result.explanation,
354
+ responseMeta: result.responseMeta
355
+ });
356
+
357
+ return {
358
+ explanation: result.explanation,
359
+ promptMeta,
360
+ responseMeta: result.responseMeta
361
+ };
362
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+
6
+ function getCacheDir() {
7
+ return path.join(os.homedir(), ".gitxplain", "cache");
8
+ }
9
+
10
+ export function createCacheKey(parts) {
11
+ const hash = createHash("sha256");
12
+ hash.update(JSON.stringify(parts));
13
+ return hash.digest("hex");
14
+ }
15
+
16
+ function getCachePath(cacheKey) {
17
+ return path.join(getCacheDir(), `${cacheKey}.json`);
18
+ }
19
+
20
+ export function readCache(cacheKey) {
21
+ const filePath = getCachePath(cacheKey);
22
+ if (!existsSync(filePath)) {
23
+ return null;
24
+ }
25
+
26
+ try {
27
+ return JSON.parse(readFileSync(filePath, "utf8"));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export function writeCache(cacheKey, value) {
34
+ const dir = getCacheDir();
35
+ mkdirSync(dir, { recursive: true });
36
+ writeFileSync(getCachePath(cacheKey), JSON.stringify(value, null, 2), "utf8");
37
+ }
@@ -0,0 +1,28 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import process from "node:process";
3
+
4
+ function runClipboardCommand(command, args, input) {
5
+ execFileSync(command, args, {
6
+ input,
7
+ stdio: ["pipe", "ignore", "ignore"]
8
+ });
9
+ }
10
+
11
+ export function copyToClipboard(text) {
12
+ if (process.platform === "darwin") {
13
+ runClipboardCommand("pbcopy", [], text);
14
+ return;
15
+ }
16
+
17
+ if (process.platform === "win32") {
18
+ runClipboardCommand("clip.exe", [], text);
19
+ return;
20
+ }
21
+
22
+ try {
23
+ runClipboardCommand("wl-copy", [], text);
24
+ return;
25
+ } catch {
26
+ runClipboardCommand("xclip", ["-selection", "clipboard"], text);
27
+ }
28
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ function readJsonConfig(filePath) {
6
+ if (!existsSync(filePath)) {
7
+ return {};
8
+ }
9
+
10
+ try {
11
+ return JSON.parse(readFileSync(filePath, "utf8"));
12
+ } catch (error) {
13
+ throw new Error(`Failed to parse config file ${filePath}: ${error.message}`);
14
+ }
15
+ }
16
+
17
+ export function loadConfig(cwd) {
18
+ const homeDir = os.homedir();
19
+ const userConfigPath = path.join(homeDir, ".gitxplain", "config.json");
20
+ const projectConfigPath = path.join(cwd, ".gitxplainrc");
21
+ const projectJsonConfigPath = path.join(cwd, ".gitxplainrc.json");
22
+
23
+ return {
24
+ ...readJsonConfig(userConfigPath),
25
+ ...readJsonConfig(projectConfigPath),
26
+ ...readJsonConfig(projectJsonConfigPath)
27
+ };
28
+ }
@@ -0,0 +1,132 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export function runGitCommand(args, cwd) {
4
+ try {
5
+ return execFileSync("git", args, {
6
+ cwd,
7
+ encoding: "utf8",
8
+ stdio: ["ignore", "pipe", "pipe"]
9
+ }).trim();
10
+ } catch (error) {
11
+ const stderr = error.stderr?.toString().trim();
12
+ throw new Error(stderr || `Git command failed: git ${args.join(" ")}`);
13
+ }
14
+ }
15
+
16
+ export function isGitRepository(cwd) {
17
+ try {
18
+ return runGitCommand(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ function parseFilesChanged(raw) {
25
+ return raw
26
+ .split("\n")
27
+ .map((file) => file.trim())
28
+ .filter(Boolean);
29
+ }
30
+
31
+ function parseStatsLine(statsRaw) {
32
+ return (
33
+ statsRaw
34
+ .split("\n")
35
+ .map((line) => line.trim())
36
+ .find((line) => /changed|insertions?\(\+\)|deletions?\(-\)/.test(line)) ??
37
+ "No change statistics available."
38
+ );
39
+ }
40
+
41
+ function parseCommitLog(logRaw) {
42
+ return logRaw
43
+ .split("\n")
44
+ .filter(Boolean)
45
+ .map((line) => {
46
+ const [hash, subject, body = ""] = line.split("\u001f");
47
+ return { hash, subject, body };
48
+ });
49
+ }
50
+
51
+ function buildCommitMessage(commits) {
52
+ return commits
53
+ .map((commit) => `${commit.hash.slice(0, 7)} ${commit.subject}${commit.body ? `\n${commit.body}` : ""}`)
54
+ .join("\n\n");
55
+ }
56
+
57
+ function isRangeRef(ref) {
58
+ return ref.includes("..");
59
+ }
60
+
61
+ export function getDefaultBaseRef(cwd) {
62
+ for (const candidate of ["main", "master", "origin/main", "origin/master"]) {
63
+ try {
64
+ runGitCommand(["rev-parse", "--verify", candidate], cwd);
65
+ return candidate;
66
+ } catch {
67
+ continue;
68
+ }
69
+ }
70
+
71
+ throw new Error("Could not detect a default base branch. Pass --branch <base-ref> explicitly.");
72
+ }
73
+
74
+ export function buildBranchRange(baseRef, cwd) {
75
+ const mergeBase = runGitCommand(["merge-base", baseRef, "HEAD"], cwd);
76
+ return `${mergeBase}..HEAD`;
77
+ }
78
+
79
+ function fetchSingleCommitData(commitId, cwd, runner) {
80
+ const commitMessage = runner(["log", "-1", "--pretty=format:%B", commitId], cwd);
81
+ const diff = runner(["diff", `${commitId}^!`], cwd);
82
+ const filesChangedRaw = runner(["show", "--pretty=format:", "--name-only", commitId], cwd);
83
+ const statsRaw = runner(["show", "--stat", "--oneline", "--format=%h %s", commitId], cwd);
84
+ const subject = runner(["log", "-1", "--pretty=format:%s", commitId], cwd);
85
+
86
+ return {
87
+ analysisType: "commit",
88
+ targetRef: commitId,
89
+ displayRef: commitId,
90
+ commitId,
91
+ commitCount: 1,
92
+ commits: [{ hash: commitId, subject, body: commitMessage }],
93
+ commitMessage,
94
+ diff,
95
+ filesChanged: parseFilesChanged(filesChangedRaw),
96
+ stats: parseStatsLine(statsRaw)
97
+ };
98
+ }
99
+
100
+ function fetchRangeData(rangeRef, cwd, runner) {
101
+ const diff = runner(["diff", rangeRef], cwd);
102
+ const filesChangedRaw = runner(["diff", "--name-only", rangeRef], cwd);
103
+ const statsRaw = runner(["diff", "--stat", rangeRef], cwd);
104
+ const commitLogRaw = runner(
105
+ ["log", "--reverse", "--pretty=format:%H%x1f%s%x1f%B", rangeRef],
106
+ cwd
107
+ );
108
+
109
+ const commits = parseCommitLog(commitLogRaw);
110
+ if (commits.length === 0) {
111
+ throw new Error(`No commits found in range ${rangeRef}`);
112
+ }
113
+
114
+ return {
115
+ analysisType: "range",
116
+ targetRef: rangeRef,
117
+ displayRef: rangeRef,
118
+ commitId: null,
119
+ commitCount: commits.length,
120
+ commits,
121
+ commitMessage: buildCommitMessage(commits),
122
+ diff,
123
+ filesChanged: parseFilesChanged(filesChangedRaw),
124
+ stats: parseStatsLine(statsRaw)
125
+ };
126
+ }
127
+
128
+ export function fetchCommitData(targetRef, cwd, runner = runGitCommand) {
129
+ return isRangeRef(targetRef)
130
+ ? fetchRangeData(targetRef, cwd, runner)
131
+ : fetchSingleCommitData(targetRef, cwd, runner);
132
+ }
@@ -0,0 +1,21 @@
1
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { runGitCommand } from "./gitService.js";
4
+
5
+ export function installHook({ cwd, hookName = "post-commit" }) {
6
+ const gitDir = runGitCommand(["rev-parse", "--git-dir"], cwd);
7
+ const hookDir = path.resolve(cwd, gitDir, "hooks");
8
+ const outputDir = path.resolve(cwd, gitDir, "gitxplain");
9
+ const hookPath = path.join(hookDir, hookName);
10
+
11
+ mkdirSync(hookDir, { recursive: true });
12
+ mkdirSync(outputDir, { recursive: true });
13
+
14
+ const script = `#!/bin/sh
15
+ gitxplain HEAD --summary --markdown --quiet > "${path.join(outputDir, "last-explanation.md")}" 2>/dev/null || true
16
+ `;
17
+
18
+ writeFileSync(hookPath, script, "utf8");
19
+ chmodSync(hookPath, 0o755);
20
+ return hookPath;
21
+ }