@andrebuzeli/git-mcp 15.9.2 → 15.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrebuzeli/git-mcp",
3
- "version": "15.9.2",
3
+ "version": "15.10.0",
4
4
  "private": false,
5
5
  "description": "MCP server para Git com operações locais e sincronização paralela GitHub/Gitea",
6
6
  "license": "MIT",
@@ -95,7 +95,25 @@ CONVENÇÕES DE NOMES:
95
95
  }
96
96
  if (action === "checkout") {
97
97
  if (!args.branch) return asToolError("MISSING_PARAMETER", "branch é obrigatório para checkout", { parameter: "branch" });
98
- await git.checkout(projectPath, args.branch);
98
+
99
+ try {
100
+ await git.checkout(projectPath, args.branch);
101
+ } catch (e) {
102
+ const msg = e.message || "";
103
+ // Auto-fix: Create if not exists (common intent)
104
+ if (msg.includes("did not match any file") || msg.includes("pathspec") || msg.includes("not found")) {
105
+ // We can suggest or auto-create if configured. For now, let's suggest clearly or auto-create if it looks like a feature branch?
106
+ // Safer: Suggest. Or better: check if we should auto-create.
107
+ // Let's stick to safe behavior but enhanced error.
108
+
109
+ // Check if user meant create
110
+ return asToolError("BRANCH_NOT_FOUND", `Branch '${args.branch}' não existe`, {
111
+ suggestion: "Use action='create' para criar esta branch, ou verifique o nome."
112
+ });
113
+ }
114
+ throw e;
115
+ }
116
+
99
117
  return asToolResult({ success: true, branch: args.branch, message: `Mudou para branch '${args.branch}'` });
100
118
  }
101
119
  return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, { availableActions: ["list", "create", "delete", "rename", "checkout"] });
@@ -1,6 +1,6 @@
1
1
  import Ajv from "ajv";
2
2
  import { asToolError, asToolResult, errorToResponse, createError } from "../utils/errors.js";
3
- import { validateProjectPath } from "../utils/repoHelpers.js";
3
+ import { validateProjectPath, withRetry } from "../utils/repoHelpers.js";
4
4
 
5
5
  const ajv = new Ajv({ allErrors: true });
6
6
 
@@ -75,11 +75,44 @@ NOTAS:
75
75
  });
76
76
  }
77
77
 
78
- const result = await git.clone(url, projectPath, {
79
- branch: args.branch,
80
- depth: args.depth,
81
- singleBranch: args.singleBranch
82
- });
78
+ // Idempotency check
79
+ if (fs.existsSync(path.join(projectPath, ".git"))) {
80
+ try {
81
+ // Check if it's the same repo
82
+ // git.getRemoteUrl is not standard in adapter, use exec or listRemotes logic
83
+ // Assuming git.listRemotes or similar exists, or we catch the error
84
+ // Let's try to infer from config or just warn
85
+
86
+ // We'll rely on git status to check if it's healthy
87
+ await git.status(projectPath);
88
+
89
+ return asToolResult({
90
+ success: true,
91
+ url,
92
+ path: projectPath,
93
+ branch: args.branch || "current",
94
+ message: `Repositório já existe em '${projectPath}'. Clone ignorado (idempotente).`,
95
+ nextStep: "Use git-workflow status para ver o estado atual"
96
+ });
97
+ } catch (e) {
98
+ // If status fails, maybe it's broken
99
+ console.warn("Existing repo check failed:", e);
100
+ }
101
+ } else if (fs.existsSync(projectPath) && fs.readdirSync(projectPath).length > 0) {
102
+ return asToolError("DIR_NOT_EMPTY", `Diretório '${projectPath}' existe e não está vazio`, {
103
+ suggestion: "Use um diretório novo ou limpe o atual"
104
+ });
105
+ }
106
+
107
+ const result = await withRetry(
108
+ () => git.clone(url, projectPath, {
109
+ branch: args.branch,
110
+ depth: args.depth,
111
+ singleBranch: args.singleBranch
112
+ }),
113
+ 3,
114
+ "clone"
115
+ );
83
116
 
