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.
- package/.env.example +28 -0
- package/README.md +268 -0
- package/cli/index.js +413 -0
- package/cli/services/aiService.js +362 -0
- package/cli/services/cacheService.js +37 -0
- package/cli/services/clipboardService.js +28 -0
- package/cli/services/configService.js +28 -0
- package/cli/services/gitService.js +132 -0
- package/cli/services/hookService.js +21 -0
- package/cli/services/outputFormatter.js +197 -0
- package/cli/services/promptService.js +83 -0
- package/package.json +21 -0
- package/prompts/impact.txt +15 -0
- package/prompts/issue.txt +18 -0
- package/prompts/junior.txt +15 -0
- package/prompts/lines.txt +22 -0
- package/prompts/master.txt +38 -0
- package/prompts/review.txt +26 -0
- package/prompts/security.txt +33 -0
- package/prompts/summary.txt +10 -0
|
@@ -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
|
+
}
|