@mhalder/qdrant-mcp-server 2.2.0 → 3.1.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 +4 -1
- package/CHANGELOG.md +22 -0
- package/README.md +78 -3
- package/build/code/indexer.d.ts +2 -1
- package/build/code/indexer.d.ts.map +1 -1
- package/build/code/indexer.js +37 -7
- package/build/code/indexer.js.map +1 -1
- package/build/git/chunker.d.ts +39 -0
- package/build/git/chunker.d.ts.map +1 -0
- package/build/git/chunker.js +210 -0
- package/build/git/chunker.js.map +1 -0
- package/build/git/chunker.test.d.ts +2 -0
- package/build/git/chunker.test.d.ts.map +1 -0
- package/build/git/chunker.test.js +230 -0
- package/build/git/chunker.test.js.map +1 -0
- package/build/git/config.d.ts +34 -0
- package/build/git/config.d.ts.map +1 -0
- package/build/git/config.js +163 -0
- package/build/git/config.js.map +1 -0
- package/build/git/extractor.d.ts +57 -0
- package/build/git/extractor.d.ts.map +1 -0
- package/build/git/extractor.integration.test.d.ts +6 -0
- package/build/git/extractor.integration.test.d.ts.map +1 -0
- package/build/git/extractor.integration.test.js +166 -0
- package/build/git/extractor.integration.test.js.map +1 -0
- package/build/git/extractor.js +231 -0
- package/build/git/extractor.js.map +1 -0
- package/build/git/extractor.test.d.ts +2 -0
- package/build/git/extractor.test.d.ts.map +1 -0
- package/build/git/extractor.test.js +267 -0
- package/build/git/extractor.test.js.map +1 -0
- package/build/git/index.d.ts +10 -0
- package/build/git/index.d.ts.map +1 -0
- package/build/git/index.js +11 -0
- package/build/git/index.js.map +1 -0
- package/build/git/indexer.d.ts +50 -0
- package/build/git/indexer.d.ts.map +1 -0
- package/build/git/indexer.js +588 -0
- package/build/git/indexer.js.map +1 -0
- package/build/git/indexer.test.d.ts +2 -0
- package/build/git/indexer.test.d.ts.map +1 -0
- package/build/git/indexer.test.js +867 -0
- package/build/git/indexer.test.js.map +1 -0
- package/build/git/sync/synchronizer.d.ts +43 -0
- package/build/git/sync/synchronizer.d.ts.map +1 -0
- package/build/git/sync/synchronizer.js +108 -0
- package/build/git/sync/synchronizer.js.map +1 -0
- package/build/git/sync/synchronizer.test.d.ts +2 -0
- package/build/git/sync/synchronizer.test.d.ts.map +1 -0
- package/build/git/sync/synchronizer.test.js +188 -0
- package/build/git/sync/synchronizer.test.js.map +1 -0
- package/build/git/types.d.ts +159 -0
- package/build/git/types.d.ts.map +1 -0
- package/build/git/types.js +5 -0
- package/build/git/types.js.map +1 -0
- package/build/index.js +18 -0
- package/build/index.js.map +1 -1
- package/build/tools/git-history.d.ts +10 -0
- package/build/tools/git-history.d.ts.map +1 -0
- package/build/tools/git-history.js +144 -0
- package/build/tools/git-history.js.map +1 -0
- package/build/tools/index.d.ts +2 -0
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +4 -0
- package/build/tools/index.js.map +1 -1
- package/build/tools/schemas.d.ts +24 -0
- package/build/tools/schemas.d.ts.map +1 -1
- package/build/tools/schemas.js +64 -0
- package/build/tools/schemas.js.map +1 -1
- package/package.json +2 -2
- package/src/code/indexer.ts +49 -7
- package/src/git/chunker.test.ts +284 -0
- package/src/git/chunker.ts +256 -0
- package/src/git/config.ts +173 -0
- package/src/git/extractor.integration.test.ts +221 -0
- package/src/git/extractor.test.ts +403 -0
- package/src/git/extractor.ts +284 -0
- package/src/git/index.ts +31 -0
- package/src/git/indexer.test.ts +1089 -0
- package/src/git/indexer.ts +745 -0
- package/src/git/sync/synchronizer.test.ts +250 -0
- package/src/git/sync/synchronizer.ts +122 -0
- package/src/git/types.ts +192 -0
- package/src/index.ts +42 -0
- package/src/tools/git-history.ts +208 -0
- package/src/tools/index.ts +7 -0
- package/src/tools/schemas.ts +75 -0
- package/vitest.config.ts +2 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for GitExtractor
|
|
3
|
+
* These tests run against real git repositories (not mocked)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
7
|
+
import { GitExtractor } from "./extractor.js";
|
|
8
|
+
import { DEFAULT_GIT_CONFIG } from "./config.js";
|
|
9
|
+
import type { GitConfig } from "./types.js";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
describe("GitExtractor Integration Tests", () => {
|
|
16
|
+
let extractor: GitExtractor;
|
|
17
|
+
const config: GitConfig = { ...DEFAULT_GIT_CONFIG, maxCommits: 100 };
|
|
18
|
+
|
|
19
|
+
// Use the current repository for integration tests
|
|
20
|
+
const repoPath = process.cwd();
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
extractor = new GitExtractor(repoPath, config);
|
|
24
|
+
|
|
25
|
+
// Verify we're in a git repository
|
|
26
|
+
const isRepo = await extractor.validateRepository();
|
|
27
|
+
if (!isRepo) {
|
|
28
|
+
throw new Error("Integration tests must be run from a git repository");
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("validateRepository", () => {
|
|
33
|
+
it("should detect valid git repository", async () => {
|
|
34
|
+
const result = await extractor.validateRepository();
|
|
35
|
+
expect(result).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should return false for non-existent path", async () => {
|
|
39
|
+
const badExtractor = new GitExtractor("/nonexistent/path", config);
|
|
40
|
+
const result = await badExtractor.validateRepository();
|
|
41
|
+
expect(result).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("getCommits - data integrity", () => {
|
|
46
|
+
it("should extract commits without data corruption", async () => {
|
|
47
|
+
const commits = await extractor.getCommits({ maxCommits: 50 });
|
|
48
|
+
|
|
49
|
+
expect(commits.length).toBeGreaterThan(0);
|
|
50
|
+
expect(commits.length).toBeLessThanOrEqual(50);
|
|
51
|
+
|
|
52
|
+
for (const commit of commits) {
|
|
53
|
+
// Verify hash format (40 hex characters)
|
|
54
|
+
expect(commit.hash).toMatch(/^[a-f0-9]{40}$/);
|
|
55
|
+
|
|
56
|
+
// Verify short hash format (7+ hex characters)
|
|
57
|
+
expect(commit.shortHash).toMatch(/^[a-f0-9]{7,}$/);
|
|
58
|
+
|
|
59
|
+
// Verify author is not empty
|
|
60
|
+
expect(commit.author.length).toBeGreaterThan(0);
|
|
61
|
+
|
|
62
|
+
// Verify author email format
|
|
63
|
+
expect(commit.authorEmail).toMatch(/.+@.+/);
|
|
64
|
+
|
|
65
|
+
// Verify date is valid
|
|
66
|
+
expect(commit.date).toBeInstanceOf(Date);
|
|
67
|
+
expect(commit.date.getTime()).not.toBeNaN();
|
|
68
|
+
|
|
69
|
+
// Verify subject is not empty
|
|
70
|
+
expect(commit.subject.length).toBeGreaterThan(0);
|
|
71
|
+
|
|
72
|
+
// CRITICAL: Verify fields don't contain numstat patterns
|
|
73
|
+
// This catches the parsing bug where numstat bleeds into format fields
|
|
74
|
+
const numstatPattern = /^\d+\s+\d+\s+\S+/;
|
|
75
|
+
expect(commit.hash).not.toMatch(numstatPattern);
|
|
76
|
+
expect(commit.author).not.toMatch(numstatPattern);
|
|
77
|
+
expect(commit.subject).not.toMatch(numstatPattern);
|
|
78
|
+
|
|
79
|
+
// Verify insertions/deletions are non-negative integers
|
|
80
|
+
expect(commit.insertions).toBeGreaterThanOrEqual(0);
|
|
81
|
+
expect(commit.deletions).toBeGreaterThanOrEqual(0);
|
|
82
|
+
expect(Number.isInteger(commit.insertions)).toBe(true);
|
|
83
|
+
expect(Number.isInteger(commit.deletions)).toBe(true);
|
|
84
|
+
|
|
85
|
+
// Verify files array
|
|
86
|
+
expect(Array.isArray(commit.files)).toBe(true);
|
|
87
|
+
for (const file of commit.files) {
|
|
88
|
+
expect(typeof file).toBe("string");
|
|
89
|
+
expect(file.length).toBeGreaterThan(0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should return correct commit count matching git rev-list", async () => {
|
|
95
|
+
// Get expected count from git directly
|
|
96
|
+
const { stdout } = await execFileAsync(
|
|
97
|
+
"git",
|
|
98
|
+
["rev-list", "--count", "-n", "50", "HEAD"],
|
|
99
|
+
{ cwd: repoPath },
|
|
100
|
+
);
|
|
101
|
+
const expectedCount = Math.min(parseInt(stdout.trim(), 10), 50);
|
|
102
|
+
|
|
103
|
+
// Get commits via extractor
|
|
104
|
+
const commits = await extractor.getCommits({ maxCommits: 50 });
|
|
105
|
+
|
|
106
|
+
// Should match exactly
|
|
107
|
+
expect(commits.length).toBe(expectedCount);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should extract files correctly with stats", async () => {
|
|
111
|
+
// Find a commit with files using git log
|
|
112
|
+
const { stdout: logOutput } = await execFileAsync(
|
|
113
|
+
"git",
|
|
114
|
+
["log", "--oneline", "--shortstat", "-n", "10", "HEAD"],
|
|
115
|
+
{ cwd: repoPath },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// If there are commits with files changed, verify our extractor gets them
|
|
119
|
+
if (logOutput.includes("file")) {
|
|
120
|
+
const commits = await extractor.getCommits({ maxCommits: 10 });
|
|
121
|
+
const commitsWithFiles = commits.filter((c) => c.files.length > 0);
|
|
122
|
+
|
|
123
|
+
// At least some commits should have files
|
|
124
|
+
expect(commitsWithFiles.length).toBeGreaterThan(0);
|
|
125
|
+
|
|
126
|
+
// Verify files and stats are consistent
|
|
127
|
+
for (const commit of commitsWithFiles) {
|
|
128
|
+
// If there are files, there should typically be insertions or deletions
|
|
129
|
+
// (unless all files are renames with no changes)
|
|
130
|
+
expect(commit.files.length).toBeGreaterThan(0);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("getCommits - range filtering", () => {
|
|
137
|
+
it("should support sinceCommit range filtering", async () => {
|
|
138
|
+
// Get all commits first
|
|
139
|
+
const allCommits = await extractor.getCommits({ maxCommits: 20 });
|
|
140
|
+
|
|
141
|
+
if (allCommits.length >= 5) {
|
|
142
|
+
// Use the 5th commit as the "since" point
|
|
143
|
+
const sinceHash = allCommits[4].hash;
|
|
144
|
+
|
|
145
|
+
// Get commits since that point
|
|
146
|
+
const recentCommits = await extractor.getCommits({
|
|
147
|
+
sinceCommit: sinceHash,
|
|
148
|
+
maxCommits: 20,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Should have fewer commits (the 4 before the since point)
|
|
152
|
+
expect(recentCommits.length).toBeLessThan(allCommits.length);
|
|
153
|
+
expect(recentCommits.length).toBe(4);
|
|
154
|
+
|
|
155
|
+
// Verify the commits are the expected ones
|
|
156
|
+
for (let i = 0; i < recentCommits.length; i++) {
|
|
157
|
+
expect(recentCommits[i].hash).toBe(allCommits[i].hash);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("getCommitDiff", () => {
|
|
164
|
+
it("should return diff for a valid commit", async () => {
|
|
165
|
+
const commits = await extractor.getCommits({ maxCommits: 1 });
|
|
166
|
+
|
|
167
|
+
if (commits.length > 0) {
|
|
168
|
+
const diff = await extractor.getCommitDiff(commits[0].hash);
|
|
169
|
+
|
|
170
|
+
// Diff should contain commit information
|
|
171
|
+
expect(diff).toContain("commit");
|
|
172
|
+
expect(diff).toContain(commits[0].hash);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should return empty string for invalid commit", async () => {
|
|
177
|
+
const diff = await extractor.getCommitDiff("0000000000000000000000000000000000000000");
|
|
178
|
+
expect(diff).toBe("");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("getLatestCommitHash", () => {
|
|
183
|
+
it("should return the HEAD commit hash", async () => {
|
|
184
|
+
const hash = await extractor.getLatestCommitHash();
|
|
185
|
+
|
|
186
|
+
// Verify format
|
|
187
|
+
expect(hash).toMatch(/^[a-f0-9]{40}$/);
|
|
188
|
+
|
|
189
|
+
// Verify it matches git rev-parse HEAD
|
|
190
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
|
|
191
|
+
cwd: repoPath,
|
|
192
|
+
});
|
|
193
|
+
expect(hash).toBe(stdout.trim());
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("getCommitCount", () => {
|
|
198
|
+
it("should return total commit count", async () => {
|
|
199
|
+
const count = await extractor.getCommitCount();
|
|
200
|
+
|
|
201
|
+
// Verify against git rev-list
|
|
202
|
+
const { stdout } = await execFileAsync(
|
|
203
|
+
"git",
|
|
204
|
+
["rev-list", "--count", "HEAD"],
|
|
205
|
+
{ cwd: repoPath },
|
|
206
|
+
);
|
|
207
|
+
expect(count).toBe(parseInt(stdout.trim(), 10));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should return count since specific commit", async () => {
|
|
211
|
+
const commits = await extractor.getCommits({ maxCommits: 10 });
|
|
212
|
+
|
|
213
|
+
if (commits.length >= 5) {
|
|
214
|
+
const sinceHash = commits[4].hash;
|
|
215
|
+
const count = await extractor.getCommitCount(sinceHash);
|
|
216
|
+
|
|
217
|
+
expect(count).toBe(4); // 4 commits between sinceHash and HEAD
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { GitExtractor, normalizeRemoteUrl } from "./extractor.js";
|
|
3
|
+
import { DEFAULT_GIT_CONFIG, GIT_LOG_COMMIT_DELIMITER } from "./config.js";
|
|
4
|
+
import type { GitConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
// Mock child_process with promisify-compatible execFile
|
|
7
|
+
vi.mock("node:child_process", () => ({
|
|
8
|
+
execFile: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("node:util", () => ({
|
|
12
|
+
promisify: (fn: any) => fn,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
const mockExecFile = vi.mocked(execFile);
|
|
18
|
+
|
|
19
|
+
describe("GitExtractor", () => {
|
|
20
|
+
let extractor: GitExtractor;
|
|
21
|
+
const config: GitConfig = { ...DEFAULT_GIT_CONFIG };
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
mockExecFile.mockReset();
|
|
25
|
+
extractor = new GitExtractor("/test/repo", config);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("validateRepository", () => {
|
|
29
|
+
it("should return true for valid git repository", async () => {
|
|
30
|
+
mockExecFile.mockResolvedValue({ stdout: ".git", stderr: "" } as any);
|
|
31
|
+
|
|
32
|
+
const result = await extractor.validateRepository();
|
|
33
|
+
|
|
34
|
+
expect(result).toBe(true);
|
|
35
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
36
|
+
"git",
|
|
37
|
+
["rev-parse", "--git-dir"],
|
|
38
|
+
expect.objectContaining({ cwd: "/test/repo" }),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should return false for non-git directory", async () => {
|
|
43
|
+
mockExecFile.mockRejectedValue(new Error("fatal: not a git repository"));
|
|
44
|
+
|
|
45
|
+
const result = await extractor.validateRepository();
|
|
46
|
+
|
|
47
|
+
expect(result).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("getLatestCommitHash", () => {
|
|
52
|
+
it("should return latest commit hash", async () => {
|
|
53
|
+
mockExecFile.mockResolvedValue({
|
|
54
|
+
stdout: "abc123def456789\n",
|
|
55
|
+
stderr: "",
|
|
56
|
+
} as any);
|
|
57
|
+
|
|
58
|
+
const result = await extractor.getLatestCommitHash();
|
|
59
|
+
|
|
60
|
+
expect(result).toBe("abc123def456789");
|
|
61
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
62
|
+
"git",
|
|
63
|
+
["rev-parse", "HEAD"],
|
|
64
|
+
expect.objectContaining({ cwd: "/test/repo" }),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("getCommitCount", () => {
|
|
70
|
+
it("should return total commit count", async () => {
|
|
71
|
+
mockExecFile.mockResolvedValue({ stdout: "42\n", stderr: "" } as any);
|
|
72
|
+
|
|
73
|
+
const result = await extractor.getCommitCount();
|
|
74
|
+
|
|
75
|
+
expect(result).toBe(42);
|
|
76
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
77
|
+
"git",
|
|
78
|
+
["rev-list", "--count", "HEAD"],
|
|
79
|
+
expect.objectContaining({ cwd: "/test/repo" }),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should return commit count since specific commit", async () => {
|
|
84
|
+
mockExecFile.mockResolvedValue({ stdout: "10\n", stderr: "" } as any);
|
|
85
|
+
|
|
86
|
+
const result = await extractor.getCommitCount("abc123");
|
|
87
|
+
|
|
88
|
+
expect(result).toBe(10);
|
|
89
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
90
|
+
"git",
|
|
91
|
+
["rev-list", "--count", "abc123..HEAD"],
|
|
92
|
+
expect.objectContaining({ cwd: "/test/repo" }),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("getCommits", () => {
|
|
98
|
+
it("should parse commits from git log output", async () => {
|
|
99
|
+
const gitLogOutput = [
|
|
100
|
+
GIT_LOG_COMMIT_DELIMITER,
|
|
101
|
+
"abc123def|abc123d|John Doe|john@example.com|2024-01-15T10:30:00Z|feat: add new feature|This is the body",
|
|
102
|
+
"",
|
|
103
|
+
"10\t5\tsrc/feature.ts",
|
|
104
|
+
"3\t1\tsrc/utils.ts",
|
|
105
|
+
GIT_LOG_COMMIT_DELIMITER,
|
|
106
|
+
"xyz789ghi|xyz789g|Jane Smith|jane@example.com|2024-01-14T09:00:00Z|fix: resolve bug|Bug fix description",
|
|
107
|
+
"",
|
|
108
|
+
"2\t8\tsrc/bug.ts",
|
|
109
|
+
].join("\n");
|
|
110
|
+
|
|
111
|
+
mockExecFile.mockResolvedValue({
|
|
112
|
+
stdout: gitLogOutput,
|
|
113
|
+
stderr: "",
|
|
114
|
+
} as any);
|
|
115
|
+
|
|
116
|
+
const result = await extractor.getCommits();
|
|
117
|
+
|
|
118
|
+
expect(result).toHaveLength(2);
|
|
119
|
+
|
|
120
|
+
// First commit
|
|
121
|
+
expect(result[0].hash).toBe("abc123def");
|
|
122
|
+
expect(result[0].shortHash).toBe("abc123d");
|
|
123
|
+
expect(result[0].author).toBe("John Doe");
|
|
124
|
+
expect(result[0].authorEmail).toBe("john@example.com");
|
|
125
|
+
expect(result[0].subject).toBe("feat: add new feature");
|
|
126
|
+
expect(result[0].body).toBe("This is the body");
|
|
127
|
+
expect(result[0].files).toEqual(["src/feature.ts", "src/utils.ts"]);
|
|
128
|
+
expect(result[0].insertions).toBe(13);
|
|
129
|
+
expect(result[0].deletions).toBe(6);
|
|
130
|
+
|
|
131
|
+
// Second commit
|
|
132
|
+
expect(result[1].hash).toBe("xyz789ghi");
|
|
133
|
+
expect(result[1].subject).toBe("fix: resolve bug");
|
|
134
|
+
expect(result[1].files).toEqual(["src/bug.ts"]);
|
|
135
|
+
expect(result[1].insertions).toBe(2);
|
|
136
|
+
expect(result[1].deletions).toBe(8);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should handle commits with no files", async () => {
|
|
140
|
+
const gitLogOutput = [
|
|
141
|
+
GIT_LOG_COMMIT_DELIMITER,
|
|
142
|
+
"abc123|abc12|Author|author@example.com|2024-01-15T10:00:00Z|chore: empty commit|",
|
|
143
|
+
].join("\n");
|
|
144
|
+
|
|
145
|
+
mockExecFile.mockResolvedValue({
|
|
146
|
+
stdout: gitLogOutput,
|
|
147
|
+
stderr: "",
|
|
148
|
+
} as any);
|
|
149
|
+
|
|
150
|
+
const result = await extractor.getCommits();
|
|
151
|
+
|
|
152
|
+
expect(result).toHaveLength(1);
|
|
153
|
+
expect(result[0].files).toEqual([]);
|
|
154
|
+
expect(result[0].insertions).toBe(0);
|
|
155
|
+
expect(result[0].deletions).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("should handle binary files in numstat", async () => {
|
|
159
|
+
const gitLogOutput = [
|
|
160
|
+
GIT_LOG_COMMIT_DELIMITER,
|
|
161
|
+
"abc123|abc12|Author|author@example.com|2024-01-15T10:00:00Z|feat: add image|",
|
|
162
|
+
"",
|
|
163
|
+
"-\t-\tassets/image.png",
|
|
164
|
+
"5\t2\tsrc/component.ts",
|
|
165
|
+
].join("\n");
|
|
166
|
+
|
|
167
|
+
mockExecFile.mockResolvedValue({
|
|
168
|
+
stdout: gitLogOutput,
|
|
169
|
+
stderr: "",
|
|
170
|
+
} as any);
|
|
171
|
+
|
|
172
|
+
const result = await extractor.getCommits();
|
|
173
|
+
|
|
174
|
+
expect(result[0].files).toEqual(["assets/image.png", "src/component.ts"]);
|
|
175
|
+
expect(result[0].insertions).toBe(5); // Binary files don't count
|
|
176
|
+
expect(result[0].deletions).toBe(2);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should handle renamed files", async () => {
|
|
180
|
+
const gitLogOutput = [
|
|
181
|
+
GIT_LOG_COMMIT_DELIMITER,
|
|
182
|
+
"abc123|abc12|Author|author@example.com|2024-01-15T10:00:00Z|refactor: rename file|",
|
|
183
|
+
"",
|
|
184
|
+
"0\t0\tsrc/{old.ts => new.ts}",
|
|
185
|
+
].join("\n");
|
|
186
|
+
|
|
187
|
+
mockExecFile.mockResolvedValue({
|
|
188
|
+
stdout: gitLogOutput,
|
|
189
|
+
stderr: "",
|
|
190
|
+
} as any);
|
|
191
|
+
|
|
192
|
+
const result = await extractor.getCommits();
|
|
193
|
+
|
|
194
|
+
expect(result[0].files).toEqual(["src/new.ts"]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should respect maxCommits option", async () => {
|
|
198
|
+
mockExecFile.mockResolvedValue({ stdout: "", stderr: "" } as any);
|
|
199
|
+
|
|
200
|
+
await extractor.getCommits({ maxCommits: 100 });
|
|
201
|
+
|
|
202
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
203
|
+
"git",
|
|
204
|
+
expect.arrayContaining(["-n100"]),
|
|
205
|
+
expect.any(Object),
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("should add sinceCommit range when specified", async () => {
|
|
210
|
+
mockExecFile.mockResolvedValue({ stdout: "", stderr: "" } as any);
|
|
211
|
+
|
|
212
|
+
await extractor.getCommits({ sinceCommit: "abc123" });
|
|
213
|
+
|
|
214
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
215
|
+
"git",
|
|
216
|
+
expect.arrayContaining(["abc123..HEAD"]),
|
|
217
|
+
expect.any(Object),
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("should add sinceDate filter when specified", async () => {
|
|
222
|
+
mockExecFile.mockResolvedValue({ stdout: "", stderr: "" } as any);
|
|
223
|
+
|
|
224
|
+
await extractor.getCommits({ sinceDate: "2024-01-01" });
|
|
225
|
+
|
|
226
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
227
|
+
"git",
|
|
228
|
+
expect.arrayContaining(["--since=2024-01-01"]),
|
|
229
|
+
expect.any(Object),
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("should handle commits with pipes in body", async () => {
|
|
234
|
+
const gitLogOutput = [
|
|
235
|
+
GIT_LOG_COMMIT_DELIMITER,
|
|
236
|
+
"abc123|abc12|Author|author@example.com|2024-01-15T10:00:00Z|feat: add feature|Body with | pipes | characters",
|
|
237
|
+
].join("\n");
|
|
238
|
+
|
|
239
|
+
mockExecFile.mockResolvedValue({
|
|
240
|
+
stdout: gitLogOutput,
|
|
241
|
+
stderr: "",
|
|
242
|
+
} as any);
|
|
243
|
+
|
|
244
|
+
const result = await extractor.getCommits();
|
|
245
|
+
|
|
246
|
+
expect(result[0].body).toBe("Body with | pipes | characters");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("should handle simple rename format", async () => {
|
|
250
|
+
const gitLogOutput = [
|
|
251
|
+
GIT_LOG_COMMIT_DELIMITER,
|
|
252
|
+
"abc123|abc12|Author|author@example.com|2024-01-15T10:00:00Z|refactor: move|",
|
|
253
|
+
"",
|
|
254
|
+
"0\t0\told-name.ts => new-name.ts",
|
|
255
|
+
].join("\n");
|
|
256
|
+
|
|
257
|
+
mockExecFile.mockResolvedValue({
|
|
258
|
+
stdout: gitLogOutput,
|
|
259
|
+
stderr: "",
|
|
260
|
+
} as any);
|
|
261
|
+
|
|
262
|
+
const result = await extractor.getCommits();
|
|
263
|
+
|
|
264
|
+
expect(result[0].files).toEqual(["new-name.ts"]);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("getCommitDiff", () => {
|
|
269
|
+
it("should return diff for a commit", async () => {
|
|
270
|
+
const diffOutput = `commit abc123
|
|
271
|
+
Author: John Doe <john@example.com>
|
|
272
|
+
|
|
273
|
+
feat: add feature
|
|
274
|
+
|
|
275
|
+
diff --git a/file.ts b/file.ts
|
|
276
|
+
--- a/file.ts
|
|
277
|
+
+++ b/file.ts
|
|
278
|
+
@@ -1,3 +1,5 @@
|
|
279
|
+
+const newLine = true;
|
|
280
|
+
const existingLine = true;`;
|
|
281
|
+
|
|
282
|
+
mockExecFile.mockResolvedValue({ stdout: diffOutput, stderr: "" } as any);
|
|
283
|
+
|
|
284
|
+
const result = await extractor.getCommitDiff("abc123");
|
|
285
|
+
|
|
286
|
+
expect(result).toBe(diffOutput);
|
|
287
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
288
|
+
"git",
|
|
289
|
+
["show", "--no-color", "-p", "abc123"],
|
|
290
|
+
expect.objectContaining({ cwd: "/test/repo" }),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("should truncate large diffs", async () => {
|
|
295
|
+
const largeDiff = "x".repeat(10000);
|
|
296
|
+
const customConfig = { ...config, maxDiffSize: 100 };
|
|
297
|
+
const customExtractor = new GitExtractor("/test/repo", customConfig);
|
|
298
|
+
|
|
299
|
+
mockExecFile.mockResolvedValue({ stdout: largeDiff, stderr: "" } as any);
|
|
300
|
+
|
|
301
|
+
const result = await customExtractor.getCommitDiff("abc123");
|
|
302
|
+
|
|
303
|
+
expect(result.length).toBeLessThan(largeDiff.length);
|
|
304
|
+
expect(result).toContain("[diff truncated: showing 100 of 10000 bytes]");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should return empty string on error", async () => {
|
|
308
|
+
mockExecFile.mockRejectedValue(new Error("Commit not found"));
|
|
309
|
+
|
|
310
|
+
const result = await extractor.getCommitDiff("nonexistent");
|
|
311
|
+
|
|
312
|
+
expect(result).toBe("");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("getRemoteUrl", () => {
|
|
317
|
+
it("should return remote origin URL", async () => {
|
|
318
|
+
mockExecFile.mockResolvedValue({
|
|
319
|
+
stdout: "git@github.com:user/repo.git\n",
|
|
320
|
+
stderr: "",
|
|
321
|
+
} as any);
|
|
322
|
+
|
|
323
|
+
const result = await extractor.getRemoteUrl();
|
|
324
|
+
|
|
325
|
+
expect(result).toBe("git@github.com:user/repo.git");
|
|
326
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
327
|
+
"git",
|
|
328
|
+
["remote", "get-url", "origin"],
|
|
329
|
+
expect.objectContaining({ cwd: "/test/repo" }),
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("should return empty string when no remote configured", async () => {
|
|
334
|
+
mockExecFile.mockRejectedValue(
|
|
335
|
+
new Error("fatal: No such remote 'origin'"),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const result = await extractor.getRemoteUrl();
|
|
339
|
+
|
|
340
|
+
expect(result).toBe("");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("should return HTTPS URL", async () => {
|
|
344
|
+
mockExecFile.mockResolvedValue({
|
|
345
|
+
stdout: "https://github.com/user/repo.git\n",
|
|
346
|
+
stderr: "",
|
|
347
|
+
} as any);
|
|
348
|
+
|
|
349
|
+
const result = await extractor.getRemoteUrl();
|
|
350
|
+
|
|
351
|
+
expect(result).toBe("https://github.com/user/repo.git");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("normalizeRemoteUrl", () => {
|
|
357
|
+
it("should normalize SSH URL", () => {
|
|
358
|
+
expect(normalizeRemoteUrl("git@github.com:user/repo.git")).toBe(
|
|
359
|
+
"user/repo",
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("should normalize HTTPS URL", () => {
|
|
364
|
+
expect(normalizeRemoteUrl("https://github.com/user/repo.git")).toBe(
|
|
365
|
+
"user/repo",
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("should handle URL without .git suffix", () => {
|
|
370
|
+
expect(normalizeRemoteUrl("git@github.com:user/repo")).toBe("user/repo");
|
|
371
|
+
expect(normalizeRemoteUrl("https://github.com/user/repo")).toBe(
|
|
372
|
+
"user/repo",
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("should return empty string for empty input", () => {
|
|
377
|
+
expect(normalizeRemoteUrl("")).toBe("");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("should handle GitLab SSH URL", () => {
|
|
381
|
+
expect(normalizeRemoteUrl("git@gitlab.com:group/project.git")).toBe(
|
|
382
|
+
"group/project",
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("should handle Bitbucket SSH URL", () => {
|
|
387
|
+
expect(normalizeRemoteUrl("git@bitbucket.org:team/repo.git")).toBe(
|
|
388
|
+
"team/repo",
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("should handle HTTP URL (not HTTPS)", () => {
|
|
393
|
+
expect(normalizeRemoteUrl("http://github.com/user/repo.git")).toBe(
|
|
394
|
+
"user/repo",
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("should handle nested paths", () => {
|
|
399
|
+
expect(
|
|
400
|
+
normalizeRemoteUrl("https://github.com/org/group/subgroup/repo.git"),
|
|
401
|
+
).toBe("org/group/subgroup/repo");
|
|
402
|
+
});
|
|
403
|
+
});
|