gitxplain 0.1.6 → 0.1.8

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,7 +1,41 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
+ const ENV_CONFIG_KEYS = new Set([
6
+ "LLM_PROVIDER",
7
+ "LLM_MODEL",
8
+ "OPENAI_API_KEY",
9
+ "OPENAI_MODEL",
10
+ "OPENAI_BASE_URL",
11
+ "GROQ_API_KEY",
12
+ "GROQ_MODEL",
13
+ "GROQ_BASE_URL",
14
+ "OPENROUTER_API_KEY",
15
+ "OPENROUTER_MODEL",
16
+ "OPENROUTER_BASE_URL",
17
+ "OPENROUTER_SITE_URL",
18
+ "OPENROUTER_APP_NAME",
19
+ "GEMINI_API_KEY",
20
+ "GEMINI_MODEL",
21
+ "GEMINI_BASE_URL",
22
+ "OLLAMA_API_KEY",
23
+ "OLLAMA_MODEL",
24
+ "OLLAMA_BASE_URL",
25
+ "CHUTES_API_KEY",
26
+ "CHUTES_MODEL",
27
+ "CHUTES_BASE_URL"
28
+ ]);
29
+
30
+ const PROVIDER_API_KEY_FIELDS = {
31
+ openai: "OPENAI_API_KEY",
32
+ groq: "GROQ_API_KEY",
33
+ openrouter: "OPENROUTER_API_KEY",
34
+ gemini: "GEMINI_API_KEY",
35
+ ollama: "OLLAMA_API_KEY",
36
+ chutes: "CHUTES_API_KEY"
37
+ };
38
+
5
39
  function readJsonConfig(filePath) {
6
40
  if (!existsSync(filePath)) {
7
41
  return {};
@@ -14,9 +48,16 @@ function readJsonConfig(filePath) {
14
48
  }
15
49
  }
16
50
 
51
+ export function getUserConfigPath() {
52
+ return path.join(os.homedir(), ".gitxplain", "config.json");
53
+ }
54
+
55
+ export function loadUserConfig() {
56
+ return readJsonConfig(getUserConfigPath());
57
+ }
58
+
17
59
  export function loadConfig(cwd) {
18
- const homeDir = os.homedir();
19
- const userConfigPath = path.join(homeDir, ".gitxplain", "config.json");
60
+ const userConfigPath = getUserConfigPath();
20
61
  const projectConfigPath = path.join(cwd, ".gitxplainrc");
21
62
  const projectJsonConfigPath = path.join(cwd, ".gitxplainrc.json");
22
63
 
@@ -26,3 +67,34 @@ export function loadConfig(cwd) {
26
67
  ...readJsonConfig(projectJsonConfigPath)
27
68
  };
28
69
  }
70
+
71
+ export function applyConfigEnvironment(config) {
72
+ for (const [key, value] of Object.entries(config)) {
73
+ if (!ENV_CONFIG_KEYS.has(key)) {
74
+ continue;
75
+ }
76
+
77
+ if (typeof value === "string" && value !== "" && !process.env[key]) {
78
+ process.env[key] = value;
79
+ }
80
+ }
81
+ }
82
+
83
+ export function getProviderApiKeyField(provider) {
84
+ const normalized = provider?.toLowerCase();
85
+ return normalized ? PROVIDER_API_KEY_FIELDS[normalized] ?? null : null;
86
+ }
87
+
88
+ export function writeUserConfig(nextConfig) {
89
+ const configPath = getUserConfigPath();
90
+ mkdirSync(path.dirname(configPath), { recursive: true });
91
+ writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
92
+ return configPath;
93
+ }
94
+
95
+ export function updateUserConfig(updates) {
96
+ const currentConfig = loadUserConfig();
97
+ const nextConfig = { ...currentConfig, ...updates };
98
+ const configPath = writeUserConfig(nextConfig);
99
+ return { configPath, config: nextConfig };
100
+ }
@@ -306,6 +306,23 @@ export function buildReleaseWindows(sourceCommits) {
306
306
  return windows;
307
307
  }
308
308
 
309
+ function selectLatestWindowsPerVersion(windows) {
310
+ const seenVersions = new Set();
311
+ const latestWindows = [];
312
+
313
+ for (let index = windows.length - 1; index >= 0; index -= 1) {
314
+ const window = windows[index];
315
+ if (!window.version || seenVersions.has(window.version)) {
316
+ continue;
317
+ }
318
+
319
+ seenVersions.add(window.version);
320
+ latestWindows.push(window);
321
+ }
322
+
323
+ return latestWindows.reverse();
324
+ }
325
+
309
326
  export function selectReleaseWindows(sourceCommits, releaseCommits = []) {
310
327
  const windows = buildReleaseWindows(sourceCommits);
311
328
  const releasedVersions = getReleasedVersions(releaseCommits);
@@ -319,7 +336,7 @@ export function selectReleaseWindows(sourceCommits, releaseCommits = []) {
319
336
  }
320
337
 
321
338
  export function selectReleaseTags(sourceCommits, existingTagNames = []) {
322
- const windows = buildReleaseWindows(sourceCommits);
339
+ const windows = selectLatestWindowsPerVersion(buildReleaseWindows(sourceCommits));
323
340
  const taggedVersions = extractTaggedVersions(existingTagNames);
324
341
  const tags = windows
325
342
  .filter((window) => !taggedVersions.has(window.version))
@@ -342,6 +359,31 @@ export function selectReleaseTags(sourceCommits, existingTagNames = []) {
342
359
  };
343
360
  }
344
361
 
362
+ function findLatestTaggedSourceVersion(sourceCommits, taggedVersions) {
363
+ const tagged = new Set(taggedVersions);
364
+ return selectLatestWindowsPerVersion(buildReleaseWindows(sourceCommits))
365
+ .map((window) => window.version)
366
+ .filter((version) => version && tagged.has(version))
367
+ .at(-1) ?? null;
368
+ }
369
+
370
+ function buildReleaseTagPlanForSource(sourceBranch, sourceRef, cwd) {
371
+ const sourceCommits = listBranchCommits(sourceRef, cwd).map((sha) => inspectCommit(sha, cwd));
372
+ const existingTagNames = listTags(cwd);
373
+ const selection = selectReleaseTags(sourceCommits, existingTagNames);
374
+
375
+ return {
376
+ sourceBranch,
377
+ baseRef: sourceRef,
378
+ mergeBase: null,
379
+ releaseExists: localBranchExists(RELEASE_BRANCH, cwd),
380
+ taggedVersions: selection.taggedVersions,
381
+ latestDetectedVersion: selection.latestDetectedVersion,
382
+ latestTaggedVersion: findLatestTaggedSourceVersion(sourceCommits, selection.taggedVersions),
383
+ tags: selection.tags
384
+ };
385
+ }
386
+
345
387
  export function selectReleaseTagsFromReleaseCommits(releaseCommits, existingTagNames = []) {
346
388
  const taggedVersions = extractTaggedVersions(existingTagNames);
347
389
  const tags = releaseCommits
@@ -431,26 +473,7 @@ export function buildReleaseTagPlan(cwd) {
431
473
  throw new Error(`Already on "${RELEASE_BRANCH}". Switch to a source branch before running --tag.`);
432
474
  }
433
475
 
434
- const releaseExists = localBranchExists(RELEASE_BRANCH, cwd);
435
- const baseRef = releaseExists ? RELEASE_BRANCH : getDefaultBaseRef(cwd);
436
- const { mergeBase, sourceCommitShas } = getReleaseTrackSourceCommitShas(releaseExists, baseRef, "HEAD", cwd);
437
- const existingTagNames = listTags(cwd);
438
- const selection = releaseExists
439
- ? selectReleaseTagsFromReleaseCommits(
440
- listBranchCommits(RELEASE_BRANCH, cwd).map((sha) => inspectCommit(sha, cwd)),
441
- existingTagNames
442
- )
443
- : selectReleaseTags(sourceCommits.map((sha) => inspectCommit(sha, cwd)), existingTagNames);
444
-
445
- return {
446
- sourceBranch,
447
- baseRef,
448
- mergeBase,
449
- releaseExists,
450
- taggedVersions: selection.taggedVersions,
451
- latestDetectedVersion: selection.latestDetectedVersion,
452
- tags: selection.tags
453
- };
476
+ return buildReleaseTagPlanForSource(sourceBranch, "HEAD", cwd);
454
477
  }
455
478
 
456
479
  export function finalizeReleaseMergePlan(plan) {
@@ -523,7 +546,11 @@ function buildDriftStatus(sourceRef, sourceLabel, releaseExists, cwd) {
523
546
 
524
547
  function getNextRecommendedAction({ releaseExists, mergePlan, missingTagCount }) {
525
548
  if (!releaseExists && mergePlan.windows.length > 0) {
526
- return `Run \`gitxplain merge --execute\` to create ${RELEASE_BRANCH} and promote ${mergePlan.windows.length} unreleased version(s).`;
549
+ return `Run \`gitxplain --merge --execute\` to create ${RELEASE_BRANCH} and promote ${mergePlan.windows.length} unreleased version(s).`;
550
+ }
551
+
552
+ if (!releaseExists && missingTagCount > 0) {
553
+ return `Run \`gitxplain --tag --execute\` to create ${missingTagCount} missing version tag(s) on the current branch.`;
527
554
  }
528
555
 
529
556
  if (!releaseExists) {
@@ -531,15 +558,15 @@ function getNextRecommendedAction({ releaseExists, mergePlan, missingTagCount })
531
558
  }
532
559
 
533
560
  if (mergePlan.windows.length > 0 && missingTagCount > 0) {
534
- return `Run \`gitxplain merge --execute\` first, then \`gitxplain tag --execute\` to finish tagging release commits.`;
561
+ return `Run \`gitxplain --merge --execute\` to update ${RELEASE_BRANCH}, and \`gitxplain --tag --execute\` to create ${missingTagCount} missing version tag(s).`;
535
562
  }
536
563
 
537
564
  if (mergePlan.windows.length > 0) {
538
- return `Run \`gitxplain merge --execute\` to promote ${mergePlan.windows.length} unreleased version(s) to ${RELEASE_BRANCH}.`;
565
+ return `Run \`gitxplain --merge --execute\` to promote ${mergePlan.windows.length} unreleased version(s) to ${RELEASE_BRANCH}.`;
539
566
  }
540
567
 
541
568
  if (missingTagCount > 0) {
542
- return `Run \`gitxplain tag --execute\` to create ${missingTagCount} missing release tag(s).`;
569
+ return `Run \`gitxplain --tag --execute\` to create ${missingTagCount} missing version tag(s).`;
543
570
  }
544
571
 
545
572
  return "No action required. Release branch and tags are up to date.";
@@ -552,11 +579,9 @@ export function buildReleaseStatus(cwd) {
552
579
  const sourceRef = currentBranch === RELEASE_BRANCH ? sourceBranch : "HEAD";
553
580
  const mergePlan = finalizeReleaseMergePlan(buildReleaseMergePlanForSource(sourceBranch, sourceRef, cwd));
554
581
  const releaseCommits = releaseExists ? listBranchCommits(RELEASE_BRANCH, cwd).map((sha) => inspectCommit(sha, cwd)) : [];
555
- const tagSelection = releaseExists
556
- ? selectReleaseTagsFromReleaseCommits(releaseCommits, listTags(cwd))
557
- : { tags: [], taggedVersions: [], latestDetectedVersion: null };
582
+ const tagPlan = finalizeReleaseTagPlan(buildReleaseTagPlanForSource(sourceBranch, sourceRef, cwd));
558
583
  const drift = buildDriftStatus(sourceRef, sourceBranch, releaseExists, cwd);
559
- const missingTagVersions = tagSelection.tags.map((tag) => tag.tagName);
584
+ const missingTagVersions = tagPlan.tags.map((tag) => tag.tagName);
560
585
  const unmergedVersions = mergePlan.windows.map((window) => window.version);
561
586
 
562
587
  return {
@@ -571,20 +596,12 @@ export function buildReleaseStatus(cwd) {
571
596
  : "healthy",
572
597
  latestSourceVersion: mergePlan.latestDetectedVersion,
573
598
  latestReleaseVersion: findLatestReleaseVersion(releaseCommits),
574
- latestTaggedVersion: findLatestTaggedReleaseVersion(releaseCommits, tagSelection.taggedVersions),
599
+ latestTaggedVersion: tagPlan.latestTaggedVersion,
575
600
  unmergedVersions,
576
601
  missingTagVersions,
577
602
  drift,
578
603
  mergePlan,
579
- tagPlan: finalizeReleaseTagPlan({
580
- sourceBranch,
581
- baseRef: mergePlan.baseRef,
582
- mergeBase: mergePlan.mergeBase,
583
- releaseExists,
584
- taggedVersions: tagSelection.taggedVersions,
585
- latestDetectedVersion: tagSelection.latestDetectedVersion,
586
- tags: tagSelection.tags
587
- }),
604
+ tagPlan,
588
605
  nextRecommendedAction: getNextRecommendedAction({
589
606
  releaseExists,
590
607
  mergePlan,
@@ -714,7 +714,7 @@ export function writePipelineFiles(cwd, analysis, selection) {
714
714
  }
715
715
 
716
716
  if (selection.id === "container" && !selection.files.includes(".github/workflows/ci.yml")) {
717
- notes.push("This option only creates the container workflow. Run `gitxplain pipeline` again if you also want CI verification.");
717
+ notes.push("This option only creates the container workflow. Run `gitxplain --pipeline` again if you also want CI verification.");
718
718
  }
719
719
 
720
720
  return { writtenFiles, notes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitxplain",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "AI-powered Git commit explainer CLI",
5
5
  "type": "module",
6
6
  "bin": {