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.
@@ -0,0 +1,472 @@
1
+ import process from "node:process";
2
+ import {
3
+ createCommitFromTree,
4
+ deletePaths,
5
+ getCommitMetadata,
6
+ getCommitParents,
7
+ getCurrentBranchName,
8
+ getCurrentHeadSha,
9
+ gitCherryPick,
10
+ gitCherryPickAbort,
11
+ gitCherryPickNoCommit,
12
+ gitAddFiles,
13
+ gitCreateBranch,
14
+ gitCheckout,
15
+ gitCheckoutDetached,
16
+ gitCheckoutOrphan,
17
+ gitCommit,
18
+ gitDeleteBranch,
19
+ gitForceBranch,
20
+ gitRemoveCachedAll,
21
+ gitRebaseAbort,
22
+ gitRebaseRebaseMergesOnto,
23
+ gitResetHard,
24
+ gitUnstageAll,
25
+ hasStagedChanges,
26
+ isAncestorCommit,
27
+ isWorkingTreeClean,
28
+ listCommitsAfter,
29
+ listCommitsAfterTopo,
30
+ listFilesInRef,
31
+ resolveTreeSha,
32
+ runGitCommandUnchecked,
33
+ resolveCommitSha
34
+ } from "./gitService.js";
35
+
36
+ const ANSI = {
37
+ reset: "\u001b[0m",
38
+ bold: "\u001b[1m",
39
+ cyan: "\u001b[36m",
40
+ yellow: "\u001b[33m",
41
+ green: "\u001b[32m"
42
+ };
43
+
44
+ function supportsColor() {
45
+ return Boolean(process.stdout?.isTTY) && process.env.NO_COLOR == null;
46
+ }
47
+
48
+ function colorize(text, color) {
49
+ if (!supportsColor()) {
50
+ return text;
51
+ }
52
+
53
+ return `${color}${text}${ANSI.reset}`;
54
+ }
55
+
56
+ function extractJsonPayload(explanation) {
57
+ const fencedMatch = explanation.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
58
+ if (fencedMatch) {
59
+ return fencedMatch[1].trim();
60
+ }
61
+
62
+ const startIndex = explanation.indexOf("{");
63
+ const endIndex = explanation.lastIndexOf("}");
64
+
65
+ if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
66
+ throw new Error("Failed to parse split plan: no JSON object found in model response.");
67
+ }
68
+
69
+ return explanation.slice(startIndex, endIndex + 1);
70
+ }
71
+
72
+ function isNonEmptyString(value) {
73
+ return typeof value === "string" && value.trim() !== "";
74
+ }
75
+
76
+ function validateCommitEntry(entry, index) {
77
+ if (typeof entry !== "object" || entry == null || Array.isArray(entry)) {
78
+ throw new Error(`Failed to parse split plan: commit ${index + 1} must be an object.`);
79
+ }
80
+
81
+ if (!Number.isInteger(entry.order)) {
82
+ throw new Error(`Failed to parse split plan: commit ${index + 1} is missing a numeric order.`);
83
+ }
84
+
85
+ if (!isNonEmptyString(entry.message)) {
86
+ throw new Error(`Failed to parse split plan: commit ${index + 1} is missing a message.`);
87
+ }
88
+
89
+ if (!Array.isArray(entry.files) || !entry.files.every(isNonEmptyString)) {
90
+ throw new Error(`Failed to parse split plan: commit ${index + 1} must include a files array.`);
91
+ }
92
+
93
+ if (!isNonEmptyString(entry.description)) {
94
+ throw new Error(`Failed to parse split plan: commit ${index + 1} is missing a description.`);
95
+ }
96
+ }
97
+
98
+ function sortPlanCommits(plan) {
99
+ return [...plan.commits].sort((left, right) => left.order - right.order);
100
+ }
101
+
102
+ function normalizeSplitPlan(plan) {
103
+ const seenFiles = new Set();
104
+ const normalizedCommits = [];
105
+ const dedupedFiles = [];
106
+
107
+ for (const commit of sortPlanCommits(plan)) {
108
+ const files = [];
109
+
110
+ for (const file of commit.files) {
111
+ if (seenFiles.has(file)) {
112
+ dedupedFiles.push(file);
113
+ continue;
114
+ }
115
+
116
+ seenFiles.add(file);
117
+ files.push(file);
118
+ }
119
+
120
+ if (files.length === 0) {
121
+ continue;
122
+ }
123
+
124
+ normalizedCommits.push({
125
+ ...commit,
126
+ files
127
+ });
128
+ }
129
+
130
+ return {
131
+ ...plan,
132
+ commits: normalizedCommits.map((commit, index) => ({
133
+ ...commit,
134
+ order: index + 1
135
+ })),
136
+ warnings:
137
+ dedupedFiles.length > 0
138
+ ? [
139
+ `Duplicate file assignments were removed from later split groups: ${[...new Set(dedupedFiles)].join(", ")}.`
140
+ ]
141
+ : []
142
+ };
143
+ }
144
+
145
+ function getPlanFiles(plan) {
146
+ return [...new Set(sortPlanCommits(plan).flatMap((commit) => commit.files))];
147
+ }
148
+
149
+ function summarizeFileKinds(files) {
150
+ if (files.every((file) => file.startsWith("test/") || file.endsWith(".test.js"))) {
151
+ return {
152
+ message: "test: include remaining test updates",
153
+ description: "Captures remaining test file changes that were not assigned to an earlier split group."
154
+ };
155
+ }
156
+
157
+ if (files.every((file) => file.startsWith("docs/") || file.toLowerCase() === "readme.md")) {
158
+ return {
159
+ message: "docs: include remaining documentation updates",
160
+ description: "Captures remaining documentation changes that were not assigned to an earlier split group."
161
+ };
162
+ }
163
+
164
+ return {
165
+ message: "chore: include remaining commit changes",
166
+ description: "Captures files from the original commit that were not assigned to an earlier split group."
167
+ };
168
+ }
169
+
170
+ export function parseSplitPlan(explanation) {
171
+ let parsed;
172
+
173
+ try {
174
+ parsed = JSON.parse(extractJsonPayload(explanation));
175
+ } catch (error) {
176
+ throw new Error(`Failed to parse split plan JSON: ${error.message}`);
177
+ }
178
+
179
+ if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
180
+ throw new Error("Failed to parse split plan: top-level JSON must be an object.");
181
+ }
182
+
183
+ if (!Object.hasOwn(parsed, "original_summary") || typeof parsed.original_summary !== "string") {
184
+ throw new Error("Failed to parse split plan: missing original_summary string.");
185
+ }
186
+
187
+ if (
188
+ !Object.hasOwn(parsed, "reason_to_split") ||
189
+ (parsed.reason_to_split !== null && typeof parsed.reason_to_split !== "string")
190
+ ) {
191
+ throw new Error("Failed to parse split plan: reason_to_split must be a string or null.");
192
+ }
193
+
194
+ if (!Array.isArray(parsed.commits)) {
195
+ throw new Error("Failed to parse split plan: commits must be an array.");
196
+ }
197
+
198
+ parsed.commits.forEach(validateCommitEntry);
199
+
200
+ return normalizeSplitPlan(parsed);
201
+ }
202
+
203
+ export function reconcileSplitPlan(plan, filesChanged) {
204
+ const commitFiles = [...new Set(filesChanged)];
205
+ const commitFileSet = new Set(commitFiles);
206
+ const plannedFiles = getPlanFiles(plan);
207
+ const extraFiles = plannedFiles.filter((file) => !commitFileSet.has(file));
208
+ const missingFiles = commitFiles.filter((file) => !plannedFiles.includes(file));
209
+ const warnings = [...(plan.warnings ?? [])];
210
+ let commits = sortPlanCommits(plan).map((commit) => ({ ...commit, files: [...commit.files] }));
211
+
212
+ if (extraFiles.length > 0) {
213
+ warnings.push(`Files not present in the target commit were removed from the split plan: ${extraFiles.join(", ")}.`);
214
+ commits = commits
215
+ .map((commit) => ({
216
+ ...commit,
217
+ files: commit.files.filter((file) => !extraFiles.includes(file))
218
+ }))
219
+ .filter((commit) => commit.files.length > 0);
220
+ }
221
+
222
+ if (missingFiles.length > 0) {
223
+ const fallback = summarizeFileKinds(missingFiles);
224
+ warnings.push(`Missing files were added to a final fallback split group: ${missingFiles.join(", ")}.`);
225
+ commits.push({
226
+ order: commits.length + 1,
227
+ message: fallback.message,
228
+ files: missingFiles,
229
+ description: fallback.description
230
+ });
231
+ }
232
+
233
+ return {
234
+ ...plan,
235
+ commits: commits.map((commit, index) => ({ ...commit, order: index + 1 })),
236
+ warnings
237
+ };
238
+ }
239
+
240
+ export function formatSplitPlan(plan) {
241
+ const lines = [
242
+ colorize("Split Plan", ANSI.bold + ANSI.cyan),
243
+ `${colorize("Original Summary:", ANSI.bold + ANSI.cyan)} ${plan.original_summary}`,
244
+ `${colorize("Reason To Split:", ANSI.bold + ANSI.cyan)} ${plan.reason_to_split ?? "Already atomic"}`
245
+ ];
246
+
247
+ for (const warning of plan.warnings ?? []) {
248
+ lines.push(`${colorize("Warning:", ANSI.bold + ANSI.yellow)} ${warning}`);
249
+ }
250
+
251
+ if (plan.commits.length === 0) {
252
+ lines.push(colorize("No split recommended.", ANSI.green));
253
+ return lines.join("\n");
254
+ }
255
+
256
+ for (const commit of [...plan.commits].sort((left, right) => left.order - right.order)) {
257
+ lines.push("");
258
+ lines.push(colorize(`${commit.order}. ${commit.message}`, ANSI.bold + ANSI.yellow));
259
+ lines.push(`${colorize("Files:", ANSI.bold + ANSI.cyan)} ${commit.files.join(", ")}`);
260
+ lines.push(`${colorize("Why:", ANSI.bold + ANSI.cyan)} ${commit.description}`);
261
+ }
262
+
263
+ return lines.join("\n");
264
+ }
265
+
266
+ function buildRecoveryMessage(originalHeadSha) {
267
+ return [
268
+ "Split execution failed. To recover:",
269
+ `- Find the original HEAD in \`git reflog\` (expected SHA: ${originalHeadSha})`,
270
+ `- Restore it with \`git reset --hard ${originalHeadSha}\``
271
+ ].join("\n");
272
+ }
273
+
274
+ function getDirtyWorkingTreeSummary(cwd) {
275
+ const result = runGitCommandUnchecked(["status", "--short"], cwd);
276
+ if (result.exitCode !== 0 || result.stdout === "") {
277
+ return null;
278
+ }
279
+
280
+ return result.stdout
281
+ .split("\n")
282
+ .filter(Boolean)
283
+ .slice(0, 10)
284
+ .join("\n");
285
+ }
286
+
287
+ export function validateSplitExecutionTarget(
288
+ commitId,
289
+ cwd,
290
+ helpers = {
291
+ resolveCommitSha,
292
+ getCurrentHeadSha,
293
+ getCommitParents,
294
+ isAncestorCommit
295
+ }
296
+ ) {
297
+ const targetSha = helpers.resolveCommitSha(commitId, cwd);
298
+ const currentHeadSha = helpers.getCurrentHeadSha(cwd);
299
+
300
+ if (!helpers.isAncestorCommit(targetSha, currentHeadSha, cwd)) {
301
+ throw new Error(`Commit ${commitId} is not reachable from the current HEAD.`);
302
+ }
303
+
304
+ const parents = helpers.getCommitParents(targetSha, cwd);
305
+ if (parents.length > 1) {
306
+ throw new Error("Only non-merge commits can be split. Merge commits have multiple parents.");
307
+ }
308
+
309
+ return {
310
+ targetSha,
311
+ currentHeadSha,
312
+ parentSha: parents[0] ?? null,
313
+ isHeadTarget: targetSha === currentHeadSha
314
+ };
315
+ }
316
+
317
+ function createTempRootSplitBranchName() {
318
+ return `gitxplain-split-root-${Date.now()}`;
319
+ }
320
+
321
+ function restoreOriginalPointer(originalBranch, originalHeadSha, cwd) {
322
+ if (originalBranch === "HEAD") {
323
+ gitCheckoutDetached(originalHeadSha, cwd);
324
+ return;
325
+ }
326
+
327
+ gitCheckout(originalBranch, cwd);
328
+ gitResetHard(originalHeadSha, cwd);
329
+ }
330
+
331
+ function finalizeRootSplitBranch(tempBranch, originalBranch, rewrittenHeadSha, cwd) {
332
+ if (originalBranch === "HEAD") {
333
+ gitCheckoutDetached(rewrittenHeadSha, cwd);
334
+ gitDeleteBranch(tempBranch, cwd);
335
+ return;
336
+ }
337
+
338
+ gitForceBranch(originalBranch, rewrittenHeadSha, cwd);
339
+ gitCheckout(originalBranch, cwd);
340
+ gitDeleteBranch(tempBranch, cwd);
341
+ }
342
+
343
+ function replayDescendantsFromOriginalTrees(targetSha, originalHeadSha, splitTipSha, cwd) {
344
+ const descendantShas = listCommitsAfterTopo(targetSha, originalHeadSha, cwd);
345
+ const rewritten = new Map([[targetSha, splitTipSha]]);
346
+
347
+ for (const originalSha of descendantShas) {
348
+ const originalParents = getCommitParents(originalSha, cwd);
349
+ const rewrittenParents = originalParents.map((parentSha) => rewritten.get(parentSha) ?? parentSha);
350
+ const treeSha = resolveTreeSha(originalSha, cwd);
351
+ const metadata = getCommitMetadata(originalSha, cwd);
352
+ const rewrittenSha = createCommitFromTree(treeSha, rewrittenParents, metadata, cwd);
353
+ rewritten.set(originalSha, rewrittenSha);
354
+ }
355
+
356
+ return rewritten.get(originalHeadSha) ?? splitTipSha;
357
+ }
358
+
359
+ export function executeSplit(plan, commitId, cwd) {
360
+ const { targetSha, currentHeadSha, parentSha } = validateSplitExecutionTarget(commitId, cwd);
361
+ const originalHeadSha = currentHeadSha;
362
+ const originalTargetTreeSha = resolveTreeSha(targetSha, cwd);
363
+ const originalHeadTreeSha = resolveTreeSha("HEAD", cwd);
364
+ const orderedCommits = sortPlanCommits(plan);
365
+ const originalBranch = getCurrentBranchName(cwd);
366
+ let rootSplitTempBranch = null;
367
+ let rootSplitOriginalBranch = null;
368
+
369
+ try {
370
+ if (!isWorkingTreeClean(cwd)) {
371
+ const dirtySummary = getDirtyWorkingTreeSummary(cwd);
372
+ throw new Error(
373
+ dirtySummary
374
+ ? `Working tree must be clean before executing a split.\nUncommitted changes:\n${dirtySummary}`
375
+ : "Working tree must be clean before executing a split."
376
+ );
377
+ }
378
+
379
+ const commitsToReplay = listCommitsAfter(targetSha, originalHeadSha, cwd);
380
+
381
+ if (parentSha == null) {
382
+ const tempBranch = createTempRootSplitBranchName();
383
+ const originalHeadFiles = listFilesInRef("HEAD", cwd);
384
+ rootSplitOriginalBranch = originalBranch;
385
+ rootSplitTempBranch = tempBranch;
386
+
387
+ gitCheckoutOrphan(tempBranch, cwd);
388
+ gitRemoveCachedAll(cwd);
389
+ deletePaths(originalHeadFiles, cwd);
390
+ gitCherryPickNoCommit(targetSha, cwd);
391
+
392
+ for (const commit of orderedCommits) {
393
+ gitUnstageAll(cwd);
394
+ gitAddFiles(commit.files, cwd);
395
+
396
+ if (!hasStagedChanges(cwd)) {
397
+ throw new Error(
398
+ `Split plan execution failed: commit ${commit.order} (${commit.message}) does not stage any new changes.`
399
+ );
400
+ }
401
+
402
+ gitCommit(commit.message, cwd);
403
+ }
404
+ } else {
405
+ gitResetHard(parentSha, cwd);
406
+ gitCherryPickNoCommit(targetSha, cwd);
407
+
408
+ for (const commit of orderedCommits) {
409
+ gitUnstageAll(cwd);
410
+ gitAddFiles(commit.files, cwd);
411
+
412
+ if (!hasStagedChanges(cwd)) {
413
+ throw new Error(
414
+ `Split plan execution failed: commit ${commit.order} (${commit.message}) does not stage any new changes.`
415
+ );
416
+ }
417
+
418
+ gitCommit(commit.message, cwd);
419
+ }
420
+ }
421
+
422
+ const splitTipSha = getCurrentHeadSha(cwd);
423
+ const splitTipTreeSha = resolveTreeSha(splitTipSha, cwd);
424
+
425
+ if (splitTipTreeSha !== originalTargetTreeSha) {
426
+ throw new Error("Split verification failed: the split commit stack does not reproduce the original target commit tree.");
427
+ }
428
+
429
+ let rewrittenHeadSha = splitTipSha;
430
+ if (commitsToReplay.length > 0) {
431
+ rewrittenHeadSha = replayDescendantsFromOriginalTrees(targetSha, originalHeadSha, splitTipSha, cwd);
432
+
433
+ if (rootSplitTempBranch) {
434
+ finalizeRootSplitBranch(rootSplitTempBranch, rootSplitOriginalBranch, rewrittenHeadSha, cwd);
435
+ } else {
436
+ if (originalBranch !== "HEAD") {
437
+ gitResetHard(rewrittenHeadSha, cwd);
438
+ } else {
439
+ gitCheckoutDetached(rewrittenHeadSha, cwd);
440
+ }
441
+ }
442
+ } else if (rootSplitTempBranch) {
443
+ finalizeRootSplitBranch(rootSplitTempBranch, rootSplitOriginalBranch, splitTipSha, cwd);
444
+ } else if (originalBranch === "HEAD") {
445
+ gitCheckoutDetached(splitTipSha, cwd);
446
+ }
447
+
448
+ const rewrittenHeadTreeSha = resolveTreeSha("HEAD", cwd);
449
+ if (rewrittenHeadTreeSha !== originalHeadTreeSha) {
450
+ throw new Error(
451
+ "Split verification failed: the rewritten HEAD tree does not match the original HEAD tree."
452
+ );
453
+ }
454
+ } catch (error) {
455
+ gitCherryPickAbort(cwd);
456
+
457
+ try {
458
+ if (rootSplitTempBranch) {
459
+ restoreOriginalPointer(rootSplitOriginalBranch ?? "HEAD", originalHeadSha, cwd);
460
+ gitDeleteBranch(rootSplitTempBranch, cwd);
461
+ } else {
462
+ runGitCommandUnchecked(["reset", "--hard", originalHeadSha], cwd);
463
+ }
464
+ } catch {
465
+ runGitCommandUnchecked(["reset", "--hard", originalHeadSha], cwd);
466
+ }
467
+
468
+ console.error(error.message);
469
+ console.error(buildRecoveryMessage(originalHeadSha));
470
+ throw new Error("Split execution aborted.");
471
+ }
472
+ }
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "gitxplain",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "AI-powered Git commit explainer CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
- "gitxplain": "./cli/index.js"
7
+ "gitxplain": "./cli/index.js",
8
+ "gitxplore": "./cli/index.js"
8
9
  },
