@andrebuzeli/git-mcp 12.0.0 → 13.5.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/package.json +1 -1
- package/src/index.js +4 -4
- package/src/tools/git-workflow.js +5 -3
- package/src/utils/gitAdapter.js +305 -4
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Server } from "@modelcontextprotocol/sdk/
|
|
3
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import Ajv from "ajv";
|
|
5
5
|
import { asToolError } from "./utils/errors.js";
|
|
6
6
|
import { ProviderManager } from "./providers/providerManager.js";
|
|
@@ -52,13 +52,13 @@ function listToolsResult() {
|
|
|
52
52
|
|
|
53
53
|
server.setRequestHandler(
|
|
54
54
|
// list tools
|
|
55
|
-
(await import("@modelcontextprotocol/sdk/
|
|
55
|
+
(await import("@modelcontextprotocol/sdk/types.js")).ListToolsRequestSchema,
|
|
56
56
|
async () => listToolsResult()
|
|
57
57
|
);
|
|
58
58
|
|
|
59
59
|
server.setRequestHandler(
|
|
60
60
|
// call tool
|
|
61
|
-
(await import("@modelcontextprotocol/sdk/
|
|
61
|
+
(await import("@modelcontextprotocol/sdk/types.js")).CallToolRequestSchema,
|
|
62
62
|
async (req) => {
|
|
63
63
|
const name = req.params?.name || "";
|
|
64
64
|
const args = req.params?.arguments || {};
|
|
@@ -11,7 +11,8 @@ export function createGitWorkflowTool(pm, git) {
|
|
|
11
11
|
projectPath: { type: "string", description: "Caminho absoluto do projeto" },
|
|
12
12
|
action: { type: "string", enum: ["init", "status", "add", "remove", "commit", "push", "pull", "sync", "ensure-remotes"], description: "Ação" },
|
|
13
13
|
files: { type: "array", items: { type: "string" } },
|
|
14
|
-
message: { type: "string" }
|
|
14
|
+
message: { type: "string" },
|
|
15
|
+
force: { type: "boolean", description: "Force push (para resolver divergências de histórico)" }
|
|
15
16
|
},
|
|
16
17
|
required: ["projectPath", "action"],
|
|
17
18
|
additionalProperties: true
|
|
@@ -62,8 +63,9 @@ export function createGitWorkflowTool(pm, git) {
|
|
|
62
63
|
}
|
|
63
64
|
if (action === "push") {
|
|
64
65
|
const branch = await git.getCurrentBranch(projectPath);
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
const force = !!args.force;
|
|
67
|
+
await git.pushParallel(projectPath, branch, force);
|
|
68
|
+
return asToolResult({ success: true, branch, remotes: ["github", "gitea"], force });
|
|
67
69
|
}
|
|
68
70
|
if (action === "sync" || action === "pull") {
|
|
69
71
|
// Basic implementation: fetch + status; full merge/pull can be added
|
package/src/utils/gitAdapter.js
CHANGED
|
@@ -97,20 +97,321 @@ export class GitAdapter {
|
|
|
97
97
|
throw new MCPError("AUTH", "GITEA_TOKEN ausente");
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
async pushOne(dir, remote, branch) {
|
|
100
|
+
async pushOne(dir, remote, branch, force = false) {
|
|
101
101
|
const remotes = await git.listRemotes({ fs, dir });
|
|
102
102
|
const info = remotes.find(r => r.remote === remote);
|
|
103
103
|
if (!info) throw new MCPError("REMOTE", `Remote '${remote}' não encontrado`);
|
|
104
104
|
const onAuth = this.getAuth(info.url);
|
|
105
105
|
const ref = branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
|
|
106
|
-
await git.push({ fs, http, dir, remote, ref, remoteRef: ref, onAuth });
|
|
106
|
+
await git.push({ fs, http, dir, remote, ref, remoteRef: ref, onAuth, force });
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
async pushParallel(dir, branch) {
|
|
109
|
+
async pushParallel(dir, branch, force = false) {
|
|
110
110
|
const remotes = await git.listRemotes({ fs, dir });
|
|
111
111
|
const targets = remotes.filter(r => ["github", "gitea"].includes(r.remote));
|
|
112
112
|
if (targets.length === 0) throw new MCPError("REMOTE", "Nenhum remote github/gitea configurado");
|
|
113
|
-
await Promise.all(targets.map(t => this.pushOne(dir, t.remote, branch)));
|
|
113
|
+
await Promise.all(targets.map(t => this.pushOne(dir, t.remote, branch, force)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ============ BRANCHES ============
|
|
117
|
+
async listBranches(dir, remote = false) {
|
|
118
|
+
if (remote) {
|
|
119
|
+
const remotes = await git.listRemotes({ fs, dir });
|
|
120
|
+
const branches = [];
|
|
121
|
+
for (const r of remotes) {
|
|
122
|
+
try {
|
|
123
|
+
const refs = await git.listServerRefs({ http, url: r.url, prefix: "refs/heads/", onAuth: this.getAuth(r.url) });
|
|
124
|
+
for (const ref of refs) branches.push(`${r.remote}/${ref.ref.replace("refs/heads/", "")}`);
|
|
125
|
+
} catch { /* ignore */ }
|
|
126
|
+
}
|
|
127
|
+
return branches;
|
|
128
|
+
}
|
|
129
|
+
return await git.listBranches({ fs, dir });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async createBranch(dir, name, ref = "HEAD") {
|
|
133
|
+
const oid = await git.resolveRef({ fs, dir, ref });
|
|
134
|
+
await git.branch({ fs, dir, ref: name, checkout: false });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async deleteBranch(dir, name, force = false) {
|
|
138
|
+
await git.deleteBranch({ fs, dir, ref: name });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async renameBranch(dir, oldName, newName) {
|
|
142
|
+
await git.renameBranch({ fs, dir, oldref: oldName, ref: newName });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async checkout(dir, ref) {
|
|
146
|
+
await git.checkout({ fs, dir, ref, force: true });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============ TAGS ============
|
|
150
|
+
async listTags(dir) {
|
|
151
|
+
return await git.listTags({ fs, dir });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async createTag(dir, tag, ref = "HEAD", message = "") {
|
|
155
|
+
const oid = await git.resolveRef({ fs, dir, ref });
|
|
156
|
+
if (message) {
|
|
157
|
+
const author = await this.getAuthor(dir);
|
|
158
|
+
await git.tag({ fs, dir, ref: tag, object: oid, tagger: { name: author.name, email: author.email }, message });
|
|
159
|
+
} else {
|
|
160
|
+
await git.tag({ fs, dir, ref: tag, object: oid });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async deleteTag(dir, tag) {
|
|
165
|
+
await git.deleteTag({ fs, dir, ref: tag });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async pushTag(dir, remote, tag) {
|
|
169
|
+
const remotes = await git.listRemotes({ fs, dir });
|
|
170
|
+
const info = remotes.find(r => r.remote === remote);
|
|
171
|
+
if (!info) throw new MCPError("REMOTE", `Remote '${remote}' não encontrado`);
|
|
172
|
+
const onAuth = this.getAuth(info.url);
|
|
173
|
+
await git.push({ fs, http, dir, remote, ref: `refs/tags/${tag}`, remoteRef: `refs/tags/${tag}`, onAuth });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ============ STASH (simulated via JSON file) ============
|
|
177
|
+
_getStashFile(dir) {
|
|
178
|
+
return path.join(dir, ".git", "stash.json");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async listStash(dir) {
|
|
182
|
+
const stashFile = this._getStashFile(dir);
|
|
183
|
+
if (!fs.existsSync(stashFile)) return [];
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(fs.readFileSync(stashFile, "utf8"));
|
|
186
|
+
} catch { return []; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async saveStash(dir, message = "WIP", includeUntracked = false) {
|
|
190
|
+
const st = await this.status(dir);
|
|
191
|
+
if (st.isClean && !includeUntracked) throw new MCPError("STASH", "Nada para stash");
|
|
192
|
+
|
|
193
|
+
// Salva arquivos modificados/adicionados
|
|
194
|
+
const filesToSave = [...st.not_added, ...st.modified, ...st.created];
|
|
195
|
+
const stashData = { message, timestamp: Date.now(), files: {} };
|
|
196
|
+
|
|
197
|
+
for (const file of filesToSave) {
|
|
198
|
+
const fullPath = path.join(dir, file);
|
|
199
|
+
if (fs.existsSync(fullPath)) {
|
|
200
|
+
stashData.files[file] = fs.readFileSync(fullPath, "utf8");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Carrega stashes existentes
|
|
205
|
+
const stashes = await this.listStash(dir);
|
|
206
|
+
stashes.unshift(stashData);
|
|
207
|
+
|
|
208
|
+
// Salva lista de stashes
|
|
209
|
+
fs.writeFileSync(this._getStashFile(dir), JSON.stringify(stashes, null, 2));
|
|
210
|
+
|
|
211
|
+
// Restaura arquivos para estado HEAD mantendo a branch atual
|
|
212
|
+
const currentBranch = await this.getCurrentBranch(dir);
|
|
213
|
+
if (currentBranch && currentBranch !== "HEAD") {
|
|
214
|
+
await git.checkout({ fs, dir, ref: currentBranch, force: true });
|
|
215
|
+
} else {
|
|
216
|
+
// Se estiver em detached HEAD, usa o SHA atual
|
|
217
|
+
const oid = await git.resolveRef({ fs, dir, ref: "HEAD" });
|
|
218
|
+
await git.checkout({ fs, dir, ref: oid, force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async applyStash(dir, ref = "stash@{0}") {
|
|
223
|
+
const stashes = await this.listStash(dir);
|
|
224
|
+
const idx = Number(ref.match(/\{(\d+)\}/)?.[1] || 0);
|
|
225
|
+
const stash = stashes[idx];
|
|
226
|
+
if (!stash) throw new MCPError("STASH", "Stash não encontrado");
|
|
227
|
+
|
|
228
|
+
// Restaura arquivos do stash
|
|
229
|
+
for (const [file, content] of Object.entries(stash.files)) {
|
|
230
|
+
const fullPath = path.join(dir, file);
|
|
231
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
232
|
+
fs.writeFileSync(fullPath, content);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async popStash(dir, ref = "stash@{0}") {
|
|
237
|
+
await this.applyStash(dir, ref);
|
|
238
|
+
await this.dropStash(dir, ref);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async dropStash(dir, ref = "stash@{0}") {
|
|
242
|
+
const stashes = await this.listStash(dir);
|
|
243
|
+
const idx = Number(ref.match(/\{(\d+)\}/)?.[1] || 0);
|
|
244
|
+
stashes.splice(idx, 1);
|
|
245
|
+
|
|
246
|
+
if (stashes.length === 0) {
|
|
247
|
+
const stashFile = this._getStashFile(dir);
|
|
248
|
+
if (fs.existsSync(stashFile)) fs.unlinkSync(stashFile);
|
|
249
|
+
} else {
|
|
250
|
+
fs.writeFileSync(this._getStashFile(dir), JSON.stringify(stashes, null, 2));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async clearStash(dir) {
|
|
255
|
+
const stashFile = this._getStashFile(dir);
|
|
256
|
+
if (fs.existsSync(stashFile)) fs.unlinkSync(stashFile);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ============ CONFIG ============
|
|
260
|
+
async getConfig(dir, key, scope = "local") {
|
|
261
|
+
return await git.getConfig({ fs, dir, path: key }).catch(() => undefined);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async setConfig(dir, key, value, scope = "local") {
|
|
265
|
+
await git.setConfig({ fs, dir, path: key, value });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async unsetConfig(dir, key, scope = "local") {
|
|
269
|
+
// isomorphic-git não tem unset, então setamos para undefined
|
|
270
|
+
const configPath = path.join(dir, ".git", "config");
|
|
271
|
+
if (!fs.existsSync(configPath)) return;
|
|
272
|
+
let content = fs.readFileSync(configPath, "utf8");
|
|
273
|
+
const regex = new RegExp(`^\\s*${key.split(".").pop()}\\s*=.*$`, "gm");
|
|
274
|
+
content = content.replace(regex, "");
|
|
275
|
+
fs.writeFileSync(configPath, content);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async listConfig(dir, scope = "local") {
|
|
279
|
+
const configPath = path.join(dir, ".git", "config");
|
|
280
|
+
const items = {};
|
|
281
|
+
if (!fs.existsSync(configPath)) return items;
|
|
282
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
283
|
+
let section = "";
|
|
284
|
+
for (const line of content.split("\n")) {
|
|
285
|
+
const secMatch = line.match(/^\[([^\]]+)\]$/);
|
|
286
|
+
if (secMatch) { section = secMatch[1].replace(/\s+"/g, ".").replace(/"/g, ""); continue; }
|
|
287
|
+
const kvMatch = line.match(/^\s*(\w+)\s*=\s*(.*)$/);
|
|
288
|
+
if (kvMatch && section) items[`${section}.${kvMatch[1]}`] = kvMatch[2].trim();
|
|
289
|
+
}
|
|
290
|
+
return items;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ============ FILES ============
|
|
294
|
+
async listFiles(dir, ref = "HEAD") {
|
|
295
|
+
const oid = await git.resolveRef({ fs, dir, ref });
|
|
296
|
+
const { tree } = await git.readTree({ fs, dir, oid });
|
|
297
|
+
const files = [];
|
|
298
|
+
const walk = async (treePath, entries) => {
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
const fullPath = treePath ? `${treePath}/${entry.path}` : entry.path;
|
|
301
|
+
if (entry.type === "blob") files.push(fullPath);
|
|
302
|
+
else if (entry.type === "tree") {
|
|
303
|
+
const { tree: subTree } = await git.readTree({ fs, dir, oid: entry.oid });
|
|
304
|
+
await walk(fullPath, subTree);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
await walk("", tree);
|
|
309
|
+
return files;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async readFile(dir, filepath, ref = "HEAD") {
|
|
313
|
+
const oid = await git.resolveRef({ fs, dir, ref });
|
|
314
|
+
const { blob } = await git.readBlob({ fs, dir, oid, filepath });
|
|
315
|
+
return new TextDecoder().decode(blob);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ============ HISTORY ============
|
|
319
|
+
async log(dir, { ref = "HEAD", maxCount = 50 } = {}) {
|
|
320
|
+
const commits = await git.log({ fs, dir, ref, depth: maxCount });
|
|
321
|
+
return commits.map(c => ({
|
|
322
|
+
sha: c.oid,
|
|
323
|
+
message: c.commit.message,
|
|
324
|
+
author: c.commit.author,
|
|
325
|
+
date: new Date(c.commit.author.timestamp * 1000).toISOString()
|
|
326
|
+
}));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ============ SYNC (FETCH/PULL) ============
|
|
330
|
+
async fetch(dir, remote, branch) {
|
|
331
|
+
const remotes = await git.listRemotes({ fs, dir });
|
|
332
|
+
const info = remotes.find(r => r.remote === remote);
|
|
333
|
+
if (!info) throw new MCPError("REMOTE", `Remote '${remote}' não encontrado`);
|
|
334
|
+
const onAuth = this.getAuth(info.url);
|
|
335
|
+
await git.fetch({ fs, http, dir, remote, ref: branch, singleBranch: true, onAuth });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async pull(dir, remote, branch) {
|
|
339
|
+
const remotes = await git.listRemotes({ fs, dir });
|
|
340
|
+
const info = remotes.find(r => r.remote === remote);
|
|
341
|
+
if (!info) throw new MCPError("REMOTE", `Remote '${remote}' não encontrado`);
|
|
342
|
+
const onAuth = this.getAuth(info.url);
|
|
343
|
+
await git.pull({ fs, http, dir, remote, ref: branch, singleBranch: true, onAuth, author: await this.getAuthor(dir) });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ============ RESET ============
|
|
347
|
+
async _resolveRef(dir, ref) {
|
|
348
|
+
// Resolve refs como HEAD~1, HEAD~2, etc.
|
|
349
|
+
const match = ref.match(/^(HEAD|[a-zA-Z0-9_\-\/]+)~(\d+)$/);
|
|
350
|
+
if (match) {
|
|
351
|
+
const baseRef = match[1];
|
|
352
|
+
const steps = parseInt(match[2], 10);
|
|
353
|
+
const commits = await git.log({ fs, dir, ref: baseRef, depth: steps + 1 });
|
|
354
|
+
if (commits.length > steps) return commits[steps].oid;
|
|
355
|
+
throw new MCPError("REF", `Não foi possível resolver ${ref}`);
|
|
356
|
+
}
|
|
357
|
+
// Refs normais
|
|
358
|
+
return await git.resolveRef({ fs, dir, ref });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async resetSoft(dir, ref) {
|
|
362
|
+
const oid = await this._resolveRef(dir, ref);
|
|
363
|
+
const headPath = path.join(dir, ".git", "HEAD");
|
|
364
|
+
const headContent = fs.readFileSync(headPath, "utf8").trim();
|
|
365
|
+
if (headContent.startsWith("ref:")) {
|
|
366
|
+
const branchRef = headContent.replace("ref: ", "");
|
|
367
|
+
const branchPath = path.join(dir, ".git", branchRef);
|
|
368
|
+
fs.writeFileSync(branchPath, oid + "\n");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async resetMixed(dir, ref) {
|
|
373
|
+
await this.resetSoft(dir, ref);
|
|
374
|
+
// Reset index to match ref
|
|
375
|
+
const oid = await this._resolveRef(dir, ref);
|
|
376
|
+
await git.checkout({ fs, dir, ref: oid, noUpdateHead: true });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async resetHard(dir, ref) {
|
|
380
|
+
const oid = await this._resolveRef(dir, ref);
|
|
381
|
+
await git.checkout({ fs, dir, ref: oid, force: true });
|
|
382
|
+
await this.resetSoft(dir, ref);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ============ GITIGNORE ============
|
|
386
|
+
async listGitignore(dir) {
|
|
387
|
+
const ignorePath = path.join(dir, ".gitignore");
|
|
388
|
+
if (!fs.existsSync(ignorePath)) return [];
|
|
389
|
+
return fs.readFileSync(ignorePath, "utf8").split("\n").filter(l => l.trim() && !l.startsWith("#"));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async createGitignore(dir, patterns = []) {
|
|
393
|
+
const ignorePath = path.join(dir, ".gitignore");
|
|
394
|
+
fs.writeFileSync(ignorePath, patterns.join("\n") + "\n");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async addToGitignore(dir, patterns = []) {
|
|
398
|
+
const ignorePath = path.join(dir, ".gitignore");
|
|
399
|
+
const existing = fs.existsSync(ignorePath) ? fs.readFileSync(ignorePath, "utf8") : "";
|
|
400
|
+
const newContent = existing.trimEnd() + "\n" + patterns.join("\n") + "\n";
|
|
401
|
+
fs.writeFileSync(ignorePath, newContent);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async removeFromGitignore(dir, patterns = []) {
|
|
405
|
+
const ignorePath = path.join(dir, ".gitignore");
|
|
406
|
+
if (!fs.existsSync(ignorePath)) return;
|
|
407
|
+
let lines = fs.readFileSync(ignorePath, "utf8").split("\n");
|
|
408
|
+
lines = lines.filter(l => !patterns.includes(l.trim()));
|
|
409
|
+
fs.writeFileSync(ignorePath, lines.join("\n"));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ============ REMOTES ============
|
|
413
|
+
async listRemotes(dir) {
|
|
414
|
+
return await git.listRemotes({ fs, dir });
|
|
114
415
|
}
|
|
115
416
|
}
|
|
116
417
|
|