@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.
Files changed (88) hide show
  1. package/.github/workflows/ci.yml +4 -1
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +78 -3
  4. package/build/code/indexer.d.ts +2 -1
  5. package/build/code/indexer.d.ts.map +1 -1
  6. package/build/code/indexer.js +37 -7
  7. package/build/code/indexer.js.map +1 -1
  8. package/build/git/chunker.d.ts +39 -0
  9. package/build/git/chunker.d.ts.map +1 -0
  10. package/build/git/chunker.js +210 -0
  11. package/build/git/chunker.js.map +1 -0
  12. package/build/git/chunker.test.d.ts +2 -0
  13. package/build/git/chunker.test.d.ts.map +1 -0
  14. package/build/git/chunker.test.js +230 -0
  15. package/build/git/chunker.test.js.map +1 -0
  16. package/build/git/config.d.ts +34 -0
  17. package/build/git/config.d.ts.map +1 -0
  18. package/build/git/config.js +163 -0
  19. package/build/git/config.js.map +1 -0
  20. package/build/git/extractor.d.ts +57 -0
  21. package/build/git/extractor.d.ts.map +1 -0
  22. package/build/git/extractor.integration.test.d.ts +6 -0
  23. package/build/git/extractor.integration.test.d.ts.map +1 -0
  24. package/build/git/extractor.integration.test.js +166 -0
  25. package/build/git/extractor.integration.test.js.map +1 -0
  26. package/build/git/extractor.js +231 -0
  27. package/build/git/extractor.js.map +1 -0
  28. package/build/git/extractor.test.d.ts +2 -0
  29. package/build/git/extractor.test.d.ts.map +1 -0
  30. package/build/git/extractor.test.js +267 -0
  31. package/build/git/extractor.test.js.map +1 -0
  32. package/build/git/index.d.ts +10 -0
  33. package/build/git/index.d.ts.map +1 -0
  34. package/build/git/index.js +11 -0
  35. package/build/git/index.js.map +1 -0
  36. package/build/git/indexer.d.ts +50 -0
  37. package/build/git/indexer.d.ts.map +1 -0
  38. package/build/git/indexer.js +588 -0
  39. package/build/git/indexer.js.map +1 -0
  40. package/build/git/indexer.test.d.ts +2 -0
  41. package/build/git/indexer.test.d.ts.map +1 -0
  42. package/build/git/indexer.test.js +867 -0
  43. package/build/git/indexer.test.js.map +1 -0
  44. package/build/git/sync/synchronizer.d.ts +43 -0
  45. package/build/git/sync/synchronizer.d.ts.map +1 -0
  46. package/build/git/sync/synchronizer.js +108 -0
  47. package/build/git/sync/synchronizer.js.map +1 -0
  48. package/build/git/sync/synchronizer.test.d.ts +2 -0
  49. package/build/git/sync/synchronizer.test.d.ts.map +1 -0
  50. package/build/git/sync/synchronizer.test.js +188 -0
  51. package/build/git/sync/synchronizer.test.js.map +1 -0
  52. package/build/git/types.d.ts +159 -0
  53. package/build/git/types.d.ts.map +1 -0
  54. package/build/git/types.js +5 -0
  55. package/build/git/types.js.map +1 -0
  56. package/build/index.js +18 -0
  57. package/build/index.js.map +1 -1
  58. package/build/tools/git-history.d.ts +10 -0
  59. package/build/tools/git-history.d.ts.map +1 -0
  60. package/build/tools/git-history.js +144 -0
  61. package/build/tools/git-history.js.map +1 -0
  62. package/build/tools/index.d.ts +2 -0
  63. package/build/tools/index.d.ts.map +1 -1
  64. package/build/tools/index.js +4 -0
  65. package/build/tools/index.js.map +1 -1
  66. package/build/tools/schemas.d.ts +24 -0
  67. package/build/tools/schemas.d.ts.map +1 -1
  68. package/build/tools/schemas.js +64 -0
  69. package/build/tools/schemas.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/code/indexer.ts +49 -7
  72. package/src/git/chunker.test.ts +284 -0
  73. package/src/git/chunker.ts +256 -0
  74. package/src/git/config.ts +173 -0
  75. package/src/git/extractor.integration.test.ts +221 -0
  76. package/src/git/extractor.test.ts +403 -0
  77. package/src/git/extractor.ts +284 -0
  78. package/src/git/index.ts +31 -0
  79. package/src/git/indexer.test.ts +1089 -0
  80. package/src/git/indexer.ts +745 -0
  81. package/src/git/sync/synchronizer.test.ts +250 -0
  82. package/src/git/sync/synchronizer.ts +122 -0
  83. package/src/git/types.ts +192 -0
  84. package/src/index.ts +42 -0
  85. package/src/tools/git-history.ts +208 -0
  86. package/src/tools/index.ts +7 -0
  87. package/src/tools/schemas.ts +75 -0
  88. 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
+ });