-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit-fetch-tool.test.ts
More file actions
264 lines (223 loc) · 9.51 KB
/
git-fetch-tool.test.ts
File metadata and controls
264 lines (223 loc) · 9.51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
import { afterEach, describe, expect, it, test } from "bun:test";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
/**
* Tests for git_fetch tool: output parsing (unit) + execute path (integration).
*/
import { registerGitFetchTool } from "./git-fetch-tool.js";
import {
captureTool,
cleanupTmpPaths,
gitCmd,
makeRepoWithUpstream,
mkTmpDir,
} from "./test-harness.js";
afterEach(cleanupTmpPaths);
// ---------------------------------------------------------------------------
// Unit: parseGitFetchOutput (local copy to test parsing logic in isolation)
// ---------------------------------------------------------------------------
function parseGitFetchOutput(output: string): { updatedRefs: string[]; newRefs: string[] } {
const lines = output.split("\n");
const updatedRefs: string[] = [];
const newRefs: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.includes("[new")) {
newRefs.push(trimmed);
} else if (trimmed.includes(" -> ")) {
updatedRefs.push(trimmed);
}
}
return { updatedRefs, newRefs };
}
describe("git_fetch parseGitFetchOutput", () => {
it("parses empty output", () => {
const result = parseGitFetchOutput("");
expect(result.updatedRefs).toEqual([]);
expect(result.newRefs).toEqual([]);
});
it("parses updated refs with -> notation", () => {
const output = `From origin
abc1234..def5678 main -> origin/main`;
const result = parseGitFetchOutput(output);
expect(result.updatedRefs).toContain("abc1234..def5678 main -> origin/main");
expect(result.newRefs).toEqual([]);
});
it("parses new refs with [new tag] notation", () => {
const output = `From origin
* [new tag] v1.0.0 -> v1.0.0`;
const result = parseGitFetchOutput(output);
expect(result.newRefs.length).toBe(1);
expect(result.newRefs[0]).toContain("[new tag]");
expect(result.newRefs[0]).toContain("v1.0.0");
});
it("parses mixed updated and new refs", () => {
const output = `From origin
abc1234..def5678 main -> origin/main
* [new branch] feature/x -> origin/feature/x
* [new tag] v2.0.0 -> v2.0.0`;
const result = parseGitFetchOutput(output);
expect(result.updatedRefs.length).toBe(1);
expect(result.updatedRefs).toContain("abc1234..def5678 main -> origin/main");
expect(result.newRefs.length).toBe(2);
expect(result.newRefs).toEqual([
expect.stringContaining("[new branch]"),
expect.stringContaining("[new tag]"),
]);
});
it("ignores lines without -> or [new prefix", () => {
const output = `From origin
Fetching submodule foo
Some other message`;
const result = parseGitFetchOutput(output);
expect(result.updatedRefs).toEqual([]);
expect(result.newRefs).toEqual([]);
});
it("handles whitespace correctly", () => {
const output = `
abc1234..def5678 main -> origin/main
[new tag] v1.0.0 -> v1.0.0
`;
const result = parseGitFetchOutput(output);
expect(result.updatedRefs.length).toBe(1);
expect(result.newRefs.length).toBe(1);
expect(result.newRefs[0]).toContain("[new tag]");
});
it("parses pruned refs with -> notation", () => {
const output = `From origin
- [deleted] origin/old-branch`;
const result = parseGitFetchOutput(output);
expect(result.updatedRefs).toEqual([]);
expect(result.newRefs).toEqual([]);
});
it("captures branch tracking updates", () => {
const output = `From origin
d1e2f3..a4b5c6 main -> origin/main
1234567..abcdef main -> main`;
const result = parseGitFetchOutput(output);
expect(result.updatedRefs.length).toBe(2);
});
it("handles refs with special characters", () => {
const output = `From origin
abc1234..def5678 refs/pull/123/head -> origin/pull/123/head
* [new ref] refs/heads/feature/my-feature -> origin/feature/my-feature`;
const result = parseGitFetchOutput(output);
expect(result.updatedRefs.length).toBe(1);
expect(result.updatedRefs[0]).toContain("refs/pull/123/head");
expect(result.newRefs.length).toBe(1);
expect(result.newRefs[0]).toContain("[new ref]");
});
});
// ---------------------------------------------------------------------------
// Integration: execute path via captureTool
// ---------------------------------------------------------------------------
describe("git_fetch execute handler", () => {
test("not_a_git_repository returns error in json format", async () => {
const plain = mkTmpDir("mcp-plain-fetch-");
const run = captureTool(registerGitFetchTool);
const text = await run({ workspaceRoot: plain, format: "json" });
const parsed = JSON.parse(text) as { error: string };
expect(parsed.error).toBe("not_a_git_repository");
});
test("fetch from local bare remote succeeds (already up to date)", async () => {
const { work } = makeRepoWithUpstream("mcp-fetch-work-", "mcp-fetch-remote-");
const run = captureTool(registerGitFetchTool);
const text = await run({ workspaceRoot: work, format: "json" });
const parsed = JSON.parse(text) as {
ok: boolean;
remote: string;
updatedRefs: string[];
newRefs: string[];
};
expect(parsed.ok).toBe(true);
expect(parsed.remote).toBe("origin");
expect(Array.isArray(parsed.updatedRefs)).toBe(true);
expect(Array.isArray(parsed.newRefs)).toBe(true);
});
test("fetch picks up new branch pushed to remote — newRefs and created populated", async () => {
const { work, remote } = makeRepoWithUpstream("mcp-fetch-work2-", "mcp-fetch-remote2-");
// Push a new branch to the bare remote directly.
const cloneDir = mkTmpDir("mcp-fetch-clone-");
gitCmd(cloneDir, "clone", remote, ".");
writeFileSync(join(cloneDir, "extra.ts"), "export const x = 1;\n");
gitCmd(cloneDir, "checkout", "-b", "feature-new");
gitCmd(cloneDir, "add", "extra.ts");
gitCmd(cloneDir, "commit", "-m", "feat: extra");
gitCmd(cloneDir, "push", "origin", "feature-new");
const run = captureTool(registerGitFetchTool);
const text = await run({ workspaceRoot: work, format: "json" });
const parsed = JSON.parse(text) as {
ok: boolean;
newRefs: string[];
created?: Array<{ ref: string; newSha: string; flag: string }>;
};
expect(parsed.ok).toBe(true);
// Legacy field still present
expect(parsed.newRefs.some((r) => r.includes("feature-new"))).toBe(true);
// Structured created field populated when --porcelain is supported
if (parsed.created !== undefined) {
expect(parsed.created.length).toBeGreaterThan(0);
const entry = parsed.created.find((c) => c.ref.includes("feature-new"));
expect(entry).toBeDefined();
expect(typeof entry?.newSha).toBe("string");
expect(entry?.newSha.length).toBeGreaterThan(0);
expect(entry?.flag).toBe("*");
}
});
test("fetch picks up updated ref — updated field populated with oldSha/newSha", async () => {
const { work, remote } = makeRepoWithUpstream("mcp-fetch-upd-", "mcp-fetch-upd-remote-");
// Push an additional commit to main on the remote via a second clone
const cloneDir = mkTmpDir("mcp-fetch-upd-clone-");
gitCmd(cloneDir, "clone", remote, ".");
writeFileSync(join(cloneDir, "update.ts"), "export const y = 2;\n");
gitCmd(cloneDir, "add", "update.ts");
gitCmd(cloneDir, "commit", "-m", "feat: update");
gitCmd(cloneDir, "push", "origin", "main");
const run = captureTool(registerGitFetchTool);
const text = await run({ workspaceRoot: work, format: "json" });
const parsed = JSON.parse(text) as {
ok: boolean;
updatedRefs: string[];
updated?: Array<{ ref: string; oldSha: string; newSha: string; flag: string }>;
};
expect(parsed.ok).toBe(true);
// Legacy field shows update
expect(parsed.updatedRefs.length).toBeGreaterThan(0);
// Structured updated field populated when --porcelain is supported
if (parsed.updated !== undefined) {
expect(parsed.updated.length).toBeGreaterThan(0);
const entry = parsed.updated[0];
expect(typeof entry?.oldSha).toBe("string");
expect(typeof entry?.newSha).toBe("string");
expect(entry?.oldSha).not.toBe(entry?.newSha);
expect(entry?.oldSha.length).toBeGreaterThan(0);
expect(entry?.newSha.length).toBeGreaterThan(0);
}
});
test("fetch markdown output contains success status", async () => {
const { work } = makeRepoWithUpstream("mcp-fetch-md-", "mcp-fetch-md-remote-");
const run = captureTool(registerGitFetchTool);
const text = await run({ workspaceRoot: work });
expect(text).toContain("Git fetch from");
expect(text).toContain("Success");
});
test("leading-dash remote is rejected with unsafe_remote_token", async () => {
const { work } = makeRepoWithUpstream("mcp-fetch-inject-", "mcp-fetch-inject-remote-");
const run = captureTool(registerGitFetchTool);
const text = await run({
workspaceRoot: work,
format: "json",
remote: "--upload-pack=/tmp/x",
});
const parsed = JSON.parse(text) as { error: string };
expect(parsed.error).toBe("unsafe_remote_token");
});
test("fetch with invalid remote returns ok:false in json", async () => {
const { work } = makeRepoWithUpstream("mcp-fetch-bad-", "mcp-fetch-bad-remote-");
const run = captureTool(registerGitFetchTool);
const text = await run({ workspaceRoot: work, format: "json", remote: "no-such-remote" });
const parsed = JSON.parse(text) as { ok: boolean };
expect(parsed.ok).toBe(false);
});
});