@andrebuzeli/git-mcp 12.0.0 → 13.6.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.
@@ -2,8 +2,9 @@ import * as git from "isomorphic-git";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import http from "isomorphic-git/http/node";
5
- import { MCPError } from "./errors.js";
5
+ import { MCPError, createError, mapExternalError } from "./errors.js";
6
6
  import { getProvidersEnv } from "./repoHelpers.js";
7
+ import { withRetry } from "./retry.js";
7
8
 
8
9
  export class GitAdapter {
9
10
  constructor(providerManager) {
@@ -90,27 +91,409 @@ export class GitAdapter {
90
91
  getAuth(remoteUrl) {
91
92
  const { githubToken, giteaToken } = getProvidersEnv();
92
93
  if (remoteUrl.includes("github.com")) {
93
- if (!githubToken) throw new MCPError("AUTH", "GITHUB_TOKEN ausente");
94
+ if (!githubToken) throw createError("AUTH_GITHUB_INVALID", { message: "GITHUB_TOKEN não configurado" });
94
95
  return () => ({ username: githubToken, password: "x-oauth-basic" });
95
96
  }
96
97
  if (giteaToken) return () => ({ username: "git", password: giteaToken });
97
- throw new MCPError("AUTH", "GITEA_TOKEN ausente");
98
+ throw createError("AUTH_GITEA_INVALID", { message: "GITEA_TOKEN não configurado" });
98
99
  }
99
100
 
100
- async pushOne(dir, remote, branch) {
101
+ async pushOne(dir, remote, branch, force = false) {
101
102
  const remotes = await git.listRemotes({ fs, dir });
102
103
  const info = remotes.find(r => r.remote === remote);
103
- if (!info) throw new MCPError("REMOTE", `Remote '${remote}' não encontrado`);
104
+ if (!info) {
105
+ const available = remotes.map(r => r.remote).join(", ");
106
+ throw createError("REMOTE_NOT_FOUND", {
107
+ message: `Remote '${remote}' não encontrado`,
108
+ remote,
109
+ availableRemotes: available || "nenhum"
110
+ });
111
+ }
104
112
  const onAuth = this.getAuth(info.url);
105
113
  const ref = branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
106
- await git.push({ fs, http, dir, remote, ref, remoteRef: ref, onAuth });
114
+ try {
115
+ await withRetry(() => git.push({ fs, http, dir, remote, ref, remoteRef: ref, onAuth, force }));
116
+ } catch (e) {
117
+ if (e.message?.includes("non-fast-forward") || e.message?.includes("rejected")) {
118
+ throw createError("PUSH_REJECTED", { message: e.message, remote, branch });
119
+ }
120
+ throw mapExternalError(e, { type: "push", remote, branch });
121
+ }
107
122
  }
108
123
 
109
- async pushParallel(dir, branch) {
124
+ async pushParallel(dir, branch, force = false) {
110
125
  const remotes = await git.listRemotes({ fs, dir });
111
126
  const targets = remotes.filter(r => ["github", "gitea"].includes(r.remote));
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)));
127
+ if (targets.length === 0) {
128
+ throw createError("REMOTE_NOT_FOUND", {
129
+ message: "Nenhum remote github/gitea configurado",
130
+ availableRemotes: remotes.map(r => r.remote).join(", ") || "nenhum",
131
+ suggestion: "Use action='ensure-remotes' para configurar os remotes"
132
+ });
133
+ }
134
+ const results = await Promise.allSettled(targets.map(t => this.pushOne(dir, t.remote, branch, force)));
135
+ const errors = results.filter(r => r.status === "rejected");
136
+ if (errors.length === targets.length) {
137
+ throw createError("PUSH_REJECTED", {
138
+ message: "Push falhou para todos os remotes",
139
+ errors: errors.map(e => e.reason?.message || String(e.reason))
140
+ });
141
+ }
142
+ return {
143
+ pushed: targets.filter((t, i) => results[i].status === "fulfilled").map(t => t.remote),
144
+ failed: errors.map((e, i) => ({ remote: targets[i]?.remote, error: e.reason?.message }))
145
+ };
146
+ }
147
+
148
+ // ============ BRANCHES ============
149
+ async listBranches(dir, remote = false) {
150
+ if (remote) {
151
+ const remotes = await git.listRemotes({ fs, dir });
152
+ const branches = [];
153
+ for (const r of remotes) {
154
+ try {
155
+ const refs = await git.listServerRefs({ http, url: r.url, prefix: "refs/heads/", onAuth: this.getAuth(r.url) });
156
+ for (const ref of refs) branches.push(`${r.remote}/${ref.ref.replace("refs/heads/", "")}`);
157
+ } catch { /* ignore */ }
158
+ }
159
+ return branches;
160
+ }
161
+ return await git.listBranches({ fs, dir });
162
+ }
163
+
164
+ async createBranch(dir, name, ref = "HEAD") {
165
+ const oid = await git.resolveRef({ fs, dir, ref });
166
+ await git.branch({ fs, dir, ref: name, checkout: false });
167
+ }
168
+
169
+ async deleteBranch(dir, name, force = false) {
170
+ await git.deleteBranch({ fs, dir, ref: name });
171
+ }
172
+
173
+ async renameBranch(dir, oldName, newName) {
174
+ await git.renameBranch({ fs, dir, oldref: oldName, ref: newName });
175
+ }
176
+
177
+ async checkout(dir, ref) {
178
+ await git.checkout({ fs, dir, ref, force: true });
179
+ }
180
+
181
+ // ============ TAGS ============
182
+ async listTags(dir) {
183
+ return await git.listTags({ fs, dir });
184
+ }
185
+
186
+ async createTag(dir, tag, ref = "HEAD", message = "") {
187
+ const oid = await git.resolveRef({ fs, dir, ref });
188
+ if (message) {
189
+ const author = await this.getAuthor(dir);
190
+ await git.tag({ fs, dir, ref: tag, object: oid, tagger: { name: author.name, email: author.email }, message });
191
+ } else {
192
+ await git.tag({ fs, dir, ref: tag, object: oid });
193
+ }
194
+ }
195
+
196
+ async deleteTag(dir, tag) {
197
+ await git.deleteTag({ fs, dir, ref: tag });
198
+ }
199
+
200
+ async pushTag(dir, remote, tag) {
201
+ const remotes = await git.listRemotes({ fs, dir });
202
+ const info = remotes.find(r => r.remote === remote);
203
+ if (!info) throw new MCPError("REMOTE", `Remote '${remote}' não encontrado`);
204
+ const onAuth = this.getAuth(info.url);
205
+ await git.push({ fs, http, dir, remote, ref: `refs/tags/${tag}`, remoteRef: `refs/tags/${tag}`, onAuth });
206
+ }
207
+
208
+ // ============ STASH (simulated via JSON file) ============
209
+ _getStashFile(dir) {
210
+ return path.join(dir, ".git", "stash.json");
211
+ }
212
+
213
+ async listStash(dir) {
214
+ const stashFile = this._getStashFile(dir);
215
+ if (!fs.existsSync(stashFile)) return [];
216
+ try {
217
+ return JSON.parse(fs.readFileSync(stashFile, "utf8"));
218
+ } catch { return []; }
219
+ }
220
+
221
+ async saveStash(dir, message = "WIP", includeUntracked = false) {
222
+ const st = await this.status(dir);
223
+ if (st.isClean && !includeUntracked) {
224
+ throw createError("NOTHING_TO_STASH", {
225
+ message: "Working tree limpa, nada para stash",
226
+ status: st
227
+ });
228
+ }
229
+
230
+ // Salva arquivos modificados/adicionados
231
+ const filesToSave = [...st.not_added, ...st.modified, ...st.created];
232
+ const stashData = { message, timestamp: Date.now(), files: {} };
233
+
234
+ for (const file of filesToSave) {
235
+ const fullPath = path.join(dir, file);
236
+ if (fs.existsSync(fullPath)) {
237
+ stashData.files[file] = fs.readFileSync(fullPath, "utf8");
238
+ }
239
+ }
240
+
241
+ // Carrega stashes existentes
242
+ const stashes = await this.listStash(dir);
243
+ stashes.unshift(stashData);
244
+
245
+ // Salva lista de stashes
246
+ fs.writeFileSync(this._getStashFile(dir), JSON.stringify(stashes, null, 2));
247
+
248
+ // Restaura arquivos para estado HEAD mantendo a branch atual
249
+ const currentBranch = await this.getCurrentBranch(dir);
250
+ if (currentBranch && currentBranch !== "HEAD") {
251
+ await git.checkout({ fs, dir, ref: currentBranch, force: true });
252
+ } else {
253
+ // Se estiver em detached HEAD, usa o SHA atual
254
+ const oid = await git.resolveRef({ fs, dir, ref: "HEAD" });
255
+ await git.checkout({ fs, dir, ref: oid, force: true });
256
+ }
257
+ }
258
+
259
+ async applyStash(dir, ref = "stash@{0}") {
260
+ const stashes = await this.listStash(dir);
261
+ const idx = Number(ref.match(/\{(\d+)\}/)?.[1] || 0);
262
+ const stash = stashes[idx];
263
+ if (!stash) {
264
+ throw createError("STASH_NOT_FOUND", {
265
+ message: `Stash '${ref}' não encontrado`,
266
+ requestedIndex: idx,
267
+ availableStashes: stashes.length,
268
+ stashList: stashes.map((s, i) => `stash@{${i}}: ${s.message}`).slice(0, 5)
269
+ });
270
+ }
271
+
272
+ // Restaura arquivos do stash
273
+ for (const [file, content] of Object.entries(stash.files)) {
274
+ const fullPath = path.join(dir, file);
275
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
276
+ fs.writeFileSync(fullPath, content);
277
+ }
278
+ }
279
+
280
+ async popStash(dir, ref = "stash@{0}") {
281
+ await this.applyStash(dir, ref);
282
+ await this.dropStash(dir, ref);
283
+ }
284
+
285
+ async dropStash(dir, ref = "stash@{0}") {
286
+ const stashes = await this.listStash(dir);
287
+ const idx = Number(ref.match(/\{(\d+)\}/)?.[1] || 0);
288
+ stashes.splice(idx, 1);
289
+
290
+ if (stashes.length === 0) {
291
+ const stashFile = this._getStashFile(dir);
292
+ if (fs.existsSync(stashFile)) fs.unlinkSync(stashFile);
293
+ } else {
294
+ fs.writeFileSync(this._getStashFile(dir), JSON.stringify(stashes, null, 2));
295
+ }
296
+ }
297
+
298
+ async clearStash(dir) {
299
+ const stashFile = this._getStashFile(dir);
300
+ if (fs.existsSync(stashFile)) fs.unlinkSync(stashFile);
301
+ }
302
+
303
+ // ============ CONFIG ============
304
+ async getConfig(dir, key, scope = "local") {
305
+ return await git.getConfig({ fs, dir, path: key }).catch(() => undefined);
306
+ }
307
+
308
+ async setConfig(dir, key, value, scope = "local") {
309
+ await git.setConfig({ fs, dir, path: key, value });
310
+ }
311
+
312
+ async unsetConfig(dir, key, scope = "local") {
313
+ // isomorphic-git não tem unset, então setamos para undefined
314
+ const configPath = path.join(dir, ".git", "config");
315
+ if (!fs.existsSync(configPath)) return;
316
+ let content = fs.readFileSync(configPath, "utf8");
317
+ const regex = new RegExp(`^\\s*${key.split(".").pop()}\\s*=.*$`, "gm");
318
+ content = content.replace(regex, "");
319
+ fs.writeFileSync(configPath, content);
320
+ }
321
+
322
+ async listConfig(dir, scope = "local") {
323
+ const configPath = path.join(dir, ".git", "config");
324
+ const items = {};
325
+ if (!fs.existsSync(configPath)) return items;
326
+ const content = fs.readFileSync(configPath, "utf8");
327
+ let section = "";
328
+ for (const line of content.split("\n")) {
329
+ const secMatch = line.match(/^\[([^\]]+)\]$/);
330
+ if (secMatch) { section = secMatch[1].replace(/\s+"/g, ".").replace(/"/g, ""); continue; }
331
+ const kvMatch = line.match(/^\s*(\w+)\s*=\s*(.*)$/);
332
+ if (kvMatch && section) items[`${section}.${kvMatch[1]}`] = kvMatch[2].trim();
333
+ }
334
+ return items;
335
+ }
336
+
337
+ // ============ FILES ============
338
+ async listFiles(dir, ref = "HEAD") {
339
+ const oid = await git.resolveRef({ fs, dir, ref });
340
+ const { tree } = await git.readTree({ fs, dir, oid });
341
+ const files = [];
342
+ const walk = async (treePath, entries) => {
343
+ for (const entry of entries) {
344
+ const fullPath = treePath ? `${treePath}/${entry.path}` : entry.path;
345
+ if (entry.type === "blob") files.push(fullPath);
346
+ else if (entry.type === "tree") {
347
+ const { tree: subTree } = await git.readTree({ fs, dir, oid: entry.oid });
348
+ await walk(fullPath, subTree);
349
+ }
350
+ }
351
+ };
352
+ await walk("", tree);
353
+ return files;
354
+ }
355
+
356
+ async readFile(dir, filepath, ref = "HEAD") {
357
+ const oid = await git.resolveRef({ fs, dir, ref });
358
+ const { blob } = await git.readBlob({ fs, dir, oid, filepath });
359
+ return new TextDecoder().decode(blob);
360
+ }
361
+
362
+ // ============ HISTORY ============
363
+ async log(dir, { ref = "HEAD", maxCount = 50 } = {}) {
364
+ const commits = await git.log({ fs, dir, ref, depth: maxCount });
365
+ return commits.map(c => ({
366
+ sha: c.oid,
367
+ message: c.commit.message,
368
+ author: c.commit.author,
369
+ date: new Date(c.commit.author.timestamp * 1000).toISOString()
370
+ }));
371
+ }
372
+
373
+ // ============ SYNC (FETCH/PULL) ============
374
+ async fetch(dir, remote, branch) {
375
+ const remotes = await git.listRemotes({ fs, dir });
376
+ const info = remotes.find(r => r.remote === remote);
377
+ if (!info) {
378
+ throw createError("REMOTE_NOT_FOUND", {
379
+ message: `Remote '${remote}' não encontrado`,
380
+ availableRemotes: remotes.map(r => r.remote)
381
+ });
382
+ }
383
+ const onAuth = this.getAuth(info.url);
384
+ try {
385
+ await withRetry(() => git.fetch({ fs, http, dir, remote, ref: branch, singleBranch: true, onAuth }));
386
+ } catch (e) {
387
+ throw mapExternalError(e, { type: "fetch", remote, branch, provider: info.url.includes("github") ? "github" : "gitea" });
388
+ }
389
+ }
390
+
391
+ async pull(dir, remote, branch) {
392
+ const remotes = await git.listRemotes({ fs, dir });
393
+ const info = remotes.find(r => r.remote === remote);
394
+ if (!info) {
395
+ throw createError("REMOTE_NOT_FOUND", {
396
+ message: `Remote '${remote}' não encontrado`,
397
+ availableRemotes: remotes.map(r => r.remote)
398
+ });
399
+ }
400
+ const onAuth = this.getAuth(info.url);
401
+ const author = await this.getAuthor(dir);
402
+ try {
403
+ await withRetry(async () => git.pull({ fs, http, dir, remote, ref: branch, singleBranch: true, onAuth, author }));
404
+ } catch (e) {
405
+ if (e.message?.includes("conflict")) {
406
+ throw createError("MERGE_CONFLICT", { message: e.message, remote, branch });
407
+ }
408
+ throw mapExternalError(e, { type: "pull", remote, branch, provider: info.url.includes("github") ? "github" : "gitea" });
409
+ }
410
+ }
411
+
412
+ // ============ RESET ============
413
+ async _resolveRef(dir, ref) {
414
+ // Resolve refs como HEAD~1, HEAD~2, etc.
415
+ const match = ref.match(/^(HEAD|[a-zA-Z0-9_\-\/]+)~(\d+)$/);
416
+ if (match) {
417
+ const baseRef = match[1];
418
+ const steps = parseInt(match[2], 10);
419
+ const commits = await git.log({ fs, dir, ref: baseRef, depth: steps + 1 });
420
+ if (commits.length > steps) return commits[steps].oid;
421
+ throw createError("INSUFFICIENT_HISTORY", {
422
+ message: `Não foi possível resolver ${ref}`,
423
+ requestedSteps: steps,
424
+ availableCommits: commits.length,
425
+ suggestion: `Histórico tem apenas ${commits.length} commits. Use HEAD~${commits.length - 1} no máximo.`
426
+ });
427
+ }
428
+ // Refs normais
429
+ try {
430
+ return await git.resolveRef({ fs, dir, ref });
431
+ } catch (e) {
432
+ const branches = await git.listBranches({ fs, dir }).catch(() => []);
433
+ const tags = await git.listTags({ fs, dir }).catch(() => []);
434
+ throw createError("REF_NOT_FOUND", {
435
+ message: `Ref '${ref}' não encontrada`,
436
+ ref,
437
+ availableBranches: branches.slice(0, 10),
438
+ availableTags: tags.slice(0, 10)
439
+ });
440
+ }
441
+ }
442
+
443
+ async resetSoft(dir, ref) {
444
+ const oid = await this._resolveRef(dir, ref);
445
+ const headPath = path.join(dir, ".git", "HEAD");
446
+ const headContent = fs.readFileSync(headPath, "utf8").trim();
447
+ if (headContent.startsWith("ref:")) {
448
+ const branchRef = headContent.replace("ref: ", "");
449
+ const branchPath = path.join(dir, ".git", branchRef);
450
+ fs.writeFileSync(branchPath, oid + "\n");
451
+ }
452
+ }
453
+
454
+ async resetMixed(dir, ref) {
455
+ await this.resetSoft(dir, ref);
456
+ // Reset index to match ref
457
+ const oid = await this._resolveRef(dir, ref);
458
+ await git.checkout({ fs, dir, ref: oid, noUpdateHead: true });
459
+ }
460
+
461
+ async resetHard(dir, ref) {
462
+ const oid = await this._resolveRef(dir, ref);
463
+ await git.checkout({ fs, dir, ref: oid, force: true });
464
+ await this.resetSoft(dir, ref);
465
+ }
466
+
467
+ // ============ GITIGNORE ============
468
+ async listGitignore(dir) {
469
+ const ignorePath = path.join(dir, ".gitignore");
470
+ if (!fs.existsSync(ignorePath)) return [];
471
+ return fs.readFileSync(ignorePath, "utf8").split("\n").filter(l => l.trim() && !l.startsWith("#"));
472
+ }
473
+
474
+ async createGitignore(dir, patterns = []) {
475
+ const ignorePath = path.join(dir, ".gitignore");
476
+ fs.writeFileSync(ignorePath, patterns.join("\n") + "\n");
477
+ }
478
+
479
+ async addToGitignore(dir, patterns = []) {
480
+ const ignorePath = path.join(dir, ".gitignore");
481
+ const existing = fs.existsSync(ignorePath) ? fs.readFileSync(ignorePath, "utf8") : "";
482
+ const newContent = existing.trimEnd() + "\n" + patterns.join("\n") + "\n";
483
+ fs.writeFileSync(ignorePath, newContent);
484
+ }
485
+
486
+ async removeFromGitignore(dir, patterns = []) {
487
+ const ignorePath = path.join(dir, ".gitignore");
488
+ if (!fs.existsSync(ignorePath)) return;
489
+ let lines = fs.readFileSync(ignorePath, "utf8").split("\n");
490
+ lines = lines.filter(l => !patterns.includes(l.trim()));
491
+ fs.writeFileSync(ignorePath, lines.join("\n"));
492
+ }
493
+
494
+ // ============ REMOTES ============
495
+ async listRemotes(dir) {
496
+ return await git.listRemotes({ fs, dir });
114
497
  }
115
498
  }
