@andrebuzeli/git-mcp 15.9.1 → 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.1",
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",
package/src/index.js CHANGED
@@ -47,7 +47,7 @@ if (!hasGitHub && !hasGitea) {
47
47
 
48
48
  const transport = new StdioServerTransport();
49
49
  const server = new Server(
50
- { name: "git-mcpv2", version: "15.8.0" },
50
+ { name: "git-mcpv2", version: "15.9.2" },
51
51
  { capabilities: { tools: {}, resources: {}, prompts: {} } }
52
52
  );
53
53
  server.connect(transport);
@@ -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
 
@@ -8,13 +10,13 @@ export function createGitWorkflowTool(pm, git) {
8
10
  const inputSchema = {
9
11
  type: "object",
10
12
  properties: {
11
- projectPath: {
12
- type: "string",
13
- description: "Caminho absoluto do diretório do projeto (ex: 'C:/Users/user/projeto' ou '/home/user/projeto')"
13
+ projectPath: {
14
+ type: "string",
15
+ description: "Caminho absoluto do diretório do projeto (ex: 'C:/Users/user/projeto' ou '/home/user/projeto')"
14
16
  },
15
- action: {
16
- type: "string",
17
- enum: ["init", "status", "add", "remove", "commit", "push", "ensure-remotes", "clean", "update"],
17
+ action: {
18
+ type: "string",
19
+ enum: ["init", "status", "add", "remove", "commit", "push", "ensure-remotes", "clean"],
18
20
  description: `Ação a executar:
19
21
  - init: Inicializa repositório git local E cria repos no GitHub/Gitea automaticamente
20
22
  - status: Retorna arquivos modificados, staged, untracked (use ANTES de commit para ver o que mudou)
@@ -23,20 +25,19 @@ export function createGitWorkflowTool(pm, git) {
23
25
  - commit: Cria commit com os arquivos staged (use DEPOIS de add)
24
26
  - push: Envia commits para GitHub E Gitea em paralelo (use DEPOIS de commit)
25
27
  - ensure-remotes: Configura remotes GitHub e Gitea (use se push falhar por falta de remote)
26
- - clean: Remove arquivos não rastreados do working directory
27
- - update: Atualiza projeto completo: init (se necessário), add, commit, ensure-remotes e push`
28
+ - clean: Remove arquivos não rastreados do working directory`
28
29
  },
29
- files: {
30
- type: "array",
30
+ files: {
31
+ type: "array",
31
32
  items: { type: "string" },
32
33
  description: "Lista de arquivos para add/remove. Use ['.'] para todos os arquivos. Ex: ['src/index.js', 'package.json']"
33
34
  },
34
- message: {
35
+ message: {
35
36
  type: "string",
36
- description: "Mensagem do commit. Obrigatório para action='commit'. Opcional para action='update' (se não fornecido, usa mensagem padrão). Ex: 'feat: adiciona nova funcionalidade'"
37
+ description: "Mensagem do commit. Obrigatório para action='commit'. Ex: 'feat: adiciona nova funcionalidade'"
37
38
  },
38
- force: {
39
- type: "boolean",
39
+ force: {
40
+ type: "boolean",
40
41
  description: "Force push (use apenas se push normal falhar com erro de histórico divergente). Default: false"
41
42
  },
42
43
  createGitignore: {
@@ -48,7 +49,7 @@ export function createGitWorkflowTool(pm, git) {
48
49
  description: "Se true, repositório será PÚBLICO. Default: false (privado). Aplica-se a action='init' e 'ensure-remotes'"
49
50
  },
50
51
  dryRun: {
51
- type: "boolean",
52
+ type: "boolean",
52
53
  description: "Se true, simula a operação sem executar (útil para testes). Default: false"
53
54
  }
54
55
  },
@@ -72,7 +73,6 @@ QUANDO USAR CADA ACTION:
72
73
  - init: Apenas uma vez, para novos projetos (cria .gitignore automaticamente)
73
74
  - ensure-remotes: Se push falhar por falta de configuração
74
75
  - clean: Limpar arquivos não rastreados
75
- - update: Para atualizar projeto completo (init + add + commit + push) - mais rápido que fazer cada ação separadamente
76
76
 
77
77
  EXEMPLOS DE USO:
78
78
  • Iniciar projeto: { "projectPath": "/path/to/project", "action": "init" }
@@ -90,29 +90,29 @@ EXEMPLOS DE USO:
90
90
  try {
91
91
  // #region agent log
92
92
  if (process.env.DEBUG_AGENT_LOG) {
93
- fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId:'debug-session',runId:'run1',hypothesisId:'H1',location:'git-workflow.js:handle-entry',message:'handle start',data:{action,projectPath},timestamp:Date.now()})}).catch(()=>{});
93
+ fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'debug-session', runId: 'run1', hypothesisId: 'H1', location: 'git-workflow.js:handle-entry', message: 'handle start', data: { action, projectPath }, timestamp: Date.now() }) }).catch(() => { });
94
94
  }
95
95
  // #endregion
96
96
 
97
97
  validateProjectPath(projectPath);
98
98
  // #region agent log
99
99
  if (process.env.DEBUG_AGENT_LOG) {
100
- fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId:'debug-session',runId:'run1',hypothesisId:'H1',location:'git-workflow.js:handle-validated',message:'projectPath validated',data:{action},timestamp:Date.now()})}).catch(()=>{});
100
+ fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'debug-session', runId: 'run1', hypothesisId: 'H1', location: 'git-workflow.js:handle-validated', message: 'projectPath validated', data: { action }, timestamp: Date.now() }) }).catch(() => { });
101
101
  }
102
102
  // #endregion
103
103
  if (action === "init") {
104
104
  if (args.dryRun) {
105
- return asToolResult({
106
- success: true,
105
+ return asToolResult({
106
+ success: true,
107
107
  dryRun: true,
108
108
  message: "DRY RUN: Repositório seria inicializado localmente e nos providers",
109
109
  repoName: getRepoNameFromPath(projectPath),
110
110
  gitignoreCreated: shouldCreateGitignore
111
111
  });
112
112
  }
113
-
113
+
114
114
  await git.init(projectPath);
115
-
115
+
116
116
  // Criar .gitignore baseado no tipo de projeto
117
117
  const shouldCreateGitignore = args.createGitignore !== false;
118
118
  let gitignoreCreated = false;
@@ -122,12 +122,12 @@ EXEMPLOS DE USO:
122
122
  await git.createGitignore(projectPath, patterns);
123
123
  gitignoreCreated = true;
124
124
  }
125
-
125
+
126
126
  const repo = getRepoNameFromPath(projectPath);
127
127
  const isPublic = args.isPublic === true; // Default: privado
128
128
  const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic });
129
- return asToolResult({
130
- success: true,
129
+ return asToolResult({
130
+ success: true,
131
131
  ensured,
132
132
  isPrivate: !isPublic,
133
133
  gitignoreCreated,
@@ -138,24 +138,24 @@ EXEMPLOS DE USO:
138
138
  const st = await git.status(projectPath);
139
139
  // #region agent log
140
140
  if (process.env.DEBUG_AGENT_LOG) {
141
- fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId:'debug-session',runId:'run1',hypothesisId:'H2',location:'git-workflow.js:status',message:'status result',data:{modified:st.modified.length,created:st.created.length,deleted:st.deleted.length,notAdded:st.not_added.length,isClean:st.isClean},timestamp:Date.now()})}).catch(()=>{});
141
+ fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'debug-session', runId: 'run1', hypothesisId: 'H2', location: 'git-workflow.js:status', message: 'status result', data: { modified: st.modified.length, created: st.created.length, deleted: st.deleted.length, notAdded: st.not_added.length, isClean: st.isClean }, timestamp: Date.now() }) }).catch(() => { });
142
142
  }
143
143
  // #endregion
144
-
144
+
145
145
  if (args.dryRun) {
146
- return asToolResult({
147
- success: true,
146
+ return asToolResult({
147
+ success: true,
148
148
  dryRun: true,
149
149
  message: "DRY RUN: Status seria verificado",
150
150
  ...st
151
151
  });
152
152
  }
153
-
153
+
154
154
  // Adicionar contexto para AI Agent decidir próximo passo
155
155
  const hasUnstaged = st.not_added?.length > 0 || st.modified?.length > 0 || st.created?.length > 0;
156
156
  const hasStaged = st.staged?.length > 0;
157
157
  const hasConflicts = st.conflicted?.length > 0;
158
-
158
+
159
159
  let _aiContext = {
160
160
  needsAdd: hasUnstaged && !hasStaged,
161
161
  readyToCommit: hasStaged && !hasConflicts,
@@ -163,7 +163,7 @@ EXEMPLOS DE USO:
163
163
  isClean: st.isClean,
164
164
  suggestedAction: null
165
165
  };
166
-
166
+
167
167
  if (hasConflicts) {
168
168
  _aiContext.suggestedAction = "Resolva os conflitos manualmente antes de continuar";
169
169
  } else if (hasStaged) {
@@ -173,21 +173,21 @@ EXEMPLOS DE USO:
173
173
  } else {
174
174
  _aiContext.suggestedAction = "Working tree limpa. Modifique arquivos ou use action='push' se há commits pendentes";
175
175
  }
176
-
176
+
177
177
  return asToolResult({ ...st, _aiContext }, { tool: 'workflow', action: 'status' });
178
178
  }
179
179
  if (action === "add") {
180
180
  const files = Array.isArray(args.files) && args.files.length ? args.files : ["."];
181
-
181
+
182
182
  if (args.dryRun) {
183
- return asToolResult({
184
- success: true,
183
+ return asToolResult({
184
+ success: true,
185
185
  dryRun: true,
186
186
  message: `DRY RUN: Arquivos seriam adicionados: ${files.join(", ")}`,
187
187
  files
188
188
  });
189
189
  }
190
-
190
+
191
191
  await git.add(projectPath, files);
192
192
  return asToolResult({ success: true, files }, { tool: 'workflow', action: 'add' });
193
193
  }
@@ -200,19 +200,19 @@ EXEMPLOS DE USO:
200
200
  if (!args.message) {
201
201
  return asToolError("MISSING_PARAMETER", "message é obrigatório para commit", { parameter: "message" });
202
202
  }
203
-
203
+
204
204
  if (args.dryRun) {
205
- return asToolResult({
206
- success: true,
205
+ return asToolResult({
206
+ success: true,
207
207
  dryRun: true,
208
208
  message: `DRY RUN: Commit seria criado com mensagem: "${args.message}"`
209
209
  });
210
210
  }
211
-
211
+
212
212
  const sha = await git.commit(projectPath, args.message);
213
213
  // #region agent log
214
214
  if (process.env.DEBUG_AGENT_LOG) {
215
- fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId:'debug-session',runId:'run1',hypothesisId:'H3',location:'git-workflow.js:commit',message:'commit created',data:{sha,message:args.message},timestamp:Date.now()})}).catch(()=>{});
215
+ fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'debug-session', runId: 'run1', hypothesisId: 'H3', location: 'git-workflow.js:commit', message: 'commit created', data: { sha, message: args.message }, timestamp: Date.now() }) }).catch(() => { });
216
216
  }
217
217
  // #endregion
218
218
  return asToolResult({ success: true, sha, message: args.message }, { tool: 'workflow', action: 'commit' });
@@ -220,19 +220,19 @@ EXEMPLOS DE USO:
220
220
  if (action === "clean") {
221
221
  if (args.dryRun) {
222
222
  const result = await git.cleanUntracked(projectPath);
223
- return asToolResult({
224
- success: true,
223
+ return asToolResult({
224
+ success: true,
225
225
  dryRun: true,
226
226
  message: `DRY RUN: ${result.cleaned.length} arquivo(s) seriam removidos`,
227
227
  wouldClean: result.cleaned
228
228
  });
229
229
  }
230
-
230
+
231
231
  const result = await git.cleanUntracked(projectPath);
232
- return asToolResult({
233
- success: true,
232
+ return asToolResult({
233
+ success: true,
234
234
  ...result,
235
- message: result.cleaned.length > 0
235
+ message: result.cleaned.length > 0
236
236
  ? `${result.cleaned.length} arquivo(s) não rastreados removidos`
237
237
  : "Nenhum arquivo para limpar"
238
238
  });
@@ -240,7 +240,7 @@ EXEMPLOS DE USO:
240
240
  if (action === "ensure-remotes") {
241
241
  const repo = getRepoNameFromPath(projectPath);
242
242
  const isPublic = args.isPublic === true; // Default: privado
243
-
243
+
244
244
  if (args.dryRun) {
245
245
  const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: false, isPublic }); // Don't create for dry run
246
246
  const ghOwner = await pm.getGitHubOwner();
@@ -248,9 +248,9 @@ EXEMPLOS DE USO:
248
248
  const githubUrl = ghOwner ? `https://github.com/${ghOwner}/${repo}.git` : "";
249
249
  const base = pm.giteaUrl?.replace(/\/$/, "") || "";
250
250
  const giteaUrl = geOwner && base ? `${base}/${geOwner}/${repo}.git` : "";
251
-
252
- return asToolResult({
253
- success: true,
251
+
252
+ return asToolResult({
253
+ success: true,
254
254
  dryRun: true,
255
255
  message: "DRY RUN: Remotes seriam configurados",
256
256
  repo,
@@ -259,7 +259,7 @@ EXEMPLOS DE USO:
259
259
  ensured
260
260
  });
261
261
  }
262
-
262
+
263
263
  const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic });
264
264
  const ghOwner = await pm.getGitHubOwner();
265
265
  const geOwner = await pm.getGiteaOwner();
@@ -273,120 +273,28 @@ EXEMPLOS DE USO:
273
273
  if (action === "push") {
274
274
  const branch = await git.getCurrentBranch(projectPath);
275
275
  const force = !!args.force;
276
-
276
+
277
277
  if (args.dryRun) {
278
- return asToolResult({
279
- success: true,
278
+ return asToolResult({
279
+ success: true,
280
280
  dryRun: true,
281
281
  message: `DRY RUN: Push seria executado na branch '${branch}'${force ? ' (force)' : ''}`,
282
282
  branch,
283
283
  force
284
284
  });
285
285
  }
286
+
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
+ );
286
293
 
287
- const result = await git.pushParallel(projectPath, branch, force);
288
294
  return asToolResult({ success: true, branch, ...result });
289
295
  }
290
- if (action === "update") {
291
- if (args.dryRun) {
292
- return asToolResult({
293
- success: true,
294
- dryRun: true,
295
- message: "DRY RUN: Update completo seria executado (init se necessário, add, commit, push)"
296
- });
297
- }
298
-
299
- const results = {
300
- init: null,
301
- ensureRemotes: null,
302
- add: null,
303
- commit: null,
304
- push: null
305
- };
306
-
307
- const repo = getRepoNameFromPath(projectPath);
308
- const isPublic = args.isPublic === true;
309
-
310
- // 1. Verificar se é repo Git, se não for, fazer init
311
- const isRepo = await git.isRepo(projectPath).catch(() => false);
312
- if (!isRepo) {
313
- await git.init(projectPath);
314
-
315
- // Criar .gitignore baseado no tipo de projeto
316
- const shouldCreateGitignore = args.createGitignore !== false;
317
- if (shouldCreateGitignore) {
318
- const projectType = detectProjectType(projectPath);
319
- const patterns = GITIGNORE_TEMPLATES[projectType] || GITIGNORE_TEMPLATES.general;
320
- await git.createGitignore(projectPath, patterns);
321
- }
322
-
323
- const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic });
324
- results.init = { success: true, ensured, isPrivate: !isPublic, gitignoreCreated: shouldCreateGitignore };
325
- } else {
326
- results.init = { success: true, skipped: true, message: "Repositório já existe" };
327
- }
328
-
329
- // 2. Garantir remotes configurados (sempre verifica/configura)
330
- const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic });
331
- const ghOwner = await pm.getGitHubOwner();
332
- const geOwner = await pm.getGiteaOwner();
333
- const githubUrl = ghOwner ? `https://github.com/${ghOwner}/${repo}.git` : "";
334
- const base = pm.giteaUrl?.replace(/\/$/, "") || "";
335
- const giteaUrl = geOwner && base ? `${base}/${geOwner}/${repo}.git` : "";
336
- await git.ensureRemotes(projectPath, { githubUrl, giteaUrl });
337
- const remotes = await git.listRemotes(projectPath);
338
- results.ensureRemotes = { success: true, ensured, remotes };
339
-
340
- // 3. Verificar status e fazer add se necessário
341
- const status = await git.status(projectPath);
342
- if (!status.isClean || status.modified?.length > 0 || status.created?.length > 0 || status.notAdded?.length > 0) {
343
- await git.add(projectPath, ["."]);
344
- results.add = { success: true, files: ["."] };
345
- } else {
346
- results.add = { success: true, skipped: true, message: "Nenhum arquivo para adicionar" };
347
- }
348
-
349
- // 4. Verificar se há algo staged e fazer commit
350
- const statusAfterAdd = await git.status(projectPath);
351
- if (statusAfterAdd.staged?.length > 0) {
352
- // Gerar mensagem padrão se não fornecida
353
- const commitMessage = args.message || `Update: ${new Date().toISOString().split('T')[0]} - ${statusAfterAdd.staged.length} arquivo(s) modificado(s)`;
354
- const sha = await git.commit(projectPath, commitMessage);
355
- results.commit = { success: true, sha, message: commitMessage };
356
- } else {
357
- results.commit = { success: true, skipped: true, message: "Nenhum arquivo staged para commit" };
358
- }
359
-
360
- // 5. Fazer push (só se houver commits para enviar)
361
- const branch = await git.getCurrentBranch(projectPath);
362
- const force = !!args.force;
363
- try {
364
- const pushResult = await git.pushParallel(projectPath, branch, force);
365
- results.push = { success: true, branch, ...pushResult };
366
- } catch (pushError) {
367
- // Se push falhar mas não houver commits, não é erro crítico
368
- if (results.commit?.skipped) {
369
- results.push = { success: true, skipped: true, message: "Nenhum commit para enviar" };
370
- } else {
371
- throw pushError;
372
- }
373
- }
374
-
375
- // Resumo final
376
- const allSuccess = Object.values(results).every(r => r?.success !== false);
377
- const stepsExecuted = Object.entries(results)
378
- .filter(([_, r]) => r && !r.skipped)
379
- .map(([step, _]) => step);
380
-
381
- return asToolResult({
382
- success: allSuccess,
383
- message: `Update completo executado: ${stepsExecuted.join(" → ")}`,
384
- results,
385
- stepsExecuted
386
- }, { tool: 'workflow', action: 'update' });
387
- }
388
- return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, {
389
- availableActions: ["init", "status", "add", "remove", "commit", "push", "ensure-remotes", "clean", "update"],
296
+ return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, {
297
+ availableActions: ["init", "status", "add", "remove", "commit", "push", "ensure-remotes", "clean"],
390
298
  suggestion: "Use uma das actions disponíveis"
391
299
  });
392
300
  } catch (e) {
@@ -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
+