gitxplain 0.1.8 → 0.1.9

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.
@@ -24,7 +24,20 @@ const ENV_CONFIG_KEYS = new Set([
24
24
  "OLLAMA_BASE_URL",
25
25
  "CHUTES_API_KEY",
26
26
  "CHUTES_MODEL",
27
- "CHUTES_BASE_URL"
27
+ "CHUTES_BASE_URL",
28
+ "ANTHROPIC_API_KEY",
29
+ "ANTHROPIC_MODEL",
30
+ "ANTHROPIC_BASE_URL",
31
+ "MISTRAL_API_KEY",
32
+ "MISTRAL_MODEL",
33
+ "MISTRAL_BASE_URL",
34
+ "AZURE_OPENAI_API_KEY",
35
+ "AZURE_OPENAI_MODEL",
36
+ "AZURE_OPENAI_BASE_URL",
37
+ "AZURE_OPENAI_DEPLOYMENT",
38
+ "AZURE_OPENAI_API_VERSION",
39
+ "LLM_INPUT_COST_PER_MTOK",
40
+ "LLM_OUTPUT_COST_PER_MTOK"
28
41
  ]);
29
42
 
30
43
  const PROVIDER_API_KEY_FIELDS = {
@@ -33,7 +46,10 @@ const PROVIDER_API_KEY_FIELDS = {
33
46
  openrouter: "OPENROUTER_API_KEY",
34
47
  gemini: "GEMINI_API_KEY",
35
48
  ollama: "OLLAMA_API_KEY",
36
- chutes: "CHUTES_API_KEY"
49
+ chutes: "CHUTES_API_KEY",
50
+ anthropic: "ANTHROPIC_API_KEY",
51
+ mistral: "MISTRAL_API_KEY",
52
+ "azure-openai": "AZURE_OPENAI_API_KEY"
37
53
  };
38
54
 
39
55
  function readJsonConfig(filePath) {
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- export function loadEnvFile() {
5
+ export function loadEnvFile(cwd = process.cwd()) {
6
6
  try {
7
7
  // Get the directory of the CLI installation
8
8
  const __filename = fileURLToPath(import.meta.url);
@@ -11,7 +11,7 @@ export function loadEnvFile() {
11
11
  const envPath = path.join(projectDir, ".env");
12
12
 
13
13
  // Also check current working directory
14
- const cwdEnvPath = path.join(process.cwd(), ".env");
14
+ const cwdEnvPath = path.join(cwd, ".env");
15
15
  const finalEnvPath = fs.existsSync(envPath) ? envPath : (fs.existsSync(cwdEnvPath) ? cwdEnvPath : null);
16
16
 
17
17
  if (finalEnvPath && fs.existsSync(finalEnvPath)) {
@@ -1,26 +1,9 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
2
  import os from "node:os";
3
- import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import path from "node:path";
5
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
- }
6
+ import { ANSI, colorize } from "./colorSupport.js";
24
7
 
25
8
  export function runGitCommand(args, cwd) {
26
9
  try {
@@ -88,10 +71,20 @@ export function runGitCommandUnchecked(args, cwd) {
88
71
  }
89
72
 
90
73
  export function listGitSubcommands() {
91
- const output = execFileSync("git", ["help", "-a"], {
92
- encoding: "utf8",
93
- stdio: ["ignore", "pipe", "pipe"]
94
- });
74
+ let output;
75
+ try {
76
+ output = execFileSync("git", ["help", "-a"], {
77
+ encoding: "utf8",
78
+ stdio: ["ignore", "pipe", "pipe"]
79
+ });
80
+ } catch (error) {
81
+ if (error?.code === "ENOENT") {
82
+ throw new Error("git is not installed or not available in PATH.");
83
+ }
84
+
85
+ const stderr = error.stderr?.toString().trim();
86
+ throw new Error(stderr || "Unable to list git subcommands.");
87
+ }
95
88
 
96
89
  return new Set(
97
90
  output
@@ -289,6 +282,36 @@ export function listTags(cwd) {
289
282
  .filter(Boolean);
290
283
  }
291
284
 
285
+ export function listTagTargets(cwd) {
286
+ const result = runGitCommandUnchecked(["show-ref", "--tags", "-d"], cwd);
287
+ if (result.exitCode !== 0) {
288
+ return [];
289
+ }
290
+
291
+ const output = result.stdout;
292
+ const tagTargets = new Map();
293
+
294
+ for (const line of output.split("\n").map((entry) => entry.trim()).filter(Boolean)) {
295
+ const [sha, ref] = line.split(" ");
296
+ if (!sha || !ref?.startsWith("refs/tags/")) {
297
+ continue;
298
+ }
299
+
300
+ const rawTagName = ref.slice("refs/tags/".length);
301
+ const isDereferenced = rawTagName.endsWith("^{}");
302
+ const tagName = isDereferenced ? rawTagName.slice(0, -3) : rawTagName;
303
+ const existing = tagTargets.get(tagName) ?? {};
304
+
305
+ tagTargets.set(tagName, {
306
+ tagName,
307
+ tagSha: isDereferenced ? existing.tagSha ?? null : sha,
308
+ targetSha: isDereferenced ? sha : existing.targetSha ?? sha
309
+ });
310
+ }
311
+
312
+ return [...tagTargets.values()];
313
+ }
314
+
292
315
  export function hasStagedChanges(cwd) {
293
316
  const result = runGitCommandUnchecked(["diff", "--cached", "--quiet"], cwd);
294
317
 
@@ -666,6 +689,193 @@ function parseUniqueFiles(...groups) {
666
689
  return [...new Set(groups.flatMap((group) => group.split("\n").map((line) => line.trim()).filter(Boolean)))];
667
690
  }
668
691
 
692
+ function buildFileScopedDisplayRef(targetRef, filePath) {
693
+ return `${targetRef} :: ${filePath}`;
694
+ }
695
+
696
+ function extractConflictBlocks(fileContent) {
697
+ const lines = fileContent.split("\n");
698
+ const blocks = [];
699
+ let index = 0;
700
+
701
+ while (index < lines.length) {
702
+ if (!lines[index].startsWith("<<<<<<<")) {
703
+ index += 1;
704
+ continue;
705
+ }
706
+
707
+ const startLine = index + 1;
708
+ const currentLabel = lines[index].slice("<<<<<<<".length).trim() || "current";
709
+ index += 1;
710
+
711
+ const currentLines = [];
712
+ while (index < lines.length && !lines[index].startsWith("=======")) {
713
+ currentLines.push(lines[index]);
714
+ index += 1;
715
+ }
716
+
717
+ if (index >= lines.length) {
718
+ break;
719
+ }
720
+
721
+ index += 1;
722
+ const incomingLines = [];
723
+ while (index < lines.length && !lines[index].startsWith(">>>>>>>")) {
724
+ incomingLines.push(lines[index]);
725
+ index += 1;
726
+ }
727
+
728
+ if (index >= lines.length) {
729
+ break;
730
+ }
731
+
732
+ const incomingLabel = lines[index].slice(">>>>>>>".length).trim() || "incoming";
733
+ const endLine = index + 1;
734
+ index += 1;
735
+
736
+ blocks.push({
737
+ startLine,
738
+ endLine,
739
+ currentLabel,
740
+ incomingLabel,
741
+ currentText: currentLines.join("\n"),
742
+ incomingText: incomingLines.join("\n")
743
+ });
744
+ }
745
+
746
+ return blocks;
747
+ }
748
+
749
+ function buildConflictAnalysisDiff(conflicts) {
750
+ return conflicts
751
+ .map((fileConflict) => {
752
+ const blockText = fileConflict.blocks
753
+ .map(
754
+ (block, idx) =>
755
+ [
756
+ `Conflict ${idx + 1} (${fileConflict.filePath}:${block.startLine}-${block.endLine})`,
757
+ `Current Side (${block.currentLabel}):`,
758
+ block.currentText || "<empty>",
759
+ `Incoming Side (${block.incomingLabel}):`,
760
+ block.incomingText || "<empty>"
761
+ ].join("\n")
762
+ )
763
+ .join("\n\n");
764
+
765
+ return [`File: ${fileConflict.filePath}`, blockText].join("\n");
766
+ })
767
+ .join("\n\n");
768
+ }
769
+
770
+ function formatIsoDateFromUnixTimestamp(value) {
771
+ const timestampMs = Number.parseInt(value, 10) * 1000;
772
+ if (Number.isNaN(timestampMs)) {
773
+ return "unknown-date";
774
+ }
775
+
776
+ return new Date(timestampMs).toISOString().slice(0, 10);
777
+ }
778
+
779
+ function parseBlamePorcelain(porcelain) {
780
+ const records = [];
781
+ const lines = porcelain.split("\n");
782
+ let index = 0;
783
+
784
+ while (index < lines.length) {
785
+ const header = lines[index]?.trim();
786
+ if (!header) {
787
+ index += 1;
788
+ continue;
789
+ }
790
+
791
+ const headerMatch = header.match(/^([0-9a-f]{7,40}|\^?[0-9a-f]{7,40})\s+\d+\s+(\d+)\s+(\d+)$/i);
792
+ if (!headerMatch) {
793
+ index += 1;
794
+ continue;
795
+ }
796
+
797
+ const [, commitSha, finalLineRaw, lineCountRaw] = headerMatch;
798
+ const record = {
799
+ commitSha,
800
+ finalLine: Number.parseInt(finalLineRaw, 10),
801
+ lineCount: Number.parseInt(lineCountRaw, 10),
802
+ author: "Unknown Author",
803
+ authorMail: "",
804
+ authorTime: "",
805
+ summary: "",
806
+ code: ""
807
+ };
808
+
809
+ index += 1;
810
+ while (index < lines.length) {
811
+ const line = lines[index];
812
+ if (line.startsWith("\t")) {
813
+ record.code = line.slice(1);
814
+ index += 1;
815
+ break;
816
+ }
817
+
818
+ if (line.startsWith("author ")) {
819
+ record.author = line.slice("author ".length).trim();
820
+ } else if (line.startsWith("author-mail ")) {
821
+ record.authorMail = line.slice("author-mail ".length).trim();
822
+ } else if (line.startsWith("author-time ")) {
823
+ record.authorTime = line.slice("author-time ".length).trim();
824
+ } else if (line.startsWith("summary ")) {
825
+ record.summary = line.slice("summary ".length).trim();
826
+ }
827
+
828
+ index += 1;
829
+ }
830
+
831
+ records.push(record);
832
+ }
833
+
834
+ return records;
835
+ }
836
+
837
+ function buildBlameAnalysisDiff(filePath, records) {
838
+ const byAuthor = new Map();
839
+
840
+ for (const record of records) {
841
+ const key = `${record.author}|${record.authorMail}`;
842
+ const existing = byAuthor.get(key) ?? {
843
+ author: record.author,
844
+ authorMail: record.authorMail,
845
+ lineCount: 0,
846
+ commitShas: new Set(),
847
+ summaries: new Set()
848
+ };
849
+
850
+ existing.lineCount += 1;
851
+ existing.commitShas.add(record.commitSha);
852
+ if (record.summary) {
853
+ existing.summaries.add(record.summary);
854
+ }
855
+ byAuthor.set(key, existing);
856
+ }
857
+
858
+ const authorSection = [...byAuthor.values()]
859
+ .sort((left, right) => right.lineCount - left.lineCount)
860
+ .map(
861
+ (entry) =>
862
+ `- ${entry.author}${entry.authorMail ? ` ${entry.authorMail}` : ""}: ${entry.lineCount} line(s), ${entry.commitShas.size} commit(s), notable commits: ${[...entry.summaries].slice(0, 3).join("; ") || "n/a"}`
863
+ )
864
+ .join("\n");
865
+
866
+ const lineSection = records
867
+ .map((record) => {
868
+ const endLine = record.finalLine + record.lineCount - 1;
869
+ const lineLabel = record.lineCount > 1 ? `L${record.finalLine}-L${endLine}` : `L${record.finalLine}`;
870
+ const shortSha = record.commitSha.replace(/^\^/, "").slice(0, 8);
871
+ const authorDate = formatIsoDateFromUnixTimestamp(record.authorTime);
872
+ return `${lineLabel} | ${record.author} | ${authorDate} | ${shortSha} | ${record.summary || "no summary"} | ${record.code}`;
873
+ })
874
+ .join("\n");
875
+
876
+ return [`Blame summary for ${filePath}`, "", "Authors:", authorSection || "- none", "", "Line annotations:", lineSection].join("\n");
877
+ }
878
+
669
879
  export function fetchWorkingTreeData(cwd) {
670
880
  const stagedDiff = getUncheckedCommandOutput(["diff", "--cached"], cwd);
671
881
  const unstagedDiff = getUncheckedCommandOutput(["diff"], cwd);
@@ -708,6 +918,87 @@ export function fetchWorkingTreeData(cwd) {
708
918
  };
709
919
  }
710
920
 
921
+ export function fetchBlameData(filePath, cwd, runner = runGitCommand) {
922
+ const porcelain = runner(["blame", "--line-porcelain", "--", filePath], cwd);
923
+ const records = parseBlamePorcelain(porcelain);
924
+ const authorCount = new Set(records.map((record) => `${record.author}|${record.authorMail}`)).size;
925
+
926
+ return {
927
+ analysisType: "blame",
928
+ targetRef: `blame:${filePath}`,
929
+ displayRef: filePath,
930
+ commitId: null,
931
+ commitCount: records.length,
932
+ commits: [],
933
+ commitMessage: `Blame analysis for ${filePath}`,
934
+ diff: buildBlameAnalysisDiff(filePath, records),
935
+ filesChanged: [filePath],
936
+ stats: `${records.length} line annotation${records.length === 1 ? "" : "s"} across ${authorCount} author${authorCount === 1 ? "" : "s"}`
937
+ };
938
+ }
939
+
940
+ export function fetchStashData(stashRef = null, cwd, filePath = null, runner = runGitCommand) {
941
+ const resolvedStashRef = resolveStashRef(stashRef);
942
+ const fileArgs = filePath ? ["--", filePath] : [];
943
+ const commitMessage = runner(["log", "-1", "--pretty=format:%gs", resolvedStashRef], cwd);
944
+ const diff = runner(["stash", "show", "-p", resolvedStashRef, ...fileArgs], cwd);
945
+ const filesChangedRaw = runner(["stash", "show", "--name-only", resolvedStashRef, ...fileArgs], cwd);
946
+ const statsRaw = runner(["stash", "show", "--stat", resolvedStashRef, ...fileArgs], cwd);
947
+
948
+ return {
949
+ analysisType: "stash",
950
+ targetRef: resolvedStashRef,
951
+ displayRef: filePath ? buildFileScopedDisplayRef(resolvedStashRef, filePath) : resolvedStashRef,
952
+ commitId: resolvedStashRef,
953
+ commitCount: 1,
954
+ commits: [{ hash: resolvedStashRef, subject: commitMessage, body: commitMessage }],
955
+ commitMessage: commitMessage || `Stash entry ${resolvedStashRef}`,
956
+ diff,
957
+ filesChanged: parseFilesChanged(filesChangedRaw),
958
+ stats: parseStatsLine(statsRaw)
959
+ };
960
+ }
961
+
962
+ export function fetchConflictData(cwd, filePath = null, runner = runGitCommand) {
963
+ const fileArgs = filePath ? ["--", filePath] : [];
964
+ const conflictedFilesRaw = runner(["diff", "--name-only", "--diff-filter=U", ...fileArgs], cwd);
965
+ const conflictedFiles = parseFilesChanged(conflictedFilesRaw);
966
+
967
+ if (conflictedFiles.length === 0) {
968
+ throw new Error(filePath ? `No unresolved merge conflicts found for ${filePath}.` : "No unresolved merge conflicts found in the working tree.");
969
+ }
970
+
971
+ const conflicts = conflictedFiles.map((relativePath) => {
972
+ const absolutePath = path.resolve(cwd, relativePath);
973
+ const content = readFileSync(absolutePath, "utf8");
974
+ const blocks = extractConflictBlocks(content);
975
+
976
+ return {
977
+ filePath: relativePath,
978
+ blocks
979
+ };
980
+ }).filter((entry) => entry.blocks.length > 0);
981
+
982
+ if (conflicts.length === 0) {
983
+ throw new Error(filePath ? `Conflict markers were not found in ${filePath}.` : "Git reports unresolved conflicts, but no conflict markers were found in the conflicted files.");
984
+ }
985
+
986
+ const conflictCount = conflicts.reduce((sum, entry) => sum + entry.blocks.length, 0);
987
+
988
+ return {
989
+ analysisType: "conflict",
990
+ targetRef: filePath ? `conflict:${filePath}` : "conflict",
991
+ displayRef: filePath ?? "working-tree conflicts",
992
+ commitId: null,
993
+ commitCount: conflictCount,
994
+ commits: [],
995
+ commitMessage: filePath ? `Merge conflict analysis for ${filePath}` : "Merge conflict analysis for the working tree",
996
+ diff: buildConflictAnalysisDiff(conflicts),
997
+ filesChanged: conflicts.map((entry) => entry.filePath),
998
+ stats: `${conflictCount} conflict block${conflictCount === 1 ? "" : "s"} across ${conflicts.length} file${conflicts.length === 1 ? "" : "s"}`
999
+ };
1000
+ }
1001
+
711
1002
  function fetchSingleCommitData(commitId, cwd, runner) {
712
1003
  const commitMessage = runner(["log", "-1", "--pretty=format:%B", commitId], cwd);
713
1004
  const diff = runner(["diff", `${commitId}^!`], cwd);
@@ -729,6 +1020,27 @@ function fetchSingleCommitData(commitId, cwd, runner) {
729
1020
  };
730
1021
  }
731
1022
 
1023
+ function fetchSingleCommitFileData(commitId, filePath, cwd, runner) {
1024
+ const commitMessage = runner(["log", "-1", "--pretty=format:%B", commitId], cwd);
1025
+ const diff = runner(["diff", `${commitId}^!`, "--", filePath], cwd);
1026
+ const filesChangedRaw = runner(["show", "--pretty=format:", "--name-only", commitId, "--", filePath], cwd);
1027
+ const statsRaw = runner(["show", "--stat", "--oneline", "--format=%h %s", commitId, "--", filePath], cwd);
1028
+ const subject = runner(["log", "-1", "--pretty=format:%s", commitId], cwd);
1029
+
1030
+ return {
1031
+ analysisType: "commit",
1032
+ targetRef: commitId,
1033
+ displayRef: buildFileScopedDisplayRef(commitId, filePath),
1034
+ commitId,
1035
+ commitCount: 1,
1036
+ commits: [{ hash: commitId, subject, body: commitMessage }],
1037
+ commitMessage,
1038
+ diff,
1039
+ filesChanged: parseFilesChanged(filesChangedRaw),
1040
+ stats: parseStatsLine(statsRaw)
1041
+ };
1042
+ }
1043
+
732
1044
  function fetchRangeData(rangeRef, cwd, runner) {
733
1045
  const diff = runner(["diff", rangeRef], cwd);
734
1046
  const filesChangedRaw = runner(["diff", "--name-only", rangeRef], cwd);
@@ -757,8 +1069,42 @@ function fetchRangeData(rangeRef, cwd, runner) {
757
1069
  };
758
1070
  }
759
1071
 
1072
+ function fetchRangeFileData(rangeRef, filePath, cwd, runner) {
1073
+ const diff = runner(["diff", rangeRef, "--", filePath], cwd);
1074
+ const filesChangedRaw = runner(["diff", "--name-only", rangeRef, "--", filePath], cwd);
1075
+ const statsRaw = runner(["diff", "--stat", rangeRef, "--", filePath], cwd);
1076
+ const commitLogRaw = runner(
1077
+ ["log", "--reverse", "--pretty=format:%H%x1f%s%x1f%B", rangeRef, "--", filePath],
1078
+ cwd
1079
+ );
1080
+
1081
+ const commits = parseCommitLog(commitLogRaw);
1082
+ if (commits.length === 0) {
1083
+ throw new Error(`No commits found in range ${rangeRef} for file ${filePath}`);
1084
+ }
1085
+
1086
+ return {
1087
+ analysisType: "range",
1088
+ targetRef: rangeRef,
1089
+ displayRef: buildFileScopedDisplayRef(rangeRef, filePath),
1090
+ commitId: null,
1091
+ commitCount: commits.length,
1092
+ commits,
1093
+ commitMessage: buildCommitMessage(commits),
1094
+ diff,
1095
+ filesChanged: parseFilesChanged(filesChangedRaw),
1096
+ stats: parseStatsLine(statsRaw)
1097
+ };
1098
+ }
1099
+
760
1100
  export function fetchCommitData(targetRef, cwd, runner = runGitCommand) {
761
1101
  return isRangeRef(targetRef)
762
1102
  ? fetchRangeData(targetRef, cwd, runner)
763
1103
  : fetchSingleCommitData(targetRef, cwd, runner);
764
1104
  }
1105
+
1106
+ export function fetchCommitDataForFile(targetRef, filePath, cwd, runner = runGitCommand) {
1107
+ return isRangeRef(targetRef)
1108
+ ? fetchRangeFileData(targetRef, filePath, cwd, runner)
1109
+ : fetchSingleCommitFileData(targetRef, filePath, cwd, runner);
1110
+ }
@@ -1,7 +1,34 @@
1
- import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { runGitCommand } from "./gitService.js";
4
4
 
5
+ const HOOK_MARKER = "# gitxplain-hook";
6
+
7
+ function buildHookScript(hookName, outputDir) {
8
+ if (hookName === "post-commit") {
9
+ return `#!/bin/sh
10
+ ${HOOK_MARKER}
11
+ gitxplain HEAD --summary --markdown --quiet > "${path.join(outputDir, "last-explanation.md")}" 2>/dev/null || true
12
+ `;
13
+ }
14
+
15
+ if (hookName === "post-merge") {
16
+ return `#!/bin/sh
17
+ ${HOOK_MARKER}
18
+ gitxplain HEAD --summary --markdown --quiet > "${path.join(outputDir, "last-merge-explanation.md")}" 2>/dev/null || true
19
+ `;
20
+ }
21
+
22
+ if (hookName === "pre-push") {
23
+ return `#!/bin/sh
24
+ ${HOOK_MARKER}
25
+ gitxplain HEAD --security --markdown --quiet > "${path.join(outputDir, "last-pre-push-security.md")}" 2>/dev/null || true
26
+ `;
27
+ }
28
+
29
+ throw new Error(`Unsupported hook "${hookName}". Supported hooks: post-commit, post-merge, pre-push.`);
30
+ }
31
+
5
32
  export function installHook({ cwd, hookName = "post-commit" }) {
6
33
  const gitDir = runGitCommand(["rev-parse", "--git-dir"], cwd);
7
34
  const hookDir = path.resolve(cwd, gitDir, "hooks");
@@ -11,9 +38,14 @@ export function installHook({ cwd, hookName = "post-commit" }) {
11
38
  mkdirSync(hookDir, { recursive: true });
12
39
  mkdirSync(outputDir, { recursive: true });
13
40
 
14
- const script = `#!/bin/sh
15
- gitxplain HEAD --summary --markdown --quiet > "${path.join(outputDir, "last-explanation.md")}" 2>/dev/null || true
16
- `;
41
+ const script = buildHookScript(hookName, outputDir);
42
+
43
+ if (existsSync(hookPath)) {
44
+ const existing = readFileSync(hookPath, "utf8");
45
+ if (!existing.includes(HOOK_MARKER)) {
46
+ throw new Error(`Hook ${hookName} already exists at ${hookPath}. Refusing to overwrite a non-gitxplain hook.`);
47
+ }
48
+ }
17
49
 
18
50
  writeFileSync(hookPath, script, "utf8");
19
51
  chmodSync(hookPath, 0o755);
@@ -1,4 +1,3 @@
1
- import process from "node:process";
2
1
  import {
3
2
  createCommitFromTree,
4
3
  getCommitMetadata,
@@ -15,19 +14,13 @@ import {
15
14
  listBranchCommits,
16
15
  listCommitsAfter,
17
16
  listTags,
17
+ listTagTargets,
18
18
  localBranchExists,
19
19
  resolveTreeSha,
20
20
  resolveCommitSha,
21
21
  runGitCommand
22
22
  } from "./gitService.js";
23
-
24
- const ANSI = {
25
- reset: "\u001b[0m",
26
- bold: "\u001b[1m",
27
- cyan: "\u001b[36m",
28
- yellow: "\u001b[33m",
29
- green: "\u001b[32m"
30
- };
23
+ import { ANSI, colorize } from "./colorSupport.js";
31
24
 
32
25
  const RELEASE_BRANCH = "release";
33
26
  const VERSION_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/g;
@@ -36,18 +29,6 @@ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$
36
29
  const INTEGER_PATTERN = /^\d+$/;
37
30
  const TAG_VERSION_PATTERN = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?|\d+)$/;
38
31
 
39
- function supportsColor() {
40
- return Boolean(process.stdout?.isTTY) && process.env.NO_COLOR == null;
41
- }
42
-
43
- function colorize(text, color) {
44
- if (!supportsColor()) {
45
- return text;
46
- }
47
-
48
- return `${color}${text}${ANSI.reset}`;
49
- }
50
-
51
32
  function unique(values) {
52
33
  return [...new Set(values)];
53
34
  }
@@ -335,21 +316,38 @@ export function selectReleaseWindows(sourceCommits, releaseCommits = []) {
335
316
  };
336
317
  }
337
318
 
338
- export function selectReleaseTags(sourceCommits, existingTagNames = []) {
319
+ export function selectReleaseTags(sourceCommits, existingTagNames = [], existingTagTargets = []) {
339
320
  const windows = selectLatestWindowsPerVersion(buildReleaseWindows(sourceCommits));
340
321
  const taggedVersions = extractTaggedVersions(existingTagNames);
322
+ const targetByVersion = new Map(
323
+ existingTagTargets
324
+ .map((tag) => {
325
+ const version = tag.tagName?.match(TAG_VERSION_PATTERN)?.[1] ?? null;
326
+ return version ? [version, tag.targetSha] : null;
327
+ })
328
+ .filter(Boolean)
329
+ );
341
330
  const tags = windows
342
- .filter((window) => !taggedVersions.has(window.version))
343
331
  .map((window) => {
344
332
  const targetCommit = window.commits.at(-1) ?? null;
333
+ const existingTargetSha = targetByVersion.get(window.version) ?? null;
334
+ const windowCommitShas = new Set(window.commits.map((commit) => commit.sha));
335
+
345
336
  return {
346
337
  ...window,
347
338
  tagName: window.version,
339
+ existingTargetSha,
340
+ needsMove:
341
+ existingTargetSha != null &&
342
+ targetCommit?.sha != null &&
343
+ windowCommitShas.has(existingTargetSha) &&
344
+ existingTargetSha !== targetCommit.sha,
348
345
  targetSha: targetCommit?.sha ?? null,
349
346
  targetShortSha: targetCommit?.shortSha ?? null,
350
347
  targetSubject: targetCommit?.subject ?? null
351
348
  };
352
349
  })
350
+ .filter((tag) => !taggedVersions.has(tag.version) || tag.needsMove)
353
351
  .filter((tag) => tag.targetSha != null);
354
352
 
355
353
  return {
@@ -370,7 +368,8 @@ function findLatestTaggedSourceVersion(sourceCommits, taggedVersions) {
370
368
  function buildReleaseTagPlanForSource(sourceBranch, sourceRef, cwd) {
371
369
  const sourceCommits = listBranchCommits(sourceRef, cwd).map((sha) => inspectCommit(sha, cwd));
372
370
  const existingTagNames = listTags(cwd);
373
- const selection = selectReleaseTags(sourceCommits, existingTagNames);
371
+ const existingTagTargets = listTagTargets(cwd);
372
+ const selection = selectReleaseTags(sourceCommits, existingTagNames, existingTagTargets);
374
373
 
375
374
  return {
376
375
  sourceBranch,
@@ -655,15 +654,18 @@ export function formatReleaseTagPlan(plan) {
655
654
  ];
656
655
 
657
656
  if (plan.tags.length === 0) {
658
- lines.push(colorize("No unreleased release tags detected. Nothing to tag.", ANSI.green));
657
+ lines.push(colorize("No release tag changes detected. Nothing to tag.", ANSI.green));
659
658
  return lines.join("\n");
660
659
  }
661
660
 
662
661
  for (const tag of plan.tags) {
663
662
  lines.push("");
664
- lines.push(colorize(`tag ${tag.tagName}`, ANSI.bold + ANSI.yellow));
663
+ lines.push(colorize(`${tag.needsMove ? "move tag" : "tag"} ${tag.tagName}`, ANSI.bold + ANSI.yellow));
665
664
  lines.push(`${colorize("Commit Range:", ANSI.bold + ANSI.cyan)} ${tag.startRef}..${tag.endRef}`);
666
665
  lines.push(`${colorize("Target Commit:", ANSI.bold + ANSI.cyan)} ${tag.targetShortSha} ${tag.targetSubject}`);
666
+ if (tag.needsMove) {
667
+ lines.push(`${colorize("Action:", ANSI.bold + ANSI.cyan)} move existing tag to the latest commit for ${tag.tagName}`);
668
+ }
667
669
 
668
670
  for (const commit of tag.commits) {
669
671
  lines.push(`${colorize(commit.shortSha, ANSI.bold + ANSI.cyan)} ${commit.subject}`);
@@ -817,13 +819,17 @@ export function executeReleaseMerge(plan, cwd) {
817
819
 
818
820
  export function executeReleaseTagPlan(plan, cwd) {
819
821
  if (plan.tags.length === 0) {
820
- throw new Error("No unreleased release tags detected. Nothing to tag.");
822
+ throw new Error("No release tag changes detected. Nothing to tag.");
821
823
  }
822
824
 
823
825
  const createdTags = [];
824
826
 
825
827
  try {
826
828
  for (const tag of plan.tags) {
829
+ if (tag.needsMove) {
830
+ gitDeleteTag(tag.tagName, cwd);
831
+ }
832
+
827
833
  gitCreateAnnotatedTag(tag.tagName, tag.targetSha, `release ${tag.tagName}`, cwd);
828
834
  createdTags.push(tag.tagName);
829
835
  }