116
499
 
@@ -1,12 +1,61 @@
1
- export async function withRetry(fn, { retries = 3, baseDelayMs = 300 } = {}) {
2
- let attempt = 0;
3
- let lastErr;
4
- while (attempt < retries) {
5
- try { return await fn(); } catch (e) { lastErr = e; }
6
- const delay = baseDelayMs * Math.pow(2, attempt);
7
- await new Promise(r => setTimeout(r, delay));
8
- attempt++;
1
+ // Sistema de Retry com Backoff Exponencial
2
+
3
+ const DEFAULT_OPTIONS = {
4
+ maxRetries: 3,
5
+ initialDelay: 1000,
6
+ maxDelay: 10000,
7
+ backoffFactor: 2,
8
+ retryableErrors: ["ETIMEDOUT", "ECONNRESET", "ENOTFOUND", "ECONNREFUSED", "timeout", "rate limit", "429", "503", "502"]
9
+ };
10
+
11
+ function shouldRetry(error, options) {
12
+ const msg = (error?.message || String(error)).toLowerCase();
13
+ const code = error?.code?.toLowerCase() || "";
14
+
15
+ return options.retryableErrors.some(e =>
16
+ msg.includes(e.toLowerCase()) || code.includes(e.toLowerCase())
17
+ );
18
+ }
19
+
20
+ function sleep(ms) {
21
+ return new Promise(resolve => setTimeout(resolve, ms));
22
+ }
23
+
24
+ export async function withRetry(fn, options = {}) {
25
+ const opts = { ...DEFAULT_OPTIONS, ...options };
26
+ let lastError;
27
+ let delay = opts.initialDelay;
28
+
29
+ for (let attempt = 1; attempt <= opts.maxRetries; attempt++) {
30
+ try {
31
+ return await fn();
32
+ } catch (error) {
33
+ lastError = error;
34
+
35
+ if (attempt === opts.maxRetries || !shouldRetry(error, opts)) {
36
+ throw error;
37
+ }
38
+
39
+ // Log retry (silencioso)
40
+ console.error(`[Retry] Attempt ${attempt}/${opts.maxRetries} failed, retrying in ${delay}ms...`);
41
+
42
+ await sleep(delay);
43
+ delay = Math.min(delay * opts.backoffFactor, opts.maxDelay);
44
+ }
9
45
  }
10
- throw lastErr;
46
+
47
+ throw lastError;
48
+ }
49
+
50
+ // Wrapper para axios com retry
51
+ export async function axiosWithRetry(axiosInstance, config, options = {}) {
52
+ return withRetry(() => axiosInstance(config), options);
11
53
  }
12
54
 
55
+ // Wrapper para operações git com retry
56
+ export async function gitWithRetry(fn, options = {}) {
57
+ return withRetry(fn, {
58
+ ...options,
59
+ retryableErrors: [...DEFAULT_OPTIONS.retryableErrors, "ENOENT", "lock"]
60
+ });
61
+ }