gitxplain 0.1.0 → 0.1.3
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/IMPLEMENTATION.md +225 -0
- package/README.md +154 -0
- package/cli/index.js +446 -19
- package/cli/services/aiService.js +2 -2
- package/cli/services/chatService.js +663 -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 +590 -0
- package/cli/services/mergeService.js +609 -0
- package/cli/services/outputFormatter.js +217 -11
- 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
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import {
|
|
3
|
+
deletePaths,
|
|
4
|
+
fetchWorkingTreeData,
|
|
5
|
+
getCurrentHeadSha,
|
|
6
|
+
gitAddAll,
|
|
7
|
+
gitAddFiles,
|
|
8
|
+
gitCommit,
|
|
9
|
+
gitResetHard,
|
|
10
|
+
gitStashApply,
|
|
11
|
+
gitStashDrop,
|
|
12
|
+
gitStashPush,
|
|
13
|
+
gitUnstageAll,
|
|
14
|
+
getLatestStashRef,
|
|
15
|
+
hasStagedChanges,
|
|
16
|
+
isWorkingTreeClean,
|
|
17
|
+
pathExistsInRef,
|
|
18
|
+
resolveTreeSha,
|
|
19
|
+
writeCurrentIndexTree
|
|
20
|
+
} from "./gitService.js";
|
|
21
|
+
|
|
22
|
+
const ANSI = {
|
|
23
|
+
reset: "\u001b[0m",
|
|
24
|
+
bold: "\u001b[1m",
|
|
25
|
+
cyan: "\u001b[36m",
|
|
26
|
+
yellow: "\u001b[33m",
|
|
27
|
+
green: "\u001b[32m"
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function supportsColor() {
|
|
31
|
+
return Boolean(process.stdout?.isTTY) && process.env.NO_COLOR == null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function colorize(text, color) {
|
|
35
|
+
if (!supportsColor()) {
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return `${color}${text}${ANSI.reset}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractJsonPayload(explanation) {
|
|
43
|
+
const fencedMatch = explanation.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
44
|
+
if (fencedMatch) {
|
|
45
|
+
return fencedMatch[1].trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const startIndex = explanation.indexOf("{");
|
|
49
|
+
const endIndex = explanation.lastIndexOf("}");
|
|
50
|
+
|
|
51
|
+
if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
|
|
52
|
+
throw new Error("Failed to parse commit plan: no JSON object found in model response.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return explanation.slice(startIndex, endIndex + 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isNonEmptyString(value) {
|
|
59
|
+
return typeof value === "string" && value.trim() !== "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validateCommitEntry(entry, index) {
|
|
63
|
+
if (typeof entry !== "object" || entry == null || Array.isArray(entry)) {
|
|
64
|
+
throw new Error(`Failed to parse commit plan: commit ${index + 1} must be an object.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!Number.isInteger(entry.order)) {
|
|
68
|
+
throw new Error(`Failed to parse commit plan: commit ${index + 1} is missing a numeric order.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!isNonEmptyString(entry.message)) {
|
|
72
|
+
throw new Error(`Failed to parse commit plan: commit ${index + 1} is missing a message.`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!Array.isArray(entry.files) || !entry.files.every(isNonEmptyString)) {
|
|
76
|
+
throw new Error(`Failed to parse commit plan: commit ${index + 1} must include a files array.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!isNonEmptyString(entry.description)) {
|
|
80
|
+
throw new Error(`Failed to parse commit plan: commit ${index + 1} is missing a description.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateUniqueFileAssignments(commits, errorPrefix) {
|
|
85
|
+
const seenFiles = new Map();
|
|
86
|
+
|
|
87
|
+
for (const commit of commits) {
|
|
88
|
+
for (const file of commit.files) {
|
|
89
|
+
const previousOrder = seenFiles.get(file);
|
|
90
|
+
if (previousOrder != null) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`${errorPrefix}: file "${file}" appears in both commit ${previousOrder} and commit ${commit.order}.`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
seenFiles.set(file, commit.order);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeCommitPlan(plan) {
|
|
102
|
+
const seenFiles = new Set();
|
|
103
|
+
const normalizedCommits = [];
|
|
104
|
+
const dedupedFiles = [];
|
|
105
|
+
|
|
106
|
+
for (const commit of sortPlanCommits(plan)) {
|
|
107
|
+
const files = [];
|
|
108
|
+
|
|
109
|
+
for (const file of commit.files) {
|
|
110
|
+
if (seenFiles.has(file)) {
|
|
111
|
+
dedupedFiles.push(file);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
seenFiles.add(file);
|
|
116
|
+
files.push(file);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (files.length === 0) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
normalizedCommits.push({
|
|
124
|
+
...commit,
|
|
125
|
+
files
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
...plan,
|
|
131
|
+
commits: normalizedCommits.map((commit, index) => ({
|
|
132
|
+
...commit,
|
|
133
|
+
order: index + 1
|
|
134
|
+
})),
|
|
135
|
+
warnings:
|
|
136
|
+
dedupedFiles.length > 0
|
|
137
|
+
? [
|
|
138
|
+
`Duplicate file assignments were removed from later commit groups: ${[...new Set(dedupedFiles)].join(", ")}.`
|
|
139
|
+
]
|
|
140
|
+
: []
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sortPlanCommits(plan) {
|
|
145
|
+
return [...plan.commits].sort((left, right) => left.order - right.order);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getPlanFiles(plan) {
|
|
149
|
+
return [...new Set(sortPlanCommits(plan).flatMap((commit) => commit.files))];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function summarizeFileKinds(files) {
|
|
153
|
+
if (files.every((file) => file.startsWith("test/") || file.endsWith(".test.js"))) {
|
|
154
|
+
return {
|
|
155
|
+
message: "test: include remaining test updates",
|
|
156
|
+
description: "Captures remaining test file changes that were not assigned to an earlier commit."
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (files.every((file) => file.startsWith("docs/") || file.toLowerCase() === "readme.md")) {
|
|
161
|
+
return {
|
|
162
|
+
message: "docs: include remaining documentation updates",
|
|
163
|
+
description: "Captures remaining documentation changes that were not assigned to an earlier commit."
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
message: "chore: include remaining working tree changes",
|
|
169
|
+
description: "Captures files that were changed in the working tree but were not assigned to an earlier commit group."
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildCoverageDetails(plan, cwd) {
|
|
174
|
+
const workingTreeData = fetchWorkingTreeData(cwd);
|
|
175
|
+
const changedFiles = new Set(workingTreeData.filesChanged);
|
|
176
|
+
const plannedFiles = new Set(getPlanFiles(plan));
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
changedFiles: workingTreeData.filesChanged,
|
|
180
|
+
missingFiles: [...changedFiles].filter((file) => !plannedFiles.has(file)),
|
|
181
|
+
extraFiles: [...plannedFiles].filter((file) => !changedFiles.has(file))
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function reconcileCommitPlan(plan, cwd) {
|
|
186
|
+
const { missingFiles, extraFiles } = buildCoverageDetails(plan, cwd);
|
|
187
|
+
const warnings = [...(plan.warnings ?? [])];
|
|
188
|
+
let commits = sortPlanCommits(plan).map((commit) => ({ ...commit, files: [...commit.files] }));
|
|
189
|
+
|
|
190
|
+
if (extraFiles.length > 0) {
|
|
191
|
+
warnings.push(`Files not present in the working tree were removed from the plan: ${extraFiles.join(", ")}.`);
|
|
192
|
+
commits = commits
|
|
193
|
+
.map((commit) => ({
|
|
194
|
+
...commit,
|
|
195
|
+
files: commit.files.filter((file) => !extraFiles.includes(file))
|
|
196
|
+
}))
|
|
197
|
+
.filter((commit) => commit.files.length > 0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (missingFiles.length > 0) {
|
|
201
|
+
const fallback = summarizeFileKinds(missingFiles);
|
|
202
|
+
warnings.push(`Missing files were added to a final fallback commit: ${missingFiles.join(", ")}.`);
|
|
203
|
+
commits.push({
|
|
204
|
+
order: commits.length + 1,
|
|
205
|
+
message: fallback.message,
|
|
206
|
+
files: missingFiles,
|
|
207
|
+
description: fallback.description
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
...plan,
|
|
213
|
+
commits: commits.map((commit, index) => ({ ...commit, order: index + 1 })),
|
|
214
|
+
warnings
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validatePlanCoverage(plan, cwd) {
|
|
219
|
+
const { missingFiles, extraFiles } = buildCoverageDetails(plan, cwd);
|
|
220
|
+
|
|
221
|
+
if (missingFiles.length === 0 && extraFiles.length === 0) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const details = [];
|
|
226
|
+
if (missingFiles.length > 0) {
|
|
227
|
+
details.push(`Missing files: ${missingFiles.join(", ")}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (extraFiles.length > 0) {
|
|
231
|
+
details.push(`Unexpected files: ${extraFiles.join(", ")}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new Error(`Commit plan must cover each changed file exactly once. ${details.join(". ")}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function getFilesAbsentFromRef(files, ref, cwd) {
|
|
238
|
+
return files.filter((file) => !pathExistsInRef(ref, file, cwd));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildRecoveryMessage(originalHeadSha, stashRef) {
|
|
242
|
+
const lines = [
|
|
243
|
+
"Commit execution failed. To recover:",
|
|
244
|
+
`- Reset back to the original HEAD with \`git reset --hard ${originalHeadSha}\``
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
if (stashRef) {
|
|
248
|
+
lines.push(`- Reapply your original working tree with \`git stash apply --index ${stashRef}\``);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return lines.join("\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function parseCommitPlan(explanation) {
|
|
255
|
+
let parsed;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
parsed = JSON.parse(extractJsonPayload(explanation));
|
|
259
|
+
} catch (error) {
|
|
260
|
+
throw new Error(`Failed to parse commit plan JSON: ${error.message}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
|
|
264
|
+
throw new Error("Failed to parse commit plan: top-level JSON must be an object.");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!Object.hasOwn(parsed, "working_tree_summary") || typeof parsed.working_tree_summary !== "string") {
|
|
268
|
+
throw new Error("Failed to parse commit plan: missing working_tree_summary string.");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (
|
|
272
|
+
!Object.hasOwn(parsed, "reason_to_commit") ||
|
|
273
|
+
(parsed.reason_to_commit !== null && typeof parsed.reason_to_commit !== "string")
|
|
274
|
+
) {
|
|
275
|
+
throw new Error("Failed to parse commit plan: reason_to_commit must be a string or null.");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!Array.isArray(parsed.commits)) {
|
|
279
|
+
throw new Error("Failed to parse commit plan: commits must be an array.");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
parsed.commits.forEach(validateCommitEntry);
|
|
283
|
+
return normalizeCommitPlan(parsed);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function formatCommitPlan(plan) {
|
|
287
|
+
const lines = [
|
|
288
|
+
colorize("Commit Plan", ANSI.bold + ANSI.cyan),
|
|
289
|
+
`${colorize("Working Tree Summary:", ANSI.bold + ANSI.cyan)} ${plan.working_tree_summary}`,
|
|
290
|
+
`${colorize("Reason To Commit:", ANSI.bold + ANSI.cyan)} ${plan.reason_to_commit ?? "No commit needed"}`
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
if (plan.warnings?.length) {
|
|
294
|
+
lines.push(...plan.warnings.map((warning) => `${colorize("Warning:", ANSI.bold + ANSI.yellow)} ${warning}`));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (plan.commits.length === 0) {
|
|
298
|
+
lines.push(colorize("No commit recommended.", ANSI.green));
|
|
299
|
+
return lines.join("\n");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const commit of sortPlanCommits(plan)) {
|
|
303
|
+
lines.push("");
|
|
304
|
+
lines.push(colorize(`${commit.order}. ${commit.message}`, ANSI.bold + ANSI.yellow));
|
|
305
|
+
lines.push(`${colorize("Files:", ANSI.bold + ANSI.cyan)} ${commit.files.join(", ")}`);
|
|
306
|
+
lines.push(`${colorize("Why:", ANSI.bold + ANSI.cyan)} ${commit.description}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return lines.join("\n");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function executeCommitPlan(plan, cwd) {
|
|
313
|
+
if (isWorkingTreeClean(cwd)) {
|
|
314
|
+
throw new Error("Working tree is already clean. Nothing to commit.");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
validatePlanCoverage(plan, cwd);
|
|
318
|
+
|
|
319
|
+
const originalHeadSha = getCurrentHeadSha(cwd);
|
|
320
|
+
const newFiles = getFilesAbsentFromRef(getPlanFiles(plan), originalHeadSha, cwd);
|
|
321
|
+
let stashRef = null;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
gitStashPush("gitxplain-autocommit-backup", cwd);
|
|
325
|
+
stashRef = getLatestStashRef(cwd);
|
|
326
|
+
|
|
327
|
+
if (!stashRef) {
|
|
328
|
+
throw new Error("Failed to create a backup stash before committing.");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
gitStashApply(stashRef, cwd);
|
|
332
|
+
gitAddAll(cwd);
|
|
333
|
+
const expectedTreeSha = writeCurrentIndexTree(cwd);
|
|
334
|
+
gitUnstageAll(cwd);
|
|
335
|
+
|
|
336
|
+
for (const commit of sortPlanCommits(plan)) {
|
|
337
|
+
gitAddFiles(commit.files, cwd);
|
|
338
|
+
|
|
339
|
+
if (!hasStagedChanges(cwd)) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`Commit plan execution failed: commit ${commit.order} (${commit.message}) does not stage any new changes.`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
gitCommit(commit.message, cwd);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const finalHeadTreeSha = resolveTreeSha("HEAD", cwd);
|
|
349
|
+
if (finalHeadTreeSha !== expectedTreeSha) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
"Commit verification failed: the rewritten HEAD tree does not match the original working tree."
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
gitStashDrop(stashRef, cwd);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
gitResetHard(originalHeadSha, cwd);
|
|
358
|
+
|
|
359
|
+
if (newFiles.length > 0) {
|
|
360
|
+
try {
|
|
361
|
+
deletePaths(newFiles, cwd);
|
|
362
|
+
} catch {
|
|
363
|
+
console.error("Failed to remove temporary untracked files created during commit execution.");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (stashRef) {
|
|
368
|
+
try {
|
|
369
|
+
gitStashApply(stashRef, cwd);
|
|
370
|
+
} catch {
|
|
371
|
+
console.error("Failed to automatically restore the original working tree from the backup stash.");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
console.error(error.message);
|
|
376
|
+
console.error(buildRecoveryMessage(originalHeadSha, stashRef));
|
|
377
|
+
throw new Error("Commit execution aborted.");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export function loadEnvFile() {
|
|
6
|
+
try {
|
|
7
|
+
// Get the directory of the CLI installation
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const cliDir = path.dirname(__filename);
|
|
10
|
+
const projectDir = path.dirname(path.dirname(cliDir));
|
|
11
|
+
const envPath = path.join(projectDir, ".env");
|
|
12
|
+
|
|
13
|
+
// Also check current working directory
|
|
14
|
+
const cwdEnvPath = path.join(process.cwd(), ".env");
|
|
15
|
+
const finalEnvPath = fs.existsSync(envPath) ? envPath : (fs.existsSync(cwdEnvPath) ? cwdEnvPath : null);
|
|
16
|
+
|
|
17
|
+
if (finalEnvPath && fs.existsSync(finalEnvPath)) {
|
|
18
|
+
const envContent = fs.readFileSync(finalEnvPath, "utf8");
|
|
19
|
+
envContent.split("\n").forEach((line) => {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (trimmed && !trimmed.startsWith("#")) {
|
|
22
|
+
const [key, ...valueParts] = trimmed.split("=");
|
|
23
|
+
const value = valueParts.join("=").replace(/^["']|["']$/g, "").trim();
|
|
24
|
+
if (key && value && !process.env[key]) {
|
|
25
|
+
process.env[key] = value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Silently ignore if .env file doesn't exist or can't be read
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = path.join(os.homedir(), ".gitxplain");
|
|
7
|
+
const CONNECTION_FILE = path.join(CONFIG_DIR, "git-connection.json");
|
|
8
|
+
|
|
9
|
+
function ensureConfigDir() {
|
|
10
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
11
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function saveGitConnection(token, provider = "github", userInfo = null) {
|
|
16
|
+
ensureConfigDir();
|
|
17
|
+
const connection = {
|
|
18
|
+
token,
|
|
19
|
+
provider,
|
|
20
|
+
user: userInfo || {},
|
|
21
|
+
connectedAt: new Date().toISOString()
|
|
22
|
+
};
|
|
23
|
+
fs.writeFileSync(CONNECTION_FILE, JSON.stringify(connection, null, 2), "utf8");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadGitConnection() {
|
|
27
|
+
try {
|
|
28
|
+
if (fs.existsSync(CONNECTION_FILE)) {
|
|
29
|
+
const data = fs.readFileSync(CONNECTION_FILE, "utf8");
|
|
30
|
+
return JSON.parse(data);
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// If file is corrupted, return null
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isGitConnected() {
|
|
40
|
+
return loadGitConnection() !== null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function clearGitConnection() {
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(CONNECTION_FILE)) {
|
|
46
|
+
fs.unlinkSync(CONNECTION_FILE);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// Ignore errors during cleanup
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getGitUserInfo() {
|
|
54
|
+
try {
|
|
55
|
+
const name = execFileSync("git", ["config", "user.name"], {
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
58
|
+
}).trim();
|
|
59
|
+
const email = execFileSync("git", ["config", "user.email"], {
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
62
|
+
}).trim();
|
|
63
|
+
return { name, email };
|
|
64
|
+
} catch {
|
|
65
|
+
return { name: "Unknown", email: "unknown@example.com" };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function verifyGitToken(token, provider = "github") {
|
|
70
|
+
try {
|
|
71
|
+
if (provider === "github") {
|
|
72
|
+
const response = await fetch("https://api.github.com/user", {
|
|
73
|
+
headers: {
|
|
74
|
+
Authorization: `Bearer ${token}`,
|
|
75
|
+
Accept: "application/vnd.github.v3+json",
|
|
76
|
+
"User-Agent": "gitxplain"
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const errorData = await response.json();
|
|
82
|
+
throw new Error(
|
|
83
|
+
errorData.message || `GitHub API returned status ${response.status}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return await response.json();
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function fetchGitHubRepositories(token) {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch("https://api.github.com/user/repos?per_page=30&sort=updated", {
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Bearer ${token}`,
|
|
99
|
+
Accept: "application/vnd.github.v3+json",
|
|
100
|
+
"User-Agent": "gitxplain"
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new Error(`Failed to fetch repositories: ${response.statusText}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const repos = await response.json();
|
|
109
|
+
return repos.map((repo) => ({
|
|
110
|
+
name: repo.name,
|
|
111
|
+
owner: repo.owner.login,
|
|
112
|
+
description: repo.description,
|
|
113
|
+
url: repo.html_url,
|
|
114
|
+
language: repo.language,
|
|
115
|
+
stars: repo.stargazers_count,
|
|
116
|
+
updated_at: repo.updated_at
|
|
117
|
+
}));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new Error(`Failed to fetch GitHub repositories: ${error.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function fetchGitHubCommits(token, owner, repo) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=15`, {
|
|
126
|
+
headers: {
|
|
127
|
+
Authorization: `Bearer ${token}`,
|
|
128
|
+
Accept: "application/vnd.github.v3+json",
|
|
129
|
+
"User-Agent": "gitxplain"
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(`Failed to fetch commits: ${response.statusText}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const commits = await response.json();
|
|
138
|
+
return commits.map((commitData) => ({
|
|
139
|
+
sha: commitData.sha.substring(0, 7),
|
|
140
|
+
fullSha: commitData.sha,
|
|
141
|
+
message: commitData.commit.message.split('\n')[0],
|
|
142
|
+
author: commitData.commit.author.name,
|
|
143
|
+
date: commitData.commit.author.date
|
|
144
|
+
}));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw new Error(`Failed to fetch GitHub commits: ${error.message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function fetchCommitDetails(token, owner, repo, sha) {
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits/${sha}`, {
|
|
153
|
+
headers: {
|
|
154
|
+
Authorization: `Bearer ${token}`,
|
|
155
|
+
Accept: "application/vnd.github.v3+json",
|
|
156
|
+
"User-Agent": "gitxplain"
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new Error(`Failed to fetch commit details: ${response.statusText}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const data = await response.json();
|
|
165
|
+
return {
|
|
166
|
+
sha: data.sha,
|
|
167
|
+
stats: data.stats,
|
|
168
|
+
files: (data.files || []).map(f => ({
|
|
169
|
+
filename: f.filename,
|
|
170
|
+
status: f.status,
|
|
171
|
+
additions: f.additions,
|
|
172
|
+
deletions: f.deletions,
|
|
173
|
+
patch: f.patch || "No patch available or binary file"
|
|
174
|
+
}))
|
|
175
|
+
};
|
|
176
|
+
} catch (error) {
|
|
177
|
+
throw new Error(`Failed to fetch commit details: ${error.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function fetchRepoTree(token, owner, repo, sha) {
|
|
182
|
+
try {
|
|
183
|
+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${sha}?recursive=1`, {
|
|
184
|
+
headers: {
|
|
185
|
+
Authorization: `Bearer ${token}`,
|
|
186
|
+
Accept: "application/vnd.github.v3+json",
|
|
187
|
+
"User-Agent": "gitxplain"
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
throw new Error(`Failed to fetch repo tree: ${response.statusText}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const data = await response.json();
|
|
196
|
+
const paths = (data.tree || [])
|
|
197
|
+
.filter(t => t.type === 'blob' || t.type === 'tree')
|
|
198
|
+
.map(t => t.path);
|
|
199
|
+
|
|
200
|
+
if (paths.length > 200) {
|
|
201
|
+
return [...paths.slice(0, 200), `... and ${paths.length - 200} more items truncated`];
|
|
202
|
+
}
|
|
203
|
+
return paths;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return ["Failed to load repository file structure."];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function downloadCommitArchive(token, owner, repo, sha, destPath) {
|
|
210
|
+
try {
|
|
211
|
+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/zipball/${sha}`, {
|
|
212
|
+
headers: {
|
|
213
|
+
Authorization: `Bearer ${token}`,
|
|
214
|
+
Accept: "application/vnd.github.v3+json",
|
|
215
|
+
"User-Agent": "gitxplain"
|
|
216
|
+
},
|
|
217
|
+
redirect: 'follow'
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
throw new Error(`Failed to fetch zipball: ${response.statusText}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
225
|
+
fs.writeFileSync(destPath, Buffer.from(arrayBuffer));
|
|
226
|
+
return true;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
throw new Error(`Failed to download archive: ${error.message}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function fetchFileContent(token, owner, repo, sha, filePath) {
|
|
233
|
+
try {
|
|
234
|
+
const response = await fetch(`https://raw.githubusercontent.com/${owner}/${repo}/${sha}/${filePath}`, {
|
|
235
|
+
headers: {
|
|
236
|
+
Authorization: `Bearer ${token}`,
|
|
237
|
+
"User-Agent": "gitxplain"
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
if (!response.ok) throw new Error(`Failed to fetch file: ${response.statusText}`);
|
|
241
|
+
return await response.text();
|
|
242
|
+
} catch (error) {
|
|
243
|
+
throw new Error(`Failed to fetch file content: ${error.message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function fetchRepoIssues(token, owner, repo) {
|
|
248
|
+
try {
|
|
249
|
+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues?state=open&per_page=10`, {
|
|
250
|
+
headers: {
|
|
251
|
+
Authorization: `Bearer ${token}`,
|
|
252
|
+
Accept: "application/vnd.github.v3+json",
|
|
253
|
+
"User-Agent": "gitxplain"
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
if (!response.ok) throw new Error(`Failed to fetch issues: ${response.statusText}`);
|
|
257
|
+
const issues = await response.json();
|
|
258
|
+
return issues.map(i => ({
|
|
259
|
+
number: i.number,
|
|
260
|
+
title: i.title,
|
|
261
|
+
user: i.user.login,
|
|
262
|
+
url: i.html_url
|
|
263
|
+
}));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
throw new Error(`Failed to fetch issues: ${error.message}`);
|
|
266
|
+
}
|
|
267
|
+
}
|