9
10
  "scripts": {
10
11
  "start": "node ./cli/index.js",
11
- "lint": "node --check ./cli/index.js && node --check ./cli/services/aiService.js && node --check ./cli/services/cacheService.js && node --check ./cli/services/clipboardService.js && node --check ./cli/services/configService.js && node --check ./cli/services/gitService.js && node --check ./cli/services/hookService.js && node --check ./cli/services/promptService.js && node --check ./cli/services/outputFormatter.js",
12
+ "lint": "node --check ./cli/index.js && node --check ./cli/services/chatService.js && node --check ./cli/services/gitConnectionService.js && node --check ./cli/services/envLoader.js",
12
13
  "test": "node --test"
13
14
  },
14
15
  "keywords": [
@@ -0,0 +1,57 @@
1
+ You are a senior software engineer specializing in clean Git history.
2
+
3
+ Analyze the current uncommitted Git changes and propose how to commit them into one or more smaller, atomic commits.
4
+
5
+ Context:
6
+ - These changes are currently in the working tree and are not committed yet.
7
+ - Your goal is to group them into logical commits with clear conventional commit messages.
8
+
9
+ Change Summary:
10
+ {{commit_message}}
11
+
12
+ Files Changed:
13
+ {{files_changed}}
14
+
15
+ Stats:
16
+ {{stats}}
17
+
18
+ Change Hints:
19
+ {{change_hints}}
20
+
21
+ Code Diff:
22
+ {{diff}}
23
+
24
+ Rules:
25
+ - Each proposed commit should represent exactly one logical change
26
+ - Order the commits so each one builds on the previous without breaking the project
27
+ - This tool can only stage whole files, not separate hunks from the same file
28
+ - Each file must appear in exactly one commit group
29
+ - If a file contains mixed concerns, keep that file in a single commit instead of splitting it across commits
30
+ - Write clear, conventional commit messages
31
+ - Be conservative: do not describe comments, docs, renames, formatting, or refactors as new features or fixes unless the diff clearly changes runtime behavior
32
+ - If the diff is comments-only or documentation-only, prefer `docs:` or `chore:` wording instead of `feat:`/`fix:`
33
+ - The `Why` text must describe the actual change shown in the diff and must not invent implementation work that is not present
34
+ - Prefer the smallest number of commits that still preserve logical clarity
35
+
36
+ Return ONLY valid JSON in this exact format, with no other text:
37
+
38
+ {
39
+ "working_tree_summary": "One sentence describing the current uncommitted changes",
40
+ "reason_to_commit": "Why these changes should be committed this way",
41
+ "commits": [
42
+ {
43
+ "order": 1,
44
+ "message": "feat: add user validation helper",
45
+ "files": ["src/validation.js", "src/utils.js"],
46
+ "description": "Brief explanation of what this commit contains and why it is a separate logical unit"
47
+ }
48
+ ]
49
+ }
50
+
51
+ If there are no meaningful changes to commit, return:
52
+
53
+ {
54
+ "working_tree_summary": "No meaningful changes detected",
55
+ "reason_to_commit": null,
56
+ "commits": []
57
+ }
@@ -0,0 +1,44 @@
1
+ You are a senior software engineer specializing in clean Git history.
2
+
3
+ Analyze the following Git commit and propose how to split it into multiple smaller, atomic commits that each represent a single logical change.
4
+
5
+ Commit Message:
6
+ {{commit_message}}
7
+
8
+ Files Changed:
9
+ {{files_changed}}
10
+
11
+ Stats:
12
+ {{stats}}
13
+
14
+ Code Diff:
15
+ {{diff}}
16
+
17
+ Rules:
18
+ - Each proposed commit should represent exactly one logical change (e.g., a refactor, a bug fix, a new feature, a test addition, a config change)
19
+ - Order the commits so that each one builds on the previous (no broken intermediate states)
20
+ - Each file should appear in exactly one commit group (do not split individual files across commits unless the diff clearly contains unrelated hunks)
21
+ - Write clear, conventional commit messages for each proposed commit
22
+
23
+ Return ONLY valid JSON in this exact format, with no other text:
24
+
25
+ {
26
+ "original_summary": "One sentence describing what the original commit did",
27
+ "reason_to_split": "Why this commit should be split",
28
+ "commits": [
29
+ {
30
+ "order": 1,
31
+ "message": "feat: add user validation helper",
32
+ "files": ["src/validation.js", "src/utils.js"],
33
+ "description": "Brief explanation of what this sub-commit does and why it's a separate logical unit"
34
+ }
35
+ ]
36
+ }
37
+
38
+ If the commit is already atomic and should NOT be split, return:
39
+
40
+ {
41
+ "original_summary": "...",
42
+ "reason_to_split": null,
43
+ "commits": []
44
+ }