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.
@@ -1,4 +1,26 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import os from "node:os";
3
+ import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+
7
+ const ANSI = {
8
+ reset: "\u001b[0m",
9
+ green: "\u001b[32m",
10
+ red: "\u001b[31m"
11
+ };
12
+
13
+ function supportsColor() {
14
+ return Boolean(process.stdout?.isTTY) && process.env.NO_COLOR == null;
15
+ }
16
+
17
+ function colorize(text, color) {
18
+ if (!supportsColor()) {
19
+ return text;
20
+ }
21
+
22
+ return `${color}${text}${ANSI.reset}`;
23
+ }
2
24
 
3
25
  export function runGitCommand(args, cwd) {
4
26
  try {
@@ -13,6 +35,58 @@ export function runGitCommand(args, cwd) {
13
35
  }
14
36
  }
15
37
 
38
+ export function runGitCommandWithInput(args, cwd, input) {
39
+ try {
40
+ return execFileSync("git", args, {
41
+ cwd,
42
+ input,
43
+ encoding: "utf8",
44
+ stdio: ["pipe", "pipe", "pipe"]
45
+ }).trim();
46
+ } catch (error) {
47
+ const stderr = error.stderr?.toString().trim();
48
+ throw new Error(stderr || `Git command failed: git ${args.join(" ")}`);
49
+ }
50
+ }
51
+
52
+ export function runGitCommandWithInputAndEnv(args, cwd, input, env) {
53
+ try {
54
+ return execFileSync("git", args, {
55
+ cwd,
56
+ input,
57
+ env: {
58
+ ...process.env,
59
+ ...env
60
+ },
61
+ encoding: "utf8",
62
+ stdio: ["pipe", "pipe", "pipe"]
63
+ }).trim();
64
+ } catch (error) {
65
+ const stderr = error.stderr?.toString().trim();
66
+ throw new Error(stderr || `Git command failed: git ${args.join(" ")}`);
67
+ }
68
+ }
69
+
70
+ export function runGitCommandUnchecked(args, cwd) {
71
+ try {
72
+ return {
73
+ stdout: execFileSync("git", args, {
74
+ cwd,
75
+ encoding: "utf8",
76
+ stdio: ["ignore", "pipe", "pipe"]
77
+ }).trim(),
78
+ stderr: "",
79
+ exitCode: 0
80
+ };
81
+ } catch (error) {
82
+ return {
83
+ stdout: error.stdout?.toString().trim() ?? "",
84
+ stderr: error.stderr?.toString().trim() ?? "",
85
+ exitCode: error.status ?? 1
86
+ };
87
+ }
88
+ }
89
+
16
90
  export function isGitRepository(cwd) {
17
91
  try {
18
92
  return runGitCommand(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
@@ -76,6 +150,522 @@ export function buildBranchRange(baseRef, cwd) {
76
150
  return `${mergeBase}..HEAD`;
77
151
  }
78
152
 
153
+ export function isWorkingTreeClean(cwd) {
154
+ const result = runGitCommandUnchecked(["status", "--porcelain"], cwd);
155
+
156
+ if (result.exitCode !== 0) {
157
+ throw new Error(result.stderr || "Unable to determine working tree status.");
158
+ }
159
+
160
+ return result.stdout === "";
161
+ }
162
+
163
+ export function resolveCommitSha(ref, cwd) {
164
+ return runGitCommand(["rev-parse", ref], cwd);
165
+ }
166
+
167
+ export function getCurrentHeadSha(cwd) {
168
+ return runGitCommand(["rev-parse", "HEAD"], cwd);
169
+ }
170
+
171
+ export function getCurrentBranchName(cwd) {
172
+ return runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
173
+ }
174
+
175
+ export function resolveTreeSha(ref, cwd, runner = runGitCommand) {
176
+ return runner(["rev-parse", `${ref}^{tree}`], cwd);
177
+ }
178
+
179
+ export function getMergeBase(leftRef, rightRef, cwd) {
180
+ return runGitCommand(["merge-base", leftRef, rightRef], cwd);
181
+ }
182
+
183
+ export function pathExistsInRef(ref, filePath, cwd) {
184
+ const result = runGitCommandUnchecked(["cat-file", "-e", `${ref}:${filePath}`], cwd);
185
+ return result.exitCode === 0;
186
+ }
187
+
188
+ export function gitResetSoft(cwd) {
189
+ return runGitCommand(["reset", "--soft", "HEAD~1"], cwd);
190
+ }
191
+
192
+ export function gitUnstageAll(cwd) {
193
+ return runGitCommand(["reset", "HEAD", "--", "."], cwd);
194
+ }
195
+
196
+ export function gitAddFiles(files, cwd) {
197
+ return runGitCommand(["add", ...files], cwd);
198
+ }
199
+
200
+ export function gitRestoreStaged(files, cwd) {
201
+ return runGitCommand(["restore", "--staged", "--", ...files], cwd);
202
+ }
203
+
204
+ export function deletePaths(files, cwd) {
205
+ for (const file of files) {
206
+ const targetPath = path.resolve(cwd, file);
207
+
208
+ if (targetPath === cwd || !targetPath.startsWith(`${cwd}${path.sep}`)) {
209
+ throw new Error(`Refusing to delete path outside the repository: ${file}`);
210
+ }
211
+
212
+ rmSync(targetPath, { recursive: true, force: true });
213
+ }
214
+ }
215
+
216
+ export function gitCommit(message, cwd) {
217
+ return runGitCommand(["commit", "-m", message], cwd);
218
+ }
219
+
220
+ export function gitPush(cwd, remote = null, branch = null, runner = runGitCommand) {
221
+ const args = ["push"];
222
+
223
+ if (remote) {
224
+ args.push(remote);
225
+ }
226
+
227
+ if (branch) {
228
+ args.push(branch);
229
+ }
230
+
231
+ return runner(args, cwd);
232
+ }
233
+
234
+ export function gitCreateAnnotatedTag(tagName, ref, message, cwd) {
235
+ return runGitCommand(["tag", "-a", tagName, ref, "-m", message], cwd);
236
+ }
237
+
238
+ export function gitDeleteTag(tagName, cwd) {
239
+ return runGitCommand(["tag", "-d", tagName], cwd);
240
+ }
241
+
242
+ export function listTags(cwd) {
243
+ const output = runGitCommand(["tag", "--list"], cwd);
244
+ return output
245
+ .split("\n")
246
+ .map((line) => line.trim())
247
+ .filter(Boolean);
248
+ }
249
+
250
+ export function hasStagedChanges(cwd) {
251
+ const result = runGitCommandUnchecked(["diff", "--cached", "--quiet"], cwd);
252
+
253
+ if (result.exitCode === 0) {
254
+ return false;
255
+ }
256
+
257
+ if (result.exitCode === 1) {
258
+ return true;
259
+ }
260
+
261
+ throw new Error(result.stderr || "Unable to determine whether staged changes exist.");
262
+ }
263
+
264
+ export function gitAddAll(cwd) {
265
+ return runGitCommand(["add", "--all"], cwd);
266
+ }
267
+
268
+ export function getRepositoryLog(cwd, limit = null, runner = runGitCommand) {
269
+ const args = ["log", "--date=short", "--pretty=format:%h %ad %an %s"];
270
+
271
+ if (limit != null) {
272
+ args.splice(1, 0, `--max-count=${limit}`);
273
+ }
274
+
275
+ return runner(args, cwd);
276
+ }
277
+
278
+ function describeStatusCode(code, area) {
279
+ const normalized = code === " " ? "" : code;
280
+
281
+ if (normalized === "") {
282
+ return null;
283
+ }
284
+
285
+ const labels = {
286
+ M: area === "index" ? "staged modification" : "unstaged modification",
287
+ A: area === "index" ? "staged new file" : "added in working tree",
288
+ D: area === "index" ? "staged deletion" : "unstaged deletion",
289
+ R: area === "index" ? "staged rename" : "unstaged rename",
290
+ C: area === "index" ? "staged copy" : "unstaged copy",
291
+ U: "merge conflict",
292
+ "?": "untracked"
293
+ };
294
+
295
+ return labels[normalized] ?? `${area === "index" ? "index" : "working tree"} change (${normalized})`;
296
+ }
297
+
298
+ function colorizeStatusLabel(label) {
299
+ if (label.startsWith("staged ")) {
300
+ return colorize(label, ANSI.green);
301
+ }
302
+
303
+ if (
304
+ label.startsWith("unstaged ") ||
305
+ label.includes("untracked") ||
306
+ label.includes("conflict") ||
307
+ label.includes("change (")
308
+ ) {
309
+ return colorize(label, ANSI.red);
310
+ }
311
+
312
+ if (label === "clean") {
313
+ return colorize(label, ANSI.green);
314
+ }
315
+
316
+ return label;
317
+ }
318
+
319
+ function formatStatusEntry(line) {
320
+ if (!line) {
321
+ return null;
322
+ }
323
+
324
+ if (line.startsWith("?? ")) {
325
+ return `- ${line.slice(3)}: ${colorizeStatusLabel("untracked")}`;
326
+ }
327
+
328
+ if (line.startsWith("## ")) {
329
+ return line.slice(3);
330
+ }
331
+
332
+ const indexCode = line[0];
333
+ const worktreeCode = line[1];
334
+ const path = line.slice(3).trim();
335
+ const statuses = [
336
+ describeStatusCode(indexCode, "index"),
337
+ describeStatusCode(worktreeCode, "worktree")
338
+ ].filter(Boolean);
339
+
340
+ if (statuses.length === 0) {
341
+ return `- ${path}: ${colorizeStatusLabel("clean")}`;
342
+ }
343
+
344
+ return `- ${path}: ${statuses.map((status) => colorizeStatusLabel(status)).join(", ")}`;
345
+ }
346
+
347
+ export function getRepositoryStatus(cwd, runner = runGitCommand) {
348
+ const raw = runner(["status", "--short", "--branch"], cwd);
349
+
350
+ if (!raw) {
351
+ return "Working tree is clean.";
352
+ }
353
+
354
+ const lines = raw.split("\n").filter(Boolean);
355
+ const branchLine = lines.find((line) => line.startsWith("## ")) ?? null;
356
+ const entries = lines
357
+ .filter((line) => !line.startsWith("## "))
358
+ .map((line) => formatStatusEntry(line))
359
+ .filter(Boolean);
360
+
361
+ if (entries.length === 0) {
362
+ return branchLine ? `${branchLine.slice(3)}\n\nWorking tree is clean.` : "Working tree is clean.";
363
+ }
364
+
365
+ return [branchLine ? branchLine.slice(3) : null, "", "Changes:", ...entries].filter(Boolean).join("\n");
366
+ }
367
+
368
+ export function getCommitParents(ref, cwd) {
369
+ const output = runGitCommand(["show", "-s", "--format=%P", ref], cwd);
370
+ return output
371
+ .split(" ")
372
+ .map((parent) => parent.trim())
373
+ .filter(Boolean);
374
+ }
375
+
376
+ export function getCommitMetadata(ref, cwd) {
377
+ const output = runGitCommand(
378
+ ["show", "-s", "--format=%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%B", ref],
379
+ cwd
380
+ );
381
+ const [authorName = "", authorEmail = "", authorDate = "", committerName = "", committerEmail = "", committerDate = "", ...messageParts] =
382
+ output.split("\u001f");
383
+
384
+ return {
385
+ authorName,
386
+ authorEmail,
387
+ authorDate,
388
+ committerName,
389
+ committerEmail,
390
+ committerDate,
391
+ message: messageParts.join("\u001f")
392
+ };
393
+ }
394
+
395
+ export function listCommitsAfter(baseRef, headRef, cwd) {
396
+ const output = runGitCommand(["rev-list", "--reverse", `${baseRef}..${headRef}`], cwd);
397
+ return output
398
+ .split("\n")
399
+ .map((line) => line.trim())
400
+ .filter(Boolean);
401
+ }
402
+
403
+ export function listCommitsAfterTopo(baseRef, headRef, cwd) {
404
+ const output = runGitCommand(["rev-list", "--reverse", "--topo-order", `${baseRef}..${headRef}`], cwd);
405
+ return output
406
+ .split("\n")
407
+ .map((line) => line.trim())
408
+ .filter(Boolean);
409
+ }
410
+
411
+ export function listBranchCommits(ref, cwd) {
412
+ const output = runGitCommand(["rev-list", "--reverse", ref], cwd);
413
+ return output
414
+ .split("\n")
415
+ .map((line) => line.trim())
416
+ .filter(Boolean);
417
+ }
418
+
419
+ export function listFilesInRef(ref, cwd) {
420
+ const output = runGitCommand(["ls-tree", "-r", "--name-only", ref], cwd);
421
+ return output
422
+ .split("\n")
423
+ .map((line) => line.trim())
424
+ .filter(Boolean);
425
+ }
426
+
427
+ export function isAncestorCommit(ancestorRef, descendantRef, cwd) {
428
+ const result = runGitCommandUnchecked(["merge-base", "--is-ancestor", ancestorRef, descendantRef], cwd);
429
+
430
+ if (result.exitCode === 0) {
431
+ return true;
432
+ }
433
+
434
+ if (result.exitCode === 1) {
435
+ return false;
436
+ }
437
+
438
+ throw new Error(result.stderr || "Unable to determine commit ancestry.");
439
+ }
440
+
441
+ export function gitResetHard(ref, cwd) {
442
+ return runGitCommand(["reset", "--hard", ref], cwd);
443
+ }
444
+
445
+ export function gitCherryPickNoCommit(ref, cwd) {
446
+ return runGitCommand(["cherry-pick", "--no-commit", ref], cwd);
447
+ }
448
+
449
+ export function gitCherryPick(ref, cwd) {
450
+ return runGitCommand(["cherry-pick", ref], cwd);
451
+ }
452
+
453
+ export function gitCherryPickRecordSource(ref, cwd) {
454
+ return runGitCommand(["cherry-pick", "-x", ref], cwd);
455
+ }
456
+
457
+ export function gitMerge(ref, cwd, message = null) {
458
+ const args = message == null ? ["merge", "--no-ff", ref] : ["merge", "--no-ff", ref, "-m", message];
459
+ return runGitCommand(args, cwd);
460
+ }
461
+
462
+ export function gitCherryPickAbort(cwd) {
463
+ const result = runGitCommandUnchecked(["cherry-pick", "--abort"], cwd);
464
+ return result.exitCode === 0;
465
+ }
466
+
467
+ export function gitMergeAbort(cwd) {
468
+ const result = runGitCommandUnchecked(["merge", "--abort"], cwd);
469
+ return result.exitCode === 0;
470
+ }
471
+
472
+ export function localBranchExists(branchName, cwd) {
473
+ const result = runGitCommandUnchecked(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], cwd);
474
+ return result.exitCode === 0;
475
+ }
476
+
477
+ export function gitCheckout(ref, cwd) {
478
+ return runGitCommand(["checkout", ref], cwd);
479
+ }
480
+
481
+ export function gitCheckoutDetached(ref, cwd) {
482
+ return runGitCommand(["checkout", "--detach", ref], cwd);
483
+ }
484
+
485
+ export function gitCreateBranch(branchName, startPoint, cwd) {
486
+ return runGitCommand(["branch", branchName, startPoint], cwd);
487
+ }
488
+
489
+ export function gitCheckoutNewBranch(branchName, startPoint, cwd) {
490
+ return runGitCommand(["checkout", "-b", branchName, startPoint], cwd);
491
+ }
492
+
493
+ export function gitCheckoutOrphan(branchName, cwd) {
494
+ return runGitCommand(["checkout", "--orphan", branchName], cwd);
495
+ }
496
+
497
+ export function gitDeleteBranch(branchName, cwd) {
498
+ return runGitCommand(["branch", "-D", branchName], cwd);
499
+ }
500
+
501
+ export function gitForceBranch(branchName, ref, cwd) {
502
+ return runGitCommand(["branch", "-f", branchName, ref], cwd);
503
+ }
504
+
505
+ export function gitRebaseRebaseMergesOnto(newBase, upstream, cwd, strategyOption = null) {
506
+ const args = ["rebase", "--rebase-merges"];
507
+
508
+ if (strategyOption) {
509
+ args.push("-X", strategyOption);
510
+ }
511
+
512
+ args.push("--onto", newBase, upstream);
513
+ return runGitCommand(args, cwd);
514
+ }
515
+
516
+ export function gitRebaseAbort(cwd) {
517
+ const result = runGitCommandUnchecked(["rebase", "--abort"], cwd);
518
+ return result.exitCode === 0;
519
+ }
520
+
521
+ export function gitRemoveCachedAll(cwd) {
522
+ return runGitCommand(["rm", "-r", "--cached", "--ignore-unmatch", "."], cwd);
523
+ }
524
+
525
+ export function createEmptyRootCommit(message, cwd) {
526
+ const emptyTree = runGitCommandWithInput(["mktree"], cwd, "");
527
+ return runGitCommand(["commit-tree", emptyTree, "-m", message], cwd);
528
+ }
529
+
530
+ export function createCommitFromTree(treeSha, parentShas, metadata, cwd) {
531
+ const args = ["commit-tree", treeSha];
532
+
533
+ for (const parentSha of parentShas) {
534
+ args.push("-p", parentSha);
535
+ }
536
+
537
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "gitxplain-commit-tree-"));
538
+ const messagePath = path.join(tempDir, "message.txt");
539
+
540
+ try {
541
+ writeFileSync(messagePath, metadata.message.endsWith("\n") ? metadata.message : `${metadata.message}\n`, "utf8");
542
+ args.push("-F", messagePath);
543
+
544
+ return execFileSync("git", args, {
545
+ cwd,
546
+ env: {
547
+ ...process.env,
548
+ GIT_AUTHOR_NAME: metadata.authorName,
549
+ GIT_AUTHOR_EMAIL: metadata.authorEmail,
550
+ GIT_AUTHOR_DATE: metadata.authorDate,
551
+ GIT_COMMITTER_NAME: metadata.committerName,
552
+ GIT_COMMITTER_EMAIL: metadata.committerEmail,
553
+ GIT_COMMITTER_DATE: metadata.committerDate
554
+ },
555
+ encoding: "utf8",
556
+ stdio: ["ignore", "pipe", "pipe"]
557
+ }).trim();
558
+ } catch (error) {
559
+ const stderr = error.stderr?.toString().trim();
560
+ throw new Error(stderr || `Git command failed: git ${args.join(" ")}`);
561
+ } finally {
562
+ try {
563
+ unlinkSync(messagePath);
564
+ } catch {}
565
+ try {
566
+ rmSync(tempDir, { recursive: true, force: true });
567
+ } catch {}
568
+ }
569
+ }
570
+
571
+ export function writeCurrentIndexTree(cwd) {
572
+ return runGitCommand(["write-tree"], cwd);
573
+ }
574
+
575
+ export function gitStashPush(message, cwd) {
576
+ return runGitCommand(["stash", "push", "--include-untracked", "--message", message], cwd);
577
+ }
578
+
579
+ export function gitStashApply(stashRef, cwd) {
580
+ return runGitCommand(["stash", "apply", "--index", stashRef], cwd);
581
+ }
582
+
583
+ export function gitStashDrop(stashRef, cwd) {
584
+ return runGitCommand(["stash", "drop", stashRef], cwd);
585
+ }
586
+
587
+ export function resolveStashRef(index = null) {
588
+ if (index == null) {
589
+ return "stash@{0}";
590
+ }
591
+
592
+ if (typeof index === "string" && /^stash@\{\d+\}$/.test(index.trim())) {
593
+ return index.trim();
594
+ }
595
+
596
+ const parsed = Number.parseInt(String(index), 10);
597
+ if (Number.isNaN(parsed) || parsed < 0) {
598
+ throw new Error(`Invalid stash index: ${index}`);
599
+ }
600
+
601
+ return `stash@{${parsed}}`;
602
+ }
603
+
604
+ export function gitStashPop(index, cwd) {
605
+ const stashRef = resolveStashRef(index);
606
+ return runGitCommand(["stash", "pop", "--index", stashRef], cwd);
607
+ }
608
+
609
+ export function getLatestStashRef(cwd) {
610
+ const output = runGitCommand(["stash", "list", "--format=%gd"], cwd);
611
+ return output.split("\n").map((line) => line.trim()).find(Boolean) ?? null;
612
+ }
613
+
614
+ function getUncheckedCommandOutput(args, cwd) {
615
+ const result = runGitCommandUnchecked(args, cwd);
616
+ if (result.exitCode !== 0 && result.stderr) {
617
+ return result.stdout;
618
+ }
619
+
620
+ return result.stdout;
621
+ }
622
+
623
+ function parseUniqueFiles(...groups) {
624
+ return [...new Set(groups.flatMap((group) => group.split("\n").map((line) => line.trim()).filter(Boolean)))];
625
+ }
626
+
627
+ export function fetchWorkingTreeData(cwd) {
628
+ const stagedDiff = getUncheckedCommandOutput(["diff", "--cached"], cwd);
629
+ const unstagedDiff = getUncheckedCommandOutput(["diff"], cwd);
630
+ const trackedFiles = getUncheckedCommandOutput(["diff", "--name-only", "HEAD"], cwd);
631
+ const untrackedFiles = getUncheckedCommandOutput(["ls-files", "--others", "--exclude-standard"], cwd);
632
+ const trackedStats = getUncheckedCommandOutput(["diff", "--stat", "HEAD"], cwd);
633
+
634
+ const untrackedList = untrackedFiles
635
+ .split("\n")
636
+ .map((line) => line.trim())
637
+ .filter(Boolean);
638
+
639
+ const untrackedDiff = untrackedList
640
+ .map((file) => {
641
+ const result = runGitCommandUnchecked(["diff", "--no-index", "--", "/dev/null", file], cwd);
642
+ return result.stdout;
643
+ })
644
+ .filter(Boolean)
645
+ .join("\n");
646
+
647
+ const filesChanged = parseUniqueFiles(trackedFiles, untrackedFiles);
648
+ const diff = [stagedDiff, unstagedDiff, untrackedDiff].filter(Boolean).join("\n").trim();
649
+ const trackedStatsLine = parseStatsLine(trackedStats);
650
+ const untrackedStatsLine =
651
+ untrackedList.length > 0
652
+ ? `${untrackedList.length} untracked file${untrackedList.length === 1 ? "" : "s"}`
653
+ : null;
654
+
655
+ return {
656
+ analysisType: "workingTree",
657
+ targetRef: "working-tree",
658
+ displayRef: "working-tree",
659
+ commitId: null,
660
+ commitCount: 0,
661
+ commits: [],
662
+ commitMessage: "Uncommitted working tree changes",
663
+ diff,
664
+ filesChanged,
665
+ stats: [trackedStatsLine, untrackedStatsLine].filter(Boolean).join("; ")
666
+ };
667
+ }
668
+
79
669
  function fetchSingleCommitData(commitId, cwd, runner) {
80
670
  const commitMessage = runner(["log", "-1", "--pretty=format:%B", commitId], cwd);
81
671
  const diff = runner(["diff", `${commitId}^!`], cwd);