gitxplain 0.1.8 → 0.2.0
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.
- package/.github/workflows/ci.yml +2 -0
- package/.github/workflows/release.yml +92 -5
- package/README.md +227 -4
- package/cli/index.js +439 -114
- package/cli/services/aiService.js +234 -28
- package/cli/services/cacheService.js +92 -1
- package/cli/services/clipboardService.js +6 -1
- package/cli/services/colorSupport.js +31 -0
- package/cli/services/commitService.js +105 -23
- package/cli/services/configService.js +18 -2
- package/cli/services/envLoader.js +2 -2
- package/cli/services/gitService.js +369 -23
- package/cli/services/hookService.js +36 -4
- package/cli/services/mergeService.js +43 -30
- package/cli/services/outputFormatter.js +23 -73
- package/cli/services/pipelineService.js +344 -9
- package/cli/services/promptService.js +8 -1
- package/cli/services/splitService.js +1 -21
- package/cli/services/usageService.js +158 -0
- package/package.json +4 -4
- package/packaging/README.md +60 -0
- package/packaging/aur/.SRCINFO +12 -0
- package/packaging/aur/PKGBUILD +22 -0
- package/packaging/homebrew-tap/Formula/gitxplain.rb +19 -0
- package/prompts/blame.txt +29 -0
- package/prompts/changelog.txt +36 -0
- package/prompts/conflict.txt +33 -0
- package/prompts/pr-description.txt +40 -0
- package/prompts/refactor.txt +29 -0
- package/prompts/stash.txt +34 -0
- package/prompts/test-suggest.txt +29 -0
- package/scripts/build-deb.sh +64 -0
- package/IMPLEMENTATION.md +0 -225
- package/cli/services/chatService.js +0 -683
- package/cli/services/gitConnectionService.js +0 -267
|
@@ -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(
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 =
|
|
15
|
-
|
|
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);
|