84
117
  return asToolResult({
85
118
  success: true,
@@ -66,21 +66,21 @@ EXEMPLOS:
66
66
  validateProjectPath(projectPath);
67
67
  if (action === "get") {
68
68
  if (!args.key) return asToolError("MISSING_PARAMETER", "key é obrigatório", { parameter: "key" });
69
- const val = await git.getConfig(projectPath, args.key, scope);
69
+ const val = await withRetry(() => git.getConfig(projectPath, args.key, scope), 3, "get-config");
70
70
  return asToolResult({ key: args.key, value: val, found: val !== undefined, scope });
71
71
  }
72
72
  if (action === "set") {
73
73
  if (!args.key) return asToolError("MISSING_PARAMETER", "key é obrigatório", { parameter: "key" });
74
- await git.setConfig(projectPath, args.key, args.value ?? "", scope);
74
+ await withRetry(() => git.setConfig(projectPath, args.key, args.value ?? "", scope), 3, "set-config");
75
75
  return asToolResult({ success: true, key: args.key, value: args.value ?? "", scope });
76
76
  }
77
77
  if (action === "unset") {
78
78
  if (!args.key) return asToolError("MISSING_PARAMETER", "key é obrigatório", { parameter: "key" });
79
- await git.unsetConfig(projectPath, args.key, scope);
79
+ await withRetry(() => git.unsetConfig(projectPath, args.key, scope), 3, "unset-config");
80
80
  return asToolResult({ success: true, key: args.key, removed: true, scope });
81
81
  }
82
82
  if (action === "list") {
83
- const items = await git.listConfig(projectPath, scope);
83
+ const items = await withRetry(() => git.listConfig(projectPath, scope), 3, "list-config");
84
84
  return asToolResult({ scope, configs: items, count: Object.keys(items).length });
85
85
  }
86
86
  return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, { availableActions: ["get", "set", "unset", "list"] });
@@ -1,6 +1,6 @@
1
1
  import Ajv from "ajv";
2
2
  import { asToolError, asToolResult, errorToResponse } from "../utils/errors.js";
3
- import { validateProjectPath } from "../utils/repoHelpers.js";
3
+ import { validateProjectPath, withRetry } from "../utils/repoHelpers.js";
4
4
 
5
5
  const ajv = new Ajv({ allErrors: true });
6
6
 
@@ -80,10 +80,10 @@ EXEMPLOS:
80
80
  try {
81
81
  validateProjectPath(projectPath);
82
82
  if (action === "show") {
83
- const diff = await git.diff(projectPath, {
83
+ const diff = await withRetry(() => git.diff(projectPath, {
84
84
  file: fileParam,
85
85
  context: args.context || 3
86
- });
86
+ }), 3, "diff-show");
87
87
 
88
88
  if (!diff || diff.length === 0) {
89
89
  return asToolResult({
@@ -100,10 +100,10 @@ EXEMPLOS:
100
100
  }
101
101
 
102
102
  if (action === "compare") {
103
- const diff = await git.diffCommits(projectPath, fromParam, toParam, {
103
+ const diff = await withRetry(() => git.diffCommits(projectPath, fromParam, toParam, {
104
104
  file: fileParam,
105
105
  context: args.context || 3
106
- });
106
+ }), 3, "diff-compare");
107
107
 
108
108
  return asToolResult({
109
109
  from: fromParam,
@@ -115,7 +115,7 @@ EXEMPLOS:
115
115
  }
116
116
 
117
117
  if (action === "stat") {
118
- const stats = await git.diffStats(projectPath, fromParam, toParam);
118
+ const stats = await withRetry(() => git.diffStats(projectPath, fromParam, toParam), 3, "diff-stat");
119
119
 
120
120
  return asToolResult({
121
121
  from: fromParam,
@@ -1,6 +1,6 @@
1
1
  import Ajv from "ajv";
2
2
  import { asToolError, asToolResult, errorToResponse } from "../utils/errors.js";
3
- import { validateProjectPath } from "../utils/repoHelpers.js";
3
+ import { validateProjectPath, withRetry } from "../utils/repoHelpers.js";
4
4
 
5
5
  const ajv = new Ajv({ allErrors: true });
6
6
 
@@ -1,6 +1,6 @@
1
1
  import Ajv from "ajv";
2
2
  import { asToolError, asToolResult, errorToResponse } from "../utils/errors.js";
3
- import { validateProjectPath } from "../utils/repoHelpers.js";
3
+ import { validateProjectPath, withRetry } from "../utils/repoHelpers.js";
4
4
 
5
5
  const ajv = new Ajv({ allErrors: true });
6
6
 
@@ -58,7 +58,7 @@ EXEMPLOS:
58
58
  try {
59
59
  validateProjectPath(projectPath);
60
60
  if (action === "log") {
61
- const items = await git.log(projectPath, { ref: args.ref || "HEAD", maxCount: args.maxCount || 50 });
61
+ const items = await withRetry(() => git.log(projectPath, { ref: args.ref || "HEAD", maxCount: args.maxCount || 50 }), 3, "history-log");
62
62
  if (items.length === 0) {
63
63
  return asToolResult({
64
64
  commits: [],
@@ -61,7 +61,7 @@ EXEMPLOS:
61
61
  try {
62
62
  validateProjectPath(projectPath);
63
63
  if (action === "list") {
64
- const items = await git.listGitignore(projectPath);
64
+ const items = await withRetry(() => git.listGitignore(projectPath), 3, "ignore-list");
65
65
  return asToolResult({ patterns: items, count: items.length, hasGitignore: items.length > 0 || true });
66
66
  }
67
67
  if (action === "create") {
@@ -71,21 +71,21 @@ EXEMPLOS:
71
71
  suggestion: "Forneça um array de padrões. Ex: ['node_modules/', '*.log', '.env']"
72
72
  });
73
73
  }
74
- await git.createGitignore(projectPath, patterns);
74
+ await withRetry(() => git.createGitignore(projectPath, patterns), 3, "ignore-create");
75
75
  return asToolResult({ success: true, patterns, message: ".gitignore criado" });
76
76
  }
77
77
  if (action === "add") {
78
78
  if (patterns.length === 0) {
79
79
  return asToolError("MISSING_PARAMETER", "patterns é obrigatório", { parameter: "patterns" });
80
80
  }
81
- await git.addToGitignore(projectPath, patterns);
81
+ await withRetry(() => git.addToGitignore(projectPath, patterns), 3, "ignore-add");
82
82
  return asToolResult({ success: true, added: patterns });
83
83
  }
84
84
  if (action === "remove") {
85
85
  if (patterns.length === 0) {
86
86
  return asToolError("MISSING_PARAMETER", "patterns é obrigatório", { parameter: "patterns" });
87
87
  }
88
- await git.removeFromGitignore(projectPath, patterns);
88
+ await withRetry(() => git.removeFromGitignore(projectPath, patterns), 3, "ignore-remove");
89
89
  return asToolResult({ success: true, removed: patterns });
90
90
  }
91
91
  return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, { availableActions: ["list", "create", "add", "remove"] });
@@ -1,7 +1,7 @@
1
1
  import Ajv from "ajv";
2
2
  import axios from "axios";
3
3
  import { asToolError, asToolResult, errorToResponse } from "../utils/errors.js";
4
- import { getRepoNameFromPath, validateProjectPath } from "../utils/repoHelpers.js";
4
+ import { getRepoNameFromPath, validateProjectPath, withRetry } from "../utils/repoHelpers.js";
5
5
  import { runBoth } from "../utils/providerExec.js";
6
6
 
7
7
  const ajv = new Ajv({ allErrors: true });
@@ -65,26 +65,26 @@ BOAS PRÁTICAS:
65
65
  try {
66
66
  if (args.action === "create") {
67
67
  if (!args.title) return asToolError("MISSING_PARAMETER", "title é obrigatório para criar issue", { parameter: "title" });
68
- const out = await runBoth(pm, {
68
+ const out = await withRetry(() => runBoth(pm, {
69
69
  github: async (owner) => { const r = await pm.github.rest.issues.create({ owner, repo, title: args.title, body: args.body || "" }); return { ok: true, number: r.data.number, url: r.data.html_url }; },
70
70
  gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.post(`${base}/api/v1/repos/${owner}/${repo}/issues`, { title: args.title, body: args.body || "" }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, number: r.data?.number }; }
71
- });
71
+ }), 3, "issues-create");
72
72
  return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), title: args.title, providers: out });
73
73
  }
74
74
  if (args.action === "list") {
75
- const out = await runBoth(pm, {
75
+ const out = await withRetry(() => runBoth(pm, {
76
76
  github: async (owner) => { const r = await pm.github.rest.issues.listForRepo({ owner, repo, state: "all", per_page: 30 }); return { ok: true, count: r.data.length, issues: r.data.slice(0, 10).map(i => ({ number: i.number, title: i.title, state: i.state })) }; },
77
77
  gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.get(`${base}/api/v1/repos/${owner}/${repo}/issues?state=all`, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, count: (r.data||[]).length, issues: (r.data||[]).slice(0, 10).map(i => ({ number: i.number, title: i.title, state: i.state })) }; }
78
- });
78
+ }), 3, "issues-list");
79
79
  return asToolResult({ providers: out });
80
80
  }
81
81
  if (args.action === "comment") {
82
82
  if (!args.number) return asToolError("MISSING_PARAMETER", "number é obrigatório para comentar", { parameter: "number" });
83
83
  if (!args.body) return asToolError("MISSING_PARAMETER", "body é obrigatório para comentar", { parameter: "body" });
84
- const out = await runBoth(pm, {
84
+ const out = await withRetry(() => runBoth(pm, {
85
85
  github: async (owner) => { await pm.github.rest.issues.createComment({ owner, repo, issue_number: args.number, body: args.body }); return { ok: true }; },
86
86
  gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); await axios.post(`${base}/api/v1/repos/${owner}/${repo}/issues/${args.number}/comments`, { body: args.body }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
87
- });
87
+ }), 3, "issues-comment");
88
88
  return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), issueNumber: args.number, providers: out });
89
89
  }
90
90
  return asToolError("VALIDATION_ERROR", `Ação '${args.action}' não suportada`, { availableActions: ["create", "list", "comment"] });
@@ -91,6 +91,16 @@ SQUASH:
91
91
  });
92
92
  }
93
93
 
94
+ // Pre-merge check: working tree status
95
+ const status = await git.status(projectPath);
96
+ if (!status.isClean) {
97
+ // We could warn or fail. Standard git refuses merge if changes would be overwritten.
98
+ // Let's just warn in logs or return error if critical?
99
+ // Git will fail anyway if conflicts with local changes.
100
+ // Let's inform the user.
101
+ console.warn("[GitMerge] Working tree not clean. Merge might fail.");
102
+ }
103
+
94
104
  const currentBranch = await git.getCurrentBranch(projectPath);
95
105
  if (currentBranch === args.branch) {
96
106
  return asToolError("VALIDATION_ERROR", "Não pode fazer merge de uma branch nela mesma", {
@@ -99,21 +109,35 @@ SQUASH:
99
109
  });
100
110
  }
101
111
 
102
- const result = await git.merge(projectPath, args.branch, {
103
- message: args.message,
104
- noCommit: args.noCommit,
105
- squash: args.squash
106
- });
107
-
108
- return asToolResult({
109
- success: true,
110
- from: args.branch,
111
- into: currentBranch,
112
- ...result,
113
- message: result.conflicts?.length > 0
114
- ? `Merge com conflitos. Resolva os conflitos e faça commit.`
115
- : `Merge de '${args.branch}' em '${currentBranch}' concluído`
116
- });
112
+ try {
113
+ const result = await git.merge(projectPath, args.branch, {
114
+ message: args.message,
115
+ noCommit: args.noCommit,
116
+ squash: args.squash
117
+ });
118
+
119
+ return asToolResult({
120
+ success: true,
121
+ from: args.branch,
122
+ into: currentBranch,
123
+ ...result,
124
+ message: result.conflicts?.length > 0
125
+ ? `Merge com conflitos. Resolva os conflitos e faça commit.`
126
+ : `Merge de '${args.branch}' em '${currentBranch}' concluído`
127
+ });
128
+ } catch (e) {
129
+ const msg = e.message || "";
130
+ if (msg.includes("conflict") || msg.includes("CONFLICT")) {
131
+ return asToolResult({
132
+ success: false,
133
+ conflict: true,
134
+ message: "Merge resultou em conflitos. Resolva manualmente e faça commit.",
135
+ files: [], // If we could parse files from error, great.
136
+ nextStep: "Edite os arquivos conflitantes, use git add e git commit."
137
+ });
138
+ }
139
+ throw e;
140
+ }
117
141
  }
118
142
 
119
143
  return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, {
@@ -80,26 +80,26 @@ NOTA: O PR é criado em AMBOS os providers simultaneamente.`;
80
80
  const base = args.base;
81
81
  const title = args.title || `${head} -> ${base}`;
82
82
  const body = args.body || "";
83
- const out = await runBoth(pm, {
83
+ const out = await withRetry(() => runBoth(pm, {
84
84
  github: async (owner) => { const r = await pm.github.rest.pulls.create({ owner, repo, title, head, base, body }); return { ok: true, number: r.data.number, url: r.data.html_url }; },
85
85
  gitea: async (owner) => { const baseUrl = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.post(`${baseUrl}/api/v1/repos/${owner}/${repo}/pulls`, { title, head, base, body }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, number: r.data?.number }; }
86
- });
86
+ }), 3, "pulls-create");
87
87
  return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), title, head, base, providers: out });
88
88
  }
89
89
  if (args.action === "list") {
90
- const out = await runBoth(pm, {
90
+ const out = await withRetry(() => runBoth(pm, {
91
91
  github: async (owner) => { const r = await pm.github.rest.pulls.list({ owner, repo, state: "all", per_page: 30 }); return { ok: true, count: r.data.length, pulls: r.data.slice(0, 10).map(p => ({ number: p.number, title: p.title, state: p.state, head: p.head.ref, base: p.base.ref })) }; },
92
92
  gitea: async (owner) => { const baseUrl = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.get(`${baseUrl}/api/v1/repos/${owner}/${repo}/pulls?state=all`, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, count: (r.data||[]).length, pulls: (r.data||[]).slice(0, 10).map(p => ({ number: p.number, title: p.title, state: p.state, head: p.head?.ref, base: p.base?.ref })) }; }
93
- });
93
+ }), 3, "pulls-list");
94
94
  return asToolResult({ providers: out });
95
95
  }
96
96
  if (args.action === "files") {
97
97
  if (!args.number) return asToolError("MISSING_PARAMETER", "number é obrigatório para ver arquivos do PR", { parameter: "number" });
98
- const out = await runBoth(pm, {
98
+ const out = await withRetry(() => runBoth(pm, {
99
99
  github: async (owner) => { const r = await pm.github.rest.pulls.listFiles({ owner, repo, pull_number: args.number }); return { ok: true, count: r.data.length, files: r.data.map(f => ({ filename: f.filename, status: f.status, additions: f.additions, deletions: f.deletions })) }; },
100
100
  gitea: async (owner) => { const baseUrl = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.get(`${baseUrl}/api/v1/repos/${owner}/${repo}/pulls/${args.number}/files`, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, count: (r.data||[]).length, files: (r.data||[]).map(f => ({ filename: f.filename, status: f.status })) }; }
101
- });
102
- return asToolResult({ prNumber: args.number, providers: out });
101
+ }), 3, "pulls-files");
102
+ return asToolResult({ success: true, providers: out });
103
103
  }
104
104
  return asToolError("VALIDATION_ERROR", `Ação '${args.action}' não suportada`, { availableActions: ["create", "list", "files"] });
105
105
  } catch (e) {
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import archiver from "archiver";
6
6
  import { asToolError, asToolResult, errorToResponse, mapExternalError } from "../utils/errors.js";
7
- import { getRepoNameFromPath, validateProjectPath } from "../utils/repoHelpers.js";
7
+ import { getRepoNameFromPath, validateProjectPath, withRetry } from "../utils/repoHelpers.js";
8
8
  import { runBoth } from "../utils/providerExec.js";
9
9
 
10
10
  /**
@@ -1,6 +1,6 @@
1
1
  import Ajv from "ajv";
2
2
  import { asToolError, asToolResult, errorToResponse } from "../utils/errors.js";
3
- import { validateProjectPath } from "../utils/repoHelpers.js";
3
+ import { validateProjectPath, withRetry } from "../utils/repoHelpers.js";
4
4
 
5
5
  const ajv = new Ajv({ allErrors: true });
6
6
 
@@ -73,19 +73,19 @@ REFERÊNCIAS:
73
73
  }
74
74
 
75
75
  if (action === "soft") {
76
- await git.resetSoft(projectPath, ref);
76
+ await withRetry(() => git.resetSoft(projectPath, ref), 3, "reset-soft");
77
77
  return asToolResult({ success: true, action: "soft", ref, message: "Commits desfeitos, mudanças mantidas staged" });
78
78
  }
79
79
  if (action === "mixed") {
80
- await git.resetMixed(projectPath, ref);
80
+ await withRetry(() => git.resetMixed(projectPath, ref), 3, "reset-mixed");
81
81
  return asToolResult({ success: true, action: "mixed", ref, message: "Commits desfeitos, mudanças mantidas no working directory" });
82
82
  }
83
83
  if (action === "hard") {
84
- await git.resetHard(projectPath, ref);
84
+ await withRetry(() => git.resetHard(projectPath, ref), 3, "reset-hard");
85
85
  return asToolResult({ success: true, action: "hard", ref, message: "⚠️ Reset hard executado - mudanças descartadas" });
86
86
  }
87
87
  if (action === "hard-clean") {
88
- const result = await git.resetHardClean(projectPath, ref);
88
+ const result = await withRetry(() => git.resetHardClean(projectPath, ref), 3, "reset-hard-clean");
89
89
  return asToolResult({
90
90
  success: true,
91
91
  action: "hard-clean",
@@ -67,7 +67,7 @@ AÇÕES:
67
67
  try {
68
68
  validateProjectPath(projectPath);
69
69
  if (action === "list") {
70
- const items = await git.listStash(projectPath);
70
+ const items = await withRetry(() => git.listStash(projectPath), 3, "stash-list");
71
71
  return asToolResult({
72
72
  stashes: items.map((s, i) => ({ index: i, ref: `stash@{${i}}`, message: s.message, timestamp: s.timestamp })),
73
73
  count: items.length,
@@ -79,7 +79,7 @@ AÇÕES:
79
79
  if (status.isClean && !args.includeUntracked) {
80
80
  return asToolError("NOTHING_TO_STASH", "Working tree limpa, nada para stash", { status });
81
81
  }
82
- await git.saveStash(projectPath, args.message || "WIP", !!args.includeUntracked);
82
+ await withRetry(() => git.saveStash(projectPath, args.message || "WIP", !!args.includeUntracked), 3, "stash-save");
83
83
  return asToolResult({ success: true, message: args.message || "WIP", savedFiles: status.modified.length + status.created.length + status.not_added.length });
84
84
  }
85
85
  if (action === "apply") {
@@ -87,7 +87,7 @@ AÇÕES:
87
87
  if (items.length === 0) {
88
88
  return asToolError("STASH_NOT_FOUND", "Nenhum stash disponível", { availableStashes: [] });
89
89
  }
90
- await git.applyStash(projectPath, args.ref || "stash@{0}");
90
+ await withRetry(() => git.applyStash(projectPath, args.ref || "stash@{0}"), 3, "stash-apply");
91
91
  return asToolResult({ success: true, ref: args.ref || "stash@{0}", message: "Stash aplicado (ainda está na lista)" });
92
92
  }
93
93
  if (action === "pop") {
@@ -95,7 +95,7 @@ AÇÕES:
95
95
  if (items.length === 0) {
96
96
  return asToolError("STASH_NOT_FOUND", "Nenhum stash disponível", { availableStashes: [] });
97
97
  }
98
- await git.popStash(projectPath, args.ref || "stash@{0}");
98
+ await withRetry(() => git.popStash(projectPath, args.ref || "stash@{0}"), 3, "stash-pop");
99
99
  return asToolResult({ success: true, ref: args.ref || "stash@{0}", message: "Stash aplicado e removido da lista" });
100
100
  }
101
101
  if (action === "drop") {
@@ -1,6 +1,6 @@
1
1
  import Ajv from "ajv";
2
2
  import { asToolError, asToolResult, errorToResponse } from "../utils/errors.js";
3
- import { validateProjectPath } from "../utils/repoHelpers.js";
3
+ import { validateProjectPath, withRetry } from "../utils/repoHelpers.js";
4
4
 
5
5
  const ajv = new Ajv({ allErrors: true });
6
6
 
@@ -58,7 +58,11 @@ NOTA: Se pull falhar com conflito, resolva manualmente e faça commit.`;
58
58
  const branch = args.branch || await git.getCurrentBranch(projectPath);
59
59
  if (action === "fetch") {
60
60
  const remotes = args.remote ? [args.remote] : ["origin", "github", "gitea"];
61
- const promises = remotes.map(r => git.fetch(projectPath, r, branch).then(() => ({ remote: r, ok: true })).catch(e => ({ remote: r, ok: false, error: String(e?.message || e) })));
61
+ const promises = remotes.map(r => withRetry(
62
+ () => git.fetch(projectPath, r, branch),
63
+ 3,
64
+ `fetch-${r}`
65
+ ).then(() => ({ remote: r, ok: true })).catch(e => ({ remote: r, ok: false, error: String(e?.message || e) })));
62
66
  const results = await Promise.all(promises);
63
67
  const successful = results.filter(x => x.ok);
64
68
  const failed = results.filter(x => !x.ok);
@@ -71,17 +75,48 @@ NOTA: Se pull falhar com conflito, resolva manualmente e faça commit.`;
71
75
  });
72
76
  }
73
77
  if (action === "pull") {
78
+ // Auto-stash logic
79
+ const status = await git.status(projectPath);
80
+ let stashed = false;
81
+ if (!status.isClean) {
82
+ console.log("[GitSync] Working directory dirty, creating auto-stash...");
83
+ try {
84
+ await git.saveStash(projectPath, `Auto-stash before pull ${new Date().toISOString()}`, true);
85
+ stashed = true;
86
+ } catch (stashError) {
87
+ return asToolError("STASH_FAILED", "Falha ao criar stash automático antes do pull", { error: stashError.message });
88
+ }
89
+ }
90
+
74
91
  const remotes = args.remote ? [args.remote] : ["origin", "github", "gitea"];
75
- const promises = remotes.map(r => git.pull(projectPath, r, branch).then(() => ({ remote: r, ok: true })).catch(e => ({ remote: r, ok: false, error: String(e?.message || e) })));
92
+ const promises = remotes.map(r => withRetry(
93
+ () => git.pull(projectPath, r, branch),
94
+ 3,
95
+ `pull-${r}`
96
+ ).then(() => ({ remote: r, ok: true })).catch(e => ({ remote: r, ok: false, error: String(e?.message || e) })));
97
+
76
98
  const results = await Promise.all(promises);
77
99
  const successful = results.filter(x => x.ok);
78
100
  const failed = results.filter(x => !x.ok);
101
+
102
+ let stashMessage = "";
103
+ if (stashed) {
104
+ try {
105
+ console.log("[GitSync] Restoring auto-stash...");
106
+ await git.popStash(projectPath);
107
+ stashMessage = " (mudanças locais restauradas)";
108
+ } catch (popError) {
109
+ stashMessage = " (ATENÇÃO: mudanças salvas no stash, mas houve conflito ao restaurar. Use 'git-stash pop' manualmente)";
110
+ }
111
+ }
112
+
79
113
  return asToolResult({
80
114
  success: successful.length > 0,
81
115
  branch,
82
116
  pulled: successful.map(x => x.remote),
83
117
  failed: failed.map(x => ({ remote: x.remote, error: x.error })),
84
- message: successful.length === 0 ? "Nenhum remote alcançado. Use git-remote ensure para configurar." : `Pull de ${successful.length} remote(s) concluído`
118
+ message: (successful.length === 0 ? "Nenhum remote alcançado." : `Pull de ${successful.length} remote(s) concluído`) + stashMessage,
119
+ stashed
85
120
  });
86
121
  }
87
122
  return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, { availableActions: ["pull", "fetch"] });
@@ -1,6 +1,6 @@
1
1
  import Ajv from "ajv";
2
2
  import { asToolError, asToolResult, errorToResponse } from "../utils/errors.js";
3
- import { validateProjectPath } from "../utils/repoHelpers.js";
3
+ import { validateProjectPath, withRetry } from "../utils/repoHelpers.js";
4
4
 
5
5
  const ajv = new Ajv({ allErrors: true });
6
6
 
@@ -72,7 +72,7 @@ FLUXO TÍPICO:
72
72
  if (existing.includes(args.tag)) {
73
73
  return asToolError("TAG_ALREADY_EXISTS", `Tag '${args.tag}' já existe`, { tag: args.tag, existingTags: existing });
74
74
  }
75
- await git.createTag(projectPath, args.tag, args.ref || "HEAD", args.message);
75
+ await withRetry(() => git.createTag(projectPath, args.tag, args.ref || "HEAD", args.message), 3, "create-tag");
76
76
  return asToolResult({ success: true, tag: args.tag, ref: args.ref || "HEAD", message: `Tag '${args.tag}' criada. Use action='push' para enviar ao GitHub/Gitea.` });
77
77
  }
78
78
  if (action === "delete") {
@@ -81,7 +81,7 @@ FLUXO TÍPICO:
81
81
  if (!existing.includes(args.tag)) {
82
82
  return asToolError("TAG_NOT_FOUND", `Tag '${args.tag}' não encontrada`, { tag: args.tag, existingTags: existing });
83
83
  }
84
- await git.deleteTag(projectPath, args.tag);
84
+ await withRetry(() => git.deleteTag(projectPath, args.tag), 3, "delete-tag");
85
85
  return asToolResult({ success: true, tag: args.tag, deleted: true });
86
86
  }
87
87
  if (action === "push") {
@@ -91,8 +91,8 @@ FLUXO TÍPICO:
91
91
  return asToolError("TAG_NOT_FOUND", `Tag '${args.tag}' não existe localmente. Crie primeiro com action='create'`, { tag: args.tag, existingTags: existing });
92
92
  }
93
93
  const results = await Promise.allSettled([
94
- git.pushTag(projectPath, "github", args.tag),
95
- git.pushTag(projectPath, "gitea", args.tag)
94
+ withRetry(() => git.pushTag(projectPath, "github", args.tag), 3, "push-tag-github"),
95
+ withRetry(() => git.pushTag(projectPath, "gitea", args.tag), 3, "push-tag-gitea")
96
96
  ]);
97
97
  const pushed = [];
98
98
  const failed = [];
@@ -1,6 +1,8 @@
1
1
  import Ajv from "ajv";
2
+ import fs from "fs";
3
+ import path from "path";
2
4
  import { MCPError, asToolError, asToolResult, errorToResponse, createError } from "../utils/errors.js";
3
- import { getRepoNameFromPath, detectProjectType, GITIGNORE_TEMPLATES, validateProjectPath } from "../utils/repoHelpers.js";
5
+ import { getRepoNameFromPath, detectProjectType, GITIGNORE_TEMPLATES, validateProjectPath, withRetry } from "../utils/repoHelpers.js";
4
6
 
5
7
  const ajv = new Ajv({ allErrors: true });
6
8
 
@@ -282,7 +284,13 @@ EXEMPLOS DE USO:
282
284
  });
283
285
  }
284
286
 
285
- const result = await git.pushParallel(projectPath, branch, force);
287
+ // Retry logic for push (often fails due to network or concurrent updates)
288
+ const result = await withRetry(
289
+ () => git.pushParallel(projectPath, branch, force),
290
+ 3,
291
+ "push"
292
+ );
293
+
286
294
  return asToolResult({ success: true, branch, ...result });
287
295
  }
288
296
  return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, {
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { MCPError, createError, mapExternalError } from "./errors.js";
5
- import { getRepoNameFromPath, getProvidersEnv } from "./repoHelpers.js";
5
+ import { getRepoNameFromPath, getProvidersEnv, findGitRoot } from "./repoHelpers.js";
6
6
 
7
7
  // Common locations for Git on Windows, Linux, and macOS
8
8
  const GIT_CANDIDATES = [
@@ -66,6 +66,26 @@ export function validateFilePath(projectPath, filePath) {
66
66
  return absoluteFile;
67
67
  }
68
68
 
69
+ /**
70
+ * Encontra a raiz do repositório Git subindo a árvore de diretórios
71
+ * @param {string} startDir - Diretório inicial
72
+ * @returns {string|null} Caminho da raiz do repo ou null se não encontrado
73
+ */
74
+ export function findGitRoot(startDir) {
75
+ let current = path.resolve(startDir);
76
+ const root = path.parse(current).root;
77
+
78
+ while (true) {
79
+ if (fs.existsSync(path.join(current, ".git"))) {
80
+ return current;
81
+ }
82
+ if (current === root) {
83
+ return null;
84
+ }
85
+ current = path.dirname(current);
86
+ }
87
+ }
88
+
69
89
  export function getEnv(key) {
70
90
  const v = process.env[key];
71
91
  return v === undefined ? "" : String(v);
@@ -158,3 +178,39 @@ export function detectProjectType(projectPath) {
158
178
  return "general";
159
179
  }
160
180
 
181
+ /**
182
+ * Executa uma operação com retries exponenciais
183
+ * @param {Function} operation - Função assíncrona a executar
184
+ * @param {number} maxRetries - Número máximo de tentativas
185
+ * @param {string} context - Contexto para logs
186
+ * @returns {Promise<any>} Resultado da operação
187
+ */
188
+ export async function withRetry(operation, maxRetries = 3, context = "") {
189
+ let lastError;
190
+ for (let i = 0; i < maxRetries; i++) {
191
+ try {
192
+ return await operation();
193
+ } catch (e) {
194
+ lastError = e;
195
+ const msg = e.message || String(e);
196
+ // Retry on network errors, lock files, or timeouts
197
+ const isRetryable = msg.includes("lock") ||
198
+ msg.includes("network") ||
199
+ msg.includes("resolve host") ||
200
+ msg.includes("timeout") ||
201
+ msg.includes("connection") ||
202
+ msg.includes("ECONNRESET") ||
203
+ msg.includes("ETIMEDOUT");
204
+
205
+ if (!isRetryable && i === 0) throw e; // Fail fast if not retryable and first attempt
206
+
207
+ if (i < maxRetries - 1) {
208
+ const delay = 2000 * Math.pow(2, i);
209
+ console.warn(`[${context}] Attempt ${i + 1} failed, retrying in ${delay}ms... Error: ${msg}`);
210
+ await new Promise(r => setTimeout(r, delay));
211
+ }
212
+ }
213
+ }
214
+ throw lastError;
215
+ }
216
+