@mhalder/qdrant-mcp-server 2.1.2 → 3.0.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 (101) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +78 -3
  3. package/build/code/indexer.d.ts +2 -1
  4. package/build/code/indexer.d.ts.map +1 -1
  5. package/build/code/indexer.js +58 -25
  6. package/build/code/indexer.js.map +1 -1
  7. package/build/git/chunker.d.ts +39 -0
  8. package/build/git/chunker.d.ts.map +1 -0
  9. package/build/git/chunker.js +210 -0
  10. package/build/git/chunker.js.map +1 -0
  11. package/build/git/chunker.test.d.ts +2 -0
  12. package/build/git/chunker.test.d.ts.map +1 -0
  13. package/build/git/chunker.test.js +230 -0
  14. package/build/git/chunker.test.js.map +1 -0
  15. package/build/git/config.d.ts +34 -0
  16. package/build/git/config.d.ts.map +1 -0
  17. package/build/git/config.js +163 -0
  18. package/build/git/config.js.map +1 -0
  19. package/build/git/extractor.d.ts +57 -0
  20. package/build/git/extractor.d.ts.map +1 -0
  21. package/build/git/extractor.integration.test.d.ts +6 -0
  22. package/build/git/extractor.integration.test.d.ts.map +1 -0
  23. package/build/git/extractor.integration.test.js +166 -0
  24. package/build/git/extractor.integration.test.js.map +1 -0
  25. package/build/git/extractor.js +231 -0
  26. package/build/git/extractor.js.map +1 -0
  27. package/build/git/extractor.test.d.ts +2 -0
  28. package/build/git/extractor.test.d.ts.map +1 -0
  29. package/build/git/extractor.test.js +267 -0
  30. package/build/git/extractor.test.js.map +1 -0
  31. package/build/git/index.d.ts +10 -0
  32. package/build/git/index.d.ts.map +1 -0
  33. package/build/git/index.js +11 -0
  34. package/build/git/index.js.map +1 -0
  35. package/build/git/indexer.d.ts +50 -0
  36. package/build/git/indexer.d.ts.map +1 -0
  37. package/build/git/indexer.js +588 -0
  38. package/build/git/indexer.js.map +1 -0
  39. package/build/git/indexer.test.d.ts +2 -0
  40. package/build/git/indexer.test.d.ts.map +1 -0
  41. package/build/git/indexer.test.js +867 -0
  42. package/build/git/indexer.test.js.map +1 -0
  43. package/build/git/sync/synchronizer.d.ts +43 -0
  44. package/build/git/sync/synchronizer.d.ts.map +1 -0
  45. package/build/git/sync/synchronizer.js +108 -0
  46. package/build/git/sync/synchronizer.js.map +1 -0
  47. package/build/git/sync/synchronizer.test.d.ts +2 -0
  48. package/build/git/sync/synchronizer.test.d.ts.map +1 -0
  49. package/build/git/sync/synchronizer.test.js +188 -0
  50. package/build/git/sync/synchronizer.test.js.map +1 -0
  51. package/build/git/types.d.ts +159 -0
  52. package/build/git/types.d.ts.map +1 -0
  53. package/build/git/types.js +5 -0
  54. package/build/git/types.js.map +1 -0
  55. package/build/index.js +18 -0
  56. package/build/index.js.map +1 -1
  57. package/build/qdrant/client.d.ts +5 -0
  58. package/build/qdrant/client.d.ts.map +1 -1
  59. package/build/qdrant/client.js +10 -0
  60. package/build/qdrant/client.js.map +1 -1
  61. package/build/qdrant/client.test.js +25 -0
  62. package/build/qdrant/client.test.js.map +1 -1
  63. package/build/tools/git-history.d.ts +10 -0
  64. package/build/tools/git-history.d.ts.map +1 -0
  65. package/build/tools/git-history.js +144 -0
  66. package/build/tools/git-history.js.map +1 -0
  67. package/build/tools/index.d.ts +2 -0
  68. package/build/tools/index.d.ts.map +1 -1
  69. package/build/tools/index.js +4 -0
  70. package/build/tools/index.js.map +1 -1
  71. package/build/tools/schemas.d.ts +24 -0
  72. package/build/tools/schemas.d.ts.map +1 -1
  73. package/build/tools/schemas.js +64 -0
  74. package/build/tools/schemas.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/code/indexer.ts +73 -24
  77. package/src/git/chunker.test.ts +284 -0
  78. package/src/git/chunker.ts +256 -0
  79. package/src/git/config.ts +173 -0
  80. package/src/git/extractor.integration.test.ts +221 -0
  81. package/src/git/extractor.test.ts +403 -0
  82. package/src/git/extractor.ts +284 -0
  83. package/src/git/index.ts +31 -0
  84. package/src/git/indexer.test.ts +1089 -0
  85. package/src/git/indexer.ts +745 -0
  86. package/src/git/sync/synchronizer.test.ts +250 -0
  87. package/src/git/sync/synchronizer.ts +122 -0
  88. package/src/git/types.ts +192 -0
  89. package/src/index.ts +42 -0
  90. package/src/qdrant/client.test.ts +29 -0
  91. package/src/qdrant/client.ts +14 -0
  92. package/src/tools/git-history.ts +208 -0
  93. package/src/tools/index.ts +7 -0
  94. package/src/tools/schemas.ts +75 -0
  95. package/tests/code/chunker/tree-sitter-chunker.test.ts +87 -5
  96. package/tests/code/indexer.test.ts +121 -0
  97. package/tests/code/integration.test.ts +14 -0
  98. package/tests/code/scanner.test.ts +81 -6
  99. package/tests/code/sync/snapshot.test.ts +55 -4
  100. package/tests/code/sync/synchronizer.test.ts +86 -10
  101. package/vitest.config.ts +2 -0
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { GitSynchronizer } from "./synchronizer.js";
3
+
4
+ // Mock fs module
5
+ vi.mock("node:fs", () => ({
6
+ promises: {
7
+ readFile: vi.fn(),
8
+ writeFile: vi.fn(),
9
+ rename: vi.fn(),
10
+ unlink: vi.fn(),
11
+ access: vi.fn(),
12
+ mkdir: vi.fn(),
13
+ },
14
+ }));
15
+
16
+ // Mock os module
17
+ vi.mock("node:os", () => ({
18
+ homedir: vi.fn(() => "/home/test"),
19
+ }));
20
+
21
+ import { promises as fs } from "node:fs";
22
+
23
+ describe("GitSynchronizer", () => {
24
+ let synchronizer: GitSynchronizer;
25
+
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ synchronizer = new GitSynchronizer("/test/repo", "git_abc12345");
29
+ });
30
+
31
+ afterEach(() => {
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ describe("initialize", () => {
36
+ it("should return true when snapshot exists and matches repo", async () => {
37
+ const snapshot = {
38
+ repoPath: "/test/repo",
39
+ lastCommitHash: "abc123",
40
+ lastIndexedAt: Date.now(),
41
+ commitsIndexed: 100,
42
+ };
43
+
44
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(snapshot));
45
+
46
+ const result = await synchronizer.initialize();
47
+
48
+ expect(result).toBe(true);
49
+ expect(synchronizer.getLastCommitHash()).toBe("abc123");
50
+ expect(synchronizer.getCommitsIndexed()).toBe(100);
51
+ });
52
+
53
+ it("should return false when snapshot does not exist", async () => {
54
+ vi.mocked(fs.readFile).mockRejectedValue(
55
+ new Error("ENOENT: no such file"),
56
+ );
57
+
58
+ const result = await synchronizer.initialize();
59
+
60
+ expect(result).toBe(false);
61
+ expect(synchronizer.getLastCommitHash()).toBeNull();
62
+ });
63
+
64
+ it("should return false when snapshot is for different repo", async () => {
65
+ const snapshot = {
66
+ repoPath: "/different/repo",
67
+ lastCommitHash: "abc123",
68
+ lastIndexedAt: Date.now(),
69
+ commitsIndexed: 100,
70
+ };
71
+
72
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(snapshot));
73
+
74
+ const result = await synchronizer.initialize();
75
+
76
+ expect(result).toBe(false);
77
+ expect(synchronizer.getLastCommitHash()).toBeNull();
78
+ });
79
+
80
+ it("should return false when snapshot is invalid JSON", async () => {
81
+ vi.mocked(fs.readFile).mockResolvedValue("not valid json");
82
+
83
+ const result = await synchronizer.initialize();
84
+
85
+ expect(result).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe("getLastCommitHash", () => {
90
+ it("should return null when no snapshot loaded", () => {
91
+ expect(synchronizer.getLastCommitHash()).toBeNull();
92
+ });
93
+
94
+ it("should return hash after loading snapshot", async () => {
95
+ const snapshot = {
96
+ repoPath: "/test/repo",
97
+ lastCommitHash: "xyz789",
98
+ lastIndexedAt: Date.now(),
99
+ commitsIndexed: 50,
100
+ };
101
+
102
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(snapshot));
103
+ await synchronizer.initialize();
104
+
105
+ expect(synchronizer.getLastCommitHash()).toBe("xyz789");
106
+ });
107
+ });
108
+
109
+ describe("getLastIndexedAt", () => {
110
+ it("should return null when no snapshot loaded", () => {
111
+ expect(synchronizer.getLastIndexedAt()).toBeNull();
112
+ });
113
+
114
+ it("should return date after loading snapshot", async () => {
115
+ const timestamp = Date.now();
116
+ const snapshot = {
117
+ repoPath: "/test/repo",
118
+ lastCommitHash: "abc123",
119
+ lastIndexedAt: timestamp,
120
+ commitsIndexed: 50,
121
+ };
122
+
123
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(snapshot));
124
+ await synchronizer.initialize();
125
+
126
+ const date = synchronizer.getLastIndexedAt();
127
+ expect(date).toBeInstanceOf(Date);
128
+ expect(date?.getTime()).toBe(timestamp);
129
+ });
130
+ });
131
+
132
+ describe("getCommitsIndexed", () => {
133
+ it("should return 0 when no snapshot loaded", () => {
134
+ expect(synchronizer.getCommitsIndexed()).toBe(0);
135
+ });
136
+
137
+ it("should return count after loading snapshot", async () => {
138
+ const snapshot = {
139
+ repoPath: "/test/repo",
140
+ lastCommitHash: "abc123",
141
+ lastIndexedAt: Date.now(),
142
+ commitsIndexed: 150,
143
+ };
144
+
145
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(snapshot));
146
+ await synchronizer.initialize();
147
+
148
+ expect(synchronizer.getCommitsIndexed()).toBe(150);
149
+ });
150
+ });
151
+
152
+ describe("updateSnapshot", () => {
153
+ it("should write snapshot to file", async () => {
154
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
155
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined);
156
+ vi.mocked(fs.rename).mockResolvedValue(undefined);
157
+
158
+ await synchronizer.updateSnapshot("def456", 200);
159
+
160
+ expect(fs.mkdir).toHaveBeenCalledWith(
161
+ expect.stringContaining("git-snapshots"),
162
+ { recursive: true },
163
+ );
164
+ // Check the file was written with correct data (JSON may be formatted)
165
+ expect(fs.writeFile).toHaveBeenCalledWith(
166
+ expect.stringContaining(".tmp"),
167
+ expect.stringMatching(/lastCommitHash.*def456/s),
168
+ );
169
+ expect(fs.rename).toHaveBeenCalled();
170
+
171
+ // Verify internal state was updated
172
+ expect(synchronizer.getLastCommitHash()).toBe("def456");
173
+ expect(synchronizer.getCommitsIndexed()).toBe(200);
174
+ });
175
+
176
+ it("should use atomic write with rename", async () => {
177
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
178
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined);
179
+ vi.mocked(fs.rename).mockResolvedValue(undefined);
180
+
181
+ await synchronizer.updateSnapshot("abc123", 100);
182
+
183
+ // Verify write to temp file first, then rename
184
+ const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
185
+ const renameCall = vi.mocked(fs.rename).mock.calls[0];
186
+
187
+ expect(writeCall[0]).toContain(".tmp");
188
+ expect(renameCall[0]).toContain(".tmp");
189
+ expect(renameCall[1]).not.toContain(".tmp");
190
+ });
191
+ });
192
+
193
+ describe("deleteSnapshot", () => {
194
+ it("should delete snapshot file", async () => {
195
+ vi.mocked(fs.unlink).mockResolvedValue(undefined);
196
+
197
+ await synchronizer.deleteSnapshot();
198
+
199
+ expect(fs.unlink).toHaveBeenCalled();
200
+ expect(synchronizer.getLastCommitHash()).toBeNull();
201
+ });
202
+
203
+ it("should not throw when file does not exist", async () => {
204
+ vi.mocked(fs.unlink).mockRejectedValue(new Error("ENOENT"));
205
+
206
+ await expect(synchronizer.deleteSnapshot()).resolves.not.toThrow();
207
+ });
208
+ });
209
+
210
+ describe("exists", () => {
211
+ it("should return true when snapshot file exists", async () => {
212
+ vi.mocked(fs.access).mockResolvedValue(undefined);
213
+
214
+ const result = await synchronizer.exists();
215
+
216
+ expect(result).toBe(true);
217
+ });
218
+
219
+ it("should return false when snapshot file does not exist", async () => {
220
+ vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
221
+
222
+ const result = await synchronizer.exists();
223
+
224
+ expect(result).toBe(false);
225
+ });
226
+ });
227
+
228
+ describe("getSnapshotAge", () => {
229
+ it("should return null when no snapshot loaded", () => {
230
+ expect(synchronizer.getSnapshotAge()).toBeNull();
231
+ });
232
+
233
+ it("should return age in milliseconds", async () => {
234
+ const timestamp = Date.now() - 10000; // 10 seconds ago
235
+ const snapshot = {
236
+ repoPath: "/test/repo",
237
+ lastCommitHash: "abc123",
238
+ lastIndexedAt: timestamp,
239
+ commitsIndexed: 50,
240
+ };
241
+
242
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(snapshot));
243
+ await synchronizer.initialize();
244
+
245
+ const age = synchronizer.getSnapshotAge();
246
+ expect(age).toBeGreaterThanOrEqual(10000);
247
+ expect(age).toBeLessThan(11000);
248
+ });
249
+ });
250
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * GitSynchronizer - Track last indexed commit for incremental updates
3
+ */
4
+
5
+ import { promises as fs } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+ import type { GitSnapshot } from "../types.js";
9
+
10
+ export class GitSynchronizer {
11
+ private snapshotPath: string;
12
+ private snapshot: GitSnapshot | null = null;
13
+
14
+ constructor(
15
+ private repoPath: string,
16
+ collectionName: string,
17
+ ) {
18
+ // Store snapshots in ~/.qdrant-mcp/git-snapshots/
19
+ const snapshotDir = join(homedir(), ".qdrant-mcp", "git-snapshots");
20
+ this.snapshotPath = join(snapshotDir, `${collectionName}.json`);
21
+ }
22
+
23
+ /**
24
+ * Initialize synchronizer by loading existing snapshot
25
+ * @returns true if snapshot exists and was loaded
26
+ */
27
+ async initialize(): Promise<boolean> {
28
+ try {
29
+ const content = await fs.readFile(this.snapshotPath, "utf-8");
30
+ this.snapshot = JSON.parse(content) as GitSnapshot;
31
+
32
+ // Validate snapshot is for the same repo
33
+ if (this.snapshot.repoPath !== this.repoPath) {
34
+ this.snapshot = null;
35
+ return false;
36
+ }
37
+
38
+ return true;
39
+ } catch {
40
+ this.snapshot = null;
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Get the last indexed commit hash
47
+ */
48
+ getLastCommitHash(): string | null {
49
+ return this.snapshot?.lastCommitHash ?? null;
50
+ }
51
+
52
+ /**
53
+ * Get the timestamp of last indexing
54
+ */
55
+ getLastIndexedAt(): Date | null {
56
+ if (!this.snapshot) return null;
57
+ return new Date(this.snapshot.lastIndexedAt);
58
+ }
59
+
60
+ /**
61
+ * Get the number of commits indexed
62
+ */
63
+ getCommitsIndexed(): number {
64
+ return this.snapshot?.commitsIndexed ?? 0;
65
+ }
66
+
67
+ /**
68
+ * Update snapshot with new indexing state
69
+ */
70
+ async updateSnapshot(
71
+ lastCommitHash: string,
72
+ commitsIndexed: number,
73
+ ): Promise<void> {
74
+ this.snapshot = {
75
+ repoPath: this.repoPath,
76
+ lastCommitHash,
77
+ lastIndexedAt: Date.now(),
78
+ commitsIndexed,
79
+ };
80
+
81
+ // Ensure directory exists
82
+ const dir = dirname(this.snapshotPath);
83
+ await fs.mkdir(dir, { recursive: true });
84
+
85
+ // Write snapshot atomically using temp file
86
+ const tempPath = `${this.snapshotPath}.tmp`;
87
+ await fs.writeFile(tempPath, JSON.stringify(this.snapshot, null, 2));
88
+ await fs.rename(tempPath, this.snapshotPath);
89
+ }
90
+
91
+ /**
92
+ * Delete the snapshot file
93
+ */
94
+ async deleteSnapshot(): Promise<void> {
95
+ try {
96
+ await fs.unlink(this.snapshotPath);
97
+ this.snapshot = null;
98
+ } catch {
99
+ // Ignore if file doesn't exist
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Check if snapshot exists
105
+ */
106
+ async exists(): Promise<boolean> {
107
+ try {
108
+ await fs.access(this.snapshotPath);
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Get snapshot age in milliseconds
117
+ */
118
+ getSnapshotAge(): number | null {
119
+ if (!this.snapshot) return null;
120
+ return Date.now() - this.snapshot.lastIndexedAt;
121
+ }
122
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Type definitions for git history indexing module
3
+ */
4
+
5
+ /**
6
+ * Commit type classification based on conventional commits
7
+ */
8
+ export type CommitType =
9
+ | "feat"
10
+ | "fix"
11
+ | "refactor"
12
+ | "docs"
13
+ | "test"
14
+ | "chore"
15
+ | "style"
16
+ | "perf"
17
+ | "build"
18
+ | "ci"
19
+ | "revert"
20
+ | "other";
21
+
22
+ /**
23
+ * Configuration for git history indexing
24
+ */
25
+ export interface GitConfig {
26
+ // Commit extraction
27
+ maxCommits: number; // Max commits to index per run
28
+ includeFileList: boolean; // Include changed file list in chunks
29
+ includeDiff: boolean; // Include truncated diff in chunks
30
+ maxDiffSize: number; // Max bytes of diff per commit
31
+ gitTimeout: number; // Timeout for git commands in milliseconds
32
+
33
+ // Chunking
34
+ maxChunkSize: number; // Max characters per chunk
35
+
36
+ // Indexing
37
+ batchSize: number; // Embeddings per batch
38
+ batchRetryAttempts: number; // Number of retry attempts for failed batches
39
+
40
+ // Search
41
+ defaultSearchLimit: number; // Default search results
42
+ enableHybridSearch: boolean; // Enable hybrid search
43
+ }
44
+
45
+ /**
46
+ * Raw commit data parsed from git log
47
+ */
48
+ export interface RawCommit {
49
+ hash: string; // Full commit hash
50
+ shortHash: string; // Short hash (7 chars)
51
+ author: string; // Author name
52
+ authorEmail: string; // Author email
53
+ date: Date; // Commit date
54
+ subject: string; // First line of commit message
55
+ body: string; // Full commit message body
56
+ files: string[]; // Changed files
57
+ insertions: number; // Lines added
58
+ deletions: number; // Lines deleted
59
+ }
60
+
61
+ /**
62
+ * Embeddable chunk created from a commit
63
+ */
64
+ export interface CommitChunk {
65
+ content: string; // Text content for embedding
66
+ metadata: {
67
+ commitHash: string;
68
+ shortHash: string;
69
+ author: string;
70
+ authorEmail: string;
71
+ date: string; // ISO date string
72
+ subject: string;
73
+ commitType: CommitType;
74
+ files: string[];
75
+ insertions: number;
76
+ deletions: number;
77
+ repoPath: string;
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Options for indexing git history
83
+ */
84
+ export interface GitIndexOptions {
85
+ forceReindex?: boolean; // Delete existing and re-index
86
+ sinceDate?: string; // ISO date string - only index commits after this date
87
+ maxCommits?: number; // Override config maxCommits
88
+ }
89
+
90
+ /**
91
+ * Statistics from indexing operation
92
+ */
93
+ export interface GitIndexStats {
94
+ commitsScanned: number;
95
+ commitsIndexed: number;
96
+ chunksCreated: number;
97
+ durationMs: number;
98
+ status: "completed" | "partial" | "failed";
99
+ errors?: string[];
100
+ }
101
+
102
+ /**
103
+ * Statistics from incremental update operation
104
+ */
105
+ export interface GitChangeStats {
106
+ newCommits: number;
107
+ chunksAdded: number;
108
+ durationMs: number;
109
+ }
110
+
111
+ /**
112
+ * Search result from git history
113
+ */
114
+ export interface GitSearchResult {
115
+ content: string;
116
+ commitHash: string;
117
+ shortHash: string;
118
+ author: string;
119
+ date: string;
120
+ subject: string;
121
+ commitType: CommitType;
122
+ files: string[];
123
+ score: number;
124
+ }
125
+
126
+ /**
127
+ * Search options for git history
128
+ */
129
+ export interface GitSearchOptions {
130
+ limit?: number;
131
+ useHybrid?: boolean;
132
+ commitTypes?: CommitType[]; // Filter by commit type
133
+ authors?: string[]; // Filter by author name/email
134
+ dateFrom?: string; // ISO date string - only commits after
135
+ dateTo?: string; // ISO date string - only commits before
136
+ scoreThreshold?: number;
137
+ }
138
+
139
+ /**
140
+ * Indexing status for a repository
141
+ */
142
+ export type GitIndexingStatus = "not_indexed" | "indexing" | "indexed";
143
+
144
+ /**
145
+ * Status information for a git history index
146
+ */
147
+ export interface GitIndexStatus {
148
+ /** @deprecated Use `status` instead. True only when status is 'indexed'. */
149
+ isIndexed: boolean;
150
+ /** Current indexing status: 'not_indexed', 'indexing', or 'indexed' */
151
+ status: GitIndexingStatus;
152
+ collectionName?: string;
153
+ commitsCount?: number;
154
+ chunksCount?: number;
155
+ lastCommitHash?: string;
156
+ lastIndexedAt?: Date;
157
+ }
158
+
159
+ /**
160
+ * Progress callback for indexing operations
161
+ */
162
+ export type GitProgressCallback = (progress: GitProgressUpdate) => void;
163
+
164
+ /**
165
+ * Progress update during indexing
166
+ */
167
+ export interface GitProgressUpdate {
168
+ phase: "extracting" | "chunking" | "embedding" | "storing";
169
+ current: number;
170
+ total: number;
171
+ percentage: number;
172
+ message: string;
173
+ }
174
+
175
+ /**
176
+ * Snapshot for tracking last indexed commit
177
+ */
178
+ export interface GitSnapshot {
179
+ repoPath: string;
180
+ lastCommitHash: string;
181
+ lastIndexedAt: number; // Unix timestamp
182
+ commitsIndexed: number;
183
+ }
184
+
185
+ /**
186
+ * Options for extracting commits from git
187
+ */
188
+ export interface GitExtractOptions {
189
+ sinceCommit?: string; // Only commits after this hash
190
+ sinceDate?: string; // Only commits after this date (ISO string)
191
+ maxCommits?: number; // Limit number of commits
192
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  } from "./code/config.js";
19
19
  import { CodeIndexer } from "./code/indexer.js";
20
20
  import type { CodeConfig } from "./code/types.js";
21
+ import { DEFAULT_GIT_CONFIG, GitHistoryIndexer } from "./git/index.js";
22
+ import type { GitConfig } from "./git/types.js";
21
23
  import { EmbeddingProviderFactory } from "./embeddings/factory.js";
22
24
  import { loadPromptsConfig, type PromptsConfig } from "./prompts/index.js";
23
25
  import { registerAllPrompts } from "./prompts/register.js";
@@ -183,6 +185,45 @@ const codeConfig: CodeConfig = {
183
185
 
184
186
  const codeIndexer = new CodeIndexer(qdrant, embeddings, codeConfig);
185
187
 
188
+ // Initialize git history indexer
189
+ const gitConfig: GitConfig = {
190
+ maxCommits: parseInt(
191
+ process.env.GIT_MAX_COMMITS || String(DEFAULT_GIT_CONFIG.maxCommits),
192
+ 10,
193
+ ),
194
+ includeFileList: process.env.GIT_INCLUDE_FILES !== "false",
195
+ includeDiff: process.env.GIT_INCLUDE_DIFF !== "false",
196
+ maxDiffSize: parseInt(
197
+ process.env.GIT_MAX_DIFF_SIZE || String(DEFAULT_GIT_CONFIG.maxDiffSize),
198
+ 10,
199
+ ),
200
+ gitTimeout: parseInt(
201
+ process.env.GIT_TIMEOUT || String(DEFAULT_GIT_CONFIG.gitTimeout),
202
+ 10,
203
+ ),
204
+ maxChunkSize: parseInt(
205
+ process.env.GIT_MAX_CHUNK_SIZE || String(DEFAULT_GIT_CONFIG.maxChunkSize),
206
+ 10,
207
+ ),
208
+ batchSize: parseInt(
209
+ process.env.GIT_BATCH_SIZE || String(DEFAULT_GIT_CONFIG.batchSize),
210
+ 10,
211
+ ),
212
+ batchRetryAttempts: parseInt(
213
+ process.env.GIT_BATCH_RETRY_ATTEMPTS ||
214
+ String(DEFAULT_GIT_CONFIG.batchRetryAttempts),
215
+ 10,
216
+ ),
217
+ defaultSearchLimit: parseInt(
218
+ process.env.GIT_SEARCH_LIMIT ||
219
+ String(DEFAULT_GIT_CONFIG.defaultSearchLimit),
220
+ 10,
221
+ ),
222
+ enableHybridSearch: process.env.GIT_ENABLE_HYBRID !== "false",
223
+ };
224
+
225
+ const gitHistoryIndexer = new GitHistoryIndexer(qdrant, embeddings, gitConfig);
226
+
186
227
  // Load prompts configuration if file exists
187
228
  let promptsConfig: PromptsConfig | null = null;
188
229
  if (existsSync(PROMPTS_CONFIG_FILE)) {
@@ -213,6 +254,7 @@ function createAndConfigureServer(): McpServer {
213
254
  qdrant,
214
255
  embeddings,
215
256
  codeIndexer,
257
+ gitHistoryIndexer,
216
258
  });
217
259
 
218
260
  // Register all resources
@@ -634,6 +634,35 @@ describe("QdrantManager", () => {
634
634
  });
635
635
  });
636
636
 
637
+ describe("deletePointsByFilter", () => {
638
+ it("should delete points matching filter", async () => {
639
+ const filter = {
640
+ must: [{ key: "relativePath", match: { value: "src/test.ts" } }],
641
+ };
642
+ await manager.deletePointsByFilter("test-collection", filter);
643
+
644
+ expect(mockClient.delete).toHaveBeenCalledWith("test-collection", {
645
+ wait: true,
646
+ filter: filter,
647
+ });
648
+ });
649
+
650
+ it("should delete points with complex filter", async () => {
651
+ const filter = {
652
+ must: [
653
+ { key: "relativePath", match: { value: "src/utils.ts" } },
654
+ { key: "language", match: { value: "typescript" } },
655
+ ],
656
+ };
657
+ await manager.deletePointsByFilter("test-collection", filter);
658
+
659
+ expect(mockClient.delete).toHaveBeenCalledWith("test-collection", {
660
+ wait: true,
661
+ filter: filter,
662
+ });
663
+ });
664
+ });
665
+
637
666
  describe("hybridSearch", () => {
638
667
  beforeEach(() => {
639
668
  mockClient.query = vi.fn();
@@ -240,6 +240,20 @@ export class QdrantManager {
240
240
  });
241
241
  }
242
242
 
243
+ /**
244
+ * Deletes points matching a filter condition.
245
+ * Useful for deleting all chunks associated with a specific file path.
246
+ */
247
+ async deletePointsByFilter(
248
+ collectionName: string,
249
+ filter: Record<string, any>,
250
+ ): Promise<void> {
251
+ await this.client.delete(collectionName, {
252
+ wait: true,
253
+ filter: filter,
254
+ });
255
+ }
256
+
243
257
  /**
244
258
  * Performs hybrid search combining semantic vector search with sparse vector (keyword) search
245
259
  * using Reciprocal Rank Fusion (RRF) to combine results