gitxplain 0.1.6 → 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.
@@ -1,7 +1,57 @@
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
+ "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"
41
+ ]);
42
+
43
+ const PROVIDER_API_KEY_FIELDS = {
44
+ openai: "OPENAI_API_KEY",
45
+ groq: "GROQ_API_KEY",
46
+ openrouter: "OPENROUTER_API_KEY",
47
+ gemini: "GEMINI_API_KEY",
48
+ ollama: "OLLAMA_API_KEY",
49
+ chutes: "CHUTES_API_KEY",
50
+ anthropic: "ANTHROPIC_API_KEY",
51
+ mistral: "MISTRAL_API_KEY",
52
+ "azure-openai": "AZURE_OPENAI_API_KEY"
53
+ };
54
+
5
55
  function readJsonConfig(filePath) {
6
56
  if (!existsSync(filePath)) {
7
57
  return {};
@@ -14,9 +64,16 @@ function readJsonConfig(filePath) {
14
64
  }
15
65
  }
16
66
 
67
+ export function getUserConfigPath() {
68
+ return path.join(os.homedir(), ".gitxplain", "config.json");
69
+ }
70
+
71
+ export function loadUserConfig() {
72
+ return readJsonConfig(getUserConfigPath());
73
+ }
74
+
17
75
  export function loadConfig(cwd) {
18
- const homeDir = os.homedir();
19
- const userConfigPath = path.join(homeDir, ".gitxplain", "config.json");
76
+ const userConfigPath = getUserConfigPath();
20
77
  const projectConfigPath = path.join(cwd, ".gitxplainrc");
21
78
  const projectJsonConfigPath = path.join(cwd, ".gitxplainrc.json");
22
79
 
@@ -26,3 +83,34 @@ export function loadConfig(cwd) {
26
83
  ...readJsonConfig(projectJsonConfigPath)
27
84
  };
28
85
  }
86
+
87
+ export function applyConfigEnvironment(config) {
88
+ for (const [key, value] of Object.entries(config)) {
89
+ if (!ENV_CONFIG_KEYS.has(key)) {
90
+ continue;
91
+ }
92
+
93
+ if (typeof value === "string" && value !== "" && !process.env[key]) {
94
+ process.env[key] = value;
95
+ }
96
+ }
97
+ }
98
+
99
+ export function getProviderApiKeyField(provider) {
100
+ const normalized = provider?.toLowerCase();
101
+ return normalized ? PROVIDER_API_KEY_FIELDS[normalized] ?? null : null;
102
+ }
103
+
104
+ export function writeUserConfig(nextConfig) {
105
+ const configPath = getUserConfigPath();
106
+ mkdirSync(path.dirname(configPath), { recursive: true });
107
+ writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
108
+ return configPath;
109
+ }
110
+
111
+ export function updateUserConfig(updates) {
112
+ const currentConfig = loadUserConfig();
113
+ const nextConfig = { ...currentConfig, ...updates };
114
+ const configPath = writeUserConfig(nextConfig);
115
+ return { configPath, config: nextConfig };
116
+ }
@@ -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);